jfs-components 0.0.78 → 0.0.84

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 (119) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +56 -6
  3. package/lib/commonjs/components/Attached/Attached.js +183 -0
  4. package/lib/commonjs/components/Card/Card.js +25 -2
  5. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  6. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  8. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  9. package/lib/commonjs/components/FormField/FormField.js +1 -14
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +9 -7
  11. package/lib/commonjs/components/ListItem/ListItem.js +26 -24
  12. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  13. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  14. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +237 -0
  15. package/lib/commonjs/components/Slot/Slot.js +73 -0
  16. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  17. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  18. package/lib/commonjs/components/index.js +28 -0
  19. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  20. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  21. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  22. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  23. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  24. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  25. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  26. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  27. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  28. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  29. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  30. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  31. package/lib/commonjs/icons/components/index.js +132 -0
  32. package/lib/commonjs/icons/registry.js +2 -2
  33. package/lib/module/components/AppBar/AppBar.js +56 -6
  34. package/lib/module/components/Attached/Attached.js +178 -0
  35. package/lib/module/components/Card/Card.js +25 -2
  36. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  37. package/lib/module/components/Drawer/Drawer.js +6 -1
  38. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  39. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  40. package/lib/module/components/FormField/FormField.js +3 -16
  41. package/lib/module/components/FullscreenModal/FullscreenModal.js +9 -7
  42. package/lib/module/components/ListItem/ListItem.js +26 -24
  43. package/lib/module/components/MessageField/MessageField.js +3 -15
  44. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  45. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +234 -0
  46. package/lib/module/components/Slot/Slot.js +68 -0
  47. package/lib/module/components/Spinner/Spinner.js +212 -0
  48. package/lib/module/components/TextInput/TextInput.js +34 -19
  49. package/lib/module/components/index.js +4 -0
  50. package/lib/module/icons/components/IconArrowdown.js +12 -0
  51. package/lib/module/icons/components/IconArrowup.js +12 -0
  52. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  53. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  54. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  55. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  56. package/lib/module/icons/components/IconOsnavback.js +12 -0
  57. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  58. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  59. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  60. package/lib/module/icons/components/IconSignin.js +12 -0
  61. package/lib/module/icons/components/IconSignout.js +12 -0
  62. package/lib/module/icons/components/index.js +12 -0
  63. package/lib/module/icons/registry.js +2 -2
  64. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  65. package/lib/typescript/src/components/Attached/Attached.d.ts +64 -0
  66. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  67. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  68. package/lib/typescript/src/components/ListItem/ListItem.d.ts +16 -6
  69. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  70. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +66 -0
  71. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  72. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  73. package/lib/typescript/src/components/index.d.ts +4 -0
  74. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  75. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  76. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  77. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  78. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  79. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  80. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  81. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  82. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  83. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  84. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  85. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  86. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  87. package/lib/typescript/src/icons/registry.d.ts +1 -1
  88. package/package.json +3 -2
  89. package/src/components/AppBar/AppBar.tsx +79 -12
  90. package/src/components/Attached/Attached.tsx +237 -0
  91. package/src/components/Card/Card.tsx +28 -1
  92. package/src/components/Checkbox/Checkbox.tsx +14 -2
  93. package/src/components/Drawer/Drawer.tsx +4 -0
  94. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  95. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  96. package/src/components/FormField/FormField.tsx +3 -19
  97. package/src/components/FullscreenModal/FullscreenModal.tsx +6 -3
  98. package/src/components/ListItem/ListItem.tsx +42 -25
  99. package/src/components/MessageField/MessageField.tsx +3 -18
  100. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  101. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +316 -0
  102. package/src/components/Slot/Slot.tsx +91 -0
  103. package/src/components/Spinner/Spinner.tsx +273 -0
  104. package/src/components/TextInput/TextInput.tsx +37 -19
  105. package/src/components/index.ts +4 -0
  106. package/src/icons/components/IconArrowdown.tsx +11 -0
  107. package/src/icons/components/IconArrowup.tsx +11 -0
  108. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  109. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  110. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  111. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  112. package/src/icons/components/IconOsnavback.tsx +11 -0
  113. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  114. package/src/icons/components/IconOsnavhome.tsx +11 -0
  115. package/src/icons/components/IconOsnavtask.tsx +11 -0
  116. package/src/icons/components/IconSignin.tsx +11 -0
  117. package/src/icons/components/IconSignout.tsx +11 -0
  118. package/src/icons/components/index.ts +12 -0
  119. package/src/icons/registry.ts +49 -1
@@ -0,0 +1,316 @@
1
+ import React from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ Pressable,
6
+ Platform,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ type TextStyle,
10
+ } from 'react-native';
11
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
12
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
13
+ import Icon from '../../icons/Icon';
14
+
15
+ /** Figma grid: label column 1.8fr, each plan column 1fr. */
16
+ const LABEL_COLUMN_FR = 1.8;
17
+ const PLAN_COLUMN_FR = 1;
18
+
19
+ /**
20
+ * A single plan column header (the label column has no header of its own).
21
+ */
22
+ export type PlanComparisonColumn = {
23
+ /** Header text for the plan column. */
24
+ label: string;
25
+ /**
26
+ * Render the header in the brand accent colour (gold) — use it to
27
+ * highlight the recommended / upsell plan.
28
+ * @default false
29
+ */
30
+ brand?: boolean;
31
+ };
32
+
33
+ /**
34
+ * Value rendered inside a plan cell.
35
+ * - `string` / `number` → rendered as value text.
36
+ * - `false` → renders the muted "not available" cross icon.
37
+ * - any React node → rendered as-is (e.g. a `Badge`, `MoneyValue`, icon…).
38
+ * - `null` / `undefined` / `true` → empty cell.
39
+ */
40
+ export type PlanComparisonCellValue =
41
+ | string
42
+ | number
43
+ | boolean
44
+ | null
45
+ | undefined
46
+ | React.ReactElement;
47
+
48
+ export type PlanComparisonRow = {
49
+ /** Feature label shown in the first (left) column. */
50
+ label: string;
51
+ /**
52
+ * Show an info icon after the label. When `onInfoPress` is provided the
53
+ * icon becomes tappable; otherwise it is purely decorative.
54
+ */
55
+ showInfo?: boolean;
56
+ /** Handler for the info icon. Implies `showInfo`. */
57
+ onInfoPress?: () => void;
58
+ /**
59
+ * One value per plan column, in the same order as `columns`. See
60
+ * {@link PlanComparisonCellValue} for how each value is rendered.
61
+ */
62
+ values: PlanComparisonCellValue[];
63
+ /** Stable key. Falls back to the label / index. */
64
+ key?: React.Key;
65
+ };
66
+
67
+ export type PlanComparisonCardProps = {
68
+ /**
69
+ * Plan column headers (excludes the leading label column). The order here
70
+ * maps 1:1 to each row's `values` array.
71
+ */
72
+ columns?: PlanComparisonColumn[];
73
+ /** Feature rows compared across the plan columns. */
74
+ rows?: PlanComparisonRow[];
75
+ /** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
76
+ modes?: Record<string, any>;
77
+ /** Override the outer container style. */
78
+ style?: StyleProp<ViewStyle>;
79
+ };
80
+
81
+ const DEFAULT_COLUMNS: PlanComparisonColumn[] = [
82
+ { label: 'Your plan' },
83
+ { label: 'JioFinance+', brand: true },
84
+ ];
85
+
86
+ const DEFAULT_ROWS: PlanComparisonRow[] = [
87
+ { label: 'JioPoints multiplier', values: ['1x', '1.25x'] },
88
+ { label: 'Cashback', showInfo: true, values: [false, 'Upto ₹5000'] },
89
+ { label: 'Bonus JioGold', showInfo: true, values: [false, '1%'] },
90
+ ];
91
+
92
+ /** Keeps every text layer on a single line. */
93
+ const NO_WRAP_TEXT: TextStyle = {
94
+ flexShrink: 0,
95
+ ...(Platform.OS === 'web' ? { whiteSpace: 'nowrap' as const } : {}),
96
+ };
97
+
98
+ const labelColumnStyle: ViewStyle = {
99
+ flex: LABEL_COLUMN_FR,
100
+ minWidth: 0,
101
+ };
102
+
103
+ const planColumnStyle: ViewStyle = {
104
+ flex: PLAN_COLUMN_FR,
105
+ minWidth: 0,
106
+ alignItems: 'center',
107
+ };
108
+
109
+ /**
110
+ * PlanComparisonCard renders a compact comparison table that pits the user's
111
+ * current plan against one or more alternative plans across a set of feature
112
+ * rows. Implementation of Figma node `4498:2968` (`PlanComparisonCard`).
113
+ *
114
+ * Columns use a 1.8fr / 1fr flex ratio (label vs plan), matching the Figma grid.
115
+ *
116
+ * @component
117
+ */
118
+ function PlanComparisonCard({
119
+ columns = DEFAULT_COLUMNS,
120
+ rows = DEFAULT_ROWS,
121
+ modes = EMPTY_MODES,
122
+ style,
123
+ }: PlanComparisonCardProps) {
124
+ const gap = (getVariableByName('planComparisonCard/gap', modes) as number) ?? 16;
125
+
126
+ const headerFg = (getVariableByName('planComparisonCard/header/fg', modes) as string) ?? '#ffffff';
127
+ const headerBrandFg = (getVariableByName('planComparisonCard/header/brand/fg', modes) as string) ?? '#cea15a';
128
+ const headerFontSize = (getVariableByName('planComparisonCard/header/fontSize', modes) as number) ?? 14;
129
+ const headerFontFamily = (getVariableByName('planComparisonCard/header/fontFamily', modes) as string) ?? 'JioType Var';
130
+ const headerLineHeight = (getVariableByName('planComparisonCard/header/lineHeight', modes) as number) ?? 18;
131
+ const headerFontWeight = (getVariableByName('planComparisonCard/header/fontWeight', modes) as number | string) ?? '500';
132
+
133
+ const tableBackground = (getVariableByName('planComparisonCard/tableRow/background', modes) as string) ?? '#141414';
134
+ const tableRadius = (getVariableByName('planComparisonCard/tableRow/radius', modes) as number) ?? 16;
135
+ const tableBorderSize = (getVariableByName('planComparisonCard/tableRow/border/size', modes) as number) ?? 1;
136
+ const tableBorderColor = (getVariableByName('planComparisonCard/tableRow/border/color', modes) as string) ?? '#1e1a14';
137
+
138
+ const cellPadding = (getVariableByName('planComparisonCard/tableCell/padding', modes) as number) ?? 12;
139
+ const cellGap = (getVariableByName('planComparisonCard/tableCell/gap', modes) as number) ?? 2;
140
+ const cellMinHeight = (getVariableByName('planComparisonCard/tableCell/height', modes) as number) ?? 46;
141
+ const cellBorderSize = (getVariableByName('planComparisonCard/tableCell/border/size', modes) as number) ?? 1;
142
+ const cellBorderColor = (getVariableByName('planComparisonCard/tableCell/border/color', modes) as string) ?? '#1e1a14';
143
+
144
+ const labelColor = (getVariableByName('planComparisonCard/tableCell/label/color', modes) as string) ?? '#ffffff';
145
+ const labelDisabledColor = (getVariableByName('planComparisonCard/tableCell/label/disabled/color', modes) as string) ?? '#91949c';
146
+ const labelFontSize = (getVariableByName('planComparisonCard/tableCell/label/fontSize', modes) as number) ?? 12;
147
+ const labelFontFamily = (getVariableByName('planComparisonCard/tableCell/label/fontFamily', modes) as string) ?? 'JioType Var';
148
+ const labelLineHeight = (getVariableByName('planComparisonCard/tableCell/label/lineHeight', modes) as number) ?? 16;
149
+ const labelFontWeight = (getVariableByName('planComparisonCard/tableCell/label/fontWeight', modes) as number | string) ?? '400';
150
+
151
+ const valueColor = (getVariableByName('planComparisonCard/tableCell/value/color', modes) as string) ?? '#ffffff';
152
+ const valueFontSize = (getVariableByName('planComparisonCard/tableCell/value/fontSize', modes) as number) ?? 12;
153
+ const valueFontFamily = (getVariableByName('planComparisonCard/tableCell/value/fontFamily', modes) as string) ?? 'JioType Var';
154
+ const valueLineHeight = (getVariableByName('planComparisonCard/tableCell/value/lineHeight', modes) as number) ?? 16;
155
+ const valueFontWeight = (getVariableByName('planComparisonCard/tableCell/value/fontWeight', modes) as number | string) ?? '500';
156
+
157
+ const iconColor = (getVariableByName('planComparisonCard/icon/color', modes) as string) ?? '#ffffff';
158
+ const iconSize = (getVariableByName('planComparisonCard/icon/size', modes) as number) ?? 16;
159
+
160
+ const toWeight = (w: number | string) => (typeof w === 'number' ? `${w}` : w) as TextStyle['fontWeight'];
161
+
162
+ const headerTextStyle: TextStyle = {
163
+ ...NO_WRAP_TEXT,
164
+ fontFamily: headerFontFamily,
165
+ fontSize: headerFontSize,
166
+ lineHeight: headerLineHeight,
167
+ fontWeight: toWeight(headerFontWeight),
168
+ textAlign: 'center',
169
+ };
170
+
171
+ const labelTextStyle: TextStyle = {
172
+ ...NO_WRAP_TEXT,
173
+ color: labelColor,
174
+ fontFamily: labelFontFamily,
175
+ fontSize: labelFontSize,
176
+ lineHeight: labelLineHeight,
177
+ fontWeight: toWeight(labelFontWeight),
178
+ };
179
+
180
+ const valueTextStyle: TextStyle = {
181
+ ...NO_WRAP_TEXT,
182
+ color: valueColor,
183
+ fontFamily: valueFontFamily,
184
+ fontSize: valueFontSize,
185
+ lineHeight: valueLineHeight,
186
+ fontWeight: toWeight(valueFontWeight),
187
+ textAlign: 'center',
188
+ };
189
+
190
+ const rowStyle: ViewStyle = {
191
+ flexDirection: 'row',
192
+ width: '100%',
193
+ };
194
+
195
+ const labelCellStyle: ViewStyle = {
196
+ flexDirection: 'row',
197
+ alignItems: 'center',
198
+ gap: cellGap,
199
+ padding: cellPadding,
200
+ minHeight: cellMinHeight,
201
+ };
202
+
203
+ const valueCellStyle: ViewStyle = {
204
+ flexDirection: 'row',
205
+ alignItems: 'center',
206
+ justifyContent: 'center',
207
+ padding: cellPadding,
208
+ minHeight: cellMinHeight,
209
+ width: '100%',
210
+ };
211
+
212
+ const renderValue = (value: PlanComparisonCellValue, cellKey: React.Key) => {
213
+ if (value === false) {
214
+ return (
215
+ <Icon
216
+ key={cellKey}
217
+ name="ic_close"
218
+ size={iconSize}
219
+ color={labelDisabledColor}
220
+ />
221
+ );
222
+ }
223
+ if (value === null || value === undefined || value === true) {
224
+ return null;
225
+ }
226
+ if (typeof value === 'string' || typeof value === 'number') {
227
+ return (
228
+ <Text key={cellKey} style={valueTextStyle}>
229
+ {value}
230
+ </Text>
231
+ );
232
+ }
233
+ return cloneChildrenWithModes(value, modes);
234
+ };
235
+
236
+ return (
237
+ <View style={[{ gap, width: '100%' }, style]}>
238
+ {/* Header row — same 1.8fr / 1fr grid as the table */}
239
+ <View style={rowStyle}>
240
+ <View style={labelColumnStyle} />
241
+ {columns.map((column, index) => (
242
+ <View key={column.label ?? index} style={planColumnStyle}>
243
+ <Text
244
+ style={[
245
+ headerTextStyle,
246
+ { color: column.brand ? headerBrandFg : headerFg },
247
+ ]}
248
+ >
249
+ {column.label}
250
+ </Text>
251
+ </View>
252
+ ))}
253
+ </View>
254
+
255
+ {/* Table body */}
256
+ <View
257
+ style={{
258
+ width: '100%',
259
+ backgroundColor: tableBackground,
260
+ borderWidth: tableBorderSize,
261
+ borderColor: tableBorderColor,
262
+ borderRadius: tableRadius,
263
+ overflow: 'hidden',
264
+ }}
265
+ >
266
+ {rows.map((row, rowIndex) => {
267
+ const isLast = rowIndex === rows.length - 1;
268
+ const showInfo = row.showInfo || row.onInfoPress != null;
269
+ return (
270
+ <View
271
+ key={row.key ?? `${row.label}-${rowIndex}`}
272
+ style={[
273
+ rowStyle,
274
+ {
275
+ borderBottomWidth: isLast ? 0 : cellBorderSize,
276
+ borderBottomColor: cellBorderColor,
277
+ },
278
+ ]}
279
+ >
280
+ <View style={[labelColumnStyle, labelCellStyle]}>
281
+ <Text style={labelTextStyle}>{row.label}</Text>
282
+ {showInfo &&
283
+ (row.onInfoPress ? (
284
+ <Pressable
285
+ onPress={row.onInfoPress}
286
+ accessibilityRole="button"
287
+ accessibilityLabel={`More information about ${row.label}`}
288
+ hitSlop={8}
289
+ >
290
+ <Icon name="ic_info" size={iconSize} color={iconColor} />
291
+ </Pressable>
292
+ ) : (
293
+ <Icon name="ic_info" size={iconSize} color={iconColor} />
294
+ ))}
295
+ </View>
296
+
297
+ {columns.map((column, colIndex) => (
298
+ <View
299
+ key={column.label ?? colIndex}
300
+ style={[planColumnStyle, valueCellStyle]}
301
+ >
302
+ {renderValue(
303
+ row.values?.[colIndex],
304
+ `${rowIndex}-${colIndex}`,
305
+ )}
306
+ </View>
307
+ ))}
308
+ </View>
309
+ );
310
+ })}
311
+ </View>
312
+ </View>
313
+ );
314
+ }
315
+
316
+ export default PlanComparisonCard;
@@ -0,0 +1,91 @@
1
+ import React, { useMemo } from 'react'
2
+ import { View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
3
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
+ import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
6
+
7
+ export type SlotLayoutDirection = 'vertical' | 'horizontal'
8
+
9
+ export type SlotProps = ViewProps & {
10
+ /**
11
+ * Content laid out inside the slot. `modes` are cascaded to every child via
12
+ * {@link cloneChildrenWithModes}.
13
+ */
14
+ children?: React.ReactNode
15
+ /**
16
+ * Main-axis direction for slot children. Matches the Figma Slot variant:
17
+ * - `vertical` (default): stacks children in a column
18
+ * - `horizontal`: arranges children in a row
19
+ */
20
+ layoutDirection?: SlotLayoutDirection
21
+ /**
22
+ * Alignment along the cross axis.
23
+ * Defaults to `stretch` for vertical and `flex-start` for horizontal.
24
+ */
25
+ alignCrossAxis?: ViewStyle['alignItems']
26
+ /**
27
+ * Distribution along the main axis (maps to `justifyContent`).
28
+ */
29
+ justifyMainAxis?: ViewStyle['justifyContent']
30
+ /**
31
+ * Mode configuration passed to the token resolver and cascaded to children.
32
+ */
33
+ modes?: Record<string, any>
34
+ style?: StyleProp<ViewStyle>
35
+ }
36
+
37
+ /**
38
+ * Slot — a token-driven layout container for grouped slot content.
39
+ *
40
+ * Use `Slot` instead of a raw `View` when you need a vertical or horizontal
41
+ * stack with design-token gap spacing and automatic `modes` propagation to
42
+ * children. Typical usage is nesting a column of actions inside a
43
+ * direction-locked parent such as `ActionFooter`:
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * <ActionFooter modes={modes}>
48
+ * <Slot layoutDirection="vertical" modes={modes}>
49
+ * <Button label="Continue" modes={primaryModes} />
50
+ * <Disclaimer disclaimer="Terms apply." modes={modes} />
51
+ * </Slot>
52
+ * </ActionFooter>
53
+ * ```
54
+ */
55
+ function Slot({
56
+ children,
57
+ layoutDirection = 'vertical',
58
+ alignCrossAxis,
59
+ justifyMainAxis,
60
+ modes: propModes = EMPTY_MODES,
61
+ style,
62
+ ...rest
63
+ }: SlotProps) {
64
+ const { modes: globalModes } = useTokens()
65
+ const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
66
+
67
+ const { containerStyle, processedChildren } = useMemo(() => {
68
+ const gap = (getVariableByName('slot/gap', modes) ?? 8) as number
69
+ const isHorizontal = layoutDirection === 'horizontal'
70
+
71
+ const container: ViewStyle = {
72
+ flexDirection: isHorizontal ? 'row' : 'column',
73
+ alignItems: alignCrossAxis ?? (isHorizontal ? 'flex-start' : 'stretch'),
74
+ justifyContent: justifyMainAxis ?? (isHorizontal ? 'center' : undefined),
75
+ alignSelf: 'stretch',
76
+ gap,
77
+ }
78
+
79
+ const processed = children ? cloneChildrenWithModes(children, modes) : null
80
+
81
+ return { containerStyle: container, processedChildren: processed }
82
+ }, [children, modes, layoutDirection, alignCrossAxis, justifyMainAxis])
83
+
84
+ return (
85
+ <View style={[containerStyle, style]} {...rest}>
86
+ {processedChildren}
87
+ </View>
88
+ )
89
+ }
90
+
91
+ export default React.memo(Slot)
@@ -0,0 +1,273 @@
1
+ import React, { useEffect } from 'react'
2
+ import { StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
3
+ import Animated, {
4
+ Easing,
5
+ cancelAnimation,
6
+ useAnimatedStyle,
7
+ useSharedValue,
8
+ withRepeat,
9
+ withTiming,
10
+ type SharedValue,
11
+ } from 'react-native-reanimated'
12
+ import Svg, { Path } from 'react-native-svg'
13
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
14
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
15
+ import { EMPTY_MODES } from '../../utils/react-utils'
16
+ import { useReducedMotion } from '../../skeleton/useReducedMotion'
17
+
18
+ /**
19
+ * Per-segment colours, resolved from the Figma `spiner/*` tokens. Consumers can
20
+ * override any subset via the `colors` prop.
21
+ */
22
+ export type SpinnerColors = {
23
+ /** Leading segment (front of the falling chain). */
24
+ primary?: string
25
+ /** Middle segment. */
26
+ secondary?: string
27
+ /** Trailing segment (tail of the chain). */
28
+ tertiary?: string
29
+ }
30
+
31
+ type SpinnerBaseProps = Omit<ViewProps, 'children' | 'style'>
32
+
33
+ export type SpinnerProps = SpinnerBaseProps & {
34
+ /**
35
+ * Diameter in px. The spinner is always rendered at a 1:1 ratio, so a single
36
+ * size controls both width and height. Defaults to the Figma size (72).
37
+ */
38
+ size?: number
39
+ /**
40
+ * Duration of one full clockwise revolution of the leading segment, in ms.
41
+ * Lower = faster. Defaults to 2400.
42
+ */
43
+ durationMs?: number
44
+ /**
45
+ * "Weightiness" of the fall, in `[0, 0.9]`. 0 = perfectly constant speed;
46
+ * higher values make segments whip faster over the top and ease through the
47
+ * bottom. Kept below 1 so the motion never reverses. Defaults to 0.45.
48
+ */
49
+ gravity?: number
50
+ /** Override any subset of the token-driven segment colours. */
51
+ colors?: SpinnerColors
52
+ /** When false, renders a static resting spinner (also honoured for reduced motion). Defaults to true. */
53
+ animating?: boolean
54
+ /** Design token modes forwarded to token lookups. */
55
+ modes?: Record<string, any>
56
+ /** Container style override. */
57
+ style?: StyleProp<ViewStyle>
58
+ /** Accessibility label announced to assistive tech. Defaults to "Loading". */
59
+ accessibilityLabel?: string
60
+ }
61
+
62
+ const SEGMENT_COUNT = 3
63
+ const DEFAULT_SIZE = 72
64
+ const DEFAULT_DURATION_MS = 1500
65
+ const DEFAULT_GRAVITY = 0.45
66
+
67
+ // Stroke thickness as a fraction of the diameter (matches the Figma ring weight).
68
+ const STROKE_RATIO = 0.11
69
+ // Angular length of each individual segment.
70
+ const ARC_LENGTH_DEG = 100
71
+ // Spacing between consecutive heads when fully bunched at the top. Small but
72
+ // non-zero so all three colours stay faintly visible as they crest the top.
73
+ const SPREAD_MIN_DEG = 10
74
+ // Spacing between consecutive heads at full spread. At this extent each segment's
75
+ // tail only overlaps the next head by `ARC_LENGTH_DEG - SPREAD_MAX_DEG` (16°) —
76
+ // the maximum extension the chain reaches while staying connected (never a gap).
77
+ const SPREAD_MAX_DEG = 84
78
+ // Fraction of each revolution spent gradually fanning *out* (the rest is spent
79
+ // snapping back together over the top).
80
+ //
81
+ // This is the knob that balances "reaches full extension" against "never stalls
82
+ // and never recoils". The tail segment's velocity while spreading is
83
+ // `vLead * (1 - spreadRange / (SPREAD_OUT_FRAC * π))`. Spreading the fan-out over
84
+ // ~3/4 of the turn keeps that factor around ~0.45 (so the tail always carries
85
+ // clear forward momentum — it never crawls to a stall, and never reverses),
86
+ // while still letting the breath reach a full 1.0. The remaining ~1/4 is an
87
+ // energetic gather over the top where the trailing segments whip forward to
88
+ // rejoin the lead. A symmetric (sinusoidal/triangle) breath cannot do all three:
89
+ // reach full extension, avoid recoil, and avoid a sustained stall.
90
+ const SPREAD_OUT_FRAC = 0.75
91
+
92
+ const DEG_TO_RAD = Math.PI / 180
93
+ const TWO_PI = Math.PI * 2
94
+
95
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
96
+
97
+ const toNumber = (value: unknown, fallback: number) => {
98
+ if (typeof value === 'number' && Number.isFinite(value)) {
99
+ return value
100
+ }
101
+ if (typeof value === 'string') {
102
+ const parsed = Number(value)
103
+ if (Number.isFinite(parsed)) {
104
+ return parsed
105
+ }
106
+ }
107
+ return fallback
108
+ }
109
+
110
+ /**
111
+ * Builds the SVG path for a single fixed-length arc whose *head* sits at the
112
+ * top (12 o'clock) and whose body trails counter-clockwise behind it. Rotating
113
+ * the containing view clockwise then places the head at the desired angle.
114
+ */
115
+ const buildArcPath = (center: number, radius: number, arcLengthDeg: number) => {
116
+ const arc = arcLengthDeg * DEG_TO_RAD
117
+ // Head at the top: phi = 0 -> (center, center - radius).
118
+ const headX = center
119
+ const headY = center - radius
120
+ // Tail trails counter-clockwise by `arc`: phi = -arc.
121
+ const tailX = center + radius * Math.sin(-arc)
122
+ const tailY = center - radius * Math.cos(-arc)
123
+ const largeArc = arcLengthDeg > 180 ? 1 : 0
124
+ // Sweep from tail -> head is clockwise (sweep flag = 1 in SVG y-down space).
125
+ return `M ${tailX} ${tailY} A ${radius} ${radius} 0 ${largeArc} 1 ${headX} ${headY}`
126
+ }
127
+
128
+ /**
129
+ * Animated rotation for one segment.
130
+ *
131
+ * A single linear clock drives a gravity-warped lead angle: it advances faster
132
+ * over the top and slower through the bottom, giving the fall its weight. Each
133
+ * segment trails the lead by `index * offset`, where `offset` breathes between
134
+ * its bunched (top) and spread (bottom) extents in lock-step with the lead's
135
+ * vertical position. Because the offset is bounded by `SPREAD_MAX_DEG`, the
136
+ * three segments form a continuously-overlapping chain that gathers at the top
137
+ * and fans out — fully connected — through the free fall.
138
+ */
139
+ const useSegmentRotation = (
140
+ clock: SharedValue<number>,
141
+ index: number,
142
+ gravity: number,
143
+ spreadMinRad: number,
144
+ spreadMaxRad: number,
145
+ spreadOutFrac: number,
146
+ ) =>
147
+ useAnimatedStyle(() => {
148
+ 'worklet'
149
+ const tau = clock.value * TWO_PI
150
+ // Lead angle (clockwise from top). d(lead)/dtau = 1 + gravity*cos(tau) is
151
+ // maximal at the top (tau = 0) and minimal at the bottom (tau = PI), giving
152
+ // the fall its weight.
153
+ const lead = tau + gravity * Math.sin(tau)
154
+ // Breathing is an asymmetric saw in the lead angle: it ramps *gradually* from
155
+ // 0 (bunched, top) up to 1 (fully spread) over `spreadOutFrac` of the turn,
156
+ // then drops back to 0 over the remaining arc (the quick gather over the top).
157
+ // The gentle fan-out slope keeps the trailing segment moving forward at a
158
+ // healthy fraction of the lead's speed — it never stalls and never recoils —
159
+ // while still reaching full extension; the steeper gather is a forward whip,
160
+ // so momentum only ever increases there.
161
+ const leadMod = lead - TWO_PI * Math.floor(lead / TWO_PI)
162
+ const splitLead = spreadOutFrac * TWO_PI
163
+ const breath =
164
+ leadMod < splitLead
165
+ ? leadMod / splitLead
166
+ : (TWO_PI - leadMod) / (TWO_PI - splitLead)
167
+ const offset = spreadMinRad + breath * (spreadMaxRad - spreadMinRad)
168
+ const head = lead - index * offset
169
+ return {
170
+ transform: [{ rotate: `${(head * 180) / Math.PI}deg` }],
171
+ }
172
+ }, [gravity, index, spreadMinRad, spreadMaxRad, spreadOutFrac])
173
+
174
+ const fullSize: ViewStyle = { ...StyleSheet.absoluteFillObject }
175
+
176
+ function Spinner({
177
+ size = DEFAULT_SIZE,
178
+ durationMs = DEFAULT_DURATION_MS,
179
+ gravity = DEFAULT_GRAVITY,
180
+ colors,
181
+ animating = true,
182
+ modes: propModes = EMPTY_MODES,
183
+ style,
184
+ accessibilityLabel = 'Loading',
185
+ ...rest
186
+ }: SpinnerProps) {
187
+ const { modes: globalModes } = useTokens()
188
+ const modes = { ...globalModes, ...propModes }
189
+
190
+ const systemReducedMotion = useReducedMotion()
191
+ const isAnimated = animating && !systemReducedMotion
192
+
193
+ const resolvedSize = toNumber(size, DEFAULT_SIZE)
194
+ const safeGravity = clamp(toNumber(gravity, DEFAULT_GRAVITY), 0, 0.9)
195
+ const strokeWidth = Math.max(1, resolvedSize * STROKE_RATIO)
196
+ const radius = Math.max(0, (resolvedSize - strokeWidth) / 2)
197
+ const center = resolvedSize / 2
198
+ const arcPath = buildArcPath(center, radius, ARC_LENGTH_DEG)
199
+
200
+ const segmentColors = [
201
+ colors?.primary ?? (getVariableByName('spiner/primary/bg', modes) as string) ?? '#d0a259',
202
+ colors?.secondary ?? (getVariableByName('spiner/secondary/bg', modes) as string) ?? '#5b00b5',
203
+ colors?.tertiary ?? (getVariableByName('spiner/tertiary/bg', modes) as string) ?? '#066b99',
204
+ ]
205
+
206
+ const clock = useSharedValue(0)
207
+
208
+ useEffect(() => {
209
+ if (!isAnimated) {
210
+ cancelAnimation(clock)
211
+ clock.value = 0
212
+ return
213
+ }
214
+ clock.value = 0
215
+ clock.value = withRepeat(
216
+ withTiming(1, { duration: Math.max(1, durationMs), easing: Easing.linear }),
217
+ -1,
218
+ false,
219
+ )
220
+ return () => {
221
+ cancelAnimation(clock)
222
+ }
223
+ }, [isAnimated, durationMs, clock])
224
+
225
+ // Hooks must run unconditionally and in a stable order, so all three segment
226
+ // styles are always computed even when the spinner renders statically.
227
+ const spreadMinRad = SPREAD_MIN_DEG * DEG_TO_RAD
228
+ const spreadMaxRad = SPREAD_MAX_DEG * DEG_TO_RAD
229
+ const style0 = useSegmentRotation(clock, 0, safeGravity, spreadMinRad, spreadMaxRad, SPREAD_OUT_FRAC)
230
+ const style1 = useSegmentRotation(clock, 1, safeGravity, spreadMinRad, spreadMaxRad, SPREAD_OUT_FRAC)
231
+ const style2 = useSegmentRotation(clock, 2, safeGravity, spreadMinRad, spreadMaxRad, SPREAD_OUT_FRAC)
232
+ const animatedStyles = [style0, style1, style2]
233
+
234
+ // Static resting fan (evenly spaced) used when animation is disabled.
235
+ const restingRotations = [0, -120, -240]
236
+
237
+ const containerStyle: ViewStyle = {
238
+ height: resolvedSize,
239
+ width: resolvedSize,
240
+ position: 'relative',
241
+ }
242
+
243
+ return (
244
+ <View
245
+ accessibilityRole="progressbar"
246
+ accessibilityLabel={accessibilityLabel}
247
+ style={[containerStyle, style]}
248
+ {...rest}
249
+ >
250
+ {/* Render tail -> head so the leading segment overlaps on top. */}
251
+ {Array.from({ length: SEGMENT_COUNT }, (_, i) => SEGMENT_COUNT - 1 - i).map((segmentIndex) => {
252
+ const segmentStyle = isAnimated
253
+ ? animatedStyles[segmentIndex]
254
+ : { transform: [{ rotate: `${restingRotations[segmentIndex]}deg` }] }
255
+ return (
256
+ <Animated.View key={segmentIndex} style={[fullSize, segmentStyle]} pointerEvents="none">
257
+ <Svg width={resolvedSize} height={resolvedSize} viewBox={`0 0 ${resolvedSize} ${resolvedSize}`}>
258
+ <Path
259
+ d={arcPath}
260
+ stroke={segmentColors[segmentIndex]}
261
+ strokeWidth={strokeWidth}
262
+ strokeLinecap="round"
263
+ fill="none"
264
+ />
265
+ </Svg>
266
+ </Animated.View>
267
+ )
268
+ })}
269
+ </View>
270
+ )
271
+ }
272
+
273
+ export default Spinner