jfs-components 0.1.25 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
- package/lib/commonjs/components/Carousel/Carousel.js +112 -19
- package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +104 -10
- package/lib/commonjs/components/FormUpload/Additem.js +168 -0
- package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
- package/lib/commonjs/components/Grid/Grid.js +206 -0
- package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
- package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
- package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
- package/lib/commonjs/components/Table/Table.js +27 -7
- package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/commonjs/components/index.js +42 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +21 -10
- package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
- package/lib/commonjs/icons/components/IconImage.js +19 -0
- package/lib/commonjs/icons/components/IconMic.js +19 -0
- package/lib/commonjs/icons/components/IconMicoff.js +19 -0
- package/lib/commonjs/icons/components/IconPause.js +19 -0
- package/lib/commonjs/icons/components/IconPlay.js +19 -0
- package/lib/commonjs/icons/components/index.js +66 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
- package/lib/module/components/Carousel/Carousel.js +111 -19
- package/lib/module/components/CompareTable/CompareTable.js +191 -26
- package/lib/module/components/ContentSheet/ContentSheet.js +108 -14
- package/lib/module/components/FormUpload/Additem.js +162 -0
- package/lib/module/components/FormUpload/FormUpload.js +192 -0
- package/lib/module/components/Grid/Grid.js +201 -0
- package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
- package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
- package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
- package/lib/module/components/Table/Table.js +27 -7
- package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/module/components/index.js +7 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +21 -10
- package/lib/module/icons/components/IconBrokenimage.js +12 -0
- package/lib/module/icons/components/IconImage.js +12 -0
- package/lib/module/icons/components/IconMic.js +12 -0
- package/lib/module/icons/components/IconMicoff.js +12 -0
- package/lib/module/icons/components/IconPause.js +12 -0
- package/lib/module/icons/components/IconPlay.js +12 -0
- package/lib/module/icons/components/index.js +6 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
- package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
- package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
- package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
- package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
- package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
- package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
- package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
- package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
- package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
- package/lib/typescript/src/components/Table/Table.d.ts +9 -1
- package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
- package/lib/typescript/src/components/index.d.ts +34 -28
- package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
- package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +6 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
- package/src/components/Carousel/Carousel.tsx +133 -23
- package/src/components/CompareTable/CompareTable.tsx +224 -30
- package/src/components/ContentSheet/ContentSheet.tsx +138 -8
- package/src/components/FormUpload/Additem.tsx +262 -0
- package/src/components/FormUpload/FormUpload.tsx +313 -0
- package/src/components/Grid/Grid.tsx +368 -0
- package/src/components/MoneyValue/MoneyValue.tsx +216 -65
- package/src/components/NumberPagination/NumberPagination.tsx +170 -0
- package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
- package/src/components/ProfileCard/ProfileCard.tsx +268 -0
- package/src/components/Table/Table.tsx +29 -4
- package/src/components/UpiHandle/UpiHandle.tsx +35 -24
- package/src/components/index.ts +329 -182
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +21 -10
- package/src/icons/components/IconBrokenimage.tsx +11 -0
- package/src/icons/components/IconImage.tsx +11 -0
- package/src/icons/components/IconMic.tsx +11 -0
- package/src/icons/components/IconMicoff.tsx +11 -0
- package/src/icons/components/IconPause.tsx +11 -0
- package/src/icons/components/IconPlay.tsx +11 -0
- package/src/icons/components/index.ts +6 -0
- package/src/icons/registry.ts +25 -1
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import React, { useContext, useMemo } from 'react'
|
|
1
|
+
import React, { useContext, useEffect, useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Platform,
|
|
4
|
+
Text,
|
|
4
5
|
View,
|
|
6
|
+
useWindowDimensions,
|
|
5
7
|
type StyleProp,
|
|
8
|
+
type TextStyle,
|
|
6
9
|
type ViewProps,
|
|
7
10
|
type ViewStyle,
|
|
8
11
|
} from 'react-native'
|
|
9
12
|
import Animated, {
|
|
10
13
|
useAnimatedKeyboard,
|
|
11
14
|
useAnimatedStyle,
|
|
15
|
+
useSharedValue,
|
|
16
|
+
withSpring,
|
|
17
|
+
type SharedValue,
|
|
12
18
|
} from 'react-native-reanimated'
|
|
13
19
|
import { SafeAreaInsetsContext } from 'react-native-safe-area-context'
|
|
14
20
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
@@ -17,6 +23,18 @@ import type { Modes } from '../../design-tokens'
|
|
|
17
23
|
|
|
18
24
|
const IS_WEB = Platform.OS === 'web'
|
|
19
25
|
|
|
26
|
+
// Spring config — mirrors the Drawer's bottom pop-up spring so the two
|
|
27
|
+
// bottom-sheet surfaces share the exact same entrance feel (slight bounce,
|
|
28
|
+
// snappy). Keep in sync with `Drawer.tsx` SPRING_CONFIG.
|
|
29
|
+
const SPRING_CONFIG = {
|
|
30
|
+
damping: 32,
|
|
31
|
+
stiffness: 300,
|
|
32
|
+
mass: 1,
|
|
33
|
+
overshootClamping: false,
|
|
34
|
+
restDisplacementThreshold: 0.1,
|
|
35
|
+
restSpeedThreshold: 0.1,
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export type ContentSheetProps = {
|
|
21
39
|
/**
|
|
22
40
|
* Sheet content. The sheet is one big slot: whatever you place here is
|
|
@@ -31,6 +49,12 @@ export type ContentSheetProps = {
|
|
|
31
49
|
* the same theme without you wiring `modes` onto each one.
|
|
32
50
|
*/
|
|
33
51
|
modes?: Modes
|
|
52
|
+
/**
|
|
53
|
+
* Optional title rendered in a centered, sticky header at the top of the
|
|
54
|
+
* sheet. Uses the same `drawer/title*` token family as the `Drawer` title so
|
|
55
|
+
* the two surfaces stay visually consistent.
|
|
56
|
+
*/
|
|
57
|
+
title?: string
|
|
34
58
|
/**
|
|
35
59
|
* Keep the sheet above the on-screen keyboard. When the keyboard opens the
|
|
36
60
|
* sheet rises by exactly the keyboard height; when it closes the sheet drops
|
|
@@ -63,6 +87,19 @@ export type ContentSheetProps = {
|
|
|
63
87
|
* works in both modes.
|
|
64
88
|
*/
|
|
65
89
|
pinToBottom?: boolean
|
|
90
|
+
/**
|
|
91
|
+
* Controls whether the sheet is on-screen. When this becomes `true` the sheet
|
|
92
|
+
* springs up from the bottom edge (off-screen → on-screen) using the same
|
|
93
|
+
* spring as the {@link Drawer} (`damping: 32`, `stiffness: 300`); when it
|
|
94
|
+
* becomes `false` it springs back down below the bottom edge. The sheet pops
|
|
95
|
+
* up on mount by default.
|
|
96
|
+
*
|
|
97
|
+
* The animation runs entirely on the UI thread via `react-native-reanimated`
|
|
98
|
+
* (no re-renders) and composes with keyboard avoidance — the entrance
|
|
99
|
+
* `translateY` and the keyboard `translateY` are summed in a single
|
|
100
|
+
* `useAnimatedStyle`. Default `true`.
|
|
101
|
+
*/
|
|
102
|
+
visible?: boolean
|
|
66
103
|
/** Optional style override applied to the sheet container. */
|
|
67
104
|
style?: StyleProp<ViewStyle>
|
|
68
105
|
} & Omit<ViewProps, 'style' | 'children'>
|
|
@@ -70,6 +107,8 @@ export type ContentSheetProps = {
|
|
|
70
107
|
interface ContentSheetStyle {
|
|
71
108
|
container: ViewStyle
|
|
72
109
|
paddingBottom: number
|
|
110
|
+
headerStyle: ViewStyle
|
|
111
|
+
titleStyle: TextStyle
|
|
73
112
|
}
|
|
74
113
|
|
|
75
114
|
function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
@@ -81,6 +120,23 @@ function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
|
81
120
|
const radiusTop = getVariableByName('contentSheet/padding/radius/top', modes) as number
|
|
82
121
|
const radiusBottom = getVariableByName('contentSheet/padding/radius/bottom', modes) as number
|
|
83
122
|
|
|
123
|
+
// Title tokens mirror the Drawer title so the two bottom-sheet surfaces stay
|
|
124
|
+
// visually aligned.
|
|
125
|
+
const titleColor = getVariableByName('drawer/title/color', modes) as string
|
|
126
|
+
const titleSize = getVariableByName('drawer/title/fontSize', modes) as number
|
|
127
|
+
const titleLineHeight = getVariableByName('drawer/title/lineHeight', modes) as number
|
|
128
|
+
const titleFontFamily = getVariableByName('drawer/title/fontFamily', modes) as string
|
|
129
|
+
const titleFontWeightRaw = getVariableByName('drawer/title/fontWeight', modes)
|
|
130
|
+
const titleFontWeight =
|
|
131
|
+
typeof titleFontWeightRaw === 'number' ? titleFontWeightRaw.toString() : titleFontWeightRaw
|
|
132
|
+
|
|
133
|
+
const titleWrapPaddingTop = getVariableByName('drawer/titleWrap/padding/top', modes) as number
|
|
134
|
+
const titleWrapPaddingBottom = getVariableByName('drawer/titleWrap/padding/bottom', modes) as number
|
|
135
|
+
const titleWrapPaddingHorizontal = getVariableByName(
|
|
136
|
+
'drawer/titleWrap/padding/horizontal',
|
|
137
|
+
modes
|
|
138
|
+
) as number
|
|
139
|
+
|
|
84
140
|
return {
|
|
85
141
|
container: {
|
|
86
142
|
backgroundColor,
|
|
@@ -97,6 +153,20 @@ function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
|
97
153
|
overflow: 'hidden',
|
|
98
154
|
},
|
|
99
155
|
paddingBottom,
|
|
156
|
+
headerStyle: {
|
|
157
|
+
paddingTop: titleWrapPaddingTop,
|
|
158
|
+
paddingBottom: titleWrapPaddingBottom,
|
|
159
|
+
paddingHorizontal: titleWrapPaddingHorizontal,
|
|
160
|
+
alignItems: 'center',
|
|
161
|
+
},
|
|
162
|
+
titleStyle: {
|
|
163
|
+
color: titleColor,
|
|
164
|
+
fontSize: titleSize,
|
|
165
|
+
lineHeight: titleLineHeight,
|
|
166
|
+
fontFamily: titleFontFamily,
|
|
167
|
+
fontWeight: titleFontWeight as TextStyle['fontWeight'],
|
|
168
|
+
textAlign: 'center',
|
|
169
|
+
},
|
|
100
170
|
}
|
|
101
171
|
}
|
|
102
172
|
|
|
@@ -126,10 +196,12 @@ const pinnedStyle: ViewStyle = {
|
|
|
126
196
|
function ContentSheet({
|
|
127
197
|
children,
|
|
128
198
|
modes = EMPTY_MODES,
|
|
199
|
+
title,
|
|
129
200
|
avoidKeyboard = true,
|
|
130
201
|
keyboardSpacing = 0,
|
|
131
202
|
safeAreaBottom = true,
|
|
132
203
|
pinToBottom = true,
|
|
204
|
+
visible = true,
|
|
133
205
|
style,
|
|
134
206
|
...rest
|
|
135
207
|
}: ContentSheetProps) {
|
|
@@ -158,57 +230,115 @@ function ContentSheet({
|
|
|
158
230
|
[pinToBottom, resolved.container, paddingBottom, style]
|
|
159
231
|
)
|
|
160
232
|
|
|
233
|
+
// Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
|
|
234
|
+
// sheet is bottom-anchored, so a positive `translateY` equal to the window
|
|
235
|
+
// height parks it fully below the bottom edge. Springing it back to `0`
|
|
236
|
+
// produces the same bottom pop-up the Drawer uses. The shared value is
|
|
237
|
+
// initialised off-screen so the very first mount pops in (no flash).
|
|
238
|
+
const { height: windowHeight } = useWindowDimensions()
|
|
239
|
+
const entrance = useSharedValue(windowHeight)
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
entrance.value = withSpring(visible ? 0 : windowHeight, SPRING_CONFIG)
|
|
242
|
+
}, [visible, windowHeight, entrance])
|
|
243
|
+
|
|
161
244
|
// Switching between the keyboard-aware and plain implementation is keyed off
|
|
162
245
|
// `avoidKeyboard` (and platform). Because they are distinct component types,
|
|
163
246
|
// React remounts on toggle, so the conditional `useAnimatedKeyboard` hook
|
|
164
|
-
// inside `KeyboardAwareSheet` always runs in a stable hook order.
|
|
247
|
+
// inside `KeyboardAwareSheet` always runs in a stable hook order. Both
|
|
248
|
+
// branches receive the `entrance` shared value so the bottom pop-up plays in
|
|
249
|
+
// every configuration.
|
|
250
|
+
const header = title ? (
|
|
251
|
+
<View style={resolved.headerStyle}>
|
|
252
|
+
<Text style={resolved.titleStyle}>{title}</Text>
|
|
253
|
+
</View>
|
|
254
|
+
) : null
|
|
255
|
+
|
|
165
256
|
if (avoidKeyboard && !IS_WEB) {
|
|
166
257
|
return (
|
|
167
258
|
<KeyboardAwareSheet
|
|
168
259
|
style={containerStyle}
|
|
169
260
|
spacing={keyboardSpacing}
|
|
261
|
+
entrance={entrance}
|
|
170
262
|
{...rest}
|
|
171
263
|
>
|
|
264
|
+
{header}
|
|
172
265
|
{processedChildren}
|
|
173
266
|
</KeyboardAwareSheet>
|
|
174
267
|
)
|
|
175
268
|
}
|
|
176
269
|
|
|
177
270
|
return (
|
|
178
|
-
<
|
|
271
|
+
<AnimatedSheet style={containerStyle} entrance={entrance} {...rest}>
|
|
272
|
+
{header}
|
|
179
273
|
{processedChildren}
|
|
180
|
-
</
|
|
274
|
+
</AnimatedSheet>
|
|
181
275
|
)
|
|
182
276
|
}
|
|
183
277
|
|
|
184
278
|
type KeyboardAwareSheetProps = {
|
|
185
279
|
style: StyleProp<ViewStyle>
|
|
186
280
|
spacing: number
|
|
281
|
+
entrance: SharedValue<number>
|
|
282
|
+
children: React.ReactNode
|
|
283
|
+
} & Omit<ViewProps, 'style' | 'children'>
|
|
284
|
+
|
|
285
|
+
type AnimatedSheetProps = {
|
|
286
|
+
style: StyleProp<ViewStyle>
|
|
287
|
+
entrance: SharedValue<number>
|
|
187
288
|
children: React.ReactNode
|
|
188
289
|
} & Omit<ViewProps, 'style' | 'children'>
|
|
189
290
|
|
|
190
291
|
/**
|
|
191
292
|
* Native-only wrapper that lifts the sheet by the live keyboard height. The
|
|
192
293
|
* translation is computed on the UI thread from `useAnimatedKeyboard`, so the
|
|
193
|
-
* sheet tracks the keyboard frame-for-frame without any JS work.
|
|
294
|
+
* sheet tracks the keyboard frame-for-frame without any JS work. The keyboard
|
|
295
|
+
* offset is summed with the entrance `translateY` (the bottom pop-up) inside a
|
|
296
|
+
* single `useAnimatedStyle` so both motions stay on the UI thread and never
|
|
297
|
+
* fight each other.
|
|
194
298
|
*/
|
|
195
299
|
function KeyboardAwareSheet({
|
|
196
300
|
style,
|
|
197
301
|
spacing,
|
|
302
|
+
entrance,
|
|
198
303
|
children,
|
|
199
304
|
...rest
|
|
200
305
|
}: KeyboardAwareSheetProps) {
|
|
201
306
|
const keyboard = useAnimatedKeyboard()
|
|
202
307
|
|
|
203
|
-
const
|
|
308
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
204
309
|
const height = keyboard.height.value
|
|
310
|
+
const keyboardOffset = height > 0 ? -(height + spacing) : 0
|
|
311
|
+
return {
|
|
312
|
+
transform: [{ translateY: entrance.value + keyboardOffset }],
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<Animated.View style={[style, animatedStyle]} {...rest}>
|
|
318
|
+
{children}
|
|
319
|
+
</Animated.View>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Entrance-only animated sheet (web, or when keyboard avoidance is disabled).
|
|
325
|
+
* Applies the same bottom pop-up `translateY` as `KeyboardAwareSheet`, minus
|
|
326
|
+
* the keyboard offset.
|
|
327
|
+
*/
|
|
328
|
+
function AnimatedSheet({
|
|
329
|
+
style,
|
|
330
|
+
entrance,
|
|
331
|
+
children,
|
|
332
|
+
...rest
|
|
333
|
+
}: AnimatedSheetProps) {
|
|
334
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
205
335
|
return {
|
|
206
|
-
transform: [{ translateY:
|
|
336
|
+
transform: [{ translateY: entrance.value }],
|
|
207
337
|
}
|
|
208
338
|
})
|
|
209
339
|
|
|
210
340
|
return (
|
|
211
|
-
<Animated.View style={[style,
|
|
341
|
+
<Animated.View style={[style, animatedStyle]} {...rest}>
|
|
212
342
|
{children}
|
|
213
343
|
</Animated.View>
|
|
214
344
|
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Pressable, View, type StyleProp, type ViewStyle, type ImageSourcePropType } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
|
+
import Image from '../Image/Image'
|
|
7
|
+
import IconCapsule from '../IconCapsule/IconCapsule'
|
|
8
|
+
import type { Modes } from '../../design-tokens'
|
|
9
|
+
|
|
10
|
+
export type PickedAsset = {
|
|
11
|
+
/** URI to the selected file (image, video, or document). */
|
|
12
|
+
uri: string
|
|
13
|
+
/** Original file name, when available. */
|
|
14
|
+
name?: string
|
|
15
|
+
/** MIME type, when available. */
|
|
16
|
+
type?: string
|
|
17
|
+
/** File size in bytes, when available. */
|
|
18
|
+
size?: number
|
|
19
|
+
/** Image/video width in pixels, when available. */
|
|
20
|
+
width?: number
|
|
21
|
+
/** Image/video height in pixels, when available. */
|
|
22
|
+
height?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AdditemPickerResult = {
|
|
26
|
+
/** `true` when the user cancelled the picker. */
|
|
27
|
+
didCancel?: boolean
|
|
28
|
+
/** Selected assets. Empty when cancelled. */
|
|
29
|
+
assets: PickedAsset[]
|
|
30
|
+
/** Native error message, when something went wrong. */
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Callback that opens a platform picker and resolves with the selected assets.
|
|
36
|
+
* This is the integration point for libraries like `react-native-image-picker`,
|
|
37
|
+
* `expo-image-picker`, or `react-native-document-picker`.
|
|
38
|
+
*/
|
|
39
|
+
export type AdditemPicker = () => Promise<AdditemPickerResult>
|
|
40
|
+
|
|
41
|
+
export type AdditemState = 'empty' | 'preview'
|
|
42
|
+
|
|
43
|
+
export type AdditemProps = {
|
|
44
|
+
/**
|
|
45
|
+
* Visual state of the item.
|
|
46
|
+
* - `'empty'` — shows the add/upload icon (default).
|
|
47
|
+
* - `'preview'` — shows `imageSource` and a remove icon overlay.
|
|
48
|
+
*/
|
|
49
|
+
state?: AdditemState
|
|
50
|
+
/**
|
|
51
|
+
* Image to preview when `state` is `'preview'`. Accepts the same shapes as
|
|
52
|
+
* the library's `Image` component (remote URI string, `{ uri }`, or `require()`).
|
|
53
|
+
*/
|
|
54
|
+
imageSource?: ImageSourcePropType | string | undefined
|
|
55
|
+
/**
|
|
56
|
+
* Called when the cell is pressed. In `'empty'` mode this is typically where
|
|
57
|
+
* the picker is launched; in `'preview'` mode it can open a preview/lightbox.
|
|
58
|
+
*/
|
|
59
|
+
onPress?: () => void
|
|
60
|
+
/**
|
|
61
|
+
* Called when the remove icon overlay is pressed in `'preview'` mode.
|
|
62
|
+
* Receives the press event so the parent can stop propagation if needed.
|
|
63
|
+
*/
|
|
64
|
+
onRemove?: () => void
|
|
65
|
+
/**
|
|
66
|
+
* Optional picker to invoke when the empty cell is pressed. When provided,
|
|
67
|
+
* `onPress` is ignored for the empty state and the cell becomes a real
|
|
68
|
+
* upload trigger that resolves with picked assets via `onAssetsPicked`.
|
|
69
|
+
*/
|
|
70
|
+
picker?: AdditemPicker
|
|
71
|
+
/**
|
|
72
|
+
* Called after a successful picker invocation with the selected assets.
|
|
73
|
+
*/
|
|
74
|
+
onAssetsPicked?: (assets: PickedAsset[]) => void
|
|
75
|
+
/**
|
|
76
|
+
* Modes for design-token resolution. Defaults to Neutral / Low / Icon Capsule
|
|
77
|
+
* Size S for the inner `IconCapsule` icons; any key supplied here overrides
|
|
78
|
+
* those defaults.
|
|
79
|
+
*/
|
|
80
|
+
modes?: Modes
|
|
81
|
+
/**
|
|
82
|
+
* Accessibility label. Defaults to a descriptive label based on state.
|
|
83
|
+
*/
|
|
84
|
+
accessibilityLabel?: string
|
|
85
|
+
/**
|
|
86
|
+
* Disables press interaction. Also dims the cell.
|
|
87
|
+
*/
|
|
88
|
+
isDisabled?: boolean
|
|
89
|
+
/** Test identifier. */
|
|
90
|
+
testID?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface AdditemTokens {
|
|
94
|
+
backgroundColor: string
|
|
95
|
+
borderRadius: number
|
|
96
|
+
paddingHorizontal: number
|
|
97
|
+
paddingVertical: number
|
|
98
|
+
size: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toNumber(value: unknown, fallback: number): number {
|
|
102
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
const parsed = parseFloat(value)
|
|
105
|
+
if (Number.isFinite(parsed)) return parsed
|
|
106
|
+
}
|
|
107
|
+
return fallback
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveAdditemTokens(modes: Modes): AdditemTokens {
|
|
111
|
+
const backgroundColor = getVariableByName('compareCardItem/background', modes) as string
|
|
112
|
+
const borderRadius = toNumber(getVariableByName('compareCardItem/radius', modes), 8)
|
|
113
|
+
const paddingHorizontal = toNumber(getVariableByName('compareCardItem/padding/horizontal', modes), 6)
|
|
114
|
+
const paddingVertical = toNumber(getVariableByName('compareCardItem/padding/vertical', modes), 8)
|
|
115
|
+
// The Figma design uses a fixed 44 × 44 dp cell. There is no
|
|
116
|
+
// `compareCardItem/size` token in the variables JSON, so we keep the size
|
|
117
|
+
// constant to match the design exactly.
|
|
118
|
+
const size = 44
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
backgroundColor,
|
|
122
|
+
borderRadius,
|
|
123
|
+
paddingHorizontal,
|
|
124
|
+
paddingVertical,
|
|
125
|
+
size,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Figma defaults for the inner `IconCapsule` icons. Overridable via `modes`. */
|
|
130
|
+
const ADDITEM_DEFAULT_MODES: Readonly<Record<string, string>> = Object.freeze({
|
|
131
|
+
AppearanceBrand: 'Neutral',
|
|
132
|
+
Emphasis: 'Low',
|
|
133
|
+
'Icon Capsule Size': 'S',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `Additem` — a small, reusable upload/preview cell used inside form uploaders.
|
|
138
|
+
*
|
|
139
|
+
* In its default `'empty'` state it shows a centered add/upload icon. In
|
|
140
|
+
* `'preview'` state it renders a thumbnail with a removable overlay. The
|
|
141
|
+
* component is intentionally unopinionated about *how* files are picked: pass a
|
|
142
|
+
* `picker` callback that wraps `react-native-image-picker`,
|
|
143
|
+
* `expo-image-picker`, `react-native-document-picker`, or any custom selector,
|
|
144
|
+
* and the cell will invoke it, surface the result via `onAssetsPicked`, and
|
|
145
|
+
* handle cancellation/errors gracefully.
|
|
146
|
+
*/
|
|
147
|
+
function Additem({
|
|
148
|
+
state = 'empty',
|
|
149
|
+
imageSource,
|
|
150
|
+
onPress,
|
|
151
|
+
onRemove,
|
|
152
|
+
picker,
|
|
153
|
+
onAssetsPicked,
|
|
154
|
+
modes: propModes = EMPTY_MODES,
|
|
155
|
+
accessibilityLabel,
|
|
156
|
+
isDisabled = false,
|
|
157
|
+
testID,
|
|
158
|
+
}: AdditemProps) {
|
|
159
|
+
const { modes: globalModes } = useTokens()
|
|
160
|
+
|
|
161
|
+
const modes = useMemo(
|
|
162
|
+
() =>
|
|
163
|
+
globalModes === EMPTY_MODES && propModes === EMPTY_MODES
|
|
164
|
+
? ADDITEM_DEFAULT_MODES
|
|
165
|
+
: { ...ADDITEM_DEFAULT_MODES, ...globalModes, ...propModes },
|
|
166
|
+
[globalModes, propModes],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const tokens = useMemo(() => resolveAdditemTokens(modes), [modes])
|
|
170
|
+
|
|
171
|
+
const handlePress = React.useCallback(async () => {
|
|
172
|
+
if (isDisabled) return
|
|
173
|
+
|
|
174
|
+
if (state === 'empty' && picker) {
|
|
175
|
+
try {
|
|
176
|
+
const result = await picker()
|
|
177
|
+
if (result.didCancel) return
|
|
178
|
+
if (result.error) {
|
|
179
|
+
if (__DEV__) {
|
|
180
|
+
console.warn('[Additem] picker error:', result.error)
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (result.assets.length > 0) {
|
|
185
|
+
onAssetsPicked?.(result.assets)
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (__DEV__) {
|
|
189
|
+
console.warn('[Additem] unexpected picker error:', err)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onPress?.()
|
|
196
|
+
}, [isDisabled, onPress, onAssetsPicked, picker, state])
|
|
197
|
+
|
|
198
|
+
const handleRemove = React.useCallback(() => {
|
|
199
|
+
if (!isDisabled) {
|
|
200
|
+
onRemove?.()
|
|
201
|
+
}
|
|
202
|
+
}, [isDisabled, onRemove])
|
|
203
|
+
|
|
204
|
+
const resolvedAccessibilityLabel = accessibilityLabel ?? (state === 'empty' ? 'Add attachment' : 'Attachment preview')
|
|
205
|
+
|
|
206
|
+
const containerStyle: StyleProp<ViewStyle> = useMemo(
|
|
207
|
+
() => ({
|
|
208
|
+
width: tokens.size,
|
|
209
|
+
height: tokens.size,
|
|
210
|
+
borderRadius: tokens.borderRadius,
|
|
211
|
+
backgroundColor: tokens.backgroundColor,
|
|
212
|
+
paddingHorizontal: tokens.paddingHorizontal,
|
|
213
|
+
paddingVertical: tokens.paddingVertical,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
overflow: 'hidden',
|
|
217
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
218
|
+
}),
|
|
219
|
+
[tokens, isDisabled],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const isPreview = state === 'preview'
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<Pressable
|
|
226
|
+
onPress={handlePress}
|
|
227
|
+
disabled={isDisabled}
|
|
228
|
+
accessibilityRole="button"
|
|
229
|
+
accessibilityLabel={resolvedAccessibilityLabel}
|
|
230
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
231
|
+
testID={testID}
|
|
232
|
+
style={containerStyle}
|
|
233
|
+
>
|
|
234
|
+
{isPreview ? (
|
|
235
|
+
<>
|
|
236
|
+
<Image
|
|
237
|
+
imageSource={imageSource}
|
|
238
|
+
width="100%"
|
|
239
|
+
height={tokens.size - tokens.paddingVertical * 2}
|
|
240
|
+
resizeMode="cover"
|
|
241
|
+
borderRadius={tokens.borderRadius - 2}
|
|
242
|
+
/>
|
|
243
|
+
{onRemove != null && (
|
|
244
|
+
<View style={{ position: 'absolute', top: 2, right: 2 }}>
|
|
245
|
+
<IconCapsule
|
|
246
|
+
iconName="ic_close"
|
|
247
|
+
modes={modes}
|
|
248
|
+
onTouchEnd={handleRemove}
|
|
249
|
+
accessibilityRole="button"
|
|
250
|
+
accessibilityLabel="Remove attachment"
|
|
251
|
+
/>
|
|
252
|
+
</View>
|
|
253
|
+
)}
|
|
254
|
+
</>
|
|
255
|
+
) : (
|
|
256
|
+
<IconCapsule iconName="ic_add" modes={modes} />
|
|
257
|
+
)}
|
|
258
|
+
</Pressable>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export default React.memo(Additem)
|