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