@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
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode, RefObject } from 'react';
2
- import type { PressableProps, ViewProps } from 'react-native';
2
+ import type { PressableProps, TextInputProps, ViewProps } from 'react-native';
3
3
  import type { EntryOrExitLayoutType } from 'react-native-reanimated';
4
4
  import type { OverlayPlacement } from '../overlay';
5
5
  import type { InteractionState } from '../types';
@@ -9,15 +9,21 @@ export interface SelectItemInteractionState extends InteractionState {
9
9
  checked: boolean;
10
10
  }
11
11
 
12
- export interface ISelectProps {
12
+ /** Selection mode for `Select`. */
13
+ export type SelectType = 'single' | 'multiple';
14
+
15
+ /**
16
+ * Decides whether an option stays visible for the current search query. Receives the option's
17
+ * `label` from `items` and the query.
18
+ */
19
+ export type SelectFilter = (textValue: string, inputValue: string) => boolean;
20
+
21
+ interface ISelectBaseProps {
13
22
  /**
14
23
  * Options used to resolve `Select.Value` labels and drive keyboard navigation.
15
24
  * Render matching `Select.Item` elements inside `Select.Content`.
16
25
  */
17
26
  items: SelectItemData[];
18
- value?: string;
19
- defaultValue?: string;
20
- onValueChange?: (value: string) => void;
21
27
  /** Controlled open state. Use `defaultOpen` for the uncontrolled variant. */
22
28
  isOpen?: boolean;
23
29
  defaultOpen?: boolean;
@@ -29,9 +35,58 @@ export interface ISelectProps {
29
35
  /** Prevents `Select.Content` from rendering. Reserved for native picker integration. */
30
36
  native?: boolean;
31
37
  accessibilityLabel?: string;
38
+ /** Controlled search query typed into `Select.Search`. */
39
+ inputValue?: string;
40
+ /** Uncontrolled initial search query. */
41
+ defaultInputValue?: string;
42
+ /**
43
+ * Called when the search query changes, including the reset to `''` when the popup closes.
44
+ * Pair with `inputValue` to replace `items` with server results.
45
+ */
46
+ onInputChange?: (inputValue: string) => void;
47
+ /**
48
+ * Decides which options stay visible for a non-empty query. Every option is visible while the
49
+ * query is empty.
50
+ * @default case- and accent-insensitive `contains`
51
+ */
52
+ filter?: SelectFilter;
32
53
  children?: ReactNode;
33
54
  }
34
55
 
56
+ export interface ISelectSingleProps extends ISelectBaseProps {
57
+ /**
58
+ * Selection mode. `single` selects one option and closes the popup.
59
+ * @default 'single'
60
+ */
61
+ type?: 'single';
62
+ /** Controlled selected value. */
63
+ value?: string;
64
+ /** Uncontrolled initial value. */
65
+ defaultValue?: string;
66
+ /** Called with the newly selected value. */
67
+ onValueChange?: (value: string) => void;
68
+ }
69
+
70
+ export interface ISelectMultipleProps extends ISelectBaseProps {
71
+ /**
72
+ * Selection mode. `multiple` toggles options on press and keeps the popup open.
73
+ * Each toggle commits immediately.
74
+ */
75
+ type: 'multiple';
76
+ /** Controlled selected values. An empty array means no selection. */
77
+ value?: string[];
78
+ /** Uncontrolled initial values. */
79
+ defaultValue?: string[];
80
+ /** Called with the full array of selected values after each toggle. */
81
+ onValueChange?: (value: string[]) => void;
82
+ }
83
+
84
+ /**
85
+ * Discriminated union: `type` narrows `value` / `defaultValue` / `onValueChange` to `string`
86
+ * (single, the default) or `string[]` (multiple).
87
+ */
88
+ export type ISelectProps = ISelectSingleProps | ISelectMultipleProps;
89
+
35
90
  export interface ISelectTriggerProps extends PressableProps {
36
91
  isHovered?: boolean;
37
92
  isActive?: boolean;
@@ -42,9 +97,23 @@ export interface ISelectTriggerProps extends PressableProps {
42
97
  'aria-describedby'?: string;
43
98
  }
44
99
 
100
+ /** State passed to a `Select.Value` function child. */
101
+ export interface SelectValueRenderProps {
102
+ /** `true` when nothing is selected. */
103
+ isPlaceholder: boolean;
104
+ /** Selected options, in `items` order. Values missing from `items` are omitted. */
105
+ selectedItems: SelectItemData[];
106
+ /** Labels of `selectedItems` joined with `', '`. Empty when nothing is selected. */
107
+ selectedText: string;
108
+ }
109
+
45
110
  export interface ISelectValueProps {
46
111
  placeholder?: string;
47
- children?: ReactNode;
112
+ /**
113
+ * Replaces the default display. A function child receives the selection state; return
114
+ * `null` or `undefined` to fall back to the default (placeholder or `selectedText`).
115
+ */
116
+ children?: ReactNode | ((state: SelectValueRenderProps) => ReactNode);
48
117
  }
49
118
 
50
119
  export interface ISelectContentProps extends ViewProps {
@@ -58,6 +127,38 @@ export interface ISelectContentProps extends ViewProps {
58
127
  enteringAnimation?: EntryOrExitLayoutType;
59
128
  /** Exit animation for the dropdown. Defaults to a 100ms fade. */
60
129
  exitingAnimation?: EntryOrExitLayoutType;
130
+ /**
131
+ * Accessible name of the visually hidden button that lets screen-reader users close the
132
+ * popup on web.
133
+ * @default 'Dismiss'
134
+ */
135
+ dismissAccessibilityLabel?: string;
136
+ children?: ReactNode;
137
+ }
138
+
139
+ export interface ISelectSheetContentProps extends ViewProps {
140
+ children?: ReactNode;
141
+ }
142
+
143
+ export interface ISelectSearchProps extends Omit<
144
+ TextInputProps,
145
+ 'value' | 'defaultValue' | 'onChangeText' | 'children'
146
+ > {
147
+ /** Accessible name of the search input. Recommended, because a placeholder is not a label. */
148
+ accessibilityLabel?: string;
149
+ /**
150
+ * Text the polite live region announces when the result count changes, such as
151
+ * `"3 results"` (the English default). When nothing matches, the `Select.Empty` text is
152
+ * announced instead.
153
+ */
154
+ getResultCountLabel?: (count: number) => string;
155
+ }
156
+
157
+ export interface ISelectListboxProps extends ViewProps {
158
+ children?: ReactNode;
159
+ }
160
+
161
+ export interface ISelectEmptyProps extends ViewProps {
61
162
  children?: ReactNode;
62
163
  }
63
164
 
@@ -78,7 +179,8 @@ export interface ISelectItemLabelProps {
78
179
  export interface SelectItemData {
79
180
  value: string;
80
181
  label: string;
81
- disabled?: boolean; // TODO: This isn't working
182
+ /** Prevents the option from being selected or highlighted by keyboard. */
183
+ disabled?: boolean;
82
184
  }
83
185
 
84
186
  export interface SelectItemCollectionContextValue {
@@ -91,8 +193,22 @@ export interface SelectContextValue {
91
193
  open: boolean;
92
194
  items: SelectItemData[];
93
195
  setOpen: (open: boolean) => void;
94
- value: string | undefined;
95
- onValueChange: (value: string) => void;
196
+ type: SelectType;
197
+ /** Raw selection: `string` in single mode, `string[]` in multiple mode. */
198
+ value: string | string[] | undefined;
199
+ /** Selection normalized to an array for both modes. */
200
+ selectedValues: string[];
201
+ isSelected: (value: string) => boolean;
202
+ /** Adds or removes `value` from the selection without closing. */
203
+ toggleValue: (value: string) => void;
204
+ /** Replaces the selection with `value` and closes the popup. */
205
+ selectValueAndClose: (value: string) => void;
206
+ /** Commits an option press: `toggleValue` in multiple mode, `selectValueAndClose` in single. */
207
+ commitValue: (value: string) => void;
208
+ /** Disabled via the root, `SelectItemData.disabled`, or a mounted `Select.Item disabled`. */
209
+ isItemDisabled: (value: string) => boolean;
210
+ /** Called by `Select.Item` so item-level `disabled` reaches keyboard navigation. */
211
+ registerDisabledItem: (value: string) => () => void;
96
212
  disabled: boolean;
97
213
  required: boolean;
98
214
  invalid: boolean;
@@ -101,6 +217,23 @@ export interface SelectContextValue {
101
217
  triggerRef: RefObject<any>;
102
218
  contentId: string;
103
219
  triggerId: string;
220
+ /** Current search query. Reset to `''` when the popup closes. */
221
+ inputValue: string;
222
+ setInputValue: (inputValue: string) => void;
223
+ /** `items` that pass `filter` for the current query, in `items` order. */
224
+ visibleItems: SelectItemData[];
225
+ isItemVisible: (value: string) => boolean;
226
+ /** `true` while a `Select.Search` is mounted: the popup becomes a dialog around the listbox. */
227
+ hasSearch: boolean;
228
+ /** Called by `Select.Search` on mount. Returns the unregister function. */
229
+ registerSearch: () => () => void;
230
+ /** Plain text of `Select.Empty`, announced when nothing matches. */
231
+ emptyText: string | undefined;
232
+ setEmptyText: (text: string | undefined) => void;
233
+ /** Id of the listbox inside the dialog when `hasSearch` is `true`. */
234
+ listboxId: string;
235
+ /** `Field.Label` id when the select is inside a `Field`. */
236
+ labelId?: string;
104
237
  /** Currently highlighted value during keyboard navigation */
105
238
  activeValue: string | undefined;
106
239
  setActiveValue: (value: string | undefined) => void;
@@ -1,54 +1,35 @@
1
1
  import type { RefObject } from 'react';
2
- import { useCallback, useEffect, useRef } from 'react';
2
+ import { useEffect, useRef } from 'react';
3
3
  import { Platform } from 'react-native';
4
- import type { SelectItemData } from './types';
5
4
 
6
5
  // TODO: Split into web and native versions?
7
6
 
8
7
  export interface UseContentFocusOptions {
9
8
  open: boolean;
10
9
  triggerRef: RefObject<any>;
11
- items: SelectItemData[];
12
- value: string | undefined;
13
10
  setActiveValue: (value: string | undefined) => void;
14
11
  }
15
12
 
16
13
  /**
17
14
  * Manages focus lifecycle for overlay content:
15
+ * - Clears `activeValue` when the overlay closes
18
16
  * - Restores focus to the trigger when the overlay closes (web)
19
- * - Initializes `activeValue` to the current value or first enabled item
20
17
  *
21
- * Initial focus on open is handled via a ref callback in SelectContent
22
- * to avoid timing issues with deferred portal rendering.
18
+ * Initial focus on open is handled by `FocusScope autoFocus` in SelectContent, and the
19
+ * highlight while open by `useQueryHighlight`.
23
20
  */
24
- export function useContentFocus({
25
- open,
26
- triggerRef,
27
- items,
28
- value,
29
- setActiveValue,
30
- }: UseContentFocusOptions) {
31
- const prevOpenRef = useRef(open);
32
-
33
- const getEnabledItems = useCallback(() => items.filter((item) => !item.disabled), [items]);
21
+ export function useContentFocus({ open, triggerRef, setActiveValue }: UseContentFocusOptions) {
22
+ const prevOpenRef = useRef(false);
34
23
 
35
24
  useEffect(() => {
36
- if (prevOpenRef.current && !open && Platform.OS === 'web') {
37
- triggerRef.current?.focus?.();
38
- }
25
+ const wasOpen = prevOpenRef.current;
39
26
  prevOpenRef.current = open;
40
- }, [open, triggerRef]);
41
27
 
42
- useEffect(() => {
43
- if (open) {
44
- const enabled = getEnabledItems();
45
- if (value && enabled.some((i) => i.value === value)) {
46
- setActiveValue(value);
47
- } else if (enabled.length > 0) {
48
- setActiveValue(enabled[0].value);
49
- }
50
- } else {
28
+ if (!open) {
51
29
  setActiveValue(undefined);
30
+ if (wasOpen && Platform.OS === 'web') {
31
+ triggerRef.current?.focus?.();
32
+ }
52
33
  }
53
- }, [open, getEnabledItems, setActiveValue, value]);
34
+ }, [open, setActiveValue, triggerRef]);
54
35
  }
@@ -1,30 +1,53 @@
1
1
  import { useCallback, useRef } from 'react';
2
- import type { SelectItemData } from './types';
2
+ import type { SelectItemData, SelectType } from './types';
3
3
 
4
4
  // TODO: Split into web and native versions?
5
5
 
6
6
  export interface UseListboxNavigationOptions {
7
+ /** Options to navigate, in display order. Pass the filtered list when a search is present. */
7
8
  items: SelectItemData[];
8
9
  activeValue: string | undefined;
9
10
  setActiveValue: (value: string | undefined) => void;
10
- onSelect: (value: string) => void;
11
+ type: SelectType;
12
+ /** Commit for `type="multiple"`: toggles the highlighted option and keeps the listbox open. */
13
+ onToggle: (value: string) => void;
14
+ /** Commit for `type="single"`: selects the highlighted option and closes the listbox. */
15
+ onSelectAndClose: (value: string) => void;
16
+ /** Excludes options from keyboard highlight and commit. */
17
+ isItemDisabled: (value: string) => boolean;
11
18
  onDismiss: () => void;
19
+ /**
20
+ * Keys arrive from the `Select.Search` input (virtual focus). Space types instead of
21
+ * committing, Home/End move the caret, and Enter is always consumed so the input neither
22
+ * blurs nor submits an enclosing form.
23
+ */
24
+ isSearch?: boolean;
12
25
  }
13
26
 
27
+ /** Keys the search input keeps for text editing: Space types, Home/End move the caret. */
28
+ const SEARCH_INPUT_KEYS = new Set([' ', 'Home', 'End']);
29
+
14
30
  export function useListboxNavigation({
15
31
  items,
16
32
  activeValue,
17
33
  setActiveValue,
18
- onSelect,
34
+ type,
35
+ onToggle,
36
+ onSelectAndClose,
37
+ isItemDisabled,
19
38
  onDismiss,
39
+ isSearch = false,
20
40
  }: UseListboxNavigationOptions) {
21
- const onSelectRef = useRef(onSelect);
22
- onSelectRef.current = onSelect;
41
+ const commitRef = useRef(onSelectAndClose);
42
+ commitRef.current = type === 'multiple' ? onToggle : onSelectAndClose;
23
43
 
24
44
  const onDismissRef = useRef(onDismiss);
25
45
  onDismissRef.current = onDismiss;
26
46
 
27
- const getEnabledItems = useCallback(() => items.filter((item) => !item.disabled), [items]);
47
+ const getEnabledItems = useCallback(
48
+ () => items.filter((item) => !isItemDisabled(item.value)),
49
+ [items, isItemDisabled],
50
+ );
28
51
 
29
52
  const handleKeyDown = useCallback(
30
53
  (e: any) => {
@@ -40,6 +63,15 @@ export function useListboxNavigation({
40
63
  return;
41
64
  }
42
65
 
66
+ if (isSearch && SEARCH_INPUT_KEYS.has(e.key)) {
67
+ return;
68
+ }
69
+
70
+ if (isSearch && e.key === 'Enter') {
71
+ // Keeps the input from blurring or submitting an enclosing form, even with no match.
72
+ e.preventDefault();
73
+ }
74
+
43
75
  const enabledItems = getEnabledItems();
44
76
  if (enabledItems.length === 0) {
45
77
  return;
@@ -74,14 +106,14 @@ export function useListboxNavigation({
74
106
  case 'Enter':
75
107
  case ' ': {
76
108
  e.preventDefault();
77
- if (activeValue) {
78
- onSelectRef.current(activeValue);
109
+ if (activeValue !== undefined && idx !== -1) {
110
+ commitRef.current(activeValue);
79
111
  }
80
112
  break;
81
113
  }
82
114
  }
83
115
  },
84
- [activeValue, getEnabledItems, setActiveValue],
116
+ [activeValue, getEnabledItems, isSearch, setActiveValue],
85
117
  );
86
118
 
87
119
  return { handleKeyDown, getEnabledItems };
@@ -0,0 +1,61 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { SelectItemData } from './types';
3
+
4
+ export interface UseQueryHighlightOptions {
5
+ open: boolean;
6
+ inputValue: string;
7
+ visibleItems: SelectItemData[];
8
+ selectedValues: string[];
9
+ isItemDisabled: (value: string) => boolean;
10
+ activeValue: string | undefined;
11
+ setActiveValue: (value: string | undefined) => void;
12
+ }
13
+
14
+ /**
15
+ * Owns the highlight while open:
16
+ * - Unset (on open, or once async `items` arrive): the first enabled selected item, or the
17
+ * first enabled item.
18
+ * - Query change: the first enabled match.
19
+ * - Hidden by the filter or a swapped `items` list: the first enabled item.
20
+ *
21
+ * Otherwise the highlight stays put, so a multiple-mode toggle does not move it.
22
+ */
23
+ export function useQueryHighlight({
24
+ open,
25
+ inputValue,
26
+ visibleItems,
27
+ selectedValues,
28
+ isItemDisabled,
29
+ activeValue,
30
+ setActiveValue,
31
+ }: UseQueryHighlightOptions) {
32
+ const prevInputValueRef = useRef(inputValue);
33
+ const selectedRef = useRef(selectedValues);
34
+ selectedRef.current = selectedValues;
35
+
36
+ useEffect(() => {
37
+ const queryChanged = prevInputValueRef.current !== inputValue;
38
+ prevInputValueRef.current = inputValue;
39
+
40
+ if (!open) {
41
+ return;
42
+ }
43
+
44
+ const enabled = visibleItems.filter((item) => !isItemDisabled(item.value));
45
+
46
+ if (activeValue === undefined) {
47
+ const selected = selectedRef.current;
48
+ const initial = enabled.find((item) => selected.includes(item.value)) ?? enabled.at(0);
49
+ if (initial) {
50
+ setActiveValue(initial.value);
51
+ }
52
+ return;
53
+ }
54
+
55
+ const activeHidden = !enabled.some((item) => item.value === activeValue);
56
+
57
+ if (queryChanged || activeHidden) {
58
+ setActiveValue(enabled.at(0)?.value);
59
+ }
60
+ }, [open, inputValue, visibleItems, isItemDisabled, activeValue, setActiveValue]);
61
+ }
@@ -142,6 +142,7 @@ export function useSelectTrigger(
142
142
  contentId,
143
143
  triggerRef,
144
144
  activeValue,
145
+ hasSearch,
145
146
  accessibilityLabel,
146
147
  ariaDescribedBy: ariaDescribedByFromField,
147
148
  } = useSelectContext();
@@ -278,10 +279,13 @@ export function useSelectTrigger(
278
279
  const ariaProps: SelectTriggerAriaProps = {
279
280
  role: 'combobox',
280
281
  'aria-label': accessibilityLabel,
282
+ // Kept as `listbox` with search too (RAC's choice), pending the a11y spike (DEVEX-8289).
281
283
  'aria-haspopup': 'listbox',
282
284
  'aria-expanded': open,
283
285
  'aria-controls': contentId,
284
- 'aria-activedescendant': open && activeValue ? `${contentId}-option-${activeValue}` : undefined,
286
+ // With search, the input owns the active descendant.
287
+ 'aria-activedescendant':
288
+ open && activeValue && !hasSearch ? `${contentId}-option-${activeValue}` : undefined,
285
289
  'aria-required': contextRequired || undefined,
286
290
  'aria-invalid': contextInvalid || undefined,
287
291
  'aria-readonly': contextReadOnly || undefined,
@@ -78,6 +78,7 @@ export function useSelectTrigger(
78
78
  contentId,
79
79
  triggerRef,
80
80
  activeValue,
81
+ hasSearch,
81
82
  accessibilityLabel,
82
83
  ariaDescribedBy: ariaDescribedByFromField,
83
84
  } = useSelectContext();
@@ -207,10 +208,13 @@ export function useSelectTrigger(
207
208
  const ariaProps: SelectTriggerAriaProps = {
208
209
  role: 'combobox',
209
210
  'aria-label': accessibilityLabel,
211
+ // Kept as `listbox` with search too (RAC's choice), pending the a11y spike (DEVEX-8289).
210
212
  'aria-haspopup': 'listbox',
211
213
  'aria-expanded': open,
212
214
  'aria-controls': contentId,
213
- 'aria-activedescendant': open && activeValue ? `${contentId}-option-${activeValue}` : undefined,
215
+ // With search, the input owns the active descendant.
216
+ 'aria-activedescendant':
217
+ open && activeValue && !hasSearch ? `${contentId}-option-${activeValue}` : undefined,
214
218
  'aria-required': contextRequired || undefined,
215
219
  'aria-invalid': contextInvalid || undefined,
216
220
  'aria-readonly': contextReadOnly || undefined,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Splits the props a split root's container keeps (`className`, data attributes) from the ones
3
+ * its press region receives.
4
+ */
5
+ export function splitContainerProps(props: Record<string, unknown>) {
6
+ const container: Record<string, unknown> = {};
7
+ const region: Record<string, unknown> = {};
8
+ for (const key in props) {
9
+ if (key === 'className' || key === 'dataSet' || key.startsWith('data-')) {
10
+ container[key] = props[key];
11
+ } else {
12
+ region[key] = props[key];
13
+ }
14
+ }
15
+ return { container, region };
16
+ }