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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  type AccessibilityRole,
4
4
  type GestureResponderEvent,
@@ -9,24 +9,32 @@ import {
9
9
  } from 'react-native';
10
10
 
11
11
  import { typedMemo } from '../../hocs';
12
- import { useActionState } from '../../hooks';
12
+ import { useActionState, useControlledValue } from '../../hooks';
13
13
  import { useToggle } from '../../hooks';
14
14
  import { resolveStateVariant } from '../../utils';
15
15
  import { Chip } from '../Chip';
16
16
  import { Icon } from '../Icon';
17
+ import { IconButton } from '../IconButton';
17
18
  import { List } from '../List';
18
19
  import { Popover } from '../Popover';
19
20
  import { Text } from '../Text';
21
+ import { TextInput, type TextInputHandles, type TextInputProps } from '../TextInput';
20
22
  import {
21
23
  SelectDropdownContextProvider,
24
+ SelectSearchContextProvider,
22
25
  useSelectContextValue,
23
26
  useSelectDropdownContextValue,
27
+ useSelectSearchContextValue,
24
28
  } from './context';
25
29
  import type {
26
30
  DefaultItemT,
31
+ SelectContentProps,
27
32
  SelectDropdownProps,
28
33
  SelectOptionProps,
29
34
  SelectProps,
35
+ SelectSearchContextValue,
36
+ SelectSearchInputProps,
37
+ SelectSearchKey,
30
38
  SelectTriggerProps,
31
39
  SelectValueProps,
32
40
  } from './types';
@@ -34,6 +42,43 @@ import { collectWebSelectKeyboardOptionElements, styles, triggerStyles } from '.
34
42
 
35
43
  const emptyArr: unknown[] = [];
36
44
 
45
+ const getDisplayLabel = (item: DefaultItemT, labelKey?: string) => {
46
+ const itemLabelKey = typeof item.labelKey === 'string' ? item.labelKey : undefined;
47
+ const key = labelKey ?? itemLabelKey ?? 'label';
48
+ const value = item[key];
49
+ return value == null ? String(item.id) : String(value);
50
+ };
51
+
52
+ const getNested = (item: unknown, path: string): unknown => {
53
+ if (item == null || typeof item !== 'object') return undefined;
54
+ if (!path.includes('.')) return (item as Record<string, unknown>)[path];
55
+ let val: unknown = item;
56
+ for (const part of path.split('.')) {
57
+ if (val == null || typeof val !== 'object') return undefined;
58
+ val = (val as Record<string, unknown>)[part];
59
+ }
60
+ return val;
61
+ };
62
+
63
+ const matchesByKey = (item: unknown, key: string, lowerQuery: string): boolean =>
64
+ String(getNested(item, key) ?? '')
65
+ .toLowerCase()
66
+ .includes(lowerQuery);
67
+
68
+ const applySearch = <T extends object>(
69
+ items: T[],
70
+ searchKey: SelectSearchKey<T> | undefined,
71
+ query: string,
72
+ ): T[] => {
73
+ if (!query) return items;
74
+ if (typeof searchKey === 'function') {
75
+ return items.filter(item => searchKey(item, query));
76
+ }
77
+ const keys = Array.isArray(searchKey) ? searchKey : [searchKey || 'label'];
78
+ const lowerQuery = query.toLowerCase();
79
+ return items.filter(item => keys.some(key => matchesByKey(item, key, lowerQuery)));
80
+ };
81
+
37
82
  const SelectDropdownProvider = memo(
38
83
  ({
39
84
  children,
@@ -86,25 +131,98 @@ const SelectDropdownProvider = memo(
86
131
  },
87
132
  );
88
133
 
89
- const Select = typedMemo(
134
+ export const SelectRoot = typedMemo(
90
135
  <Option extends DefaultItemT = DefaultItemT>({
91
136
  children,
92
137
  options = emptyArr as Option[],
138
+ searchKey,
139
+ searchQuery: searchQueryProp,
140
+ defaultSearchQuery,
141
+ onSearchChange,
142
+ searchMode = 'client',
143
+ getItemId,
93
144
  ...listProps
94
145
  }: SelectProps<Option>) => {
146
+ const [searchQuery, setSearchQuery] = useControlledValue<string>({
147
+ value: searchQueryProp,
148
+ defaultValue: defaultSearchQuery ?? '',
149
+ onChange: onSearchChange,
150
+ });
151
+
152
+ const getOptionId = useMemo(
153
+ () => (getItemId ?? ((item: Option) => item.id)) as (item: Option) => string | number,
154
+ [getItemId],
155
+ );
156
+
157
+ const filteredOptions = useMemo(() => {
158
+ if (searchMode === 'external') return options;
159
+ return applySearch(options, searchKey, searchQuery);
160
+ }, [options, searchKey, searchMode, searchQuery]);
161
+
162
+ const optionById = useMemo(() => {
163
+ const map = new Map<string | number, Option>();
164
+ for (const option of options) {
165
+ map.set(getOptionId(option), option);
166
+ }
167
+ return map;
168
+ }, [getOptionId, options]);
169
+
170
+ const searchContextValue = useMemo(
171
+ () =>
172
+ ({
173
+ searchQuery,
174
+ setSearchQuery,
175
+ allOptions: options,
176
+ options: filteredOptions,
177
+ optionById,
178
+ getOptionId,
179
+ } as unknown as SelectSearchContextValue<DefaultItemT>),
180
+ [filteredOptions, getOptionId, optionById, options, searchQuery, setSearchQuery],
181
+ );
182
+
95
183
  return (
96
- <List {...listProps} items={options}>
97
- <SelectDropdownProvider>{children}</SelectDropdownProvider>
98
- </List>
184
+ <SelectSearchContextProvider value={searchContextValue}>
185
+ <List {...listProps}>
186
+ <SelectDropdownProvider>{children}</SelectDropdownProvider>
187
+ </List>
188
+ </SelectSearchContextProvider>
99
189
  );
100
190
  },
101
191
  );
102
192
 
103
- const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
104
- const { onOpen, isOpen, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
193
+ export const SelectContent = typedMemo(
194
+ <Option extends DefaultItemT = DefaultItemT>({
195
+ children,
196
+ ...rest
197
+ }: SelectContentProps<Option>) => {
198
+ const { options, getOptionId } = useSelectSearchContextValue(state => ({
199
+ options: state.options as Option[],
200
+ getOptionId: state.getOptionId as (item: Option) => string | number,
201
+ }));
202
+ const isSelectedId = useSelectContextValue(state => state.isSelectedId);
203
+
204
+ if (typeof children !== 'function') {
205
+ return <List.Content {...rest}>{children}</List.Content>;
206
+ }
207
+
208
+ return (
209
+ <List.Content {...rest}>
210
+ {options.map(item => (
211
+ <Fragment key={String(getOptionId(item))}>
212
+ {children(item, isSelectedId(getOptionId(item)))}
213
+ </Fragment>
214
+ ))}
215
+ </List.Content>
216
+ );
217
+ },
218
+ );
219
+
220
+ export const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
221
+ const { isOpen, onOpen, onClose, triggerRef, setTriggerLayout } = useSelectDropdownContextValue(
105
222
  state => ({
106
- onOpen: state.onOpen,
107
223
  isOpen: state.isOpen,
224
+ onOpen: state.onOpen,
225
+ onClose: state.onClose,
108
226
  triggerRef: state.triggerRef,
109
227
  setTriggerLayout: state.setTriggerLayout,
110
228
  }),
@@ -140,10 +258,13 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
140
258
  );
141
259
 
142
260
  const handlePress = useCallback(() => {
143
- if (!isOpen && !disabled) {
261
+ if (disabled) return;
262
+ if (!isOpen) {
144
263
  onOpen();
264
+ } else {
265
+ onClose();
145
266
  }
146
- }, [isOpen, onOpen, disabled]);
267
+ }, [isOpen, onOpen, onClose, disabled]);
147
268
 
148
269
  return (
149
270
  <Pressable
@@ -168,44 +289,45 @@ const SelectTrigger = ({ children, style, ...rest }: SelectTriggerProps) => {
168
289
 
169
290
  SelectTrigger.displayName = 'Select_Trigger';
170
291
 
171
- const SelectValue = memo(
292
+ export const SelectValue = memo(
172
293
  ({ placeholder, labelKey, renderValue, style, ...rest }: SelectValueProps) => {
173
- const { value, multiple, onRemove, options } = useSelectContextValue(state => ({
294
+ const { value, multiple, onRemove } = useSelectContextValue(state => ({
174
295
  value: state.value,
175
296
  multiple: state.multiple,
176
297
  onRemove: state.onRemove,
177
- options: state.items,
298
+ }));
299
+ const { optionById } = useSelectSearchContextValue(state => ({
300
+ optionById: state.optionById,
178
301
  }));
179
302
 
180
303
  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;
304
+ const resolve = (id: unknown) => {
305
+ if (id === null || id === undefined) return null;
306
+ const found = optionById.get(id as string | number);
307
+ return found || { id: id as string | number };
186
308
  };
187
309
 
188
310
  if (multiple) {
189
311
  return (Array.isArray(value) ? value : []).map(resolve).filter(Boolean);
190
312
  }
191
313
  return resolve(value);
192
- }, [value, multiple, options]);
314
+ }, [optionById, value, multiple]);
193
315
 
194
316
  const displayValue = useMemo(() => {
195
317
  if (!resolvedValue) return placeholder || '';
196
318
  if (multiple && (resolvedValue as any[]).length === 0) return placeholder || '';
197
319
 
198
320
  if (renderValue) {
199
- return renderValue(resolvedValue as any);
321
+ return renderValue(resolvedValue as DefaultItemT | DefaultItemT[] | null);
200
322
  }
201
323
 
202
324
  if (multiple) {
203
325
  const values = resolvedValue as DefaultItemT[];
204
326
  // For multi-select, show chips
205
- return values.map(item => item[labelKey || 'label'] || String(item.id)).join(', ');
327
+ return values.map(item => getDisplayLabel(item, labelKey)).join(', ');
206
328
  } else {
207
329
  const singleValue = resolvedValue as DefaultItemT;
208
- return singleValue[labelKey || 'label'] || String(singleValue.id || singleValue);
330
+ return getDisplayLabel(singleValue, labelKey);
209
331
  }
210
332
  }, [resolvedValue, multiple, labelKey, placeholder, renderValue]);
211
333
 
@@ -225,7 +347,7 @@ const SelectValue = memo(
225
347
  }
226
348
 
227
349
  return (
228
- <Text style={style} {...rest}>
350
+ <Text style={[styles.valueText, style]} selectable={false} {...rest}>
229
351
  {displayValue}
230
352
  </Text>
231
353
  );
@@ -246,7 +368,7 @@ const SelectValueItem = typedMemo(
246
368
 
247
369
  return (
248
370
  <Chip.Input
249
- label={item[item.labelKey || 'label'] || String(item.id || item)}
371
+ label={getDisplayLabel(item)}
250
372
  size="sm"
251
373
  selected
252
374
  left={<></>}
@@ -259,7 +381,7 @@ const SelectValueItem = typedMemo(
259
381
  SelectValue.displayName = 'Select_Value';
260
382
 
261
383
  // Select.Dropdown - popover with keyboard navigation
262
- const SelectDropdown = memo(
384
+ export const SelectDropdown = memo(
263
385
  ({
264
386
  children,
265
387
  WrapperComponent,
@@ -417,10 +539,8 @@ const KeyboardNavigationWrapper = memo(({ children }: { children: React.ReactNod
417
539
 
418
540
  SelectDropdown.displayName = 'Select_Dropdown';
419
541
 
420
- const SelectGroup = List.Group;
421
-
422
542
  // Select.Item - select item that uses context
423
- const SelectOption = memo(
543
+ export const SelectOption = memo(
424
544
  <Option extends DefaultItemT = DefaultItemT>({
425
545
  value,
426
546
  children,
@@ -434,33 +554,29 @@ const SelectOption = memo(
434
554
  onAdd,
435
555
  onRemove,
436
556
  disabled: selectDisabled,
437
- items,
557
+ isSelectedId,
438
558
  } = useSelectContextValue(state => ({
439
559
  multiple: state.multiple,
440
560
  onAdd: state.onAdd,
441
561
  onRemove: state.onRemove,
442
562
  disabled: state.disabled,
443
- items: state.items,
563
+ isSelectedId: state.isSelectedId,
564
+ }));
565
+ const { allOptions, getOptionId } = useSelectSearchContextValue(state => ({
566
+ allOptions: state.allOptions,
567
+ getOptionId: state.getOptionId,
444
568
  }));
445
569
 
446
570
  const option = useMemo(() => {
447
- const found = items.find(i => i.id === value);
571
+ const found = allOptions.find(i => getOptionId(i as Option) === value);
448
572
  if (found) return found as Option;
449
573
  return {
450
574
  id: value,
451
575
  ...(optionDisabledProp ? { selectable: false } : {}),
452
576
  } as Option;
453
- }, [items, optionDisabledProp, value]);
454
-
455
- const isSelected = useSelectContextValue(state => {
456
- if (multiple) {
457
- const values = state.value as any[];
458
- return values?.some(v => (v?.id ?? v) === option.id) || false;
459
- }
577
+ }, [allOptions, getOptionId, optionDisabledProp, value]);
460
578
 
461
- const singleValue = state.value as any;
462
- return (singleValue?.id ?? singleValue) === option.id || false;
463
- });
579
+ const isSelected = isSelectedId(value);
464
580
 
465
581
  const { onClose } = useSelectDropdownContextValue(state => ({
466
582
  onClose: state.onClose,
@@ -495,7 +611,7 @@ const SelectOption = memo(
495
611
  style={style}
496
612
  value={value}
497
613
  shouldToggleOnPress={false}
498
- onPress={(_, event) => handlePress(event)}
614
+ onPress={handlePress}
499
615
  disabled={isOptionDisabled}
500
616
  accessibilityState={{ selected: isSelected, disabled: isOptionDisabled }}
501
617
  {...(Platform.OS === 'web'
@@ -523,16 +639,64 @@ const SelectOption = memo(
523
639
 
524
640
  SelectOption.displayName = 'Select_Option';
525
641
 
526
- const SelectSearchInput = List.SearchInput;
642
+ export const SelectSearchInput = memo(({ children, ...textInputProps }: SelectSearchInputProps) => {
643
+ const { searchQuery, setSearchQuery } = useSelectSearchContextValue(state => ({
644
+ searchQuery: state.searchQuery,
645
+ setSearchQuery: state.setSearchQuery,
646
+ }));
647
+
648
+ const textInputRef = useRef<TextInputHandles>(null);
649
+
650
+ const handleChangeText = useCallback(
651
+ (text: string) => {
652
+ setSearchQuery(text);
653
+ },
654
+ [setSearchQuery],
655
+ );
656
+
657
+ const inputProps = {
658
+ ...textInputProps,
659
+ value: searchQuery,
660
+ onChangeText: handleChangeText,
661
+ placeholder: textInputProps.placeholder || 'Search...',
662
+ inputStyle: styles.searchInputInput,
663
+ } as TextInputProps;
664
+
665
+ const onPressLeftIcon = useCallback(() => {
666
+ textInputRef.current?.focus();
667
+ }, []);
668
+
669
+ const onClearSearchQuery = useCallback(() => {
670
+ handleChangeText('');
671
+ }, [handleChangeText]);
672
+
673
+ return (
674
+ <TextInput
675
+ ref={textInputRef}
676
+ style={styles.searchInput}
677
+ size="sm"
678
+ variant="outlined"
679
+ {...inputProps}>
680
+ <TextInput.Left>
681
+ <Icon onPress={onPressLeftIcon} name="magnify" size={20} />
682
+ </TextInput.Left>
683
+ {searchQuery ? (
684
+ <TextInput.Right>
685
+ <IconButton name="close" size={20} onPress={onClearSearchQuery} />
686
+ </TextInput.Right>
687
+ ) : null}
688
+ {children}
689
+ </TextInput>
690
+ );
691
+ });
527
692
 
528
693
  SelectSearchInput.displayName = 'Select_SearchInput';
529
694
 
530
- const SelectWithSubcomponents = Object.assign(Select, {
695
+ const SelectWithSubcomponents = Object.assign(SelectRoot, {
531
696
  Trigger: SelectTrigger,
532
697
  Value: SelectValue,
533
698
  Dropdown: SelectDropdown,
534
- Content: List.Content,
535
- Group: SelectGroup,
699
+ Content: SelectContent,
536
700
  Option: SelectOption,
537
701
  SearchInput: SelectSearchInput,
538
702
  });
@@ -9,7 +9,7 @@ import {
9
9
  useListStoreRef,
10
10
  } from '../List';
11
11
  import { registerPortalContext } from '../Portal';
12
- import type { SelectDropdownContextValue } from './types';
12
+ import type { DefaultItemT, SelectDropdownContextValue, SelectSearchContextValue } from './types';
13
13
 
14
14
  export {
15
15
  ListContext as SelectContext,
@@ -43,12 +43,37 @@ const {
43
43
  Context: SelectDropdownContext,
44
44
  } = createFastContext<SelectDropdownContextType>(selectDropdownContextDefaultValue, true);
45
45
 
46
+ const selectSearchContextDefaultValue: SelectSearchContextValue<DefaultItemT> = {
47
+ searchQuery: '',
48
+ setSearchQuery: () => {},
49
+ allOptions: [],
50
+ options: [],
51
+ optionById: new Map(),
52
+ getOptionId: item => item.id,
53
+ };
54
+
55
+ const {
56
+ useStoreRef: useSelectSearchStoreRef,
57
+ Provider: SelectSearchContextProvider,
58
+ useContext: useSelectSearchContext,
59
+ useContextValue: useSelectSearchContextValue,
60
+ Context: SelectSearchContext,
61
+ } = createFastContext<SelectSearchContextValue<DefaultItemT>>(
62
+ selectSearchContextDefaultValue,
63
+ true,
64
+ );
65
+
46
66
  export {
47
67
  SelectDropdownContext,
48
68
  SelectDropdownContextProvider,
69
+ SelectSearchContext,
70
+ SelectSearchContextProvider,
49
71
  useSelectDropdownContext,
50
72
  useSelectDropdownContextValue,
51
73
  useSelectDropdownStoreRef,
74
+ useSelectSearchContext,
75
+ useSelectSearchContextValue,
76
+ useSelectSearchStoreRef,
52
77
  };
53
78
 
54
- registerPortalContext([SelectDropdownContext]);
79
+ registerPortalContext([SelectDropdownContext, SelectSearchContext]);
@@ -1,21 +1,16 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
- import type { GestureResponderEvent, ViewProps } from 'react-native';
2
+ import type { GestureResponderEvent, TextInputProps, ViewProps } from 'react-native';
3
3
 
4
- import type { ListValue } from '../List';
4
+ import type { ListContentProps, ListItemId, ListValue } from '../List';
5
5
  import type { PopoverProps } from '../Popover';
6
6
 
7
- export type {
8
- ListContentProps as SelectContentProps,
9
- ListContextValue as SelectContextValue,
10
- ListGroupProps as SelectGroupProps,
11
- ListSearchInputProps as SelectSearchInputProps,
12
- } from '../List';
7
+ export type { ListContextValue as SelectContextValue } from '../List';
13
8
 
14
9
  export type DefaultItemT = {
15
10
  id: string | number;
16
11
  label?: string;
17
12
  selectable?: boolean;
18
- [key: string]: any;
13
+ [key: string]: unknown;
19
14
  };
20
15
 
21
16
  // SelectDropdownContext types
@@ -25,42 +20,63 @@ export type SelectDropdownContextValue = {
25
20
  onOpen: () => void;
26
21
  };
27
22
 
23
+ export type SelectSearchMode = 'client' | 'external';
24
+
25
+ export type SelectSearchKey<Option extends object = DefaultItemT> =
26
+ | string
27
+ | string[]
28
+ | ((item: Option, query: string) => boolean);
29
+
30
+ export type SelectSearchContextValue<Option extends DefaultItemT = DefaultItemT> = {
31
+ searchQuery: string;
32
+ setSearchQuery: (query: string) => void;
33
+ allOptions: Option[];
34
+ options: Option[];
35
+ optionById: Map<ListItemId, Option>;
36
+ getOptionId: (item: Option) => ListItemId;
37
+ };
38
+
28
39
  // SelectProvider props
29
40
  type SelectPropsBase<Option extends DefaultItemT = DefaultItemT> = {
30
41
  children: ReactNode;
31
42
  disabled?: boolean;
32
43
  error?: boolean;
33
44
  options: Option[];
34
- searchKey?: string;
45
+ searchKey?: SelectSearchKey<Option>;
46
+ searchQuery?: string;
47
+ defaultSearchQuery?: string;
35
48
  onSearchChange?: (query: string) => void;
36
- hideSelected?: boolean;
49
+ searchMode?: SelectSearchMode;
50
+ allowDeselect?: boolean;
51
+ getItemId?: (item: Option) => ListItemId;
37
52
  };
38
53
 
54
+ export type SelectSearchInputProps = Omit<TextInputProps, 'value' | 'onChangeText'>;
55
+
39
56
  type SingleSelectProps<Option extends DefaultItemT = DefaultItemT> = {
40
57
  multiple?: false | undefined;
41
- value?: ListValue<Option, false>;
42
- defaultValue?: ListValue<Option, false>;
43
- onChange?: (
44
- value: ListValue<Option, false>,
45
- item: Option,
46
- event?: GestureResponderEvent,
47
- ) => void;
58
+ value?: ListValue<false>;
59
+ defaultValue?: ListValue<false>;
60
+ onChange?: (value: ListValue<false>, item: Option, event?: GestureResponderEvent) => void;
48
61
  };
49
62
 
50
63
  type MultipleSelectProps<Option extends DefaultItemT = DefaultItemT> = {
51
64
  multiple: true;
52
- value?: ListValue<Option, true>;
53
- defaultValue?: ListValue<Option, true>;
54
- onChange?: (
55
- value: ListValue<Option, true>,
56
- item: Option,
57
- event?: GestureResponderEvent,
58
- ) => void;
65
+ value?: ListValue<true>;
66
+ defaultValue?: ListValue<true>;
67
+ onChange?: (value: ListValue<true>, item: Option, event?: GestureResponderEvent) => void;
59
68
  };
60
69
 
61
70
  export type SelectProps<Option extends DefaultItemT = DefaultItemT> = SelectPropsBase<Option> &
62
71
  (SingleSelectProps<Option> | MultipleSelectProps<Option>);
63
72
 
73
+ export type SelectContentProps<Option extends DefaultItemT = DefaultItemT> = Omit<
74
+ ListContentProps,
75
+ 'children'
76
+ > & {
77
+ children?: ReactNode | ((item: Option, isSelected: boolean) => ReactNode);
78
+ };
79
+
64
80
  // Select.Trigger props
65
81
  export type SelectTriggerProps = ViewProps & {
66
82
  children?: ReactNode;
@@ -92,16 +92,23 @@ const triggerDefaultStyles = StyleSheet.create(theme => ({
92
92
  }));
93
93
 
94
94
  export const defaultStyles = StyleSheet.create(theme => ({
95
+ valueText: {
96
+ flex: 1,
97
+ },
95
98
  chipContainer: {
96
99
  flexDirection: 'row',
97
100
  flexWrap: 'wrap',
98
101
  gap: 6,
99
102
  maxWidth: '90%',
103
+ flex: 1,
100
104
  },
101
105
  searchInput: {
102
106
  marginHorizontal: theme.spacings['2'],
103
107
  marginVertical: theme.spacings['3'],
104
108
  },
109
+ searchInputInput: {
110
+ height: 42,
111
+ },
105
112
  }));
106
113
 
107
114
  export const triggerStyles = getRegisteredComponentStylesWithFallback(