jfs-components 0.0.77 → 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 (87) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/commonjs/components/Accordion/Accordion.js +55 -55
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +48 -2
  4. package/lib/commonjs/components/Attached/Attached.js +144 -0
  5. package/lib/commonjs/components/Card/Card.js +25 -2
  6. package/lib/commonjs/components/Checkbox/Checkbox.js +21 -9
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
  8. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
  9. package/lib/commonjs/components/FormField/FormField.js +14 -1
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +353 -0
  11. package/lib/commonjs/components/ListItem/ListItem.js +46 -24
  12. package/lib/commonjs/components/MessageField/MessageField.js +318 -0
  13. package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
  14. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
  15. package/lib/commonjs/components/Slot/Slot.js +73 -0
  16. package/lib/commonjs/components/Stepper/Step.js +47 -60
  17. package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
  18. package/lib/commonjs/components/Stepper/Stepper.js +15 -17
  19. package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
  20. package/lib/commonjs/components/TextInput/TextInput.js +16 -1
  21. package/lib/commonjs/components/Title/Title.js +10 -2
  22. package/lib/commonjs/components/index.js +49 -0
  23. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  24. package/lib/commonjs/icons/registry.js +1 -1
  25. package/lib/module/components/Accordion/Accordion.js +56 -56
  26. package/lib/module/components/ActionFooter/ActionFooter.js +50 -4
  27. package/lib/module/components/Attached/Attached.js +139 -0
  28. package/lib/module/components/Card/Card.js +25 -2
  29. package/lib/module/components/Checkbox/Checkbox.js +22 -10
  30. package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
  31. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
  32. package/lib/module/components/FormField/FormField.js +16 -3
  33. package/lib/module/components/FullscreenModal/FullscreenModal.js +348 -0
  34. package/lib/module/components/ListItem/ListItem.js +46 -24
  35. package/lib/module/components/MessageField/MessageField.js +313 -0
  36. package/lib/module/components/NavArrow/NavArrow.js +59 -18
  37. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
  38. package/lib/module/components/Slot/Slot.js +68 -0
  39. package/lib/module/components/Stepper/Step.js +48 -61
  40. package/lib/module/components/Stepper/StepLabel.js +40 -10
  41. package/lib/module/components/Stepper/Stepper.js +15 -17
  42. package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
  43. package/lib/module/components/TextInput/TextInput.js +17 -2
  44. package/lib/module/components/Title/Title.js +10 -2
  45. package/lib/module/components/index.js +7 -0
  46. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  47. package/lib/module/icons/registry.js +1 -1
  48. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
  49. package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
  50. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  51. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
  52. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
  53. package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
  54. package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
  55. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
  56. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
  57. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  58. package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
  59. package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
  60. package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
  61. package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
  62. package/lib/typescript/src/components/index.d.ts +10 -3
  63. package/lib/typescript/src/icons/registry.d.ts +1 -1
  64. package/package.json +1 -1
  65. package/src/components/Accordion/Accordion.tsx +113 -73
  66. package/src/components/ActionFooter/ActionFooter.tsx +56 -4
  67. package/src/components/Attached/Attached.tsx +181 -0
  68. package/src/components/Card/Card.tsx +28 -1
  69. package/src/components/Checkbox/Checkbox.tsx +22 -9
  70. package/src/components/DropdownInput/DropdownInput.tsx +67 -39
  71. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
  72. package/src/components/FormField/FormField.tsx +19 -3
  73. package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
  74. package/src/components/ListItem/ListItem.tsx +55 -25
  75. package/src/components/MessageField/MessageField.tsx +543 -0
  76. package/src/components/NavArrow/NavArrow.tsx +81 -17
  77. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
  78. package/src/components/Slot/Slot.tsx +91 -0
  79. package/src/components/Stepper/Step.tsx +52 -51
  80. package/src/components/Stepper/StepLabel.tsx +46 -9
  81. package/src/components/Stepper/Stepper.tsx +20 -15
  82. package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
  83. package/src/components/TextInput/TextInput.tsx +14 -1
  84. package/src/components/Title/Title.tsx +13 -2
  85. package/src/components/index.ts +10 -3
  86. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  87. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,181 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ View,
4
+ type LayoutChangeEvent,
5
+ type StyleProp,
6
+ type ViewProps,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
10
+ import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
11
+
12
+ /**
13
+ * Anchor point on the main content where the attached `badge` is centered.
14
+ * Mirrors the nine Figma `position` variants (corners, edge midpoints, center).
15
+ */
16
+ export type AttachedPosition =
17
+ | 'top-left'
18
+ | 'top'
19
+ | 'top-right'
20
+ | 'left'
21
+ | 'center'
22
+ | 'right'
23
+ | 'bottom-left'
24
+ | 'bottom'
25
+ | 'bottom-right'
26
+
27
+ export type AttachedProps = Omit<ViewProps, 'children'> & {
28
+ /**
29
+ * Main content the badge attaches to (the Figma "main slot"). Any node —
30
+ * typically an `IconCapsule`, `Avatar`, image, etc. `modes` are cascaded to
31
+ * every child via {@link cloneChildrenWithModes}.
32
+ */
33
+ children?: React.ReactNode
34
+ /**
35
+ * The element attached on top of `children` (the Figma "slot"). Centered on
36
+ * the anchor point given by `position` so it straddles the edge/corner.
37
+ * `modes` are cascaded into it as well.
38
+ */
39
+ badge?: React.ReactNode
40
+ /**
41
+ * Anchor point for the `badge` relative to the main content.
42
+ * @default 'bottom-right'
43
+ */
44
+ position?: AttachedPosition
45
+ /**
46
+ * How the anchor point is computed for diagonal (corner) positions:
47
+ * - `false` (default): treat the main content as a **square** — corner
48
+ * anchors sit on the bounding-box corners.
49
+ * - `true`: treat the main content as a **circle** inscribed in its bounding
50
+ * box — corner anchors sit on the circle's circumference (the 45° point),
51
+ * so badges hug round content like a circular `IconCapsule` or `Avatar`.
52
+ *
53
+ * Edge (`top`/`bottom`/`left`/`right`) and `center` anchors are unaffected,
54
+ * since the circle meets the bounding box at those points.
55
+ * @default false
56
+ */
57
+ circular?: boolean
58
+ /** Mode configuration cascaded to the token resolver and all children. */
59
+ modes?: Record<string, any>
60
+ style?: StyleProp<ViewStyle>
61
+ }
62
+
63
+ type Size = { width: number; height: number }
64
+
65
+ const ZERO_SIZE: Size = { width: 0, height: 0 }
66
+
67
+ /**
68
+ * Fraction (0 | 0.5 | 1) of the main content's width/height at which the badge
69
+ * center should sit, derived from the `position` anchor.
70
+ */
71
+ function resolveAnchorFractions(position: AttachedPosition): { fx: number; fy: number } {
72
+ const fx = position.includes('left') ? 0 : position.includes('right') ? 1 : 0.5
73
+ const fy = position.startsWith('top') ? 0 : position.startsWith('bottom') ? 1 : 0.5
74
+ return { fx, fy }
75
+ }
76
+
77
+ /**
78
+ * Attached — overlays a small `badge` on top of arbitrary main content,
79
+ * centered on one of nine anchor points (corners, edge midpoints, or center).
80
+ *
81
+ * The badge straddles the chosen anchor regardless of either element's size:
82
+ * both the main content and the badge are measured via `onLayout`, then the
83
+ * badge is absolutely positioned so its center lands exactly on the anchor.
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * <Attached position="bottom-right" badge={<InstitutionBadge modes={modes} />} modes={modes}>
88
+ * <IconCapsule iconName="ic_card" modes={modes} />
89
+ * </Attached>
90
+ * ```
91
+ */
92
+ function Attached({
93
+ children,
94
+ badge,
95
+ position = 'bottom-right',
96
+ circular = true,
97
+ modes: propModes = EMPTY_MODES,
98
+ style,
99
+ ...rest
100
+ }: AttachedProps) {
101
+ const { modes: globalModes } = useTokens()
102
+ const modes = useMemo(
103
+ () =>
104
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
105
+ ? EMPTY_MODES
106
+ : { ...globalModes, ...propModes },
107
+ [globalModes, propModes]
108
+ )
109
+
110
+ const [mainSize, setMainSize] = useState<Size>(ZERO_SIZE)
111
+ const [badgeSize, setBadgeSize] = useState<Size>(ZERO_SIZE)
112
+
113
+ const onMainLayout = useCallback((e: LayoutChangeEvent) => {
114
+ const { width, height } = e.nativeEvent.layout
115
+ setMainSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
116
+ }, [])
117
+
118
+ const onBadgeLayout = useCallback((e: LayoutChangeEvent) => {
119
+ const { width, height } = e.nativeEvent.layout
120
+ setBadgeSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
121
+ }, [])
122
+
123
+ const mainChildren = useMemo(
124
+ () => (children != null ? cloneChildrenWithModes(children, modes) : null),
125
+ [children, modes]
126
+ )
127
+ const badgeChildren = useMemo(
128
+ () => (badge != null ? cloneChildrenWithModes(badge, modes) : null),
129
+ [badge, modes]
130
+ )
131
+
132
+ const badgePlacement = useMemo<ViewStyle>(() => {
133
+ const { fx, fy } = resolveAnchorFractions(position)
134
+ const measured = mainSize.width > 0 && badgeSize.width > 0
135
+
136
+ let anchorX: number
137
+ let anchorY: number
138
+ if (circular) {
139
+ // Project the anchor onto the circle inscribed in the bounding box, so
140
+ // corner badges land on the circumference (45°) instead of the box corner.
141
+ const cx = mainSize.width / 2
142
+ const cy = mainSize.height / 2
143
+ const radius = Math.min(mainSize.width, mainSize.height) / 2
144
+ const dx = (fx - 0.5) * 2 // -1 | 0 | 1
145
+ const dy = (fy - 0.5) * 2 // -1 | 0 | 1
146
+ const len = Math.hypot(dx, dy) || 1 // 'center' → 0, guard against /0
147
+ anchorX = cx + (dx / len) * radius
148
+ anchorY = cy + (dy / len) * radius
149
+ } else {
150
+ anchorX = mainSize.width * fx
151
+ anchorY = mainSize.height * fy
152
+ }
153
+
154
+ return {
155
+ position: 'absolute',
156
+ left: anchorX - badgeSize.width / 2,
157
+ top: anchorY - badgeSize.height / 2,
158
+ // Hide until both elements are measured to avoid a one-frame flash at (0,0).
159
+ opacity: measured ? 1 : 0,
160
+ }
161
+ }, [position, circular, mainSize, badgeSize])
162
+
163
+ return (
164
+ <View style={[styles.container, style]} {...rest}>
165
+ <View onLayout={onMainLayout}>{mainChildren}</View>
166
+ {badgeChildren != null && (
167
+ <View style={badgePlacement} onLayout={onBadgeLayout} pointerEvents="box-none">
168
+ {badgeChildren}
169
+ </View>
170
+ )}
171
+ </View>
172
+ )
173
+ }
174
+
175
+ const styles = {
176
+ // alignSelf flex-start so the wrapper hugs the main content; anchors are then
177
+ // computed relative to the content size rather than a stretched parent.
178
+ container: { position: 'relative', alignSelf: 'flex-start' } as ViewStyle,
179
+ }
180
+
181
+ export default React.memo(Attached)
@@ -11,6 +11,11 @@ import { EMPTY_MODES } from '../../utils/react-utils';
11
11
  const CardContext = createContext<{ modes?: Record<string, any> }>({});
12
12
 
13
13
  export interface CardProps {
14
+ /**
15
+ * Content rendered in the header slot at the top of the card (e.g. a brand logo).
16
+ * Sits above the media slot, with its own padding.
17
+ */
18
+ header?: React.ReactNode;
14
19
  /**
15
20
  * The content to be rendered in the media slot (e.g. an Image).
16
21
  * This content is wrapped in a container that respects the `aspectRatio`.
@@ -39,9 +44,11 @@ export interface CardProps {
39
44
  * Card component implementation from Figma node 765:6186.
40
45
  *
41
46
  * Supports a `media` slot (with aspect ratio) and a content area.
47
+ * Supports an optional `header` slot (e.g. a brand logo), a `media` slot
48
+ * (with aspect ratio) and a content area.
42
49
  * Usage:
43
50
  * ```tsx
44
- * <Card media={<Image source={...} />} modes={modes}>
51
+ * <Card header={<GoldLogo />} media={<Image source={...} />} modes={modes}>
45
52
  * <Card.SupportText>Support text</Card.SupportText>
46
53
  * <Card.Title>Title</Card.Title>
47
54
  * <Card.SupportText>Support text</Card.SupportText>
@@ -49,6 +56,7 @@ export interface CardProps {
49
56
  * ```
50
57
  */
51
58
  export function Card({
59
+ header,
52
60
  media,
53
61
  children,
54
62
  modes = EMPTY_MODES,
@@ -74,6 +82,11 @@ export function Card({
74
82
  ? cloneElement(media as any, { modes: { ...(media.props as any).modes, ...modes } })
75
83
  : media;
76
84
 
85
+ // Clone header to pass modes if it's a valid element
86
+ const headerWithModes = isValidElement(header)
87
+ ? cloneElement(header as any, { modes: { ...(header.props as any).modes, ...modes } })
88
+ : header;
89
+
77
90
  const containerStyle: ViewStyle = {
78
91
  backgroundColor,
79
92
  borderColor,
@@ -85,6 +98,15 @@ export function Card({
85
98
  overflow: 'hidden', // Ensure border radius clips content
86
99
  };
87
100
 
101
+ // Header wrap uses fixed padding from Figma (no dedicated tokens defined).
102
+ const headerWrapperStyle: ViewStyle = {
103
+ width: '100%',
104
+ flexDirection: 'row',
105
+ alignItems: 'flex-start',
106
+ paddingHorizontal: 12,
107
+ paddingVertical: 16,
108
+ };
109
+
88
110
  const mediaWrapperStyle: ViewStyle = {
89
111
  width: '100%',
90
112
  aspectRatio: mediaAspectRatio,
@@ -104,6 +126,11 @@ export function Card({
104
126
  return (
105
127
  <CardContext.Provider value={{ modes }}>
106
128
  <View style={[containerStyle, style]}>
129
+ {header && (
130
+ <View style={headerWrapperStyle}>
131
+ {headerWithModes}
132
+ </View>
133
+ )}
107
134
  {media && (
108
135
  <View style={mediaWrapperStyle}>
109
136
  {mediaWithModes}
@@ -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}