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.
- package/lib/commonjs/components/AppBar/AppBar.js +56 -6
- package/lib/commonjs/components/Attached/Attached.js +46 -7
- package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
- package/lib/commonjs/components/Drawer/Drawer.js +6 -1
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/commonjs/components/FormField/FormField.js +1 -14
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/commonjs/components/ListItem/ListItem.js +6 -11
- package/lib/commonjs/components/MessageField/MessageField.js +1 -13
- package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
- package/lib/commonjs/components/Spinner/Spinner.js +217 -0
- package/lib/commonjs/components/TextInput/TextInput.js +33 -18
- package/lib/commonjs/components/index.js +7 -0
- package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
- package/lib/commonjs/icons/components/IconArrowup.js +19 -0
- package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
- package/lib/commonjs/icons/components/IconSignin.js +19 -0
- package/lib/commonjs/icons/components/IconSignout.js +19 -0
- package/lib/commonjs/icons/components/index.js +132 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AppBar/AppBar.js +56 -6
- package/lib/module/components/Attached/Attached.js +46 -7
- package/lib/module/components/Checkbox/Checkbox.js +18 -2
- package/lib/module/components/Drawer/Drawer.js +6 -1
- package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/module/components/FormField/FormField.js +3 -16
- package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/module/components/ListItem/ListItem.js +6 -11
- package/lib/module/components/MessageField/MessageField.js +3 -15
- package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
- package/lib/module/components/Spinner/Spinner.js +212 -0
- package/lib/module/components/TextInput/TextInput.js +34 -19
- package/lib/module/components/index.js +1 -0
- package/lib/module/icons/components/IconArrowdown.js +12 -0
- package/lib/module/icons/components/IconArrowup.js +12 -0
- package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
- package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
- package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
- package/lib/module/icons/components/IconChevronupcircle.js +12 -0
- package/lib/module/icons/components/IconOsnavback.js +12 -0
- package/lib/module/icons/components/IconOsnavcenter.js +12 -0
- package/lib/module/icons/components/IconOsnavhome.js +12 -0
- package/lib/module/icons/components/IconOsnavtask.js +12 -0
- package/lib/module/icons/components/IconSignin.js +12 -0
- package/lib/module/icons/components/IconSignout.js +12 -0
- package/lib/module/icons/components/index.js +12 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
- package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
- package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
- package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
- package/lib/typescript/src/components/index.d.ts +1 -0
- package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +12 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +3 -2
- package/src/components/AppBar/AppBar.tsx +79 -12
- package/src/components/Attached/Attached.tsx +63 -7
- package/src/components/Checkbox/Checkbox.tsx +14 -2
- package/src/components/Drawer/Drawer.tsx +4 -0
- package/src/components/DropdownInput/DropdownInput.tsx +54 -20
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
- package/src/components/FormField/FormField.tsx +3 -19
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
- package/src/components/ListItem/ListItem.tsx +14 -16
- package/src/components/MessageField/MessageField.tsx +3 -18
- package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
- package/src/components/Spinner/Spinner.tsx +273 -0
- package/src/components/TextInput/TextInput.tsx +37 -19
- package/src/components/index.ts +1 -0
- package/src/icons/components/IconArrowdown.tsx +11 -0
- package/src/icons/components/IconArrowup.tsx +11 -0
- package/src/icons/components/IconChevrondowncircle.tsx +11 -0
- package/src/icons/components/IconChevronleftcircle.tsx +11 -0
- package/src/icons/components/IconChevronrightcircle.tsx +11 -0
- package/src/icons/components/IconChevronupcircle.tsx +11 -0
- package/src/icons/components/IconOsnavback.tsx +11 -0
- package/src/icons/components/IconOsnavcenter.tsx +11 -0
- package/src/icons/components/IconOsnavhome.tsx +11 -0
- package/src/icons/components/IconOsnavtask.tsx +11 -0
- package/src/icons/components/IconSignin.tsx +11 -0
- package/src/icons/components/IconSignout.tsx +11 -0
- package/src/icons/components/index.ts +12 -0
- package/src/icons/registry.ts +49 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
-
*
|
|
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
|
|
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,
|
|
304
|
-
{/*
|
|
305
|
-
<View style={
|
|
306
|
-
<View
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
{/*
|
|
255
|
+
{/* Table body */}
|
|
337
256
|
<View
|
|
338
257
|
style={{
|
|
339
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|