jfs-components 0.0.77 → 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 (70) hide show
  1. package/CHANGELOG.md +17 -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/Checkbox/Checkbox.js +21 -9
  5. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -16
  6. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +167 -0
  7. package/lib/commonjs/components/FormField/FormField.js +14 -1
  8. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +355 -0
  9. package/lib/commonjs/components/ListItem/ListItem.js +25 -10
  10. package/lib/commonjs/components/MessageField/MessageField.js +318 -0
  11. package/lib/commonjs/components/NavArrow/NavArrow.js +58 -17
  12. package/lib/commonjs/components/Stepper/Step.js +47 -60
  13. package/lib/commonjs/components/Stepper/StepLabel.js +40 -10
  14. package/lib/commonjs/components/Stepper/Stepper.js +15 -17
  15. package/lib/commonjs/components/SuggestiveSearch/SuggestiveSearch.js +487 -0
  16. package/lib/commonjs/components/TextInput/TextInput.js +16 -1
  17. package/lib/commonjs/components/Title/Title.js +10 -2
  18. package/lib/commonjs/components/index.js +28 -0
  19. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  20. package/lib/commonjs/icons/registry.js +1 -1
  21. package/lib/module/components/Accordion/Accordion.js +56 -56
  22. package/lib/module/components/ActionFooter/ActionFooter.js +50 -4
  23. package/lib/module/components/Checkbox/Checkbox.js +22 -10
  24. package/lib/module/components/DropdownInput/DropdownInput.js +30 -16
  25. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +161 -0
  26. package/lib/module/components/FormField/FormField.js +16 -3
  27. package/lib/module/components/FullscreenModal/FullscreenModal.js +350 -0
  28. package/lib/module/components/ListItem/ListItem.js +25 -10
  29. package/lib/module/components/MessageField/MessageField.js +313 -0
  30. package/lib/module/components/NavArrow/NavArrow.js +59 -18
  31. package/lib/module/components/Stepper/Step.js +48 -61
  32. package/lib/module/components/Stepper/StepLabel.js +40 -10
  33. package/lib/module/components/Stepper/Stepper.js +15 -17
  34. package/lib/module/components/SuggestiveSearch/SuggestiveSearch.js +481 -0
  35. package/lib/module/components/TextInput/TextInput.js +17 -2
  36. package/lib/module/components/Title/Title.js +10 -2
  37. package/lib/module/components/index.js +4 -0
  38. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  39. package/lib/module/icons/registry.js +1 -1
  40. package/lib/typescript/src/components/Accordion/Accordion.d.ts +14 -20
  41. package/lib/typescript/src/components/ExpandableCheckbox/ExpandableCheckbox.d.ts +63 -0
  42. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +99 -0
  43. package/lib/typescript/src/components/MessageField/MessageField.d.ts +81 -0
  44. package/lib/typescript/src/components/NavArrow/NavArrow.d.ts +10 -5
  45. package/lib/typescript/src/components/Stepper/Step.d.ts +4 -1
  46. package/lib/typescript/src/components/Stepper/StepLabel.d.ts +4 -1
  47. package/lib/typescript/src/components/Stepper/Stepper.d.ts +3 -1
  48. package/lib/typescript/src/components/SuggestiveSearch/SuggestiveSearch.d.ts +123 -0
  49. package/lib/typescript/src/components/index.d.ts +7 -3
  50. package/lib/typescript/src/icons/registry.d.ts +1 -1
  51. package/package.json +1 -1
  52. package/src/components/Accordion/Accordion.tsx +113 -73
  53. package/src/components/ActionFooter/ActionFooter.tsx +56 -4
  54. package/src/components/Checkbox/Checkbox.tsx +22 -9
  55. package/src/components/DropdownInput/DropdownInput.tsx +67 -39
  56. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +237 -0
  57. package/src/components/FormField/FormField.tsx +19 -3
  58. package/src/components/FullscreenModal/FullscreenModal.tsx +414 -0
  59. package/src/components/ListItem/ListItem.tsx +21 -10
  60. package/src/components/MessageField/MessageField.tsx +543 -0
  61. package/src/components/NavArrow/NavArrow.tsx +81 -17
  62. package/src/components/Stepper/Step.tsx +52 -51
  63. package/src/components/Stepper/StepLabel.tsx +46 -9
  64. package/src/components/Stepper/Stepper.tsx +20 -15
  65. package/src/components/SuggestiveSearch/SuggestiveSearch.tsx +756 -0
  66. package/src/components/TextInput/TextInput.tsx +14 -1
  67. package/src/components/Title/Title.tsx +13 -2
  68. package/src/components/index.ts +7 -3
  69. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  70. package/src/icons/registry.ts +1 -1
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react'
1
+ import React, { useMemo, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -21,9 +21,49 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental
21
21
  UIManager.setLayoutAnimationEnabledExperimental(true)
22
22
  }
23
23
 
24
+ type AccordionStateMode = 'Idle' | 'Hover' | 'Open' | 'Open Hover' | 'Disabled'
25
+
26
+ function resolveAccordionStateMode(
27
+ disabled: boolean,
28
+ isExpanded: boolean,
29
+ isHovered: boolean,
30
+ contained: boolean,
31
+ ): AccordionStateMode {
32
+ if (disabled) return 'Disabled'
33
+
34
+ if (contained) {
35
+ return isExpanded ? 'Open Hover' : 'Hover'
36
+ }
37
+
38
+ if (isExpanded) {
39
+ return isHovered ? 'Open Hover' : 'Open'
40
+ }
41
+
42
+ return isHovered ? 'Hover' : 'Idle'
43
+ }
44
+
45
+ function toFontWeight(value: unknown, fallback: TextStyle['fontWeight']): TextStyle['fontWeight'] {
46
+ if (typeof value === 'number') return String(value) as TextStyle['fontWeight']
47
+ if (typeof value === 'string') {
48
+ const normalized = value.trim().toLowerCase()
49
+ if (normalized === 'bold') return '700'
50
+ if (normalized === 'medium') return '500'
51
+ if (normalized === 'regular' || normalized === 'normal') return '400'
52
+ if (/^\d+$/.test(normalized)) return normalized as TextStyle['fontWeight']
53
+ return value as TextStyle['fontWeight']
54
+ }
55
+ return fallback
56
+ }
57
+
24
58
  export type AccordionProps = {
25
59
  /** The accordion header title */
26
60
  title?: string;
61
+ /**
62
+ * When `true`, the header always uses the filled background treatment
63
+ * (Figma Hover / Open Hover visuals). Defaults to `false` (transparent at
64
+ * rest, filled only while hovered or pressed).
65
+ */
66
+ contained?: boolean;
27
67
  /** Initial expanded state. Defaults to false (collapsed) */
28
68
  defaultExpanded?: boolean;
29
69
  /** Controlled expanded state. When provided, the component becomes controlled */
@@ -51,31 +91,20 @@ export type AccordionProps = {
51
91
  /**
52
92
  * Accordion component that mirrors the Figma "Accordion" component.
53
93
  *
54
- * This component supports:
55
- * - **Expandable/collapsible content** with smooth animation
56
- * - **States**: Idle, Hover, Open, Disabled
57
- * - **Slot** for custom content
58
- * - **Design-token driven styling** via `getVariableByName` and `modes`
94
+ * Supports two visual treatments via the `contained` prop:
95
+ * - **`contained={false}`** (default) transparent header at rest; filled
96
+ * background on hover / press.
97
+ * - **`contained={true}`** header always uses the filled background.
59
98
  *
60
- * Wherever the Figma layer name contains "Slot", this component exposes a
61
- * dedicated React "slot" prop:
62
- * - Slot "content" `children`
99
+ * Interaction states (Idle, Hover, Open, Disabled) are resolved automatically
100
+ * from `expanded`, `disabled`, hover, and `contained` — consumers should not
101
+ * pass `'Accordion States'` in `modes`.
63
102
  *
64
103
  * @component
65
- * @param {Object} props
66
- * @param {string} [props.title='Accordion title'] - The accordion header title
67
- * @param {boolean} [props.defaultExpanded=false] - Initial expanded state
68
- * @param {boolean} [props.expanded] - Controlled expanded state
69
- * @param {Function} [props.onExpandedChange] - Callback fired when expanded state changes
70
- * @param {boolean} [props.disabled=false] - Whether the accordion is disabled
71
- * @param {React.ReactNode} [props.children] - Content to display when expanded
72
- * @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens
73
- * @param {Object} [props.style] - Optional container style overrides
74
- * @param {string} [props.accessibilityLabel] - Accessibility label for the accordion. If not provided, uses title
75
- * @param {string} [props.accessibilityHint] - Additional accessibility hint for screen readers
76
104
  */
77
105
  function Accordion({
78
106
  title = 'Accordion title',
107
+ contained = false,
79
108
  defaultExpanded = false,
80
109
  expanded: controlledExpanded,
81
110
  onExpandedChange,
@@ -89,23 +118,31 @@ function Accordion({
89
118
  webAccessibilityProps,
90
119
  ...rest
91
120
  }: AccordionProps) {
92
- // Internal state for uncontrolled mode
93
121
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
94
-
95
- // Determine if controlled or uncontrolled
122
+ const [isHovered, setIsHovered] = useState(false)
123
+
96
124
  const isControlled = controlledExpanded !== undefined
97
125
  const isExpanded = isControlled ? controlledExpanded : internalExpanded
98
-
99
- // Hover state for web
100
- const [isHovered, setIsHovered] = useState(false)
101
-
102
- // Handle toggle
126
+
127
+ const resolvedModes = useMemo(() => {
128
+ const accordionState = resolveAccordionStateMode(
129
+ disabled,
130
+ isExpanded,
131
+ isHovered,
132
+ contained,
133
+ )
134
+
135
+ return {
136
+ ...modes,
137
+ 'Accordion States': accordionState,
138
+ }
139
+ }, [contained, disabled, isExpanded, isHovered, modes])
140
+
103
141
  const handleToggle = () => {
104
142
  if (disabled) return
105
-
106
- // Animate the layout change
143
+
107
144
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
108
-
145
+
109
146
  if (isControlled) {
110
147
  onExpandedChange?.(!isExpanded)
111
148
  } else {
@@ -113,38 +150,45 @@ function Accordion({
113
150
  onExpandedChange?.(!isExpanded)
114
151
  }
115
152
  }
116
-
117
- // Resolve design tokens
118
- const titleColor = disabled
119
- ? '#999999'
120
- : getVariableByName('accordion/title/color', modes) || '#0d0d0d'
121
- const titleFontSize = getVariableByName('accordion/title/fontSize', modes) || 18
122
- const titleLineHeight = getVariableByName('accordion/title/lineHeight', modes) || 20
123
- const titleFontFamily = getVariableByName('accordion/title/fontFamily', modes) || 'System'
124
-
125
- const iconColor = getVariableByName('accordion/icon/color', modes) || '#141414'
126
- const iconSize = getVariableByName('accordion/icon/size', modes) || 24
127
-
128
- const headerGap = getVariableByName('accordion/header/gap', modes) || 12
129
- const headerPaddingVertical = getVariableByName('accordion/header/padding/vertical', modes) || 24
130
- const headerBackground = isHovered && !disabled
131
- ? '#f2f2f2'
132
- : getVariableByName('accordion/header/background', modes) || 'transparent'
133
-
134
- const contentGap = getVariableByName('accordion/content/gap', modes) || 12
135
- const contentPaddingTop = getVariableByName('accordion/content/padding/top', modes) || 8
136
- const contentPaddingBottom = isExpanded
137
- ? (getVariableByName('accordion/content/padding/bottom', modes) ?? 24)
138
- : 8
139
-
140
- const borderColor = getVariableByName('accordion/border/color', modes) || '#e6e6e6'
141
-
142
- // Styles
153
+
154
+ const titleColor =
155
+ (getVariableByName('accordion/title/color', resolvedModes) as string | null) ?? '#0d0d0d'
156
+ const titleFontSize =
157
+ (getVariableByName('accordion/title/fontSize', resolvedModes) as number | null) ?? 14
158
+ const titleLineHeight =
159
+ (getVariableByName('accordion/title/lineHeight', resolvedModes) as number | null) ?? 20
160
+ const titleFontFamily =
161
+ (getVariableByName('accordion/title/fontFamily', resolvedModes) as string | null) ?? 'System'
162
+ const titleFontWeight = toFontWeight(
163
+ getVariableByName('accordion/title/fontWeight', resolvedModes),
164
+ '700',
165
+ )
166
+
167
+ const iconColor =
168
+ (getVariableByName('accordion/icon/color', resolvedModes) as string | null) ?? '#141414'
169
+ const iconSize = (getVariableByName('accordion/icon/size', resolvedModes) as number | null) ?? 24
170
+
171
+ const headerGap = (getVariableByName('accordion/header/gap', resolvedModes) as number | null) ?? 12
172
+ const headerPaddingVertical =
173
+ (getVariableByName('accordion/header/padding/vertical', resolvedModes) as number | null) ?? 8
174
+ const headerBackground =
175
+ (getVariableByName('accordion/header/background', resolvedModes) as string | null) ??
176
+ 'transparent'
177
+
178
+ const contentGap = (getVariableByName('accordion/content/gap', resolvedModes) as number | null) ?? 12
179
+ const contentPaddingTop =
180
+ (getVariableByName('accordion/content/padding/top', resolvedModes) as number | null) ?? 8
181
+ const contentPaddingBottom =
182
+ (getVariableByName('accordion/content/padding/bottom', resolvedModes) as number | null) ?? 8
183
+
184
+ const borderColor =
185
+ (getVariableByName('accordion/border/color', resolvedModes) as string | null) ?? '#e6e6e6'
186
+
143
187
  const containerStyle: ViewStyle = {
144
188
  borderBottomWidth: 1,
145
189
  borderBottomColor: borderColor,
146
190
  }
147
-
191
+
148
192
  const headerStyle: ViewStyle = {
149
193
  flexDirection: 'row',
150
194
  alignItems: 'center',
@@ -154,16 +198,16 @@ function Accordion({
154
198
  backgroundColor: headerBackground,
155
199
  overflow: 'hidden',
156
200
  }
157
-
201
+
158
202
  const titleStyle: TextStyle = {
159
203
  flex: 1,
160
204
  color: titleColor,
161
205
  fontSize: titleFontSize,
162
206
  lineHeight: titleLineHeight,
163
207
  fontFamily: titleFontFamily,
164
- fontWeight: '700',
208
+ fontWeight: titleFontWeight,
165
209
  }
166
-
210
+
167
211
  const contentStyle: ViewStyle = {
168
212
  backgroundColor: 'transparent',
169
213
  gap: contentGap,
@@ -172,11 +216,9 @@ function Accordion({
172
216
  paddingHorizontal: 0,
173
217
  overflow: 'hidden',
174
218
  }
175
-
176
- // Generate default accessibility label
219
+
177
220
  const defaultAccessibilityLabel = accessibilityLabel || title
178
-
179
- // Web platform support
221
+
180
222
  const webProps = usePressableWebSupport({
181
223
  restProps: {},
182
224
  onPress: handleToggle,
@@ -184,12 +226,11 @@ function Accordion({
184
226
  accessibilityLabel: defaultAccessibilityLabel,
185
227
  webAccessibilityProps,
186
228
  })
187
-
188
- // Process children to pass modes
229
+
189
230
  const processedChildren = children
190
- ? cloneChildrenWithModes(React.Children.toArray(children), modes)
231
+ ? cloneChildrenWithModes(React.Children.toArray(children), resolvedModes)
191
232
  : null
192
-
233
+
193
234
  return (
194
235
  <View style={[containerStyle, style]} {...rest}>
195
236
  <Pressable
@@ -217,12 +258,12 @@ function Accordion({
217
258
  <Icon
218
259
  name={isExpanded ? 'ic_minus' : 'ic_add'}
219
260
  size={iconSize}
220
- color={disabled ? '#999999' : iconColor}
261
+ color={iconColor}
221
262
  accessibilityElementsHidden={true}
222
263
  importantForAccessibility="no"
223
264
  />
224
265
  </Pressable>
225
-
266
+
226
267
  {isExpanded && processedChildren && (
227
268
  <View style={contentStyle}>
228
269
  {processedChildren}
@@ -233,4 +274,3 @@ function Accordion({
233
274
  }
234
275
 
235
276
  export default Accordion
236
-
@@ -1,7 +1,10 @@
1
- import React, { useMemo } from 'react'
1
+ import React, { useEffect, useMemo, useRef } from 'react'
2
2
  import {
3
+ Animated,
4
+ Keyboard,
3
5
  View,
4
6
  Platform,
7
+ type KeyboardEvent,
5
8
  type ViewStyle,
6
9
  type StyleProp,
7
10
  } from 'react-native'
@@ -133,6 +136,47 @@ function ActionFooter({
133
136
  style,
134
137
  accessibilityLabel,
135
138
  }: ActionFooterProps) {
139
+ // -------------------------------------------------------------------------
140
+ // Keep the footer locked in place behind the software keyboard (Android).
141
+ // -------------------------------------------------------------------------
142
+ //
143
+ // The Android activity is configured with `windowSoftInputMode="adjustResize"`,
144
+ // which shrinks the app window by the keyboard height when the keyboard
145
+ // opens. A bottom-anchored footer therefore gets lifted UP by the keyboard
146
+ // height — exactly the jump the design does not want.
147
+ //
148
+ // To counteract that, we translate the footer back DOWN by the same keyboard
149
+ // height so it visually stays exactly where it was (now sitting behind the
150
+ // keyboard). iOS does not resize the window for the keyboard, so the footer
151
+ // already stays put there; we only run this on Android to avoid pushing the
152
+ // footer off-screen on platforms that don't lift it in the first place.
153
+ const keyboardOffset = useRef(new Animated.Value(0)).current
154
+ useEffect(() => {
155
+ if (Platform.OS !== 'android') return undefined
156
+
157
+ const animateTo = (toValue: number, duration?: number) => {
158
+ Animated.timing(keyboardOffset, {
159
+ toValue,
160
+ // Match the OS keyboard animation so the resize and our counter-shift
161
+ // cancel out smoothly with no visible footer movement.
162
+ duration: typeof duration === 'number' && duration > 0 ? duration : 150,
163
+ useNativeDriver: true,
164
+ }).start()
165
+ }
166
+
167
+ const showSub = Keyboard.addListener('keyboardDidShow', (e: KeyboardEvent) => {
168
+ animateTo(e?.endCoordinates?.height ?? 0, e?.duration)
169
+ })
170
+ const hideSub = Keyboard.addListener('keyboardDidHide', (e: KeyboardEvent) => {
171
+ animateTo(0, e?.duration)
172
+ })
173
+
174
+ return () => {
175
+ showSub.remove()
176
+ hideSub.remove()
177
+ }
178
+ }, [keyboardOffset])
179
+
136
180
  // All token reads collapsed into a single useMemo keyed on `modes`. With
137
181
  // the shared `EMPTY_MODES` default this resolves once for the common path
138
182
  // and never re-allocates the container/slot style objects between renders.
@@ -192,13 +236,21 @@ function ActionFooter({
192
236
  }, [children, modes])
193
237
 
194
238
  return (
195
- <View
196
- style={[containerStyle, WEB_SHADOW, style]}
239
+ <Animated.View
240
+ style={[
241
+ containerStyle,
242
+ WEB_SHADOW,
243
+ style,
244
+ // Counter-translate by the keyboard height on Android so `adjustResize`
245
+ // can't lift the footer above the keyboard (no-op on iOS/web where the
246
+ // value stays at 0).
247
+ { transform: [{ translateY: keyboardOffset }] },
248
+ ]}
197
249
  accessibilityRole="toolbar"
198
250
  accessibilityLabel={accessibilityLabel}
199
251
  >
200
252
  <View style={slotStyle}>{enhancedChildren}</View>
201
- </View>
253
+ </Animated.View>
202
254
  )
203
255
  }
204
256
 
@@ -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}