react-native-molecules 0.5.0-beta.4 → 0.5.0-beta.6

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.
@@ -1,13 +1,4 @@
1
- import {
2
- createContext,
3
- memo,
4
- useCallback,
5
- useContext,
6
- useEffect,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from 'react';
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
2
  import {
12
3
  type AccessibilityRole,
13
4
  type GestureResponderEvent,
@@ -17,281 +8,236 @@ import {
17
8
  ScrollView,
18
9
  View,
19
10
  } from 'react-native';
20
- import { StyleSheet } from 'react-native-unistyles';
21
11
 
22
- import { useActionState, useControlledValue } from '../../hooks';
12
+ import { typedMemo } from '../../hocs';
13
+ import { useActionState, useControlledValue, useLatest } from '../../hooks';
23
14
  import { useToggle } from '../../hooks';
24
15
  import { resolveStateVariant } from '../../utils';
25
16
  import { Chip } from '../Chip';
26
17
  import { Icon } from '../Icon';
27
18
  import { IconButton } from '../IconButton';
28
19
  import { Popover } from '../Popover';
29
- import { registerPortalContext } from '../Portal';
30
20
  import { Text } from '../Text';
31
21
  import { TextInput, type TextInputHandles, type TextInputProps } from '../TextInput';
32
22
  import type {
33
23
  DefaultItemT,
34
24
  SelectContentProps,
35
25
  SelectContextValue,
36
- SelectDropdownContextValue,
37
26
  SelectDropdownProps,
38
27
  SelectGroupProps,
39
28
  SelectOptionProps,
40
- SelectProviderProps,
29
+ SelectProps,
41
30
  SelectSearchInputProps,
42
31
  SelectTriggerProps,
43
32
  SelectValueProps,
44
33
  } from './types';
34
+ import {
35
+ SelectContextProvider,
36
+ SelectDropdownContextProvider,
37
+ styles,
38
+ triggerStyles,
39
+ useSelectContextValue,
40
+ useSelectDropdownContextValue,
41
+ } from './utils';
45
42
 
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;
78
- };
43
+ const emptyArr: unknown[] = [];
79
44
 
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
- });
45
+ // SelectProvider - manages controlled/uncontrolled state
46
+ const SelectProvider = typedMemo(
47
+ <Option extends DefaultItemT = DefaultItemT>({
48
+ children,
49
+ value: valueProp,
50
+ defaultValue,
51
+ onChange,
52
+ multiple = false,
53
+ disabled = false,
54
+ error = false,
55
+ labelKey = 'label',
56
+ options = emptyArr as Option[],
57
+ searchKey,
58
+ onSearchChange,
59
+ hideSelected: hideSelectedProp,
60
+ }: SelectProps<Option>) => {
61
+ const [value, onValueChange] = useControlledValue<Option['id'] | Option['id'][] | null>({
62
+ value: valueProp,
63
+ defaultValue: defaultValue ?? (multiple ? (emptyArr as Option['id'][]) : null),
64
+ onChange,
65
+ });
66
+ const valueRef = useLatest(value);
89
67
 
90
- registerPortalContext([SelectContext, SelectDropdownContext]);
68
+ const [searchQuery, setSearchQuery] = useState('');
91
69
 
92
- export const useSelectDropdownContext = () => {
93
- return useContext(SelectDropdownContext);
94
- };
70
+ const handleSearchQueryChange = useCallback(
71
+ (query: string) => {
72
+ setSearchQuery(query);
73
+ onSearchChange?.(query);
74
+ },
75
+ [onSearchChange],
76
+ );
95
77
 
96
- export const useSelectDropdownContextValue = <T,>(
97
- selector: (state: SelectDropdownContextType) => T,
98
- ): T => {
99
- const context = useContext(SelectDropdownContext);
100
- return selector(context);
101
- };
78
+ // Default hideSelected to multiple (true for multi-select, false for single select)
79
+ const hideSelected = hideSelectedProp !== undefined ? hideSelectedProp : multiple;
102
80
 
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
- });
81
+ const filteredOptions = useMemo(() => {
82
+ let result = options;
125
83
 
126
- const [searchQuery, setSearchQuery] = useState('');
84
+ // Filter out selected items if hideSelected is true
85
+ if (hideSelected) {
86
+ result = result.filter(item => {
87
+ if (multiple) {
88
+ const values = (value as Option['id'][]) || [];
89
+ return !values.some(v => v === item.id);
90
+ } else {
91
+ const singleValue = value as Option['id'] | null;
92
+ return singleValue !== item.id;
93
+ }
94
+ });
95
+ }
127
96
 
128
- const handleSearchQueryChange = useCallback(
129
- (query: string) => {
130
- setSearchQuery(query);
131
- onSearchChange?.(query);
132
- },
133
- [onSearchChange],
134
- );
97
+ // Apply search filter if there's a search query
98
+ if (searchQuery) {
99
+ const key = searchKey || labelKey || 'label';
100
+ const lowerQuery = searchQuery.toLowerCase();
101
+ result = result.filter(item => {
102
+ const itemValue = item[key];
103
+ return String(itemValue || '')
104
+ .toLowerCase()
105
+ .includes(lowerQuery);
106
+ });
107
+ }
135
108
 
136
- // Default hideSelected to multiple (true for multi-select, false for single select)
137
- const hideSelected = hideSelectedProp !== undefined ? hideSelectedProp : multiple;
109
+ return result;
110
+ }, [options, searchQuery, searchKey, labelKey, hideSelected, multiple, value]);
138
111
 
139
- const filteredOptions = useMemo(() => {
140
- let result = options;
112
+ const onAdd = useCallback(
113
+ (item: Option) => {
114
+ if (multiple) {
115
+ const currentValue = (valueRef.current as Option['id'][]) || [];
116
+ if (!currentValue.find(v => v === item.id)) {
117
+ onValueChange([...currentValue, item.id] as Option['id'][], item);
118
+ }
119
+ } else {
120
+ onValueChange(item.id, item);
121
+ }
122
+ },
123
+ [multiple, valueRef, onValueChange],
124
+ );
141
125
 
142
- // Filter out selected items if hideSelected is true
143
- if (hideSelected) {
144
- result = result.filter(item => {
126
+ const onRemove = useCallback(
127
+ (item: Option) => {
145
128
  if (multiple) {
146
- const values = (value as Option['id'][]) || [];
147
- return !values.some(v => v === item.id);
129
+ const currentValue = (valueRef.current as Option['id'][]) || [];
130
+ onValueChange(currentValue.filter(v => v !== item.id) as Option['id'][], item);
148
131
  } else {
149
- const singleValue = value as Option['id'] | null;
150
- return singleValue !== item.id;
132
+ onValueChange(null, item);
151
133
  }
152
- });
153
- }
134
+ },
135
+ [multiple, valueRef, onValueChange],
136
+ );
154
137
 
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
- }
138
+ const contextValue = useMemo(
139
+ () => ({
140
+ value: value,
141
+ multiple,
142
+ onAdd: onAdd as (item: DefaultItemT) => void,
143
+ onRemove: onRemove as (item: DefaultItemT) => void,
144
+ disabled,
145
+ error,
146
+ labelKey,
147
+ options,
148
+ searchQuery,
149
+ setSearchQuery: handleSearchQueryChange,
150
+ filteredOptions,
151
+ }),
152
+ [
153
+ value,
154
+ multiple,
155
+ onAdd,
156
+ onRemove,
157
+ disabled,
158
+ error,
159
+ labelKey,
160
+ options,
161
+ searchQuery,
162
+ handleSearchQueryChange,
163
+ filteredOptions,
164
+ ],
165
+ );
166
166
 
167
- return result;
168
- }, [options, searchQuery, searchKey, labelKey, hideSelected, multiple, value]);
167
+ return (
168
+ <SelectContextProvider
169
+ value={contextValue as unknown as SelectContextValue<DefaultItemT>}>
170
+ {children}
171
+ </SelectContextProvider>
172
+ );
173
+ },
174
+ );
169
175
 
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
- }
176
+ // SelectDropdownProvider - manages dropdown state
177
+ const SelectDropdownProvider = memo(
178
+ ({
179
+ children,
180
+ isOpen: isOpenProp,
181
+ onClose: onCloseProp,
182
+ }: {
183
+ children: React.ReactNode;
184
+ isOpen?: boolean;
185
+ onClose?: () => void;
186
+ }) => {
187
+ const { state: isOpen, handleOpen, handleClose } = useToggle(false);
188
+ const triggerRef = useRef<View>(null);
189
+ const contentRef = useRef<any>(null);
190
+ const [triggerLayout, setTriggerLayout] = useState<{
191
+ width: number;
192
+ height: number;
193
+ } | null>(null);
194
+ const isControlled = isOpenProp !== undefined;
195
+
196
+ const onClose = useCallback(() => {
197
+ if (isControlled) {
198
+ onCloseProp?.();
177
199
  } else {
178
- onValueChange(item.id, item);
200
+ handleClose();
179
201
  }
180
- },
181
- [multiple, value, onValueChange],
182
- );
202
+ }, [isControlled, onCloseProp, handleClose]);
183
203
 
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);
204
+ const onOpen = useCallback(() => {
205
+ if (!isControlled) {
206
+ handleOpen();
191
207
  }
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
- };
231
-
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;
249
-
250
- const onClose = useCallback(() => {
251
- if (isControlled) {
252
- onCloseProp?.();
253
- } else {
254
- handleClose();
255
- }
256
- }, [isControlled, onCloseProp, handleClose]);
257
-
258
- const onOpen = useCallback(() => {
259
- if (!isControlled) {
260
- handleOpen();
261
- }
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
- );
208
+ }, [handleOpen, isControlled]);
209
+
210
+ const contextValue = useMemo(
211
+ () => ({
212
+ isOpen: isControlled ? isOpenProp! : isOpen,
213
+ onClose,
214
+ onOpen,
215
+ triggerRef: triggerRef as React.RefObject<View>,
216
+ contentRef,
217
+ triggerLayout,
218
+ setTriggerLayout,
219
+ }),
220
+ [isControlled, isOpenProp, isOpen, onClose, onOpen, triggerLayout],
221
+ );
276
222
 
277
- return (
278
- <SelectDropdownContext.Provider value={contextValue}>
279
- {children}
280
- </SelectDropdownContext.Provider>
281
- );
282
- };
223
+ return (
224
+ <SelectDropdownContextProvider value={contextValue}>
225
+ {children}
226
+ </SelectDropdownContextProvider>
227
+ );
228
+ },
229
+ );
283
230
 
284
231
  // 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
- };
232
+ const Select = typedMemo(
233
+ <Option extends DefaultItemT = DefaultItemT>({ children, ...props }: SelectProps<Option>) => {
234
+ return (
235
+ <SelectProvider<Option> {...props}>
236
+ <SelectDropdownProvider>{children}</SelectDropdownProvider>
237
+ </SelectProvider>
238
+ );
239
+ },
240
+ );
295
241
 
296
242
  // Select.Trigger - opens the dropdown
297
243
  const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
@@ -363,7 +309,7 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
363
309
  SelectTrigger.displayName = 'Select_Trigger';
364
310
 
365
311
  // Select.Value - displays the value
366
- const SelectValue = ({ placeholder, renderValue, style, ...rest }: SelectValueProps) => {
312
+ const SelectValue = memo(({ placeholder, renderValue, style, ...rest }: SelectValueProps) => {
367
313
  const { value, multiple, labelKey, onRemove, options } = useSelectContextValue(state => ({
368
314
  value: state.value,
369
315
  multiple: state.multiple,
@@ -409,13 +355,10 @@ const SelectValue = ({ placeholder, renderValue, style, ...rest }: SelectValuePr
409
355
  return (
410
356
  <View style={[styles.chipContainer, style]} {...rest}>
411
357
  {(resolvedValue as DefaultItemT[]).map(item => (
412
- <Chip.Input
358
+ <SelectValueItem
413
359
  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)}
360
+ item={item}
361
+ onRemoveItem={onRemove}
419
362
  />
420
363
  ))}
421
364
  </View>
@@ -427,67 +370,95 @@ const SelectValue = ({ placeholder, renderValue, style, ...rest }: SelectValuePr
427
370
  {displayValue}
428
371
  </Text>
429
372
  );
430
- };
373
+ });
374
+
375
+ const SelectValueItem = typedMemo(
376
+ ({
377
+ item,
378
+ onRemoveItem,
379
+ }: {
380
+ item: DefaultItemT;
381
+ onRemoveItem: (item: DefaultItemT) => void;
382
+ }) => {
383
+ const onRemove = useCallback(() => {
384
+ onRemoveItem(item);
385
+ }, [item, onRemoveItem]);
386
+
387
+ return (
388
+ <Chip.Input
389
+ label={item[item.labelKey || 'label'] || String(item.id || item)}
390
+ size="sm"
391
+ selected
392
+ left={<></>}
393
+ onClose={onRemove}
394
+ />
395
+ );
396
+ },
397
+ );
431
398
 
432
399
  SelectValue.displayName = 'Select_Value';
433
400
 
434
401
  // 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
- }));
402
+ const SelectDropdown = memo(
403
+ ({
404
+ children,
405
+ WrapperComponent,
406
+ wrapperComponentProps,
407
+ enableKeyboardNavigation = true,
408
+ style: popoverStyleProp,
409
+ ...popoverProps
410
+ }: SelectDropdownProps & { enableKeyboardNavigation?: boolean }) => {
411
+ const { isOpen, onClose, triggerRef, triggerLayout } = useSelectDropdownContextValue(
412
+ state => ({
413
+ isOpen: state.isOpen,
414
+ onClose: state.onClose,
415
+ triggerRef: state.triggerRef,
416
+ triggerLayout: state.triggerLayout,
417
+ }),
418
+ );
449
419
 
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]);
420
+ const popoverStyle = useMemo(() => {
421
+ const baseStyle = popoverStyleProp ? [popoverStyleProp] : [];
422
+ if (triggerLayout) {
423
+ return [{ width: triggerLayout.width }, ...baseStyle];
424
+ }
425
+ return baseStyle;
426
+ }, [triggerLayout, popoverStyleProp]);
427
+
428
+ if (!triggerLayout) return null;
457
429
 
458
- if (!triggerLayout) return null;
430
+ if (WrapperComponent) {
431
+ return (
432
+ <WrapperComponent isOpen={isOpen} onClose={onClose} {...wrapperComponentProps}>
433
+ {enableKeyboardNavigation && Platform.OS === 'web' ? (
434
+ <KeyboardNavigationWrapper>{children}</KeyboardNavigationWrapper>
435
+ ) : (
436
+ children
437
+ )}
438
+ </WrapperComponent>
439
+ );
440
+ }
459
441
 
460
- if (WrapperComponent) {
461
442
  return (
462
- <WrapperComponent isOpen={isOpen} onClose={onClose} {...wrapperComponentProps}>
443
+ <Popover
444
+ triggerRef={triggerRef as React.RefObject<View>}
445
+ isOpen={isOpen}
446
+ onClose={onClose}
447
+ style={popoverStyle}
448
+ triggerDimensions={triggerLayout}
449
+ {...popoverProps}>
463
450
  {enableKeyboardNavigation && Platform.OS === 'web' ? (
464
451
  <KeyboardNavigationWrapper>{children}</KeyboardNavigationWrapper>
465
452
  ) : (
466
453
  children
467
454
  )}
468
- </WrapperComponent>
455
+ </Popover>
469
456
  );
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
- };
457
+ },
458
+ );
488
459
 
489
460
  // Keyboard navigation wrapper for web
490
- const KeyboardNavigationWrapper = ({ children }: { children: React.ReactNode }) => {
461
+ const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNode }) => {
491
462
  const { onClose, contentRef, isOpen } = useSelectDropdownContextValue(state => ({
492
463
  onClose: state.onClose,
493
464
  contentRef: state.contentRef,
@@ -533,8 +504,14 @@ const KeyboardNavigationWrapper = ({ children }: { children: React.ReactNode })
533
504
  break;
534
505
  case 'Enter':
535
506
  e.preventDefault();
507
+ e.stopImmediatePropagation();
536
508
  if (currentIndex !== -1) {
537
- options[currentIndex]?.click();
509
+ // Store reference to the focused element before triggering click
510
+ // to prevent issues with DOM updates during the click handler
511
+ const focusedOption = options[currentIndex];
512
+ if (focusedOption) {
513
+ focusedOption.click();
514
+ }
538
515
  }
539
516
  break;
540
517
  case 'Escape':
@@ -599,75 +576,81 @@ const KeyboardNavigationWrapper = ({ children }: { children: React.ReactNode })
599
576
  }, [handleKeyDown, contentRef, isOpen]);
600
577
 
601
578
  return <>{children}</>;
602
- };
579
+ });
603
580
 
604
581
  SelectDropdown.displayName = 'Select_Dropdown';
605
582
 
606
583
  // 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
- );
584
+ const SelectContent = memo(
585
+ ({
586
+ children,
587
+ ContainerComponent = ScrollView,
588
+ style,
589
+ emptyState,
590
+ ...rest
591
+ }: SelectContentProps) => {
592
+ const { contentRef } = useSelectDropdownContextValue(state => ({
593
+ contentRef: state.contentRef,
594
+ }));
627
595
 
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;
596
+ const { filteredOptions, value, multiple, searchQuery, options } = useSelectContextValue(
597
+ state => ({
598
+ filteredOptions: state.filteredOptions,
599
+ value: state.value,
600
+ multiple: state.multiple,
601
+ searchQuery: state.searchQuery,
602
+ options: state.options,
603
+ }),
604
+ );
633
605
 
634
- return children(option, !!isSelected);
635
- });
636
- }, [filteredOptions, value, multiple, children]);
606
+ const content = useMemo(() => {
607
+ return filteredOptions.map(option => {
608
+ const isSelected = multiple
609
+ ? (value as any[])?.some(v => (v?.id ?? v) === option.id)
610
+ : (value as any)?.id === option.id || (value as any) === option.id;
637
611
 
638
- const defaultEmptyState = useMemo(() => {
639
- const hasSearchQuery = searchQuery && searchQuery.trim().length > 0;
640
- const hasNoOptions = options.length === 0;
612
+ return children(option, !!isSelected);
613
+ });
614
+ }, [filteredOptions, value, multiple, children]);
615
+
616
+ const defaultEmptyState = useMemo(() => {
617
+ const hasSearchQuery = searchQuery && searchQuery.trim().length > 0;
618
+ const hasNoOptions = options.length === 0;
619
+
620
+ if (hasNoOptions) {
621
+ return (
622
+ <View style={styles.emptyState}>
623
+ <Text style={styles.emptyStateText}>No options available</Text>
624
+ </View>
625
+ );
626
+ }
641
627
 
642
- if (hasNoOptions) {
643
- return (
644
- <View style={styles.emptyState}>
645
- <Text style={styles.emptyStateText}>No options available</Text>
646
- </View>
647
- );
648
- }
628
+ if (hasSearchQuery) {
629
+ return (
630
+ <View style={styles.emptyState}>
631
+ <Text style={styles.emptyStateText}>No results found</Text>
632
+ </View>
633
+ );
634
+ }
649
635
 
650
- if (hasSearchQuery) {
651
636
  return (
652
637
  <View style={styles.emptyState}>
653
- <Text style={styles.emptyStateText}>No results found</Text>
638
+ <Text style={styles.emptyStateText}>No options</Text>
654
639
  </View>
655
640
  );
656
- }
641
+ }, [searchQuery, options.length]);
657
642
 
658
643
  return (
659
- <View style={styles.emptyState}>
660
- <Text style={styles.emptyStateText}>No options</Text>
661
- </View>
644
+ <ContainerComponent
645
+ ref={contentRef}
646
+ style={style}
647
+ {...rest}
648
+ accessibilityRole="listbox">
649
+ {filteredOptions.length === 0 ? emptyState ?? defaultEmptyState : content}
650
+ </ContainerComponent>
662
651
  );
663
- }, [searchQuery, options.length]);
664
-
665
- return (
666
- <ContainerComponent ref={contentRef} style={style} {...rest} accessibilityRole="listbox">
667
- {filteredOptions.length === 0 ? emptyState ?? defaultEmptyState : content}
668
- </ContainerComponent>
669
- );
670
- };
652
+ },
653
+ );
671
654
 
672
655
  SelectContent.displayName = 'Select_Content';
673
656
 
@@ -695,23 +678,17 @@ const SelectOption = memo(
695
678
  ...rest
696
679
  }: SelectOptionProps<Option>) => {
697
680
  const {
698
- value: selectionValue,
699
681
  multiple,
700
682
  onAdd,
701
683
  onRemove,
702
684
  disabled: selectDisabled,
703
- } = useSelectContextValue<Option>(state => ({
704
- value: state.value,
685
+ } = useSelectContextValue(state => ({
705
686
  multiple: state.multiple,
706
687
  onAdd: state.onAdd,
707
688
  onRemove: state.onRemove,
708
689
  disabled: state.disabled,
709
690
  }));
710
691
 
711
- const { onClose } = useSelectDropdownContextValue(state => ({
712
- onClose: state.onClose,
713
- }));
714
-
715
692
  const option = useMemo(() => {
716
693
  return {
717
694
  id: value,
@@ -720,15 +697,19 @@ const SelectOption = memo(
720
697
  } as Option;
721
698
  }, [children, optionDisabledProp, value]);
722
699
 
723
- const isSelected = useMemo(() => {
700
+ const isSelected = useSelectContextValue(state => {
724
701
  if (multiple) {
725
- const values = selectionValue as any[];
702
+ const values = state.value as any[];
726
703
  return values?.some(v => (v?.id ?? v) === option.id) || false;
727
704
  } else {
728
- const singleValue = selectionValue as any;
705
+ const singleValue = state.value as any;
729
706
  return (singleValue?.id ?? singleValue) === option.id || false;
730
707
  }
731
- }, [selectionValue, multiple, option.id]);
708
+ });
709
+
710
+ const { onClose } = useSelectDropdownContextValue(state => ({
711
+ onClose: state.onClose,
712
+ }));
732
713
 
733
714
  const isOptionDisabled = Boolean(
734
715
  selectDisabled || optionDisabledProp || option.selectable === false,
@@ -779,6 +760,14 @@ const SelectOption = memo(
779
760
  tabIndex: -1 as 0 | -1 | undefined,
780
761
  // Use a dataset attribute to help the keyboard navigator find this
781
762
  'data-option-id': String(option.id),
763
+ // Prevent Pressable's native Enter key handling since we handle it in KeyboardNavigationWrapper
764
+ // This prevents double-triggering of onPress when Enter is pressed
765
+ onKeyDown: (e: React.KeyboardEvent) => {
766
+ if (e.key === 'Enter' || e.key === ' ') {
767
+ e.preventDefault();
768
+ e.stopPropagation();
769
+ }
770
+ },
782
771
  },
783
772
  }),
784
773
  };
@@ -818,7 +807,7 @@ SelectOption.displayName = 'Select_Option';
818
807
 
819
808
  // Select.SearchInput - handles search
820
809
  const SelectSearchInput = memo(
821
- ({ onQueryChange, autoFocus = true, ...textInputProps }: SelectSearchInputProps) => {
810
+ ({ autoFocus = true, ...textInputProps }: SelectSearchInputProps) => {
822
811
  const { searchQuery, setSearchQuery } = useSelectContextValue(state => ({
823
812
  searchQuery: state.searchQuery,
824
813
  setSearchQuery: state.setSearchQuery,
@@ -828,15 +817,13 @@ const SelectSearchInput = memo(
828
817
  const handleChangeText = useCallback(
829
818
  (text: string) => {
830
819
  setSearchQuery(text);
831
- onQueryChange?.(text);
832
- textInputProps.onChangeText?.(text);
833
820
  },
834
- [onQueryChange, setSearchQuery, textInputProps],
821
+ [setSearchQuery],
835
822
  );
836
823
 
837
824
  const inputProps = {
838
825
  ...textInputProps,
839
- value: textInputProps.value !== undefined ? textInputProps.value : searchQuery,
826
+ value: searchQuery,
840
827
  onChangeText: handleChangeText,
841
828
  placeholder: textInputProps.placeholder || 'Search...',
842
829
  inputStyle: styles.searchInputInput,
@@ -866,17 +853,23 @@ const SelectSearchInput = memo(
866
853
  requestAnimationFrame(focusField);
867
854
  }, [autoFocus]);
868
855
 
856
+ const onPressLeftIcon = useCallback(() => {
857
+ textInputRef.current?.focus();
858
+ }, []);
859
+
860
+ const onClearSearchQuery = useCallback(() => {
861
+ handleChangeText('');
862
+ }, [handleChangeText]);
863
+
869
864
  return (
870
865
  <TextInput
871
866
  ref={textInputRef}
872
867
  autoFocus={Platform.OS !== 'web' && autoFocus}
873
868
  style={styles.searchInput}
874
- left={
875
- <Icon onPress={() => textInputRef.current?.focus()} name="magnify" size={20} />
876
- }
869
+ left={<Icon onPress={onPressLeftIcon} name="magnify" size={20} />}
877
870
  right={
878
871
  searchQuery ? (
879
- <IconButton name="close" size={20} onPress={() => setSearchQuery('')} />
872
+ <IconButton name="close" size={20} onPress={onClearSearchQuery} />
880
873
  ) : undefined
881
874
  }
882
875
  size="sm"
@@ -900,139 +893,5 @@ const SelectWithSubcomponents = Object.assign(Select, {
900
893
  SearchInput: SelectSearchInput,
901
894
  });
902
895
 
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
- }));
1036
-
1037
896
  export default SelectWithSubcomponents;
1038
897
  export { SelectDropdownProvider, SelectProvider };