jfs-components 0.1.23 → 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/CardCTA/CardCTA.js +32 -25
- package/lib/commonjs/components/Carousel/Carousel.js +112 -19
- package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
- 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/ListItem/ListItem.js +11 -4
- package/lib/commonjs/components/MetricData/MetricData.js +132 -0
- package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
- package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
- package/lib/commonjs/components/Rating/Rating.js +137 -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 +23 -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/CardCTA/CardCTA.js +32 -25
- package/lib/module/components/Carousel/Carousel.js +111 -19
- package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
- 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/ListItem/ListItem.js +11 -4
- package/lib/module/components/MetricData/MetricData.js +127 -0
- package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
- package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
- package/lib/module/components/Rating/Rating.js +132 -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 +23 -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/ChipSelect/ChipSelect.d.ts +6 -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/MetricData/MetricData.d.ts +53 -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/Rating/Rating.d.ts +30 -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/CardCTA/CardCTA.tsx +30 -15
- package/src/components/Carousel/Carousel.tsx +133 -23
- package/src/components/ChipSelect/ChipSelect.tsx +7 -1
- 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/ListItem/ListItem.tsx +11 -4
- package/src/components/MetricData/MetricData.tsx +185 -0
- package/src/components/NumberPagination/NumberPagination.tsx +170 -0
- package/src/components/ProfileCard/ProfileCard.tsx +268 -0
- package/src/components/Rating/Rating.tsx +174 -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 +23 -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,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)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import { EMPTY_MODES, cloneChildrenWithModes, flattenChildren } from '../../utils/react-utils'
|
|
6
|
+
import type { Modes } from '../../design-tokens'
|
|
7
|
+
|
|
8
|
+
export type GridJustifyContent = 'start' | 'stretch' | 'space-between'
|
|
9
|
+
export type GridAlignItems = 'start' | 'stretch' | 'center'
|
|
10
|
+
|
|
11
|
+
export type GridProps = {
|
|
12
|
+
/**
|
|
13
|
+
* Number of columns per row. Defaults to `1`. Children flow left-to-right,
|
|
14
|
+
* top-to-bottom into this many columns.
|
|
15
|
+
*/
|
|
16
|
+
columns?: number
|
|
17
|
+
/**
|
|
18
|
+
* Fixed row count. When set, the grid always renders `columns × rows` cells.
|
|
19
|
+
* Missing cells render as empty placeholders so the geometry stays stable.
|
|
20
|
+
* When omitted, the row count is derived from the number of children.
|
|
21
|
+
*/
|
|
22
|
+
rows?: number
|
|
23
|
+
/**
|
|
24
|
+
* Fractional column track weights — the React Native equivalent of CSS
|
|
25
|
+
* `grid-template-columns: 1fr 2fr 1fr`. Each value becomes a `flex` grow
|
|
26
|
+
* weight on the matching column. Shorter arrays repeat the last weight;
|
|
27
|
+
* omitted values default to equal `1fr` columns.
|
|
28
|
+
*/
|
|
29
|
+
columnFrs?: readonly number[]
|
|
30
|
+
/**
|
|
31
|
+
* Fractional row track weights. When provided, each row container receives
|
|
32
|
+
* a matching `flexGrow` weight. Useful when the grid should fill a fixed
|
|
33
|
+
* height and rows should share space proportionally.
|
|
34
|
+
*/
|
|
35
|
+
rowFrs?: readonly number[]
|
|
36
|
+
/** Override the resolved column gap token. */
|
|
37
|
+
columnGap?: number
|
|
38
|
+
/** Override the resolved row gap token. */
|
|
39
|
+
rowGap?: number
|
|
40
|
+
/**
|
|
41
|
+
* Shorthand gap override applied when `columnGap` / `rowGap` are not set
|
|
42
|
+
* individually. Falls back to the `Grid/gap` token.
|
|
43
|
+
*/
|
|
44
|
+
gap?: number
|
|
45
|
+
/** Uniform padding override for all sides. */
|
|
46
|
+
padding?: number
|
|
47
|
+
paddingHorizontal?: number
|
|
48
|
+
paddingVertical?: number
|
|
49
|
+
paddingTop?: number
|
|
50
|
+
paddingBottom?: number
|
|
51
|
+
paddingLeft?: number
|
|
52
|
+
paddingRight?: number
|
|
53
|
+
/**
|
|
54
|
+
* How cells are distributed along each row.
|
|
55
|
+
* - `'stretch'` (default) — cells expand using `columnFrs`.
|
|
56
|
+
* - `'start'` — cells hug the leading edge; trailing space stays empty.
|
|
57
|
+
* - `'space-between'` — leftover space is distributed between cells
|
|
58
|
+
* (first cell flush-left, last cell flush-right).
|
|
59
|
+
*/
|
|
60
|
+
justifyContent?: GridJustifyContent
|
|
61
|
+
/** Cross-axis alignment applied to every cell wrapper. Defaults to `'stretch'`. */
|
|
62
|
+
alignItems?: GridAlignItems
|
|
63
|
+
/**
|
|
64
|
+
* Slot content. Each child becomes one grid cell. `modes` cascade into all
|
|
65
|
+
* slot children automatically.
|
|
66
|
+
*/
|
|
67
|
+
children?: React.ReactNode
|
|
68
|
+
/** Design token modes (e.g. `{ 'Slot gap': 'M' }`). */
|
|
69
|
+
modes?: Modes
|
|
70
|
+
/** Style override for the outer container. */
|
|
71
|
+
style?: StyleProp<ViewStyle>
|
|
72
|
+
/** Style override for the inner rows wrapper. */
|
|
73
|
+
contentStyle?: StyleProp<ViewStyle>
|
|
74
|
+
/** Style applied to every cell wrapper. */
|
|
75
|
+
cellStyle?: StyleProp<ViewStyle>
|
|
76
|
+
/** Accessibility label for the grid container. */
|
|
77
|
+
accessibilityLabel?: string
|
|
78
|
+
testID?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toNumber(value: unknown, fallback: number): number {
|
|
82
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const parsed = parseFloat(value)
|
|
85
|
+
if (Number.isFinite(parsed)) return parsed
|
|
86
|
+
}
|
|
87
|
+
return fallback
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface GridTokens {
|
|
91
|
+
backgroundColor: string
|
|
92
|
+
columnGap: number
|
|
93
|
+
rowGap: number
|
|
94
|
+
paddingTop: number
|
|
95
|
+
paddingBottom: number
|
|
96
|
+
paddingLeft: number
|
|
97
|
+
paddingRight: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveGridTokens(modes: Modes): GridTokens {
|
|
101
|
+
const gapFallback = toNumber(getVariableByName('Grid/gap', modes), 8)
|
|
102
|
+
const columnGap = toNumber(getVariableByName('Grid/column/gap', modes), gapFallback)
|
|
103
|
+
const rowGap = toNumber(getVariableByName('Grid/row/gap', modes), gapFallback)
|
|
104
|
+
|
|
105
|
+
const paddingHorizontal = toNumber(getVariableByName('Grid/padding/horizontal', modes), 8)
|
|
106
|
+
const paddingVertical = toNumber(getVariableByName('Grid/padding/vertical', modes), 8)
|
|
107
|
+
const paddingTop = toNumber(getVariableByName('Grid/padding/top', modes), paddingVertical)
|
|
108
|
+
const paddingBottom = toNumber(getVariableByName('Grid/padding/bottom', modes), paddingVertical)
|
|
109
|
+
const paddingLeft = toNumber(getVariableByName('Grid/padding/left', modes), paddingHorizontal)
|
|
110
|
+
const paddingRight = toNumber(getVariableByName('Grid/padding/right', modes), paddingHorizontal)
|
|
111
|
+
|
|
112
|
+
const backgroundColor =
|
|
113
|
+
(getVariableByName('Grid/background/color', modes) as string | null) ?? 'transparent'
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
backgroundColor,
|
|
117
|
+
columnGap,
|
|
118
|
+
rowGap,
|
|
119
|
+
paddingTop,
|
|
120
|
+
paddingBottom,
|
|
121
|
+
paddingLeft,
|
|
122
|
+
paddingRight,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeFrs(frs: readonly number[] | undefined, count: number): number[] {
|
|
127
|
+
if (!frs || frs.length === 0) {
|
|
128
|
+
return Array.from({ length: count }, () => 1)
|
|
129
|
+
}
|
|
130
|
+
return Array.from({ length: count }, (_, index) => {
|
|
131
|
+
const value = frs[index] ?? frs[frs.length - 1] ?? 1
|
|
132
|
+
return value > 0 ? value : 1
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveAlignSelf(alignItems: GridAlignItems): ViewStyle['alignSelf'] {
|
|
137
|
+
if (alignItems === 'center') return 'center'
|
|
138
|
+
if (alignItems === 'start') return 'flex-start'
|
|
139
|
+
return 'stretch'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveRowJustifyContent(justifyContent: GridJustifyContent): ViewStyle['justifyContent'] {
|
|
143
|
+
if (justifyContent === 'space-between') return 'space-between'
|
|
144
|
+
if (justifyContent === 'start') return 'flex-start'
|
|
145
|
+
return 'flex-start'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildCellStyle(
|
|
149
|
+
columnFr: number,
|
|
150
|
+
justifyContent: GridJustifyContent,
|
|
151
|
+
alignItems: GridAlignItems,
|
|
152
|
+
): ViewStyle {
|
|
153
|
+
const alignSelf = resolveAlignSelf(alignItems)
|
|
154
|
+
|
|
155
|
+
if (justifyContent === 'space-between' || justifyContent === 'start') {
|
|
156
|
+
return {
|
|
157
|
+
flexGrow: 0,
|
|
158
|
+
flexShrink: 1,
|
|
159
|
+
flexBasis: 'auto',
|
|
160
|
+
minWidth: 0,
|
|
161
|
+
alignSelf,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
flex: columnFr,
|
|
167
|
+
minWidth: 0,
|
|
168
|
+
alignSelf,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `Grid` — a token-driven layout grid for React Native.
|
|
174
|
+
*
|
|
175
|
+
* Mirrors the Figma "Grid" component: a padded container with configurable
|
|
176
|
+
* columns, rows, fractional column tracks (`columnFrs`), and gap tokens.
|
|
177
|
+
* Each slot child maps to one cell — no separate `GridItem` wrapper is needed.
|
|
178
|
+
*
|
|
179
|
+
* @component
|
|
180
|
+
*/
|
|
181
|
+
function Grid({
|
|
182
|
+
columns = 1,
|
|
183
|
+
rows,
|
|
184
|
+
columnFrs,
|
|
185
|
+
rowFrs,
|
|
186
|
+
columnGap: columnGapProp,
|
|
187
|
+
rowGap: rowGapProp,
|
|
188
|
+
gap: gapProp,
|
|
189
|
+
padding: paddingProp,
|
|
190
|
+
paddingHorizontal: paddingHorizontalProp,
|
|
191
|
+
paddingVertical: paddingVerticalProp,
|
|
192
|
+
paddingTop: paddingTopProp,
|
|
193
|
+
paddingBottom: paddingBottomProp,
|
|
194
|
+
paddingLeft: paddingLeftProp,
|
|
195
|
+
paddingRight: paddingRightProp,
|
|
196
|
+
justifyContent = 'stretch',
|
|
197
|
+
alignItems = 'stretch',
|
|
198
|
+
children,
|
|
199
|
+
modes: propModes = EMPTY_MODES,
|
|
200
|
+
style,
|
|
201
|
+
contentStyle,
|
|
202
|
+
cellStyle,
|
|
203
|
+
accessibilityLabel,
|
|
204
|
+
testID,
|
|
205
|
+
}: GridProps) {
|
|
206
|
+
const { modes: globalModes } = useTokens()
|
|
207
|
+
const modes = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
globalModes === EMPTY_MODES && propModes === EMPTY_MODES
|
|
210
|
+
? EMPTY_MODES
|
|
211
|
+
: { ...globalModes, ...propModes },
|
|
212
|
+
[globalModes, propModes],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const tokens = useMemo(() => resolveGridTokens(modes), [modes])
|
|
216
|
+
|
|
217
|
+
const resolvedColumnGap = columnGapProp ?? gapProp ?? tokens.columnGap
|
|
218
|
+
const resolvedRowGap = rowGapProp ?? gapProp ?? tokens.rowGap
|
|
219
|
+
|
|
220
|
+
const resolvedPadding = useMemo(
|
|
221
|
+
() => ({
|
|
222
|
+
paddingTop: paddingTopProp ?? paddingVerticalProp ?? paddingProp ?? tokens.paddingTop,
|
|
223
|
+
paddingBottom: paddingBottomProp ?? paddingVerticalProp ?? paddingProp ?? tokens.paddingBottom,
|
|
224
|
+
paddingLeft: paddingLeftProp ?? paddingHorizontalProp ?? paddingProp ?? tokens.paddingLeft,
|
|
225
|
+
paddingRight: paddingRightProp ?? paddingHorizontalProp ?? paddingProp ?? tokens.paddingRight,
|
|
226
|
+
}),
|
|
227
|
+
[
|
|
228
|
+
paddingTopProp,
|
|
229
|
+
paddingBottomProp,
|
|
230
|
+
paddingLeftProp,
|
|
231
|
+
paddingRightProp,
|
|
232
|
+
paddingHorizontalProp,
|
|
233
|
+
paddingVerticalProp,
|
|
234
|
+
paddingProp,
|
|
235
|
+
tokens,
|
|
236
|
+
],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const safeColumns = Math.max(1, Math.floor(columns))
|
|
240
|
+
|
|
241
|
+
const processedChildren = useMemo(
|
|
242
|
+
() => cloneChildrenWithModes(flattenChildren(children), modes),
|
|
243
|
+
[children, modes],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const cellNodes = useMemo(() => {
|
|
247
|
+
const nodes = [...processedChildren]
|
|
248
|
+
if (rows != null) {
|
|
249
|
+
const targetCount = safeColumns * Math.max(1, Math.floor(rows))
|
|
250
|
+
while (nodes.length < targetCount) {
|
|
251
|
+
nodes.push(null)
|
|
252
|
+
}
|
|
253
|
+
if (nodes.length > targetCount) {
|
|
254
|
+
nodes.length = targetCount
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return nodes
|
|
258
|
+
}, [processedChildren, rows, safeColumns])
|
|
259
|
+
|
|
260
|
+
const gridRows = useMemo(() => {
|
|
261
|
+
const result: React.ReactNode[][] = []
|
|
262
|
+
for (let index = 0; index < cellNodes.length; index += safeColumns) {
|
|
263
|
+
result.push(cellNodes.slice(index, index + safeColumns))
|
|
264
|
+
}
|
|
265
|
+
return result
|
|
266
|
+
}, [cellNodes, safeColumns])
|
|
267
|
+
|
|
268
|
+
const normalizedColumnFrs = useMemo(
|
|
269
|
+
() => normalizeFrs(columnFrs, safeColumns),
|
|
270
|
+
[columnFrs, safeColumns],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const normalizedRowFrs = useMemo(
|
|
274
|
+
() => (rowFrs ? normalizeFrs(rowFrs, gridRows.length) : null),
|
|
275
|
+
[rowFrs, gridRows.length],
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const containerStyle = useMemo<ViewStyle>(
|
|
279
|
+
() => ({
|
|
280
|
+
backgroundColor: tokens.backgroundColor,
|
|
281
|
+
...resolvedPadding,
|
|
282
|
+
width: '100%',
|
|
283
|
+
}),
|
|
284
|
+
[tokens.backgroundColor, resolvedPadding],
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const rowsContainerStyle = useMemo<ViewStyle>(
|
|
288
|
+
() => ({
|
|
289
|
+
gap: resolvedRowGap,
|
|
290
|
+
width: '100%',
|
|
291
|
+
}),
|
|
292
|
+
[resolvedRowGap],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const rowStyle = useMemo<ViewStyle>(
|
|
296
|
+
() => ({
|
|
297
|
+
flexDirection: 'row',
|
|
298
|
+
columnGap: resolvedColumnGap,
|
|
299
|
+
justifyContent: resolveRowJustifyContent(justifyContent),
|
|
300
|
+
alignItems: alignItems === 'stretch' ? 'stretch' : alignItems === 'center' ? 'center' : 'flex-start',
|
|
301
|
+
width: '100%',
|
|
302
|
+
}),
|
|
303
|
+
[resolvedColumnGap, justifyContent, alignItems],
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
const defaultAccessibilityLabel =
|
|
307
|
+
accessibilityLabel ??
|
|
308
|
+
`Grid, ${safeColumns} column${safeColumns === 1 ? '' : 's'}, ${gridRows.length} row${
|
|
309
|
+
gridRows.length === 1 ? '' : 's'
|
|
310
|
+
}`
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<View
|
|
314
|
+
style={[containerStyle, style]}
|
|
315
|
+
accessibilityRole="none"
|
|
316
|
+
accessibilityLabel={defaultAccessibilityLabel}
|
|
317
|
+
testID={testID}
|
|
318
|
+
>
|
|
319
|
+
<View style={[rowsContainerStyle, contentStyle]}>
|
|
320
|
+
{gridRows.map((row, rowIndex) => {
|
|
321
|
+
const rowFr = normalizedRowFrs?.[rowIndex]
|
|
322
|
+
const spacersNeeded =
|
|
323
|
+
justifyContent === 'space-between' && row.length < safeColumns
|
|
324
|
+
? safeColumns - row.length
|
|
325
|
+
: 0
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<View
|
|
329
|
+
key={`grid-row-${rowIndex}`}
|
|
330
|
+
style={rowFr != null ? [rowStyle, { flexGrow: rowFr }] : rowStyle}
|
|
331
|
+
>
|
|
332
|
+
{row.map((cell, columnIndex) => {
|
|
333
|
+
const absoluteIndex = rowIndex * safeColumns + columnIndex
|
|
334
|
+
const cellFlexStyle = buildCellStyle(
|
|
335
|
+
normalizedColumnFrs[columnIndex] ?? 1,
|
|
336
|
+
justifyContent,
|
|
337
|
+
alignItems,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<View
|
|
342
|
+
key={`grid-cell-${absoluteIndex}`}
|
|
343
|
+
style={[cellFlexStyle, cellStyle]}
|
|
344
|
+
>
|
|
345
|
+
{cell}
|
|
346
|
+
</View>
|
|
347
|
+
)
|
|
348
|
+
})}
|
|
349
|
+
{spacersNeeded > 0 &&
|
|
350
|
+
Array.from({ length: spacersNeeded }, (_, spacerIndex) => (
|
|
351
|
+
<View
|
|
352
|
+
key={`grid-spacer-${rowIndex}-${spacerIndex}`}
|
|
353
|
+
style={buildCellStyle(
|
|
354
|
+
normalizedColumnFrs[row.length + spacerIndex] ?? 1,
|
|
355
|
+
justifyContent,
|
|
356
|
+
alignItems,
|
|
357
|
+
)}
|
|
358
|
+
/>
|
|
359
|
+
))}
|
|
360
|
+
</View>
|
|
361
|
+
)
|
|
362
|
+
})}
|
|
363
|
+
</View>
|
|
364
|
+
</View>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export default React.memo(Grid)
|
|
@@ -322,9 +322,16 @@ function ListItemImpl({
|
|
|
322
322
|
const renderSupportContent = () => {
|
|
323
323
|
if (processedSupportSlot) return processedSupportSlot
|
|
324
324
|
|
|
325
|
-
// Default support text
|
|
326
|
-
//
|
|
327
|
-
//
|
|
325
|
+
// Default support text — ONE `<Text>` hard-clamped to 2 lines via
|
|
326
|
+
// `numberOfLines={2}`, the only mechanism React Native actually enforces
|
|
327
|
+
// on iOS/Android (native has no `white-space: nowrap` equivalent, so any
|
|
328
|
+
// style-based trick cannot stop a too-wide word from wrapping).
|
|
329
|
+
//
|
|
330
|
+
// - vertical layout: line-broken on spaces so each word gets its own line
|
|
331
|
+
// ("Split Payments" → "Split" / "Payments"). If a word is still wider
|
|
332
|
+
// than the cell (or there are 3+ words), RN cuts it off at line 2 with
|
|
333
|
+
// a trailing ellipsis instead of ever spilling onto a third line.
|
|
334
|
+
// - horizontal layout: natural wrapping, same 2-line clamp.
|
|
328
335
|
const displayText = layout === 'Vertical'
|
|
329
336
|
? supportText.replace(/ /g, '\n')
|
|
330
337
|
: supportText
|
|
@@ -336,7 +343,7 @@ function ListItemImpl({
|
|
|
336
343
|
? [tokens.supportTextStyle, verticalSupportTextOverride]
|
|
337
344
|
: tokens.supportTextStyle
|
|
338
345
|
}
|
|
339
|
-
{...resolveTruncation(disableTruncation,
|
|
346
|
+
{...resolveTruncation(disableTruncation, 2, 'tail')}
|
|
340
347
|
>
|
|
341
348
|
{displayText}
|
|
342
349
|
</Text>
|