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.
- package/CHANGELOG.md +40 -0
- package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
- package/lib/commonjs/components/Carousel/Carousel.js +112 -19
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +39 -5
- 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/NumberPagination/NumberPagination.js +144 -0
- package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
- 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/ContentSheet/ContentSheet.js +41 -7
- 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/NumberPagination/NumberPagination.js +138 -0
- package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
- 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/ContentSheet/ContentSheet.d.ts +7 -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/NumberPagination/NumberPagination.d.ts +21 -0
- package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
- 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/ContentSheet/ContentSheet.tsx +50 -0
- 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/NumberPagination/NumberPagination.tsx +170 -0
- package/src/components/ProfileCard/ProfileCard.tsx +268 -0
- 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
|
@@ -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)
|