jfs-components 0.0.74 → 0.0.78

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 (146) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +55 -55
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +193 -82
  4. package/lib/commonjs/components/Avatar/Avatar.js +20 -0
  5. package/lib/commonjs/components/Badge/Badge.js +23 -0
  6. package/lib/commonjs/components/Button/Button.js +37 -0
  7. package/lib/commonjs/components/Checkbox/Checkbox.js +21 -9
  8. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
  9. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
  10. package/lib/commonjs/components/FormField/FormField.js +14 -1
  11. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +355 -0
  12. package/lib/commonjs/components/IconButton/IconButton.js +20 -0
  13. package/lib/commonjs/components/Image/Image.js +26 -1
  14. package/lib/commonjs/components/ListItem/ListItem.js +25 -10
  15. package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
  16. package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
  17. package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
  18. package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
  19. package/lib/commonjs/components/MessageField/MessageField.js +318 -0
  20. package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
  21. package/lib/commonjs/components/PageHero/PageHero.js +41 -5
  22. package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
  23. package/lib/commonjs/components/Stepper/Step.js +47 -60
  24. package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
  25. package/lib/commonjs/components/Stepper/Stepper.js +15 -17
  26. package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
  27. package/lib/commonjs/components/Text/Text.js +31 -1
  28. package/lib/commonjs/components/TextInput/TextInput.js +16 -1
  29. package/lib/commonjs/components/Title/Title.js +10 -2
  30. package/lib/commonjs/components/index.js +35 -0
  31. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  32. package/lib/commonjs/icons/Icon.js +16 -0
  33. package/lib/commonjs/icons/registry.js +1 -1
  34. package/lib/commonjs/index.js +12 -0
  35. package/lib/commonjs/skeleton/Skeleton.js +234 -0
  36. package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
  37. package/lib/commonjs/skeleton/index.js +58 -0
  38. package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
  39. package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
  40. package/lib/module/components/Accordion/Accordion.js +56 -56
  41. package/lib/module/components/ActionFooter/ActionFooter.js +193 -83
  42. package/lib/module/components/Avatar/Avatar.js +19 -0
  43. package/lib/module/components/Badge/Badge.js +23 -0
  44. package/lib/module/components/Button/Button.js +37 -0
  45. package/lib/module/components/Checkbox/Checkbox.js +22 -10
  46. package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
  47. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
  48. package/lib/module/components/FormField/FormField.js +16 -3
  49. package/lib/module/components/FullscreenModal/FullscreenModal.js +350 -0
  50. package/lib/module/components/IconButton/IconButton.js +20 -0
  51. package/lib/module/components/Image/Image.js +25 -1
  52. package/lib/module/components/ListItem/ListItem.js +25 -10
  53. package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
  54. package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
  55. package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
  56. package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
  57. package/lib/module/components/MessageField/MessageField.js +313 -0
  58. package/lib/module/components/NavArrow/NavArrow.js +59 -18
  59. package/lib/module/components/PageHero/PageHero.js +41 -5
  60. package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
  61. package/lib/module/components/Stepper/Step.js +48 -61
  62. package/lib/module/components/Stepper/StepLabel.js +40 -10
  63. package/lib/module/components/Stepper/Stepper.js +15 -17
  64. package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
  65. package/lib/module/components/Text/Text.js +31 -1
  66. package/lib/module/components/TextInput/TextInput.js +17 -2
  67. package/lib/module/components/Title/Title.js +10 -2
  68. package/lib/module/components/index.js +5 -0
  69. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  70. package/lib/module/icons/Icon.js +16 -0
  71. package/lib/module/icons/registry.js +1 -1
  72. package/lib/module/index.js +2 -1
  73. package/lib/module/skeleton/Skeleton.js +229 -0
  74. package/lib/module/skeleton/SkeletonGroup.js +133 -0
  75. package/lib/module/skeleton/index.js +6 -0
  76. package/lib/module/skeleton/shimmer-tokens.js +181 -0
  77. package/lib/module/skeleton/useReducedMotion.js +61 -0
  78. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
  79. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
  80. package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
  81. package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
  82. package/lib/typescript/src/components/Button/Button.d.ts +8 -1
  83. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
  84. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
  85. package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
  86. package/lib/typescript/src/components/Image/Image.d.ts +8 -1
  87. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
  88. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
  89. package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
  90. package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
  91. package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
  92. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
  93. package/lib/typescript/src/components/PageHero/PageHero.d.ts +31 -5
  94. package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
  95. package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
  96. package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
  97. package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
  98. package/lib/typescript/src/components/Text/Text.d.ts +20 -1
  99. package/lib/typescript/src/components/index.d.ts +8 -3
  100. package/lib/typescript/src/icons/Icon.d.ts +7 -1
  101. package/lib/typescript/src/icons/registry.d.ts +1 -1
  102. package/lib/typescript/src/index.d.ts +1 -0
  103. package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
  104. package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
  105. package/lib/typescript/src/skeleton/index.d.ts +5 -0
  106. package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
  107. package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
  108. package/package.json +11 -1
  109. package/src/components/Accordion/Accordion.tsx +113 -73
  110. package/src/components/ActionFooter/ActionFooter.tsx +210 -92
  111. package/src/components/Avatar/Avatar.tsx +26 -0
  112. package/src/components/Badge/Badge.tsx +27 -0
  113. package/src/components/Button/Button.tsx +40 -0
  114. package/src/components/Checkbox/Checkbox.tsx +22 -9
  115. package/src/components/DropdownInput/DropdownInput.tsx +67 -39
  116. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
  117. package/src/components/FormField/FormField.tsx +19 -3
  118. package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
  119. package/src/components/IconButton/IconButton.tsx +27 -0
  120. package/src/components/Image/Image.tsx +25 -0
  121. package/src/components/ListItem/ListItem.tsx +21 -10
  122. package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
  123. package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
  124. package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
  125. package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
  126. package/src/components/MessageField/MessageField.tsx +543 -0
  127. package/src/components/NavArrow/NavArrow.tsx +81 -17
  128. package/src/components/PageHero/PageHero.tsx +61 -4
  129. package/src/components/RechargeCard/RechargeCard.tsx +32 -24
  130. package/src/components/Stepper/Step.tsx +52 -51
  131. package/src/components/Stepper/StepLabel.tsx +46 -9
  132. package/src/components/Stepper/Stepper.tsx +20 -15
  133. package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
  134. package/src/components/Text/Text.tsx +54 -0
  135. package/src/components/TextInput/TextInput.tsx +14 -1
  136. package/src/components/Title/Title.tsx +13 -2
  137. package/src/components/index.ts +8 -3
  138. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  139. package/src/icons/Icon.tsx +17 -0
  140. package/src/icons/registry.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/skeleton/Skeleton.tsx +298 -0
  143. package/src/skeleton/SkeletonGroup.tsx +193 -0
  144. package/src/skeleton/index.ts +10 -0
  145. package/src/skeleton/shimmer-tokens.ts +221 -0
  146. package/src/skeleton/useReducedMotion.ts +72 -0
@@ -14,6 +14,8 @@ import {
14
14
  } from 'react-native'
15
15
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
16
16
  import { EMPTY_MODES } from '../../utils/react-utils'
17
+ import Skeleton from '../../skeleton/Skeleton'
18
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
17
19
 
18
20
  const avatarImage = require('./31595e70c4181263f9971590224b12934b280c9b.png')
19
21
 
@@ -134,6 +136,12 @@ export type AvatarProps = {
134
136
  accessibilityLabel?: string;
135
137
  onPress?: () => void;
136
138
  disabled?: boolean;
139
+ /**
140
+ * Explicit per-instance loading override. When `true`, renders a
141
+ * same-size circular skeleton instead of the avatar. Defaults to
142
+ * inheriting from the surrounding `<SkeletonGroup>`.
143
+ */
144
+ loading?: boolean;
137
145
  } & Omit<React.ComponentProps<typeof View>, 'style' | 'accessibilityRole' | 'accessibilityLabel'>;
138
146
 
139
147
  function Avatar({
@@ -145,11 +153,17 @@ function Avatar({
145
153
  // component intentionally renders `accessibilityLabel={undefined}` on the
146
154
  // wrapper (the inner Text/Image carry the label instead).
147
155
  accessibilityLabel: _accessibilityLabel,
156
+ loading,
148
157
  ...rest
149
158
  }: AvatarProps) {
150
159
  const isMonogram = style === 'Monogram'
151
160
  const tokens = useMemo(() => resolveAvatarTokens(modes, isMonogram), [modes, isMonogram])
152
161
 
162
+ // Skeleton context — read unconditionally; the actual short-circuit
163
+ // happens AFTER all remaining hooks below.
164
+ const { active: groupActive } = useSkeleton()
165
+ const isLoading = loading ?? groupActive
166
+
153
167
  // Focus is a sustained visible state — keep mirroring on web; gate the
154
168
  // setter so it never fires on native (where focus events don't fire on
155
169
  // these elements anyway).
@@ -197,6 +211,18 @@ function Avatar({
197
211
  [tokens.containerStyle, isFocused]
198
212
  )
199
213
 
214
+ if (isLoading) {
215
+ const size = tokens.containerStyle.width as number
216
+ return (
217
+ <Skeleton
218
+ kind="other"
219
+ width={size}
220
+ height={size}
221
+ modes={modes}
222
+ />
223
+ )
224
+ }
225
+
200
226
  // The inner content varies; everything else (wrapper, handlers, style) is shared.
201
227
  const innerContent = isMonogram ? (
202
228
  <View style={monogramContainerStyle}>
@@ -8,6 +8,8 @@ import {
8
8
  } from 'react-native'
9
9
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
10
  import { EMPTY_MODES } from '../../utils/react-utils'
11
+ import Skeleton from '../../skeleton/Skeleton'
12
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
11
13
 
12
14
  type BadgeProps = {
13
15
  /** Visible label text shown inside the badge */
@@ -19,6 +21,12 @@ type BadgeProps = {
19
21
  accessibilityLabel?: string
20
22
  style?: ViewStyle
21
23
  labelStyle?: TextStyle
24
+ /**
25
+ * Explicit per-instance loading override. When `true`, renders a
26
+ * badge-shaped skeleton placeholder instead of the badge. Defaults to
27
+ * inheriting from the surrounding `<SkeletonGroup>`.
28
+ */
29
+ loading?: boolean
22
30
  } & Omit<React.ComponentProps<typeof View>, 'style' | 'accessibilityLabel' | 'accessibilityRole'>
23
31
 
24
32
  function Badge({
@@ -28,6 +36,7 @@ function Badge({
28
36
  accessibilityLabel,
29
37
  style,
30
38
  labelStyle,
39
+ loading,
31
40
  ...rest
32
41
  }: BadgeProps) {
33
42
  // Resolve token values (fall back to sensible defaults)
@@ -51,6 +60,24 @@ function Badge({
51
60
  Number(getVariableByName('badge/label/lineHeight', modes) as unknown) ||
52
61
  Math.round(fontSize * 1.2)
53
62
 
63
+ // Skeleton short-circuit. Size derived from the same tokens the loaded
64
+ // badge would use so the placeholder occupies the same box.
65
+ const { active: groupActive } = useSkeleton()
66
+ const isLoading = loading ?? groupActive
67
+ if (isLoading) {
68
+ const charWidth = fontSize * 0.55
69
+ const labelWidth = Math.max(label.length, 3) * charWidth
70
+ return (
71
+ <Skeleton
72
+ kind="badge"
73
+ width={paddingHorizontal * 2 + labelWidth}
74
+ height={paddingVertical * 2 + lineHeight}
75
+ style={{ alignSelf: 'flex-start' }}
76
+ modes={modes}
77
+ />
78
+ )
79
+ }
80
+
54
81
  const Container: any = onPress ? Pressable : View
55
82
 
56
83
  const containerStyle: ViewStyle = {
@@ -14,6 +14,8 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
14
14
  import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
15
15
  import { EMPTY_MODES } from '../../utils/react-utils'
16
16
  import Icon from '../../icons/Icon'
17
+ import Skeleton from '../../skeleton/Skeleton'
18
+ import { useSkeleton } from '../../skeleton/SkeletonGroup'
17
19
 
18
20
  export type ButtonProps = SafePressableProps & {
19
21
  label?: string;
@@ -54,6 +56,13 @@ export type ButtonProps = SafePressableProps & {
54
56
  * Web-specific accessibility props (only used on web platform)
55
57
  */
56
58
  webAccessibilityProps?: WebAccessibilityProps;
59
+ /**
60
+ * Explicit per-instance loading override. When `true`, the button renders
61
+ * as a pill-shaped skeleton of the same size; when `false`, the
62
+ * surrounding `<SkeletonGroup>` is ignored. Defaults to inheriting from
63
+ * the group.
64
+ */
65
+ loading?: boolean;
57
66
  };
58
67
 
59
68
  // ---------------------------------------------------------------------------
@@ -224,6 +233,7 @@ function ButtonImpl({
224
233
  accessibilityHint,
225
234
  accessibilityState,
226
235
  webAccessibilityProps,
236
+ loading,
227
237
  ...rest
228
238
  }: ButtonProps) {
229
239
  // Hover state is web-only in practice; the setter is gated so native taps
@@ -248,6 +258,12 @@ function ButtonImpl({
248
258
  [modes, disabled]
249
259
  )
250
260
 
261
+ // Skeleton context — read unconditionally so React's hook order stays
262
+ // stable. The actual short-circuit return happens AFTER all remaining
263
+ // hooks have been called below.
264
+ const { active: groupActive } = useSkeleton()
265
+ const isLoading = loading ?? groupActive
266
+
251
267
  // Active label color: base by default; hover override (web-only) when hovered.
252
268
  // Press color is intentionally NOT applied to the label on native — applying
253
269
  // it would require a React render per touch and re-introduce the flicker.
@@ -354,6 +370,30 @@ function ButtonImpl({
354
370
  }
355
371
  }
356
372
 
373
+ if (isLoading) {
374
+ const { container, baseLabel, iconSize, accessoryOffset } = tokens
375
+ const paddingHorizontal = (container.paddingHorizontal as number) ?? 20
376
+ const paddingVertical = (container.paddingVertical as number) ?? 12
377
+ const lineHeight = (baseLabel.lineHeight as number) ?? 19
378
+ const fontSize = (baseLabel.fontSize as number) ?? 16
379
+ const labelText = typeof label === 'string' ? label : 'Button'
380
+ const charWidth = fontSize * 0.55
381
+ const labelWidth = Math.max(labelText.length, 4) * charWidth
382
+ const hasAccessory = !!(leading || trailing || icon)
383
+ const accessoryWidth = hasAccessory ? iconSize + accessoryOffset * 2 : 0
384
+ const skeletonWidth = paddingHorizontal * 2 + labelWidth + accessoryWidth
385
+ const skeletonHeight = paddingVertical * 2 + lineHeight
386
+ return (
387
+ <Skeleton
388
+ kind="other"
389
+ width={skeletonWidth}
390
+ height={skeletonHeight}
391
+ style={style as any}
392
+ modes={modes}
393
+ />
394
+ )
395
+ }
396
+
357
397
  return (
358
398
  <Pressable
359
399
  accessibilityRole="button"
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
2
  import {
3
3
  Pressable,
4
4
  Platform,
5
+ View,
5
6
  type StyleProp,
6
7
  type ViewStyle,
7
8
  } from 'react-native'
@@ -50,6 +51,16 @@ function useFocusVisible() {
50
51
  return { isFocusVisible, focusHandlers: { onFocus, onBlur } }
51
52
  }
52
53
 
54
+ /** Minimum touch target per iOS HIG / Material accessibility guidance. */
55
+ const MIN_TOUCH_TARGET = 44
56
+
57
+ const touchTargetStyle: ViewStyle = {
58
+ minWidth: MIN_TOUCH_TARGET,
59
+ minHeight: MIN_TOUCH_TARGET,
60
+ alignItems: 'center',
61
+ justifyContent: 'center',
62
+ }
63
+
53
64
  export interface CheckboxProps {
54
65
  /** Whether the checkbox is checked (controlled) */
55
66
  checked?: boolean
@@ -207,7 +218,7 @@ function Checkbox({
207
218
 
208
219
  return (
209
220
  <Pressable
210
- style={[resolveStyle(), style]}
221
+ style={[touchTargetStyle, style]}
211
222
  onPress={handlePress}
212
223
  disabled={disabled}
213
224
  onHoverIn={() => setIsHovered(true)}
@@ -217,14 +228,16 @@ function Checkbox({
217
228
  accessibilityState={{ checked: isChecked, disabled }}
218
229
  accessibilityLabel={accessibilityLabel}
219
230
  >
220
- {isChecked && (
221
- <Svg width={12} height={9} viewBox="0 0 12 9" fill="none">
222
- <Path
223
- d="M4.00091 8.66939C3.91321 8.6699 3.82628 8.65309 3.74509 8.61991C3.6639 8.58673 3.59006 8.53785 3.52779 8.47606L0.195972 5.14273C0.0704931 5.01719 -1.86978e-09 4.84693 0 4.66939C1.86978e-09 4.49186 0.0704931 4.3216 0.195972 4.19606C0.321451 4.07053 0.491636 4 0.66909 4C0.846544 4 1.01673 4.07053 1.14221 4.19606L4.00091 7.06273L10.8578 0.196061C10.9833 0.0705253 11.1535 0 11.3309 0C11.5084 0 11.6785 0.0705253 11.804 0.196061C11.9295 0.321597 12 0.49186 12 0.669394C12 0.846929 11.9295 1.01719 11.804 1.14273L4.47403 8.47606C4.41176 8.53785 4.33792 8.58673 4.25673 8.61991C4.17554 8.65309 4.08861 8.6699 4.00091 8.66939Z"
224
- fill={markColor}
225
- />
226
- </Svg>
227
- )}
231
+ <View style={resolveStyle()}>
232
+ {isChecked && (
233
+ <Svg width={12} height={9} viewBox="0 0 12 9" fill="none">
234
+ <Path
235
+ d="M4.00091 8.66939C3.91321 8.6699 3.82628 8.65309 3.74509 8.61991C3.6639 8.58673 3.59006 8.53785 3.52779 8.47606L0.195972 5.14273C0.0704931 5.01719 -1.86978e-09 4.84693 0 4.66939C1.86978e-09 4.49186 0.0704931 4.3216 0.195972 4.19606C0.321451 4.07053 0.491636 4 0.66909 4C0.846544 4 1.01673 4.07053 1.14221 4.19606L4.00091 7.06273L10.8578 0.196061C10.9833 0.0705253 11.1535 0 11.3309 0C11.5084 0 11.6785 0.0705253 11.804 0.196061C11.9295 0.321597 12 0.49186 12 0.669394C12 0.846929 11.9295 1.01719 11.804 1.14273L4.47403 8.47606C4.41176 8.53785 4.33792 8.58673 4.25673 8.61991C4.17554 8.65309 4.08861 8.6699 4.00091 8.66939Z"
236
+ fill={markColor}
237
+ />
238
+ </Svg>
239
+ )}
240
+ </View>
228
241
  </Pressable>
229
242
  )
230
243
  }
@@ -162,51 +162,60 @@ function useChevronTokens(modes: Record<string, any>) {
162
162
  }, [modes])
163
163
  }
164
164
 
165
+ function toNumber(value: unknown, fallback: number): number {
166
+ if (typeof value === 'number' && Number.isFinite(value)) return value
167
+ if (typeof value === 'string') {
168
+ const parsed = parseFloat(value)
169
+ if (Number.isFinite(parsed)) return parsed
170
+ }
171
+ return fallback
172
+ }
173
+
165
174
  function useFormFieldTokens(modes: Record<string, any>) {
166
175
  return useMemo(() => {
167
176
  const labelColor =
168
177
  (getVariableByName('formField/label/color', modes) as string) ||
169
- '#0c0d10'
178
+ '#000000'
170
179
  const labelFontFamily =
171
180
  (getVariableByName('formField/label/fontFamily', modes) as string) ||
172
181
  'JioType Var'
173
- const labelFontSize =
174
- parseInt(getVariableByName('formField/label/fontSize', modes), 10) ||
182
+ const labelFontSize = toNumber(
183
+ getVariableByName('formField/label/fontSize', modes),
175
184
  14
176
- const labelLineHeight =
177
- parseInt(
178
- getVariableByName('formField/label/lineHeight', modes),
179
- 10
180
- ) || 17
185
+ )
186
+ const labelLineHeight = toNumber(
187
+ getVariableByName('formField/label/lineHeight', modes),
188
+ 17
189
+ )
181
190
  const labelFontWeight =
182
191
  (getVariableByName('formField/label/fontWeight', modes) as string) ||
183
192
  '500'
184
193
 
185
- const gap = parseInt(getVariableByName('formField/gap', modes), 10) || 8
186
-
187
- const inputPaddingH =
188
- parseInt(
189
- getVariableByName('formField/input/padding/horizontal', modes),
190
- 10
191
- ) || 12
192
- const inputGap =
193
- parseInt(getVariableByName('formField/input/gap', modes), 10) || 8
194
- const inputRadius =
195
- parseInt(getVariableByName('formField/input/radius', modes), 10) ||
194
+ const gap = toNumber(getVariableByName('formField/gap', modes), 8)
195
+
196
+ const inputPaddingH = toNumber(
197
+ getVariableByName('formField/input/padding/horizontal', modes),
198
+ 12
199
+ )
200
+ const inputGap = toNumber(
201
+ getVariableByName('formField/input/gap', modes),
196
202
  8
203
+ )
204
+ const inputRadius = toNumber(
205
+ getVariableByName('formField/input/radius', modes),
206
+ 8
207
+ )
197
208
  const inputBackground =
198
209
  (getVariableByName('formField/input/background', modes) as string) ||
199
210
  '#ffffff'
200
- const inputFontSize =
201
- parseInt(
202
- getVariableByName('formField/input/label/fontSize', modes),
203
- 10
204
- ) || 16
205
- const inputLineHeight =
206
- parseInt(
207
- getVariableByName('formField/input/label/lineHeight', modes),
208
- 10
209
- ) || 45
211
+ const inputFontSize = toNumber(
212
+ getVariableByName('formField/input/label/fontSize', modes),
213
+ 16
214
+ )
215
+ const inputLineHeight = toNumber(
216
+ getVariableByName('formField/input/label/lineHeight', modes),
217
+ 45
218
+ )
210
219
  const inputFontFamily =
211
220
  (getVariableByName(
212
221
  'formField/input/label/fontFamily',
@@ -231,11 +240,13 @@ function useFormFieldTokens(modes: Record<string, any>) {
231
240
  ) as string) ||
232
241
  (getVariableByName('formField/input/border/color', modes) as string) ||
233
242
  '#b5b6b7'
234
- const inputBorderSize =
235
- parseInt(
236
- getVariableByName('formField/input/border/size', modes),
237
- 10
238
- ) || 1
243
+ // Figma spec: 1.5px. Using parseFloat (via toNumber) preserves the
244
+ // fractional value — parseInt was truncating it to 1, leaving the
245
+ // resolved row height ~1px shorter than the Figma reference.
246
+ const inputBorderSize = toNumber(
247
+ getVariableByName('formField/input/border/size', modes),
248
+ 1.5
249
+ )
239
250
 
240
251
  return {
241
252
  labelColor,
@@ -314,7 +325,7 @@ function DropdownInput({
314
325
  supportText,
315
326
  errorMessage,
316
327
  menuMaxHeight = 240,
317
- menuOffset = 4,
328
+ menuOffset = 6,
318
329
  matchTriggerWidth = true,
319
330
  closeOnBackdropPress = true,
320
331
  modes: propModes = EMPTY_MODES,
@@ -594,19 +605,23 @@ function DropdownInput({
594
605
  }
595
606
 
596
607
  // Focus ring uses the resolved input border color from FormField States so
597
- // active/error look consistent with TextInput-based FormField. We also lift
598
- // border weight to 2 when "Active" to read as a focus ring.
608
+ // active/error look consistent with TextInput-based FormField. Only the
609
+ // color changes between states width stays constant to avoid layout
610
+ // shift when opening the menu (a shift would invalidate the measured
611
+ // trigger rect and visually shove the popup).
599
612
  const inputRowStyle: ViewStyle = {
600
613
  flexDirection: 'row',
601
614
  alignItems: 'center',
602
615
  backgroundColor: tokens.inputBackground,
603
616
  borderColor: tokens.inputBorderColor,
604
- borderWidth: isOpen ? Math.max(tokens.inputBorderSize, 1) : tokens.inputBorderSize,
617
+ borderWidth: tokens.inputBorderSize,
618
+ borderStyle: 'solid',
605
619
  borderRadius: tokens.inputRadius,
606
620
  paddingHorizontal: tokens.inputPaddingH,
607
621
  paddingVertical: 0,
608
622
  gap: tokens.inputGap,
609
623
  minHeight: tokens.inputLineHeight,
624
+ width: '100%',
610
625
  }
611
626
 
612
627
  const valueTextStyle: TextStyle = {
@@ -763,12 +778,25 @@ function DropdownInput({
763
778
  />
764
779
  )}
765
780
 
781
+ {/*
782
+ IMPORTANT: do NOT pass `statusBarTranslucent` to this Modal.
783
+ On Android, a `statusBarTranslucent` Modal opens its own window
784
+ that spans the entire screen (origin at screen-top, including
785
+ the status bar), but `measureInWindow` on the trigger returns
786
+ coordinates relative to the *activity* window — which on a
787
+ default Android setup starts BELOW the status bar. The two
788
+ coordinate spaces then differ by `StatusBar.currentHeight`, so
789
+ `triggerRect.y + triggerRect.height + menuOffset` lands roughly
790
+ one status-bar-height ABOVE the visible input, making the
791
+ popup overlap the input row. Leaving `statusBarTranslucent`
792
+ off keeps the Modal's window aligned with the activity
793
+ window, which is what every measurement here assumes.
794
+ */}
766
795
  <Modal
767
796
  visible={isOpen}
768
797
  transparent
769
798
  animationType="fade"
770
799
  onRequestClose={closeMenu}
771
- statusBarTranslucent
772
800
  >
773
801
  <Pressable
774
802
  style={StyleSheet.absoluteFill}
@@ -0,0 +1,237 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ type TextStyle,
8
+ } from 'react-native'
9
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
+ import { EMPTY_MODES } from '../../utils/react-utils'
11
+ import Checkbox from '../Checkbox/Checkbox'
12
+ import Button from '../Button/Button'
13
+
14
+ export type ExpandableCheckboxProps = {
15
+ /** Long text label rendered next to the checkbox. */
16
+ label?: string
17
+ /** Whether the checkbox is checked (controlled). */
18
+ checked?: boolean
19
+ /** Initial checked state (uncontrolled). */
20
+ defaultChecked?: boolean
21
+ /** Callback fired when the checked state changes. */
22
+ onValueChange?: (checked: boolean) => void
23
+ /** Whether the row is expanded to reveal the full label (controlled). */
24
+ expanded?: boolean
25
+ /** Initial expanded state (uncontrolled). Defaults to `false` (Idle). */
26
+ defaultExpanded?: boolean
27
+ /** Callback fired when the expanded state changes. */
28
+ onExpandedChange?: (expanded: boolean) => void
29
+ /** Whether the entire row is disabled. */
30
+ disabled?: boolean
31
+ /** Label for the toggle button shown when the row is collapsed. */
32
+ readMoreLabel?: string
33
+ /** Label for the toggle button shown when the row is expanded. */
34
+ readLessLabel?: string
35
+ /** Number of lines to show when collapsed. Defaults to `1`. */
36
+ collapsedLines?: number
37
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
38
+ modes?: Record<string, any>
39
+ /** Override outer container styles. */
40
+ style?: StyleProp<ViewStyle>
41
+ /** Override the label text styles. */
42
+ labelStyle?: StyleProp<TextStyle>
43
+ /** Accessibility label for the checkbox. Falls back to `label`. */
44
+ accessibilityLabel?: string
45
+ }
46
+
47
+ /**
48
+ * Default modes applied to the inner toggle `Button`. These resolve the
49
+ * tertiary-style pill in the Figma reference (small, transparent background,
50
+ * brand purple foreground). Any value supplied via the consumer `modes` prop
51
+ * takes precedence over these defaults.
52
+ */
53
+ const BUTTON_DEFAULT_MODES = {
54
+ 'Button / Size': 'XS',
55
+ AppearanceBrand: 'Secondary',
56
+ Emphasis: 'Low',
57
+ } as const
58
+
59
+ /**
60
+ * ExpandableCheckbox composes a `Checkbox`, a long-form label and a
61
+ * "Read more" / "Read less" toggle. Mirrors the Figma "Expandable Checkbox"
62
+ * component with two states:
63
+ *
64
+ * - **Idle (collapsed)** — checkbox + truncated label + toggle button arranged
65
+ * in a horizontal row (cross-axis centered).
66
+ * - **Open (expanded)** — checkbox + full multi-line label, with the toggle
67
+ * button right-aligned beneath the row.
68
+ *
69
+ * The checkbox and the toggle button have independent press handlers — pressing
70
+ * the toggle does not affect the checked state, and toggling the checkbox does
71
+ * not collapse / expand the row.
72
+ *
73
+ * @component
74
+ * @param {ExpandableCheckboxProps} props
75
+ *
76
+ * @example
77
+ * ```tsx
78
+ * <ExpandableCheckbox
79
+ * label="By checking this box, I (a) acknowledge and (b) agree to the full terms…"
80
+ * defaultChecked
81
+ * onValueChange={setAccepted}
82
+ * modes={{ 'Color Mode': 'Light' }}
83
+ * />
84
+ * ```
85
+ */
86
+ function ExpandableCheckbox({
87
+ label = '',
88
+ checked: controlledChecked,
89
+ defaultChecked = false,
90
+ onValueChange,
91
+ expanded: controlledExpanded,
92
+ defaultExpanded = false,
93
+ onExpandedChange,
94
+ disabled = false,
95
+ readMoreLabel = 'Read more',
96
+ readLessLabel = 'Read less',
97
+ collapsedLines = 1,
98
+ modes = EMPTY_MODES,
99
+ style,
100
+ labelStyle,
101
+ accessibilityLabel,
102
+ }: ExpandableCheckboxProps) {
103
+ const isCheckedControlled = controlledChecked !== undefined
104
+ const [internalChecked, setInternalChecked] = useState(defaultChecked)
105
+ const isChecked = isCheckedControlled ? controlledChecked : internalChecked
106
+
107
+ const isExpandedControlled = controlledExpanded !== undefined
108
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
109
+ const isExpanded = isExpandedControlled ? controlledExpanded : internalExpanded
110
+
111
+ const handleToggleChecked = useCallback(
112
+ (next: boolean) => {
113
+ if (disabled) return
114
+ if (!isCheckedControlled) setInternalChecked(next)
115
+ onValueChange?.(next)
116
+ },
117
+ [disabled, isCheckedControlled, onValueChange]
118
+ )
119
+
120
+ const handleToggleExpanded = useCallback(() => {
121
+ if (disabled) return
122
+ const next = !isExpanded
123
+ if (!isExpandedControlled) setInternalExpanded(next)
124
+ onExpandedChange?.(next)
125
+ }, [disabled, isExpanded, isExpandedControlled, onExpandedChange])
126
+
127
+ const gap =
128
+ (getVariableByName('expandableCheckbox/gap', modes) as number | null) ?? 8
129
+
130
+ const rowGap =
131
+ (getVariableByName('checkboxItem/gap', modes) as number | null) ?? 8
132
+ const rowPaddingHorizontal =
133
+ (getVariableByName('checkboxItem/padding/horizontal', modes) as number | null) ?? 0
134
+ const rowPaddingVertical =
135
+ (getVariableByName('checkboxItem/padding/vertical', modes) as number | null) ?? 0
136
+
137
+ const labelColor =
138
+ (getVariableByName('checkboxItem/foreground', modes) as string | null) ?? '#1a1c1f'
139
+ const labelFontFamily =
140
+ (getVariableByName('checkboxItem/label/fontFamily', modes) as string | null) ?? 'JioType Var'
141
+ const labelFontSize =
142
+ (getVariableByName('checkboxItem/label/fontSize', modes) as number | null) ?? 14
143
+ const labelLineHeight =
144
+ (getVariableByName('checkboxItem/label/lineHeight', modes) as number | null) ?? 19
145
+ const labelFontWeightRaw =
146
+ getVariableByName('checkboxItem/label/fontWeight', modes) ?? 400
147
+ const labelFontWeight = String(labelFontWeightRaw) as TextStyle['fontWeight']
148
+
149
+ const containerStyle: ViewStyle = useMemo(
150
+ () => ({
151
+ flexDirection: isExpanded ? 'column' : 'row',
152
+ alignItems: isExpanded ? 'flex-end' : 'center',
153
+ gap,
154
+ width: '100%',
155
+ ...(disabled ? { opacity: 0.6 } : null),
156
+ }),
157
+ [isExpanded, gap, disabled]
158
+ )
159
+
160
+ const rowStyle: ViewStyle = useMemo(
161
+ () => ({
162
+ flex: isExpanded ? undefined : 1,
163
+ alignSelf: isExpanded ? 'stretch' : 'auto',
164
+ minWidth: 0,
165
+ flexDirection: 'row',
166
+ alignItems: 'flex-start',
167
+ gap: rowGap,
168
+ paddingHorizontal: rowPaddingHorizontal,
169
+ paddingVertical: rowPaddingVertical,
170
+ }),
171
+ [isExpanded, rowGap, rowPaddingHorizontal, rowPaddingVertical]
172
+ )
173
+
174
+ const resolvedLabelStyle: TextStyle = useMemo(
175
+ () => ({
176
+ flex: 1,
177
+ minWidth: 0,
178
+ color: labelColor,
179
+ fontFamily: labelFontFamily,
180
+ fontSize: labelFontSize,
181
+ lineHeight: labelLineHeight,
182
+ fontWeight: labelFontWeight,
183
+ }),
184
+ [
185
+ labelColor,
186
+ labelFontFamily,
187
+ labelFontSize,
188
+ labelLineHeight,
189
+ labelFontWeight,
190
+ ]
191
+ )
192
+
193
+ const buttonModes = useMemo(
194
+ () => ({ ...BUTTON_DEFAULT_MODES, ...modes }),
195
+ [modes]
196
+ )
197
+
198
+ const a11yLabel =
199
+ accessibilityLabel ?? (typeof label === 'string' ? label : undefined)
200
+ const buttonLabel = isExpanded ? readLessLabel : readMoreLabel
201
+
202
+ const labelNumberOfLinesProps =
203
+ !isExpanded && collapsedLines > 0
204
+ ? { numberOfLines: collapsedLines, ellipsizeMode: 'tail' as const }
205
+ : null
206
+
207
+ return (
208
+ <View style={[containerStyle, style]}>
209
+ <View style={rowStyle}>
210
+ <Checkbox
211
+ checked={isChecked}
212
+ disabled={disabled}
213
+ onValueChange={handleToggleChecked}
214
+ modes={modes}
215
+ {...(a11yLabel !== undefined ? { accessibilityLabel: a11yLabel } : {})}
216
+ />
217
+ <Text
218
+ style={[resolvedLabelStyle, labelStyle]}
219
+ selectable={false}
220
+ {...(labelNumberOfLinesProps ?? {})}
221
+ >
222
+ {label}
223
+ </Text>
224
+ </View>
225
+ <Button
226
+ label={buttonLabel}
227
+ onPress={handleToggleExpanded}
228
+ disabled={disabled}
229
+ modes={buttonModes}
230
+ accessibilityLabel={buttonLabel}
231
+ accessibilityState={{ expanded: isExpanded }}
232
+ />
233
+ </View>
234
+ )
235
+ }
236
+
237
+ export default ExpandableCheckbox