@cdx-ui/primitives 0.3.2 → 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 (188) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/insight/InsightRoot.js +159 -0
  4. package/lib/commonjs/insight/InsightRoot.js.map +1 -0
  5. package/lib/commonjs/insight/context.js +11 -0
  6. package/lib/commonjs/insight/context.js.map +1 -0
  7. package/lib/commonjs/insight/index.js +26 -0
  8. package/lib/commonjs/insight/index.js.map +1 -0
  9. package/lib/commonjs/insight/types.js +6 -0
  10. package/lib/commonjs/insight/types.js.map +1 -0
  11. package/lib/commonjs/list-item/ListItemRoot.js +2 -18
  12. package/lib/commonjs/list-item/ListItemRoot.js.map +1 -1
  13. package/lib/commonjs/select/SelectContent.js +89 -45
  14. package/lib/commonjs/select/SelectContent.js.map +1 -1
  15. package/lib/commonjs/select/SelectDismissButton.js +14 -0
  16. package/lib/commonjs/select/SelectDismissButton.js.map +1 -0
  17. package/lib/commonjs/select/SelectDismissButton.web.js +28 -0
  18. package/lib/commonjs/select/SelectDismissButton.web.js.map +1 -0
  19. package/lib/commonjs/select/SelectEmpty.js +53 -0
  20. package/lib/commonjs/select/SelectEmpty.js.map +1 -0
  21. package/lib/commonjs/select/SelectItem.js +30 -9
  22. package/lib/commonjs/select/SelectItem.js.map +1 -1
  23. package/lib/commonjs/select/SelectListbox.js +47 -0
  24. package/lib/commonjs/select/SelectListbox.js.map +1 -0
  25. package/lib/commonjs/select/SelectRoot.js +110 -21
  26. package/lib/commonjs/select/SelectRoot.js.map +1 -1
  27. package/lib/commonjs/select/SelectSearch.js +81 -0
  28. package/lib/commonjs/select/SelectSearch.js.map +1 -0
  29. package/lib/commonjs/select/SelectSearchStatus.js +14 -0
  30. package/lib/commonjs/select/SelectSearchStatus.js.map +1 -0
  31. package/lib/commonjs/select/SelectSearchStatus.web.js +24 -0
  32. package/lib/commonjs/select/SelectSearchStatus.web.js.map +1 -0
  33. package/lib/commonjs/select/SelectSheetContent.js +67 -0
  34. package/lib/commonjs/select/SelectSheetContent.js.map +1 -0
  35. package/lib/commonjs/select/context.js +5 -1
  36. package/lib/commonjs/select/context.js.map +1 -1
  37. package/lib/commonjs/select/filter.js +16 -0
  38. package/lib/commonjs/select/filter.js.map +1 -0
  39. package/lib/commonjs/select/index.js +34 -0
  40. package/lib/commonjs/select/index.js.map +1 -1
  41. package/lib/commonjs/select/popupRole.js +27 -0
  42. package/lib/commonjs/select/popupRole.js.map +1 -0
  43. package/lib/commonjs/select/useContentFocus.js +10 -21
  44. package/lib/commonjs/select/useContentFocus.js.map +1 -1
  45. package/lib/commonjs/select/useListboxNavigation.js +21 -8
  46. package/lib/commonjs/select/useListboxNavigation.js.map +1 -1
  47. package/lib/commonjs/select/useQueryHighlight.js +50 -0
  48. package/lib/commonjs/select/useQueryHighlight.js.map +1 -0
  49. package/lib/commonjs/select/useSelectTrigger.js +4 -1
  50. package/lib/commonjs/select/useSelectTrigger.js.map +1 -1
  51. package/lib/commonjs/select/useSelectTrigger.web.js +4 -1
  52. package/lib/commonjs/select/useSelectTrigger.web.js.map +1 -1
  53. package/lib/commonjs/utils/splitContainerProps.js +26 -0
  54. package/lib/commonjs/utils/splitContainerProps.js.map +1 -0
  55. package/lib/module/index.js +1 -0
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/insight/InsightRoot.js +154 -0
  58. package/lib/module/insight/InsightRoot.js.map +1 -0
  59. package/lib/module/insight/context.js +5 -0
  60. package/lib/module/insight/context.js.map +1 -0
  61. package/lib/module/insight/index.js +5 -0
  62. package/lib/module/insight/index.js.map +1 -0
  63. package/lib/module/insight/types.js +4 -0
  64. package/lib/module/insight/types.js.map +1 -0
  65. package/lib/module/list-item/ListItemRoot.js +1 -17
  66. package/lib/module/list-item/ListItemRoot.js.map +1 -1
  67. package/lib/module/select/SelectContent.js +94 -48
  68. package/lib/module/select/SelectContent.js.map +1 -1
  69. package/lib/module/select/SelectDismissButton.js +10 -0
  70. package/lib/module/select/SelectDismissButton.js.map +1 -0
  71. package/lib/module/select/SelectDismissButton.web.js +24 -0
  72. package/lib/module/select/SelectDismissButton.web.js.map +1 -0
  73. package/lib/module/select/SelectEmpty.js +49 -0
  74. package/lib/module/select/SelectEmpty.js.map +1 -0
  75. package/lib/module/select/SelectItem.js +32 -11
  76. package/lib/module/select/SelectItem.js.map +1 -1
  77. package/lib/module/select/SelectListbox.js +43 -0
  78. package/lib/module/select/SelectListbox.js.map +1 -0
  79. package/lib/module/select/SelectRoot.js +111 -22
  80. package/lib/module/select/SelectRoot.js.map +1 -1
  81. package/lib/module/select/SelectSearch.js +77 -0
  82. package/lib/module/select/SelectSearch.js.map +1 -0
  83. package/lib/module/select/SelectSearchStatus.js +10 -0
  84. package/lib/module/select/SelectSearchStatus.js.map +1 -0
  85. package/lib/module/select/SelectSearchStatus.web.js +20 -0
  86. package/lib/module/select/SelectSearchStatus.web.js.map +1 -0
  87. package/lib/module/select/SelectSheetContent.js +63 -0
  88. package/lib/module/select/SelectSheetContent.js.map +1 -0
  89. package/lib/module/select/context.js +2 -0
  90. package/lib/module/select/context.js.map +1 -1
  91. package/lib/module/select/filter.js +11 -0
  92. package/lib/module/select/filter.js.map +1 -0
  93. package/lib/module/select/index.js +5 -1
  94. package/lib/module/select/index.js.map +1 -1
  95. package/lib/module/select/popupRole.js +23 -0
  96. package/lib/module/select/popupRole.js.map +1 -0
  97. package/lib/module/select/useContentFocus.js +11 -22
  98. package/lib/module/select/useContentFocus.js.map +1 -1
  99. package/lib/module/select/useListboxNavigation.js +21 -8
  100. package/lib/module/select/useListboxNavigation.js.map +1 -1
  101. package/lib/module/select/useQueryHighlight.js +46 -0
  102. package/lib/module/select/useQueryHighlight.js.map +1 -0
  103. package/lib/module/select/useSelectTrigger.js +4 -1
  104. package/lib/module/select/useSelectTrigger.js.map +1 -1
  105. package/lib/module/select/useSelectTrigger.web.js +4 -1
  106. package/lib/module/select/useSelectTrigger.web.js.map +1 -1
  107. package/lib/module/utils/splitContainerProps.js +22 -0
  108. package/lib/module/utils/splitContainerProps.js.map +1 -0
  109. package/lib/typescript/index.d.ts +1 -0
  110. package/lib/typescript/index.d.ts.map +1 -1
  111. package/lib/typescript/insight/InsightRoot.d.ts +6 -0
  112. package/lib/typescript/insight/InsightRoot.d.ts.map +1 -0
  113. package/lib/typescript/insight/context.d.ts +6 -0
  114. package/lib/typescript/insight/context.d.ts.map +1 -0
  115. package/lib/typescript/insight/index.d.ts +4 -0
  116. package/lib/typescript/insight/index.d.ts.map +1 -0
  117. package/lib/typescript/insight/types.d.ts +44 -0
  118. package/lib/typescript/insight/types.d.ts.map +1 -0
  119. package/lib/typescript/list-item/ListItemRoot.d.ts.map +1 -1
  120. package/lib/typescript/select/SelectContent.d.ts.map +1 -1
  121. package/lib/typescript/select/SelectDismissButton.d.ts +10 -0
  122. package/lib/typescript/select/SelectDismissButton.d.ts.map +1 -0
  123. package/lib/typescript/select/SelectDismissButton.web.d.ts +9 -0
  124. package/lib/typescript/select/SelectDismissButton.web.d.ts.map +1 -0
  125. package/lib/typescript/select/SelectEmpty.d.ts +12 -0
  126. package/lib/typescript/select/SelectEmpty.d.ts.map +1 -0
  127. package/lib/typescript/select/SelectItem.d.ts.map +1 -1
  128. package/lib/typescript/select/SelectListbox.d.ts +12 -0
  129. package/lib/typescript/select/SelectListbox.d.ts.map +1 -0
  130. package/lib/typescript/select/SelectRoot.d.ts +2 -2
  131. package/lib/typescript/select/SelectRoot.d.ts.map +1 -1
  132. package/lib/typescript/select/SelectSearch.d.ts +18 -0
  133. package/lib/typescript/select/SelectSearch.d.ts.map +1 -0
  134. package/lib/typescript/select/SelectSearchStatus.d.ts +9 -0
  135. package/lib/typescript/select/SelectSearchStatus.d.ts.map +1 -0
  136. package/lib/typescript/select/SelectSearchStatus.web.d.ts +8 -0
  137. package/lib/typescript/select/SelectSearchStatus.web.d.ts.map +1 -0
  138. package/lib/typescript/select/SelectSheetContent.d.ts +14 -0
  139. package/lib/typescript/select/SelectSheetContent.d.ts.map +1 -0
  140. package/lib/typescript/select/context.d.ts +9 -0
  141. package/lib/typescript/select/context.d.ts.map +1 -1
  142. package/lib/typescript/select/filter.d.ts +4 -0
  143. package/lib/typescript/select/filter.d.ts.map +1 -0
  144. package/lib/typescript/select/index.d.ts +6 -2
  145. package/lib/typescript/select/index.d.ts.map +1 -1
  146. package/lib/typescript/select/popupRole.d.ts +18 -0
  147. package/lib/typescript/select/popupRole.d.ts.map +1 -0
  148. package/lib/typescript/select/types.d.ts +129 -8
  149. package/lib/typescript/select/types.d.ts.map +1 -1
  150. package/lib/typescript/select/useContentFocus.d.ts +4 -7
  151. package/lib/typescript/select/useContentFocus.d.ts.map +1 -1
  152. package/lib/typescript/select/useListboxNavigation.d.ts +16 -3
  153. package/lib/typescript/select/useListboxNavigation.d.ts.map +1 -1
  154. package/lib/typescript/select/useQueryHighlight.d.ts +21 -0
  155. package/lib/typescript/select/useQueryHighlight.d.ts.map +1 -0
  156. package/lib/typescript/select/useSelectTrigger.d.ts.map +1 -1
  157. package/lib/typescript/select/useSelectTrigger.web.d.ts.map +1 -1
  158. package/lib/typescript/utils/splitContainerProps.d.ts +9 -0
  159. package/lib/typescript/utils/splitContainerProps.d.ts.map +1 -0
  160. package/package.json +3 -3
  161. package/src/index.ts +1 -0
  162. package/src/insight/InsightRoot.tsx +162 -0
  163. package/src/insight/context.tsx +5 -0
  164. package/src/insight/index.ts +11 -0
  165. package/src/insight/types.ts +51 -0
  166. package/src/list-item/ListItemRoot.tsx +1 -14
  167. package/src/select/SelectContent.tsx +89 -54
  168. package/src/select/SelectDismissButton.tsx +14 -0
  169. package/src/select/SelectDismissButton.web.tsx +26 -0
  170. package/src/select/SelectEmpty.tsx +61 -0
  171. package/src/select/SelectItem.tsx +42 -10
  172. package/src/select/SelectListbox.tsx +43 -0
  173. package/src/select/SelectRoot.tsx +184 -12
  174. package/src/select/SelectSearch.tsx +98 -0
  175. package/src/select/SelectSearchStatus.tsx +11 -0
  176. package/src/select/SelectSearchStatus.web.tsx +18 -0
  177. package/src/select/SelectSheetContent.tsx +54 -0
  178. package/src/select/context.tsx +9 -0
  179. package/src/select/filter.ts +12 -0
  180. package/src/select/index.ts +14 -1
  181. package/src/select/popupRole.ts +26 -0
  182. package/src/select/types.ts +142 -9
  183. package/src/select/useContentFocus.ts +12 -31
  184. package/src/select/useListboxNavigation.ts +41 -9
  185. package/src/select/useQueryHighlight.ts +61 -0
  186. package/src/select/useSelectTrigger.ts +5 -1
  187. package/src/select/useSelectTrigger.web.ts +5 -1
  188. package/src/utils/splitContainerProps.ts +16 -0
@@ -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));
@@ -1,17 +1,30 @@
1
1
  export type {
2
2
  ISelectContentProps,
3
+ ISelectEmptyProps,
3
4
  ISelectItemLabelProps,
4
5
  ISelectItemProps,
6
+ ISelectListboxProps,
7
+ ISelectMultipleProps,
5
8
  ISelectProps,
9
+ ISelectSearchProps,
10
+ ISelectSheetContentProps,
11
+ ISelectSingleProps,
6
12
  ISelectTriggerProps,
7
13
  ISelectValueProps,
8
14
  SelectContextValue,
15
+ SelectFilter,
9
16
  SelectItemData,
10
17
  SelectItemInteractionState,
18
+ SelectType,
19
+ SelectValueRenderProps,
11
20
  } from './types';
12
21
 
13
- export { useSelectContext, useSelectItemContext } from './context';
22
+ export { SelectProvider, useSelectContext, useSelectItemContext } from './context';
14
23
  export { SelectRoot, type SelectRootProps } from './SelectRoot';
15
24
  export { SelectTrigger, type SelectTriggerProps } from './SelectTrigger';
16
25
  export { SelectContent, type SelectContentProps } from './SelectContent';
26
+ export { SelectSheetContent, type SelectSheetContentProps } from './SelectSheetContent';
27
+ export { SelectSearch, type SelectSearchProps } from './SelectSearch';
28
+ export { SelectListbox, type SelectListboxProps } from './SelectListbox';
29
+ export { SelectEmpty, type SelectEmptyProps } from './SelectEmpty';
17
30
  export { SelectItem, type SelectItemProps } from './SelectItem';
@@ -0,0 +1,26 @@
1
+ import type { SelectContextValue } from './types';
2
+
3
+ type PopupRoleSource = Pick<
4
+ SelectContextValue,
5
+ 'hasSearch' | 'labelId' | 'accessibilityLabel' | 'triggerId'
6
+ >;
7
+
8
+ /**
9
+ * Role and name of the popup surface. With `Select.Search` the popup is a dialog holding a
10
+ * searchbox and a listbox (a listbox may only own options), named by the field label, then
11
+ * `accessibilityLabel`. Without it the popup is the listbox.
12
+ */
13
+ export function getPopupRoleProps({
14
+ hasSearch,
15
+ labelId,
16
+ accessibilityLabel,
17
+ triggerId,
18
+ }: PopupRoleSource) {
19
+ return hasSearch
20
+ ? {
21
+ role: 'dialog' as const,
22
+ 'aria-labelledby': labelId ?? (accessibilityLabel ? undefined : triggerId),
23
+ 'aria-label': labelId ? undefined : accessibilityLabel,
24
+ }
25
+ : { role: 'listbox' as const, 'aria-labelledby': triggerId };
26
+ }