jfs-components 0.1.51 → 0.1.54

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 (90) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/components/ActionFooter/ActionFooter.js +319 -135
  3. package/lib/commonjs/components/Badge/Badge.js +20 -1
  4. package/lib/commonjs/components/Button/Button.js +139 -47
  5. package/lib/commonjs/components/ButtonGroup/ButtonGroup.js +1 -1
  6. package/lib/commonjs/components/Card/Card.js +151 -112
  7. package/lib/commonjs/components/Card/assets/gold-logo-mark.png +0 -0
  8. package/lib/commonjs/components/CategoryCard/CategoryCard.js +97 -63
  9. package/lib/commonjs/components/CategoryCard/assets/sample.png +0 -0
  10. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +59 -10
  11. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +132 -49
  12. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +56 -14
  13. package/lib/commonjs/components/IconCapsule/IconCapsule.js +1 -1
  14. package/lib/commonjs/components/MetricData/MetricData.js +26 -13
  15. package/lib/commonjs/components/MoneyValue/MoneyValue.js +1 -1
  16. package/lib/commonjs/components/Nudge/Nudge.js +1 -1
  17. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +47 -91
  18. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
  19. package/lib/commonjs/components/PortfolioHero/PortfolioHero.js +1 -1
  20. package/lib/commonjs/components/Slider/Slider.js +3 -3
  21. package/lib/commonjs/components/TextInput/TextInput.js +26 -8
  22. package/lib/commonjs/components/Tooltip/Tooltip.js +3 -5
  23. package/lib/commonjs/components/index.js +7 -1
  24. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  25. package/lib/commonjs/design-tokens/figma-modes.generated.js +13 -13
  26. package/lib/commonjs/icons/registry.js +1 -1
  27. package/lib/module/components/ActionFooter/ActionFooter.js +322 -138
  28. package/lib/module/components/Badge/Badge.js +20 -1
  29. package/lib/module/components/Button/Button.js +140 -48
  30. package/lib/module/components/ButtonGroup/ButtonGroup.js +1 -1
  31. package/lib/module/components/Card/Card.js +152 -114
  32. package/lib/module/components/Card/assets/gold-logo-mark.png +0 -0
  33. package/lib/module/components/CategoryCard/CategoryCard.js +97 -63
  34. package/lib/module/components/CategoryCard/assets/sample.png +0 -0
  35. package/lib/module/components/CheckboxItem/CheckboxItem.js +59 -10
  36. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +135 -52
  37. package/lib/module/components/HelloJioInput/HelloJioInput.js +56 -14
  38. package/lib/module/components/IconCapsule/IconCapsule.js +1 -1
  39. package/lib/module/components/MetricData/MetricData.js +26 -14
  40. package/lib/module/components/MoneyValue/MoneyValue.js +1 -1
  41. package/lib/module/components/Nudge/Nudge.js +1 -1
  42. package/lib/module/components/PdpCcCard/PdpCcCard.js +48 -92
  43. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +52 -45
  44. package/lib/module/components/PortfolioHero/PortfolioHero.js +1 -1
  45. package/lib/module/components/Slider/Slider.js +3 -3
  46. package/lib/module/components/TextInput/TextInput.js +27 -9
  47. package/lib/module/components/Tooltip/Tooltip.js +3 -5
  48. package/lib/module/components/index.js +1 -1
  49. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  50. package/lib/module/design-tokens/figma-modes.generated.js +13 -13
  51. package/lib/module/icons/registry.js +1 -1
  52. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +50 -21
  53. package/lib/typescript/src/components/Button/Button.d.ts +24 -1
  54. package/lib/typescript/src/components/ButtonGroup/ButtonGroup.d.ts +2 -2
  55. package/lib/typescript/src/components/Card/Card.d.ts +54 -25
  56. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +29 -12
  57. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +27 -9
  58. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +37 -12
  59. package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +1 -1
  60. package/lib/typescript/src/components/MetricData/MetricData.d.ts +7 -5
  61. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +16 -6
  62. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +9 -2
  63. package/lib/typescript/src/components/index.d.ts +2 -2
  64. package/lib/typescript/src/icons/registry.d.ts +1 -1
  65. package/package.json +1 -1
  66. package/src/components/ActionFooter/ActionFooter.tsx +375 -141
  67. package/src/components/Badge/Badge.tsx +21 -1
  68. package/src/components/Button/Button.tsx +192 -44
  69. package/src/components/ButtonGroup/ButtonGroup.tsx +2 -2
  70. package/src/components/Card/Card.tsx +370 -212
  71. package/src/components/Card/assets/gold-logo-mark.png +0 -0
  72. package/src/components/CategoryCard/CategoryCard.tsx +130 -84
  73. package/src/components/CategoryCard/assets/sample.png +0 -0
  74. package/src/components/CheckboxItem/CheckboxItem.tsx +72 -12
  75. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +186 -67
  76. package/src/components/HelloJioInput/HelloJioInput.tsx +58 -17
  77. package/src/components/IconCapsule/IconCapsule.tsx +1 -1
  78. package/src/components/MetricData/MetricData.tsx +39 -22
  79. package/src/components/MoneyValue/MoneyValue.tsx +1 -1
  80. package/src/components/Nudge/Nudge.tsx +1 -1
  81. package/src/components/PdpCcCard/PdpCcCard.tsx +41 -92
  82. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +73 -58
  83. package/src/components/PortfolioHero/PortfolioHero.tsx +1 -1
  84. package/src/components/Slider/Slider.tsx +3 -3
  85. package/src/components/TextInput/TextInput.tsx +26 -9
  86. package/src/components/Tooltip/Tooltip.tsx +3 -5
  87. package/src/components/index.ts +9 -2
  88. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  89. package/src/design-tokens/figma-modes.generated.ts +13 -13
  90. package/src/icons/registry.ts +1 -1
@@ -26,16 +26,24 @@ import Badge from '../Badge/Badge'
26
26
  import Image from '../Image/Image'
27
27
  import type { Modes } from '../../design-tokens'
28
28
 
29
- const CATEGORY_CARD_STATE = 'Category Card State'
29
+ /** Canonical Figma collection that drives idle vs active chrome. */
30
+ const CARD_TAB_STATE = 'Card Tab State'
31
+ /**
32
+ * Legacy collection name used by earlier CategoryCard builds. Still accepted on
33
+ * `modes` so existing callers keep resolving the correct Active/Default tokens.
34
+ */
35
+ const LEGACY_CARD_TAB_STATE = 'Category Card State'
36
+
30
37
  const BADGE_OVERLAP = 11
31
38
  const DEFAULT_CARD_WIDTH = 60
39
+ const DEFAULT_IMAGE_ASSET = require('./assets/sample.png')
32
40
 
33
41
  // Default modes from the Figma CategoryCard component. Overridable via `modes`.
34
42
  const CATEGORY_CARD_DEFAULT_MODES = Object.freeze({
35
43
  AppearanceBrand: 'Tertiary',
36
44
  'Badge Size': 'Small',
37
45
  Context4: 'Badge',
38
- "Radius": "None"
46
+ Radius: 'None',
39
47
  })
40
48
 
41
49
  const toNumber = (value: unknown, fallback: number): number => {
@@ -58,25 +66,67 @@ const toFontWeight = (
58
66
  return fallback
59
67
  }
60
68
 
69
+ /**
70
+ * Maps any accepted state token (`Default` / `Active`, plus legacy `Idle`) onto
71
+ * the canonical `Card Tab State` mode names used by the variable resolver.
72
+ */
73
+ const normalizeCardTabState = (
74
+ value: unknown
75
+ ): 'Default' | 'Active' | undefined => {
76
+ if (value === 'Active') return 'Active'
77
+ if (value === 'Default' || value === 'Idle') return 'Default'
78
+ return undefined
79
+ }
80
+
81
+ /**
82
+ * Resolves whether the card should render in the Active (gold stroke) state.
83
+ *
84
+ * Precedence (backward compatible):
85
+ * 1. Explicit `active` boolean prop (existing public API)
86
+ * 2. `modes['Card Tab State']` / legacy `modes['Category Card State']`
87
+ * 3. Default → idle (`Default`)
88
+ */
89
+ const resolveIsActive = (
90
+ active: boolean | undefined,
91
+ propModes: Modes
92
+ ): boolean => {
93
+ if (typeof active === 'boolean') return active
94
+ const fromModes = normalizeCardTabState(
95
+ propModes[CARD_TAB_STATE] ?? propModes[LEGACY_CARD_TAB_STATE]
96
+ )
97
+ return fromModes === 'Active'
98
+ }
99
+
61
100
  export type CategoryCardProps = SafePressableProps & {
62
101
  /** Bottom label (e.g. `"Popular"`). */
63
102
  label?: string
64
103
  /**
65
- * Image shown in the card centre. Ignored when `imageSlot` is provided.
66
- * Accepts a remote URL string, `{ uri }` object, or `require()`-ed asset.
104
+ * Image shown in the card centre. Ignored when `imageSlot` / `children` is
105
+ * provided. Accepts a remote URL string, `{ uri }` object, or `require()`-ed
106
+ * asset.
67
107
  */
68
108
  imageSource?: ImageSourcePropType | string
69
109
  /**
70
- * Slot replacing the default `Image`. Receives `modes` recursively so inner
71
- * components inherit theming.
110
+ * Slot replacing the default `Image` (Figma Image instance / Code Connect).
111
+ * Receives `modes` recursively so inner components inherit theming.
112
+ * Prefer this or `children` when swapping the media.
72
113
  */
73
114
  imageSlot?: React.ReactNode
115
+ /**
116
+ * Alias for {@link imageSlot} — the Figma image slot. When both are set,
117
+ * `imageSlot` wins so existing `imageSlot` callers stay authoritative.
118
+ */
119
+ children?: React.ReactNode
74
120
  /**
75
121
  * Optional badge label rendered as an overlapping pill at the top edge.
76
122
  * Omit or pass `undefined` to hide the badge.
77
123
  */
78
124
  badge?: string
79
- /** Whether the card is in the selected / active state. */
125
+ /**
126
+ * Whether the card is in the selected / active state (gold stroke).
127
+ * When omitted, falls back to `modes['Card Tab State'] === 'Active'`
128
+ * (and the legacy `modes['Category Card State'] === 'Active'` alias).
129
+ */
80
130
  active?: boolean
81
131
  /**
82
132
  * Fixed width of the card in dp. Defaults to `60` per the Figma spec.
@@ -84,15 +134,16 @@ export type CategoryCardProps = SafePressableProps & {
84
134
  width?: number
85
135
  /**
86
136
  * When `true`, disables text truncation: the trailing ellipsis (`…`) is never
87
- * shown and any `numberOfLines` clamp is ignored, so the label wraps to as many
88
- * lines as it needs even when horizontal space is tight. Same behaviour as
89
- * {@link Text}'s `disableTruncation`.
137
+ * shown and any `numberOfLines` clamp is ignored. Defaults to `true` (same
138
+ * overflow behaviour as {@link Text}'s `disableTruncation`). Pass `false` to
139
+ * restore single-line ellipsis clamping.
90
140
  */
91
141
  disableTruncation?: boolean
92
142
  /**
93
143
  * When `true`, forces the label onto a single line with no wrapping (implies
94
144
  * `disableTruncation`). On web the line overflows horizontally instead of
95
- * wrapping or truncating. Same behaviour as {@link Text}'s `singleLine`.
145
+ * wrapping or truncating. Defaults to `true` — same API as {@link Text}'s
146
+ * `singleLine`. Pass `false` to allow wrapping.
96
147
  */
97
148
  singleLine?: boolean
98
149
  /** Press handler — the card is always a `Pressable`. */
@@ -114,70 +165,69 @@ export type CategoryCardProps = SafePressableProps & {
114
165
  interface CategoryCardTokens {
115
166
  containerStyle: ViewStyle
116
167
  labelStyle: TextStyle
117
- imageSize: number
168
+ imageWidth: number
169
+ imageHeight: number
118
170
  imageRadius: number
119
171
  }
120
172
 
121
- function resolveCategoryCardTokens(
122
- modes: Modes,
123
- active: boolean
124
- ): CategoryCardTokens {
125
- const idleModes = { ...modes, [CATEGORY_CARD_STATE]: 'Idle' }
126
- const activeModes = { ...modes, [CATEGORY_CARD_STATE]: 'Active' }
127
- const resolvedModes = active ? activeModes : idleModes
128
-
129
- const gap = toNumber(getVariableByName('cardTab/gap', resolvedModes), 6)
173
+ function resolveCategoryCardTokens(modes: Modes): CategoryCardTokens {
174
+ const gap = toNumber(getVariableByName('cardTab/gap', modes), 6)
130
175
  const paddingHorizontal = toNumber(
131
- getVariableByName('cardTab/padding/horizontal', resolvedModes),
176
+ getVariableByName('cardTab/padding/horizontal', modes),
132
177
  8
133
178
  )
134
179
  const paddingVertical = toNumber(
135
- getVariableByName('cardTab/padding/vertical', resolvedModes),
180
+ getVariableByName('cardTab/padding/vertical', modes),
136
181
  8
137
182
  )
138
- const radius = toNumber(getVariableByName('cardTab/radius', resolvedModes), 8)
183
+ const radius = toNumber(getVariableByName('cardTab/radius', modes), 8)
139
184
  const strokeWidth = toNumber(
140
- getVariableByName('cardTab/strokeWidth', resolvedModes),
185
+ getVariableByName('cardTab/strokeWidth', modes),
141
186
  1
142
187
  )
188
+ // Active → gold (`#cea15a`); Default → grey (`#f5f5f5`). Both resolve through
189
+ // `Card Tab State` on `cardTab/stroke/color`.
143
190
  const strokeColor =
144
- (getVariableByName('cardTab/stroke/color', resolvedModes) as string | null) ??
191
+ (getVariableByName('cardTab/stroke/color', modes) as string | null) ??
145
192
  '#f5f5f5'
146
193
 
147
- const idleBackground =
148
- (getVariableByName('cardTab/background/color', idleModes) as string | null) ??
194
+ // Both Idle and Active use a white surface — selection is stroke-only.
195
+ const backgroundColor =
196
+ (getVariableByName('cardTab/background/color', modes) as string | null) ??
149
197
  '#ffffff'
150
- const activeBackground =
151
- (getVariableByName('cardTab/background/color', activeModes) as string | null) ??
152
- '#f5f5f5'
153
- const backgroundColor = active ? activeBackground : idleBackground
154
198
 
155
- const imageSize = toNumber(
156
- getVariableByName('cardTab/image/width', resolvedModes),
199
+ const imageWidth = toNumber(
200
+ getVariableByName('cardTab/image/width', modes),
157
201
  36
158
202
  )
159
- const imageRadius = toNumber(
160
- getVariableByName('image/radius', resolvedModes),
161
- 0
203
+ const imageHeight = toNumber(
204
+ getVariableByName('cardTab/image/height', modes),
205
+ imageWidth
162
206
  )
207
+ const imageRadius = toNumber(getVariableByName('image/radius', modes), 0)
163
208
 
209
+ // Prefer component-scoped `cardTab/label/*` (Figma bindings). Do not fall
210
+ // back to bare `label/fontWeight` / `label/lineHeight` — those currently
211
+ // resolve from an unrelated Donut Chart collection (400 / 15.6).
164
212
  const fontFamily =
165
- (getVariableByName('label/fontFamily', resolvedModes) as string | null) ??
213
+ (getVariableByName('cardTab/label/fontFamily', modes) as string | null) ??
214
+ (getVariableByName('label/fontFamily', modes) as string | null) ??
166
215
  'JioType Var'
167
216
  const fontSize = toNumber(
168
- getVariableByName('label/fontSize', resolvedModes),
217
+ getVariableByName('cardTab/label/fontSize', modes) ??
218
+ getVariableByName('label/fontSize', modes),
169
219
  12
170
220
  )
171
221
  const lineHeight = toNumber(
172
- getVariableByName('label/lineHeight', resolvedModes),
173
- Math.round(fontSize * 1.3)
222
+ getVariableByName('cardTab/label/lineHeight', modes),
223
+ 16
174
224
  )
175
225
  const fontWeight = toFontWeight(
176
- getVariableByName('label/fontWeight', resolvedModes),
226
+ getVariableByName('cardTab/label/fontWeight', modes),
177
227
  '500'
178
228
  )
179
229
  const labelColor =
180
- (getVariableByName('cardTab/label/color', resolvedModes) as string | null) ??
230
+ (getVariableByName('cardTab/label/color', modes) as string | null) ??
181
231
  '#000000'
182
232
 
183
233
  return {
@@ -203,7 +253,8 @@ function resolveCategoryCardTokens(
203
253
  lineHeight,
204
254
  textAlign: 'center',
205
255
  },
206
- imageSize,
256
+ imageWidth,
257
+ imageHeight,
207
258
  imageRadius,
208
259
  }
209
260
  }
@@ -217,8 +268,13 @@ const disabledOverlayStyle: ViewStyle = { opacity: 0.5 }
217
268
  * optionally show an overlapping badge at the top edge. Cards are typically
218
269
  * laid out in a horizontal `ScrollView` or `HStack`.
219
270
  *
220
- * All visual values resolve through the `cardTab/*` design tokens with
221
- * sensible Figma defaults so the card renders correctly out of the box.
271
+ * Selection is communicated with a gold stroke (`Card Tab State = Active`);
272
+ * the surface stays white in both Idle and Active. All visual values resolve
273
+ * through the `cardTab/*` design tokens with sensible Figma defaults so the
274
+ * card renders correctly out of the box.
275
+ *
276
+ * The image area is a real slot (`imageSlot` or `children`) matching the Figma
277
+ * Image instance — omit both to keep the default `Image`.
222
278
  *
223
279
  * @component
224
280
  * @param {CategoryCardProps} props
@@ -227,11 +283,12 @@ function CategoryCard({
227
283
  label = 'Popular',
228
284
  imageSource,
229
285
  imageSlot,
286
+ children,
230
287
  badge,
231
- active = false,
288
+ active: activeProp,
232
289
  width = DEFAULT_CARD_WIDTH,
233
- disableTruncation,
234
- singleLine,
290
+ disableTruncation = true,
291
+ singleLine = true,
235
292
  onPress,
236
293
  disabled = false,
237
294
  modes: propModes = EMPTY_MODES,
@@ -245,28 +302,26 @@ function CategoryCard({
245
302
  ...rest
246
303
  }: CategoryCardProps) {
247
304
  const { modes: globalModes } = useTokens()
305
+ const active = resolveIsActive(activeProp, propModes)
306
+ const cardTabState = active ? 'Active' : 'Default'
307
+
248
308
  const modes = useMemo(
249
309
  () => ({
250
310
  ...globalModes,
251
311
  ...CATEGORY_CARD_DEFAULT_MODES,
252
312
  ...propModes,
313
+ // Canonical collection always wins so stroke/background tokens resolve.
314
+ [CARD_TAB_STATE]: cardTabState,
315
+ // Keep the legacy key in sync for any nested consumers still reading it.
316
+ [LEGACY_CARD_TAB_STATE]: active ? 'Active' : 'Idle',
253
317
  }),
254
- [globalModes, propModes]
255
- )
256
-
257
- const resolvedModes = useMemo(
258
- () => ({
259
- ...modes,
260
- [CATEGORY_CARD_STATE]: active ? 'Active' : 'Idle',
261
- }),
262
- [modes, active]
318
+ [globalModes, propModes, cardTabState, active]
263
319
  )
264
320
 
265
- const tokens = useMemo(
266
- () => resolveCategoryCardTokens(resolvedModes, active),
267
- [resolvedModes, active]
268
- )
321
+ const tokens = useMemo(() => resolveCategoryCardTokens(modes), [modes])
269
322
 
323
+ // Same layout merge order as `Text`: consumer `style` first, then layout so
324
+ // single-line / no-truncation overflow always wins.
270
325
  const { style: labelLayoutStyle, ...labelTruncation } = useMemo(
271
326
  () =>
272
327
  resolveTextLayout({
@@ -278,37 +333,27 @@ function CategoryCard({
278
333
  [disableTruncation, singleLine]
279
334
  )
280
335
 
336
+ const slotContent = imageSlot ?? children
281
337
  const processedImageSlot = useMemo(() => {
282
- if (!imageSlot) return null
283
- const processed = cloneChildrenWithModes(imageSlot, resolvedModes)
338
+ if (slotContent == null) return null
339
+ const processed = cloneChildrenWithModes(slotContent, modes)
284
340
  return processed.length === 1 ? processed[0] : processed
285
- }, [imageSlot, resolvedModes])
341
+ }, [slotContent, modes])
286
342
 
287
343
  const imageNode =
288
344
  processedImageSlot ??
289
- (imageSource !== undefined ? (
290
- <Image
291
- imageSource={imageSource}
292
- width={tokens.imageSize}
293
- height={tokens.imageSize}
294
- ratio={1}
295
- resizeMode="cover"
296
- borderRadius={tokens.imageRadius}
297
- accessibilityElementsHidden
298
- importantForAccessibility="no"
299
- />
300
- ) : (
345
+ (
301
346
  <Image
302
- imageSource="https://www.figma.com/api/mcp/asset/0ee34871-646f-4736-be06-22fb6c0a4eed"
303
- width={tokens.imageSize}
304
- height={tokens.imageSize}
347
+ imageSource={imageSource ?? DEFAULT_IMAGE_ASSET}
348
+ width={tokens.imageWidth}
349
+ height={tokens.imageHeight}
305
350
  ratio={1}
306
351
  resizeMode="cover"
307
352
  borderRadius={tokens.imageRadius}
308
353
  accessibilityElementsHidden
309
354
  importantForAccessibility="no"
310
355
  />
311
- ))
356
+ )
312
357
 
313
358
  const a11yLabel = accessibilityLabel ?? (badge ? `${label}, ${badge}` : label)
314
359
 
@@ -322,7 +367,8 @@ function CategoryCard({
322
367
 
323
368
  const badgeNode = badge ? (
324
369
  <View style={BADGE_ANCHOR} pointerEvents="none">
325
- <Badge label={badge} modes={resolvedModes} style={BADGE_CENTER_STYLE} />
370
+ {/* Figma Idle caps the badge at 38dp so it stays within the 60dp card. */}
371
+ <Badge label={badge} modes={modes} style={BADGE_STYLE} />
326
372
  </View>
327
373
  ) : null
328
374
 
@@ -365,7 +411,7 @@ function CategoryCard({
365
411
  {badgeNode}
366
412
  {imageNode}
367
413
  <Text
368
- style={[tokens.labelStyle, labelWidthStyle, labelLayoutStyle, labelStyle]}
414
+ style={[tokens.labelStyle, labelWidthStyle, labelStyle, labelLayoutStyle]}
369
415
  {...labelTruncation}
370
416
  accessibilityElementsHidden
371
417
  importantForAccessibility="no"
@@ -386,7 +432,7 @@ const BADGE_ANCHOR: ViewStyle = {
386
432
  zIndex: 1,
387
433
  }
388
434
 
389
- const BADGE_CENTER_STYLE: ViewStyle = { alignSelf: 'center' }
435
+ const BADGE_STYLE: ViewStyle = { alignSelf: 'center', maxWidth: 38 }
390
436
 
391
437
  /** Lets the label fill the card width so the one-line ellipsis has a bound. */
392
438
  const LABEL_FILL_STYLE: TextStyle = { width: '100%', alignSelf: 'stretch' }
@@ -6,12 +6,21 @@ import {
6
6
  type StyleProp,
7
7
  type ViewStyle,
8
8
  type TextStyle,
9
+ type GestureResponderEvent,
9
10
  } from 'react-native'
10
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
11
12
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
12
13
  import Checkbox from '../Checkbox/Checkbox'
13
14
  import type { Modes } from '../../design-tokens'
14
15
 
16
+ /**
17
+ * Figma's Checkbox Item label slot lays out `Text` + `Link` (etc.) in a
18
+ * horizontal auto-layout frame with `Slot gap = XS` (2px). Consumers may still
19
+ * override via `modes['Slot gap']`. Applied only when resolving the slot gap —
20
+ * not merged into child `modes` — so nested components are unaffected.
21
+ */
22
+ const LABEL_SLOT_GAP_DEFAULTS = { 'Slot gap': 'XS' } as const
23
+
15
24
  export type CheckboxItemProps = {
16
25
  /** Whether the checkbox is checked (controlled). */
17
26
  checked?: boolean
@@ -24,9 +33,13 @@ export type CheckboxItemProps = {
24
33
  /**
25
34
  * Content rendered in the row's label slot (between the leading and trailing
26
35
  * edges). This is the primary way to supply the label and mirrors the Figma
27
- * "slot" — pass a plain string for the default token-styled label, or any
28
- * custom node (e.g. a `<Text>`, a row of `<Text>` + `<Badge>`, etc.) for full
29
- * control. Custom nodes receive the same `modes` as the parent.
36
+ * "Text" slot — pass a plain string for the default token-styled label, or any
37
+ * custom node(s) for full control.
38
+ *
39
+ * Multiple children are laid out in a horizontal row with `slot/gap` (Figma
40
+ * default `Slot gap = XS` → 2px), matching the `Text` + `Link` composition in
41
+ * the design. A single custom node (e.g. a vertical stack) is left as-is.
42
+ * Custom nodes receive the same `modes` as the parent.
30
43
  *
31
44
  * When both `children` and the legacy {@link label} prop are provided,
32
45
  * `children` wins.
@@ -44,7 +57,8 @@ export type CheckboxItemProps = {
44
57
  /**
45
58
  * Position of the checkbox control relative to the label.
46
59
  * - `'leading'` (default): checkbox is rendered on the left, then label, then optional `endSlot` on the right.
47
- * - `'trailing'`: optional `endSlot` is rendered on the left, then label, then checkbox on the right.
60
+ * - `'trailing'`: optional `endSlot` is rendered on the left, then label, then checkbox on the right
61
+ * (row uses `space-between` so the checkbox sits on the trailing edge).
48
62
  *
49
63
  * Matches the Figma "Control" variant on the Checkbox Item component.
50
64
  */
@@ -52,7 +66,8 @@ export type CheckboxItemProps = {
52
66
  /**
53
67
  * Optional content rendered in the row's end slot (e.g. a `Button`). When provided,
54
68
  * it receives the same `modes` as the parent. Wrapped in a fixed-width container
55
- * matching the Figma `endSlot` design.
69
+ * matching the Figma `endSlot` design. Presses on the end slot do not toggle
70
+ * the checkbox.
56
71
  *
57
72
  * Placement depends on `control`:
58
73
  * - With `control="leading"` the slot appears on the right edge (after the label).
@@ -72,15 +87,21 @@ export type CheckboxItemProps = {
72
87
  }
73
88
 
74
89
  /**
75
- * CheckboxItem composes a `Checkbox`, a label and an optional `endSlot` into a
90
+ * CheckboxItem composes a `Checkbox`, a label slot and an optional `endSlot` into a
76
91
  * single horizontal pressable row. Pressing anywhere on the row (outside of the
77
- * `endSlot`) toggles the checkbox, mirroring the typical native form pattern.
92
+ * `endSlot` and nested interactive children such as `Link`) toggles the checkbox,
93
+ * mirroring the typical native form pattern.
78
94
  *
79
95
  * Use the `control` prop to swap the checkbox between the leading (left, default)
80
96
  * and trailing (right) edge of the row. The `endSlot` flips to the opposite edge.
81
97
  *
98
+ * The Figma default composition places `Text` + `Link` in the label slot
99
+ * ("I agree" + "Terms & Conditions"). Pass those as `children` — the slot lays
100
+ * multiple children out in a horizontal row with `slot/gap`.
101
+ *
82
102
  * Mirrors the Figma "Checkbox Item" component and uses the `checkboxItem/*`
83
- * design tokens for typography and spacing.
103
+ * design tokens for row spacing; string labels still resolve `checkboxItem/label/*`
104
+ * for backward-compatible typography.
84
105
  *
85
106
  * @component
86
107
  * @param {CheckboxItemProps} props
@@ -89,13 +110,19 @@ export type CheckboxItemProps = {
89
110
  * ```tsx
90
111
  * const [checked, setChecked] = useState(false)
91
112
  *
92
- * // Recommended: pass the label via the children slot.
113
+ * // Figma default composition Text + Link in the label slot.
93
114
  * <CheckboxItem
94
115
  * checked={checked}
95
116
  * onValueChange={setChecked}
96
117
  * control="leading"
97
118
  * modes={{ 'Color Mode': 'Light' }}
98
119
  * >
120
+ * <Text text="I agree" autolayout="Hug" />
121
+ * <Link text="Terms & Conditions" autolayout="Hug" onPress={openTerms} />
122
+ * </CheckboxItem>
123
+ *
124
+ * // Plain string (still supported).
125
+ * <CheckboxItem checked={checked} onValueChange={setChecked}>
99
126
  * Fixed deposit • 0245
100
127
  * </CheckboxItem>
101
128
  *
@@ -124,8 +151,8 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
124
151
  // 2. otherwise the legacy `label` prop, whenever it was passed at all —
125
152
  // including an explicit `null`/`false`, which (as before) hides the
126
153
  // label entirely.
127
- // 3. otherwise the Figma placeholder, so the default story still renders
128
- // something meaningful.
154
+ // 3. otherwise the Figma plain-string placeholder, so the default story
155
+ // still renders something meaningful when no slot content is supplied.
129
156
  const hasChildren = children != null && children !== false
130
157
  const slotContent: React.ReactNode = hasChildren
131
158
  ? children
@@ -151,6 +178,12 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
151
178
  (getVariableByName('checkboxItem/padding/horizontal', modes) as number | null) ?? 0
152
179
  const paddingVertical =
153
180
  (getVariableByName('checkboxItem/padding/vertical', modes) as number | null) ?? 0
181
+ // Label-slot internal gap — Figma uses Slot gap = XS (2). Consumer modes win.
182
+ const labelSlotGap =
183
+ (getVariableByName('slot/gap', {
184
+ ...LABEL_SLOT_GAP_DEFAULTS,
185
+ ...modes,
186
+ }) as number | null) ?? 2
154
187
 
155
188
  const labelColor =
156
189
  (getVariableByName('checkboxItem/foreground', modes) as string | null) ?? '#1a1c1f'
@@ -168,11 +201,17 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
168
201
  flexDirection: 'row',
169
202
  alignItems: 'center',
170
203
  gap,
204
+ // Trailing matches Figma: space-between pins the checkbox to the far edge
205
+ // while the label (and optional endSlot) stay on the leading side.
206
+ justifyContent: isTrailing ? 'space-between' : 'flex-start',
171
207
  paddingHorizontal,
172
208
  paddingVertical,
173
209
  width: '100%',
174
210
  }
175
211
 
212
+ // String labels keep the historical checkboxItem/label/* typography so
213
+ // existing plain-string call-sites do not change visually. `flex: 1` is
214
+ // retained in both control modes so long labels still wrap inside the row.
176
215
  const resolvedLabelStyle: TextStyle = {
177
216
  flex: 1,
178
217
  minWidth: 0,
@@ -203,17 +242,36 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
203
242
  // behaviour of the `label` prop).
204
243
  const isTextSlot =
205
244
  typeof slotContent === 'string' || typeof slotContent === 'number'
245
+
246
+ const labelSlotStyle: ViewStyle = {
247
+ // Grow to fill remaining row width so long content wraps. Trailing rows
248
+ // also set `justifyContent: 'space-between'` so the checkbox stays pinned
249
+ // to the far edge (Figma Control=Trailing).
250
+ flex: 1,
251
+ minWidth: 0,
252
+ flexDirection: 'row',
253
+ flexWrap: 'wrap',
254
+ alignItems: 'flex-start',
255
+ gap: labelSlotGap,
256
+ }
257
+
206
258
  const labelNode =
207
259
  slotContent == null || slotContent === false ? null : isTextSlot ? (
208
260
  <Text style={[resolvedLabelStyle, labelStyle]} selectable={false}>
209
261
  {slotContent}
210
262
  </Text>
211
263
  ) : (
212
- <View style={{ flex: 1, minWidth: 0 }}>
264
+ <View style={labelSlotStyle}>
213
265
  {cloneChildrenWithModes(slotContent, modes)}
214
266
  </View>
215
267
  )
216
268
 
269
+ // Capture the end-slot responder so presses on nested actions (e.g. Button)
270
+ // never bubble to the row Pressable and toggle the checkbox.
271
+ const suppressRowToggle = useCallback((event: GestureResponderEvent) => {
272
+ event.stopPropagation?.()
273
+ }, [])
274
+
217
275
  const endSlotNode = endSlot ? (
218
276
  <View
219
277
  style={{
@@ -221,6 +279,8 @@ const CheckboxItem = forwardRef<View, CheckboxItemProps>(function CheckboxItem({
221
279
  flexShrink: 0,
222
280
  alignItems: 'stretch',
223
281
  }}
282
+ onStartShouldSetResponder={() => true}
283
+ onTouchEnd={suppressRowToggle}
224
284
  >
225
285
  {cloneChildrenWithModes(endSlot, modes)}
226
286
  </View>