react-native-molecules 0.5.0-beta.21 → 0.5.0-beta.22

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 (51) hide show
  1. package/components/Card/Card.tsx +1 -1
  2. package/components/Checkbox/CheckboxBase.ios.tsx +1 -4
  3. package/components/Checkbox/CheckboxBase.tsx +2 -7
  4. package/components/DatePicker/DateCalendar.tsx +4 -4
  5. package/components/DatePicker/DatePickerModal.tsx +2 -1
  6. package/components/DatePickerInline/DatePickerDockedHeader.tsx +3 -3
  7. package/components/DatePickerInline/DatePickerInline.tsx +1 -1
  8. package/components/DatePickerInline/DatePickerInlineBase.tsx +2 -2
  9. package/components/DatePickerInline/DatePickerInlineHeader.tsx +43 -17
  10. package/components/DatePickerInline/HeaderItem.tsx +2 -2
  11. package/components/DatePickerInline/MonthPicker.tsx +64 -54
  12. package/components/DatePickerInline/Swiper.native.tsx +2 -2
  13. package/components/DatePickerInline/Swiper.tsx +3 -3
  14. package/components/DatePickerInline/YearPicker.tsx +136 -112
  15. package/components/DatePickerInline/{DatePickerContext.tsx → store.tsx} +7 -3
  16. package/components/DatePickerInline/types.ts +1 -1
  17. package/components/Divider/Divider.tsx +192 -0
  18. package/components/Divider/index.tsx +11 -0
  19. package/components/Drawer/DrawerItemGroup.tsx +3 -7
  20. package/components/IconButton/IconButton.tsx +2 -12
  21. package/components/List/List.tsx +507 -0
  22. package/components/List/context.tsx +28 -0
  23. package/components/List/index.ts +9 -0
  24. package/components/List/types.ts +149 -0
  25. package/components/{ListItem → List}/utils.ts +47 -50
  26. package/components/Menu/Menu.tsx +156 -12
  27. package/components/Menu/index.tsx +11 -7
  28. package/components/Menu/utils.ts +21 -70
  29. package/components/RadioButton/RadioButtonAndroid.tsx +38 -54
  30. package/components/RadioButton/RadioButtonIOS.tsx +2 -16
  31. package/components/Select/Select.tsx +139 -497
  32. package/components/Select/context.tsx +14 -32
  33. package/components/Select/types.ts +44 -53
  34. package/components/Select/utils.ts +15 -47
  35. package/components/Text/textFactory.tsx +17 -5
  36. package/components/TimePicker/TimeInput.tsx +2 -7
  37. package/components/TimePicker/utils.ts +0 -4
  38. package/components/TouchableRipple/TouchableRipple.native.tsx +36 -5
  39. package/components/TouchableRipple/TouchableRipple.tsx +53 -19
  40. package/components/TouchableRipple/rippleFromForegroundColor.ts +21 -0
  41. package/package.json +4 -2
  42. package/components/HorizontalDivider/HorizontalDivider.tsx +0 -103
  43. package/components/HorizontalDivider/index.tsx +0 -9
  44. package/components/ListItem/ListItem.tsx +0 -138
  45. package/components/ListItem/ListItemDescription.tsx +0 -25
  46. package/components/ListItem/ListItemTitle.tsx +0 -25
  47. package/components/ListItem/index.tsx +0 -14
  48. package/components/Menu/MenuDivider.tsx +0 -13
  49. package/components/Menu/MenuItem.tsx +0 -128
  50. package/components/VerticalDivider/VerticalDivider.tsx +0 -100
  51. package/components/VerticalDivider/index.tsx +0 -9
@@ -5,174 +5,35 @@ import {
5
5
  type LayoutChangeEvent,
6
6
  Platform,
7
7
  Pressable,
8
- ScrollView,
9
8
  View,
10
9
  } from 'react-native';
11
10
 
12
11
  import { typedMemo } from '../../hocs';
13
- import { useActionState, useControlledValue, useLatest } from '../../hooks';
12
+ import { useActionState } from '../../hooks';
14
13
  import { useToggle } from '../../hooks';
15
14
  import { resolveStateVariant } from '../../utils';
16
15
  import { Chip } from '../Chip';
17
16
  import { Icon } from '../Icon';
18
- import { IconButton } from '../IconButton';
17
+ import { List } from '../List';
19
18
  import { Popover } from '../Popover';
20
19
  import { Text } from '../Text';
21
- import { TextInput, type TextInputHandles, type TextInputProps } from '../TextInput';
22
20
  import {
23
- SelectContextProvider,
24
21
  SelectDropdownContextProvider,
25
22
  useSelectContextValue,
26
23
  useSelectDropdownContextValue,
27
24
  } from './context';
28
25
  import type {
29
26
  DefaultItemT,
30
- SelectContentProps,
31
- SelectContextValue,
32
27
  SelectDropdownProps,
33
- SelectGroupProps,
34
28
  SelectOptionProps,
35
29
  SelectProps,
36
- SelectSearchInputProps,
37
30
  SelectTriggerProps,
38
31
  SelectValueProps,
39
32
  } from './types';
40
- import { styles, triggerStyles } from './utils';
33
+ import { collectWebSelectKeyboardOptionElements, styles, triggerStyles } from './utils';
41
34
 
42
35
  const emptyArr: unknown[] = [];
43
36
 
44
- // SelectProvider - manages controlled/uncontrolled state
45
- const SelectProvider = typedMemo(
46
- <Option extends DefaultItemT = DefaultItemT>({
47
- children,
48
- value: valueProp,
49
- defaultValue,
50
- onChange,
51
- multiple = false,
52
- disabled = false,
53
- error = false,
54
- labelKey = 'label',
55
- options = emptyArr as Option[],
56
- searchKey,
57
- onSearchChange,
58
- hideSelected: hideSelectedProp,
59
- }: SelectProps<Option>) => {
60
- const [value, onValueChange] = useControlledValue<Option['id'] | Option['id'][] | null>({
61
- value: valueProp,
62
- defaultValue: defaultValue ?? (multiple ? (emptyArr as Option['id'][]) : null),
63
- onChange,
64
- });
65
- const valueRef = useLatest(value);
66
-
67
- const [searchQuery, setSearchQuery] = useState('');
68
-
69
- const handleSearchQueryChange = useCallback(
70
- (query: string) => {
71
- setSearchQuery(query);
72
- onSearchChange?.(query);
73
- },
74
- [onSearchChange],
75
- );
76
-
77
- // Default hideSelected to multiple (true for multi-select, false for single select)
78
- const hideSelected = hideSelectedProp !== undefined ? hideSelectedProp : multiple;
79
-
80
- const filteredOptions = useMemo(() => {
81
- let result = options;
82
-
83
- // Filter out selected items if hideSelected is true
84
- if (hideSelected) {
85
- result = result.filter(item => {
86
- if (multiple) {
87
- const values = (value as Option['id'][]) || [];
88
- return !values.some(v => v === item.id);
89
- } else {
90
- const singleValue = value as Option['id'] | null;
91
- return singleValue !== item.id;
92
- }
93
- });
94
- }
95
-
96
- // Apply search filter if there's a search query
97
- if (searchQuery) {
98
- const key = searchKey || labelKey || 'label';
99
- const lowerQuery = searchQuery.toLowerCase();
100
- result = result.filter(item => {
101
- const itemValue = item[key];
102
- return String(itemValue || '')
103
- .toLowerCase()
104
- .includes(lowerQuery);
105
- });
106
- }
107
-
108
- return result;
109
- }, [options, searchQuery, searchKey, labelKey, hideSelected, multiple, value]);
110
-
111
- const onAdd = useCallback(
112
- (item: Option) => {
113
- if (multiple) {
114
- const currentValue = (valueRef.current as Option['id'][]) || [];
115
- if (!currentValue.find(v => v === item.id)) {
116
- onValueChange([...currentValue, item.id] as Option['id'][], item);
117
- }
118
- } else {
119
- onValueChange(item.id, item);
120
- }
121
- },
122
- [multiple, valueRef, onValueChange],
123
- );
124
-
125
- const onRemove = useCallback(
126
- (item: Option) => {
127
- if (multiple) {
128
- const currentValue = (valueRef.current as Option['id'][]) || [];
129
- onValueChange(currentValue.filter(v => v !== item.id) as Option['id'][], item);
130
- } else {
131
- onValueChange(null, item);
132
- }
133
- },
134
- [multiple, valueRef, onValueChange],
135
- );
136
-
137
- const contextValue = useMemo(
138
- () => ({
139
- value: value,
140
- multiple,
141
- onAdd: onAdd as (item: DefaultItemT) => void,
142
- onRemove: onRemove as (item: DefaultItemT) => void,
143
- disabled,
144
- error,
145
- labelKey,
146
- options,
147
- searchQuery,
148
- setSearchQuery: handleSearchQueryChange,
149
- filteredOptions,
150
- }),
151
- [
152
- value,
153
- multiple,
154
- onAdd,
155
- onRemove,
156
- disabled,
157
- error,
158
- labelKey,
159
- options,
160
- searchQuery,
161
- handleSearchQueryChange,
162
- filteredOptions,
163
- ],
164
- );
165
-
166
- return (
167
- <SelectContextProvider
168
- value={contextValue as unknown as SelectContextValue<DefaultItemT>}>
169
- {children}
170
- </SelectContextProvider>
171
- );
172
- },
173
- );
174
-
175
- // SelectDropdownProvider - manages dropdown state
176
37
  const SelectDropdownProvider = memo(
177
38
  ({
178
39
  children,
@@ -185,7 +46,6 @@ const SelectDropdownProvider = memo(
185
46
  }) => {
186
47
  const { state: isOpen, handleOpen, handleClose } = useToggle(false);
187
48
  const triggerRef = useRef<View>(null);
188
- const contentRef = useRef<any>(null);
189
49
  const [triggerLayout, setTriggerLayout] = useState<{
190
50
  width: number;
191
51
  height: number;
@@ -212,7 +72,6 @@ const SelectDropdownProvider = memo(
212
72
  onClose,
213
73
  onOpen,
214
74
  triggerRef: triggerRef as React.RefObject<View>,
215
- contentRef,
216
75
  triggerLayout,
217
76
  setTriggerLayout,
218
77
  }),
@@ -227,18 +86,20 @@ const SelectDropdownProvider = memo(
227
86
  },
228
87
  );
229
88
 
230
- // Select - wrapper component
231
89
  const Select = typedMemo(
232
- <Option extends DefaultItemT = DefaultItemT>({ children, ...props }: SelectProps<Option>) => {
90
+ <Option extends DefaultItemT = DefaultItemT>({
91
+ children,
92
+ options = emptyArr as Option[],
93
+ ...listProps
94
+ }: SelectProps<Option>) => {
233
95
  return (
234
- <SelectProvider<Option> {...props}>
96
+ <List {...listProps} items={options}>
235
97
  <SelectDropdownProvider>{children}</SelectDropdownProvider>
236
- </SelectProvider>
98
+ </List>
237
99
  );
238
100
  },
239
101
  );
240
102
 
241
- // Select.Trigger - opens the dropdown
242
103
  const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
243
104
  const { onOpen, isOpen, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
244
105
  state => ({
@@ -307,69 +168,69 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
307
168
 
308
169
  SelectTrigger.displayName = 'Select_Trigger';
309
170
 
310
- // Select.Value - displays the value
311
- const SelectValue = memo(({ placeholder, renderValue, style, ...rest }: SelectValueProps) => {
312
- const { value, multiple, labelKey, onRemove, options } = useSelectContextValue(state => ({
313
- value: state.value,
314
- multiple: state.multiple,
315
- labelKey: state.labelKey,
316
- onRemove: state.onRemove,
317
- options: state.options,
318
- }));
171
+ const SelectValue = memo(
172
+ ({ placeholder, labelKey, renderValue, style, ...rest }: SelectValueProps) => {
173
+ const { value, multiple, onRemove, options } = useSelectContextValue(state => ({
174
+ value: state.value,
175
+ multiple: state.multiple,
176
+ onRemove: state.onRemove,
177
+ options: state.items,
178
+ }));
319
179
 
320
- const resolvedValue = useMemo(() => {
321
- const resolve = (item: any) => {
322
- if (item === null || item === undefined) return null;
323
- const id = typeof item === 'object' ? item.id : item;
324
- const found = options.find(o => o.id === id);
325
- return found || item;
326
- };
180
+ const resolvedValue = useMemo(() => {
181
+ const resolve = (item: any) => {
182
+ if (item === null || item === undefined) return null;
183
+ const id = typeof item === 'object' ? item.id : item;
184
+ const found = options.find(o => o.id === id);
185
+ return found || item;
186
+ };
327
187
 
328
- if (multiple) {
329
- return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
330
- }
331
- return resolve(value);
332
- }, [value, multiple, options]);
188
+ if (multiple) {
189
+ return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
190
+ }
191
+ return resolve(value);
192
+ }, [value, multiple, options]);
333
193
 
334
- const displayValue = useMemo(() => {
335
- if (!resolvedValue) return placeholder || '';
336
- if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
194
+ const displayValue = useMemo(() => {
195
+ if (!resolvedValue) return placeholder || '';
196
+ if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
337
197
 
338
- if (renderValue) {
339
- return renderValue(resolvedValue as any);
340
- }
198
+ if (renderValue) {
199
+ return renderValue(resolvedValue as any);
200
+ }
201
+
202
+ if (multiple) {
203
+ const values = resolvedValue as DefaultItemT[];
204
+ // For multi-select, show chips
205
+ return values.map(item => item[labelKey || 'label'] || String(item.id)).join(', ');
206
+ } else {
207
+ const singleValue = resolvedValue as DefaultItemT;
208
+ return singleValue[labelKey || 'label'] || String(singleValue.id || singleValue);
209
+ }
210
+ }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
341
211
 
342
- if (multiple) {
343
- const values = resolvedValue as DefaultItemT[];
344
- // For multi-select, show chips
345
- return values.map(item => item[labelKey || 'label'] || String(item.id)).join(', ');
346
- } else {
347
- const singleValue = resolvedValue as DefaultItemT;
348
- return singleValue[labelKey || 'label'] || String(singleValue.id || singleValue);
212
+ if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
213
+ // Render chips for multi-select
214
+ return (
215
+ <View style={[styles.chipContainer, style]} {...rest}>
216
+ {(resolvedValue as DefaultItemT[]).map(item => (
217
+ <SelectValueItem
218
+ key={item.id || String(item)}
219
+ item={item}
220
+ onRemoveItem={onRemove}
221
+ />
222
+ ))}
223
+ </View>
224
+ );
349
225
  }
350
- }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
351
226
 
352
- if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
353
- // Render chips for multi-select
354
227
  return (
355
- <View style={[styles.chipContainer, style]} {...rest}>
356
- {(resolvedValue as DefaultItemT[]).map(item => (
357
- <SelectValueItem
358
- key={item.id || String(item)}
359
- item={item}
360
- onRemoveItem={onRemove}
361
- />
362
- ))}
363
- </View>
228
+ <Text style={style} {...rest}>
229
+ {displayValue}
230
+ </Text>
364
231
  );
365
- }
366
-
367
- return (
368
- <Text style={style} {...rest}>
369
- {displayValue}
370
- </Text>
371
- );
372
- });
232
+ },
233
+ );
373
234
 
374
235
  const SelectValueItem = typedMemo(
375
236
  ({
@@ -456,28 +317,21 @@ const SelectDropdown = memo(
456
317
  },
457
318
  );
458
319
 
459
- // Keyboard navigation wrapper for web
320
+ // Keyboard navigation wrapper for web. Captures its own DOM ref via a `display: contents`
321
+ // wrapper so the keyboard navigator can query options without needing the dropdown content
322
+ // itself to plumb a contentRef.
460
323
  const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNode }) => {
461
- const { onClose, contentRef, isOpen } = useSelectDropdownContextValue(state => ({
324
+ const { onClose, isOpen } = useSelectDropdownContextValue(state => ({
462
325
  onClose: state.onClose,
463
- contentRef: state.contentRef,
464
326
  isOpen: state.isOpen,
465
327
  }));
328
+ const containerRef = useRef<HTMLDivElement>(null);
466
329
 
467
330
  const handleKeyDown = useCallback(
468
331
  (e: globalThis.KeyboardEvent) => {
469
- if (!contentRef?.current) return;
470
-
471
- // Find all focusable options
472
- // We assume options have role="option" and are descendants of the contentRef
473
- // On React Native Web, refs often point to the host node (div)
474
- const container = contentRef.current as HTMLElement;
475
- if (!container || !container.querySelectorAll) return;
476
-
477
- const options = Array.from(
478
- container.querySelectorAll('[role="option"]:not([disabled])'),
479
- ) as HTMLElement[];
332
+ if (!containerRef.current) return;
480
333
 
334
+ const options = collectWebSelectKeyboardOptionElements(containerRef.current);
481
335
  if (options.length === 0) return;
482
336
 
483
337
  const currentIndex = options.findIndex(el => el === document.activeElement);
@@ -516,161 +370,60 @@ const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNod
516
370
  case 'Escape':
517
371
  e.preventDefault();
518
372
  onClose();
519
- // Return focus to trigger? This should be handled by the caller/Popover usually.
520
373
  break;
521
374
  }
522
375
  },
523
- [contentRef, onClose],
376
+ [onClose],
524
377
  );
525
378
 
526
379
  useEffect(() => {
527
- if (Platform.OS === 'web') {
528
- const controller = new AbortController();
529
- // We attach listener to the window or the container?
530
- // If we attach to container, it needs focus to receive keys.
531
- // Popovers usually trap focus.
532
- // Let's attach to window to be safe, but only when open (which this component implies).
533
- // Actually, best practice is to attach to the container if it captures focus.
534
- // But SelectDropdown usually renders in a Portal.
535
- // Let's attach to window but check if the event target is inside our content.
536
- // Or rely on the fact that if an option is focused, the keydown bubbles up.
537
- // If nothing is focused, where do keys go? Body.
538
- const listener = (e: KeyboardEvent) => {
539
- // Only handle navigation keys when dropdown is open
540
- if (!isOpen) return;
541
-
542
- // For arrow keys, Enter, and Escape, allow navigation regardless of focus location
543
- // This ensures keyboard navigation works even when focus is still on the trigger
544
- const isNavigationKey = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
545
-
546
- if (isNavigationKey) {
547
- handleKeyDown(e);
548
- return;
549
- }
550
-
551
- // For other keys, only handle if focus is within the dropdown
552
- const contentEl = contentRef?.current as HTMLElement | null;
553
- const dropdownContainer = contentEl?.parentElement ?? contentEl;
554
- const targetNode = e.target as Node;
555
-
556
- const isWithinDropdown =
557
- !!dropdownContainer &&
558
- (dropdownContainer === targetNode || dropdownContainer.contains(targetNode));
559
-
560
- if (isWithinDropdown || e.target === document.body) {
561
- handleKeyDown(e);
562
- }
563
- };
380
+ if (Platform.OS !== 'web') return undefined;
564
381
 
565
- window.addEventListener('keydown', listener, {
566
- capture: true,
567
- signal: controller.signal,
568
- });
382
+ const controller = new AbortController();
383
+ const listener = (e: KeyboardEvent) => {
384
+ if (!isOpen) return;
569
385
 
570
- return () => {
571
- controller.abort();
572
- };
573
- }
574
- return undefined;
575
- }, [handleKeyDown, contentRef, isOpen]);
576
-
577
- return <>{children}</>;
578
- });
579
-
580
- SelectDropdown.displayName = 'Select_Dropdown';
581
-
582
- // Select.Content - ScrollView that renders children
583
- const SelectContent = memo(
584
- ({
585
- children,
586
- ContainerComponent = ScrollView,
587
- style,
588
- emptyState,
589
- ...rest
590
- }: SelectContentProps) => {
591
- const { contentRef } = useSelectDropdownContextValue(state => ({
592
- contentRef: state.contentRef,
593
- }));
594
-
595
- const { filteredOptions, value, multiple, searchQuery, options } = useSelectContextValue(
596
- state => ({
597
- filteredOptions: state.filteredOptions,
598
- value: state.value,
599
- multiple: state.multiple,
600
- searchQuery: state.searchQuery,
601
- options: state.options,
602
- }),
603
- );
604
-
605
- const content = useMemo(() => {
606
- return filteredOptions.map(option => {
607
- const isSelected = multiple
608
- ? (value as any[])?.some(v => (v?.id ?? v) === option.id)
609
- : (value as any)?.id === option.id || (value as any) === option.id;
610
-
611
- return children(option, !!isSelected);
612
- });
613
- }, [filteredOptions, value, multiple, children]);
614
-
615
- const defaultEmptyState = useMemo(() => {
616
- const hasSearchQuery = searchQuery && searchQuery.trim().length > 0;
617
- const hasNoOptions = options.length === 0;
618
-
619
- if (hasNoOptions) {
620
- return (
621
- <View style={styles.emptyState}>
622
- <Text style={styles.emptyStateText}>No options available</Text>
623
- </View>
624
- );
386
+ // Navigation keys are handled regardless of focus location so keyboard nav works
387
+ // even while focus is still on the trigger.
388
+ const isNavigationKey = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
389
+ if (isNavigationKey) {
390
+ handleKeyDown(e);
391
+ return;
625
392
  }
626
393
 
627
- if (hasSearchQuery) {
628
- return (
629
- <View style={styles.emptyState}>
630
- <Text style={styles.emptyStateText}>No results found</Text>
631
- </View>
632
- );
394
+ // Other keys: only handle if focus is inside the dropdown.
395
+ const container = containerRef.current;
396
+ const targetNode = e.target as Node;
397
+ const isWithinDropdown =
398
+ !!container && (container === targetNode || container.contains(targetNode));
399
+ if (isWithinDropdown || e.target === document.body) {
400
+ handleKeyDown(e);
633
401
  }
402
+ };
634
403
 
635
- return (
636
- <View style={styles.emptyState}>
637
- <Text style={styles.emptyStateText}>No options</Text>
638
- </View>
639
- );
640
- }, [searchQuery, options.length]);
641
-
642
- return (
643
- <ContainerComponent
644
- ref={contentRef}
645
- style={style}
646
- {...rest}
647
- accessibilityRole="listbox">
648
- {filteredOptions.length === 0 ? emptyState ?? defaultEmptyState : content}
649
- </ContainerComponent>
650
- );
651
- },
652
- );
653
-
654
- SelectContent.displayName = 'Select_Content';
404
+ window.addEventListener('keydown', listener, {
405
+ capture: true,
406
+ signal: controller.signal,
407
+ });
408
+ return () => controller.abort();
409
+ }, [handleKeyDown, isOpen]);
655
410
 
656
- // Select.Group - groups items with label
657
- const SelectGroup = memo(({ children, label, style, ...rest }: SelectGroupProps) => {
658
411
  return (
659
- <View style={style} {...rest}>
660
- {label && <Text style={styles.groupLabel}>{label}</Text>}
412
+ <div ref={containerRef} style={{ display: 'contents' }}>
661
413
  {children}
662
- </View>
414
+ </div>
663
415
  );
664
416
  });
665
417
 
666
- SelectGroup.displayName = 'Select_Group';
418
+ SelectDropdown.displayName = 'Select_Dropdown';
419
+
420
+ const SelectGroup = List.Group;
667
421
 
668
422
  // Select.Item - select item that uses context
669
423
  const SelectOption = memo(
670
424
  <Option extends DefaultItemT = DefaultItemT>({
671
425
  value,
672
426
  children,
673
- renderItem,
674
427
  onPress,
675
428
  style,
676
429
  disabled: optionDisabledProp = false,
@@ -681,29 +434,32 @@ const SelectOption = memo(
681
434
  onAdd,
682
435
  onRemove,
683
436
  disabled: selectDisabled,
437
+ items,
684
438
  } = useSelectContextValue(state => ({
685
439
  multiple: state.multiple,
686
440
  onAdd: state.onAdd,
687
441
  onRemove: state.onRemove,
688
442
  disabled: state.disabled,
443
+ items: state.items,
689
444
  }));
690
445
 
691
446
  const option = useMemo(() => {
447
+ const found = items.find(i => i.id === value);
448
+ if (found) return found as Option;
692
449
  return {
693
450
  id: value,
694
- ...(typeof children === 'string' ? { label: children } : {}),
695
451
  ...(optionDisabledProp ? { selectable: false } : {}),
696
452
  } as Option;
697
- }, [children, optionDisabledProp, value]);
453
+ }, [items, optionDisabledProp, value]);
698
454
 
699
455
  const isSelected = useSelectContextValue(state => {
700
456
  if (multiple) {
701
457
  const values = state.value as any[];
702
458
  return values?.some(v => (v?.id ?? v) === option.id) || false;
703
- } else {
704
- const singleValue = state.value as any;
705
- return (singleValue?.id ?? singleValue) === option.id || false;
706
459
  }
460
+
461
+ const singleValue = state.value as any;
462
+ return (singleValue?.id ?? singleValue) === option.id || false;
707
463
  });
708
464
 
709
465
  const { onClose } = useSelectDropdownContextValue(state => ({
@@ -717,10 +473,7 @@ const SelectOption = memo(
717
473
  const handlePress = useCallback(
718
474
  (event: GestureResponderEvent) => {
719
475
  if (isOptionDisabled) return;
720
-
721
- if (onPress) {
722
- onPress(option, event);
723
- }
476
+ onPress?.(option, event);
724
477
 
725
478
  if (isSelected) {
726
479
  onRemove(option);
@@ -736,164 +489,53 @@ const SelectOption = memo(
736
489
  [isOptionDisabled, option, isSelected, onPress, onAdd, onRemove, multiple, onClose],
737
490
  );
738
491
 
739
- const content = useMemo(() => {
740
- if (typeof children === 'string') {
741
- return <Text style={isOptionDisabled && styles.itemDisabledText}>{children}</Text>;
742
- }
743
-
744
- if (children) return children;
745
-
746
- return (
747
- <Text style={isOptionDisabled && styles.itemDisabledText}>
748
- {option.label || String(option.id)}
749
- </Text>
750
- );
751
- }, [children, option.id, option.label, isOptionDisabled]);
752
-
753
- const accessibilityProps = {
754
- accessibilityRole: 'button' as AccessibilityRole, // Fallback for native
755
- accessibilityState: { selected: isSelected, disabled: isOptionDisabled },
756
- ...Platform.select({
757
- web: {
758
- accessibilityRole: 'option' as AccessibilityRole,
759
- tabIndex: -1 as 0 | -1 | undefined,
760
- // Use a dataset attribute to help the keyboard navigator find this
761
- 'data-option-id': String(option.id),
762
- // Prevent Pressable's native Enter key handling since we handle it in KeyboardNavigationWrapper
763
- // This prevents double-triggering of onPress when Enter is pressed
764
- onKeyDown: (e: React.KeyboardEvent) => {
765
- if (e.key === 'Enter' || e.key === ' ') {
766
- e.preventDefault();
767
- e.stopPropagation();
768
- }
769
- },
770
- },
771
- }),
772
- };
773
-
774
- if (renderItem) {
775
- return (
776
- <Pressable
777
- onPress={handlePress}
778
- disabled={isOptionDisabled}
779
- style={[isOptionDisabled && styles.itemDisabled, style]}
780
- {...accessibilityProps}
781
- {...rest}>
782
- {renderItem(option, isSelected)}
783
- </Pressable>
784
- );
785
- }
786
-
787
492
  return (
788
- <Pressable
789
- onPress={handlePress}
493
+ <List.Item
494
+ {...rest}
495
+ style={style}
496
+ value={value}
497
+ shouldToggleOnPress={false}
498
+ onPress={(_, event) => handlePress(event)}
790
499
  disabled={isOptionDisabled}
791
- style={[
792
- styles.item,
793
- isSelected && styles.itemSelected,
794
- isOptionDisabled && styles.itemDisabled,
795
- style,
796
- ]}
797
- {...accessibilityProps}
798
- {...rest}>
799
- {content}
800
- </Pressable>
500
+ accessibilityState={{ selected: isSelected, disabled: isOptionDisabled }}
501
+ {...(Platform.OS === 'web'
502
+ ? {
503
+ // Force role="option" on web — the keyboard navigator finds rows by
504
+ // [role="option"], so callers must not override these.
505
+ accessibilityRole: 'option' as AccessibilityRole,
506
+ role: 'option',
507
+ tabIndex: -1 as 0 | -1 | undefined,
508
+ 'data-molecules-select-option': '',
509
+ 'data-option-id': String(option.id),
510
+ onKeyDown: (e: React.KeyboardEvent) => {
511
+ if (e.key === 'Enter' || e.key === ' ') {
512
+ e.preventDefault();
513
+ e.stopPropagation();
514
+ }
515
+ },
516
+ }
517
+ : { accessibilityRole: 'button' as AccessibilityRole })}>
518
+ {children}
519
+ </List.Item>
801
520
  );
802
521
  },
803
522
  );
804
523
 
805
524
  SelectOption.displayName = 'Select_Option';
806
525
 
807
- // Select.SearchInput - handles search
808
- const SelectSearchInput = memo(
809
- ({ autoFocus = true, children, ...textInputProps }: SelectSearchInputProps) => {
810
- const { searchQuery, setSearchQuery } = useSelectContextValue(state => ({
811
- searchQuery: state.searchQuery,
812
- setSearchQuery: state.setSearchQuery,
813
- }));
814
- const textInputRef = useRef<TextInputHandles>(null);
815
-
816
- const handleChangeText = useCallback(
817
- (text: string) => {
818
- setSearchQuery(text);
819
- },
820
- [setSearchQuery],
821
- );
822
-
823
- const inputProps = {
824
- ...textInputProps,
825
- value: searchQuery,
826
- onChangeText: handleChangeText,
827
- placeholder: textInputProps.placeholder || 'Search...',
828
- inputStyle: styles.searchInputInput,
829
- } as TextInputProps;
830
-
831
- useEffect(() => {
832
- if (Platform.OS !== 'web') return;
833
- if (!autoFocus || !textInputRef.current) {
834
- return;
835
- }
836
-
837
- const node = textInputRef.current as TextInputHandles & {
838
- focus?: (options?: { preventScroll?: boolean }) => void;
839
- };
840
-
841
- const focusField = () => {
842
- try {
843
- node.focus?.({ preventScroll: true });
844
- } catch {
845
- const { scrollX, scrollY } = window;
846
- node.focus?.();
847
- window.scrollTo(scrollX, scrollY);
848
- }
849
- };
850
-
851
- // Run after popover layout so positioning is stable before focus.
852
- requestAnimationFrame(focusField);
853
- }, [autoFocus]);
854
-
855
- const onPressLeftIcon = useCallback(() => {
856
- textInputRef.current?.focus();
857
- }, []);
858
-
859
- const onClearSearchQuery = useCallback(() => {
860
- handleChangeText('');
861
- }, [handleChangeText]);
862
-
863
- return (
864
- <TextInput
865
- ref={textInputRef}
866
- autoFocus={Platform.OS !== 'web' && autoFocus}
867
- style={styles.searchInput}
868
- size="sm"
869
- variant="outlined"
870
- {...inputProps}>
871
- <TextInput.Left>
872
- <Icon onPress={onPressLeftIcon} name="magnify" size={20} />
873
- </TextInput.Left>
874
- {searchQuery && (
875
- <TextInput.Right>
876
- <IconButton name="close" size={20} onPress={onClearSearchQuery} />
877
- </TextInput.Right>
878
- )}
879
- {children}
880
- </TextInput>
881
- );
882
- },
883
- );
526
+ const SelectSearchInput = List.SearchInput;
884
527
 
885
528
  SelectSearchInput.displayName = 'Select_SearchInput';
886
529
 
887
- // Attach subcomponents
888
530
  const SelectWithSubcomponents = Object.assign(Select, {
889
531
  Trigger: SelectTrigger,
890
532
  Value: SelectValue,
891
533
  Dropdown: SelectDropdown,
892
- Content: SelectContent,
534
+ Content: List.Content,
893
535
  Group: SelectGroup,
894
536
  Option: SelectOption,
895
537
  SearchInput: SelectSearchInput,
896
538
  });
897
539
 
898
540
  export default SelectWithSubcomponents;
899
- export { SelectDropdownProvider, SelectProvider };
541
+ export { SelectDropdownProvider };