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.
Files changed (97) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
  3. package/lib/commonjs/components/Carousel/Carousel.js +112 -19
  4. package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
  5. package/lib/commonjs/components/ContentSheet/ContentSheet.js +104 -10
  6. package/lib/commonjs/components/FormUpload/Additem.js +168 -0
  7. package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
  8. package/lib/commonjs/components/Grid/Grid.js +206 -0
  9. package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
  10. package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
  11. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
  12. package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
  13. package/lib/commonjs/components/Table/Table.js +27 -7
  14. package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
  15. package/lib/commonjs/components/index.js +42 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/design-tokens/figma-modes.generated.js +21 -10
  18. package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
  19. package/lib/commonjs/icons/components/IconImage.js +19 -0
  20. package/lib/commonjs/icons/components/IconMic.js +19 -0
  21. package/lib/commonjs/icons/components/IconMicoff.js +19 -0
  22. package/lib/commonjs/icons/components/IconPause.js +19 -0
  23. package/lib/commonjs/icons/components/IconPlay.js +19 -0
  24. package/lib/commonjs/icons/components/index.js +66 -0
  25. package/lib/commonjs/icons/registry.js +2 -2
  26. package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
  27. package/lib/module/components/Carousel/Carousel.js +111 -19
  28. package/lib/module/components/CompareTable/CompareTable.js +191 -26
  29. package/lib/module/components/ContentSheet/ContentSheet.js +108 -14
  30. package/lib/module/components/FormUpload/Additem.js +162 -0
  31. package/lib/module/components/FormUpload/FormUpload.js +192 -0
  32. package/lib/module/components/Grid/Grid.js +201 -0
  33. package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
  34. package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
  35. package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
  36. package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
  37. package/lib/module/components/Table/Table.js +27 -7
  38. package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
  39. package/lib/module/components/index.js +7 -1
  40. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  41. package/lib/module/design-tokens/figma-modes.generated.js +21 -10
  42. package/lib/module/icons/components/IconBrokenimage.js +12 -0
  43. package/lib/module/icons/components/IconImage.js +12 -0
  44. package/lib/module/icons/components/IconMic.js +12 -0
  45. package/lib/module/icons/components/IconMicoff.js +12 -0
  46. package/lib/module/icons/components/IconPause.js +12 -0
  47. package/lib/module/icons/components/IconPlay.js +12 -0
  48. package/lib/module/icons/components/index.js +6 -0
  49. package/lib/module/icons/registry.js +2 -2
  50. package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
  51. package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
  52. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
  53. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
  54. package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
  55. package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
  56. package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
  57. package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
  58. package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
  59. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
  60. package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
  61. package/lib/typescript/src/components/Table/Table.d.ts +9 -1
  62. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
  63. package/lib/typescript/src/components/index.d.ts +34 -28
  64. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
  65. package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
  66. package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
  67. package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
  68. package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
  69. package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
  70. package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
  71. package/lib/typescript/src/icons/components/index.d.ts +6 -0
  72. package/lib/typescript/src/icons/registry.d.ts +1 -1
  73. package/package.json +1 -1
  74. package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
  75. package/src/components/Carousel/Carousel.tsx +133 -23
  76. package/src/components/CompareTable/CompareTable.tsx +224 -30
  77. package/src/components/ContentSheet/ContentSheet.tsx +138 -8
  78. package/src/components/FormUpload/Additem.tsx +262 -0
  79. package/src/components/FormUpload/FormUpload.tsx +313 -0
  80. package/src/components/Grid/Grid.tsx +368 -0
  81. package/src/components/MoneyValue/MoneyValue.tsx +216 -65
  82. package/src/components/NumberPagination/NumberPagination.tsx +170 -0
  83. package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
  84. package/src/components/ProfileCard/ProfileCard.tsx +268 -0
  85. package/src/components/Table/Table.tsx +29 -4
  86. package/src/components/UpiHandle/UpiHandle.tsx +35 -24
  87. package/src/components/index.ts +329 -182
  88. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  89. package/src/design-tokens/figma-modes.generated.ts +21 -10
  90. package/src/icons/components/IconBrokenimage.tsx +11 -0
  91. package/src/icons/components/IconImage.tsx +11 -0
  92. package/src/icons/components/IconMic.tsx +11 -0
  93. package/src/icons/components/IconMicoff.tsx +11 -0
  94. package/src/icons/components/IconPause.tsx +11 -0
  95. package/src/icons/components/IconPlay.tsx +11 -0
  96. package/src/icons/components/index.ts +6 -0
  97. 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
- <View style={containerStyle} {...rest}>
271
+ <AnimatedSheet style={containerStyle} entrance={entrance} {...rest}>
272
+ {header}
179
273
  {processedChildren}
180
- </View>
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 keyboardStyle = useAnimatedStyle(() => {
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: height > 0 ? -(height + spacing) : 0 }],
336
+ transform: [{ translateY: entrance.value }],
207
337
  }
208
338
  })
209
339
 
210
340
  return (
211
- <Animated.View style={[style, keyboardStyle]} {...rest}>
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)