jfs-components 0.0.78 → 0.0.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/lib/commonjs/components/Attached/Attached.js +144 -0
- package/lib/commonjs/components/Card/Card.js +25 -2
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +4 -6
- package/lib/commonjs/components/ListItem/ListItem.js +22 -15
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
- package/lib/commonjs/components/Slot/Slot.js +73 -0
- package/lib/commonjs/components/index.js +21 -0
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Attached/Attached.js +139 -0
- package/lib/module/components/Card/Card.js +25 -2
- package/lib/module/components/FullscreenModal/FullscreenModal.js +4 -6
- package/lib/module/components/ListItem/ListItem.js +22 -15
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
- package/lib/module/components/Slot/Slot.js +68 -0
- package/lib/module/components/index.js +3 -0
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
- package/lib/typescript/src/components/Card/Card.d.ts +9 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
- package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
- package/lib/typescript/src/components/index.d.ts +3 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Attached/Attached.tsx +181 -0
- package/src/components/Card/Card.tsx +28 -1
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -3
- package/src/components/ListItem/ListItem.tsx +35 -16
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
- package/src/components/Slot/Slot.tsx +91 -0
- package/src/components/index.ts +3 -0
- package/src/icons/registry.ts +1 -1
|
@@ -21,8 +21,16 @@ type ListItemProps = {
|
|
|
21
21
|
title?: string;
|
|
22
22
|
supportText?: string;
|
|
23
23
|
showSupportText?: boolean;
|
|
24
|
+
/** Leading slot (Figma "leading"). Defaults to an `IconCapsule` when omitted. */
|
|
24
25
|
leading?: React.ReactNode;
|
|
25
26
|
supportSlot?: React.ReactNode;
|
|
27
|
+
/** Trailing slot (Figma "trailing"), e.g. `MoneyValue` or `Button`. Horizontal layout only. */
|
|
28
|
+
trailing?: React.ReactNode;
|
|
29
|
+
/**
|
|
30
|
+
* @deprecated Renamed to `trailing` for a symmetric `leading` / `trailing`
|
|
31
|
+
* slot API. Still honored for backward compatibility; `trailing` wins when
|
|
32
|
+
* both are provided. Will be removed in a future major version.
|
|
33
|
+
*/
|
|
26
34
|
endSlot?: React.ReactNode;
|
|
27
35
|
/** Whether to show the NavArrow on the far right (Horizontal layout only). Defaults to true. */
|
|
28
36
|
navArrow?: boolean;
|
|
@@ -46,9 +54,10 @@ type ListItemProps = {
|
|
|
46
54
|
const IS_IOS = Platform.OS === 'ios'
|
|
47
55
|
const PRESS_DELAY = IS_IOS ? 130 : 0
|
|
48
56
|
|
|
49
|
-
// Forced modes for the
|
|
50
|
-
// overridden by external modes. Frozen so identity is stable across
|
|
51
|
-
|
|
57
|
+
// Forced modes for the leading/trailing slots — `Context: 'ListItem'` can
|
|
58
|
+
// never be overridden by external modes. Frozen so identity is stable across
|
|
59
|
+
// renders. Applied to both slots so they cascade modes identically.
|
|
60
|
+
const SLOT_FORCED_MODES = Object.freeze({ Context: 'ListItem' })
|
|
52
61
|
|
|
53
62
|
// Pressed visual is applied on the host view through Pressable's style
|
|
54
63
|
// callback, so a scroll-cancelled touch never schedules a React render.
|
|
@@ -72,7 +81,7 @@ interface ListItemTokens {
|
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
function resolveListItemTokens(modes: Record<string, any>): ListItemTokens {
|
|
75
|
-
// Modes used to cascade into slot children (leading / supportSlot /
|
|
84
|
+
// Modes used to cascade into slot children (leading / supportSlot / trailing).
|
|
76
85
|
// We do NOT inject an `AppearanceBrand` default here: slot content such as
|
|
77
86
|
// Buttons or Badges carry their own intended appearance, so forcing one onto
|
|
78
87
|
// them would be surprising.
|
|
@@ -167,9 +176,11 @@ const verticalSupportTextOverride: TextStyle = { textAlign: 'center' }
|
|
|
167
176
|
* - **design-token driven styling** via `getVariableByName` and `modes`
|
|
168
177
|
*
|
|
169
178
|
* Wherever the Figma layer name contains "Slot", this component exposes a
|
|
170
|
-
* dedicated React "slot" prop
|
|
179
|
+
* dedicated React "slot" prop. The leading and trailing edges share a
|
|
180
|
+
* symmetric `leading` / `trailing` slot API:
|
|
181
|
+
* - Slot "leading" → `leading`
|
|
171
182
|
* - Slot "support text" → `supportSlot`
|
|
172
|
-
* - Slot "
|
|
183
|
+
* - Slot "trailing" → `trailing`
|
|
173
184
|
*
|
|
174
185
|
* @component
|
|
175
186
|
* @param {Object} props
|
|
@@ -177,9 +188,9 @@ const verticalSupportTextOverride: TextStyle = { textAlign: 'center' }
|
|
|
177
188
|
* @param {string} [props.title='Title'] - Primary title used in the horizontal layout.
|
|
178
189
|
* @param {string} [props.supportText='Support Text'] - Support text used in both layouts when `supportSlot` is not provided.
|
|
179
190
|
* @param {boolean} [props.showSupportText=true] - Toggles rendering of the support text in Horizontal layout.
|
|
180
|
-
* @param {React.ReactNode} [props.leading] - Optional leading
|
|
191
|
+
* @param {React.ReactNode} [props.leading] - Optional leading slot. Defaults to `IconCapsule`.
|
|
181
192
|
* @param {React.ReactNode} [props.supportSlot] - Optional custom slot used instead of the default support text block.
|
|
182
|
-
* @param {React.ReactNode} [props.
|
|
193
|
+
* @param {React.ReactNode} [props.trailing] - Optional trailing slot (Figma Slot "trailing"). Horizontal layout only.
|
|
183
194
|
* @param {boolean} [props.navArrow=true] - Whether to show NavArrow on the far right (Horizontal layout only).
|
|
184
195
|
* @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens.
|
|
185
196
|
* @param {Function} [props.onPress] - When provided, the entire item becomes pressable (navigation variant).
|
|
@@ -208,6 +219,7 @@ function ListItemImpl({
|
|
|
208
219
|
showSupportText = true,
|
|
209
220
|
leading,
|
|
210
221
|
supportSlot,
|
|
222
|
+
trailing,
|
|
211
223
|
endSlot,
|
|
212
224
|
navArrow = true,
|
|
213
225
|
modes = EMPTY_MODES,
|
|
@@ -252,7 +264,11 @@ function ListItemImpl({
|
|
|
252
264
|
// (leading, resolvedModes) so a parent re-render doesn't re-walk the tree.
|
|
253
265
|
const leadingElement = useMemo(() => {
|
|
254
266
|
const processed = leading
|
|
255
|
-
? cloneChildrenWithModes(
|
|
267
|
+
? cloneChildrenWithModes(
|
|
268
|
+
React.Children.toArray(leading),
|
|
269
|
+
tokens.resolvedModes,
|
|
270
|
+
SLOT_FORCED_MODES
|
|
271
|
+
)
|
|
256
272
|
: []
|
|
257
273
|
if (processed.length === 0) {
|
|
258
274
|
return <IconCapsule modes={tokens.resolvedModes} accessibilityLabel={undefined} />
|
|
@@ -269,15 +285,18 @@ function ListItemImpl({
|
|
|
269
285
|
return processed.length === 1 ? processed[0] : processed
|
|
270
286
|
}, [supportSlot, tokens.resolvedModes])
|
|
271
287
|
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
// `trailing` wins; `endSlot` is the deprecated alias kept for back-compat.
|
|
289
|
+
const trailingContent = trailing ?? endSlot
|
|
290
|
+
|
|
291
|
+
const processedTrailing = useMemo(() => {
|
|
292
|
+
if (!trailingContent) return null
|
|
274
293
|
const processed = cloneChildrenWithModes(
|
|
275
|
-
React.Children.toArray(
|
|
294
|
+
React.Children.toArray(trailingContent),
|
|
276
295
|
tokens.resolvedModes,
|
|
277
|
-
|
|
296
|
+
SLOT_FORCED_MODES
|
|
278
297
|
)
|
|
279
298
|
return processed.length === 1 ? processed[0] : processed
|
|
280
|
-
}, [
|
|
299
|
+
}, [trailingContent, tokens.resolvedModes])
|
|
281
300
|
|
|
282
301
|
const renderSupportContent = () => {
|
|
283
302
|
if (processedSupportSlot) return processedSupportSlot
|
|
@@ -370,8 +389,8 @@ function ListItemImpl({
|
|
|
370
389
|
</Text>
|
|
371
390
|
{showSupportText && renderSupportContent()}
|
|
372
391
|
</View>
|
|
373
|
-
{
|
|
374
|
-
<View style={tokens.trailingWrapperStyle}>{
|
|
392
|
+
{processedTrailing ? (
|
|
393
|
+
<View style={tokens.trailingWrapperStyle}>{processedTrailing}</View>
|
|
375
394
|
) : null}
|
|
376
395
|
{navArrow && <NavArrow direction="Forward" modes={tokens.resolvedModes} />}
|
|
377
396
|
</View>
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
Platform,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
type TextStyle,
|
|
10
|
+
type LayoutChangeEvent,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
|
|
13
|
+
import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
|
|
14
|
+
import Icon from '../../icons/Icon';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single plan column header (the label column has no header of its own).
|
|
18
|
+
*/
|
|
19
|
+
export type PlanComparisonColumn = {
|
|
20
|
+
/** Header text for the plan column. */
|
|
21
|
+
label: string;
|
|
22
|
+
/**
|
|
23
|
+
* Render the header in the brand accent colour (gold) — use it to
|
|
24
|
+
* highlight the recommended / upsell plan.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
brand?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Value rendered inside a plan cell.
|
|
32
|
+
* - `string` / `number` → rendered as value text.
|
|
33
|
+
* - `false` → renders the muted "not available" cross icon.
|
|
34
|
+
* - any React node → rendered as-is (e.g. a `Badge`, `MoneyValue`, icon…).
|
|
35
|
+
* - `null` / `undefined` / `true` → empty cell.
|
|
36
|
+
*/
|
|
37
|
+
export type PlanComparisonCellValue =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| undefined
|
|
43
|
+
| React.ReactElement;
|
|
44
|
+
|
|
45
|
+
export type PlanComparisonRow = {
|
|
46
|
+
/** Feature label shown in the first (left) column. */
|
|
47
|
+
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* Show an info icon after the label. When `onInfoPress` is provided the
|
|
50
|
+
* icon becomes tappable; otherwise it is purely decorative.
|
|
51
|
+
*/
|
|
52
|
+
showInfo?: boolean;
|
|
53
|
+
/** Handler for the info icon. Implies `showInfo`. */
|
|
54
|
+
onInfoPress?: () => void;
|
|
55
|
+
/**
|
|
56
|
+
* One value per plan column, in the same order as `columns`. See
|
|
57
|
+
* {@link PlanComparisonCellValue} for how each value is rendered.
|
|
58
|
+
*/
|
|
59
|
+
values: PlanComparisonCellValue[];
|
|
60
|
+
/** Stable key. Falls back to the label / index. */
|
|
61
|
+
key?: React.Key;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type PlanComparisonCardProps = {
|
|
65
|
+
/**
|
|
66
|
+
* Plan column headers (excludes the leading label column). The order here
|
|
67
|
+
* maps 1:1 to each row's `values` array.
|
|
68
|
+
*/
|
|
69
|
+
columns?: PlanComparisonColumn[];
|
|
70
|
+
/** Feature rows compared across the plan columns. */
|
|
71
|
+
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
|
+
/** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
|
|
80
|
+
modes?: Record<string, any>;
|
|
81
|
+
/** Override the outer container style. */
|
|
82
|
+
style?: StyleProp<ViewStyle>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const DEFAULT_COLUMNS: PlanComparisonColumn[] = [
|
|
86
|
+
{ label: 'Your plan' },
|
|
87
|
+
{ label: 'JioFinance+', brand: true },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const DEFAULT_ROWS: PlanComparisonRow[] = [
|
|
91
|
+
{ label: 'JioPoints multiplier', values: ['1x', '1.25x'] },
|
|
92
|
+
{ label: 'Cashback', showInfo: true, values: [false, 'Upto ₹5000'] },
|
|
93
|
+
{ label: 'Bonus JioGold', showInfo: true, values: [false, '1%'] },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* PlanComparisonCard renders a compact comparison table that pits the user's
|
|
98
|
+
* current plan against one or more alternative plans across a set of feature
|
|
99
|
+
* rows. Implementation of Figma node `4498:2968` (`PlanComparisonCard`).
|
|
100
|
+
*
|
|
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.
|
|
104
|
+
*
|
|
105
|
+
* @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
|
+
/** 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
|
+
function PlanComparisonCard({
|
|
124
|
+
columns = DEFAULT_COLUMNS,
|
|
125
|
+
rows = DEFAULT_ROWS,
|
|
126
|
+
labelColumnFlex = 0,
|
|
127
|
+
modes = EMPTY_MODES,
|
|
128
|
+
style,
|
|
129
|
+
}: 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
|
+
const gap = (getVariableByName('planComparisonCard/gap', modes) as number) ?? 16;
|
|
180
|
+
|
|
181
|
+
// Header
|
|
182
|
+
const headerFg = (getVariableByName('planComparisonCard/header/fg', modes) as string) ?? '#ffffff';
|
|
183
|
+
const headerBrandFg = (getVariableByName('planComparisonCard/header/brand/fg', modes) as string) ?? '#cea15a';
|
|
184
|
+
const headerFontSize = (getVariableByName('planComparisonCard/header/fontSize', modes) as number) ?? 14;
|
|
185
|
+
const headerFontFamily = (getVariableByName('planComparisonCard/header/fontFamily', modes) as string) ?? 'JioType Var';
|
|
186
|
+
const headerLineHeight = (getVariableByName('planComparisonCard/header/lineHeight', modes) as number) ?? 18;
|
|
187
|
+
const headerFontWeight = (getVariableByName('planComparisonCard/header/fontWeight', modes) as number | string) ?? '500';
|
|
188
|
+
|
|
189
|
+
// Table
|
|
190
|
+
const tableBackground = (getVariableByName('planComparisonCard/tableRow/background', modes) as string) ?? '#141414';
|
|
191
|
+
const tableRadius = (getVariableByName('planComparisonCard/tableRow/radius', modes) as number) ?? 16;
|
|
192
|
+
const tableBorderSize = (getVariableByName('planComparisonCard/tableRow/border/size', modes) as number) ?? 1;
|
|
193
|
+
const tableBorderColor = (getVariableByName('planComparisonCard/tableRow/border/color', modes) as string) ?? '#1e1a14';
|
|
194
|
+
|
|
195
|
+
// Cell
|
|
196
|
+
const cellPadding = (getVariableByName('planComparisonCard/tableCell/padding', modes) as number) ?? 12;
|
|
197
|
+
const cellGap = (getVariableByName('planComparisonCard/tableCell/gap', modes) as number) ?? 2;
|
|
198
|
+
const cellMinHeight = (getVariableByName('planComparisonCard/tableCell/height', modes) as number) ?? 46;
|
|
199
|
+
const cellBorderSize = (getVariableByName('planComparisonCard/tableCell/border/size', modes) as number) ?? 1;
|
|
200
|
+
const cellBorderColor = (getVariableByName('planComparisonCard/tableCell/border/color', modes) as string) ?? '#1e1a14';
|
|
201
|
+
|
|
202
|
+
// Cell label
|
|
203
|
+
const labelColor = (getVariableByName('planComparisonCard/tableCell/label/color', modes) as string) ?? '#ffffff';
|
|
204
|
+
const labelDisabledColor = (getVariableByName('planComparisonCard/tableCell/label/disabled/color', modes) as string) ?? '#91949c';
|
|
205
|
+
const labelFontSize = (getVariableByName('planComparisonCard/tableCell/label/fontSize', modes) as number) ?? 12;
|
|
206
|
+
const labelFontFamily = (getVariableByName('planComparisonCard/tableCell/label/fontFamily', modes) as string) ?? 'JioType Var';
|
|
207
|
+
const labelLineHeight = (getVariableByName('planComparisonCard/tableCell/label/lineHeight', modes) as number) ?? 16;
|
|
208
|
+
const labelFontWeight = (getVariableByName('planComparisonCard/tableCell/label/fontWeight', modes) as number | string) ?? '400';
|
|
209
|
+
|
|
210
|
+
// Cell value
|
|
211
|
+
const valueColor = (getVariableByName('planComparisonCard/tableCell/value/color', modes) as string) ?? '#ffffff';
|
|
212
|
+
const valueFontSize = (getVariableByName('planComparisonCard/tableCell/value/fontSize', modes) as number) ?? 12;
|
|
213
|
+
const valueFontFamily = (getVariableByName('planComparisonCard/tableCell/value/fontFamily', modes) as string) ?? 'JioType Var';
|
|
214
|
+
const valueLineHeight = (getVariableByName('planComparisonCard/tableCell/value/lineHeight', modes) as number) ?? 16;
|
|
215
|
+
const valueFontWeight = (getVariableByName('planComparisonCard/tableCell/value/fontWeight', modes) as number | string) ?? '500';
|
|
216
|
+
|
|
217
|
+
// Icon
|
|
218
|
+
const iconColor = (getVariableByName('planComparisonCard/icon/color', modes) as string) ?? '#ffffff';
|
|
219
|
+
const iconSize = (getVariableByName('planComparisonCard/icon/size', modes) as number) ?? 16;
|
|
220
|
+
|
|
221
|
+
const toWeight = (w: number | string) => (typeof w === 'number' ? `${w}` : w) as TextStyle['fontWeight'];
|
|
222
|
+
|
|
223
|
+
const headerTextStyle: TextStyle = {
|
|
224
|
+
...NO_WRAP_TEXT,
|
|
225
|
+
fontFamily: headerFontFamily,
|
|
226
|
+
fontSize: headerFontSize,
|
|
227
|
+
lineHeight: headerLineHeight,
|
|
228
|
+
fontWeight: toWeight(headerFontWeight),
|
|
229
|
+
textAlign: 'center',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const labelTextStyle: TextStyle = {
|
|
233
|
+
...NO_WRAP_TEXT,
|
|
234
|
+
color: labelColor,
|
|
235
|
+
fontFamily: labelFontFamily,
|
|
236
|
+
fontSize: labelFontSize,
|
|
237
|
+
lineHeight: labelLineHeight,
|
|
238
|
+
fontWeight: toWeight(labelFontWeight),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const valueTextStyle: TextStyle = {
|
|
242
|
+
...NO_WRAP_TEXT,
|
|
243
|
+
color: valueColor,
|
|
244
|
+
fontFamily: valueFontFamily,
|
|
245
|
+
fontSize: valueFontSize,
|
|
246
|
+
lineHeight: valueLineHeight,
|
|
247
|
+
fontWeight: toWeight(valueFontWeight),
|
|
248
|
+
textAlign: 'center',
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const planHeaderColumnStyle: ViewStyle = {
|
|
252
|
+
alignItems: 'center',
|
|
253
|
+
justifyContent: 'center',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const renderValue = (value: PlanComparisonCellValue, cellKey: React.Key) => {
|
|
257
|
+
// "Not available" → muted cross icon.
|
|
258
|
+
if (value === false) {
|
|
259
|
+
return (
|
|
260
|
+
<Icon
|
|
261
|
+
key={cellKey}
|
|
262
|
+
name="ic_close"
|
|
263
|
+
size={iconSize}
|
|
264
|
+
color={labelDisabledColor}
|
|
265
|
+
/>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
// Empty cell.
|
|
269
|
+
if (value === null || value === undefined || value === true) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
// Text content.
|
|
273
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
274
|
+
return (
|
|
275
|
+
<Text key={cellKey} style={valueTextStyle}>
|
|
276
|
+
{value}
|
|
277
|
+
</Text>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
// Custom node — forward modes so themed children stay in sync.
|
|
281
|
+
return cloneChildrenWithModes(value, modes);
|
|
282
|
+
};
|
|
283
|
+
|
|
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
|
+
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]}
|
|
319
|
+
>
|
|
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
|
+
})}
|
|
334
|
+
</View>
|
|
335
|
+
|
|
336
|
+
{/* Single rounded table — columns size to their widest cell */}
|
|
337
|
+
<View
|
|
338
|
+
style={{
|
|
339
|
+
flexDirection: 'row',
|
|
340
|
+
alignSelf: 'flex-start',
|
|
341
|
+
backgroundColor: tableBackground,
|
|
342
|
+
borderWidth: tableBorderSize,
|
|
343
|
+
borderColor: tableBorderColor,
|
|
344
|
+
borderRadius: tableRadius,
|
|
345
|
+
overflow: 'hidden',
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
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
|
+
>
|
|
369
|
+
<Text style={labelTextStyle}>{row.label}</Text>
|
|
370
|
+
{showInfo &&
|
|
371
|
+
(row.onInfoPress ? (
|
|
372
|
+
<Pressable
|
|
373
|
+
onPress={row.onInfoPress}
|
|
374
|
+
accessibilityRole="button"
|
|
375
|
+
accessibilityLabel={`More information about ${row.label}`}
|
|
376
|
+
hitSlop={8}
|
|
377
|
+
>
|
|
378
|
+
<Icon name="ic_info" size={iconSize} color={iconColor} />
|
|
379
|
+
</Pressable>
|
|
380
|
+
) : (
|
|
381
|
+
<Icon name="ic_info" size={iconSize} color={iconColor} />
|
|
382
|
+
))}
|
|
383
|
+
</View>
|
|
384
|
+
);
|
|
385
|
+
})}
|
|
386
|
+
</View>
|
|
387
|
+
|
|
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 (
|
|
399
|
+
<View
|
|
400
|
+
key={row.key ?? `${row.label}-${rowIndex}`}
|
|
401
|
+
style={[
|
|
402
|
+
valueCellStyle,
|
|
403
|
+
{
|
|
404
|
+
borderBottomWidth: isLast ? 0 : cellBorderSize,
|
|
405
|
+
borderBottomColor: cellBorderColor,
|
|
406
|
+
},
|
|
407
|
+
]}
|
|
408
|
+
>
|
|
409
|
+
<View style={{ flexShrink: 0 }}>
|
|
410
|
+
{renderValue(
|
|
411
|
+
row.values?.[colIndex],
|
|
412
|
+
`${rowIndex}-${colIndex}`,
|
|
413
|
+
)}
|
|
414
|
+
</View>
|
|
415
|
+
</View>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</View>
|
|
419
|
+
);
|
|
420
|
+
})}
|
|
421
|
+
</View>
|
|
422
|
+
</View>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
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)
|
package/src/components/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default as AccountCard, type AccountCardProps, type AccountCardState } from './AccountCard/AccountCard';
|
|
2
2
|
export { default as ActionFooter, type ActionFooterProps } from './ActionFooter/ActionFooter';
|
|
3
|
+
export { default as Attached, type AttachedProps, type AttachedPosition } from './Attached/Attached';
|
|
3
4
|
export { default as AppBar } from './AppBar/AppBar';
|
|
4
5
|
export { default as Avatar, type AvatarProps } from './Avatar/Avatar';
|
|
5
6
|
export { default as AvatarGroup } from './AvatarGroup/AvatarGroup';
|
|
@@ -63,6 +64,7 @@ export { default as Numpad, type NumpadProps, type NumpadKeyValue } from './Nump
|
|
|
63
64
|
export { default as Title, type TitleProps } from './Title/Title';
|
|
64
65
|
export { default as Screen, type ScreenProps } from './Screen/Screen';
|
|
65
66
|
export { default as Section } from './Section/Section';
|
|
67
|
+
export { default as Slot, type SlotProps, type SlotLayoutDirection } from './Slot/Slot';
|
|
66
68
|
export { default as Stepper, type StepperProps } from './Stepper/Stepper';
|
|
67
69
|
export { Step, type StepProps, type StepStatus } from './Stepper/Step';
|
|
68
70
|
export { StepLabel, type StepLabelProps } from './Stepper/StepLabel';
|
|
@@ -119,6 +121,7 @@ export { default as AmountInput, type AmountInputProps } from './AmountInput/Amo
|
|
|
119
121
|
export { default as PageHero, type PageHeroProps } from './PageHero/PageHero';
|
|
120
122
|
export { default as Popup, type PopupProps, type PopupRef } from './Popup/Popup';
|
|
121
123
|
export { default as PortfolioHero, type PortfolioHeroProps } from './PortfolioHero/PortfolioHero';
|
|
124
|
+
export { default as PlanComparisonCard, type PlanComparisonCardProps, type PlanComparisonColumn, type PlanComparisonRow, type PlanComparisonCellValue } from './PlanComparisonCard/PlanComparisonCard';
|
|
122
125
|
export { default as PoweredByLabel, type PoweredByLabelProps } from './PoweredByLabel/PoweredByLabel';
|
|
123
126
|
export { default as ProductLabel, type ProductLabelProps } from './ProductLabel/ProductLabel';
|
|
124
127
|
export { default as ProductOverview, type ProductOverviewProps, type ProductOverviewStat } from './ProductOverview/ProductOverview';
|
package/src/icons/registry.ts
CHANGED