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.
Files changed (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/components/Attached/Attached.js +144 -0
  3. package/lib/commonjs/components/Card/Card.js +25 -2
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +4 -6
  5. package/lib/commonjs/components/ListItem/ListItem.js +22 -15
  6. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
  7. package/lib/commonjs/components/Slot/Slot.js +73 -0
  8. package/lib/commonjs/components/index.js +21 -0
  9. package/lib/commonjs/icons/registry.js +1 -1
  10. package/lib/module/components/Attached/Attached.js +139 -0
  11. package/lib/module/components/Card/Card.js +25 -2
  12. package/lib/module/components/FullscreenModal/FullscreenModal.js +4 -6
  13. package/lib/module/components/ListItem/ListItem.js +22 -15
  14. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
  15. package/lib/module/components/Slot/Slot.js +68 -0
  16. package/lib/module/components/index.js +3 -0
  17. package/lib/module/icons/registry.js +1 -1
  18. package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
  19. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  20. package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
  21. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
  22. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  23. package/lib/typescript/src/components/index.d.ts +3 -0
  24. package/lib/typescript/src/icons/registry.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/components/Attached/Attached.tsx +181 -0
  27. package/src/components/Card/Card.tsx +28 -1
  28. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -3
  29. package/src/components/ListItem/ListItem.tsx +35 -16
  30. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
  31. package/src/components/Slot/Slot.tsx +91 -0
  32. package/src/components/index.ts +3 -0
  33. 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 endSlot — `Context: 'ListItem'` can never be
50
- // overridden by external modes. Frozen so identity is stable across renders.
51
- const END_SLOT_FORCED_MODES = Object.freeze({ Context: 'ListItem' })
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 / endSlot).
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 "end" → `endSlot`
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 element. Defaults to `IconCapsule`.
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.endSlot] - Optional custom trailing slot (Figma Slot "end").
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(React.Children.toArray(leading), tokens.resolvedModes)
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
- const processedEndSlot = useMemo(() => {
273
- if (!endSlot) return null
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(endSlot),
294
+ React.Children.toArray(trailingContent),
276
295
  tokens.resolvedModes,
277
- END_SLOT_FORCED_MODES
296
+ SLOT_FORCED_MODES
278
297
  )
279
298
  return processed.length === 1 ? processed[0] : processed
280
- }, [endSlot, tokens.resolvedModes])
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
- {processedEndSlot ? (
374
- <View style={tokens.trailingWrapperStyle}>{processedEndSlot}</View>
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)
@@ -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';
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-05-29T10:37:24.494Z
7
+ * Generated: 2026-05-29T17:01:15.629Z
8
8
  */
9
9
 
10
10
  // Icon name to SVG data mapping