@primitiv-ui/react 0.1.29 → 0.1.30

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 (213) hide show
  1. package/README.md +15 -2
  2. package/package.json +1 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
  4. package/src/Accordion/Accordion.tsx +7 -7
  5. package/src/Accordion/README.md +2 -2
  6. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
  7. package/src/Breadcrumb/Breadcrumb.tsx +7 -7
  8. package/src/Breadcrumb/README.md +2 -2
  9. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
  10. package/src/Breadcrumb/types.ts +1 -1
  11. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  12. package/src/Carousel/Carousel.tsx +6 -6
  13. package/src/Carousel/README.md +22 -22
  14. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
  15. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
  16. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
  17. package/src/Carousel/loopEngine.ts +1 -1
  18. package/src/Carousel/types.ts +1 -1
  19. package/src/Checkbox/Checkbox.tsx +2 -2
  20. package/src/CheckboxCard/CheckboxCard.tsx +1 -1
  21. package/src/CheckboxCard/README.md +1 -1
  22. package/src/Collapsible/README.md +2 -2
  23. package/src/Combobox/Combobox.tsx +508 -0
  24. package/src/Combobox/ComboboxContext.ts +23 -0
  25. package/src/Combobox/README.md +170 -0
  26. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  27. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  28. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  29. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  30. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  31. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  32. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  33. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  34. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  35. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  36. package/src/Combobox/hooks/index.ts +1 -0
  37. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  38. package/src/Combobox/index.ts +3 -0
  39. package/src/Combobox/types.ts +133 -0
  40. package/src/ContextMenu/ContextMenu.tsx +3 -3
  41. package/src/ContextMenu/README.md +1 -1
  42. package/src/DirectionProvider/DirectionProvider.tsx +5 -5
  43. package/src/DirectionProvider/README.md +4 -4
  44. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  45. package/src/Drawer/Drawer.tsx +5 -5
  46. package/src/Drawer/README.md +2 -2
  47. package/src/Dropdown/Dropdown.tsx +2 -2
  48. package/src/Dropdown/README.md +7 -5
  49. package/src/EmptyState/README.md +2 -2
  50. package/src/Field/README.md +1 -1
  51. package/src/Field/hooks/useFieldProps.ts +1 -1
  52. package/src/Fieldset/Fieldset.tsx +3 -3
  53. package/src/Fieldset/README.md +2 -2
  54. package/src/Input/Input.tsx +1 -1
  55. package/src/Input/README.md +2 -2
  56. package/src/Listbox/Listbox.tsx +475 -0
  57. package/src/Listbox/ListboxContext.ts +48 -0
  58. package/src/Listbox/README.md +248 -0
  59. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  60. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  61. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  62. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  63. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  64. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  65. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  66. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  67. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  68. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  69. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  70. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  71. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  72. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  73. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  74. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  75. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  76. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  77. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  78. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  79. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  80. package/src/Listbox/constants.ts +2 -0
  81. package/src/Listbox/hooks/index.ts +1 -0
  82. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  83. package/src/Listbox/index.ts +3 -0
  84. package/src/Listbox/types.ts +238 -0
  85. package/src/MillerColumns/MillerColumns.tsx +104 -30
  86. package/src/MillerColumns/README.md +90 -20
  87. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  88. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  89. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  90. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  91. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  92. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  93. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  94. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  95. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  96. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  97. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  98. package/src/MillerColumns/constants.ts +2 -0
  99. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  100. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  101. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  102. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  103. package/src/MillerColumns/index.ts +4 -0
  104. package/src/MillerColumns/types.ts +56 -6
  105. package/src/MillerColumns/utils.ts +69 -3
  106. package/src/Modal/Modal.tsx +4 -4
  107. package/src/Modal/README.md +5 -5
  108. package/src/Modal/types.ts +1 -1
  109. package/src/NavigationMenu/NavigationMenu.tsx +6 -6
  110. package/src/NavigationMenu/README.md +3 -3
  111. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
  112. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
  113. package/src/Popover/Popover.tsx +2 -2
  114. package/src/Popover/README.md +1 -1
  115. package/src/Portal/Portal.tsx +2 -2
  116. package/src/Portal/README.md +2 -2
  117. package/src/Progress/README.md +1 -1
  118. package/src/Radio/Radio.tsx +1 -1
  119. package/src/RadioGroup/README.md +2 -2
  120. package/src/RadioGroup/RadioGroup.tsx +1 -1
  121. package/src/SegmentedControl/README.md +2 -2
  122. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  123. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  124. package/src/Select/README.md +2 -2
  125. package/src/Select/Select.tsx +8 -8
  126. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  127. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  128. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  129. package/src/Select/types.ts +2 -2
  130. package/src/SkipNav/README.md +3 -3
  131. package/src/SkipNav/SkipNav.tsx +5 -5
  132. package/src/Slider/README.md +37 -7
  133. package/src/Slider/Slider.tsx +9 -3
  134. package/src/Slot/Slot.tsx +2 -2
  135. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  136. package/src/Slot/types.ts +1 -1
  137. package/src/SplitButton/README.md +225 -0
  138. package/src/SplitButton/SplitButton.tsx +457 -0
  139. package/src/SplitButton/SplitButtonContext.ts +26 -0
  140. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  141. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  142. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  143. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  144. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  145. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  146. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  147. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  148. package/src/SplitButton/index.ts +2 -0
  149. package/src/SplitButton/types.ts +186 -0
  150. package/src/Switch/README.md +1 -1
  151. package/src/Switch/Switch.tsx +2 -2
  152. package/src/Switch/types.ts +1 -1
  153. package/src/Table/README.md +110 -8
  154. package/src/Table/Table.tsx +206 -10
  155. package/src/Table/TableExpandableContext.ts +7 -0
  156. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  157. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  158. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  159. package/src/Table/types.ts +125 -0
  160. package/src/Tabs/README.md +4 -4
  161. package/src/Tabs/Tabs.tsx +5 -4
  162. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  163. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  164. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  165. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  166. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  167. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  168. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  169. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  170. package/src/ToggleGroup/README.md +4 -4
  171. package/src/Tooltip/README.md +7 -7
  172. package/src/Tooltip/Tooltip.tsx +2 -2
  173. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  174. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  175. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  176. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  177. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  178. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  179. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  180. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  181. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  182. package/src/Tree/README.md +69 -5
  183. package/src/Tree/Tree.tsx +1 -1
  184. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  185. package/src/Tree/hooks/useTreePath.ts +33 -1
  186. package/src/Tree/index.ts +6 -1
  187. package/src/Tree/utils.ts +46 -4
  188. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  189. package/src/hooks/index.ts +1 -0
  190. package/src/hooks/useTypeahead.ts +95 -0
  191. package/src/index.ts +6 -0
  192. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  193. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  194. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  196. package/src/useLocalStorage/index.ts +1 -0
  197. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  198. package/src/useMediaQuery/README.md +66 -0
  199. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  200. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  201. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  202. package/src/useMediaQuery/index.ts +1 -0
  203. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  204. package/src/usePagination/README.md +170 -0
  205. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  206. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  207. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  208. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  209. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  210. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  211. package/src/usePagination/index.ts +1 -0
  212. package/src/usePagination/usePagination.ts +292 -0
  213. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -0,0 +1,480 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { Listbox } from "../Listbox";
7
+
8
+ import { fruits } from "./Listbox.fixtures";
9
+
10
+ const cursorIsOn = (name: string) =>
11
+ expect(screen.getByRole("listbox")).toHaveAttribute(
12
+ "aria-activedescendant",
13
+ screen.getByRole("option", { name }).id,
14
+ );
15
+
16
+ const selectionIs = (...names: string[]) =>
17
+ expect(
18
+ screen
19
+ .getAllByRole("option")
20
+ .filter((o) => o.getAttribute("aria-selected") === "true")
21
+ .map((o) => o.textContent),
22
+ ).toEqual(names);
23
+
24
+ /**
25
+ * Cases that exist to discriminate behaviour the broader suite happens to
26
+ * agree on by coincidence — each one fails against a specific mutant that
27
+ * every other test survives.
28
+ */
29
+ describe("Listbox selection identity", () => {
30
+ // A raw string selection would make `includes` a substring test, so "app"
31
+ // would light up whenever "apple" is selected.
32
+ it("compares option values whole, not as substrings", () => {
33
+ render(
34
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
35
+ <Listbox.Option value="app">App</Listbox.Option>
36
+ <Listbox.Option value="apple">Apple</Listbox.Option>
37
+ </Listbox.Root>,
38
+ );
39
+
40
+ expect(screen.getByRole("option", { name: "App" })).toHaveAttribute(
41
+ "aria-selected",
42
+ "false",
43
+ );
44
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
45
+ "aria-selected",
46
+ "true",
47
+ );
48
+ });
49
+
50
+ it("keeps the other selections when one is toggled off", async () => {
51
+ const user = userEvent.setup();
52
+ const onValueChange = vi.fn();
53
+ render(
54
+ <Listbox.Root
55
+ type="multiple"
56
+ defaultValue={["apple", "banana", "cherry"]}
57
+ onValueChange={onValueChange}
58
+ aria-label="Fruits"
59
+ >
60
+ {fruits.map((f) => (
61
+ <Listbox.Option key={f.value} value={f.value}>
62
+ {f.label}
63
+ </Listbox.Option>
64
+ ))}
65
+ </Listbox.Root>,
66
+ );
67
+
68
+ await user.click(screen.getByRole("option", { name: "Banana" }));
69
+
70
+ selectionIs("Apple", "Cherry");
71
+ expect(onValueChange).toHaveBeenCalledWith(["apple", "cherry"]);
72
+ });
73
+
74
+ it("selects without an onValueChange listener", async () => {
75
+ const user = userEvent.setup();
76
+ render(
77
+ <Listbox.Root type="multiple" aria-label="Fruits">
78
+ {fruits.map((f) => (
79
+ <Listbox.Option key={f.value} value={f.value}>
80
+ {f.label}
81
+ </Listbox.Option>
82
+ ))}
83
+ </Listbox.Root>,
84
+ );
85
+
86
+ await user.click(screen.getByRole("option", { name: "Banana" }));
87
+
88
+ selectionIs("Banana");
89
+ });
90
+ });
91
+
92
+ describe("Listbox unmounted options", () => {
93
+ it("drops an unmounted option out of navigation", async () => {
94
+ const user = userEvent.setup();
95
+
96
+ function Removable() {
97
+ const [visible, setVisible] = useState(true);
98
+ return (
99
+ <>
100
+ <button type="button" onClick={() => setVisible(false)}>
101
+ Drop
102
+ </button>
103
+ <Listbox.Root type="single" aria-label="Fruits">
104
+ <Listbox.Option value="apple">Apple</Listbox.Option>
105
+ {visible ? (
106
+ <Listbox.Option value="banana">Banana</Listbox.Option>
107
+ ) : null}
108
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
109
+ </Listbox.Root>
110
+ </>
111
+ );
112
+ }
113
+
114
+ render(<Removable />);
115
+ await user.click(screen.getByRole("button", { name: "Drop" }));
116
+ await user.tab();
117
+ await user.keyboard("{ArrowDown}");
118
+
119
+ cursorIsOn("Cherry");
120
+ });
121
+ });
122
+
123
+ describe("Listbox range-selection edges", () => {
124
+ it("does not extend past the first option", async () => {
125
+ const user = userEvent.setup();
126
+ const onValueChange = vi.fn();
127
+ render(
128
+ <Listbox.Root
129
+ type="multiple"
130
+ defaultValue={["apple"]}
131
+ onValueChange={onValueChange}
132
+ aria-label="Fruits"
133
+ >
134
+ {fruits.map((f) => (
135
+ <Listbox.Option key={f.value} value={f.value}>
136
+ {f.label}
137
+ </Listbox.Option>
138
+ ))}
139
+ </Listbox.Root>,
140
+ );
141
+
142
+ await user.tab();
143
+ await user.keyboard("{Shift>}{ArrowUp}{/Shift}");
144
+
145
+ cursorIsOn("Apple");
146
+ selectionIs("Apple");
147
+ expect(onValueChange).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it("extends onto the very first option rather than skipping it", async () => {
151
+ const user = userEvent.setup();
152
+ render(
153
+ <Listbox.Root type="multiple" defaultValue={["banana"]} aria-label="Fruits">
154
+ {fruits.map((f) => (
155
+ <Listbox.Option key={f.value} value={f.value}>
156
+ {f.label}
157
+ </Listbox.Option>
158
+ ))}
159
+ </Listbox.Root>,
160
+ );
161
+
162
+ await user.tab();
163
+ await user.keyboard("{Shift>}{ArrowUp}{/Shift}");
164
+
165
+ cursorIsOn("Apple");
166
+ selectionIs("Apple", "Banana");
167
+ });
168
+
169
+ it("sweeps without duplicating an already-selected option", async () => {
170
+ const user = userEvent.setup();
171
+ const onValueChange = vi.fn();
172
+ render(
173
+ <Listbox.Root
174
+ type="multiple"
175
+ defaultValue={["apple", "cherry"]}
176
+ onValueChange={onValueChange}
177
+ aria-label="Fruits"
178
+ >
179
+ {fruits.map((f) => (
180
+ <Listbox.Option key={f.value} value={f.value}>
181
+ {f.label}
182
+ </Listbox.Option>
183
+ ))}
184
+ </Listbox.Root>,
185
+ );
186
+
187
+ await user.tab();
188
+ await user.keyboard("{Control>}{Shift>}{End}{/Shift}{/Control}");
189
+
190
+ expect(onValueChange).toHaveBeenCalledWith(["apple", "cherry", "banana"]);
191
+ });
192
+ });
193
+
194
+ describe("Listbox End from a middle option", () => {
195
+ it("jumps to the last option, not the previous one", async () => {
196
+ const user = userEvent.setup();
197
+ render(
198
+ <Listbox.Root type="single" defaultValue="two" aria-label="Numbers">
199
+ <Listbox.Option value="one">One</Listbox.Option>
200
+ <Listbox.Option value="two">Two</Listbox.Option>
201
+ <Listbox.Option value="three">Three</Listbox.Option>
202
+ <Listbox.Option value="four">Four</Listbox.Option>
203
+ </Listbox.Root>,
204
+ );
205
+
206
+ await user.tab();
207
+ await user.keyboard("{End}");
208
+
209
+ cursorIsOn("Four");
210
+ });
211
+ });
212
+
213
+ describe("Listbox typeahead discrimination", () => {
214
+ beforeEach(() => {
215
+ render(
216
+ <Listbox.Root type="single" aria-label="Words">
217
+ <Listbox.Option value="zebra">Zebra</Listbox.Option>
218
+ <Listbox.Option value="apricot">Apricot</Listbox.Option>
219
+ <Listbox.Option value="apple">Apple</Listbox.Option>
220
+ </Listbox.Root>,
221
+ );
222
+ });
223
+
224
+ // "ap" is a genuine two-character prefix, so it narrows in place onto the
225
+ // match the cursor is already on. Treating it as a repeat would collapse the
226
+ // query to "a" and skip forward to Apple.
227
+ it("narrows in place instead of advancing on a real prefix", async () => {
228
+ const user = userEvent.setup();
229
+
230
+ await user.tab();
231
+ await user.keyboard("a");
232
+ cursorIsOn("Apricot");
233
+
234
+ await user.keyboard("p");
235
+ cursorIsOn("Apricot");
236
+ });
237
+
238
+ it("leaves the cursor alone when nothing matches", async () => {
239
+ const user = userEvent.setup();
240
+
241
+ await user.tab();
242
+ cursorIsOn("Zebra");
243
+
244
+ await user.keyboard("q");
245
+
246
+ cursorIsOn("Zebra");
247
+ });
248
+
249
+ it("does not fold a non-printable key into the query", async () => {
250
+ const user = userEvent.setup();
251
+
252
+ await user.tab();
253
+ await user.keyboard("{Escape}");
254
+ await user.keyboard("a");
255
+
256
+ cursorIsOn("Apricot");
257
+ });
258
+ });
259
+
260
+ describe("Listbox typeahead with no cursor", () => {
261
+ it("matches from the very top, including the first option", async () => {
262
+ const user = userEvent.setup();
263
+ const { rerender } = render(
264
+ <Listbox.Root type="single" aria-label="Fruits" />,
265
+ );
266
+
267
+ await user.tab();
268
+ rerender(
269
+ <Listbox.Root type="single" aria-label="Fruits">
270
+ <Listbox.Option value="apple">Apple</Listbox.Option>
271
+ <Listbox.Option value="apricot">Apricot</Listbox.Option>
272
+ <Listbox.Option value="avocado">Avocado</Listbox.Option>
273
+ </Listbox.Root>,
274
+ );
275
+
276
+ // Every option matches, so a scan that starts anywhere but index 0 lands
277
+ // somewhere visibly different rather than wrapping back onto Apple.
278
+ await user.keyboard("a");
279
+
280
+ cursorIsOn("Apple");
281
+ });
282
+ });
283
+
284
+ describe("Listbox activation and typeahead do not collide", () => {
285
+ // Space activates, so it must not also land in the typeahead buffer — a
286
+ // stray leading space would make every following search miss.
287
+ it("still runs typeahead after Space has activated an option", async () => {
288
+ const user = userEvent.setup();
289
+ render(
290
+ <Listbox.Root type="single" aria-label="Fruits">
291
+ {fruits.map((f) => (
292
+ <Listbox.Option key={f.value} value={f.value}>
293
+ {f.label}
294
+ </Listbox.Option>
295
+ ))}
296
+ </Listbox.Root>,
297
+ );
298
+
299
+ await user.tab();
300
+ await user.keyboard(" ");
301
+ await user.keyboard("b");
302
+
303
+ cursorIsOn("Banana");
304
+ });
305
+ });
306
+
307
+ describe("Listbox GroupLabel strict context", () => {
308
+ beforeEach(() => {
309
+ vi.spyOn(console, "error").mockImplementation(() => {});
310
+ });
311
+
312
+ it("names <Listbox.Group> when a GroupLabel is rendered outside one", () => {
313
+ expect(() =>
314
+ render(
315
+ <Listbox.Root type="single" aria-label="Fruits">
316
+ <Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
317
+ </Listbox.Root>,
318
+ ),
319
+ ).toThrow("Listbox.GroupLabel must be rendered as a child of Listbox.Group");
320
+ });
321
+ });
322
+
323
+ describe("Listbox reactive option props", () => {
324
+ it("picks up an option becoming disabled after mount", async () => {
325
+ const user = userEvent.setup();
326
+
327
+ function Toggleable() {
328
+ const [locked, setLocked] = useState(false);
329
+ return (
330
+ <>
331
+ <button type="button" onClick={() => setLocked(true)}>
332
+ Lock
333
+ </button>
334
+ <Listbox.Root type="single" aria-label="Fruits">
335
+ <Listbox.Option value="apple">Apple</Listbox.Option>
336
+ <Listbox.Option value="banana" disabled={locked}>
337
+ Banana
338
+ </Listbox.Option>
339
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
340
+ </Listbox.Root>
341
+ </>
342
+ );
343
+ }
344
+
345
+ render(<Toggleable />);
346
+ await user.click(screen.getByRole("button", { name: "Lock" }));
347
+ await user.tab();
348
+ await user.keyboard("{ArrowDown}");
349
+
350
+ cursorIsOn("Cherry");
351
+ });
352
+
353
+ it("leaves aria-disabled off an enabled option", () => {
354
+ render(
355
+ <Listbox.Root type="single" aria-label="Fruits">
356
+ <Listbox.Option value="apple">Apple</Listbox.Option>
357
+ <Listbox.Option value="banana" disabled>
358
+ Banana
359
+ </Listbox.Option>
360
+ </Listbox.Root>,
361
+ );
362
+
363
+ expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
364
+ "aria-disabled",
365
+ );
366
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
367
+ "aria-disabled",
368
+ "true",
369
+ );
370
+ });
371
+ });
372
+
373
+ describe("Listbox GroupLabel lifecycle", () => {
374
+ it("falls back to the label prop when the heading unmounts", async () => {
375
+ const user = userEvent.setup();
376
+
377
+ function Swappable() {
378
+ const [heading, setHeading] = useState(true);
379
+ return (
380
+ <>
381
+ <button type="button" onClick={() => setHeading(false)}>
382
+ Hide
383
+ </button>
384
+ <Listbox.Root type="single" aria-label="Fruits">
385
+ <Listbox.Group label="Fallback">
386
+ {heading ? (
387
+ <Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
388
+ ) : null}
389
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
390
+ </Listbox.Group>
391
+ </Listbox.Root>
392
+ </>
393
+ );
394
+ }
395
+
396
+ render(<Swappable />);
397
+ expect(screen.getByRole("group", { name: "Citrus" })).toBeInTheDocument();
398
+
399
+ await user.click(screen.getByRole("button", { name: "Hide" }));
400
+
401
+ const group = screen.getByRole("group", { name: "Fallback" });
402
+ expect(group).toHaveAttribute("aria-label", "Fallback");
403
+ expect(group).not.toHaveAttribute("aria-labelledby");
404
+ });
405
+ });
406
+
407
+ describe("Listbox chord requirements", () => {
408
+ // The range sweep needs Ctrl/Cmd AND Shift. Shift+Home alone is a plain
409
+ // cursor move, so requiring only one of the two would sweep unbidden.
410
+ it.each([
411
+ ["Shift+Home", "{Shift>}{Home}{/Shift}", "Apple"],
412
+ ["Shift+End", "{Shift>}{End}{/Shift}", "Cherry"],
413
+ ])("%s moves the cursor without sweeping a selection", async (_n, keys, landing) => {
414
+ const user = userEvent.setup();
415
+ const onValueChange = vi.fn();
416
+ render(
417
+ <Listbox.Root
418
+ type="multiple"
419
+ defaultValue={["banana"]}
420
+ onValueChange={onValueChange}
421
+ aria-label="Fruits"
422
+ >
423
+ {fruits.map((f) => (
424
+ <Listbox.Option key={f.value} value={f.value}>
425
+ {f.label}
426
+ </Listbox.Option>
427
+ ))}
428
+ </Listbox.Root>,
429
+ );
430
+
431
+ await user.tab();
432
+ await user.keyboard(keys);
433
+
434
+ cursorIsOn(landing);
435
+ selectionIs("Banana");
436
+ expect(onValueChange).not.toHaveBeenCalled();
437
+ });
438
+ });
439
+
440
+ describe("Listbox typeahead entry point", () => {
441
+ // With no cursor the scan must start at index 0. A sentinel of +1 instead of
442
+ // -1 would silently enter the list one option in.
443
+ it("starts a multi-character search at the very first option", async () => {
444
+ const user = userEvent.setup();
445
+ const { rerender } = render(
446
+ <Listbox.Root type="single" aria-label="Fruits" />,
447
+ );
448
+
449
+ await user.tab();
450
+ rerender(
451
+ <Listbox.Root type="single" aria-label="Fruits">
452
+ <Listbox.Option value="apple">Apple</Listbox.Option>
453
+ <Listbox.Option value="apricot">Apricot</Listbox.Option>
454
+ <Listbox.Option value="banana">Banana</Listbox.Option>
455
+ </Listbox.Root>,
456
+ );
457
+
458
+ await user.keyboard("ap");
459
+
460
+ cursorIsOn("Apple");
461
+ });
462
+
463
+ it("matches an option whose text is padded with whitespace", async () => {
464
+ const user = userEvent.setup();
465
+ render(
466
+ <Listbox.Root type="single" aria-label="Fruits">
467
+ <Listbox.Option value="apple">Apple</Listbox.Option>
468
+ <Listbox.Option value="banana">{" Banana "}</Listbox.Option>
469
+ </Listbox.Root>,
470
+ );
471
+
472
+ await user.tab();
473
+ await user.keyboard("b");
474
+
475
+ expect(screen.getByRole("listbox")).toHaveAttribute(
476
+ "aria-activedescendant",
477
+ screen.getByRole("option", { name: "Banana" }).id,
478
+ );
479
+ });
480
+ });
@@ -0,0 +1,59 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Listbox } from "../Listbox";
6
+
7
+ import { fruits } from "./Listbox.fixtures";
8
+
9
+ describe("Listbox orientation", () => {
10
+ it("leaves aria-orientation implicit for a vertical listbox", () => {
11
+ render(
12
+ <Listbox.Root type="single" aria-label="Fruits">
13
+ <Listbox.Option value="apple">Apple</Listbox.Option>
14
+ </Listbox.Root>,
15
+ );
16
+
17
+ const listbox = screen.getByRole("listbox");
18
+ expect(listbox).not.toHaveAttribute("aria-orientation");
19
+ expect(listbox).toHaveAttribute("data-orientation", "vertical");
20
+ });
21
+
22
+ it("declares a horizontal listbox explicitly", () => {
23
+ render(
24
+ <Listbox.Root type="single" orientation="horizontal" aria-label="Fruits">
25
+ <Listbox.Option value="apple">Apple</Listbox.Option>
26
+ </Listbox.Root>,
27
+ );
28
+
29
+ const listbox = screen.getByRole("listbox");
30
+ expect(listbox).toHaveAttribute("aria-orientation", "horizontal");
31
+ expect(listbox).toHaveAttribute("data-orientation", "horizontal");
32
+ });
33
+
34
+ it("moves the cursor with the horizontal arrow pair", async () => {
35
+ const user = userEvent.setup();
36
+ render(
37
+ <Listbox.Root
38
+ type="single"
39
+ orientation="horizontal"
40
+ defaultValue="apple"
41
+ aria-label="Fruits"
42
+ >
43
+ {fruits.map((fruit) => (
44
+ <Listbox.Option key={fruit.value} value={fruit.value}>
45
+ {fruit.label}
46
+ </Listbox.Option>
47
+ ))}
48
+ </Listbox.Root>,
49
+ );
50
+
51
+ await user.tab();
52
+ await user.keyboard("{ArrowRight}");
53
+
54
+ expect(screen.getByRole("listbox")).toHaveAttribute(
55
+ "aria-activedescendant",
56
+ screen.getByRole("option", { name: "Banana" }).id,
57
+ );
58
+ });
59
+ });
@@ -0,0 +1,65 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { DirectionProvider } from "../../DirectionProvider/index.ts";
6
+ import { Listbox } from "../Listbox";
7
+
8
+ import { fruits } from "./Listbox.fixtures";
9
+
10
+ describe("Listbox reading direction", () => {
11
+ it("mirrors the horizontal arrow pair under rtl", async () => {
12
+ const user = userEvent.setup();
13
+ render(
14
+ <Listbox.Root
15
+ type="single"
16
+ orientation="horizontal"
17
+ dir="rtl"
18
+ defaultValue="apple"
19
+ aria-label="Fruits"
20
+ >
21
+ {fruits.map((fruit) => (
22
+ <Listbox.Option key={fruit.value} value={fruit.value}>
23
+ {fruit.label}
24
+ </Listbox.Option>
25
+ ))}
26
+ </Listbox.Root>,
27
+ );
28
+
29
+ await user.tab();
30
+ await user.keyboard("{ArrowLeft}");
31
+
32
+ expect(screen.getByRole("listbox")).toHaveAttribute(
33
+ "aria-activedescendant",
34
+ screen.getByRole("option", { name: "Banana" }).id,
35
+ );
36
+ });
37
+
38
+ it("inherits the direction from a DirectionProvider", async () => {
39
+ const user = userEvent.setup();
40
+ render(
41
+ <DirectionProvider dir="rtl">
42
+ <Listbox.Root
43
+ type="single"
44
+ orientation="horizontal"
45
+ defaultValue="apple"
46
+ aria-label="Fruits"
47
+ >
48
+ {fruits.map((fruit) => (
49
+ <Listbox.Option key={fruit.value} value={fruit.value}>
50
+ {fruit.label}
51
+ </Listbox.Option>
52
+ ))}
53
+ </Listbox.Root>
54
+ </DirectionProvider>,
55
+ );
56
+
57
+ await user.tab();
58
+ await user.keyboard("{ArrowLeft}");
59
+
60
+ expect(screen.getByRole("listbox")).toHaveAttribute(
61
+ "aria-activedescendant",
62
+ screen.getByRole("option", { name: "Banana" }).id,
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,78 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { Listbox } from "../Listbox";
7
+
8
+ import { fruits } from "./Listbox.fixtures";
9
+
10
+ /**
11
+ * APG's rearrangeable example moves options up and down in place. React keeps
12
+ * the same elements mounted across a reorder (stable keys, unchanged props),
13
+ * so nothing re-registers — navigation order has to be read from the DOM, not
14
+ * from the order options happened to mount in.
15
+ */
16
+ function Reorderable() {
17
+ const [items, setItems] = useState<(typeof fruits)[number][]>([...fruits]);
18
+
19
+ return (
20
+ <>
21
+ <button type="button" onClick={() => setItems([items[2], items[0], items[1]])}>
22
+ Rotate
23
+ </button>
24
+ <Listbox.Root type="single" aria-label="Fruits">
25
+ {items.map((fruit) => (
26
+ <Listbox.Option key={fruit.value} value={fruit.value}>
27
+ {fruit.label}
28
+ </Listbox.Option>
29
+ ))}
30
+ </Listbox.Root>
31
+ </>
32
+ );
33
+ }
34
+
35
+ const cursorIsOn = (name: string) =>
36
+ expect(screen.getByRole("listbox")).toHaveAttribute(
37
+ "aria-activedescendant",
38
+ screen.getByRole("option", { name }).id,
39
+ );
40
+
41
+ describe("Listbox reordered options", () => {
42
+ it("seeds the cursor onto the option that is now first in the DOM", async () => {
43
+ const user = userEvent.setup();
44
+ render(<Reorderable />);
45
+
46
+ await user.click(screen.getByRole("button", { name: "Rotate" }));
47
+ await user.tab();
48
+
49
+ expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
50
+ "Cherry",
51
+ "Apple",
52
+ "Banana",
53
+ ]);
54
+ cursorIsOn("Cherry");
55
+ });
56
+
57
+ it("walks the arrow keys in the new visual order", async () => {
58
+ const user = userEvent.setup();
59
+ render(<Reorderable />);
60
+
61
+ await user.click(screen.getByRole("button", { name: "Rotate" }));
62
+ await user.tab();
63
+ await user.keyboard("{ArrowDown}");
64
+
65
+ cursorIsOn("Apple");
66
+ });
67
+
68
+ it("sends End to the option that is now last", async () => {
69
+ const user = userEvent.setup();
70
+ render(<Reorderable />);
71
+
72
+ await user.click(screen.getByRole("button", { name: "Rotate" }));
73
+ await user.tab();
74
+ await user.keyboard("{End}");
75
+
76
+ cursorIsOn("Banana");
77
+ });
78
+ });