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
@@ -0,0 +1,756 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import {
9
+ Platform,
10
+ Pressable,
11
+ Text,
12
+ TextInput as RNTextInput,
13
+ View,
14
+ type AccessibilityProps,
15
+ type StyleProp,
16
+ type TextInputProps as RNTextInputProps,
17
+ type TextStyle,
18
+ type ViewStyle,
19
+ } from 'react-native'
20
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
21
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
22
+ import { EMPTY_MODES } from '../../utils/react-utils'
23
+ import SupportText from '../SupportText/SupportText'
24
+ import type { SupportTextStatus } from '../SupportText/SupportTextIcon'
25
+ import Dropdown, { DropdownItem } from '../Dropdown/Dropdown'
26
+
27
+ const IS_WEB = Platform.OS === 'web'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type SuggestiveSearchOptionValue = string | number
34
+
35
+ export type SuggestiveSearchOption = {
36
+ /** Stable, unique value used to identify the suggestion. */
37
+ value: SuggestiveSearchOptionValue
38
+ /** Human-readable label shown in the suggestion list and the input. */
39
+ label: string
40
+ /** Whether the suggestion is non-selectable. */
41
+ disabled?: boolean
42
+ }
43
+
44
+ /**
45
+ * Suggestions accept either a bare string (used as both value and label) or a
46
+ * full `{ value, label }` option object for richer data.
47
+ */
48
+ export type SuggestiveSearchItem = string | SuggestiveSearchOption
49
+
50
+ export type SuggestiveSearchProps = {
51
+ /** Label rendered above the input. */
52
+ label?: string
53
+ /** Placeholder text shown when the query is empty. */
54
+ placeholder?: string
55
+ /**
56
+ * Suggestions to filter against the current query. May be bare strings or
57
+ * `{ value, label }` objects.
58
+ */
59
+ items?: SuggestiveSearchItem[]
60
+ /**
61
+ * Current query text (controlled). When `undefined` the component manages
62
+ * its own query state internally.
63
+ */
64
+ inputValue?: string
65
+ /** Initial query text for uncontrolled mode. */
66
+ defaultInputValue?: string
67
+ /** Called whenever the query text changes (typing or selection). */
68
+ onInputChange?: (text: string) => void
69
+ /**
70
+ * Currently selected suggestion value (controlled). When `undefined` the
71
+ * component tracks the selection internally.
72
+ */
73
+ value?: SuggestiveSearchOptionValue | null
74
+ /** Initial selected value for uncontrolled mode. */
75
+ defaultValue?: SuggestiveSearchOptionValue | null
76
+ /** Called when a suggestion is chosen. */
77
+ onValueChange?: (
78
+ value: SuggestiveSearchOptionValue | null,
79
+ option?: SuggestiveSearchOption
80
+ ) => void
81
+ /**
82
+ * Custom predicate deciding whether an option matches the current query.
83
+ * Defaults to a case-insensitive substring match on the label.
84
+ */
85
+ filter?: (query: string, option: SuggestiveSearchOption) => boolean
86
+ /**
87
+ * Minimum number of characters required before suggestions are shown.
88
+ * @default 1
89
+ */
90
+ minChars?: number
91
+ /** Caps the number of suggestions rendered. Defaults to no limit. */
92
+ maxResults?: number
93
+ /**
94
+ * Highlights the matched substring of each suggestion in bold.
95
+ * @default true
96
+ */
97
+ highlightMatch?: boolean
98
+ /**
99
+ * Message shown when the query has matched no suggestions. When omitted,
100
+ * the dropdown simply stays hidden on an empty result set.
101
+ */
102
+ emptyMessage?: string
103
+ /** Custom renderer for a suggestion row (overrides the default label). */
104
+ renderItem?: (
105
+ option: SuggestiveSearchOption,
106
+ meta: { query: string; isSelected: boolean }
107
+ ) => React.ReactNode
108
+ /** Controlled open state of the suggestion dropdown. */
109
+ open?: boolean
110
+ /** Initial open state for uncontrolled mode. */
111
+ defaultOpen?: boolean
112
+ /** Called whenever the open state changes. */
113
+ onOpenChange?: (open: boolean) => void
114
+ /**
115
+ * Maximum height of the suggestion list before it becomes scrollable.
116
+ * @default 240
117
+ */
118
+ menuMaxHeight?: number
119
+ /**
120
+ * Vertical gap between the input and the suggestion dropdown.
121
+ * @default 6
122
+ */
123
+ menuOffset?: number
124
+ /** Renders a required asterisk next to the label. */
125
+ isRequired?: boolean
126
+ /** Disables interaction and dims the field. */
127
+ isDisabled?: boolean
128
+ /** Marks the field as invalid and shows `errorMessage`. */
129
+ isInvalid?: boolean
130
+ /** Renders the field as read-only (non-interactive, not dimmed). */
131
+ isReadOnly?: boolean
132
+ /** Helper text displayed below the input. */
133
+ supportText?: string
134
+ /** Replaces `supportText` when `isInvalid` is true. */
135
+ errorMessage?: string
136
+ /** Modes for design token resolution. */
137
+ modes?: Record<string, any>
138
+ /** Style overrides for the outermost wrapper. */
139
+ style?: StyleProp<ViewStyle>
140
+ /** Style overrides for the input row. */
141
+ inputStyle?: StyleProp<ViewStyle>
142
+ /** Style overrides for the input text. */
143
+ inputTextStyle?: StyleProp<TextStyle>
144
+ /** Style overrides for the suggestion dropdown container. */
145
+ menuStyle?: StyleProp<ViewStyle>
146
+ /** Accessibility label. Defaults to the visible label / placeholder. */
147
+ accessibilityLabel?: string
148
+ /** Accessibility hint. */
149
+ accessibilityHint?: string
150
+ /** Called when the input receives focus. */
151
+ onFocus?: RNTextInputProps['onFocus']
152
+ /** Called when the input loses focus. */
153
+ onBlur?: RNTextInputProps['onBlur']
154
+ /** Test identifier. */
155
+ testID?: string
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Helpers
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function toNumber(value: unknown, fallback: number): number {
163
+ if (typeof value === 'number' && Number.isFinite(value)) return value
164
+ if (typeof value === 'string') {
165
+ const parsed = parseFloat(value)
166
+ if (Number.isFinite(parsed)) return parsed
167
+ }
168
+ return fallback
169
+ }
170
+
171
+ function normalizeItem(item: SuggestiveSearchItem): SuggestiveSearchOption {
172
+ if (typeof item === 'string') return { value: item, label: item }
173
+ return item
174
+ }
175
+
176
+ const defaultFilter = (query: string, option: SuggestiveSearchOption) =>
177
+ option.label.toLowerCase().includes(query.toLowerCase())
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Token resolution
181
+ // ---------------------------------------------------------------------------
182
+
183
+ function useFormFieldTokens(modes: Record<string, any>) {
184
+ return useMemo(() => {
185
+ const labelColor =
186
+ (getVariableByName('formField/label/color', modes) as string) ||
187
+ '#000000'
188
+ const labelFontFamily =
189
+ (getVariableByName('formField/label/fontFamily', modes) as string) ||
190
+ 'JioType Var'
191
+ const labelFontSize = toNumber(
192
+ getVariableByName('formField/label/fontSize', modes),
193
+ 14
194
+ )
195
+ const labelLineHeight = toNumber(
196
+ getVariableByName('formField/label/lineHeight', modes),
197
+ 17
198
+ )
199
+ const labelFontWeight =
200
+ (getVariableByName('formField/label/fontWeight', modes) as string) ||
201
+ '500'
202
+
203
+ const gap = toNumber(getVariableByName('formField/gap', modes), 8)
204
+
205
+ const inputPaddingH = toNumber(
206
+ getVariableByName('formField/input/padding/horizontal', modes),
207
+ 12
208
+ )
209
+ const inputGap = toNumber(getVariableByName('formField/input/gap', modes), 8)
210
+ const inputRadius = toNumber(
211
+ getVariableByName('formField/input/radius', modes),
212
+ 8
213
+ )
214
+ const inputBorderSize = toNumber(
215
+ getVariableByName('formField/input/border/size', modes),
216
+ 1.5
217
+ )
218
+ const inputBackground =
219
+ (getVariableByName('formField/input/background', modes) as string) ||
220
+ '#ffffff'
221
+ const inputBorderColor =
222
+ (getVariableByName('formField/input/border/color', modes) as string) ||
223
+ '#b5b6b7'
224
+ const inputFontSize = toNumber(
225
+ getVariableByName('formField/input/label/fontSize', modes),
226
+ 16
227
+ )
228
+ const inputLineHeight = toNumber(
229
+ getVariableByName('formField/input/label/lineHeight', modes),
230
+ 45
231
+ )
232
+ const inputFontFamily =
233
+ (getVariableByName('formField/input/label/fontFamily', modes) as string) ||
234
+ 'JioType Var'
235
+ const inputFontWeight =
236
+ (getVariableByName('formField/input/label/fontWeight', modes) as string) ||
237
+ '400'
238
+ const inputTextColor =
239
+ (getVariableByName('formField/input/label/color', modes) as string) ||
240
+ '#24262b'
241
+
242
+ return {
243
+ labelColor,
244
+ labelFontFamily,
245
+ labelFontSize,
246
+ labelLineHeight,
247
+ labelFontWeight,
248
+ gap,
249
+ inputPaddingH,
250
+ inputGap,
251
+ inputRadius,
252
+ inputBorderSize,
253
+ inputBackground,
254
+ inputBorderColor,
255
+ inputFontSize,
256
+ inputLineHeight,
257
+ inputFontFamily,
258
+ inputFontWeight,
259
+ inputTextColor,
260
+ }
261
+ }, [modes])
262
+ }
263
+
264
+ function useDropdownItemTextTokens(modes: Record<string, any>) {
265
+ return useMemo(() => {
266
+ const foreground =
267
+ (getVariableByName('dropdownItem/foreground', modes) as string) ||
268
+ '#000000'
269
+ const fontFamily =
270
+ (getVariableByName('dropdownItem/fontFamily', modes) as string) ||
271
+ 'JioType Var'
272
+ const fontSize = toNumber(
273
+ getVariableByName('dropdownItem/fontSize', modes),
274
+ 16
275
+ )
276
+ const lineHeight = toNumber(
277
+ getVariableByName('dropdownItem/lineHeight', modes),
278
+ 19
279
+ )
280
+ return { foreground, fontFamily, fontSize, lineHeight }
281
+ }, [modes])
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Component
286
+ // ---------------------------------------------------------------------------
287
+
288
+ function SuggestiveSearch({
289
+ label,
290
+ placeholder = 'Search',
291
+ items,
292
+ inputValue,
293
+ defaultInputValue = '',
294
+ onInputChange,
295
+ value,
296
+ defaultValue = null,
297
+ onValueChange,
298
+ filter = defaultFilter,
299
+ minChars = 1,
300
+ maxResults,
301
+ highlightMatch = true,
302
+ emptyMessage,
303
+ renderItem,
304
+ open,
305
+ defaultOpen = false,
306
+ onOpenChange,
307
+ menuMaxHeight = 240,
308
+ menuOffset = 6,
309
+ isRequired = false,
310
+ isDisabled = false,
311
+ isInvalid = false,
312
+ isReadOnly = false,
313
+ supportText,
314
+ errorMessage,
315
+ modes: propModes = EMPTY_MODES,
316
+ style,
317
+ inputStyle,
318
+ inputTextStyle,
319
+ menuStyle,
320
+ accessibilityLabel,
321
+ accessibilityHint,
322
+ onFocus,
323
+ onBlur,
324
+ testID,
325
+ }: SuggestiveSearchProps) {
326
+ // ---------------- Modes ----------------
327
+ const { modes: globalModes } = useTokens()
328
+ const baseModes = useMemo(
329
+ () => ({ ...globalModes, ...propModes }),
330
+ [globalModes, propModes]
331
+ )
332
+
333
+ const interactive = !isDisabled && !isReadOnly
334
+
335
+ // ---------------- Query state ----------------
336
+ const isControlledInput = inputValue !== undefined
337
+ const [internalInput, setInternalInput] = useState(defaultInputValue)
338
+ const query = isControlledInput ? (inputValue as string) : internalInput
339
+
340
+ // ---------------- Selected value state ----------------
341
+ const isControlledValue = value !== undefined
342
+ const [internalValue, setInternalValue] = useState<
343
+ SuggestiveSearchOptionValue | null
344
+ >(defaultValue)
345
+ const currentValue = isControlledValue
346
+ ? (value as SuggestiveSearchOptionValue | null)
347
+ : internalValue
348
+
349
+ // ---------------- Open state ----------------
350
+ const isControlledOpen = open !== undefined
351
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
352
+ const [isFocused, setIsFocused] = useState(false)
353
+
354
+ const setOpenState = useCallback(
355
+ (next: boolean) => {
356
+ if (!isControlledOpen) setInternalOpen(next)
357
+ onOpenChange?.(next)
358
+ },
359
+ [isControlledOpen, onOpenChange]
360
+ )
361
+
362
+ // ---------------- Suggestions ----------------
363
+ const normalizedItems = useMemo(
364
+ () => (items ?? []).map(normalizeItem),
365
+ [items]
366
+ )
367
+
368
+ const suggestions = useMemo(() => {
369
+ const trimmed = query.trim()
370
+ if (trimmed.length < minChars) return []
371
+ const matched = normalizedItems.filter((opt) => filter(query, opt))
372
+ return maxResults != null ? matched.slice(0, maxResults) : matched
373
+ }, [normalizedItems, query, minChars, filter, maxResults])
374
+
375
+ const hasSuggestions = suggestions.length > 0
376
+ const showEmpty = Boolean(
377
+ emptyMessage && query.trim().length >= minChars && !hasSuggestions
378
+ )
379
+ const hasMenuContent = hasSuggestions || showEmpty
380
+
381
+ // Resolved open state: an explicit `open` prop wins; otherwise the dropdown
382
+ // tracks the internal "wants suggestions" flag (set on focus / typing,
383
+ // cleared on blur / select). Blur and outside-press handle dismissal, so we
384
+ // intentionally do NOT gate on `isFocused` here — that would suppress
385
+ // `defaultOpen` on mount.
386
+ const isOpen =
387
+ interactive &&
388
+ (isControlledOpen ? Boolean(open) : internalOpen) &&
389
+ hasMenuContent
390
+
391
+ // ---------------- Token modes (state cascade) ----------------
392
+ const modes = useMemo(
393
+ () => ({
394
+ ...baseModes,
395
+ 'FormField States': isInvalid
396
+ ? 'Error'
397
+ : isReadOnly || isDisabled
398
+ ? 'Read Only'
399
+ : isFocused
400
+ ? 'Active'
401
+ : (baseModes['FormField States'] as string) || 'Idle',
402
+ }),
403
+ [baseModes, isInvalid, isReadOnly, isDisabled, isFocused]
404
+ )
405
+
406
+ const tokens = useFormFieldTokens(modes)
407
+ const itemTextTokens = useDropdownItemTextTokens(modes)
408
+
409
+ // ---------------- Handlers ----------------
410
+ const inputRef = useRef<RNTextInput>(null)
411
+ const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
412
+
413
+ const clearBlurTimer = useCallback(() => {
414
+ if (blurTimer.current) {
415
+ clearTimeout(blurTimer.current)
416
+ blurTimer.current = null
417
+ }
418
+ }, [])
419
+
420
+ useEffect(() => () => clearBlurTimer(), [clearBlurTimer])
421
+
422
+ const setQuery = useCallback(
423
+ (text: string) => {
424
+ if (!isControlledInput) setInternalInput(text)
425
+ onInputChange?.(text)
426
+ },
427
+ [isControlledInput, onInputChange]
428
+ )
429
+
430
+ const handleChangeText = useCallback(
431
+ (text: string) => {
432
+ setQuery(text)
433
+ // Typing invalidates a prior selection unless the text still
434
+ // exactly matches the selected option's label.
435
+ if (currentValue != null) {
436
+ const selected = normalizedItems.find(
437
+ (o) => o.value === currentValue
438
+ )
439
+ if (!selected || selected.label !== text) {
440
+ if (!isControlledValue) setInternalValue(null)
441
+ onValueChange?.(null)
442
+ }
443
+ }
444
+ if (!isControlledOpen) setInternalOpen(true)
445
+ },
446
+ [
447
+ setQuery,
448
+ currentValue,
449
+ normalizedItems,
450
+ isControlledValue,
451
+ onValueChange,
452
+ isControlledOpen,
453
+ ]
454
+ )
455
+
456
+ const handleSelect = useCallback(
457
+ (selectedValue: SuggestiveSearchOptionValue | null) => {
458
+ clearBlurTimer()
459
+ const option = normalizedItems.find((o) => o.value === selectedValue)
460
+ if (!option || option.disabled) return
461
+ setQuery(option.label)
462
+ if (!isControlledValue) setInternalValue(option.value)
463
+ onValueChange?.(option.value, option)
464
+ setOpenState(false)
465
+ inputRef.current?.blur()
466
+ },
467
+ [
468
+ clearBlurTimer,
469
+ normalizedItems,
470
+ setQuery,
471
+ isControlledValue,
472
+ onValueChange,
473
+ setOpenState,
474
+ ]
475
+ )
476
+
477
+ const handleFocus = useCallback<NonNullable<RNTextInputProps['onFocus']>>(
478
+ (e) => {
479
+ clearBlurTimer()
480
+ setIsFocused(true)
481
+ if (!isControlledOpen) setInternalOpen(true)
482
+ onFocus?.(e)
483
+ },
484
+ [clearBlurTimer, isControlledOpen, onFocus]
485
+ )
486
+
487
+ const handleBlur = useCallback<NonNullable<RNTextInputProps['onBlur']>>(
488
+ (e) => {
489
+ // Delay closing so a suggestion press (which blurs the input first
490
+ // on web) still registers before the list unmounts.
491
+ clearBlurTimer()
492
+ blurTimer.current = setTimeout(() => {
493
+ setIsFocused(false)
494
+ if (!isControlledOpen) setInternalOpen(false)
495
+ }, 120)
496
+ onBlur?.(e)
497
+ },
498
+ [clearBlurTimer, isControlledOpen, onBlur]
499
+ )
500
+
501
+ // ---------------- Web outside-press to close ----------------
502
+ const rootRef = useRef<View>(null)
503
+ useEffect(() => {
504
+ if (!IS_WEB || !isOpen) return
505
+ const handler = (e: any) => {
506
+ const node = rootRef.current as unknown as {
507
+ contains?: (t: EventTarget | null) => boolean
508
+ } | null
509
+ if (node?.contains && !node.contains(e.target)) {
510
+ clearBlurTimer()
511
+ setIsFocused(false)
512
+ if (!isControlledOpen) setInternalOpen(false)
513
+ }
514
+ }
515
+ document.addEventListener('mousedown', handler)
516
+ return () => document.removeEventListener('mousedown', handler)
517
+ }, [isOpen, isControlledOpen, clearBlurTimer])
518
+
519
+ // ---------------- Styles ----------------
520
+ const labelTextStyle: TextStyle = {
521
+ color: tokens.labelColor,
522
+ fontFamily: tokens.labelFontFamily,
523
+ fontSize: tokens.labelFontSize,
524
+ lineHeight: tokens.labelLineHeight,
525
+ fontWeight: tokens.labelFontWeight as TextStyle['fontWeight'],
526
+ }
527
+
528
+ const requiredIndicatorStyle: TextStyle = {
529
+ ...labelTextStyle,
530
+ color: '#d93d3d',
531
+ }
532
+
533
+ const wrapperStyle: ViewStyle = {
534
+ gap: tokens.gap,
535
+ opacity: isDisabled ? 0.5 : 1,
536
+ width: '100%',
537
+ position: 'relative',
538
+ // Keep the dropdown above sibling content when it overflows the field.
539
+ zIndex: isOpen ? 1000 : undefined,
540
+ }
541
+
542
+ const inputRowStyle: ViewStyle = {
543
+ flexDirection: 'row',
544
+ alignItems: 'center',
545
+ backgroundColor: tokens.inputBackground,
546
+ borderColor: tokens.inputBorderColor,
547
+ borderWidth: tokens.inputBorderSize,
548
+ borderStyle: 'solid',
549
+ borderRadius: tokens.inputRadius,
550
+ paddingHorizontal: tokens.inputPaddingH,
551
+ paddingVertical: 0,
552
+ gap: tokens.inputGap,
553
+ minHeight: tokens.inputLineHeight,
554
+ width: '100%',
555
+ }
556
+
557
+ const inputTextStyles: TextStyle = {
558
+ flex: 1,
559
+ color: tokens.inputTextColor,
560
+ fontFamily: tokens.inputFontFamily,
561
+ fontSize: tokens.inputFontSize,
562
+ lineHeight: tokens.inputLineHeight,
563
+ fontWeight: tokens.inputFontWeight as TextStyle['fontWeight'],
564
+ padding: 0,
565
+ margin: 0,
566
+ ...(IS_WEB
567
+ ? {
568
+ outlineStyle: 'none' as TextStyle['outlineStyle'],
569
+ outlineWidth: 0,
570
+ outlineColor: 'transparent',
571
+ }
572
+ : {}),
573
+ }
574
+
575
+ const placeholderColor = '#888a8d'
576
+
577
+ const itemBaseTextStyle: TextStyle = {
578
+ flex: 1,
579
+ color: itemTextTokens.foreground,
580
+ fontFamily: itemTextTokens.fontFamily,
581
+ fontSize: itemTextTokens.fontSize,
582
+ lineHeight: itemTextTokens.lineHeight,
583
+ fontWeight: '400',
584
+ }
585
+
586
+ // ---------------- Support text ----------------
587
+ const supportStatus: SupportTextStatus = isInvalid ? 'Error' : 'Neutral'
588
+ const supportLabel = isInvalid && errorMessage ? errorMessage : supportText
589
+
590
+ // ---------------- Accessibility ----------------
591
+ const resolvedA11yLabel =
592
+ accessibilityLabel || label || placeholder || 'Search'
593
+ const a11yProps: AccessibilityProps & { [key: string]: any } = {
594
+ accessibilityRole: IS_WEB ? undefined : 'search',
595
+ accessibilityLabel: resolvedA11yLabel,
596
+ accessibilityState: { disabled: isDisabled, expanded: isOpen },
597
+ }
598
+ if (accessibilityHint) a11yProps.accessibilityHint = accessibilityHint
599
+
600
+ // ---------------- Suggestion highlight ----------------
601
+ const renderHighlighted = useCallback(
602
+ (optLabel: string) => {
603
+ const trimmed = query.trim()
604
+ if (!highlightMatch || trimmed.length === 0) {
605
+ return <Text style={itemBaseTextStyle}>{optLabel}</Text>
606
+ }
607
+ const idx = optLabel.toLowerCase().indexOf(trimmed.toLowerCase())
608
+ if (idx < 0) {
609
+ return <Text style={itemBaseTextStyle}>{optLabel}</Text>
610
+ }
611
+ const before = optLabel.slice(0, idx)
612
+ const match = optLabel.slice(idx, idx + trimmed.length)
613
+ const after = optLabel.slice(idx + trimmed.length)
614
+ return (
615
+ <Text style={itemBaseTextStyle}>
616
+ {before}
617
+ <Text style={{ fontWeight: '700' }}>{match}</Text>
618
+ {after}
619
+ </Text>
620
+ )
621
+ },
622
+ [query, highlightMatch, itemBaseTextStyle]
623
+ )
624
+
625
+ // ---------------- Render ----------------
626
+ return (
627
+ <View
628
+ ref={rootRef}
629
+ style={[wrapperStyle, style]}
630
+ pointerEvents={isDisabled ? 'none' : 'auto'}
631
+ testID={testID}
632
+ >
633
+ {label != null && (
634
+ <View style={styles.labelRow}>
635
+ <Text style={labelTextStyle}>{label}</Text>
636
+ {isRequired && <Text style={requiredIndicatorStyle}> *</Text>}
637
+ </View>
638
+ )}
639
+
640
+ <View style={styles.anchor}>
641
+ <Pressable
642
+ style={[inputRowStyle, inputStyle]}
643
+ onPress={() => inputRef.current?.focus()}
644
+ accessible={false}
645
+ >
646
+ <RNTextInput
647
+ ref={inputRef}
648
+ style={[inputTextStyles, inputTextStyle]}
649
+ value={query}
650
+ onChangeText={handleChangeText}
651
+ onFocus={handleFocus}
652
+ onBlur={handleBlur}
653
+ placeholder={placeholder}
654
+ placeholderTextColor={placeholderColor}
655
+ editable={interactive}
656
+ autoCapitalize="none"
657
+ autoComplete="off"
658
+ autoCorrect={false}
659
+ {...a11yProps}
660
+ {...(IS_WEB
661
+ ? {
662
+ accessibilityRole: 'search' as const,
663
+ 'aria-autocomplete': 'list' as const,
664
+ 'aria-expanded': isOpen,
665
+ }
666
+ : {})}
667
+ />
668
+ </Pressable>
669
+
670
+ {isOpen && (
671
+ <View
672
+ style={[
673
+ styles.popup,
674
+ { top: '100%', marginTop: menuOffset },
675
+ ]}
676
+ // Keep taps from dismissing the keyboard before the
677
+ // item's press handler runs on native.
678
+ >
679
+ <Dropdown
680
+ modes={modes}
681
+ maxHeight={menuMaxHeight}
682
+ style={menuStyle}
683
+ accessibilityLabel={`${resolvedA11yLabel} suggestions`}
684
+ >
685
+ {hasSuggestions
686
+ ? suggestions.map((opt) => {
687
+ const isSelected = opt.value === currentValue
688
+ return (
689
+ <DropdownItem
690
+ key={`sg-${opt.value}`}
691
+ value={opt.value}
692
+ selected={isSelected}
693
+ disabled={opt.disabled ?? false}
694
+ onPress={handleSelect}
695
+ modes={modes}
696
+ >
697
+ {renderItem
698
+ ? renderItem(opt, {
699
+ query,
700
+ isSelected,
701
+ })
702
+ : renderHighlighted(opt.label)}
703
+ </DropdownItem>
704
+ )
705
+ })
706
+ : showEmpty && (
707
+ <View style={styles.emptyRow}>
708
+ <Text
709
+ style={[
710
+ itemBaseTextStyle,
711
+ { color: placeholderColor },
712
+ ]}
713
+ >
714
+ {emptyMessage}
715
+ </Text>
716
+ </View>
717
+ )}
718
+ </Dropdown>
719
+ </View>
720
+ )}
721
+ </View>
722
+
723
+ {supportLabel != null && supportLabel !== '' && (
724
+ <SupportText
725
+ label={supportLabel}
726
+ status={supportStatus}
727
+ modes={modes}
728
+ />
729
+ )}
730
+ </View>
731
+ )
732
+ }
733
+
734
+ const styles = {
735
+ labelRow: {
736
+ flexDirection: 'row',
737
+ alignItems: 'baseline',
738
+ } as ViewStyle,
739
+ anchor: {
740
+ position: 'relative',
741
+ width: '100%',
742
+ zIndex: 1,
743
+ } as ViewStyle,
744
+ popup: {
745
+ position: 'absolute',
746
+ left: 0,
747
+ right: 0,
748
+ zIndex: 1000,
749
+ } as ViewStyle,
750
+ emptyRow: {
751
+ paddingHorizontal: 12,
752
+ paddingVertical: 12,
753
+ } as ViewStyle,
754
+ }
755
+
756
+ export default SuggestiveSearch