@cdx-ui/primitives 0.3.3 → 0.3.4

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 (140) hide show
  1. package/lib/commonjs/select/SelectContent.js +89 -45
  2. package/lib/commonjs/select/SelectContent.js.map +1 -1
  3. package/lib/commonjs/select/SelectDismissButton.js +14 -0
  4. package/lib/commonjs/select/SelectDismissButton.js.map +1 -0
  5. package/lib/commonjs/select/SelectDismissButton.web.js +28 -0
  6. package/lib/commonjs/select/SelectDismissButton.web.js.map +1 -0
  7. package/lib/commonjs/select/SelectEmpty.js +53 -0
  8. package/lib/commonjs/select/SelectEmpty.js.map +1 -0
  9. package/lib/commonjs/select/SelectItem.js +30 -9
  10. package/lib/commonjs/select/SelectItem.js.map +1 -1
  11. package/lib/commonjs/select/SelectListbox.js +47 -0
  12. package/lib/commonjs/select/SelectListbox.js.map +1 -0
  13. package/lib/commonjs/select/SelectRoot.js +110 -21
  14. package/lib/commonjs/select/SelectRoot.js.map +1 -1
  15. package/lib/commonjs/select/SelectSearch.js +81 -0
  16. package/lib/commonjs/select/SelectSearch.js.map +1 -0
  17. package/lib/commonjs/select/SelectSearchStatus.js +14 -0
  18. package/lib/commonjs/select/SelectSearchStatus.js.map +1 -0
  19. package/lib/commonjs/select/SelectSearchStatus.web.js +24 -0
  20. package/lib/commonjs/select/SelectSearchStatus.web.js.map +1 -0
  21. package/lib/commonjs/select/SelectSheetContent.js +67 -0
  22. package/lib/commonjs/select/SelectSheetContent.js.map +1 -0
  23. package/lib/commonjs/select/context.js +5 -1
  24. package/lib/commonjs/select/context.js.map +1 -1
  25. package/lib/commonjs/select/filter.js +16 -0
  26. package/lib/commonjs/select/filter.js.map +1 -0
  27. package/lib/commonjs/select/index.js +34 -0
  28. package/lib/commonjs/select/index.js.map +1 -1
  29. package/lib/commonjs/select/popupRole.js +27 -0
  30. package/lib/commonjs/select/popupRole.js.map +1 -0
  31. package/lib/commonjs/select/useContentFocus.js +10 -21
  32. package/lib/commonjs/select/useContentFocus.js.map +1 -1
  33. package/lib/commonjs/select/useListboxNavigation.js +21 -8
  34. package/lib/commonjs/select/useListboxNavigation.js.map +1 -1
  35. package/lib/commonjs/select/useQueryHighlight.js +50 -0
  36. package/lib/commonjs/select/useQueryHighlight.js.map +1 -0
  37. package/lib/commonjs/select/useSelectTrigger.js +4 -1
  38. package/lib/commonjs/select/useSelectTrigger.js.map +1 -1
  39. package/lib/commonjs/select/useSelectTrigger.web.js +4 -1
  40. package/lib/commonjs/select/useSelectTrigger.web.js.map +1 -1
  41. package/lib/module/select/SelectContent.js +94 -48
  42. package/lib/module/select/SelectContent.js.map +1 -1
  43. package/lib/module/select/SelectDismissButton.js +10 -0
  44. package/lib/module/select/SelectDismissButton.js.map +1 -0
  45. package/lib/module/select/SelectDismissButton.web.js +24 -0
  46. package/lib/module/select/SelectDismissButton.web.js.map +1 -0
  47. package/lib/module/select/SelectEmpty.js +49 -0
  48. package/lib/module/select/SelectEmpty.js.map +1 -0
  49. package/lib/module/select/SelectItem.js +32 -11
  50. package/lib/module/select/SelectItem.js.map +1 -1
  51. package/lib/module/select/SelectListbox.js +43 -0
  52. package/lib/module/select/SelectListbox.js.map +1 -0
  53. package/lib/module/select/SelectRoot.js +111 -22
  54. package/lib/module/select/SelectRoot.js.map +1 -1
  55. package/lib/module/select/SelectSearch.js +77 -0
  56. package/lib/module/select/SelectSearch.js.map +1 -0
  57. package/lib/module/select/SelectSearchStatus.js +10 -0
  58. package/lib/module/select/SelectSearchStatus.js.map +1 -0
  59. package/lib/module/select/SelectSearchStatus.web.js +20 -0
  60. package/lib/module/select/SelectSearchStatus.web.js.map +1 -0
  61. package/lib/module/select/SelectSheetContent.js +63 -0
  62. package/lib/module/select/SelectSheetContent.js.map +1 -0
  63. package/lib/module/select/context.js +2 -0
  64. package/lib/module/select/context.js.map +1 -1
  65. package/lib/module/select/filter.js +11 -0
  66. package/lib/module/select/filter.js.map +1 -0
  67. package/lib/module/select/index.js +5 -1
  68. package/lib/module/select/index.js.map +1 -1
  69. package/lib/module/select/popupRole.js +23 -0
  70. package/lib/module/select/popupRole.js.map +1 -0
  71. package/lib/module/select/useContentFocus.js +11 -22
  72. package/lib/module/select/useContentFocus.js.map +1 -1
  73. package/lib/module/select/useListboxNavigation.js +21 -8
  74. package/lib/module/select/useListboxNavigation.js.map +1 -1
  75. package/lib/module/select/useQueryHighlight.js +46 -0
  76. package/lib/module/select/useQueryHighlight.js.map +1 -0
  77. package/lib/module/select/useSelectTrigger.js +4 -1
  78. package/lib/module/select/useSelectTrigger.js.map +1 -1
  79. package/lib/module/select/useSelectTrigger.web.js +4 -1
  80. package/lib/module/select/useSelectTrigger.web.js.map +1 -1
  81. package/lib/typescript/select/SelectContent.d.ts.map +1 -1
  82. package/lib/typescript/select/SelectDismissButton.d.ts +10 -0
  83. package/lib/typescript/select/SelectDismissButton.d.ts.map +1 -0
  84. package/lib/typescript/select/SelectDismissButton.web.d.ts +9 -0
  85. package/lib/typescript/select/SelectDismissButton.web.d.ts.map +1 -0
  86. package/lib/typescript/select/SelectEmpty.d.ts +12 -0
  87. package/lib/typescript/select/SelectEmpty.d.ts.map +1 -0
  88. package/lib/typescript/select/SelectItem.d.ts.map +1 -1
  89. package/lib/typescript/select/SelectListbox.d.ts +12 -0
  90. package/lib/typescript/select/SelectListbox.d.ts.map +1 -0
  91. package/lib/typescript/select/SelectRoot.d.ts +2 -2
  92. package/lib/typescript/select/SelectRoot.d.ts.map +1 -1
  93. package/lib/typescript/select/SelectSearch.d.ts +18 -0
  94. package/lib/typescript/select/SelectSearch.d.ts.map +1 -0
  95. package/lib/typescript/select/SelectSearchStatus.d.ts +9 -0
  96. package/lib/typescript/select/SelectSearchStatus.d.ts.map +1 -0
  97. package/lib/typescript/select/SelectSearchStatus.web.d.ts +8 -0
  98. package/lib/typescript/select/SelectSearchStatus.web.d.ts.map +1 -0
  99. package/lib/typescript/select/SelectSheetContent.d.ts +14 -0
  100. package/lib/typescript/select/SelectSheetContent.d.ts.map +1 -0
  101. package/lib/typescript/select/context.d.ts +9 -0
  102. package/lib/typescript/select/context.d.ts.map +1 -1
  103. package/lib/typescript/select/filter.d.ts +4 -0
  104. package/lib/typescript/select/filter.d.ts.map +1 -0
  105. package/lib/typescript/select/index.d.ts +6 -2
  106. package/lib/typescript/select/index.d.ts.map +1 -1
  107. package/lib/typescript/select/popupRole.d.ts +18 -0
  108. package/lib/typescript/select/popupRole.d.ts.map +1 -0
  109. package/lib/typescript/select/types.d.ts +129 -8
  110. package/lib/typescript/select/types.d.ts.map +1 -1
  111. package/lib/typescript/select/useContentFocus.d.ts +4 -7
  112. package/lib/typescript/select/useContentFocus.d.ts.map +1 -1
  113. package/lib/typescript/select/useListboxNavigation.d.ts +16 -3
  114. package/lib/typescript/select/useListboxNavigation.d.ts.map +1 -1
  115. package/lib/typescript/select/useQueryHighlight.d.ts +21 -0
  116. package/lib/typescript/select/useQueryHighlight.d.ts.map +1 -0
  117. package/lib/typescript/select/useSelectTrigger.d.ts.map +1 -1
  118. package/lib/typescript/select/useSelectTrigger.web.d.ts.map +1 -1
  119. package/package.json +3 -3
  120. package/src/select/SelectContent.tsx +89 -54
  121. package/src/select/SelectDismissButton.tsx +14 -0
  122. package/src/select/SelectDismissButton.web.tsx +26 -0
  123. package/src/select/SelectEmpty.tsx +61 -0
  124. package/src/select/SelectItem.tsx +42 -10
  125. package/src/select/SelectListbox.tsx +43 -0
  126. package/src/select/SelectRoot.tsx +184 -12
  127. package/src/select/SelectSearch.tsx +98 -0
  128. package/src/select/SelectSearchStatus.tsx +11 -0
  129. package/src/select/SelectSearchStatus.web.tsx +18 -0
  130. package/src/select/SelectSheetContent.tsx +54 -0
  131. package/src/select/context.tsx +9 -0
  132. package/src/select/filter.ts +12 -0
  133. package/src/select/index.ts +14 -1
  134. package/src/select/popupRole.ts +26 -0
  135. package/src/select/types.ts +142 -9
  136. package/src/select/useContentFocus.ts +12 -31
  137. package/src/select/useListboxNavigation.ts +41 -9
  138. package/src/select/useQueryHighlight.ts +61 -0
  139. package/src/select/useSelectTrigger.ts +5 -1
  140. package/src/select/useSelectTrigger.web.ts +5 -1
@@ -0,0 +1,61 @@
1
+ import {
2
+ Children,
3
+ forwardRef,
4
+ isValidElement,
5
+ useLayoutEffect,
6
+ type ComponentRef,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { View } from 'react-native';
10
+ import { Slot } from '../slot';
11
+ import { useSelectContext } from './context';
12
+ import type { ISelectEmptyProps } from './types';
13
+
14
+ export interface SelectEmptyProps extends ISelectEmptyProps {
15
+ asChild?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ function getTextContent(node: ReactNode): string {
20
+ return Children.toArray(node)
21
+ .map((child) => {
22
+ if (typeof child === 'string' || typeof child === 'number') {
23
+ return String(child);
24
+ }
25
+ if (isValidElement<{ children?: ReactNode }>(child)) {
26
+ return getTextContent(child.props.children);
27
+ }
28
+ return '';
29
+ })
30
+ .join('');
31
+ }
32
+
33
+ /**
34
+ * Renders only when no option matches. Its text is also what the search live region
35
+ * announces in that case.
36
+ */
37
+ export const SelectEmpty = forwardRef<ComponentRef<typeof View>, SelectEmptyProps>(
38
+ ({ asChild, children, ...props }, ref) => {
39
+ const { visibleItems, setEmptyText } = useSelectContext();
40
+ const text = getTextContent(children);
41
+
42
+ useLayoutEffect(() => {
43
+ setEmptyText(text || undefined);
44
+ return () => setEmptyText(undefined);
45
+ }, [setEmptyText, text]);
46
+
47
+ if (visibleItems.length > 0) {
48
+ return null;
49
+ }
50
+
51
+ const Comp = asChild ? Slot : View;
52
+
53
+ return (
54
+ <Comp ref={ref} {...props}>
55
+ {children}
56
+ </Comp>
57
+ );
58
+ },
59
+ );
60
+
61
+ SelectEmpty.displayName = 'SelectEmpty';
@@ -1,5 +1,12 @@
1
- import { forwardRef, useCallback, useMemo, type ComponentRef, type ReactNode } from 'react';
2
- import { Pressable } from 'react-native';
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ type ComponentRef,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { Platform, Pressable } from 'react-native';
3
10
  import { composeEventHandlers } from '@cdx-ui/utils';
4
11
  import { useFocus, useFocusRing } from '@react-native-aria/focus';
5
12
  import { useHover, usePress } from '@react-native-aria/interactions';
@@ -13,6 +20,12 @@ export interface SelectItemProps extends Omit<ISelectItemProps, 'children'> {
13
20
  children?: ReactNode;
14
21
  }
15
22
 
23
+ /**
24
+ * Options are reached through the highlight (`aria-activedescendant`), not the Tab order, so
25
+ * popup autofocus lands on the search input or the listbox rather than the first option.
26
+ */
27
+ const WEB_OPTION_PROPS = Platform.OS === 'web' ? { tabIndex: -1 as const } : {};
28
+
16
29
  export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemProps>(
17
30
  (
18
31
  {
@@ -30,18 +43,32 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
30
43
  ref,
31
44
  ) => {
32
45
  const {
33
- value: selectedValue,
34
- onValueChange,
35
- setOpen,
46
+ isSelected,
47
+ commitValue,
36
48
  activeValue,
49
+ setActiveValue,
37
50
  contentId,
51
+ items,
38
52
  disabled: contextDisabled,
53
+ registerDisabledItem,
54
+ isItemVisible,
39
55
  } = useSelectContext();
40
56
 
41
- const disabled = contextDisabled || !!disabledProp || !!isDisabledProp;
42
- const checked = selectedValue === itemValue;
57
+ const isDisabledByItem = !!disabledProp || !!isDisabledProp;
58
+ const disabled =
59
+ contextDisabled ||
60
+ isDisabledByItem ||
61
+ items.some((item) => item.value === itemValue && !!item.disabled);
62
+ const checked = isSelected(itemValue);
43
63
  const highlighted = activeValue === itemValue;
44
64
 
65
+ useLayoutEffect(() => {
66
+ if (isDisabledByItem) {
67
+ return registerDisabledItem(itemValue);
68
+ }
69
+ return undefined;
70
+ }, [isDisabledByItem, itemValue, registerDisabledItem]);
71
+
45
72
  const { isFocusVisible, focusProps: focusRingProps } = useFocusRing() as {
46
73
  isFocusVisible: boolean;
47
74
  focusProps: {
@@ -55,10 +82,10 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
55
82
 
56
83
  const handlePress = useCallback(() => {
57
84
  if (!disabled) {
58
- onValueChange(itemValue);
59
- setOpen(false);
85
+ setActiveValue(itemValue);
86
+ commitValue(itemValue);
60
87
  }
61
- }, [disabled, itemValue, onValueChange, setOpen]);
88
+ }, [disabled, itemValue, commitValue, setActiveValue]);
62
89
 
63
90
  const interactionState = useMemo(
64
91
  () => ({
@@ -85,6 +112,10 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
85
112
  ],
86
113
  );
87
114
 
115
+ if (!isItemVisible(itemValue)) {
116
+ return null;
117
+ }
118
+
88
119
  const Comp = asChild ? Slot : Pressable;
89
120
 
90
121
  return (
@@ -96,6 +127,7 @@ export const SelectItem = forwardRef<ComponentRef<typeof Pressable>, SelectItemP
96
127
  aria-selected={checked}
97
128
  aria-disabled={disabled}
98
129
  disabled={disabled}
130
+ {...WEB_OPTION_PROPS}
99
131
  {...props}
100
132
  {...mergeDataAttributes(
101
133
  props,
@@ -0,0 +1,43 @@
1
+ import { forwardRef, type ComponentRef } from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import { Slot } from '../slot';
4
+ import { useSelectContext } from './context';
5
+ import type { ISelectListboxProps } from './types';
6
+
7
+ export interface SelectListboxProps extends ISelectListboxProps {
8
+ asChild?: boolean;
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Wraps the options. With `Select.Search` mounted it is the `role="listbox"` inside the dialog
14
+ * popup; without it the popup itself is the listbox and this renders a plain container.
15
+ */
16
+ export const SelectListbox = forwardRef<ComponentRef<typeof View>, SelectListboxProps>(
17
+ ({ asChild, children, ...props }, ref) => {
18
+ const { hasSearch, listboxId, labelId, triggerId, accessibilityLabel, type } =
19
+ useSelectContext();
20
+
21
+ const listboxProps = hasSearch
22
+ ? {
23
+ role: 'listbox' as const,
24
+ id: listboxId,
25
+ 'aria-labelledby': labelId ?? (accessibilityLabel ? undefined : triggerId),
26
+ 'aria-label': labelId ? undefined : accessibilityLabel,
27
+ ...(Platform.OS === 'web'
28
+ ? { 'aria-multiselectable': type === 'multiple' || undefined }
29
+ : {}),
30
+ }
31
+ : {};
32
+
33
+ const Comp = asChild ? Slot : View;
34
+
35
+ return (
36
+ <Comp ref={ref} {...listboxProps} {...props}>
37
+ {children}
38
+ </Comp>
39
+ );
40
+ },
41
+ );
42
+
43
+ SelectListbox.displayName = 'SelectListbox';
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  forwardRef,
3
3
  useCallback,
4
+ useEffect,
4
5
  useId,
5
6
  useMemo,
6
7
  useRef,
@@ -12,19 +13,52 @@ import { useControllableState, useFormControl } from '@cdx-ui/utils';
12
13
  import { Slot } from '../slot';
13
14
  import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
14
15
  import { SelectProvider } from './context';
15
- import type { ISelectProps } from './types';
16
+ import { defaultSelectFilter } from './filter';
17
+ import type { ISelectProps, SelectFilter, SelectItemData, SelectType } from './types';
16
18
 
17
- export interface SelectRootProps extends ISelectProps, Omit<ViewProps, 'children'> {
19
+ export type SelectRootProps = ISelectProps &
20
+ Omit<ViewProps, 'children'> & {
21
+ asChild?: boolean;
22
+ className?: string;
23
+ };
24
+
25
+ /**
26
+ * Internal widening of the public discriminated union: the runtime accepts either value
27
+ * shape and reads `type` to decide branch behavior. The public API stays narrow.
28
+ */
29
+ type WidenedSelectRootProps = Omit<ViewProps, 'children'> & {
18
30
  asChild?: boolean;
19
31
  className?: string;
20
- }
32
+ children?: ISelectProps['children'];
33
+ items: SelectItemData[];
34
+ type?: SelectType;
35
+ value?: string | string[];
36
+ defaultValue?: string | string[];
37
+ onValueChange?: (value: string | string[]) => void;
38
+ isOpen?: boolean;
39
+ defaultOpen?: boolean;
40
+ onOpenChange?: (open: boolean) => void;
41
+ disabled?: boolean;
42
+ isRequired?: boolean;
43
+ isInvalid?: boolean;
44
+ isReadOnly?: boolean;
45
+ native?: boolean;
46
+ accessibilityLabel?: string;
47
+ inputValue?: string;
48
+ defaultInputValue?: string;
49
+ onInputChange?: (inputValue: string) => void;
50
+ filter?: SelectFilter;
51
+ };
52
+
53
+ const EMPTY_VALUES: string[] = [];
21
54
 
22
55
  export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>(
23
- (
24
- {
56
+ (rootProps, ref) => {
57
+ const {
25
58
  asChild,
26
59
  children,
27
60
  items,
61
+ type = 'single',
28
62
  value: valueProp,
29
63
  defaultValue,
30
64
  onValueChange,
@@ -37,10 +71,13 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
37
71
  isReadOnly,
38
72
  native = false,
39
73
  accessibilityLabel,
74
+ inputValue: inputValueProp,
75
+ defaultInputValue = '',
76
+ onInputChange,
77
+ filter = defaultSelectFilter,
40
78
  ...props
41
- },
42
- ref,
43
- ) => {
79
+ } = rootProps as WidenedSelectRootProps;
80
+
44
81
  const formControlProps = useFormControl({
45
82
  isDisabled: disabled,
46
83
  isRequired,
@@ -53,9 +90,11 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
53
90
  const isEffectivelyInvalid = !!formControlProps['aria-invalid'];
54
91
  const isEffectivelyReadOnly = !!formControlProps.readOnly;
55
92
 
56
- const [value, setValue] = useControllableState<string | undefined>({
93
+ const isMultiple = type === 'multiple';
94
+
95
+ const [value, setValue] = useControllableState<string | string[] | undefined>({
57
96
  prop: valueProp,
58
- defaultProp: defaultValue,
97
+ defaultProp: defaultValue ?? (isMultiple ? EMPTY_VALUES : undefined),
59
98
  onChange: (newValue) => {
60
99
  if (newValue !== undefined) {
61
100
  onValueChange?.(newValue);
@@ -63,6 +102,18 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
63
102
  },
64
103
  });
65
104
 
105
+ const selectedValues = useMemo<string[]>(() => {
106
+ if (isMultiple) {
107
+ return Array.isArray(value) ? value : EMPTY_VALUES;
108
+ }
109
+ return typeof value === 'string' ? [value] : EMPTY_VALUES;
110
+ }, [isMultiple, value]);
111
+
112
+ const isSelected = useCallback(
113
+ (itemValue: string) => selectedValues.includes(itemValue),
114
+ [selectedValues],
115
+ );
116
+
66
117
  const [open, setOpenState] = useControllableState<boolean>({
67
118
  prop: openProp,
68
119
  defaultProp: defaultOpen,
@@ -81,20 +132,115 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
81
132
 
82
133
  const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
83
134
 
135
+ const [inputValueState, setInputValueState] = useControllableState<string>({
136
+ prop: inputValueProp,
137
+ defaultProp: defaultInputValue,
138
+ onChange: onInputChange,
139
+ });
140
+ const inputValue = inputValueState ?? '';
141
+ const setInputValue = useCallback(
142
+ (next: string) => setInputValueState(next),
143
+ [setInputValueState],
144
+ );
145
+
146
+ const wasOpenRef = useRef(!!open);
147
+ useEffect(() => {
148
+ const wasOpen = wasOpenRef.current;
149
+ wasOpenRef.current = !!open;
150
+ if (wasOpen && !open) {
151
+ setInputValueState('');
152
+ }
153
+ }, [open, setInputValueState]);
154
+
155
+ const visibleItems = useMemo(
156
+ () => (inputValue === '' ? items : items.filter((item) => filter(item.label, inputValue))),
157
+ [items, inputValue, filter],
158
+ );
159
+
160
+ const visibleValues = useMemo(
161
+ () => new Set(visibleItems.map((item) => item.value)),
162
+ [visibleItems],
163
+ );
164
+ const isItemVisible = useCallback(
165
+ (itemValue: string) => inputValue === '' || visibleValues.has(itemValue),
166
+ [inputValue, visibleValues],
167
+ );
168
+
169
+ const [searchCount, setSearchCount] = useState(0);
170
+ const registerSearch = useCallback(() => {
171
+ setSearchCount((count) => count + 1);
172
+ return () => setSearchCount((count) => count - 1);
173
+ }, []);
174
+
175
+ const [emptyText, setEmptyText] = useState<string | undefined>(undefined);
176
+
177
+ const toggleValue = useCallback(
178
+ (itemValue: string) => {
179
+ setValue((prev) => {
180
+ const current = Array.isArray(prev) ? prev : EMPTY_VALUES;
181
+ return current.includes(itemValue)
182
+ ? current.filter((v) => v !== itemValue)
183
+ : [...current, itemValue];
184
+ });
185
+ },
186
+ [setValue],
187
+ );
188
+
189
+ const selectValueAndClose = useCallback(
190
+ (itemValue: string) => {
191
+ setValue(itemValue);
192
+ setOpen(false);
193
+ },
194
+ [setValue, setOpen],
195
+ );
196
+
197
+ const commitValue = isMultiple ? toggleValue : selectValueAndClose;
198
+
199
+ /**
200
+ * `Select.Item disabled` lives on the rendered child, not in `items`, so mounted items
201
+ * register here for keyboard navigation. A ref keeps registration from re-rendering the root.
202
+ */
203
+ const disabledItemsRef = useRef<Set<string>>(new Set());
204
+
205
+ const registerDisabledItem = useCallback((itemValue: string) => {
206
+ disabledItemsRef.current.add(itemValue);
207
+ return () => {
208
+ disabledItemsRef.current.delete(itemValue);
209
+ };
210
+ }, []);
211
+
212
+ const isItemDisabled = useCallback(
213
+ (itemValue: string) =>
214
+ isEffectivelyDisabled ||
215
+ disabledItemsRef.current.has(itemValue) ||
216
+ items.some((item) => item.value === itemValue && !!item.disabled),
217
+ [isEffectivelyDisabled, items],
218
+ );
219
+
84
220
  const triggerRef = useRef<ComponentRef<typeof View> | null>(null);
85
221
  const reactId = useId();
86
222
  /** Align with `useFormControl` default id (`${field.id}-input`) when inside `Form.Field`. */
87
223
  const triggerId = formControlProps.id ?? `select-trigger-${reactId}`;
88
224
  const contentId = `select-content-${reactId}`;
225
+ const listboxId = `${contentId}-listbox`;
89
226
  const ariaDescribedBy = formControlProps['aria-describedby'];
227
+ const labelId = formControlProps['aria-labelledby'];
228
+ const hasSearch = searchCount > 0;
90
229
 
91
230
  const contextValue = useMemo(
92
231
  () => ({
93
232
  open: open ?? false,
94
233
  items,
95
234
  setOpen,
235
+ type,
96
236
  value,
97
- onValueChange: setValue,
237
+ selectedValues,
238
+ isSelected,
239
+ toggleValue,
240
+ selectValueAndClose,
241
+ commitValue,
242
+ isItemDisabled,
243
+ registerDisabledItem,
98
244
  disabled: isEffectivelyDisabled,
99
245
  required: isEffectivelyRequired,
100
246
  invalid: isEffectivelyInvalid,
@@ -103,6 +249,16 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
103
249
  triggerRef,
104
250
  contentId,
105
251
  triggerId,
252
+ inputValue,
253
+ setInputValue,
254
+ visibleItems,
255
+ isItemVisible,
256
+ hasSearch,
257
+ registerSearch,
258
+ emptyText,
259
+ setEmptyText,
260
+ listboxId,
261
+ labelId,
106
262
  activeValue,
107
263
  setActiveValue,
108
264
  accessibilityLabel,
@@ -112,8 +268,15 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
112
268
  open,
113
269
  items,
114
270
  setOpen,
271
+ type,
115
272
  value,
116
- setValue,
273
+ selectedValues,
274
+ isSelected,
275
+ toggleValue,
276
+ selectValueAndClose,
277
+ commitValue,
278
+ isItemDisabled,
279
+ registerDisabledItem,
117
280
  isEffectivelyDisabled,
118
281
  isEffectivelyRequired,
119
282
  isEffectivelyInvalid,
@@ -121,6 +284,15 @@ export const SelectRoot = forwardRef<ComponentRef<typeof View>, SelectRootProps>
121
284
  native,
122
285
  contentId,
123
286
  triggerId,
287
+ inputValue,
288
+ setInputValue,
289
+ visibleItems,
290
+ isItemVisible,
291
+ hasSearch,
292
+ registerSearch,
293
+ emptyText,
294
+ listboxId,
295
+ labelId,
124
296
  activeValue,
125
297
  accessibilityLabel,
126
298
  ariaDescribedBy,
@@ -0,0 +1,98 @@
1
+ import { forwardRef, useLayoutEffect, type ReactNode } from 'react';
2
+ import { Platform, TextInput } from 'react-native';
3
+ import { composeEventHandlers } from '@cdx-ui/utils';
4
+ import { Slot } from '../slot';
5
+ import { useSelectContentContext, useSelectContext } from './context';
6
+ import { SelectSearchStatus } from './SelectSearchStatus';
7
+ import type { ISelectSearchProps } from './types';
8
+
9
+ export interface SelectSearchProps extends ISelectSearchProps {
10
+ /** Merge the search wiring onto the single child text input instead of rendering one. */
11
+ asChild?: boolean;
12
+ className?: string;
13
+ children?: ReactNode;
14
+ }
15
+
16
+ const defaultResultCountLabel = (count: number) =>
17
+ count === 1 ? '1 result' : `${String(count)} results`;
18
+
19
+ /**
20
+ * Search input for `Select.Content`. Its query filters the options through the root `filter`.
21
+ * Mounting it turns the popup into a dialog that contains this searchbox and the listbox.
22
+ *
23
+ * Web uses virtual focus: DOM focus stays here, arrow keys move the highlight, and
24
+ * `aria-activedescendant` points at it. Native renders the input without virtual focus.
25
+ */
26
+ export const SelectSearch = forwardRef<TextInput, SelectSearchProps>(
27
+ (
28
+ {
29
+ asChild,
30
+ children,
31
+ accessibilityLabel,
32
+ getResultCountLabel = defaultResultCountLabel,
33
+ onKeyPress,
34
+ ...props
35
+ },
36
+ ref,
37
+ ) => {
38
+ const {
39
+ inputValue,
40
+ setInputValue,
41
+ registerSearch,
42
+ listboxId,
43
+ contentId,
44
+ activeValue,
45
+ isItemVisible,
46
+ visibleItems,
47
+ emptyText,
48
+ } = useSelectContext();
49
+ const { handleKeyDown } = useSelectContentContext();
50
+
51
+ useLayoutEffect(() => registerSearch(), [registerSearch]);
52
+
53
+ const activeDescendant =
54
+ activeValue !== undefined && isItemVisible(activeValue)
55
+ ? `${contentId}-option-${activeValue}`
56
+ : undefined;
57
+
58
+ // RNW's TextInput stops keydown propagation, so the listbox keys are handled here.
59
+ const platformProps =
60
+ Platform.OS === 'web'
61
+ ? {
62
+ 'aria-controls': listboxId,
63
+ 'aria-autocomplete': 'list' as const,
64
+ 'aria-activedescendant': activeDescendant,
65
+ onKeyPress: composeEventHandlers(onKeyPress, handleKeyDown),
66
+ }
67
+ : { onKeyPress };
68
+
69
+ const count = visibleItems.length;
70
+ const statusMessage = count === 0 && emptyText ? emptyText : getResultCountLabel(count);
71
+
72
+ const Comp = asChild ? Slot : TextInput;
73
+
74
+ return (
75
+ <>
76
+ <Comp
77
+ ref={ref}
78
+ role="searchbox"
79
+ value={inputValue}
80
+ onChangeText={setInputValue}
81
+ autoCapitalize="none"
82
+ autoComplete="off"
83
+ autoCorrect={false}
84
+ spellCheck={false}
85
+ accessibilityLabel={accessibilityLabel}
86
+ aria-label={accessibilityLabel}
87
+ {...platformProps}
88
+ {...props}
89
+ >
90
+ {children}
91
+ </Comp>
92
+ <SelectSearchStatus message={statusMessage} />
93
+ </>
94
+ );
95
+ },
96
+ );
97
+
98
+ SelectSearch.displayName = 'SelectSearch';
@@ -0,0 +1,11 @@
1
+ export interface SelectSearchStatusProps {
2
+ message: string;
3
+ }
4
+
5
+ /**
6
+ * Web-only. On native, screen readers move through the rows directly, in the anchored popover
7
+ * or in `Select.BottomSheet`, instead of following an input-driven live region.
8
+ */
9
+ export function SelectSearchStatus(_props: SelectSearchStatusProps) {
10
+ return null;
11
+ }
@@ -0,0 +1,18 @@
1
+ import { VisuallyHidden } from '@react-aria/visually-hidden';
2
+ import type { SelectSearchStatusProps } from './SelectSearchStatus';
3
+
4
+ export type { SelectSearchStatusProps };
5
+
6
+ /**
7
+ * Polite live region for the search results. It stays mounted while the search is, so each
8
+ * change to the count (or to the empty-state text) is announced.
9
+ */
10
+ export function SelectSearchStatus({ message }: SelectSearchStatusProps) {
11
+ return (
12
+ <VisuallyHidden>
13
+ <div role="status" aria-live="polite">
14
+ {message}
15
+ </div>
16
+ </VisuallyHidden>
17
+ );
18
+ }
@@ -0,0 +1,54 @@
1
+ import { forwardRef, useCallback, type ComponentRef } from 'react';
2
+ import { View } from 'react-native';
3
+ import { composeEventHandlers } from '@cdx-ui/utils';
4
+ import { Slot } from '../slot';
5
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
6
+ import { SelectContentProvider, useSelectContext, type SelectContentContextValue } from './context';
7
+ import { getPopupRoleProps } from './popupRole';
8
+ import type { ISelectSheetContentProps } from './types';
9
+
10
+ export interface SelectSheetContentProps extends ISelectSheetContentProps {
11
+ asChild?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Native rows take screen-reader focus directly, so the sheet has no listbox keyboard model
17
+ * for `Select.Search` to drive.
18
+ */
19
+ const SHEET_CONTENT_CONTEXT: SelectContentContextValue = { handleKeyDown: () => undefined };
20
+
21
+ /**
22
+ * Body of a native sheet surface that presents the options in place of `SelectContent`. The
23
+ * styled layer owns presentation (open, swipe, backdrop, back) and renders this inside it.
24
+ * It carries the popup role and name, and closes on the VoiceOver escape gesture without
25
+ * changing the value.
26
+ */
27
+ export const SelectSheetContent = forwardRef<ComponentRef<typeof View>, SelectSheetContentProps>(
28
+ ({ asChild, children, onAccessibilityEscape, ...props }, ref) => {
29
+ const { setOpen, hasSearch, labelId, accessibilityLabel, triggerId, contentId } =
30
+ useSelectContext();
31
+
32
+ const dismiss = useCallback(() => setOpen(false), [setOpen]);
33
+ const roleProps = getPopupRoleProps({ hasSearch, labelId, accessibilityLabel, triggerId });
34
+
35
+ const Comp = asChild ? Slot : View;
36
+
37
+ return (
38
+ <SelectContentProvider value={SHEET_CONTENT_CONTEXT}>
39
+ <Comp
40
+ ref={ref}
41
+ id={contentId}
42
+ {...roleProps}
43
+ {...props}
44
+ {...mergeDataAttributes(props, dataAttributes({ state: 'open' }))}
45
+ onAccessibilityEscape={composeEventHandlers(onAccessibilityEscape, dismiss)}
46
+ >
47
+ {children}
48
+ </Comp>
49
+ </SelectContentProvider>
50
+ );
51
+ },
52
+ );
53
+
54
+ SelectSheetContent.displayName = 'SelectSheetContent';
@@ -10,3 +10,12 @@ export const [SelectProvider, useSelectContext] =
10
10
  */
11
11
  export const [SelectItemProvider, useSelectItemContext] =
12
12
  createContext<SelectItemInteractionState>('SelectItemContext');
13
+
14
+ export interface SelectContentContextValue {
15
+ /** Listbox keyboard handling, wired to the `Select.Search` input on web. */
16
+ handleKeyDown: (e: any) => void;
17
+ }
18
+
19
+ /** Internal: lets `Select.Search` drive the listbox from its input. */
20
+ export const [SelectContentProvider, useSelectContentContext] =
21
+ createContext<SelectContentContextValue>('SelectContentContext');
@@ -0,0 +1,12 @@
1
+ import type { SelectFilter } from './types';
2
+
3
+ const COMBINING_MARKS = /[̀-ͯ]/g;
4
+
5
+ function normalize(text: string): string {
6
+ const decomposed = typeof text.normalize === 'function' ? text.normalize('NFD') : text;
7
+ return decomposed.replace(COMBINING_MARKS, '').toLocaleLowerCase();
8
+ }
9
+
10
+ /** Case- and accent-insensitive `contains`: "cafe" matches "Café". */
11
+ export const defaultSelectFilter: SelectFilter = (textValue, inputValue) =>
12
+ normalize(textValue).includes(normalize(inputValue));