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
@@ -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)