jfs-components 0.0.79 → 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 (110) hide show
  1. package/lib/commonjs/components/AppBar/AppBar.js +56 -6
  2. package/lib/commonjs/components/Attached/Attached.js +46 -7
  3. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  4. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  5. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  6. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  7. package/lib/commonjs/components/FormField/FormField.js +1 -14
  8. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
  9. package/lib/commonjs/components/ListItem/ListItem.js +6 -11
  10. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  11. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  12. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
  13. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  14. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  15. package/lib/commonjs/components/index.js +7 -0
  16. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  17. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  18. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  19. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  20. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  21. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  22. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  23. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  24. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  25. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  26. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  27. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  28. package/lib/commonjs/icons/components/index.js +132 -0
  29. package/lib/commonjs/icons/registry.js +2 -2
  30. package/lib/module/components/AppBar/AppBar.js +56 -6
  31. package/lib/module/components/Attached/Attached.js +46 -7
  32. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  33. package/lib/module/components/Drawer/Drawer.js +6 -1
  34. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  35. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  36. package/lib/module/components/FormField/FormField.js +3 -16
  37. package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
  38. package/lib/module/components/ListItem/ListItem.js +6 -11
  39. package/lib/module/components/MessageField/MessageField.js +3 -15
  40. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  41. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
  42. package/lib/module/components/Spinner/Spinner.js +212 -0
  43. package/lib/module/components/TextInput/TextInput.js +34 -19
  44. package/lib/module/components/index.js +1 -0
  45. package/lib/module/icons/components/IconArrowdown.js +12 -0
  46. package/lib/module/icons/components/IconArrowup.js +12 -0
  47. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  48. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  49. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  50. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  51. package/lib/module/icons/components/IconOsnavback.js +12 -0
  52. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  53. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  54. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  55. package/lib/module/icons/components/IconSignin.js +12 -0
  56. package/lib/module/icons/components/IconSignout.js +12 -0
  57. package/lib/module/icons/components/index.js +12 -0
  58. package/lib/module/icons/registry.js +2 -2
  59. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  60. package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
  61. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  62. package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
  63. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  64. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
  65. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  66. package/lib/typescript/src/components/index.d.ts +1 -0
  67. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  68. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  69. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  70. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  71. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  72. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  73. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  74. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  75. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  76. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  77. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  78. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  79. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  80. package/lib/typescript/src/icons/registry.d.ts +1 -1
  81. package/package.json +3 -2
  82. package/src/components/AppBar/AppBar.tsx +79 -12
  83. package/src/components/Attached/Attached.tsx +63 -7
  84. package/src/components/Checkbox/Checkbox.tsx +14 -2
  85. package/src/components/Drawer/Drawer.tsx +4 -0
  86. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  87. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  88. package/src/components/FormField/FormField.tsx +3 -19
  89. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
  90. package/src/components/ListItem/ListItem.tsx +14 -16
  91. package/src/components/MessageField/MessageField.tsx +3 -18
  92. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  93. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
  94. package/src/components/Spinner/Spinner.tsx +273 -0
  95. package/src/components/TextInput/TextInput.tsx +37 -19
  96. package/src/components/index.ts +1 -0
  97. package/src/icons/components/IconArrowdown.tsx +11 -0
  98. package/src/icons/components/IconArrowup.tsx +11 -0
  99. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  100. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  101. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  102. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  103. package/src/icons/components/IconOsnavback.tsx +11 -0
  104. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  105. package/src/icons/components/IconOsnavhome.tsx +11 -0
  106. package/src/icons/components/IconOsnavtask.tsx +11 -0
  107. package/src/icons/components/IconSignin.tsx +11 -0
  108. package/src/icons/components/IconSignout.tsx +11 -0
  109. package/src/icons/components/index.ts +12 -0
  110. package/src/icons/registry.ts +49 -1
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback } from 'react';
1
+ import React from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -7,12 +7,15 @@ import {
7
7
  type StyleProp,
8
8
  type ViewStyle,
9
9
  type TextStyle,
10
- type LayoutChangeEvent,
11
10
  } from 'react-native';
12
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
13
12
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
14
13
  import Icon from '../../icons/Icon';
15
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
+
16
19
  /**
17
20
  * A single plan column header (the label column has no header of its own).
18
21
  */
@@ -69,13 +72,6 @@ export type PlanComparisonCardProps = {
69
72
  columns?: PlanComparisonColumn[];
70
73
  /** Feature rows compared across the plan columns. */
71
74
  rows?: PlanComparisonRow[];
72
- /**
73
- * Minimum flex-grow on the label column when the table is given extra
74
- * horizontal space. Plan columns always size to their content and never
75
- * shrink below it.
76
- * @default 0
77
- */
78
- labelColumnFlex?: number;
79
75
  /** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
80
76
  modes?: Record<string, any>;
81
77
  /** Override the outer container style. */
@@ -93,92 +89,40 @@ const DEFAULT_ROWS: PlanComparisonRow[] = [
93
89
  { label: 'Bonus JioGold', showInfo: true, values: [false, '1%'] },
94
90
  ];
95
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
+
96
109
  /**
97
110
  * PlanComparisonCard renders a compact comparison table that pits the user's
98
111
  * current plan against one or more alternative plans across a set of feature
99
112
  * rows. Implementation of Figma node `4498:2968` (`PlanComparisonCard`).
100
113
  *
101
- * The leading column holds feature labels (with an optional info icon); every
102
- * other column maps to a plan in `columns`. Each cell value can be plain text,
103
- * a "not available" cross (`false`), or any custom React node.
114
+ * Columns use a 1.8fr / 1fr flex ratio (label vs plan), matching the Figma grid.
104
115
  *
105
116
  * @component
106
- * @example
107
- * ```tsx
108
- * <PlanComparisonCard
109
- * columns={[{ label: 'Your plan' }, { label: 'JioFinance+', brand: true }]}
110
- * rows={[
111
- * { label: 'JioPoints multiplier', values: ['1x', '1.25x'] },
112
- * { label: 'Cashback', showInfo: true, values: [false, 'Upto ₹5000'] },
113
- * ]}
114
- * />
115
- * ```
116
117
  */
117
- /** Keeps every text layer on a single line; columns grow to fit content. */
118
- const NO_WRAP_TEXT: TextStyle = {
119
- flexShrink: 0,
120
- ...(Platform.OS === 'web' ? { whiteSpace: 'nowrap' as const } : {}),
121
- };
122
-
123
118
  function PlanComparisonCard({
124
119
  columns = DEFAULT_COLUMNS,
125
120
  rows = DEFAULT_ROWS,
126
- labelColumnFlex = 0,
127
121
  modes = EMPTY_MODES,
128
122
  style,
129
123
  }: PlanComparisonCardProps) {
130
- /** Natural widths from header labels (plan columns only). */
131
- const [headerWidths, setHeaderWidths] = useState<(number | undefined)[]>([]);
132
- /** Natural widths from table body columns. */
133
- const [bodyWidths, setBodyWidths] = useState<(number | undefined)[]>([]);
134
-
135
- const setMeasuredWidth = useCallback(
136
- (
137
- setter: React.Dispatch<React.SetStateAction<(number | undefined)[]>>,
138
- index: number,
139
- width: number,
140
- ) => {
141
- setter((prev) => {
142
- if (prev[index] === width) return prev;
143
- const next = [...prev];
144
- next[index] = width;
145
- return next;
146
- });
147
- },
148
- [],
149
- );
150
-
151
- const onHeaderColumnLayout = useCallback(
152
- (index: number, event: LayoutChangeEvent) => {
153
- setMeasuredWidth(setHeaderWidths, index, event.nativeEvent.layout.width);
154
- },
155
- [setMeasuredWidth],
156
- );
157
-
158
- const onBodyColumnLayout = useCallback(
159
- (index: number, event: LayoutChangeEvent) => {
160
- setMeasuredWidth(setBodyWidths, index, event.nativeEvent.layout.width);
161
- },
162
- [setMeasuredWidth],
163
- );
164
-
165
- /**
166
- * Shared width for header + body cells in a column (max of natural header
167
- * label vs body content). No columnGap between columns — gaps would shift
168
- * headers relative to the flush table grid below.
169
- */
170
- const columnWidthStyle = (index: number): ViewStyle => {
171
- const width = Math.max(headerWidths[index] ?? 0, bodyWidths[index] ?? 0);
172
- if (width > 0) {
173
- return { width, minWidth: width, flexShrink: 0, flexGrow: 0 };
174
- }
175
- return { flexShrink: 0, flexGrow: 0 };
176
- };
177
-
178
- // Container
179
124
  const gap = (getVariableByName('planComparisonCard/gap', modes) as number) ?? 16;
180
125
 
181
- // Header
182
126
  const headerFg = (getVariableByName('planComparisonCard/header/fg', modes) as string) ?? '#ffffff';
183
127
  const headerBrandFg = (getVariableByName('planComparisonCard/header/brand/fg', modes) as string) ?? '#cea15a';
184
128
  const headerFontSize = (getVariableByName('planComparisonCard/header/fontSize', modes) as number) ?? 14;
@@ -186,20 +130,17 @@ function PlanComparisonCard({
186
130
  const headerLineHeight = (getVariableByName('planComparisonCard/header/lineHeight', modes) as number) ?? 18;
187
131
  const headerFontWeight = (getVariableByName('planComparisonCard/header/fontWeight', modes) as number | string) ?? '500';
188
132
 
189
- // Table
190
133
  const tableBackground = (getVariableByName('planComparisonCard/tableRow/background', modes) as string) ?? '#141414';
191
134
  const tableRadius = (getVariableByName('planComparisonCard/tableRow/radius', modes) as number) ?? 16;
192
135
  const tableBorderSize = (getVariableByName('planComparisonCard/tableRow/border/size', modes) as number) ?? 1;
193
136
  const tableBorderColor = (getVariableByName('planComparisonCard/tableRow/border/color', modes) as string) ?? '#1e1a14';
194
137
 
195
- // Cell
196
138
  const cellPadding = (getVariableByName('planComparisonCard/tableCell/padding', modes) as number) ?? 12;
197
139
  const cellGap = (getVariableByName('planComparisonCard/tableCell/gap', modes) as number) ?? 2;
198
140
  const cellMinHeight = (getVariableByName('planComparisonCard/tableCell/height', modes) as number) ?? 46;
199
141
  const cellBorderSize = (getVariableByName('planComparisonCard/tableCell/border/size', modes) as number) ?? 1;
200
142
  const cellBorderColor = (getVariableByName('planComparisonCard/tableCell/border/color', modes) as string) ?? '#1e1a14';
201
143
 
202
- // Cell label
203
144
  const labelColor = (getVariableByName('planComparisonCard/tableCell/label/color', modes) as string) ?? '#ffffff';
204
145
  const labelDisabledColor = (getVariableByName('planComparisonCard/tableCell/label/disabled/color', modes) as string) ?? '#91949c';
205
146
  const labelFontSize = (getVariableByName('planComparisonCard/tableCell/label/fontSize', modes) as number) ?? 12;
@@ -207,14 +148,12 @@ function PlanComparisonCard({
207
148
  const labelLineHeight = (getVariableByName('planComparisonCard/tableCell/label/lineHeight', modes) as number) ?? 16;
208
149
  const labelFontWeight = (getVariableByName('planComparisonCard/tableCell/label/fontWeight', modes) as number | string) ?? '400';
209
150
 
210
- // Cell value
211
151
  const valueColor = (getVariableByName('planComparisonCard/tableCell/value/color', modes) as string) ?? '#ffffff';
212
152
  const valueFontSize = (getVariableByName('planComparisonCard/tableCell/value/fontSize', modes) as number) ?? 12;
213
153
  const valueFontFamily = (getVariableByName('planComparisonCard/tableCell/value/fontFamily', modes) as string) ?? 'JioType Var';
214
154
  const valueLineHeight = (getVariableByName('planComparisonCard/tableCell/value/lineHeight', modes) as number) ?? 16;
215
155
  const valueFontWeight = (getVariableByName('planComparisonCard/tableCell/value/fontWeight', modes) as number | string) ?? '500';
216
156
 
217
- // Icon
218
157
  const iconColor = (getVariableByName('planComparisonCard/icon/color', modes) as string) ?? '#ffffff';
219
158
  const iconSize = (getVariableByName('planComparisonCard/icon/size', modes) as number) ?? 16;
220
159
 
@@ -248,13 +187,29 @@ function PlanComparisonCard({
248
187
  textAlign: 'center',
249
188
  };
250
189
 
251
- const planHeaderColumnStyle: ViewStyle = {
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',
252
205
  alignItems: 'center',
253
206
  justifyContent: 'center',
207
+ padding: cellPadding,
208
+ minHeight: cellMinHeight,
209
+ width: '100%',
254
210
  };
255
211
 
256
212
  const renderValue = (value: PlanComparisonCellValue, cellKey: React.Key) => {
257
- // "Not available" → muted cross icon.
258
213
  if (value === false) {
259
214
  return (
260
215
  <Icon
@@ -265,11 +220,9 @@ function PlanComparisonCard({
265
220
  />
266
221
  );
267
222
  }
268
- // Empty cell.
269
223
  if (value === null || value === undefined || value === true) {
270
224
  return null;
271
225
  }
272
- // Text content.
273
226
  if (typeof value === 'string' || typeof value === 'number') {
274
227
  return (
275
228
  <Text key={cellKey} style={valueTextStyle}>
@@ -277,67 +230,32 @@ function PlanComparisonCard({
277
230
  </Text>
278
231
  );
279
232
  }
280
- // Custom node — forward modes so themed children stay in sync.
281
233
  return cloneChildrenWithModes(value, modes);
282
234
  };
283
235
 
284
- const labelCellStyle: ViewStyle = {
285
- flexDirection: 'row',
286
- alignItems: 'center',
287
- gap: cellGap,
288
- padding: cellPadding,
289
- minHeight: cellMinHeight,
290
- flexShrink: 0,
291
- };
292
-
293
- const valueCellStyle: ViewStyle = {
294
- flexDirection: 'row',
295
- alignItems: 'center',
296
- justifyContent: 'center',
297
- padding: cellPadding,
298
- minHeight: cellMinHeight,
299
- flexShrink: 0,
300
- };
301
-
302
236
  return (
303
- <View style={[{ gap, alignSelf: 'flex-start' }, style]}>
304
- {/* Headers above table — same column grid as body (no columnGap) */}
305
- <View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
306
- <View
307
- style={[
308
- columnWidthStyle(0),
309
- labelColumnFlex > 0 ? { flexGrow: labelColumnFlex } : undefined,
310
- ]}
311
- />
312
- {columns.map((column, index) => {
313
- const colIndex = index + 1;
314
- return (
315
- <View
316
- key={column.label ?? index}
317
- onLayout={(e) => onHeaderColumnLayout(colIndex, e)}
318
- style={[columnWidthStyle(colIndex), planHeaderColumnStyle]}
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
+ ]}
319
248
  >
320
- <Text
321
- style={[
322
- headerTextStyle,
323
- {
324
- color: column.brand ? headerBrandFg : headerFg,
325
- alignSelf: 'center',
326
- },
327
- ]}
328
- >
329
- {column.label}
330
- </Text>
331
- </View>
332
- );
333
- })}
249
+ {column.label}
250
+ </Text>
251
+ </View>
252
+ ))}
334
253
  </View>
335
254
 
336
- {/* Single rounded table — columns size to their widest cell */}
255
+ {/* Table body */}
337
256
  <View
338
257
  style={{
339
- flexDirection: 'row',
340
- alignSelf: 'flex-start',
258
+ width: '100%',
341
259
  backgroundColor: tableBackground,
342
260
  borderWidth: tableBorderSize,
343
261
  borderColor: tableBorderColor,
@@ -345,27 +263,21 @@ function PlanComparisonCard({
345
263
  overflow: 'hidden',
346
264
  }}
347
265
  >
348
- <View
349
- onLayout={(e) => onBodyColumnLayout(0, e)}
350
- style={[
351
- columnWidthStyle(0),
352
- labelColumnFlex > 0 ? { flexGrow: labelColumnFlex } : undefined,
353
- ]}
354
- >
355
- {rows.map((row, rowIndex) => {
356
- const isLast = rowIndex === rows.length - 1;
357
- const showInfo = row.showInfo || row.onInfoPress != null;
358
- return (
359
- <View
360
- key={row.key ?? `${row.label}-${rowIndex}`}
361
- style={[
362
- labelCellStyle,
363
- {
364
- borderBottomWidth: isLast ? 0 : cellBorderSize,
365
- borderBottomColor: cellBorderColor,
366
- },
367
- ]}
368
- >
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]}>
369
281
  <Text style={labelTextStyle}>{row.label}</Text>
370
282
  {showInfo &&
371
283
  (row.onInfoPress ? (
@@ -381,41 +293,19 @@ function PlanComparisonCard({
381
293
  <Icon name="ic_info" size={iconSize} color={iconColor} />
382
294
  ))}
383
295
  </View>
384
- );
385
- })}
386
- </View>
387
296
 
388
- {columns.map((column, colIndex) => {
389
- const colIndexWidth = colIndex + 1;
390
- return (
391
- <View
392
- key={column.label ?? colIndex}
393
- onLayout={(e) => onBodyColumnLayout(colIndexWidth, e)}
394
- style={[columnWidthStyle(colIndexWidth), planHeaderColumnStyle]}
395
- >
396
- {rows.map((row, rowIndex) => {
397
- const isLast = rowIndex === rows.length - 1;
398
- return (
297
+ {columns.map((column, colIndex) => (
399
298
  <View
400
- key={row.key ?? `${row.label}-${rowIndex}`}
401
- style={[
402
- valueCellStyle,
403
- {
404
- borderBottomWidth: isLast ? 0 : cellBorderSize,
405
- borderBottomColor: cellBorderColor,
406
- },
407
- ]}
299
+ key={column.label ?? colIndex}
300
+ style={[planColumnStyle, valueCellStyle]}
408
301
  >
409
- <View style={{ flexShrink: 0 }}>
410
- {renderValue(
411
- row.values?.[colIndex],
412
- `${rowIndex}-${colIndex}`,
413
- )}
414
- </View>
302
+ {renderValue(
303
+ row.values?.[colIndex],
304
+ `${rowIndex}-${colIndex}`,
305
+ )}
415
306
  </View>
416
- );
417
- })}
418
- </View>
307
+ ))}
308
+ </View>
419
309
  );
420
310
  })}
421
311
  </View>
@@ -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