jfs-components 0.1.25 → 0.1.28

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 (81) hide show
  1. package/CHANGELOG.md +40 -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/ContentSheet/ContentSheet.js +39 -5
  5. package/lib/commonjs/components/FormUpload/Additem.js +168 -0
  6. package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
  7. package/lib/commonjs/components/Grid/Grid.js +206 -0
  8. package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
  9. package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
  10. package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
  11. package/lib/commonjs/components/index.js +42 -0
  12. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  13. package/lib/commonjs/design-tokens/figma-modes.generated.js +21 -10
  14. package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
  15. package/lib/commonjs/icons/components/IconImage.js +19 -0
  16. package/lib/commonjs/icons/components/IconMic.js +19 -0
  17. package/lib/commonjs/icons/components/IconMicoff.js +19 -0
  18. package/lib/commonjs/icons/components/IconPause.js +19 -0
  19. package/lib/commonjs/icons/components/IconPlay.js +19 -0
  20. package/lib/commonjs/icons/components/index.js +66 -0
  21. package/lib/commonjs/icons/registry.js +2 -2
  22. package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
  23. package/lib/module/components/Carousel/Carousel.js +111 -19
  24. package/lib/module/components/ContentSheet/ContentSheet.js +41 -7
  25. package/lib/module/components/FormUpload/Additem.js +162 -0
  26. package/lib/module/components/FormUpload/FormUpload.js +192 -0
  27. package/lib/module/components/Grid/Grid.js +201 -0
  28. package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
  29. package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
  30. package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
  31. package/lib/module/components/index.js +7 -1
  32. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  33. package/lib/module/design-tokens/figma-modes.generated.js +21 -10
  34. package/lib/module/icons/components/IconBrokenimage.js +12 -0
  35. package/lib/module/icons/components/IconImage.js +12 -0
  36. package/lib/module/icons/components/IconMic.js +12 -0
  37. package/lib/module/icons/components/IconMicoff.js +12 -0
  38. package/lib/module/icons/components/IconPause.js +12 -0
  39. package/lib/module/icons/components/IconPlay.js +12 -0
  40. package/lib/module/icons/components/index.js +6 -0
  41. package/lib/module/icons/registry.js +2 -2
  42. package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
  43. package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
  44. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +7 -1
  45. package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
  46. package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
  47. package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
  48. package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
  49. package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
  50. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
  51. package/lib/typescript/src/components/index.d.ts +34 -28
  52. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
  53. package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
  54. package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
  55. package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
  56. package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
  57. package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
  58. package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
  59. package/lib/typescript/src/icons/components/index.d.ts +6 -0
  60. package/lib/typescript/src/icons/registry.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
  63. package/src/components/Carousel/Carousel.tsx +133 -23
  64. package/src/components/ContentSheet/ContentSheet.tsx +50 -0
  65. package/src/components/FormUpload/Additem.tsx +262 -0
  66. package/src/components/FormUpload/FormUpload.tsx +313 -0
  67. package/src/components/Grid/Grid.tsx +368 -0
  68. package/src/components/NumberPagination/NumberPagination.tsx +170 -0
  69. package/src/components/ProfileCard/ProfileCard.tsx +268 -0
  70. package/src/components/UpiHandle/UpiHandle.tsx +35 -24
  71. package/src/components/index.ts +329 -182
  72. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  73. package/src/design-tokens/figma-modes.generated.ts +21 -10
  74. package/src/icons/components/IconBrokenimage.tsx +11 -0
  75. package/src/icons/components/IconImage.tsx +11 -0
  76. package/src/icons/components/IconMic.tsx +11 -0
  77. package/src/icons/components/IconMicoff.tsx +11 -0
  78. package/src/icons/components/IconPause.tsx +11 -0
  79. package/src/icons/components/IconPlay.tsx +11 -0
  80. package/src/icons/components/index.ts +6 -0
  81. package/src/icons/registry.ts +25 -1
@@ -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)
@@ -0,0 +1,313 @@
1
+ import React, { useMemo, useCallback } from 'react'
2
+ import { View, Text, type StyleProp, type ViewStyle, type TextStyle } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
6
+ import { useFormContext } from '../Form/Form'
7
+ import SupportText from '../SupportText/SupportText'
8
+ import Additem, { type AdditemProps, type PickedAsset } from './Additem'
9
+ import type { Modes } from '../../design-tokens'
10
+
11
+ export type FormUploadAttachment = PickedAsset
12
+
13
+ export type FormUploadProps = {
14
+ /** Label rendered above the upload row. */
15
+ label?: string
16
+ /**
17
+ * Helper text rendered below the upload row. When `errorMessage` is provided,
18
+ * it replaces the support text and renders in the error status.
19
+ */
20
+ supportText?: string
21
+ /**
22
+ * Field name. When used inside `<Form>`, this is the key used to look up
23
+ * server-side `validationErrors`.
24
+ */
25
+ name?: string
26
+ /**
27
+ * Controlled list of attachments. Each item maps to an `Additem` cell:
28
+ * render attachments as `'preview'` cells and leave the last slot as the
29
+ * `'empty'` upload trigger.
30
+ */
31
+ attachments?: FormUploadAttachment[]
32
+ /**
33
+ * Maximum number of attachments allowed. When omitted, the component renders
34
+ * a single empty upload trigger. Once `attachments.length` reaches `maxCount`,
35
+ * the empty trigger is hidden.
36
+ */
37
+ maxCount?: number
38
+ /**
39
+ * Called when the attachment list should change (add or remove). The parent
40
+ * is responsible for updating the `attachments` array.
41
+ */
42
+ onAttachmentsChange?: (attachments: FormUploadAttachment[]) => void
43
+ /**
44
+ * Optional picker injected by the parent. This keeps the component free of
45
+ * native-library dependencies while still providing real upload behaviour on
46
+ * iOS/Android. The picker should wrap `react-native-image-picker`,
47
+ * `expo-image-picker`, `react-native-document-picker`, etc.
48
+ */
49
+ picker?: AdditemProps['picker']
50
+ /**
51
+ * Error message shown below the row. When provided inside `<Form>`, the form
52
+ * context error is used automatically unless this prop is set explicitly.
53
+ */
54
+ errorMessage?: string
55
+ /** Marks the field as invalid. Also set automatically by `<Form>` errors. */
56
+ isInvalid?: boolean
57
+ /** Disables all upload interaction. */
58
+ isDisabled?: boolean
59
+ /**
60
+ * Custom children/slot rendered in place of the generated `Additem` cells.
61
+ * Useful when the consumer wants full control over the upload row. `modes`
62
+ * cascade into slot children automatically.
63
+ */
64
+ children?: React.ReactNode
65
+ /** Modes for design-token resolution. */
66
+ modes?: Modes
67
+ /** Style overrides for the outermost wrapper. */
68
+ style?: StyleProp<ViewStyle>
69
+ /** Style overrides for the upload row container. */
70
+ rowStyle?: StyleProp<ViewStyle>
71
+ /** Accessibility label. Defaults to `label`. */
72
+ accessibilityLabel?: string
73
+ /** Test identifier. */
74
+ testID?: string
75
+ }
76
+
77
+ function toNumber(value: unknown, fallback: number): number {
78
+ if (typeof value === 'number' && Number.isFinite(value)) return value
79
+ if (typeof value === 'string') {
80
+ const parsed = parseFloat(value)
81
+ if (Number.isFinite(parsed)) return parsed
82
+ }
83
+ return fallback
84
+ }
85
+
86
+ function toFontWeight(value: unknown, fallback: TextStyle['fontWeight']): TextStyle['fontWeight'] {
87
+ if (typeof value === 'number') return value.toString() as TextStyle['fontWeight']
88
+ if (typeof value === 'string' && value.length > 0) return value as TextStyle['fontWeight']
89
+ return fallback
90
+ }
91
+
92
+ interface FormUploadTokens {
93
+ gap: number
94
+ rowGap: number
95
+ labelColor: string
96
+ labelFontFamily: string
97
+ labelFontSize: number
98
+ labelLineHeight: number
99
+ labelFontWeight: TextStyle['fontWeight']
100
+ }
101
+
102
+ function resolveFormUploadTokens(modes: Modes): FormUploadTokens {
103
+ const gap = toNumber(getVariableByName('formField/input/gap', modes), 8)
104
+ const rowGap = toNumber(getVariableByName('formField/gap', modes), 8)
105
+
106
+ const labelColor = getVariableByName('formField/label/color', modes) as string
107
+ const labelFontFamily = getVariableByName('formField/label/fontFamily', modes) as string
108
+ const labelFontSize = toNumber(getVariableByName('formField/label/fontSize', modes), 14)
109
+ const labelLineHeight = toNumber(getVariableByName('formField/label/lineHeight', modes), 17)
110
+ const labelFontWeight = toFontWeight(getVariableByName('formField/label/fontWeight', modes), '500')
111
+
112
+ return {
113
+ gap,
114
+ rowGap,
115
+ labelColor,
116
+ labelFontFamily,
117
+ labelFontSize,
118
+ labelLineHeight,
119
+ labelFontWeight,
120
+ }
121
+ }
122
+
123
+ function firstError(error: string | string[] | undefined): string | undefined {
124
+ if (!error) return undefined
125
+ if (Array.isArray(error)) return error[0]
126
+ return error
127
+ }
128
+
129
+ /** Figma defaults for the remove `IconCapsule` on filled preview cells. Overridable via `modes`. */
130
+ const FORM_UPLOAD_PREVIEW_ICON_CAPSULE_MODES: Modes = {
131
+ AppearanceBrand: 'Neutral',
132
+ Emphasis: 'Medium',
133
+ 'Icon Capsule Size': 'XS',
134
+ }
135
+
136
+ /**
137
+ * `FormUpload` — a form-aware upload row built from reusable `Additem` cells.
138
+ *
139
+ * The component mirrors the Figma "FormUpload" design: a label, a horizontal
140
+ * row of upload/preview cells, and support text. It integrates with the
141
+ * existing `<Form>` context so server-side validation errors automatically
142
+ * show below the row, and it clears those errors when attachments change.
143
+ *
144
+ * Upload behaviour is provided through the `picker` prop so the library stays
145
+ * free of native image-picker dependencies. Pass any picker that returns the
146
+ * `AdditemPickerResult` shape and the empty cell becomes a real upload trigger
147
+ * on both iOS and Android.
148
+ */
149
+ function FormUpload({
150
+ label,
151
+ supportText,
152
+ name,
153
+ attachments = [],
154
+ maxCount,
155
+ onAttachmentsChange,
156
+ picker,
157
+ errorMessage,
158
+ isInvalid = false,
159
+ isDisabled = false,
160
+ children,
161
+ modes: propModes = EMPTY_MODES,
162
+ style,
163
+ rowStyle,
164
+ accessibilityLabel,
165
+ testID,
166
+ }: FormUploadProps) {
167
+ const formCtx = useFormContext()
168
+ const formError = name && formCtx ? firstError(formCtx.validationErrors[name]) : undefined
169
+ const resolvedIsInvalid = isInvalid || Boolean(formError)
170
+ const resolvedErrorMessage = errorMessage ?? formError
171
+
172
+ const { modes: globalModes } = useTokens()
173
+ const baseModes = useMemo(
174
+ () =>
175
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
176
+ ? EMPTY_MODES
177
+ : { ...globalModes, ...propModes },
178
+ [globalModes, propModes],
179
+ )
180
+
181
+ const modes = useMemo(
182
+ () => ({
183
+ ...baseModes,
184
+ 'FormField States': resolvedIsInvalid ? 'Error' : 'Idle',
185
+ Status: resolvedIsInvalid ? 'Error' : 'Auto',
186
+ }),
187
+ [baseModes, resolvedIsInvalid],
188
+ )
189
+
190
+ // Empty cells rely on `Additem`'s built-in Neutral / Low / S defaults for the
191
+ // add icon. Preview cells use the tighter remove-icon treatment from Figma.
192
+ const previewAdditemModes = useMemo(
193
+ () => ({ ...FORM_UPLOAD_PREVIEW_ICON_CAPSULE_MODES, ...modes }),
194
+ [modes],
195
+ )
196
+
197
+ const tokens = useMemo(() => resolveFormUploadTokens(modes), [modes])
198
+
199
+ const handleAssetsPicked = useCallback(
200
+ (assets: PickedAsset[]) => {
201
+ if (isDisabled) return
202
+ const next = [...attachments, ...assets]
203
+ if (maxCount != null && next.length > maxCount) {
204
+ next.length = maxCount
205
+ }
206
+ onAttachmentsChange?.(next)
207
+ if (name && formCtx) {
208
+ formCtx.onFieldChange(name)
209
+ }
210
+ },
211
+ [attachments, maxCount, onAttachmentsChange, isDisabled, name, formCtx],
212
+ )
213
+
214
+ const handleRemove = useCallback(
215
+ (index: number) => {
216
+ if (isDisabled) return
217
+ const next = attachments.filter((_, i) => i !== index)
218
+ onAttachmentsChange?.(next)
219
+ if (name && formCtx) {
220
+ formCtx.onFieldChange(name)
221
+ }
222
+ },
223
+ [attachments, onAttachmentsChange, isDisabled, name, formCtx],
224
+ )
225
+
226
+ const containerStyle: ViewStyle = useMemo(
227
+ () => ({
228
+ gap: tokens.gap,
229
+ opacity: isDisabled ? 0.5 : 1,
230
+ }),
231
+ [tokens.gap, isDisabled],
232
+ )
233
+
234
+ const rowStyleResolved: ViewStyle = useMemo(
235
+ () => ({
236
+ flexDirection: 'row',
237
+ flexWrap: 'wrap',
238
+ gap: tokens.rowGap,
239
+ alignItems: 'flex-start',
240
+ }),
241
+ [tokens.rowGap],
242
+ )
243
+
244
+ const labelStyle: TextStyle = useMemo(
245
+ () => ({
246
+ color: tokens.labelColor,
247
+ fontFamily: tokens.labelFontFamily,
248
+ fontSize: tokens.labelFontSize,
249
+ lineHeight: tokens.labelLineHeight,
250
+ fontWeight: tokens.labelFontWeight,
251
+ }),
252
+ [tokens],
253
+ )
254
+
255
+ const resolvedA11yLabel = accessibilityLabel ?? label ?? 'Attachment upload'
256
+
257
+ const canAddMore = maxCount == null || attachments.length < maxCount
258
+
259
+ const renderContent = () => {
260
+ if (children) {
261
+ return cloneChildrenWithModes(children, modes)
262
+ }
263
+
264
+ return (
265
+ <>
266
+ {attachments.map((attachment, index) => (
267
+ <Additem
268
+ key={`${attachment.uri}-${index}`}
269
+ state="preview"
270
+ imageSource={attachment.uri}
271
+ onRemove={() => handleRemove(index)}
272
+ modes={previewAdditemModes}
273
+ isDisabled={isDisabled}
274
+ accessibilityLabel={attachment.name ?? `Attachment ${index + 1}`}
275
+ testID={`${testID}-item-${index}`}
276
+ />
277
+ ))}
278
+ {canAddMore && (
279
+ <Additem
280
+ state="empty"
281
+ picker={picker}
282
+ onAssetsPicked={handleAssetsPicked}
283
+ modes={modes}
284
+ isDisabled={isDisabled}
285
+ testID={`${testID}-add`}
286
+ />
287
+ )}
288
+ </>
289
+ )
290
+ }
291
+
292
+ const supportLabel = resolvedIsInvalid && resolvedErrorMessage ? resolvedErrorMessage : supportText
293
+ const supportStatus = resolvedIsInvalid ? 'Error' : 'Neutral'
294
+
295
+ return (
296
+ <View
297
+ style={[containerStyle, style]}
298
+ accessibilityRole="none"
299
+ accessibilityLabel={resolvedA11yLabel}
300
+ testID={testID}
301
+ >
302
+ {label != null && label !== '' && <Text style={labelStyle}>{label}</Text>}
303
+
304
+ <View style={[rowStyleResolved, rowStyle]}>{renderContent()}</View>
305
+
306
+ {supportLabel != null && supportLabel !== '' && (
307
+ <SupportText label={supportLabel} status={supportStatus} modes={modes} />
308
+ )}
309
+ </View>
310
+ )
311
+ }
312
+
313
+ export default React.memo(FormUpload)