react-native-molecules 0.5.0-beta.3 → 0.5.0-beta.30

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 (227) hide show
  1. package/components/Accordion/Accordion.tsx +2 -6
  2. package/components/Accordion/AccordionItem.tsx +16 -12
  3. package/components/Accordion/AccordionItemContent.tsx +6 -1
  4. package/components/Accordion/AccordionItemHeader.tsx +1 -1
  5. package/components/Accordion/utils.ts +6 -0
  6. package/components/ActivityIndicator/ActivityIndicator.tsx +6 -15
  7. package/components/Appbar/AppbarBase.tsx +18 -13
  8. package/components/Button/Button.tsx +211 -264
  9. package/components/Button/index.tsx +9 -3
  10. package/components/Button/types.ts +16 -2
  11. package/components/Button/utils.ts +230 -208
  12. package/components/Card/Card.tsx +1 -1
  13. package/components/Checkbox/Checkbox.tsx +125 -88
  14. package/components/Checkbox/CheckboxBase.ios.tsx +14 -23
  15. package/components/Checkbox/CheckboxBase.tsx +21 -137
  16. package/components/Checkbox/context.tsx +14 -0
  17. package/components/Checkbox/index.tsx +11 -4
  18. package/components/Checkbox/types.ts +63 -29
  19. package/components/Checkbox/utils.ts +25 -108
  20. package/components/Chip/Chip.tsx +41 -52
  21. package/components/Chip/utils.ts +3 -7
  22. package/components/DateField/DateField.tsx +111 -0
  23. package/components/DateField/index.tsx +6 -0
  24. package/components/{DatePickerInput/inputUtils.ts → DateField/useDateFieldState.ts} +19 -51
  25. package/components/DatePicker/DateCalendar.tsx +83 -0
  26. package/components/DatePicker/DatePickerActions.tsx +73 -0
  27. package/components/DatePicker/DatePickerModal.tsx +246 -0
  28. package/components/DatePicker/DatePickerPopover.tsx +79 -0
  29. package/components/DatePicker/DatePickerProvider.tsx +158 -0
  30. package/components/DatePicker/DatePickerTrigger.tsx +23 -0
  31. package/components/DatePicker/context.tsx +83 -0
  32. package/components/DatePicker/index.tsx +45 -0
  33. package/components/DatePicker/utils.ts +295 -0
  34. package/components/DatePickerInline/DatePickerDockedHeader.tsx +117 -0
  35. package/components/DatePickerInline/DatePickerInline.tsx +17 -16
  36. package/components/DatePickerInline/DatePickerInlineBase.tsx +11 -5
  37. package/components/DatePickerInline/DatePickerInlineHeader.tsx +50 -20
  38. package/components/DatePickerInline/Day.tsx +25 -1
  39. package/components/DatePickerInline/DayNames.tsx +13 -10
  40. package/components/DatePickerInline/DayRange.tsx +2 -4
  41. package/components/DatePickerInline/HeaderItem.tsx +44 -29
  42. package/components/DatePickerInline/Month.tsx +48 -67
  43. package/components/DatePickerInline/MonthPicker.tsx +80 -92
  44. package/components/DatePickerInline/Swiper.native.tsx +21 -4
  45. package/components/DatePickerInline/Swiper.tsx +169 -14
  46. package/components/DatePickerInline/SwiperUtils.ts +1 -1
  47. package/components/DatePickerInline/Week.tsx +6 -1
  48. package/components/DatePickerInline/YearPicker.tsx +220 -78
  49. package/components/DatePickerInline/dateUtils.tsx +18 -13
  50. package/components/DatePickerInline/store.tsx +27 -0
  51. package/components/DatePickerInline/types.ts +6 -2
  52. package/components/DatePickerInline/utils.ts +66 -29
  53. package/components/Divider/Divider.tsx +192 -0
  54. package/components/Divider/index.tsx +10 -0
  55. package/components/Drawer/Drawer.tsx +17 -6
  56. package/components/Drawer/DrawerItemGroup.tsx +3 -7
  57. package/components/ElementGroup/ElementGroup.tsx +1 -1
  58. package/components/FilePicker/FilePicker.tsx +48 -78
  59. package/components/FilePicker/index.tsx +2 -1
  60. package/components/FilePicker/utils.ts +9 -0
  61. package/components/HelperText/HelperText.tsx +0 -35
  62. package/components/Icon/iconFactory.tsx +5 -4
  63. package/components/Icon/index.tsx +1 -1
  64. package/components/Icon/types.ts +17 -6
  65. package/components/IconButton/IconButton.tsx +84 -84
  66. package/components/IconButton/index.tsx +1 -0
  67. package/components/IconButton/types.ts +10 -0
  68. package/components/IconButton/utils.ts +167 -33
  69. package/components/List/List.tsx +276 -0
  70. package/components/List/context.tsx +27 -0
  71. package/components/List/index.ts +8 -0
  72. package/components/List/types.ts +117 -0
  73. package/components/List/utils.ts +79 -0
  74. package/components/LoadingIndicator/LoadingIndicator.tsx +253 -0
  75. package/components/LoadingIndicator/LoadingIndicator.web.tsx +136 -0
  76. package/components/LoadingIndicator/index.tsx +13 -0
  77. package/components/LoadingIndicator/utils.ts +117 -0
  78. package/components/Menu/Menu.tsx +162 -39
  79. package/components/Menu/index.tsx +10 -7
  80. package/components/Menu/utils.ts +21 -70
  81. package/components/NavigationRail/NavigationRail.tsx +15 -9
  82. package/components/Popover/Popover.tsx +119 -145
  83. package/components/Popover/PopoverRoot.tsx +60 -0
  84. package/components/Popover/common.ts +54 -34
  85. package/components/Popover/index.ts +12 -1
  86. package/components/Popover/usePlatformMeasure.native.ts +90 -0
  87. package/components/Popover/usePlatformMeasure.ts +120 -0
  88. package/components/Popover/utils.ts +34 -0
  89. package/components/Portal/Portal.tsx +1 -2
  90. package/components/Radio/Radio.tsx +188 -0
  91. package/components/Radio/RadioBase.ios.tsx +69 -0
  92. package/components/Radio/RadioBase.tsx +136 -0
  93. package/components/Radio/context.tsx +23 -0
  94. package/components/Radio/index.tsx +20 -0
  95. package/components/Radio/types.ts +101 -0
  96. package/components/Radio/utils.ts +115 -0
  97. package/components/Rating/Rating.tsx +1 -1
  98. package/components/Select/Select.tsx +521 -785
  99. package/components/Select/context.tsx +81 -0
  100. package/components/Select/index.ts +26 -14
  101. package/components/Select/types.ts +65 -58
  102. package/components/Select/utils.ts +126 -0
  103. package/components/Slot/Slot.tsx +244 -0
  104. package/components/Slot/compose-refs.tsx +62 -0
  105. package/components/Slot/index.tsx +8 -0
  106. package/components/Surface/Surface.android.tsx +32 -7
  107. package/components/Surface/Surface.ios.tsx +34 -29
  108. package/components/Surface/Surface.tsx +31 -4
  109. package/components/Surface/utils.ts +44 -6
  110. package/components/Switch/Switch.ios.tsx +1 -1
  111. package/components/Switch/Switch.tsx +10 -3
  112. package/components/Tabs/TabItem.tsx +35 -58
  113. package/components/Tabs/TabLabel.tsx +5 -9
  114. package/components/Tabs/Tabs.tsx +156 -150
  115. package/components/Tabs/utils.ts +15 -2
  116. package/components/Text/textFactory.tsx +17 -5
  117. package/components/TextInput/TextInput.tsx +663 -579
  118. package/components/TextInput/index.tsx +19 -3
  119. package/components/TextInput/types.ts +77 -28
  120. package/components/TextInput/utils.ts +235 -145
  121. package/components/TimeField/TimeField.tsx +75 -0
  122. package/components/TimeField/index.tsx +6 -0
  123. package/components/TimeField/useTimeFieldState.ts +70 -0
  124. package/components/{TimePickerField/sanitizeTime.ts → TimeField/utils.ts} +77 -10
  125. package/components/TimePicker/AnalogClock.tsx +1 -1
  126. package/components/TimePicker/TimeInput.tsx +87 -42
  127. package/components/TimePicker/TimeInputs.tsx +138 -50
  128. package/components/TimePicker/TimePicker.tsx +74 -11
  129. package/components/TimePicker/TimePickerModal.tsx +186 -0
  130. package/components/TimePicker/context.tsx +17 -0
  131. package/components/TimePicker/index.tsx +15 -3
  132. package/components/TimePicker/utils.ts +93 -4
  133. package/components/Tooltip/Tooltip.tsx +42 -67
  134. package/components/Tooltip/TooltipContent.tsx +32 -5
  135. package/components/Tooltip/TooltipTrigger.tsx +20 -20
  136. package/components/Tooltip/index.tsx +1 -1
  137. package/components/TouchableRipple/TouchableRipple.native.tsx +83 -16
  138. package/components/TouchableRipple/TouchableRipple.tsx +150 -102
  139. package/components/TouchableRipple/rippleFromForegroundColor.ts +21 -0
  140. package/hocs/index.tsx +1 -1
  141. package/hocs/withKeyboardAccessibility.tsx +2 -3
  142. package/hocs/withPortal.tsx +1 -1
  143. package/hooks/index.tsx +2 -12
  144. package/hooks/useActionState.tsx +19 -8
  145. package/hooks/useContrastColor.ts +1 -2
  146. package/hooks/useFilePicker.tsx +7 -17
  147. package/hooks/useHandleNumberFormat.tsx +2 -2
  148. package/hooks/useMediaQuery.tsx +1 -2
  149. package/package.json +95 -111
  150. package/shortcuts-manager/ShortcutsManager/ShortcutsManager.tsx +6 -3
  151. package/shortcuts-manager/ShortcutsManager/utils.tsx +1 -1
  152. package/shortcuts-manager/useSetScopes/useSetScopes.tsx +1 -1
  153. package/shortcuts-manager/useShortcut/useShortcut.tsx +1 -1
  154. package/styles/shadow.ts +2 -1
  155. package/styles/themes/LightTheme.tsx +1 -1
  156. package/utils/DocumentPicker/documentPicker.ts +78 -27
  157. package/utils/DocumentPicker/types.ts +0 -1
  158. package/utils/extractSubcomponents.ts +89 -0
  159. package/utils/extractTextStyles.ts +1 -2
  160. package/utils/formatNumberWithMask/formatNumberWithMask.ts +2 -1
  161. package/utils/index.ts +0 -3
  162. package/utils/normalizeToNumberString/normalizeToNumberString.ts +1 -1
  163. package/components/DatePickerDocked/DatePickerDocked.tsx +0 -30
  164. package/components/DatePickerDocked/DatePickerDockedHeader.tsx +0 -129
  165. package/components/DatePickerDocked/index.tsx +0 -17
  166. package/components/DatePickerDocked/types.ts +0 -11
  167. package/components/DatePickerDocked/utils.ts +0 -157
  168. package/components/DatePickerInline/DatePickerContext.tsx +0 -21
  169. package/components/DatePickerInput/DatePickerInput.tsx +0 -139
  170. package/components/DatePickerInput/DatePickerInputModal.tsx +0 -48
  171. package/components/DatePickerInput/DatePickerInputWithoutModal.tsx +0 -77
  172. package/components/DatePickerInput/DateRangeInput.tsx +0 -88
  173. package/components/DatePickerInput/index.tsx +0 -10
  174. package/components/DatePickerInput/types.ts +0 -28
  175. package/components/DatePickerInput/utils.ts +0 -15
  176. package/components/DatePickerModal/AnimatedCrossView.tsx +0 -94
  177. package/components/DatePickerModal/CalendarEdit.tsx +0 -139
  178. package/components/DatePickerModal/DatePickerModal.tsx +0 -85
  179. package/components/DatePickerModal/DatePickerModalContent.tsx +0 -155
  180. package/components/DatePickerModal/DatePickerModalContentHeader.tsx +0 -213
  181. package/components/DatePickerModal/DatePickerModalHeader.tsx +0 -74
  182. package/components/DatePickerModal/DatePickerModalHeaderBackground.tsx +0 -13
  183. package/components/DatePickerModal/index.tsx +0 -16
  184. package/components/DatePickerModal/types.ts +0 -92
  185. package/components/DatePickerModal/utils.ts +0 -122
  186. package/components/DateTimePicker/DateTimePicker.tsx +0 -172
  187. package/components/DateTimePicker/index.tsx +0 -10
  188. package/components/DateTimePicker/utils.ts +0 -12
  189. package/components/HorizontalDivider/HorizontalDivider.tsx +0 -103
  190. package/components/HorizontalDivider/index.tsx +0 -9
  191. package/components/ListItem/ListItem.tsx +0 -136
  192. package/components/ListItem/ListItemDescription.tsx +0 -25
  193. package/components/ListItem/ListItemTitle.tsx +0 -25
  194. package/components/ListItem/index.tsx +0 -14
  195. package/components/ListItem/utils.ts +0 -115
  196. package/components/Menu/MenuDivider.tsx +0 -13
  197. package/components/Menu/MenuItem.tsx +0 -128
  198. package/components/Popover/Popover.native.tsx +0 -185
  199. package/components/RadioButton/RadioButton.tsx +0 -138
  200. package/components/RadioButton/RadioButtonAndroid.tsx +0 -188
  201. package/components/RadioButton/RadioButtonGroup.tsx +0 -98
  202. package/components/RadioButton/RadioButtonIOS.tsx +0 -106
  203. package/components/RadioButton/RadioButtonItem.tsx +0 -232
  204. package/components/RadioButton/index.ts +0 -22
  205. package/components/RadioButton/utils.ts +0 -165
  206. package/components/TimePickerField/TimePickerField.tsx +0 -152
  207. package/components/TimePickerField/index.tsx +0 -10
  208. package/components/TimePickerField/utils.ts +0 -94
  209. package/components/TimePickerModal/TimePickerModal.tsx +0 -115
  210. package/components/TimePickerModal/index.tsx +0 -10
  211. package/components/TimePickerModal/utils.ts +0 -47
  212. package/components/VerticalDivider/VerticalDivider.tsx +0 -100
  213. package/components/VerticalDivider/index.tsx +0 -9
  214. package/context-bridge/index.tsx +0 -87
  215. package/fast-context/index.tsx +0 -190
  216. package/hocs/typedMemo.tsx +0 -5
  217. package/hooks/useControlledValue.tsx +0 -68
  218. package/hooks/useLatest.tsx +0 -9
  219. package/hooks/useMergedRefs.ts +0 -14
  220. package/hooks/usePrevious.ts +0 -13
  221. package/hooks/useSearchable.tsx +0 -74
  222. package/hooks/useSubcomponents.tsx +0 -59
  223. package/hooks/useToggle.tsx +0 -24
  224. package/utils/color.ts +0 -22
  225. package/utils/compare/index.ts +0 -54
  226. package/utils/lodash.ts +0 -49
  227. package/utils/repository.ts +0 -53
@@ -1,8 +1,9 @@
1
+ import { useControlledValue, useToggle } from '@react-native-molecules/utils/hooks';
1
2
  import {
2
- createContext,
3
+ Fragment,
3
4
  memo,
5
+ type ReactNode,
4
6
  useCallback,
5
- useContext,
6
7
  useEffect,
7
8
  useMemo,
8
9
  useRef,
@@ -14,315 +15,279 @@ import {
14
15
  type LayoutChangeEvent,
15
16
  Platform,
16
17
  Pressable,
17
- ScrollView,
18
18
  View,
19
19
  } from 'react-native';
20
- import { StyleSheet } from 'react-native-unistyles';
21
20
 
22
- import { useActionState, useControlledValue } from '../../hooks';
23
- import { useToggle } from '../../hooks';
21
+ import { typedMemo } from '../../hocs';
24
22
  import { resolveStateVariant } from '../../utils';
23
+ import { extractSubcomponents } from '../../utils/extractSubcomponents';
25
24
  import { Chip } from '../Chip';
26
25
  import { Icon } from '../Icon';
27
26
  import { IconButton } from '../IconButton';
27
+ import { List } from '../List';
28
28
  import { Popover } from '../Popover';
29
- import { registerPortalContext } from '../Portal';
30
29
  import { Text } from '../Text';
31
30
  import { TextInput, type TextInputHandles, type TextInputProps } from '../TextInput';
31
+ import {
32
+ SelectDropdownContextProvider,
33
+ SelectSearchContextProvider,
34
+ useSelectContextValue,
35
+ useSelectDropdownContextValue,
36
+ useSelectDropdownStoreRef,
37
+ useSelectSearchContextValue,
38
+ } from './context';
32
39
  import type {
33
40
  DefaultItemT,
34
41
  SelectContentProps,
35
- SelectContextValue,
36
- SelectDropdownContextValue,
37
42
  SelectDropdownProps,
38
- SelectGroupProps,
39
43
  SelectOptionProps,
40
- SelectProviderProps,
44
+ SelectProps,
45
+ SelectSearchContextValue,
41
46
  SelectSearchInputProps,
47
+ SelectSearchKey,
48
+ SelectTriggerOutlineProps,
42
49
  SelectTriggerProps,
43
50
  SelectValueProps,
44
51
  } from './types';
45
-
46
- // SelectContext - holds value, onAdd, onRemove
47
- export const SelectContext = createContext<SelectContextValue<DefaultItemT>>({
48
- value: null,
49
- multiple: false,
50
- onAdd: () => {},
51
- onRemove: () => {},
52
- disabled: false,
53
- error: false,
54
- labelKey: 'label',
55
- options: [],
56
- searchQuery: '',
57
- setSearchQuery: () => {},
58
- filteredOptions: [],
59
- });
60
-
61
- export const useSelectContext = <Option extends DefaultItemT = DefaultItemT>() => {
62
- return useContext(SelectContext) as unknown as SelectContextValue<Option>;
63
- };
64
-
65
- export const useSelectContextValue = <Option extends DefaultItemT = DefaultItemT, T = any>(
66
- selector: (state: SelectContextValue<Option>) => T,
67
- ): T => {
68
- const context = useContext(SelectContext) as unknown as SelectContextValue<Option>;
69
- return selector(context);
70
- };
71
-
72
- // SelectDropdownContext - holds isOpen, onClose, triggerRef
73
- export type SelectDropdownContextType = SelectDropdownContextValue & {
74
- triggerRef: React.RefObject<View> | null;
75
- contentRef: React.RefObject<any> | null;
76
- triggerLayout: { width: number; height: number } | null;
77
- setTriggerLayout: (layout: { width: number; height: number }) => void;
52
+ import {
53
+ collectWebSelectKeyboardOptionElements,
54
+ selectOutlineStyles,
55
+ styles,
56
+ triggerStyles,
57
+ } from './utils';
58
+
59
+ const emptyArr: unknown[] = [];
60
+
61
+ export const getSelectTriggerState = ({
62
+ isOpen,
63
+ hovered,
64
+ disabled,
65
+ error,
66
+ }: {
67
+ isOpen: boolean;
68
+ hovered: boolean;
69
+ disabled: boolean;
70
+ error: boolean;
71
+ }) =>
72
+ resolveStateVariant({
73
+ focused: isOpen,
74
+ hovered,
75
+ disabled,
76
+ error,
77
+ hoveredAndFocused: hovered && isOpen,
78
+ errorFocused: error && isOpen,
79
+ errorHovered: error && hovered,
80
+ errorFocusedAndHovered: error && isOpen && hovered,
81
+ errorDisabled: error && disabled,
82
+ }) as any;
83
+
84
+ export const getDisplayLabel = (item: DefaultItemT, labelKey?: string) => {
85
+ const itemLabelKey = typeof item.labelKey === 'string' ? item.labelKey : undefined;
86
+ const key = labelKey ?? itemLabelKey ?? 'label';
87
+ const value = item[key];
88
+ return value == null ? String(item.id) : String(value);
78
89
  };
79
90
 
80
- export const SelectDropdownContext = createContext<SelectDropdownContextType>({
81
- isOpen: false,
82
- onClose: () => {},
83
- onOpen: () => {},
84
- triggerRef: null,
85
- contentRef: null,
86
- triggerLayout: null,
87
- setTriggerLayout: () => {},
88
- });
89
-
90
- registerPortalContext([SelectContext, SelectDropdownContext]);
91
-
92
- export const useSelectDropdownContext = () => {
93
- return useContext(SelectDropdownContext);
91
+ export const getNested = (item: unknown, path: string): unknown => {
92
+ if (item == null || typeof item !== 'object') return undefined;
93
+ if (!path.includes('.')) return (item as Record<string, unknown>)[path];
94
+ let val: unknown = item;
95
+ for (const part of path.split('.')) {
96
+ if (val == null || typeof val !== 'object') return undefined;
97
+ val = (val as Record<string, unknown>)[part];
98
+ }
99
+ return val;
94
100
  };
95
101
 
96
- export const useSelectDropdownContextValue = <T,>(
97
- selector: (state: SelectDropdownContextType) => T,
98
- ): T => {
99
- const context = useContext(SelectDropdownContext);
100
- return selector(context);
102
+ export const matchesByKey = (item: unknown, key: string, lowerQuery: string): boolean =>
103
+ String(getNested(item, key) ?? '')
104
+ .toLowerCase()
105
+ .includes(lowerQuery);
106
+
107
+ export const applySearch = <T extends object>(
108
+ items: T[],
109
+ searchKey: SelectSearchKey<T> | undefined,
110
+ query: string,
111
+ ): T[] => {
112
+ if (!query) return items;
113
+ if (typeof searchKey === 'function') {
114
+ return items.filter(item => searchKey(item, query));
115
+ }
116
+ const keys = Array.isArray(searchKey) ? searchKey : [searchKey || 'label'];
117
+ const lowerQuery = query.toLowerCase();
118
+ return items.filter(item => keys.some(key => matchesByKey(item, key, lowerQuery)));
101
119
  };
102
120
 
103
- // SelectProvider - manages controlled/uncontrolled state
104
- const SelectProvider = <Option extends DefaultItemT = DefaultItemT>({
105
- children,
106
- value: valueProp,
107
- defaultValue,
108
- onChange,
109
- multiple = false,
110
- disabled = false,
111
- error = false,
112
- labelKey = 'label',
113
- options = [],
114
- searchKey,
115
- onSearchChange,
116
- hideSelected: hideSelectedProp,
117
- }: SelectProviderProps<Option>) => {
118
- const [value, onValueChange] = useControlledValue<Option['id'] | Option['id'][] | null>({
119
- value: valueProp,
120
- defaultValue: defaultValue ?? (multiple ? [] : null),
121
- onChange: (newValue, item, event) => {
122
- onChange?.(newValue, item as Option, event);
123
- },
124
- });
125
-
126
- const [searchQuery, setSearchQuery] = useState('');
127
-
128
- const handleSearchQueryChange = useCallback(
129
- (query: string) => {
130
- setSearchQuery(query);
131
- onSearchChange?.(query);
132
- },
133
- [onSearchChange],
134
- );
135
-
136
- // Default hideSelected to multiple (true for multi-select, false for single select)
137
- const hideSelected = hideSelectedProp !== undefined ? hideSelectedProp : multiple;
121
+ export const SelectDropdownProvider = memo(
122
+ ({
123
+ children,
124
+ isOpen: isOpenProp,
125
+ onClose: onCloseProp,
126
+ }: {
127
+ children: ReactNode;
128
+ isOpen?: boolean;
129
+ onClose?: () => void;
130
+ }) => {
131
+ const { state: isOpen, handleOpen, handleClose } = useToggle(false);
132
+ const triggerRef = useRef<View>(null);
133
+ const [triggerLayout, setTriggerLayout] = useState<{
134
+ width: number;
135
+ height: number;
136
+ } | null>(null);
137
+ const isControlled = isOpenProp !== undefined;
138
+
139
+ const onClose = useCallback(() => {
140
+ if (isControlled) {
141
+ onCloseProp?.();
142
+ } else {
143
+ handleClose();
144
+ }
145
+ }, [isControlled, onCloseProp, handleClose]);
138
146
 
139
- const filteredOptions = useMemo(() => {
140
- let result = options;
147
+ const onOpen = useCallback(() => {
148
+ if (!isControlled) {
149
+ handleOpen();
150
+ }
151
+ }, [handleOpen, isControlled]);
152
+
153
+ const contextValue = useMemo(
154
+ () => ({
155
+ isOpen: isControlled ? isOpenProp! : isOpen,
156
+ onClose,
157
+ onOpen,
158
+ triggerRef: triggerRef as React.RefObject<View>,
159
+ triggerLayout,
160
+ setTriggerLayout,
161
+ }),
162
+ [isControlled, isOpenProp, isOpen, onClose, onOpen, triggerLayout],
163
+ );
141
164
 
142
- // Filter out selected items if hideSelected is true
143
- if (hideSelected) {
144
- result = result.filter(item => {
145
- if (multiple) {
146
- const values = (value as Option['id'][]) || [];
147
- return !values.some(v => v === item.id);
148
- } else {
149
- const singleValue = value as Option['id'] | null;
150
- return singleValue !== item.id;
151
- }
152
- });
153
- }
165
+ return (
166
+ <SelectDropdownContextProvider value={contextValue}>
167
+ {children}
168
+ </SelectDropdownContextProvider>
169
+ );
170
+ },
171
+ );
154
172
 
155
- // Apply search filter if there's a search query
156
- if (searchQuery) {
157
- const key = searchKey || labelKey || 'label';
158
- const lowerQuery = searchQuery.toLowerCase();
159
- result = result.filter(item => {
160
- const itemValue = item[key];
161
- return String(itemValue || '')
162
- .toLowerCase()
163
- .includes(lowerQuery);
164
- });
165
- }
173
+ export const SelectRoot = typedMemo(
174
+ <Option extends DefaultItemT = DefaultItemT>({
175
+ children,
176
+ options = emptyArr as Option[],
177
+ searchKey,
178
+ searchQuery: searchQueryProp,
179
+ defaultSearchQuery,
180
+ onSearchChange,
181
+ searchMode = 'client',
182
+ getItemId,
183
+ ...listProps
184
+ }: SelectProps<Option>) => {
185
+ const [searchQuery, setSearchQuery] = useControlledValue<string>({
186
+ value: searchQueryProp,
187
+ defaultValue: defaultSearchQuery ?? '',
188
+ onChange: onSearchChange,
189
+ });
166
190
 
167
- return result;
168
- }, [options, searchQuery, searchKey, labelKey, hideSelected, multiple, value]);
191
+ const getOptionId = useMemo(
192
+ () => (getItemId ?? ((item: Option) => item.id)) as (item: Option) => string | number,
193
+ [getItemId],
194
+ );
169
195
 
170
- const onAdd = useCallback(
171
- (item: Option) => {
172
- if (multiple) {
173
- const currentValue = (value as Option['id'][]) || [];
174
- if (!currentValue.find(v => v === item.id)) {
175
- onValueChange([...currentValue, item.id] as Option['id'][], item);
176
- }
177
- } else {
178
- onValueChange(item.id, item);
179
- }
180
- },
181
- [multiple, value, onValueChange],
182
- );
196
+ const filteredOptions = useMemo(() => {
197
+ if (searchMode === 'external') return options;
198
+ return applySearch(options, searchKey, searchQuery);
199
+ }, [options, searchKey, searchMode, searchQuery]);
183
200
 
184
- const onRemove = useCallback(
185
- (item: Option) => {
186
- if (multiple) {
187
- const currentValue = (value as Option['id'][]) || [];
188
- onValueChange(currentValue.filter(v => v !== item.id) as Option['id'][], item);
189
- } else {
190
- onValueChange(null, item);
201
+ const optionById = useMemo(() => {
202
+ const map = new Map<string | number, Option>();
203
+ for (const option of options) {
204
+ map.set(getOptionId(option), option);
191
205
  }
192
- },
193
- [multiple, value, onValueChange],
194
- );
195
-
196
- const contextValue = useMemo(
197
- () => ({
198
- value: value,
199
- multiple,
200
- onAdd: onAdd as (item: DefaultItemT) => void,
201
- onRemove: onRemove as (item: DefaultItemT) => void,
202
- disabled,
203
- error,
204
- labelKey,
205
- options,
206
- searchQuery,
207
- setSearchQuery: handleSearchQueryChange,
208
- filteredOptions,
209
- }),
210
- [
211
- value,
212
- multiple,
213
- onAdd,
214
- onRemove,
215
- disabled,
216
- error,
217
- labelKey,
218
- options,
219
- searchQuery,
220
- handleSearchQueryChange,
221
- filteredOptions,
222
- ],
223
- );
224
-
225
- return (
226
- <SelectContext.Provider value={contextValue as unknown as SelectContextValue<DefaultItemT>}>
227
- {children}
228
- </SelectContext.Provider>
229
- );
230
- };
206
+ return map;
207
+ }, [getOptionId, options]);
208
+
209
+ const searchContextValue = useMemo(
210
+ () =>
211
+ ({
212
+ searchQuery,
213
+ setSearchQuery,
214
+ allOptions: options,
215
+ options: filteredOptions,
216
+ optionById,
217
+ getOptionId,
218
+ } as unknown as SelectSearchContextValue<DefaultItemT>),
219
+ [filteredOptions, getOptionId, optionById, options, searchQuery, setSearchQuery],
220
+ );
231
221
 
232
- // SelectDropdownProvider - manages dropdown state
233
- const SelectDropdownProvider = ({
234
- children,
235
- isOpen: isOpenProp,
236
- onClose: onCloseProp,
237
- }: {
238
- children: React.ReactNode;
239
- isOpen?: boolean;
240
- onClose?: () => void;
241
- }) => {
242
- const { state: isOpen, handleOpen, handleClose } = useToggle(false);
243
- const triggerRef = useRef<View>(null);
244
- const contentRef = useRef<any>(null);
245
- const [triggerLayout, setTriggerLayout] = useState<{ width: number; height: number } | null>(
246
- null,
247
- );
248
- const isControlled = isOpenProp !== undefined;
222
+ return (
223
+ <SelectSearchContextProvider value={searchContextValue}>
224
+ <List {...listProps}>
225
+ <SelectDropdownProvider>{children}</SelectDropdownProvider>
226
+ </List>
227
+ </SelectSearchContextProvider>
228
+ );
229
+ },
230
+ );
249
231
 
250
- const onClose = useCallback(() => {
251
- if (isControlled) {
252
- onCloseProp?.();
253
- } else {
254
- handleClose();
255
- }
256
- }, [isControlled, onCloseProp, handleClose]);
232
+ export const SelectContent = typedMemo(
233
+ <Option extends DefaultItemT = DefaultItemT>({
234
+ children,
235
+ ...rest
236
+ }: SelectContentProps<Option>) => {
237
+ const { options, getOptionId } = useSelectSearchContextValue(state => ({
238
+ options: state.options as Option[],
239
+ getOptionId: state.getOptionId as (item: Option) => string | number,
240
+ }));
241
+ const isSelectedId = useSelectContextValue(state => state.isSelectedId);
257
242
 
258
- const onOpen = useCallback(() => {
259
- if (!isControlled) {
260
- handleOpen();
243
+ if (typeof children !== 'function') {
244
+ return <List.Content {...rest}>{children}</List.Content>;
261
245
  }
262
- }, [handleOpen, isControlled]);
263
-
264
- const contextValue = useMemo(
265
- () => ({
266
- isOpen: isControlled ? isOpenProp! : isOpen,
267
- onClose,
268
- onOpen,
269
- triggerRef: triggerRef as React.RefObject<View>,
270
- contentRef,
271
- triggerLayout,
272
- setTriggerLayout,
273
- }),
274
- [isControlled, isOpenProp, isOpen, onClose, onOpen, triggerLayout],
275
- );
276
-
277
- return (
278
- <SelectDropdownContext.Provider value={contextValue}>
279
- {children}
280
- </SelectDropdownContext.Provider>
281
- );
282
- };
283
246
 
284
- // Select - wrapper component
285
- const Select = <Option extends DefaultItemT = DefaultItemT>({
286
- children,
287
- ...props
288
- }: SelectProviderProps<Option>) => {
289
- return (
290
- <SelectProvider<Option> {...props}>
291
- <SelectDropdownProvider>{children}</SelectDropdownProvider>
292
- </SelectProvider>
293
- );
294
- };
247
+ return (
248
+ <List.Content {...rest}>
249
+ {options.map(item => (
250
+ <Fragment key={String(getOptionId(item))}>
251
+ {children(item, isSelectedId(getOptionId(item)))}
252
+ </Fragment>
253
+ ))}
254
+ </List.Content>
255
+ );
256
+ },
257
+ );
295
258
 
296
- // Select.Trigger - opens the dropdown
297
- const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
298
- const { onOpen, isOpen, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
259
+ export const SelectTrigger = memo(({ children, style, ...rest }: SelectTriggerProps) => {
260
+ const { isOpen, onOpen, onClose, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
299
261
  state => ({
300
- onOpen: state.onOpen,
301
262
  isOpen: state.isOpen,
263
+ onOpen: state.onOpen,
264
+ onClose: state.onClose,
302
265
  triggerRef: state.triggerRef,
303
266
  setTriggerLayout: state.setTriggerLayout,
304
267
  }),
305
268
  );
269
+ const setSelectDropdownContext = useSelectDropdownStoreRef().set;
306
270
 
307
271
  const { disabled, error } = useSelectContextValue(state => ({
308
272
  disabled: state.disabled,
309
273
  error: state.error,
310
274
  }));
311
275
 
312
- const { hovered } = useActionState({ ref: triggerRef, actionsToListen: ['hover'] });
276
+ const [hovered, setHovered] = useState(false);
277
+
278
+ const { Select_TriggerOutline, rest: restChildren } = extractSubcomponents({
279
+ children,
280
+ allowedChildren: [{ name: 'Select_TriggerOutline', allowMultiple: false }] as const,
281
+ includeRest: true,
282
+ });
313
283
 
314
284
  triggerStyles.useVariants({
315
- state: resolveStateVariant({
316
- focused: isOpen,
285
+ state: getSelectTriggerState({
286
+ isOpen,
317
287
  hovered,
318
288
  disabled: !!disabled,
319
289
  error: !!error,
320
- hoveredAndFocused: hovered && isOpen,
321
- errorFocused: !!error && isOpen,
322
- errorHovered: !!error && hovered,
323
- errorFocusedAndHovered: !!error && isOpen && hovered,
324
- errorDisabled: !!error && !!disabled,
325
- }) as any,
290
+ }),
326
291
  });
327
292
 
328
293
  const handleLayout = useCallback(
@@ -334,180 +299,240 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
334
299
  );
335
300
 
336
301
  const handlePress = useCallback(() => {
337
- if (!isOpen && !disabled) {
302
+ if (disabled) return;
303
+ if (!isOpen) {
338
304
  onOpen();
305
+ } else {
306
+ onClose();
339
307
  }
340
- }, [isOpen, onOpen, disabled]);
308
+ }, [isOpen, onOpen, onClose, disabled]);
309
+
310
+ const handleHoverIn = useCallback(() => {
311
+ setHovered(true);
312
+ setSelectDropdownContext(() => ({ triggerHovered: true }));
313
+ }, [setSelectDropdownContext]);
314
+
315
+ const handleHoverOut = useCallback(() => {
316
+ setHovered(false);
317
+ setSelectDropdownContext(() => ({ triggerHovered: false }));
318
+ }, [setSelectDropdownContext]);
319
+
320
+ const outlineElement =
321
+ Select_TriggerOutline.length > 0 ? Select_TriggerOutline : <SelectTriggerOutline />;
341
322
 
342
323
  return (
343
324
  <Pressable
344
325
  ref={triggerRef}
345
326
  onPress={handlePress}
346
327
  onLayout={handleLayout}
328
+ onHoverIn={handleHoverIn}
329
+ onHoverOut={handleHoverOut}
347
330
  style={[triggerStyles.trigger, style]}
348
331
  accessibilityRole="combobox"
349
332
  accessibilityState={{ expanded: isOpen, disabled: !!disabled }}
350
333
  disabled={disabled}
351
334
  {...rest}>
352
- {children}
335
+ {restChildren}
353
336
  <Icon
354
337
  name={isOpen ? 'chevron-up' : 'chevron-down'}
355
338
  size={20}
356
339
  style={triggerStyles.triggerIcon}
357
340
  />
358
- <View style={triggerStyles.outline} />
341
+ {outlineElement}
359
342
  </Pressable>
360
343
  );
361
- };
344
+ });
362
345
 
363
346
  SelectTrigger.displayName = 'Select_Trigger';
364
347
 
365
- // Select.Value - displays the value
366
- const SelectValue = ({ placeholder, renderValue, style, ...rest }: SelectValueProps) => {
367
- const { value, multiple, labelKey, onRemove, options } = useSelectContextValue(state => ({
368
- value: state.value,
369
- multiple: state.multiple,
370
- labelKey: state.labelKey,
371
- onRemove: state.onRemove,
372
- options: state.options,
348
+ export const SelectTriggerOutline = memo(({ style }: SelectTriggerOutlineProps) => {
349
+ const { isOpen, triggerHovered } = useSelectDropdownContextValue(state => ({
350
+ isOpen: state.isOpen,
351
+ triggerHovered: state.triggerHovered,
352
+ }));
353
+ const { disabled, error } = useSelectContextValue(state => ({
354
+ disabled: state.disabled,
355
+ error: state.error,
373
356
  }));
357
+ selectOutlineStyles.useVariants({
358
+ state: getSelectTriggerState({
359
+ isOpen,
360
+ hovered: !!triggerHovered,
361
+ disabled: !!disabled,
362
+ error: !!error,
363
+ }),
364
+ });
374
365
 
375
- const resolvedValue = useMemo(() => {
376
- const resolve = (item: any) => {
377
- if (item === null || item === undefined) return null;
378
- const id = typeof item === 'object' ? item.id : item;
379
- const found = options.find(o => o.id === id);
380
- return found || item;
381
- };
366
+ return <View pointerEvents="none" style={[selectOutlineStyles.outline, style]} />;
367
+ });
382
368
 
383
- if (multiple) {
384
- return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
385
- }
386
- return resolve(value);
387
- }, [value, multiple, options]);
369
+ SelectTriggerOutline.displayName = 'Select_TriggerOutline';
388
370
 
389
- const displayValue = useMemo(() => {
390
- if (!resolvedValue) return placeholder || '';
391
- if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
371
+ export const SelectValue = memo(
372
+ ({ placeholder, labelKey, renderValue, style, ...rest }: SelectValueProps) => {
373
+ const { value, multiple, onRemove } = useSelectContextValue(state => ({
374
+ value: state.value,
375
+ multiple: state.multiple,
376
+ onRemove: state.onRemove,
377
+ }));
378
+ const { optionById } = useSelectSearchContextValue(state => ({
379
+ optionById: state.optionById,
380
+ }));
392
381
 
393
- if (renderValue) {
394
- return renderValue(resolvedValue as any);
395
- }
382
+ const resolvedValue = useMemo(() => {
383
+ const resolve = (id: unknown) => {
384
+ if (id === null || id === undefined) return null;
385
+ const found = optionById.get(id as string | number);
386
+ return found || { id: id as string | number };
387
+ };
396
388
 
397
- if (multiple) {
398
- const values = resolvedValue as DefaultItemT[];
399
- // For multi-select, show chips
400
- return values.map(item => item[labelKey || 'label'] || String(item.id)).join(', ');
401
- } else {
402
- const singleValue = resolvedValue as DefaultItemT;
403
- return singleValue[labelKey || 'label'] || String(singleValue.id || singleValue);
389
+ if (multiple) {
390
+ return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
391
+ }
392
+ return resolve(value);
393
+ }, [optionById, value, multiple]);
394
+
395
+ const displayValue = useMemo(() => {
396
+ if (!resolvedValue) return placeholder || '';
397
+ if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
398
+
399
+ if (renderValue) {
400
+ return renderValue(resolvedValue as DefaultItemT | DefaultItemT[] | null);
401
+ }
402
+
403
+ if (multiple) {
404
+ const values = resolvedValue as DefaultItemT[];
405
+ // For multi-select, show chips
406
+ return values.map(item => getDisplayLabel(item, labelKey)).join(', ');
407
+ } else {
408
+ const singleValue = resolvedValue as DefaultItemT;
409
+ return getDisplayLabel(singleValue, labelKey);
410
+ }
411
+ }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
412
+
413
+ if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
414
+ // Render chips for multi-select
415
+ return (
416
+ <View style={[styles.chipContainer, style]} {...rest}>
417
+ {(resolvedValue as DefaultItemT[]).map(item => (
418
+ <SelectValueItem
419
+ key={item.id || String(item)}
420
+ item={item}
421
+ onRemoveItem={onRemove}
422
+ />
423
+ ))}
424
+ </View>
425
+ );
404
426
  }
405
- }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
406
427
 
407
- if (multiple && Array.isArray(resolvedValue) && resolvedValue.length > 0) {
408
- // Render chips for multi-select
409
428
  return (
410
- <View style={[styles.chipContainer, style]} {...rest}>
411
- {(resolvedValue as DefaultItemT[]).map(item => (
412
- <Chip.Input
413
- key={item.id || String(item)}
414
- label={item[labelKey || 'label'] || String(item.id || item)}
415
- size="sm"
416
- selected
417
- left={<></>}
418
- onClose={() => onRemove(item)}
419
- />
420
- ))}
421
- </View>
429
+ <Text style={[styles.valueText, style]} selectable={false} {...rest}>
430
+ {displayValue}
431
+ </Text>
422
432
  );
423
- }
433
+ },
434
+ );
424
435
 
425
- return (
426
- <Text style={style} {...rest}>
427
- {displayValue}
428
- </Text>
429
- );
430
- };
436
+ const SelectValueItem = typedMemo(
437
+ ({
438
+ item,
439
+ onRemoveItem,
440
+ }: {
441
+ item: DefaultItemT;
442
+ onRemoveItem: (item: DefaultItemT) => void;
443
+ }) => {
444
+ const onRemove = useCallback(() => {
445
+ onRemoveItem(item);
446
+ }, [item, onRemoveItem]);
447
+
448
+ return (
449
+ <Chip.Input
450
+ label={getDisplayLabel(item)}
451
+ size="sm"
452
+ selected
453
+ left={<></>}
454
+ onClose={onRemove}
455
+ />
456
+ );
457
+ },
458
+ );
431
459
 
432
460
  SelectValue.displayName = 'Select_Value';
433
461
 
434
462
  // Select.Dropdown - popover with keyboard navigation
435
- const SelectDropdown = ({
436
- children,
437
- WrapperComponent,
438
- wrapperComponentProps,
439
- enableKeyboardNavigation = true,
440
- style: popoverStyleProp,
441
- ...popoverProps
442
- }: SelectDropdownProps & { enableKeyboardNavigation?: boolean }) => {
443
- const { isOpen, onClose, triggerRef, triggerLayout } = useSelectDropdownContextValue(state => ({
444
- isOpen: state.isOpen,
445
- onClose: state.onClose,
446
- triggerRef: state.triggerRef,
447
- triggerLayout: state.triggerLayout,
448
- }));
463
+ export const SelectDropdown = memo(
464
+ ({
465
+ children,
466
+ WrapperComponent,
467
+ wrapperComponentProps,
468
+ enableKeyboardNavigation = true,
469
+ style: popoverStyleProp,
470
+ ...popoverProps
471
+ }: SelectDropdownProps & { enableKeyboardNavigation?: boolean }) => {
472
+ const { isOpen, onClose, triggerRef, triggerLayout } = useSelectDropdownContextValue(
473
+ state => ({
474
+ isOpen: state.isOpen,
475
+ onClose: state.onClose,
476
+ triggerRef: state.triggerRef,
477
+ triggerLayout: state.triggerLayout,
478
+ }),
479
+ );
449
480
 
450
- const popoverStyle = useMemo(() => {
451
- const baseStyle = popoverStyleProp ? [popoverStyleProp] : [];
452
- if (triggerLayout) {
453
- return [{ width: triggerLayout.width }, ...baseStyle];
454
- }
455
- return baseStyle;
456
- }, [triggerLayout, popoverStyleProp]);
481
+ const popoverStyle = useMemo(() => {
482
+ const baseStyle = popoverStyleProp ? [popoverStyleProp] : [];
483
+ if (triggerLayout) {
484
+ return [{ width: triggerLayout.width }, ...baseStyle];
485
+ }
486
+ return baseStyle;
487
+ }, [triggerLayout, popoverStyleProp]);
488
+
489
+ if (!triggerLayout) return null;
457
490
 
458
- if (!triggerLayout) return null;
491
+ if (WrapperComponent) {
492
+ return (
493
+ <WrapperComponent isOpen={isOpen} onClose={onClose} {...wrapperComponentProps}>
494
+ {enableKeyboardNavigation && Platform.OS === 'web' ? (
495
+ <KeyboardNavigationWrapper>{children}</KeyboardNavigationWrapper>
496
+ ) : (
497
+ children
498
+ )}
499
+ </WrapperComponent>
500
+ );
501
+ }
459
502
 
460
- if (WrapperComponent) {
461
503
  return (
462
- <WrapperComponent isOpen={isOpen} onClose={onClose} {...wrapperComponentProps}>
504
+ <Popover
505
+ triggerRef={triggerRef as React.RefObject<View>}
506
+ isOpen={isOpen}
507
+ onClose={onClose}
508
+ style={popoverStyle}
509
+ triggerDimensions={triggerLayout}
510
+ {...popoverProps}>
463
511
  {enableKeyboardNavigation && Platform.OS === 'web' ? (
464
512
  <KeyboardNavigationWrapper>{children}</KeyboardNavigationWrapper>
465
513
  ) : (
466
514
  children
467
515
  )}
468
- </WrapperComponent>
516
+ </Popover>
469
517
  );
470
- }
471
-
472
- return (
473
- <Popover
474
- triggerRef={triggerRef as React.RefObject<View>}
475
- isOpen={isOpen}
476
- onClose={onClose}
477
- style={popoverStyle}
478
- triggerDimensions={triggerLayout}
479
- {...popoverProps}>
480
- {enableKeyboardNavigation && Platform.OS === 'web' ? (
481
- <KeyboardNavigationWrapper>{children}</KeyboardNavigationWrapper>
482
- ) : (
483
- children
484
- )}
485
- </Popover>
486
- );
487
- };
518
+ },
519
+ );
488
520
 
489
- // Keyboard navigation wrapper for web
490
- const KeyboardNavigationWrapper = ({ children }: { children: React.ReactNode }) => {
491
- const { onClose, contentRef, isOpen } = useSelectDropdownContextValue(state => ({
521
+ // Keyboard navigation wrapper for web. Captures its own DOM ref via a `display: contents`
522
+ // wrapper so the keyboard navigator can query options without needing the dropdown content
523
+ // itself to plumb a contentRef.
524
+ const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNode }) => {
525
+ const { onClose, isOpen } = useSelectDropdownContextValue(state => ({
492
526
  onClose: state.onClose,
493
- contentRef: state.contentRef,
494
527
  isOpen: state.isOpen,
495
528
  }));
529
+ const containerRef = useRef<HTMLDivElement>(null);
496
530
 
497
531
  const handleKeyDown = useCallback(
498
532
  (e: globalThis.KeyboardEvent) => {
499
- if (!contentRef?.current) return;
500
-
501
- // Find all focusable options
502
- // We assume options have role="option" and are descendants of the contentRef
503
- // On React Native Web, refs often point to the host node (div)
504
- const container = contentRef.current as HTMLElement;
505
- if (!container || !container.querySelectorAll) return;
506
-
507
- const options = Array.from(
508
- container.querySelectorAll('[role="option"]:not([disabled])'),
509
- ) as HTMLElement[];
533
+ if (!containerRef.current) return;
510
534
 
535
+ const options = collectWebSelectKeyboardOptionElements(containerRef.current);
511
536
  if (options.length === 0) return;
512
537
 
513
538
  const currentIndex = options.findIndex(el => el === document.activeElement);
@@ -533,202 +558,108 @@ const KeyboardNavigationWrapper = ({ children }: { children: React.ReactNode })
533
558
  break;
534
559
  case 'Enter':
535
560
  e.preventDefault();
561
+ e.stopImmediatePropagation();
536
562
  if (currentIndex !== -1) {
537
- options[currentIndex]?.click();
563
+ // Store reference to the focused element before triggering click
564
+ // to prevent issues with DOM updates during the click handler
565
+ const focusedOption = options[currentIndex];
566
+ if (focusedOption) {
567
+ focusedOption.click();
568
+ }
538
569
  }
539
570
  break;
540
571
  case 'Escape':
541
572
  e.preventDefault();
542
573
  onClose();
543
- // Return focus to trigger? This should be handled by the caller/Popover usually.
544
574
  break;
545
575
  }
546
576
  },
547
- [contentRef, onClose],
577
+ [onClose],
548
578
  );
549
579
 
550
580
  useEffect(() => {
551
- if (Platform.OS === 'web') {
552
- const controller = new AbortController();
553
- // We attach listener to the window or the container?
554
- // If we attach to container, it needs focus to receive keys.
555
- // Popovers usually trap focus.
556
- // Let's attach to window to be safe, but only when open (which this component implies).
557
- // Actually, best practice is to attach to the container if it captures focus.
558
- // But SelectDropdown usually renders in a Portal.
559
- // Let's attach to window but check if the event target is inside our content.
560
- // Or rely on the fact that if an option is focused, the keydown bubbles up.
561
- // If nothing is focused, where do keys go? Body.
562
- const listener = (e: KeyboardEvent) => {
563
- // Only handle navigation keys when dropdown is open
564
- if (!isOpen) return;
565
-
566
- // For arrow keys, Enter, and Escape, allow navigation regardless of focus location
567
- // This ensures keyboard navigation works even when focus is still on the trigger
568
- const isNavigationKey = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
569
-
570
- if (isNavigationKey) {
571
- handleKeyDown(e);
572
- return;
573
- }
574
-
575
- // For other keys, only handle if focus is within the dropdown
576
- const contentEl = contentRef?.current as HTMLElement | null;
577
- const dropdownContainer = contentEl?.parentElement ?? contentEl;
578
- const targetNode = e.target as Node;
579
-
580
- const isWithinDropdown =
581
- !!dropdownContainer &&
582
- (dropdownContainer === targetNode || dropdownContainer.contains(targetNode));
583
-
584
- if (isWithinDropdown || e.target === document.body) {
585
- handleKeyDown(e);
586
- }
587
- };
588
-
589
- window.addEventListener('keydown', listener, {
590
- capture: true,
591
- signal: controller.signal,
592
- });
593
-
594
- return () => {
595
- controller.abort();
596
- };
597
- }
598
- return undefined;
599
- }, [handleKeyDown, contentRef, isOpen]);
581
+ if (Platform.OS !== 'web') return undefined;
600
582
 
601
- return <>{children}</>;
602
- };
603
-
604
- SelectDropdown.displayName = 'Select_Dropdown';
583
+ const controller = new AbortController();
584
+ const listener = (e: KeyboardEvent) => {
585
+ if (!isOpen) return;
605
586
 
606
- // Select.Content - ScrollView that renders children
607
- const SelectContent = ({
608
- children,
609
- ContainerComponent = ScrollView,
610
- style,
611
- emptyState,
612
- ...rest
613
- }: SelectContentProps) => {
614
- const { contentRef } = useSelectDropdownContextValue(state => ({
615
- contentRef: state.contentRef,
616
- }));
617
-
618
- const { filteredOptions, value, multiple, searchQuery, options } = useSelectContextValue(
619
- state => ({
620
- filteredOptions: state.filteredOptions,
621
- value: state.value,
622
- multiple: state.multiple,
623
- searchQuery: state.searchQuery,
624
- options: state.options,
625
- }),
626
- );
587
+ // Navigation keys are handled regardless of focus location so keyboard nav works
588
+ // even while focus is still on the trigger.
589
+ const isNavigationKey = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
590
+ if (isNavigationKey) {
591
+ handleKeyDown(e);
592
+ return;
593
+ }
627
594
 
628
- const content = useMemo(() => {
629
- return filteredOptions.map(option => {
630
- const isSelected = multiple
631
- ? (value as any[])?.some(v => (v?.id ?? v) === option.id)
632
- : (value as any)?.id === option.id || (value as any) === option.id;
595
+ // Other keys: only handle if focus is inside the dropdown.
596
+ const container = containerRef.current;
597
+ const targetNode = e.target as Node;
598
+ const isWithinDropdown =
599
+ !!container && (container === targetNode || container.contains(targetNode));
600
+ if (isWithinDropdown || e.target === document.body) {
601
+ handleKeyDown(e);
602
+ }
603
+ };
633
604
 
634
- return children(option, !!isSelected);
605
+ window.addEventListener('keydown', listener, {
606
+ capture: true,
607
+ signal: controller.signal,
635
608
  });
636
- }, [filteredOptions, value, multiple, children]);
637
-
638
- const defaultEmptyState = useMemo(() => {
639
- const hasSearchQuery = searchQuery && searchQuery.trim().length > 0;
640
- const hasNoOptions = options.length === 0;
641
-
642
- if (hasNoOptions) {
643
- return (
644
- <View style={styles.emptyState}>
645
- <Text style={styles.emptyStateText}>No options available</Text>
646
- </View>
647
- );
648
- }
649
-
650
- if (hasSearchQuery) {
651
- return (
652
- <View style={styles.emptyState}>
653
- <Text style={styles.emptyStateText}>No results found</Text>
654
- </View>
655
- );
656
- }
657
-
658
- return (
659
- <View style={styles.emptyState}>
660
- <Text style={styles.emptyStateText}>No options</Text>
661
- </View>
662
- );
663
- }, [searchQuery, options.length]);
609
+ return () => controller.abort();
610
+ }, [handleKeyDown, isOpen]);
664
611
 
665
612
  return (
666
- <ContainerComponent ref={contentRef} style={style} {...rest} accessibilityRole="listbox">
667
- {filteredOptions.length === 0 ? emptyState ?? defaultEmptyState : content}
668
- </ContainerComponent>
669
- );
670
- };
671
-
672
- SelectContent.displayName = 'Select_Content';
673
-
674
- // Select.Group - groups items with label
675
- const SelectGroup = memo(({ children, label, style, ...rest }: SelectGroupProps) => {
676
- return (
677
- <View style={style} {...rest}>
678
- {label && <Text style={styles.groupLabel}>{label}</Text>}
613
+ <div ref={containerRef} style={{ display: 'contents' }}>
679
614
  {children}
680
- </View>
615
+ </div>
681
616
  );
682
617
  });
683
618
 
684
- SelectGroup.displayName = 'Select_Group';
619
+ SelectDropdown.displayName = 'Select_Dropdown';
685
620
 
686
621
  // Select.Item - select item that uses context
687
- const SelectOption = memo(
622
+ export const SelectOption = memo(
688
623
  <Option extends DefaultItemT = DefaultItemT>({
689
624
  value,
690
625
  children,
691
- renderItem,
692
626
  onPress,
693
627
  style,
694
628
  disabled: optionDisabledProp = false,
695
629
  ...rest
696
630
  }: SelectOptionProps<Option>) => {
697
631
  const {
698
- value: selectionValue,
699
632
  multiple,
700
633
  onAdd,
701
634
  onRemove,
702
635
  disabled: selectDisabled,
703
- } = useSelectContextValue<Option>(state => ({
704
- value: state.value,
636
+ isSelectedId,
637
+ } = useSelectContextValue(state => ({
705
638
  multiple: state.multiple,
706
639
  onAdd: state.onAdd,
707
640
  onRemove: state.onRemove,
708
641
  disabled: state.disabled,
642
+ isSelectedId: state.isSelectedId,
709
643
  }));
710
-
711
- const { onClose } = useSelectDropdownContextValue(state => ({
712
- onClose: state.onClose,
644
+ const { allOptions, getOptionId } = useSelectSearchContextValue(state => ({
645
+ allOptions: state.allOptions,
646
+ getOptionId: state.getOptionId,
713
647
  }));
714
648
 
715
649
  const option = useMemo(() => {
650
+ const found = allOptions.find(i => getOptionId(i as Option) === value);
651
+ if (found) return found as Option;
716
652
  return {
717
653
  id: value,
718
- ...(typeof children === 'string' ? { label: children } : {}),
719
654
  ...(optionDisabledProp ? { selectable: false } : {}),
720
655
  } as Option;
721
- }, [children, optionDisabledProp, value]);
656
+ }, [allOptions, getOptionId, optionDisabledProp, value]);
722
657
 
723
- const isSelected = useMemo(() => {
724
- if (multiple) {
725
- const values = selectionValue as any[];
726
- return values?.some(v => (v?.id ?? v) === option.id) || false;
727
- } else {
728
- const singleValue = selectionValue as any;
729
- return (singleValue?.id ?? singleValue) === option.id || false;
730
- }
731
- }, [selectionValue, multiple, option.id]);
658
+ const isSelected = isSelectedId(value);
659
+
660
+ const { onClose } = useSelectDropdownContextValue(state => ({
661
+ onClose: state.onClose,
662
+ }));
732
663
 
733
664
  const isOptionDisabled = Boolean(
734
665
  selectDisabled || optionDisabledProp || option.selectable === false,
@@ -737,10 +668,7 @@ const SelectOption = memo(
737
668
  const handlePress = useCallback(
738
669
  (event: GestureResponderEvent) => {
739
670
  if (isOptionDisabled) return;
740
-
741
- if (onPress) {
742
- onPress(option, event);
743
- }
671
+ onPress?.(option, event);
744
672
 
745
673
  if (isSelected) {
746
674
  onRemove(option);
@@ -756,283 +684,91 @@ const SelectOption = memo(
756
684
  [isOptionDisabled, option, isSelected, onPress, onAdd, onRemove, multiple, onClose],
757
685
  );
758
686
 
759
- const content = useMemo(() => {
760
- if (typeof children === 'string') {
761
- return <Text style={isOptionDisabled && styles.itemDisabledText}>{children}</Text>;
762
- }
763
-
764
- if (children) return children;
765
-
766
- return (
767
- <Text style={isOptionDisabled && styles.itemDisabledText}>
768
- {option.label || String(option.id)}
769
- </Text>
770
- );
771
- }, [children, option.id, option.label, isOptionDisabled]);
772
-
773
- const accessibilityProps = {
774
- accessibilityRole: 'button' as AccessibilityRole, // Fallback for native
775
- accessibilityState: { selected: isSelected, disabled: isOptionDisabled },
776
- ...Platform.select({
777
- web: {
778
- accessibilityRole: 'option' as AccessibilityRole,
779
- tabIndex: -1 as 0 | -1 | undefined,
780
- // Use a dataset attribute to help the keyboard navigator find this
781
- 'data-option-id': String(option.id),
782
- },
783
- }),
784
- };
785
-
786
- if (renderItem) {
787
- return (
788
- <Pressable
789
- onPress={handlePress}
790
- disabled={isOptionDisabled}
791
- style={[isOptionDisabled && styles.itemDisabled, style]}
792
- {...accessibilityProps}
793
- {...rest}>
794
- {renderItem(option, isSelected)}
795
- </Pressable>
796
- );
797
- }
798
-
799
687
  return (
800
- <Pressable
688
+ <List.Item
689
+ {...rest}
690
+ style={style}
691
+ value={value}
692
+ shouldToggleOnPress={false}
801
693
  onPress={handlePress}
802
694
  disabled={isOptionDisabled}
803
- style={[
804
- styles.item,
805
- isSelected && styles.itemSelected,
806
- isOptionDisabled && styles.itemDisabled,
807
- style,
808
- ]}
809
- {...accessibilityProps}
810
- {...rest}>
811
- {content}
812
- </Pressable>
695
+ accessibilityState={{ selected: isSelected, disabled: isOptionDisabled }}
696
+ {...(Platform.OS === 'web'
697
+ ? {
698
+ // Force role="option" on web — the keyboard navigator finds rows by
699
+ // [role="option"], so callers must not override these.
700
+ accessibilityRole: 'option' as AccessibilityRole,
701
+ role: 'option',
702
+ tabIndex: -1 as 0 | -1 | undefined,
703
+ 'data-molecules-select-option': '',
704
+ 'data-option-id': String(option.id),
705
+ onKeyDown: (e: React.KeyboardEvent) => {
706
+ if (e.key === 'Enter' || e.key === ' ') {
707
+ e.preventDefault();
708
+ e.stopPropagation();
709
+ }
710
+ },
711
+ }
712
+ : { accessibilityRole: 'button' as AccessibilityRole })}>
713
+ {children}
714
+ </List.Item>
813
715
  );
814
716
  },
815
717
  );
816
718
 
817
719
  SelectOption.displayName = 'Select_Option';
818
720
 
819
- // Select.SearchInput - handles search
820
- const SelectSearchInput = memo(
821
- ({ onQueryChange, autoFocus = true, ...textInputProps }: SelectSearchInputProps) => {
822
- const { searchQuery, setSearchQuery } = useSelectContextValue(state => ({
823
- searchQuery: state.searchQuery,
824
- setSearchQuery: state.setSearchQuery,
825
- }));
826
- const textInputRef = useRef<TextInputHandles>(null);
827
-
828
- const handleChangeText = useCallback(
829
- (text: string) => {
830
- setSearchQuery(text);
831
- onQueryChange?.(text);
832
- textInputProps.onChangeText?.(text);
833
- },
834
- [onQueryChange, setSearchQuery, textInputProps],
835
- );
836
-
837
- const inputProps = {
838
- ...textInputProps,
839
- value: textInputProps.value !== undefined ? textInputProps.value : searchQuery,
840
- onChangeText: handleChangeText,
841
- placeholder: textInputProps.placeholder || 'Search...',
842
- inputStyle: styles.searchInputInput,
843
- } as TextInputProps;
844
-
845
- useEffect(() => {
846
- if (Platform.OS !== 'web') return;
847
- if (!autoFocus || !textInputRef.current) {
848
- return;
849
- }
721
+ export const SelectSearchInput = memo(({ children, ...textInputProps }: SelectSearchInputProps) => {
722
+ const { searchQuery, setSearchQuery } = useSelectSearchContextValue(state => ({
723
+ searchQuery: state.searchQuery,
724
+ setSearchQuery: state.setSearchQuery,
725
+ }));
850
726
 
851
- const node = textInputRef.current as TextInputHandles & {
852
- focus?: (options?: { preventScroll?: boolean }) => void;
853
- };
727
+ const textInputRef = useRef<TextInputHandles>(null);
854
728
 
855
- const focusField = () => {
856
- try {
857
- node.focus?.({ preventScroll: true });
858
- } catch {
859
- const { scrollX, scrollY } = window;
860
- node.focus?.();
861
- window.scrollTo(scrollX, scrollY);
862
- }
863
- };
729
+ const handleChangeText = useCallback(
730
+ (text: string) => {
731
+ setSearchQuery(text);
732
+ },
733
+ [setSearchQuery],
734
+ );
864
735
 
865
- // Run after popover layout so positioning is stable before focus.
866
- requestAnimationFrame(focusField);
867
- }, [autoFocus]);
736
+ const inputProps = {
737
+ ...textInputProps,
738
+ value: searchQuery,
739
+ onChangeText: handleChangeText,
740
+ placeholder: textInputProps.placeholder || 'Search...',
741
+ inputStyle: styles.searchInputInput,
742
+ } as TextInputProps;
868
743
 
869
- return (
870
- <TextInput
871
- ref={textInputRef}
872
- autoFocus={Platform.OS !== 'web' && autoFocus}
873
- style={styles.searchInput}
874
- left={
875
- <Icon onPress={() => textInputRef.current?.focus()} name="magnify" size={20} />
876
- }
877
- right={
878
- searchQuery ? (
879
- <IconButton name="close" size={20} onPress={() => setSearchQuery('')} />
880
- ) : undefined
881
- }
882
- size="sm"
883
- variant="outlined"
884
- {...inputProps}
885
- />
886
- );
887
- },
888
- );
744
+ const onPressLeftIcon = useCallback(() => {
745
+ textInputRef.current?.focus();
746
+ }, []);
889
747
 
890
- SelectSearchInput.displayName = 'Select_SearchInput';
748
+ const onClearSearchQuery = useCallback(() => {
749
+ handleChangeText('');
750
+ }, [handleChangeText]);
891
751
 
892
- // Attach subcomponents
893
- const SelectWithSubcomponents = Object.assign(Select, {
894
- Trigger: SelectTrigger,
895
- Value: SelectValue,
896
- Dropdown: SelectDropdown,
897
- Content: SelectContent,
898
- Group: SelectGroup,
899
- Option: SelectOption,
900
- SearchInput: SelectSearchInput,
752
+ return (
753
+ <TextInput
754
+ ref={textInputRef}
755
+ style={styles.searchInput}
756
+ size="sm"
757
+ variant="outlined"
758
+ {...inputProps}>
759
+ <TextInput.Left>
760
+ <Icon onPress={onPressLeftIcon} name="magnify" size={20} />
761
+ </TextInput.Left>
762
+ {searchQuery ? (
763
+ <TextInput.Right>
764
+ <IconButton name="close" size={20} onPress={onClearSearchQuery} />
765
+ </TextInput.Right>
766
+ ) : null}
767
+ {children}
768
+ </TextInput>
769
+ );
901
770
  });
902
771
 
903
- const triggerStyles = StyleSheet.create(theme => ({
904
- trigger: {
905
- borderRadius: theme.shapes.corner.extraSmall,
906
- paddingHorizontal: theme.spacings['3'],
907
- paddingVertical: theme.spacings['2'],
908
- minHeight: 56,
909
- flexDirection: 'row',
910
- alignItems: 'center',
911
- justifyContent: 'space-between',
912
- width: '100%',
913
- variants: {
914
- state: {
915
- disabled: {
916
- opacity: 0.38,
917
- backgroundColor: theme.colors.surfaceVariant,
918
- },
919
- errorDisabled: {
920
- opacity: 0.38,
921
- },
922
- },
923
- },
924
- },
925
- outline: {
926
- position: 'absolute',
927
- top: 0,
928
- left: 0,
929
- right: 0,
930
- bottom: 0,
931
- borderRadius: theme.shapes.corner.extraSmall,
932
- borderWidth: 1,
933
- borderColor: theme.colors.outline,
934
- pointerEvents: 'none',
935
- variants: {
936
- state: {
937
- focused: {
938
- borderWidth: 2,
939
- borderColor: theme.colors.primary,
940
- },
941
- hovered: {
942
- borderColor: theme.colors.onSurface,
943
- },
944
- hoveredAndFocused: {
945
- borderWidth: 2,
946
- borderColor: theme.colors.primary,
947
- },
948
- disabled: {
949
- borderColor: theme.colors.onSurface,
950
- },
951
- error: {
952
- borderColor: theme.colors.error,
953
- },
954
- errorFocused: {
955
- borderWidth: 2,
956
- borderColor: theme.colors.error,
957
- },
958
- errorHovered: {
959
- borderColor: theme.colors.onErrorContainer,
960
- },
961
- errorFocusedAndHovered: {
962
- borderWidth: 2,
963
- borderColor: theme.colors.error,
964
- },
965
- errorDisabled: {
966
- borderColor: theme.colors.error,
967
- },
968
- },
969
- },
970
- },
971
- triggerIcon: {
972
- marginLeft: theme.spacings['2'],
973
- color: theme.colors.onSurfaceVariant,
974
- },
975
- }));
976
-
977
- const styles = StyleSheet.create(theme => ({
978
- chipContainer: {
979
- flexDirection: 'row',
980
- flexWrap: 'wrap',
981
- gap: 6,
982
- maxWidth: '90%',
983
- },
984
- groupLabel: {
985
- paddingHorizontal: theme.spacings['4'],
986
- paddingVertical: theme.spacings['2'],
987
- fontWeight: '600',
988
- color: theme.colors.onSurface,
989
- },
990
- item: {
991
- paddingHorizontal: theme.spacings['4'],
992
- paddingVertical: theme.spacings['3'],
993
- backgroundColor: 'transparent',
994
-
995
- _web: {
996
- cursor: 'pointer',
997
- outlineStyle: 'none',
998
- _hover: {
999
- backgroundColor: theme.colors.stateLayer.hover.primary,
1000
- },
1001
- _focus: {
1002
- backgroundColor: theme.colors.stateLayer.hover.primary,
1003
- },
1004
- },
1005
- },
1006
- itemSelected: {
1007
- backgroundColor: theme.colors.stateLayer.hover.primary,
1008
- },
1009
- itemDisabled: {
1010
- opacity: 0.38,
1011
- _web: {
1012
- cursor: 'not-allowed',
1013
- },
1014
- },
1015
- itemDisabledText: {
1016
- color: theme.colors.onSurfaceVariant,
1017
- },
1018
- searchInput: {
1019
- marginHorizontal: theme.spacings['2'],
1020
- marginVertical: theme.spacings['3'],
1021
- },
1022
- searchInputInput: {
1023
- height: 42,
1024
- },
1025
- emptyState: {
1026
- paddingHorizontal: theme.spacings['4'],
1027
- paddingVertical: theme.spacings['6'],
1028
- alignItems: 'center',
1029
- justifyContent: 'center',
1030
- },
1031
- emptyStateText: {
1032
- color: theme.colors.onSurfaceVariant,
1033
- fontSize: 14,
1034
- },
1035
- }));
772
+ SelectSearchInput.displayName = 'Select_SearchInput';
1036
773
 
1037
- export default SelectWithSubcomponents;
1038
- export { SelectDropdownProvider, SelectProvider };
774
+ export default SelectRoot;