jfs-components 0.0.73 → 0.0.74

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 (63) hide show
  1. package/CHANGELOG.md +23 -6
  2. package/lib/commonjs/components/AccountCard/AccountCard.js +247 -0
  3. package/lib/commonjs/components/AppBar/AppBar.js +17 -11
  4. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +18 -2
  5. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +40 -25
  6. package/lib/commonjs/components/Dropdown/Dropdown.js +214 -0
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +542 -0
  8. package/lib/commonjs/components/FormField/FormField.js +328 -178
  9. package/lib/commonjs/components/LottieIntroBlock/LottieIntroBlock.js +150 -0
  10. package/lib/commonjs/components/PageHero/PageHero.js +153 -0
  11. package/lib/commonjs/components/PoweredByLabel/PoweredByLabel.js +135 -0
  12. package/lib/commonjs/components/PoweredByLabel/finvu.png +0 -0
  13. package/lib/commonjs/components/Text/Text.js +9 -2
  14. package/lib/commonjs/components/Tooltip/Tooltip.js +34 -27
  15. package/lib/commonjs/components/index.js +60 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/icons/registry.js +1 -1
  18. package/lib/module/components/AccountCard/AccountCard.js +241 -0
  19. package/lib/module/components/AppBar/AppBar.js +17 -11
  20. package/lib/module/components/CardBankAccount/CardBankAccount.js +17 -2
  21. package/lib/module/components/CheckboxItem/CheckboxItem.js +41 -26
  22. package/lib/module/components/Dropdown/Dropdown.js +206 -0
  23. package/lib/module/components/DropdownInput/DropdownInput.js +536 -0
  24. package/lib/module/components/FormField/FormField.js +330 -180
  25. package/lib/module/components/LottieIntroBlock/LottieIntroBlock.js +144 -0
  26. package/lib/module/components/PageHero/PageHero.js +147 -0
  27. package/lib/module/components/PoweredByLabel/PoweredByLabel.js +130 -0
  28. package/lib/module/components/PoweredByLabel/finvu.png +0 -0
  29. package/lib/module/components/Text/Text.js +9 -2
  30. package/lib/module/components/Tooltip/Tooltip.js +34 -27
  31. package/lib/module/components/index.js +7 -1
  32. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  33. package/lib/module/icons/registry.js +1 -1
  34. package/lib/typescript/src/components/AccountCard/AccountCard.d.ts +81 -0
  35. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +9 -2
  36. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +18 -2
  37. package/lib/typescript/src/components/Dropdown/Dropdown.d.ts +62 -0
  38. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +107 -0
  39. package/lib/typescript/src/components/FormField/FormField.d.ts +76 -19
  40. package/lib/typescript/src/components/LottieIntroBlock/LottieIntroBlock.d.ts +58 -0
  41. package/lib/typescript/src/components/PageHero/PageHero.d.ts +53 -0
  42. package/lib/typescript/src/components/PoweredByLabel/PoweredByLabel.d.ts +70 -0
  43. package/lib/typescript/src/components/Text/Text.d.ts +12 -2
  44. package/lib/typescript/src/components/Tooltip/Tooltip.d.ts +13 -2
  45. package/lib/typescript/src/components/index.d.ts +7 -1
  46. package/lib/typescript/src/icons/registry.d.ts +1 -1
  47. package/package.json +1 -3
  48. package/src/components/AccountCard/AccountCard.tsx +376 -0
  49. package/src/components/AppBar/AppBar.tsx +25 -14
  50. package/src/components/CardBankAccount/CardBankAccount.tsx +29 -3
  51. package/src/components/CheckboxItem/CheckboxItem.tsx +65 -30
  52. package/src/components/Dropdown/Dropdown.tsx +331 -0
  53. package/src/components/DropdownInput/DropdownInput.tsx +819 -0
  54. package/src/components/FormField/FormField.tsx +542 -215
  55. package/src/components/LottieIntroBlock/LottieIntroBlock.tsx +202 -0
  56. package/src/components/PageHero/PageHero.tsx +200 -0
  57. package/src/components/PoweredByLabel/PoweredByLabel.tsx +221 -0
  58. package/src/components/PoweredByLabel/finvu.png +0 -0
  59. package/src/components/Text/Text.tsx +24 -3
  60. package/src/components/Tooltip/Tooltip.tsx +50 -25
  61. package/src/components/index.ts +15 -1
  62. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  63. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,819 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import {
9
+ Dimensions,
10
+ Modal,
11
+ Platform,
12
+ Pressable,
13
+ StyleSheet,
14
+ Text,
15
+ View,
16
+ type AccessibilityProps,
17
+ type LayoutChangeEvent,
18
+ type StyleProp,
19
+ type TextStyle,
20
+ type ViewStyle,
21
+ } from 'react-native'
22
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
23
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
24
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
25
+ import { EMPTY_MODES, flattenChildren } from '../../utils/react-utils'
26
+ import Icon from '../../icons/Icon'
27
+ import SupportText from '../SupportText/SupportText'
28
+ import type { SupportTextStatus } from '../SupportText/SupportTextIcon'
29
+ import Dropdown, { DropdownItem, type DropdownItemProps } from '../Dropdown/Dropdown'
30
+
31
+ const IS_WEB = Platform.OS === 'web'
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export type DropdownInputOptionValue = string | number
38
+
39
+ export type DropdownInputOption = {
40
+ /** Stable, unique value used to identify the option. */
41
+ value: DropdownInputOptionValue
42
+ /** Human-readable label rendered in the menu and selected display. */
43
+ label: string
44
+ /** Optional element rendered before the label inside the item. */
45
+ leading?: React.ReactNode
46
+ /** Optional element rendered after the label inside the item. */
47
+ trailing?: React.ReactNode
48
+ /** Whether the option is non-selectable. */
49
+ disabled?: boolean
50
+ }
51
+
52
+ type Rect = { x: number; y: number; width: number; height: number }
53
+
54
+ export type DropdownInputProps = {
55
+ /** Label rendered above the input. */
56
+ label?: string
57
+ /** Placeholder text shown when no value is selected. */
58
+ placeholder?: string
59
+ /**
60
+ * Data-driven list of options. Mutually compatible with `children`; if
61
+ * both are provided the `items` are rendered first.
62
+ */
63
+ items?: DropdownInputOption[]
64
+ /**
65
+ * Currently selected value (controlled). When `value` is `undefined` the
66
+ * component operates in uncontrolled mode and keeps its own state.
67
+ */
68
+ value?: DropdownInputOptionValue | null
69
+ /** Initial selected value for uncontrolled mode. */
70
+ defaultValue?: DropdownInputOptionValue | null
71
+ /** Called when the selected value changes. */
72
+ onValueChange?: (
73
+ value: DropdownInputOptionValue | null,
74
+ option?: DropdownInputOption
75
+ ) => void
76
+ /**
77
+ * Custom slot of `<DropdownItem />` children. Used when finer-grained
78
+ * control is needed (icons, custom layouts, etc.). When provided alongside
79
+ * `items`, both are rendered (items first).
80
+ */
81
+ children?: React.ReactNode
82
+ /**
83
+ * Custom renderer for the trigger label. Receives the currently-selected
84
+ * option (if any) and a boolean indicating whether the field has a value.
85
+ */
86
+ renderValue?: (
87
+ option: DropdownInputOption | undefined,
88
+ hasValue: boolean
89
+ ) => React.ReactNode
90
+ /** Controlled open state. */
91
+ open?: boolean
92
+ /** Initial open state for uncontrolled mode. */
93
+ defaultOpen?: boolean
94
+ /** Called whenever the open state changes. */
95
+ onOpenChange?: (open: boolean) => void
96
+ /**
97
+ * Preferred placement for the popup. The component falls back to the
98
+ * opposite side automatically when there isn't enough room.
99
+ * @default 'bottom'
100
+ */
101
+ placement?: 'bottom' | 'top' | 'auto'
102
+ /** Renders a required asterisk next to the label. */
103
+ isRequired?: boolean
104
+ /** Disables interaction and dims the field. */
105
+ isDisabled?: boolean
106
+ /** Marks the field as invalid and shows `errorMessage`. */
107
+ isInvalid?: boolean
108
+ /** Renders the field in read-only mode (non-interactive but not disabled). */
109
+ isReadOnly?: boolean
110
+ /** Helper text displayed below the input. */
111
+ supportText?: string
112
+ /** Replaces `supportText` when `isInvalid` is true. */
113
+ errorMessage?: string
114
+ /**
115
+ * Maximum height of the popup before it becomes scrollable. Helpful for
116
+ * long lists. Defaults to 240 (roughly 5 items).
117
+ */
118
+ menuMaxHeight?: number
119
+ /**
120
+ * Pixel offset between the trigger and the popup. Defaults to 4 so the
121
+ * popup visually peeks below the input.
122
+ */
123
+ menuOffset?: number
124
+ /**
125
+ * When true, the popup width matches the trigger width. When false, the
126
+ * popup uses its intrinsic content width but never exceeds the trigger.
127
+ * @default true
128
+ */
129
+ matchTriggerWidth?: boolean
130
+ /** Whether tapping the backdrop closes the menu. */
131
+ closeOnBackdropPress?: boolean
132
+ /** Modes for design token resolution. */
133
+ modes?: Record<string, any>
134
+ /** Style overrides for the outermost wrapper. */
135
+ style?: StyleProp<ViewStyle>
136
+ /** Style overrides for the input row. */
137
+ inputStyle?: StyleProp<ViewStyle>
138
+ /** Style overrides for the popup container. */
139
+ menuStyle?: StyleProp<ViewStyle>
140
+ /** Accessibility label. Defaults to the visible label / placeholder. */
141
+ accessibilityLabel?: string
142
+ /** Accessibility hint. */
143
+ accessibilityHint?: string
144
+ /** Called when the trigger receives focus (web only). */
145
+ onFocus?: (e: any) => void
146
+ /** Called when the trigger loses focus (web only). */
147
+ onBlur?: (e: any) => void
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Token resolution
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function useChevronTokens(modes: Record<string, any>) {
155
+ return useMemo(() => {
156
+ const iconSize =
157
+ parseInt(getVariableByName('input/iconSize', modes), 10) || 32
158
+ const iconColor =
159
+ (getVariableByName('iconButton/icon/color', modes) as string) ||
160
+ '#0f0d0a'
161
+ return { iconSize, iconColor }
162
+ }, [modes])
163
+ }
164
+
165
+ function useFormFieldTokens(modes: Record<string, any>) {
166
+ return useMemo(() => {
167
+ const labelColor =
168
+ (getVariableByName('formField/label/color', modes) as string) ||
169
+ '#0c0d10'
170
+ const labelFontFamily =
171
+ (getVariableByName('formField/label/fontFamily', modes) as string) ||
172
+ 'JioType Var'
173
+ const labelFontSize =
174
+ parseInt(getVariableByName('formField/label/fontSize', modes), 10) ||
175
+ 14
176
+ const labelLineHeight =
177
+ parseInt(
178
+ getVariableByName('formField/label/lineHeight', modes),
179
+ 10
180
+ ) || 17
181
+ const labelFontWeight =
182
+ (getVariableByName('formField/label/fontWeight', modes) as string) ||
183
+ '500'
184
+
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) ||
196
+ 8
197
+ const inputBackground =
198
+ (getVariableByName('formField/input/background', modes) as string) ||
199
+ '#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
210
+ const inputFontFamily =
211
+ (getVariableByName(
212
+ 'formField/input/label/fontFamily',
213
+ modes
214
+ ) as string) || 'JioType Var'
215
+ const inputFontWeight =
216
+ (getVariableByName(
217
+ 'formField/input/label/fontWeight',
218
+ modes
219
+ ) as string) || '400'
220
+ const inputTextColor =
221
+ (getVariableByName(
222
+ 'states/formField/input/label/color',
223
+ modes
224
+ ) as string) ||
225
+ (getVariableByName('formField/input/label/color', modes) as string) ||
226
+ '#24262b'
227
+ const inputBorderColor =
228
+ (getVariableByName(
229
+ 'states/formField/input/border/color',
230
+ modes
231
+ ) as string) ||
232
+ (getVariableByName('formField/input/border/color', modes) as string) ||
233
+ '#b5b6b7'
234
+ const inputBorderSize =
235
+ parseInt(
236
+ getVariableByName('formField/input/border/size', modes),
237
+ 10
238
+ ) || 1
239
+
240
+ return {
241
+ labelColor,
242
+ labelFontFamily,
243
+ labelFontSize,
244
+ labelLineHeight,
245
+ labelFontWeight,
246
+ gap,
247
+ inputPaddingH,
248
+ inputGap,
249
+ inputRadius,
250
+ inputBackground,
251
+ inputFontSize,
252
+ inputLineHeight,
253
+ inputFontFamily,
254
+ inputFontWeight,
255
+ inputTextColor,
256
+ inputBorderColor,
257
+ inputBorderSize,
258
+ }
259
+ }, [modes])
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Helpers
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Collect every option this DropdownInput knows about, in render order, from
268
+ * both `items` and `children` slots. Used for keyboard navigation, lookups
269
+ * of the selected option, and accessibility labels.
270
+ */
271
+ function collectOptionsFromChildren(
272
+ children: React.ReactNode
273
+ ): DropdownInputOption[] {
274
+ const out: DropdownInputOption[] = []
275
+ flattenChildren(children).forEach((child) => {
276
+ if (!React.isValidElement(child)) return
277
+ if ((child.type as any) !== DropdownItem) return
278
+ const childProps = child.props as DropdownItemProps
279
+ const { value, label, disabled } = childProps
280
+ if (value == null) return
281
+ if (typeof value !== 'string' && typeof value !== 'number') return
282
+ if (typeof label !== 'string') return
283
+ const opt: DropdownInputOption = {
284
+ value: value as DropdownInputOptionValue,
285
+ label,
286
+ }
287
+ if (disabled != null) opt.disabled = disabled
288
+ out.push(opt)
289
+ })
290
+ return out
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Component
295
+ // ---------------------------------------------------------------------------
296
+
297
+ function DropdownInput({
298
+ label,
299
+ placeholder = 'Select an option',
300
+ items,
301
+ value,
302
+ defaultValue = null,
303
+ onValueChange,
304
+ children,
305
+ renderValue,
306
+ open,
307
+ defaultOpen = false,
308
+ onOpenChange,
309
+ placement = 'bottom',
310
+ isRequired = false,
311
+ isDisabled = false,
312
+ isInvalid = false,
313
+ isReadOnly = false,
314
+ supportText,
315
+ errorMessage,
316
+ menuMaxHeight = 240,
317
+ menuOffset = 4,
318
+ matchTriggerWidth = true,
319
+ closeOnBackdropPress = true,
320
+ modes: propModes = EMPTY_MODES,
321
+ style,
322
+ inputStyle,
323
+ menuStyle,
324
+ accessibilityLabel,
325
+ accessibilityHint,
326
+ onFocus,
327
+ onBlur,
328
+ }: DropdownInputProps) {
329
+ // ---------------- Modes ----------------
330
+ const { modes: globalModes } = useTokens()
331
+ const baseModes = useMemo(
332
+ () => ({ ...globalModes, ...propModes }),
333
+ [globalModes, propModes]
334
+ )
335
+
336
+ // ---------------- Open state ----------------
337
+ const isControlledOpen = open !== undefined
338
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
339
+ const isOpen = (isControlledOpen ? open : internalOpen) && !isDisabled && !isReadOnly
340
+
341
+ const setOpenState = useCallback(
342
+ (next: boolean) => {
343
+ if (!isControlledOpen) setInternalOpen(next)
344
+ onOpenChange?.(next)
345
+ },
346
+ [isControlledOpen, onOpenChange]
347
+ )
348
+
349
+ const closeMenu = useCallback(() => setOpenState(false), [setOpenState])
350
+ const toggleMenu = useCallback(() => setOpenState(!isOpen), [isOpen, setOpenState])
351
+
352
+ // ---------------- Value state ----------------
353
+ const isControlledValue = value !== undefined
354
+ const [internalValue, setInternalValue] = useState<
355
+ DropdownInputOptionValue | null
356
+ >(defaultValue)
357
+ const currentValue: DropdownInputOptionValue | null = isControlledValue
358
+ ? (value as DropdownInputOptionValue | null)
359
+ : internalValue
360
+
361
+ // Combine items + children-derived options into a single lookup table so
362
+ // selecting via either API surfaces the same option metadata.
363
+ const childOptions = useMemo(
364
+ () => collectOptionsFromChildren(children),
365
+ [children]
366
+ )
367
+ const allOptions = useMemo<DropdownInputOption[]>(
368
+ () => [...(items ?? []), ...childOptions],
369
+ [items, childOptions]
370
+ )
371
+
372
+ const selectedOption = useMemo(
373
+ () => allOptions.find((o) => o.value === currentValue),
374
+ [allOptions, currentValue]
375
+ )
376
+
377
+ const handleSelect = useCallback(
378
+ (selectedValue: DropdownItemProps['value']) => {
379
+ if (
380
+ typeof selectedValue !== 'string' &&
381
+ typeof selectedValue !== 'number'
382
+ ) {
383
+ // Items without a meaningful value just close the menu.
384
+ closeMenu()
385
+ return
386
+ }
387
+ const option = allOptions.find((o) => o.value === selectedValue)
388
+ if (option?.disabled) return
389
+ if (!isControlledValue) setInternalValue(selectedValue)
390
+ onValueChange?.(selectedValue, option)
391
+ closeMenu()
392
+ },
393
+ [allOptions, closeMenu, isControlledValue, onValueChange]
394
+ )
395
+
396
+ // ---------------- Token modes (with state cascade) ----------------
397
+ const modes = useMemo(
398
+ () => ({
399
+ ...baseModes,
400
+ 'FormField States': isInvalid
401
+ ? 'Error'
402
+ : isReadOnly
403
+ ? 'Read Only'
404
+ : isOpen
405
+ ? 'Active'
406
+ : (baseModes['FormField States'] as string) || 'Idle',
407
+ }),
408
+ [baseModes, isInvalid, isReadOnly, isOpen]
409
+ )
410
+
411
+ const tokens = useFormFieldTokens(modes)
412
+ const chevron = useChevronTokens(modes)
413
+
414
+ // ---------------- Layout / measurement ----------------
415
+ const triggerRef = useRef<View>(null)
416
+ const [triggerRect, setTriggerRect] = useState<Rect | null>(null)
417
+ const insets = useSafeAreaInsets()
418
+
419
+ const measure = useCallback(() => {
420
+ if (!triggerRef.current) return
421
+ triggerRef.current.measureInWindow((x, y, width, height) => {
422
+ if (
423
+ !Number.isFinite(x) ||
424
+ !Number.isFinite(y) ||
425
+ !Number.isFinite(width) ||
426
+ !Number.isFinite(height)
427
+ ) {
428
+ return
429
+ }
430
+ setTriggerRect((prev) => {
431
+ if (
432
+ !prev ||
433
+ Math.abs(prev.x - x) > 0.5 ||
434
+ Math.abs(prev.y - y) > 0.5 ||
435
+ prev.width !== width ||
436
+ prev.height !== height
437
+ ) {
438
+ return { x, y, width, height }
439
+ }
440
+ return prev
441
+ })
442
+ })
443
+ }, [])
444
+
445
+ // Keep the trigger rect in sync while the menu is open (handles scroll,
446
+ // window resize, etc.). One rAF tick per frame is enough; we bail early
447
+ // if the rect hasn't changed so React doesn't re-render unnecessarily.
448
+ useEffect(() => {
449
+ if (!isOpen) return
450
+ let raf = 0
451
+ const loop = () => {
452
+ measure()
453
+ raf = requestAnimationFrame(loop)
454
+ }
455
+ loop()
456
+ return () => {
457
+ if (raf) cancelAnimationFrame(raf)
458
+ }
459
+ }, [isOpen, measure])
460
+
461
+ const handleTriggerLayout = useCallback(
462
+ (_e: LayoutChangeEvent) => {
463
+ measure()
464
+ },
465
+ [measure]
466
+ )
467
+
468
+ // ---------------- Popup positioning ----------------
469
+ const [menuSize, setMenuSize] = useState<{
470
+ width: number
471
+ height: number
472
+ } | null>(null)
473
+
474
+ const handleMenuLayout = useCallback((e: LayoutChangeEvent) => {
475
+ const { width, height } = e.nativeEvent.layout
476
+ setMenuSize((prev) => {
477
+ if (!prev || prev.width !== width || prev.height !== height) {
478
+ return { width, height }
479
+ }
480
+ return prev
481
+ })
482
+ }, [])
483
+
484
+ const { width: windowWidth, height: windowHeight } = Dimensions.get('window')
485
+
486
+ const computedPlacement = useMemo<'top' | 'bottom'>(() => {
487
+ if (!triggerRect) return placement === 'top' ? 'top' : 'bottom'
488
+ const spaceBelow =
489
+ windowHeight - (triggerRect.y + triggerRect.height) - insets.bottom
490
+ const spaceAbove = triggerRect.y - insets.top
491
+ const desiredHeight = Math.min(
492
+ menuSize?.height ?? menuMaxHeight,
493
+ menuMaxHeight
494
+ )
495
+ const needed = desiredHeight + menuOffset + 8
496
+ if (placement === 'top') {
497
+ return spaceAbove >= needed || spaceAbove >= spaceBelow
498
+ ? 'top'
499
+ : 'bottom'
500
+ }
501
+ if (placement === 'bottom') {
502
+ return spaceBelow >= needed || spaceBelow >= spaceAbove
503
+ ? 'bottom'
504
+ : 'top'
505
+ }
506
+ return spaceBelow >= needed || spaceBelow >= spaceAbove
507
+ ? 'bottom'
508
+ : 'top'
509
+ }, [
510
+ triggerRect,
511
+ placement,
512
+ windowHeight,
513
+ menuSize?.height,
514
+ menuMaxHeight,
515
+ menuOffset,
516
+ insets.top,
517
+ insets.bottom,
518
+ ])
519
+
520
+ const popupStyle = useMemo<ViewStyle>(() => {
521
+ if (!triggerRect) {
522
+ return { position: 'absolute', opacity: 0, top: 0, left: 0 }
523
+ }
524
+ const screenPadding = 8
525
+ const width = matchTriggerWidth ? triggerRect.width : undefined
526
+ const intrinsicWidth = menuSize?.width ?? triggerRect.width
527
+ const finalWidth = width ?? intrinsicWidth
528
+
529
+ let leftPos = triggerRect.x
530
+ const maxLeft =
531
+ windowWidth - insets.right - finalWidth - screenPadding
532
+ const minLeft = insets.left + screenPadding
533
+ if (leftPos > maxLeft) leftPos = maxLeft
534
+ if (leftPos < minLeft) leftPos = minLeft
535
+
536
+ let topPos: number
537
+ if (computedPlacement === 'top') {
538
+ const desiredHeight = menuSize?.height ?? menuMaxHeight
539
+ topPos = triggerRect.y - desiredHeight - menuOffset
540
+ if (topPos < insets.top + screenPadding) {
541
+ topPos = insets.top + screenPadding
542
+ }
543
+ } else {
544
+ topPos = triggerRect.y + triggerRect.height + menuOffset
545
+ }
546
+
547
+ const style: ViewStyle = {
548
+ position: 'absolute',
549
+ top: topPos,
550
+ left: leftPos,
551
+ }
552
+ if (width != null) style.width = width
553
+ // Hide first frame before measurement to avoid the popup flashing in
554
+ // the wrong place. menuSize becomes truthy after the first layout.
555
+ if (menuSize == null) style.opacity = 0
556
+ return style
557
+ }, [
558
+ triggerRect,
559
+ computedPlacement,
560
+ menuSize,
561
+ menuOffset,
562
+ menuMaxHeight,
563
+ matchTriggerWidth,
564
+ windowWidth,
565
+ insets.top,
566
+ insets.left,
567
+ insets.right,
568
+ ])
569
+
570
+ // Reset menu size when closing so the next open re-measures (handles items
571
+ // changing while the menu was closed).
572
+ useEffect(() => {
573
+ if (!isOpen) setMenuSize(null)
574
+ }, [isOpen])
575
+
576
+ // ---------------- Styles ----------------
577
+ const labelTextStyle: TextStyle = {
578
+ color: tokens.labelColor,
579
+ fontFamily: tokens.labelFontFamily,
580
+ fontSize: tokens.labelFontSize,
581
+ lineHeight: tokens.labelLineHeight,
582
+ fontWeight: tokens.labelFontWeight as TextStyle['fontWeight'],
583
+ }
584
+
585
+ const requiredIndicatorStyle: TextStyle = {
586
+ ...labelTextStyle,
587
+ color: '#d93d3d',
588
+ }
589
+
590
+ const wrapperStyle: ViewStyle = {
591
+ gap: tokens.gap,
592
+ opacity: isDisabled ? 0.5 : 1,
593
+ width: '100%',
594
+ }
595
+
596
+ // 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.
599
+ const inputRowStyle: ViewStyle = {
600
+ flexDirection: 'row',
601
+ alignItems: 'center',
602
+ backgroundColor: tokens.inputBackground,
603
+ borderColor: tokens.inputBorderColor,
604
+ borderWidth: isOpen ? Math.max(tokens.inputBorderSize, 1) : tokens.inputBorderSize,
605
+ borderRadius: tokens.inputRadius,
606
+ paddingHorizontal: tokens.inputPaddingH,
607
+ paddingVertical: 0,
608
+ gap: tokens.inputGap,
609
+ minHeight: tokens.inputLineHeight,
610
+ }
611
+
612
+ const valueTextStyle: TextStyle = {
613
+ flex: 1,
614
+ color: tokens.inputTextColor,
615
+ fontFamily: tokens.inputFontFamily,
616
+ fontSize: tokens.inputFontSize,
617
+ lineHeight: tokens.inputLineHeight,
618
+ fontWeight: tokens.inputFontWeight as TextStyle['fontWeight'],
619
+ paddingVertical: 0,
620
+ }
621
+
622
+ const placeholderColor = '#888a8d'
623
+
624
+ // ---------------- Support text ----------------
625
+ const supportStatus: SupportTextStatus = isInvalid ? 'Error' : 'Neutral'
626
+ const supportLabel = isInvalid && errorMessage ? errorMessage : supportText
627
+
628
+ // ---------------- Accessibility ----------------
629
+ const resolvedA11yLabel =
630
+ accessibilityLabel || label || placeholder || 'Dropdown'
631
+ const a11yProps: AccessibilityProps & { [key: string]: any } = {
632
+ accessibilityRole: 'combobox',
633
+ accessibilityLabel: resolvedA11yLabel,
634
+ accessibilityState: {
635
+ disabled: isDisabled,
636
+ expanded: isOpen,
637
+ },
638
+ }
639
+ if (accessibilityHint) a11yProps.accessibilityHint = accessibilityHint
640
+
641
+ // ---------------- Items rendering ----------------
642
+ const renderItems = useCallback(() => {
643
+ const itemNodes: React.ReactNode[] = []
644
+ if (items && items.length > 0) {
645
+ items.forEach((opt) => {
646
+ const isSelected = opt.value === currentValue
647
+ itemNodes.push(
648
+ <DropdownItem
649
+ key={`item-${opt.value}`}
650
+ value={opt.value}
651
+ label={opt.label}
652
+ selected={isSelected}
653
+ disabled={opt.disabled ?? false}
654
+ leading={opt.leading}
655
+ trailing={opt.trailing}
656
+ onPress={handleSelect}
657
+ modes={modes}
658
+ />
659
+ )
660
+ })
661
+ }
662
+ if (children) {
663
+ // Inject `selected` and `onPress` into child DropdownItems so the
664
+ // consumer doesn't have to wire selection by hand. Existing
665
+ // `onPress` handlers on a child are preserved and called after our
666
+ // selection logic runs.
667
+ flattenChildren(children).forEach((child, idx) => {
668
+ if (!React.isValidElement(child)) {
669
+ itemNodes.push(child)
670
+ return
671
+ }
672
+ if ((child.type as any) === DropdownItem) {
673
+ const original = child.props as DropdownItemProps
674
+ const isSelected = original.value === currentValue
675
+ const composedOnPress = (
676
+ v: DropdownItemProps['value']
677
+ ) => {
678
+ original.onPress?.(v)
679
+ handleSelect(v)
680
+ }
681
+ itemNodes.push(
682
+ React.cloneElement(child, {
683
+ key: child.key ?? `child-${idx}`,
684
+ selected: isSelected,
685
+ onPress: composedOnPress,
686
+ modes: { ...modes, ...(original.modes || {}) },
687
+ } as Partial<DropdownItemProps>)
688
+ )
689
+ } else {
690
+ itemNodes.push(child)
691
+ }
692
+ })
693
+ }
694
+ return itemNodes
695
+ }, [items, children, currentValue, handleSelect, modes])
696
+
697
+ // ---------------- Render ----------------
698
+ const hasValue = selectedOption != null
699
+ const displayLabel = hasValue ? selectedOption!.label : placeholder
700
+
701
+ return (
702
+ <View style={[wrapperStyle, style]} pointerEvents={isDisabled ? 'none' : 'auto'}>
703
+ {label != null && (
704
+ <View style={styles.labelRow}>
705
+ <Text style={labelTextStyle}>{label}</Text>
706
+ {isRequired && <Text style={requiredIndicatorStyle}> *</Text>}
707
+ </View>
708
+ )}
709
+
710
+ <Pressable
711
+ ref={triggerRef}
712
+ onLayout={handleTriggerLayout}
713
+ onPress={() => {
714
+ if (isDisabled || isReadOnly) return
715
+ measure()
716
+ toggleMenu()
717
+ }}
718
+ {...(onFocus ? { onFocus } : {})}
719
+ {...(onBlur ? { onBlur } : {})}
720
+ style={[inputRowStyle, inputStyle, IS_WEB && webNoOutline]}
721
+ {...a11yProps}
722
+ {...(IS_WEB
723
+ ? {
724
+ accessibilityRole: 'combobox' as const,
725
+ 'aria-haspopup': 'listbox' as const,
726
+ 'aria-expanded': isOpen,
727
+ }
728
+ : {})}
729
+ >
730
+ {renderValue ? (
731
+ <View style={{ flex: 1, justifyContent: 'center' }}>
732
+ {renderValue(selectedOption, hasValue)}
733
+ </View>
734
+ ) : (
735
+ <Text
736
+ style={[
737
+ valueTextStyle,
738
+ !hasValue && { color: placeholderColor },
739
+ ]}
740
+ numberOfLines={1}
741
+ >
742
+ {displayLabel}
743
+ </Text>
744
+ )}
745
+ <View
746
+ accessibilityElementsHidden
747
+ importantForAccessibility="no"
748
+ pointerEvents="none"
749
+ >
750
+ <Icon
751
+ name={isOpen ? 'ic_chevron_up' : 'ic_chevron_down'}
752
+ size={chevron.iconSize}
753
+ color={chevron.iconColor}
754
+ />
755
+ </View>
756
+ </Pressable>
757
+
758
+ {supportLabel != null && (
759
+ <SupportText
760
+ label={supportLabel}
761
+ status={supportStatus}
762
+ modes={modes}
763
+ />
764
+ )}
765
+
766
+ <Modal
767
+ visible={isOpen}
768
+ transparent
769
+ animationType="fade"
770
+ onRequestClose={closeMenu}
771
+ statusBarTranslucent
772
+ >
773
+ <Pressable
774
+ style={StyleSheet.absoluteFill}
775
+ onPress={closeOnBackdropPress ? closeMenu : undefined}
776
+ accessibilityRole="button"
777
+ accessibilityLabel="Close options"
778
+ accessible={false}
779
+ >
780
+ <View
781
+ style={StyleSheet.absoluteFill}
782
+ pointerEvents="box-none"
783
+ >
784
+ <View
785
+ style={popupStyle}
786
+ onLayout={handleMenuLayout}
787
+ pointerEvents="auto"
788
+ >
789
+ <Dropdown
790
+ modes={modes}
791
+ maxHeight={menuMaxHeight}
792
+ style={menuStyle}
793
+ accessibilityLabel={`${resolvedA11yLabel} options`}
794
+ >
795
+ {renderItems()}
796
+ </Dropdown>
797
+ </View>
798
+ </View>
799
+ </Pressable>
800
+ </Modal>
801
+ </View>
802
+ )
803
+ }
804
+
805
+ const webNoOutline: any = {
806
+ outlineStyle: 'none',
807
+ outlineWidth: 0,
808
+ outlineColor: 'transparent',
809
+ cursor: 'pointer',
810
+ }
811
+
812
+ const styles = StyleSheet.create({
813
+ labelRow: {
814
+ flexDirection: 'row',
815
+ alignItems: 'baseline',
816
+ },
817
+ })
818
+
819
+ export default DropdownInput