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

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 (59) hide show
  1. package/components/Button/Button.tsx +3 -1
  2. package/components/Card/Card.tsx +1 -1
  3. package/components/Checkbox/CheckboxBase.ios.tsx +1 -4
  4. package/components/Checkbox/CheckboxBase.tsx +2 -7
  5. package/components/DatePicker/DateCalendar.tsx +4 -4
  6. package/components/DatePicker/DatePickerModal.tsx +2 -1
  7. package/components/DatePicker/utils.ts +2 -0
  8. package/components/DatePickerInline/DatePickerDockedHeader.tsx +3 -3
  9. package/components/DatePickerInline/DatePickerInline.tsx +1 -1
  10. package/components/DatePickerInline/DatePickerInlineBase.tsx +2 -2
  11. package/components/DatePickerInline/DatePickerInlineHeader.tsx +43 -17
  12. package/components/DatePickerInline/HeaderItem.tsx +2 -2
  13. package/components/DatePickerInline/MonthPicker.tsx +58 -64
  14. package/components/DatePickerInline/Swiper.native.tsx +2 -2
  15. package/components/DatePickerInline/Swiper.tsx +3 -3
  16. package/components/DatePickerInline/YearPicker.tsx +108 -119
  17. package/components/DatePickerInline/{DatePickerContext.tsx → store.tsx} +7 -3
  18. package/components/DatePickerInline/types.ts +1 -1
  19. package/components/Divider/Divider.tsx +192 -0
  20. package/components/Divider/index.tsx +11 -0
  21. package/components/Drawer/DrawerItemGroup.tsx +3 -7
  22. package/components/IconButton/IconButton.tsx +2 -12
  23. package/components/List/List.tsx +275 -0
  24. package/components/List/context.tsx +26 -0
  25. package/components/List/index.ts +8 -0
  26. package/components/List/types.ts +117 -0
  27. package/components/List/utils.ts +79 -0
  28. package/components/Menu/Menu.tsx +146 -19
  29. package/components/Menu/index.tsx +9 -7
  30. package/components/Menu/utils.ts +21 -70
  31. package/components/Popover/Popover.tsx +7 -10
  32. package/components/Popover/PopoverRoot.tsx +6 -20
  33. package/components/Popover/common.ts +4 -0
  34. package/components/Popover/index.ts +2 -8
  35. package/components/Popover/usePlatformMeasure.ts +4 -2
  36. package/components/RadioButton/RadioButtonAndroid.tsx +38 -54
  37. package/components/RadioButton/RadioButtonIOS.tsx +2 -16
  38. package/components/Select/Select.tsx +307 -501
  39. package/components/Select/context.tsx +39 -32
  40. package/components/Select/types.ts +63 -56
  41. package/components/Select/utils.ts +19 -44
  42. package/components/Text/textFactory.tsx +17 -5
  43. package/components/TimePicker/TimeInput.tsx +2 -7
  44. package/components/TimePicker/utils.ts +0 -4
  45. package/components/TouchableRipple/TouchableRipple.native.tsx +36 -5
  46. package/components/TouchableRipple/TouchableRipple.tsx +121 -163
  47. package/components/TouchableRipple/rippleFromForegroundColor.ts +21 -0
  48. package/package.json +6 -3
  49. package/components/HorizontalDivider/HorizontalDivider.tsx +0 -103
  50. package/components/HorizontalDivider/index.tsx +0 -9
  51. package/components/ListItem/ListItem.tsx +0 -138
  52. package/components/ListItem/ListItemDescription.tsx +0 -25
  53. package/components/ListItem/ListItemTitle.tsx +0 -25
  54. package/components/ListItem/index.tsx +0 -14
  55. package/components/ListItem/utils.ts +0 -115
  56. package/components/Menu/MenuDivider.tsx +0 -13
  57. package/components/Menu/MenuItem.tsx +0 -128
  58. package/components/VerticalDivider/VerticalDivider.tsx +0 -100
  59. package/components/VerticalDivider/index.tsx +0 -9
@@ -1,178 +1,84 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  type AccessibilityRole,
4
4
  type GestureResponderEvent,
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, useControlledValue } 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
17
  import { IconButton } from '../IconButton';
18
+ import { List } from '../List';
19
19
  import { Popover } from '../Popover';
20
20
  import { Text } from '../Text';
21
21
  import { TextInput, type TextInputHandles, type TextInputProps } from '../TextInput';
22
22
  import {
23
- SelectContextProvider,
24
23
  SelectDropdownContextProvider,
24
+ SelectSearchContextProvider,
25
25
  useSelectContextValue,
26
26
  useSelectDropdownContextValue,
27
+ useSelectSearchContextValue,
27
28
  } from './context';
28
29
  import type {
29
30
  DefaultItemT,
30
31
  SelectContentProps,
31
- SelectContextValue,
32
32
  SelectDropdownProps,
33
- SelectGroupProps,
34
33
  SelectOptionProps,
35
34
  SelectProps,
35
+ SelectSearchContextValue,
36
36
  SelectSearchInputProps,
37
+ SelectSearchKey,
37
38
  SelectTriggerProps,
38
39
  SelectValueProps,
39
40
  } from './types';
40
- import { styles, triggerStyles } from './utils';
41
+ import { collectWebSelectKeyboardOptionElements, styles, triggerStyles } from './utils';
41
42
 
42
43
  const emptyArr: unknown[] = [];
43
44
 
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
- );
45
+ const getDisplayLabel = (item: DefaultItemT, labelKey?: string) => {
46
+ const itemLabelKey = typeof item.labelKey === 'string' ? item.labelKey : undefined;
47
+ const key = labelKey ?? itemLabelKey ?? 'label';
48
+ const value = item[key];
49
+ return value == null ? String(item.id) : String(value);
50
+ };
136
51
 
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
- );
52
+ const getNested = (item: unknown, path: string): unknown => {
53
+ if (item == null || typeof item !== 'object') return undefined;
54
+ if (!path.includes('.')) return (item as Record<string, unknown>)[path];
55
+ let val: unknown = item;
56
+ for (const part of path.split('.')) {
57
+ if (val == null || typeof val !== 'object') return undefined;
58
+ val = (val as Record<string, unknown>)[part];
59
+ }
60
+ return val;
61
+ };
165
62
 
166
- return (
167
- <SelectContextProvider
168
- value={contextValue as unknown as SelectContextValue<DefaultItemT>}>
169
- {children}
170
- </SelectContextProvider>
171
- );
172
- },
173
- );
63
+ const matchesByKey = (item: unknown, key: string, lowerQuery: string): boolean =>
64
+ String(getNested(item, key) ?? '')
65
+ .toLowerCase()
66
+ .includes(lowerQuery);
67
+
68
+ const applySearch = <T extends object>(
69
+ items: T[],
70
+ searchKey: SelectSearchKey<T> | undefined,
71
+ query: string,
72
+ ): T[] => {
73
+ if (!query) return items;
74
+ if (typeof searchKey === 'function') {
75
+ return items.filter(item => searchKey(item, query));
76
+ }
77
+ const keys = Array.isArray(searchKey) ? searchKey : [searchKey || 'label'];
78
+ const lowerQuery = query.toLowerCase();
79
+ return items.filter(item => keys.some(key => matchesByKey(item, key, lowerQuery)));
80
+ };
174
81
 
175
- // SelectDropdownProvider - manages dropdown state
176
82
  const SelectDropdownProvider = memo(
177
83
  ({
178
84
  children,
@@ -185,7 +91,6 @@ const SelectDropdownProvider = memo(
185
91
  }) => {
186
92
  const { state: isOpen, handleOpen, handleClose } = useToggle(false);
187
93
  const triggerRef = useRef<View>(null);
188
- const contentRef = useRef<any>(null);
189
94
  const [triggerLayout, setTriggerLayout] = useState<{
190
95
  width: number;
191
96
  height: number;
@@ -212,7 +117,6 @@ const SelectDropdownProvider = memo(
212
117
  onClose,
213
118
  onOpen,
214
119
  triggerRef: triggerRef as React.RefObject<View>,
215
- contentRef,
216
120
  triggerLayout,
217
121
  setTriggerLayout,
218
122
  }),
@@ -227,23 +131,98 @@ const SelectDropdownProvider = memo(
227
131
  },
228
132
  );
229
133
 
230
- // Select - wrapper component
231
- const Select = typedMemo(
232
- <Option extends DefaultItemT = DefaultItemT>({ children, ...props }: SelectProps<Option>) => {
134
+ export const SelectRoot = typedMemo(
135
+ <Option extends DefaultItemT = DefaultItemT>({
136
+ children,
137
+ options = emptyArr as Option[],
138
+ searchKey,
139
+ searchQuery: searchQueryProp,
140
+ defaultSearchQuery,
141
+ onSearchChange,
142
+ searchMode = 'client',
143
+ getItemId,
144
+ ...listProps
145
+ }: SelectProps<Option>) => {
146
+ const [searchQuery, setSearchQuery] = useControlledValue<string>({
147
+ value: searchQueryProp,
148
+ defaultValue: defaultSearchQuery ?? '',
149
+ onChange: onSearchChange,
150
+ });
151
+
152
+ const getOptionId = useMemo(
153
+ () => (getItemId ?? ((item: Option) => item.id)) as (item: Option) => string | number,
154
+ [getItemId],
155
+ );
156
+
157
+ const filteredOptions = useMemo(() => {
158
+ if (searchMode === 'external') return options;
159
+ return applySearch(options, searchKey, searchQuery);
160
+ }, [options, searchKey, searchMode, searchQuery]);
161
+
162
+ const optionById = useMemo(() => {
163
+ const map = new Map<string | number, Option>();
164
+ for (const option of options) {
165
+ map.set(getOptionId(option), option);
166
+ }
167
+ return map;
168
+ }, [getOptionId, options]);
169
+
170
+ const searchContextValue = useMemo(
171
+ () =>
172
+ ({
173
+ searchQuery,
174
+ setSearchQuery,
175
+ allOptions: options,
176
+ options: filteredOptions,
177
+ optionById,
178
+ getOptionId,
179
+ } as unknown as SelectSearchContextValue<DefaultItemT>),
180
+ [filteredOptions, getOptionId, optionById, options, searchQuery, setSearchQuery],
181
+ );
182
+
183
+ return (
184
+ <SelectSearchContextProvider value={searchContextValue}>
185
+ <List {...listProps}>
186
+ <SelectDropdownProvider>{children}</SelectDropdownProvider>
187
+ </List>
188
+ </SelectSearchContextProvider>
189
+ );
190
+ },
191
+ );
192
+
193
+ export const SelectContent = typedMemo(
194
+ <Option extends DefaultItemT = DefaultItemT>({
195
+ children,
196
+ ...rest
197
+ }: SelectContentProps<Option>) => {
198
+ const { options, getOptionId } = useSelectSearchContextValue(state => ({
199
+ options: state.options as Option[],
200
+ getOptionId: state.getOptionId as (item: Option) => string | number,
201
+ }));
202
+ const isSelectedId = useSelectContextValue(state => state.isSelectedId);
203
+
204
+ if (typeof children !== 'function') {
205
+ return <List.Content {...rest}>{children}</List.Content>;
206
+ }
207
+
233
208
  return (
234
- <SelectProvider<Option> {...props}>
235
- <SelectDropdownProvider>{children}</SelectDropdownProvider>
236
- </SelectProvider>
209
+ <List.Content {...rest}>
210
+ {options.map(item => (
211
+ <Fragment key={String(getOptionId(item))}>
212
+ {children(item, isSelectedId(getOptionId(item)))}
213
+ </Fragment>
214
+ ))}
215
+ </List.Content>
237
216
  );
238
217
  },
239
218
  );
240
219
 
241
- // Select.Trigger - opens the dropdown
242
- const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
243
- const { onOpen, isOpen, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
220
+ export const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
221
+ const { isOpen, onOpen, onClose, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
244
222
  state => ({
245
- onOpen: state.onOpen,
246
223
  isOpen: state.isOpen,
224
+ onOpen: state.onOpen,
225
+ onClose: state.onClose,
247
226
  triggerRef: state.triggerRef,
248
227
  setTriggerLayout: state.setTriggerLayout,
249
228
  }),
@@ -279,10 +258,13 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
279
258
  );
280
259
 
281
260
  const handlePress = useCallback(() => {
282
- if (!isOpen && !disabled) {
261
+ if (disabled) return;
262
+ if (!isOpen) {
283
263
  onOpen();
264
+ } else {
265
+ onClose();
284
266
  }
285
- }, [isOpen, onOpen, disabled]);
267
+ }, [isOpen, onOpen, onClose, disabled]);
286
268
 
287
269
  return (
288
270
  <Pressable
@@ -307,69 +289,70 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
307
289
 
308
290
  SelectTrigger.displayName = 'Select_Trigger';
309
291
 
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
- }));
292
+ export const SelectValue = memo(
293
+ ({ placeholder, labelKey, renderValue, style, ...rest }: SelectValueProps) => {
294
+ const { value, multiple, onRemove } = useSelectContextValue(state => ({
295
+ value: state.value,
296
+ multiple: state.multiple,
297
+ onRemove: state.onRemove,
298
+ }));
299
+ const { optionById } = useSelectSearchContextValue(state => ({
300
+ optionById: state.optionById,
301
+ }));
319
302
 
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
- };
303
+ const resolvedValue = useMemo(() => {
304
+ const resolve = (id: unknown) => {
305
+ if (id === null || id === undefined) return null;
306
+ const found = optionById.get(id as string | number);
307
+ return found || { id: id as string | number };
308
+ };
327
309
 
328
- if (multiple) {
329
- return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
330
- }
331
- return resolve(value);
332
- }, [value, multiple, options]);
310
+ if (multiple) {
311
+ return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
312
+ }
313
+ return resolve(value);
314
+ }, [optionById, value, multiple]);
333
315
 
334
- const displayValue = useMemo(() => {
335
- if (!resolvedValue) return placeholder || '';
336
- if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
316
+ const displayValue = useMemo(() => {
317
+ if (!resolvedValue) return placeholder || '';
318
+ if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
337
319
 
338
- if (renderValue) {
339
- return renderValue(resolvedValue as any);
340
- }
320
+ if (renderValue) {
321
+ return renderValue(resolvedValue as DefaultItemT | DefaultItemT[] | null);
322
+ }
341
323
 
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);
324
+ if (multiple) {
325
+ const values = resolvedValue as DefaultItemT[];
326
+ // For multi-select, show chips
327
+ return values.map(item => getDisplayLabel(item, labelKey)).join(', ');
328
+ } else {
329
+ const singleValue = resolvedValue as DefaultItemT;
330
+ return getDisplayLabel(singleValue, labelKey);
331
+ }
332
+ }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
333
+
334
+ if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
335
+ // Render chips for multi-select
336
+ return (
337
+ <View style={[styles.chipContainer, style]} {...rest}>
338
+ {(resolvedValue as DefaultItemT[]).map(item => (
339
+ <SelectValueItem
340
+ key={item.id || String(item)}
341
+ item={item}
342
+ onRemoveItem={onRemove}
343
+ />
344
+ ))}
345
+ </View>
346
+ );
349
347
  }
350
- }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
351
348
 
352
- if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
353
- // Render chips for multi-select
354
349
  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>
350
+ <Text style={[styles.valueText, style]} selectable={false} {...rest}>
351
+ {displayValue}
352
+ </Text>
364
353
  );
365
- }
366
-
367
- return (
368
- <Text style={style} {...rest}>
369
- {displayValue}
370
- </Text>
371
- );
372
- });
354
+ },
355
+ );
373
356
 
374
357
  const SelectValueItem = typedMemo(
375
358
  ({
@@ -385,7 +368,7 @@ const SelectValueItem = typedMemo(
385
368
 
386
369
  return (
387
370
  <Chip.Input
388
- label={item[item.labelKey || 'label'] || String(item.id || item)}
371
+ label={getDisplayLabel(item)}
389
372
  size="sm"
390
373
  selected
391
374
  left={<></>}
@@ -398,7 +381,7 @@ const SelectValueItem = typedMemo(
398
381
  SelectValue.displayName = 'Select_Value';
399
382
 
400
383
  // Select.Dropdown - popover with keyboard navigation
401
- const SelectDropdown = memo(
384
+ export const SelectDropdown = memo(
402
385
  ({
403
386
  children,
404
387
  WrapperComponent,
@@ -456,28 +439,21 @@ const SelectDropdown = memo(
456
439
  },
457
440
  );
458
441
 
459
- // Keyboard navigation wrapper for web
442
+ // Keyboard navigation wrapper for web. Captures its own DOM ref via a `display: contents`
443
+ // wrapper so the keyboard navigator can query options without needing the dropdown content
444
+ // itself to plumb a contentRef.
460
445
  const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNode }) => {
461
- const { onClose, contentRef, isOpen } = useSelectDropdownContextValue(state => ({
446
+ const { onClose, isOpen } = useSelectDropdownContextValue(state => ({
462
447
  onClose: state.onClose,
463
- contentRef: state.contentRef,
464
448
  isOpen: state.isOpen,
465
449
  }));
450
+ const containerRef = useRef<HTMLDivElement>(null);
466
451
 
467
452
  const handleKeyDown = useCallback(
468
453
  (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[];
454
+ if (!containerRef.current) return;
480
455
 
456
+ const options = collectWebSelectKeyboardOptionElements(containerRef.current);
481
457
  if (options.length === 0) return;
482
458
 
483
459
  const currentIndex = options.findIndex(el => el === document.activeElement);
@@ -516,161 +492,58 @@ const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNod
516
492
  case 'Escape':
517
493
  e.preventDefault();
518
494
  onClose();
519
- // Return focus to trigger? This should be handled by the caller/Popover usually.
520
495
  break;
521
496
  }
522
497
  },
523
- [contentRef, onClose],
498
+ [onClose],
524
499
  );
525
500
 
526
501
  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
- };
564
-
565
- window.addEventListener('keydown', listener, {
566
- capture: true,
567
- signal: controller.signal,
568
- });
569
-
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';
502
+ if (Platform.OS !== 'web') return undefined;
581
503
 
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
- );
504
+ const controller = new AbortController();
505
+ const listener = (e: KeyboardEvent) => {
506
+ if (!isOpen) return;
604
507
 
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
- );
508
+ // Navigation keys are handled regardless of focus location so keyboard nav works
509
+ // even while focus is still on the trigger.
510
+ const isNavigationKey = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
511
+ if (isNavigationKey) {
512
+ handleKeyDown(e);
513
+ return;
625
514
  }
626
515
 
627
- if (hasSearchQuery) {
628
- return (
629
- <View style={styles.emptyState}>
630
- <Text style={styles.emptyStateText}>No results found</Text>
631
- </View>
632
- );
516
+ // Other keys: only handle if focus is inside the dropdown.
517
+ const container = containerRef.current;
518
+ const targetNode = e.target as Node;
519
+ const isWithinDropdown =
520
+ !!container && (container === targetNode || container.contains(targetNode));
521
+ if (isWithinDropdown || e.target === document.body) {
522
+ handleKeyDown(e);
633
523
  }
524
+ };
634
525
 
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';
526
+ window.addEventListener('keydown', listener, {
527
+ capture: true,
528
+ signal: controller.signal,
529
+ });
530
+ return () => controller.abort();
531
+ }, [handleKeyDown, isOpen]);
655
532
 
656
- // Select.Group - groups items with label
657
- const SelectGroup = memo(({ children, label, style, ...rest }: SelectGroupProps) => {
658
533
  return (
659
- <View style={style} {...rest}>
660
- {label && <Text style={styles.groupLabel}>{label}</Text>}
534
+ <div ref={containerRef} style={{ display: 'contents' }}>
661
535
  {children}
662
- </View>
536
+ </div>
663
537
  );
664
538
  });
665
539
 
666
- SelectGroup.displayName = 'Select_Group';
540
+ SelectDropdown.displayName = 'Select_Dropdown';
667
541
 
668
542
  // Select.Item - select item that uses context
669
- const SelectOption = memo(
543
+ export const SelectOption = memo(
670
544
  <Option extends DefaultItemT = DefaultItemT>({
671
545
  value,
672
546
  children,
673
- renderItem,
674
547
  onPress,
675
548
  style,
676
549
  disabled: optionDisabledProp = false,
@@ -681,30 +554,29 @@ const SelectOption = memo(
681
554
  onAdd,
682
555
  onRemove,
683
556
  disabled: selectDisabled,
557
+ isSelectedId,
684
558
  } = useSelectContextValue(state => ({
685
559
  multiple: state.multiple,
686
560
  onAdd: state.onAdd,
687
561
  onRemove: state.onRemove,
688
562
  disabled: state.disabled,
563
+ isSelectedId: state.isSelectedId,
564
+ }));
565
+ const { allOptions, getOptionId } = useSelectSearchContextValue(state => ({
566
+ allOptions: state.allOptions,
567
+ getOptionId: state.getOptionId,
689
568
  }));
690
569
 
691
570
  const option = useMemo(() => {
571
+ const found = allOptions.find(i => getOptionId(i as Option) === value);
572
+ if (found) return found as Option;
692
573
  return {
693
574
  id: value,
694
- ...(typeof children === 'string' ? { label: children } : {}),
695
575
  ...(optionDisabledProp ? { selectable: false } : {}),
696
576
  } as Option;
697
- }, [children, optionDisabledProp, value]);
577
+ }, [allOptions, getOptionId, optionDisabledProp, value]);
698
578
 
699
- const isSelected = useSelectContextValue(state => {
700
- if (multiple) {
701
- const values = state.value as any[];
702
- 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
- }
707
- });
579
+ const isSelected = isSelectedId(value);
708
580
 
709
581
  const { onClose } = useSelectDropdownContextValue(state => ({
710
582
  onClose: state.onClose,
@@ -717,10 +589,7 @@ const SelectOption = memo(
717
589
  const handlePress = useCallback(
718
590
  (event: GestureResponderEvent) => {
719
591
  if (isOptionDisabled) return;
720
-
721
- if (onPress) {
722
- onPress(option, event);
723
- }
592
+ onPress?.(option, event);
724
593
 
725
594
  if (isSelected) {
726
595
  onRemove(option);
@@ -736,164 +605,101 @@ const SelectOption = memo(
736
605
  [isOptionDisabled, option, isSelected, onPress, onAdd, onRemove, multiple, onClose],
737
606
  );
738
607
 
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
608
  return (
788
- <Pressable
609
+ <List.Item
610
+ {...rest}
611
+ style={style}
612
+ value={value}
613
+ shouldToggleOnPress={false}
789
614
  onPress={handlePress}
790
615
  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>
616
+ accessibilityState={{ selected: isSelected, disabled: isOptionDisabled }}
617
+ {...(Platform.OS === 'web'
618
+ ? {
619
+ // Force role="option" on web — the keyboard navigator finds rows by
620
+ // [role="option"], so callers must not override these.
621
+ accessibilityRole: 'option' as AccessibilityRole,
622
+ role: 'option',
623
+ tabIndex: -1 as 0 | -1 | undefined,
624
+ 'data-molecules-select-option': '',
625
+ 'data-option-id': String(option.id),
626
+ onKeyDown: (e: React.KeyboardEvent) => {
627
+ if (e.key === 'Enter' || e.key === ' ') {
628
+ e.preventDefault();
629
+ e.stopPropagation();
630
+ }
631
+ },
632
+ }
633
+ : { accessibilityRole: 'button' as AccessibilityRole })}>
634
+ {children}
635
+ </List.Item>
801
636
  );
802
637
  },
803
638
  );
804
639
 
805
640
  SelectOption.displayName = 'Select_Option';
806
641
 
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
- }
642
+ export const SelectSearchInput = memo(({ children, ...textInputProps }: SelectSearchInputProps) => {
643
+ const { searchQuery, setSearchQuery } = useSelectSearchContextValue(state => ({
644
+ searchQuery: state.searchQuery,
645
+ setSearchQuery: state.setSearchQuery,
646
+ }));
836
647
 
837
- const node = textInputRef.current as TextInputHandles & {
838
- focus?: (options?: { preventScroll?: boolean }) => void;
839
- };
648
+ const textInputRef = useRef<TextInputHandles>(null);
840
649
 
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
- };
650
+ const handleChangeText = useCallback(
651
+ (text: string) => {
652
+ setSearchQuery(text);
653
+ },
654
+ [setSearchQuery],
655
+ );
850
656
 
851
- // Run after popover layout so positioning is stable before focus.
852
- requestAnimationFrame(focusField);
853
- }, [autoFocus]);
657
+ const inputProps = {
658
+ ...textInputProps,
659
+ value: searchQuery,
660
+ onChangeText: handleChangeText,
661
+ placeholder: textInputProps.placeholder || 'Search...',
662
+ inputStyle: styles.searchInputInput,
663
+ } as TextInputProps;
854
664
 
855
- const onPressLeftIcon = useCallback(() => {
856
- textInputRef.current?.focus();
857
- }, []);
665
+ const onPressLeftIcon = useCallback(() => {
666
+ textInputRef.current?.focus();
667
+ }, []);
858
668
 
859
- const onClearSearchQuery = useCallback(() => {
860
- handleChangeText('');
861
- }, [handleChangeText]);
669
+ const onClearSearchQuery = useCallback(() => {
670
+ handleChangeText('');
671
+ }, [handleChangeText]);
862
672
 
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
- );
673
+ return (
674
+ <TextInput
675
+ ref={textInputRef}
676
+ style={styles.searchInput}
677
+ size="sm"
678
+ variant="outlined"
679
+ {...inputProps}>
680
+ <TextInput.Left>
681
+ <Icon onPress={onPressLeftIcon} name="magnify" size={20} />
682
+ </TextInput.Left>
683
+ {searchQuery ? (
684
+ <TextInput.Right>
685
+ <IconButton name="close" size={20} onPress={onClearSearchQuery} />
686
+ </TextInput.Right>
687
+ ) : null}
688
+ {children}
689
+ </TextInput>
690
+ );
691
+ });
884
692
 
885
693
  SelectSearchInput.displayName = 'Select_SearchInput';
886
694
 
887
- // Attach subcomponents
888
- const SelectWithSubcomponents = Object.assign(Select, {
695
+ const SelectWithSubcomponents = Object.assign(SelectRoot, {
889
696
  Trigger: SelectTrigger,
890
697
  Value: SelectValue,
891
698
  Dropdown: SelectDropdown,
892
699
  Content: SelectContent,
893
- Group: SelectGroup,
894
700
  Option: SelectOption,
895
701
  SearchInput: SelectSearchInput,
896
702
  });
897
703
 
898
704
  export default SelectWithSubcomponents;
899
- export { SelectDropdownProvider, SelectProvider };
705
+ export { SelectDropdownProvider };