@primitiv-ui/react 0.1.29 → 0.1.31

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 (216) 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/RadioCard/README.md +2 -1
  120. package/src/RadioCard/RadioCard.tsx +5 -2
  121. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
  122. package/src/RadioGroup/README.md +2 -2
  123. package/src/RadioGroup/RadioGroup.tsx +1 -1
  124. package/src/SegmentedControl/README.md +2 -2
  125. package/src/SegmentedControl/SegmentedControl.tsx +1 -1
  126. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
  127. package/src/Select/README.md +2 -2
  128. package/src/Select/Select.tsx +8 -8
  129. package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
  130. package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
  131. package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
  132. package/src/Select/types.ts +2 -2
  133. package/src/SkipNav/README.md +3 -3
  134. package/src/SkipNav/SkipNav.tsx +5 -5
  135. package/src/Slider/README.md +37 -7
  136. package/src/Slider/Slider.tsx +9 -3
  137. package/src/Slot/Slot.tsx +2 -2
  138. package/src/Slot/__tests__/Slot.test.tsx +2 -2
  139. package/src/Slot/types.ts +1 -1
  140. package/src/SplitButton/README.md +225 -0
  141. package/src/SplitButton/SplitButton.tsx +457 -0
  142. package/src/SplitButton/SplitButtonContext.ts +26 -0
  143. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  144. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  145. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  146. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  147. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  148. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  149. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  150. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  151. package/src/SplitButton/index.ts +2 -0
  152. package/src/SplitButton/types.ts +186 -0
  153. package/src/Switch/README.md +1 -1
  154. package/src/Switch/Switch.tsx +2 -2
  155. package/src/Switch/types.ts +1 -1
  156. package/src/Table/README.md +110 -8
  157. package/src/Table/Table.tsx +206 -10
  158. package/src/Table/TableExpandableContext.ts +7 -0
  159. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  160. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  161. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  162. package/src/Table/types.ts +125 -0
  163. package/src/Tabs/README.md +4 -4
  164. package/src/Tabs/Tabs.tsx +5 -4
  165. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  166. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  167. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  168. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  169. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  170. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  171. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  172. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  173. package/src/ToggleGroup/README.md +4 -4
  174. package/src/Tooltip/README.md +7 -7
  175. package/src/Tooltip/Tooltip.tsx +2 -2
  176. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  177. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  178. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  179. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  180. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  181. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  182. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  183. package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
  184. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  185. package/src/Tree/README.md +69 -5
  186. package/src/Tree/Tree.tsx +1 -1
  187. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  188. package/src/Tree/hooks/useTreePath.ts +33 -1
  189. package/src/Tree/index.ts +6 -1
  190. package/src/Tree/utils.ts +46 -4
  191. package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
  192. package/src/hooks/index.ts +1 -0
  193. package/src/hooks/useTypeahead.ts +95 -0
  194. package/src/index.ts +6 -0
  195. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  196. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  197. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  198. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  199. package/src/useLocalStorage/index.ts +1 -0
  200. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  201. package/src/useMediaQuery/README.md +66 -0
  202. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  203. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  204. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  205. package/src/useMediaQuery/index.ts +1 -0
  206. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  207. package/src/usePagination/README.md +170 -0
  208. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  209. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  210. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  211. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  212. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  213. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  214. package/src/usePagination/index.ts +1 -0
  215. package/src/usePagination/usePagination.ts +292 -0
  216. package/src/utils/getKeyToActionMap.ts +1 -1
@@ -0,0 +1,64 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Listbox } from "../Listbox";
6
+
7
+ import { fruits } from "./Listbox.fixtures";
8
+
9
+ describe("Listbox selectionFollowsFocus", () => {
10
+ it("selects each option the cursor lands on", async () => {
11
+ const user = userEvent.setup();
12
+ const onValueChange = vi.fn();
13
+ render(
14
+ <Listbox.Root
15
+ type="single"
16
+ defaultValue="apple"
17
+ selectionFollowsFocus
18
+ onValueChange={onValueChange}
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("{ArrowDown}");
31
+
32
+ expect(onValueChange).toHaveBeenCalledWith("banana");
33
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
34
+ "aria-selected",
35
+ "true",
36
+ );
37
+ });
38
+
39
+ it("still moves the cursor with the selection", async () => {
40
+ const user = userEvent.setup();
41
+ render(
42
+ <Listbox.Root
43
+ type="single"
44
+ defaultValue="apple"
45
+ selectionFollowsFocus
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
+ );
55
+
56
+ await user.tab();
57
+ await user.keyboard("{End}");
58
+
59
+ expect(screen.getByRole("listbox")).toHaveAttribute(
60
+ "aria-activedescendant",
61
+ screen.getByRole("option", { name: "Cherry" }).id,
62
+ );
63
+ });
64
+ });
@@ -0,0 +1,21 @@
1
+ import { render } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { Listbox } from "../Listbox";
5
+
6
+ /**
7
+ * Options read a strict context, so rendering one outside its provider throws
8
+ * a consumer-actionable message rather than failing obscurely further down.
9
+ * The message itself is the contract — assert it, not just that something threw.
10
+ */
11
+ describe("Listbox strict context", () => {
12
+ beforeEach(() => {
13
+ vi.spyOn(console, "error").mockImplementation(() => {});
14
+ });
15
+
16
+ it("names <Listbox.Root> when an Option is rendered outside one", () => {
17
+ expect(() =>
18
+ render(<Listbox.Option value="apple">Apple</Listbox.Option>),
19
+ ).toThrow("Listbox.Option must be rendered as a child of Listbox.Root");
20
+ });
21
+ });
@@ -0,0 +1,123 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Listbox } from "../Listbox";
6
+ import { TYPEAHEAD_RESET_MS } from "../constants";
7
+
8
+ import { typeaheadOptions } from "./Listbox.fixtures";
9
+
10
+ function renderListbox(props: { onValueChange?: (value: string) => void } = {}) {
11
+ return render(
12
+ <Listbox.Root type="single" aria-label="Fruits" {...props}>
13
+ {typeaheadOptions.map((fruit) => (
14
+ <Listbox.Option key={fruit.value} value={fruit.value}>
15
+ {fruit.label}
16
+ </Listbox.Option>
17
+ ))}
18
+ </Listbox.Root>,
19
+ );
20
+ }
21
+
22
+ const cursorIsOn = (name: string) =>
23
+ expect(screen.getByRole("listbox")).toHaveAttribute(
24
+ "aria-activedescendant",
25
+ screen.getByRole("option", { name }).id,
26
+ );
27
+
28
+ describe("Listbox typeahead", () => {
29
+ it("moves the cursor to the first option matching a single character", async () => {
30
+ const user = userEvent.setup();
31
+ renderListbox();
32
+
33
+ await user.tab();
34
+ await user.keyboard("b");
35
+
36
+ cursorIsOn("Banana");
37
+ });
38
+
39
+ it("cycles through matches on a repeated character, wrapping around", async () => {
40
+ const user = userEvent.setup();
41
+ renderListbox();
42
+
43
+ await user.tab();
44
+
45
+ await user.keyboard("a");
46
+ cursorIsOn("Apricot");
47
+
48
+ await user.keyboard("a");
49
+ cursorIsOn("Avocado");
50
+
51
+ await user.keyboard("a");
52
+ cursorIsOn("Apple");
53
+ });
54
+
55
+ it("narrows to a prefix match with a multi-character query", async () => {
56
+ const user = userEvent.setup();
57
+ renderListbox();
58
+
59
+ await user.tab();
60
+ await user.keyboard("av");
61
+
62
+ cursorIsOn("Avocado");
63
+ });
64
+
65
+ it("does not select the option it lands on", async () => {
66
+ const user = userEvent.setup();
67
+ const onValueChange = vi.fn();
68
+ renderListbox({ onValueChange });
69
+
70
+ await user.tab();
71
+ await user.keyboard("b");
72
+
73
+ expect(onValueChange).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("skips a disabled match", async () => {
77
+ const user = userEvent.setup();
78
+ render(
79
+ <Listbox.Root type="single" aria-label="Fruits">
80
+ <Listbox.Option value="apple">Apple</Listbox.Option>
81
+ <Listbox.Option value="banana" disabled>
82
+ Banana
83
+ </Listbox.Option>
84
+ <Listbox.Option value="blueberry">Blueberry</Listbox.Option>
85
+ </Listbox.Root>,
86
+ );
87
+
88
+ await user.tab();
89
+ await user.keyboard("b");
90
+
91
+ cursorIsOn("Blueberry");
92
+ });
93
+ });
94
+
95
+ /**
96
+ * The accumulation window is real elapsed time. userEvent + fake timers
97
+ * deadlocks in this repo (RTL's asyncWrapper drives fake clocks through a
98
+ * global `jest`, which vitest does not define), so this waits on the real
99
+ * clock rather than reaching for fireEvent. The timer callback only clears a
100
+ * ref, so nothing lands outside act.
101
+ */
102
+ describe("Listbox typeahead reset window", () => {
103
+ it("starts a fresh query once the accumulation window lapses", async () => {
104
+ const user = userEvent.setup();
105
+ renderListbox();
106
+
107
+ await user.tab();
108
+
109
+ // A two-character query narrows in place onto the only "av..." match.
110
+ await user.keyboard("av");
111
+ cursorIsOn("Avocado");
112
+
113
+ await new Promise((resolve) =>
114
+ setTimeout(resolve, TYPEAHEAD_RESET_MS + 100),
115
+ );
116
+
117
+ // With the buffer cleared this is a fresh single "a", which advances past
118
+ // Avocado and wraps to Apple. Had "av" survived, "ava" would match nothing
119
+ // and the cursor would not move.
120
+ await user.keyboard("a");
121
+ cursorIsOn("Apple");
122
+ });
123
+ });
@@ -0,0 +1,48 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Listbox } from "../Listbox";
6
+
7
+ describe("Listbox uncontrolled state", () => {
8
+ it("marks the defaultValue option as selected", () => {
9
+ render(
10
+ <Listbox.Root type="single" defaultValue="banana" aria-label="Fruits">
11
+ <Listbox.Option value="apple">Apple</Listbox.Option>
12
+ <Listbox.Option value="banana">Banana</Listbox.Option>
13
+ </Listbox.Root>,
14
+ );
15
+
16
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
17
+ "aria-selected",
18
+ "true",
19
+ );
20
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
21
+ "aria-selected",
22
+ "false",
23
+ );
24
+ });
25
+
26
+ it("selects an option on click and reports it via onValueChange", async () => {
27
+ const user = userEvent.setup();
28
+ const onValueChange = vi.fn();
29
+ render(
30
+ <Listbox.Root
31
+ type="single"
32
+ onValueChange={onValueChange}
33
+ aria-label="Fruits"
34
+ >
35
+ <Listbox.Option value="apple">Apple</Listbox.Option>
36
+ <Listbox.Option value="banana">Banana</Listbox.Option>
37
+ </Listbox.Root>,
38
+ );
39
+
40
+ await user.click(screen.getByRole("option", { name: "Banana" }));
41
+
42
+ expect(onValueChange).toHaveBeenCalledWith("banana");
43
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
44
+ "aria-selected",
45
+ "true",
46
+ );
47
+ });
48
+ });
@@ -0,0 +1,2 @@
1
+ /** Typeahead accumulation window, in milliseconds. */
2
+ export const TYPEAHEAD_RESET_MS: number = 500;
@@ -0,0 +1 @@
1
+ export * from "./useListboxRoot";
@@ -0,0 +1,407 @@
1
+ import { useCallback, useId, useState, type KeyboardEvent } from "react";
2
+
3
+ import {
4
+ useCollection,
5
+ useControllableState,
6
+ useTypeahead,
7
+ } from "../../hooks/index.ts";
8
+ import { deriveId, getKeyToActionMap } from "../../utils/index.ts";
9
+
10
+ import { TYPEAHEAD_RESET_MS } from "../constants";
11
+
12
+
13
+ type OptionMeta = { element: HTMLElement; disabled: boolean };
14
+
15
+ type UseListboxRootArgs = {
16
+ type: "single" | "multiple";
17
+ defaultValue: string | string[] | undefined;
18
+ value: string | string[] | undefined;
19
+ onValueChange:
20
+ | ((value: string) => void)
21
+ | ((value: string[]) => void)
22
+ | undefined;
23
+ selectionFollowsFocus: boolean;
24
+ orientation: "horizontal" | "vertical";
25
+ dir: "ltr" | "rtl";
26
+ };
27
+
28
+ function toArray(value: string | string[] | undefined): string[] {
29
+ if (value === undefined) return [];
30
+ return typeof value === "string" ? [value] : value;
31
+ }
32
+
33
+ /**
34
+ * Owns the Listbox's selection state and its virtual-focus cursor.
35
+ *
36
+ * Selection is normalised into an array so options only ever ask "am I in
37
+ * the selected set?". The cursor (`activeValue`) is the option
38
+ * `aria-activedescendant` points at — real DOM focus stays on the root, so
39
+ * the cursor is seeded when the root takes focus and cleared when it loses
40
+ * it.
41
+ *
42
+ * No `tabIndex` is moved between options: the root is the single tab stop,
43
+ * and navigation just repoints the cursor.
44
+ *
45
+ * Navigation order is read from the **live DOM** on every interaction rather
46
+ * than from a memoised array. Reordering options in place (APG's rearrangeable
47
+ * example) keeps the same elements mounted with unchanged props, so nothing
48
+ * re-registers and any cached order would silently go stale.
49
+ */
50
+ export function useListboxRoot({
51
+ type,
52
+ defaultValue,
53
+ value: controlledValue,
54
+ onValueChange,
55
+ selectionFollowsFocus,
56
+ orientation,
57
+ dir,
58
+ }: UseListboxRootArgs): {
59
+ selectedValues: string[];
60
+ select: (optionValue: string) => void;
61
+ activeValue: string | undefined;
62
+ registerOption: (
63
+ optionValue: string,
64
+ element: HTMLElement | null,
65
+ disabled?: boolean,
66
+ ) => void;
67
+ getOptionId: (optionValue: string) => string;
68
+ seedActiveValue: () => void;
69
+ clearActiveValue: () => void;
70
+ handleKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
71
+ moveCursor: (optionValue: string) => void;
72
+ } {
73
+ const rootId = useId();
74
+ const [selectedValues, setSelectedValues] = useControllableState<string[]>(
75
+ controlledValue === undefined ? undefined : toArray(controlledValue),
76
+ toArray(defaultValue),
77
+ );
78
+ const [activeValue, setActiveValue] = useState<string | undefined>(undefined);
79
+ // `keys` is deliberately unused: navigation order comes from the DOM, not
80
+ // from registration order. The collection is still what keeps `itemsRef`
81
+ // populated and re-renders the Root as options mount and unmount.
82
+ const { register: registerBase, itemsRef } = useCollection<
83
+ string,
84
+ OptionMeta
85
+ >();
86
+
87
+ const registerOption = useCallback(
88
+ // Stryker disable next-line BooleanLiteral: equivalent — the only caller that omits this argument is the unmount path, which passes a null element and so never reads it.
89
+ (optionValue: string, element: HTMLElement | null, disabled = false) => {
90
+ registerBase(optionValue, element ? { element, disabled } : null);
91
+ },
92
+ // Stryker disable next-line ArrayDeclaration: equivalent — registerBase is a stable useCollection callback, so an empty dep array memoises identically.
93
+ [registerBase],
94
+ );
95
+
96
+ // Disabled options stay in the DOM and in the a11y tree, but drop out of
97
+ // every navigable list: cursor movement and focus seeding both skip them.
98
+ //
99
+ // Sorted by document position, not registration order — see the note above.
100
+ const getNavigableValues = useCallback(
101
+ () => {
102
+ const entries = Array.from(itemsRef.current.entries()).filter(
103
+ ([, meta]) => !meta.disabled,
104
+ );
105
+ entries.sort(([, a], [, b]) =>
106
+ a.element.compareDocumentPosition(b.element) &
107
+ Node.DOCUMENT_POSITION_FOLLOWING
108
+ ? -1
109
+ : 1,
110
+ );
111
+ return entries.map(([key]) => key);
112
+ },
113
+ // Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
114
+ [itemsRef],
115
+ );
116
+
117
+ // Single mode replaces the selection outright — re-selecting the current
118
+ // option is a no-op re-select, not a deselect (a listbox is not a toggle).
119
+ // Multiple mode toggles the option in place, preserving the rest.
120
+ const select = useCallback(
121
+ (optionValue: string) => {
122
+ if (type === "single") {
123
+ setSelectedValues([optionValue]);
124
+ (onValueChange as ((value: string) => void) | undefined)?.(optionValue);
125
+ return;
126
+ }
127
+ const next = selectedValues.includes(optionValue)
128
+ ? selectedValues.filter((key) => key !== optionValue)
129
+ : [...selectedValues, optionValue];
130
+ setSelectedValues(next);
131
+ (onValueChange as ((value: string[]) => void) | undefined)?.(next);
132
+ },
133
+ [type, selectedValues, setSelectedValues, onValueChange],
134
+ );
135
+
136
+ // Bulk selection writes. Multiple-mode only: single mode's onValueChange
137
+ // takes a string, and none of the range shortcuts apply to it.
138
+ const commitSelection = useCallback(
139
+ (next: string[]) => {
140
+ setSelectedValues(next);
141
+ (onValueChange as ((value: string[]) => void) | undefined)?.(next);
142
+ },
143
+ // Stryker disable next-line ArrayDeclaration: equivalent — both are stable for a given render pair; the value read is the ref/state itself.
144
+ [setSelectedValues, onValueChange],
145
+ );
146
+
147
+ // APG: Ctrl+A selects all, or deselects if everything is already selected.
148
+ // Disabled options are not selectable, so they are not part of "all".
149
+ const toggleSelectAll = useCallback(() => {
150
+ const navigable = getNavigableValues();
151
+ const allSelected =
152
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with nothing navigable both arms commit the empty array.
153
+ navigable.length > 0 &&
154
+ navigable.every((key) => selectedValues.includes(key));
155
+ commitSelection(allSelected ? [] : navigable);
156
+ }, [getNavigableValues, selectedValues, commitSelection]);
157
+
158
+ const getOptionId = useCallback(
159
+ // Stryker disable next-line StringLiteral: equivalent — the suffix only has to be stable; every assertion compares against the element's own id.
160
+ (optionValue: string) => deriveId(rootId, "option", optionValue),
161
+ // Stryker disable next-line ArrayDeclaration: equivalent — rootId is a useId() constant.
162
+ [rootId],
163
+ );
164
+
165
+ // APG: when the referenced option is not fully visible, scroll it into view.
166
+ const moveCursor = useCallback(
167
+ (optionValue: string) => {
168
+ setActiveValue(optionValue);
169
+ // Stryker disable next-line OptionalChaining: unreachable — every value reaching moveCursor comes from getNavigableValues(), so the entry is always registered.
170
+ itemsRef.current
171
+ .get(optionValue)
172
+ ?.element.scrollIntoView({ block: "nearest" });
173
+ },
174
+ // Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
175
+ [itemsRef],
176
+ );
177
+
178
+ // APG: when the listbox receives focus, the first selected option takes
179
+ // the cursor; with nothing selected, the first option does.
180
+ const seedActiveValue = useCallback(
181
+ () => {
182
+ const navigable = getNavigableValues();
183
+ setActiveValue(
184
+ (current) =>
185
+ current ??
186
+ navigable.find((key) => selectedValues.includes(key)) ??
187
+ navigable[0],
188
+ );
189
+ },
190
+ // Stryker disable next-line ArrayDeclaration: equivalent — the callback is rebuilt each render and reads both through its closure.
191
+ [getNavigableValues, selectedValues],
192
+ );
193
+
194
+ // Stryker disable next-line ArrayDeclaration: equivalent — the dep array is already empty; adding a phantom entry cannot change a setState-only callback.
195
+ const clearActiveValue = useCallback(() => setActiveValue(undefined), []);
196
+
197
+ // One navigation path for both the arrow keymap and typeahead, so
198
+ // `selectionFollowsFocus` applies identically to each.
199
+ const navigateTo = useCallback(
200
+ (target: string) => {
201
+ moveCursor(target);
202
+ if (selectionFollowsFocus) select(target);
203
+ },
204
+ // Stryker disable next-line ArrayDeclaration: equivalent — a stale closure here still calls the same setState and select.
205
+ [moveCursor, selectionFollowsFocus, select],
206
+ );
207
+
208
+ // APG: Shift+Arrow "moves focus and selects" the option it lands on. Unlike
209
+ // the plain arrows this deliberately does not wrap — extending a range off
210
+ // the end and round to the top would select options never travelled past.
211
+ const extendSelection = useCallback(
212
+ (direction: "next" | "prev") => {
213
+ const navigable = getNavigableValues();
214
+ const currentIndex =
215
+ // Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
216
+ activeValue === undefined ? -1 : navigable.indexOf(activeValue);
217
+ if (currentIndex === -1) return;
218
+
219
+ const targetIndex =
220
+ direction === "next" ? currentIndex + 1 : currentIndex - 1;
221
+ if (targetIndex < 0 || targetIndex >= navigable.length) return;
222
+
223
+ const target = navigable[targetIndex];
224
+ moveCursor(target);
225
+ if (!selectedValues.includes(target)) {
226
+ commitSelection([...selectedValues, target]);
227
+ }
228
+ },
229
+ [
230
+ activeValue,
231
+ getNavigableValues,
232
+ moveCursor,
233
+ selectedValues,
234
+ commitSelection,
235
+ ],
236
+ );
237
+
238
+ // APG: Ctrl+Shift+Home/End selects from the cursor through to the first or
239
+ // last option and moves the cursor to that edge. Selections outside the
240
+ // swept range are preserved.
241
+ const extendToEdge = useCallback(
242
+ (edge: "first" | "last") => {
243
+ const navigable = getNavigableValues();
244
+ const currentIndex =
245
+ // Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
246
+ activeValue === undefined ? -1 : navigable.indexOf(activeValue);
247
+ if (currentIndex === -1) return;
248
+
249
+ const range =
250
+ edge === "first"
251
+ ? navigable.slice(0, currentIndex + 1)
252
+ : navigable.slice(currentIndex);
253
+
254
+ const next = [...selectedValues];
255
+ for (const key of range) {
256
+ if (!next.includes(key)) next.push(key);
257
+ }
258
+ commitSelection(next);
259
+ moveCursor(
260
+ edge === "first" ? navigable[0] : navigable[navigable.length - 1],
261
+ );
262
+ },
263
+ [
264
+ activeValue,
265
+ getNavigableValues,
266
+ selectedValues,
267
+ commitSelection,
268
+ moveCursor,
269
+ ],
270
+ );
271
+
272
+ // The arrow / Home / End / Enter-Space keymap. This mirrors what
273
+ // `useRovingTabindex` does, but resolves `navigable` at event time so a
274
+ // reorder is picked up; that hook takes its list at render time.
275
+ const handleNavigationKey = useCallback(
276
+ (event: KeyboardEvent<HTMLElement>) => {
277
+ const action = getKeyToActionMap({
278
+ orientation,
279
+ dir,
280
+ homeEnd: true,
281
+ activate: true,
282
+ })[event.key];
283
+ if (!action) return;
284
+
285
+ const navigable = getNavigableValues();
286
+ // Stryker disable next-line ConditionalExpression: equivalent — with no options every downstream index resolves to undefined, which moves the cursor nowhere.
287
+ if (navigable.length === 0) return;
288
+
289
+ if (action === "activate") {
290
+ if (activeValue === undefined) return;
291
+ event.preventDefault();
292
+ select(activeValue);
293
+ return;
294
+ }
295
+
296
+ const currentIndex =
297
+ // Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1, so both arms yield the same index.
298
+ activeValue === undefined ? -1 : navigable.indexOf(activeValue);
299
+ let targetIndex: number;
300
+ if (action === "first") {
301
+ targetIndex = 0;
302
+ } else if (action === "last") {
303
+ targetIndex = navigable.length - 1;
304
+ } else if (currentIndex === -1) {
305
+ // No cursor yet — the options streamed in after the listbox took
306
+ // focus, so seeding never ran. Enter the list from the near end
307
+ // rather than swallowing the keystroke.
308
+ targetIndex = action === "next" ? 0 : navigable.length - 1;
309
+ } else {
310
+ targetIndex =
311
+ action === "next"
312
+ ? (currentIndex + 1) % navigable.length
313
+ : (currentIndex - 1 + navigable.length) % navigable.length;
314
+ }
315
+
316
+ event.preventDefault();
317
+ navigateTo(navigable[targetIndex]);
318
+ },
319
+ [orientation, dir, getNavigableValues, activeValue, select, navigateTo],
320
+ );
321
+
322
+ // `optionValue` is always drawn from `navigable` (the enabled subset of the
323
+ // registered keys), and useCollection writes `itemsRef` and the keys state
324
+ // together — so the entry, and an element with textContent, always exist.
325
+ const getLabel = useCallback(
326
+ (optionValue: string) =>
327
+ itemsRef.current.get(optionValue)!.element.textContent!,
328
+ // Stryker disable next-line ArrayDeclaration: equivalent — itemsRef is a stable RefObject.
329
+ [itemsRef],
330
+ );
331
+
332
+ const { handleTypeahead } = useTypeahead({
333
+ getNavigable: getNavigableValues,
334
+ getLabel,
335
+ currentKey: activeValue,
336
+ onMatch: navigateTo,
337
+ resetMs: TYPEAHEAD_RESET_MS,
338
+ });
339
+
340
+ // The keymap gets first refusal: it calls preventDefault on every key it
341
+ // consumes, so a still-unhandled printable character falls through to
342
+ // typeahead (and Space, which activates, never does).
343
+ const handleKeyDown = useCallback(
344
+ (event: KeyboardEvent<HTMLElement>) => {
345
+ const chord = event.ctrlKey || event.metaKey;
346
+
347
+ if (chord && !event.altKey && event.key.toLowerCase() === "a") {
348
+ if (type !== "multiple") return;
349
+ event.preventDefault();
350
+ toggleSelectAll();
351
+ return;
352
+ }
353
+
354
+ if (
355
+ chord &&
356
+ event.shiftKey &&
357
+ (event.key === "Home" || event.key === "End")
358
+ ) {
359
+ if (type !== "multiple") return;
360
+ event.preventDefault();
361
+ // Stryker disable next-line StringLiteral: equivalent — extendToEdge branches on `edge === "first"`, so any non-"first" string selects the same path.
362
+ extendToEdge(event.key === "Home" ? "first" : "last");
363
+ return;
364
+ }
365
+
366
+ // Every other chorded shortcut belongs to the browser and the consumer —
367
+ // APG's rearrangeable example binds Alt+Arrow to its toolbar. Only Shift
368
+ // is ours, for range selection.
369
+ if (chord || event.altKey) return;
370
+
371
+ if (event.shiftKey && type === "multiple") {
372
+ const action = getKeyToActionMap({ orientation, dir })[event.key];
373
+ if (action === "next" || action === "prev") {
374
+ event.preventDefault();
375
+ extendSelection(action);
376
+ return;
377
+ }
378
+ }
379
+
380
+ handleNavigationKey(event);
381
+ if (event.defaultPrevented) return;
382
+ handleTypeahead(event);
383
+ },
384
+ [
385
+ type,
386
+ orientation,
387
+ dir,
388
+ toggleSelectAll,
389
+ extendSelection,
390
+ extendToEdge,
391
+ handleNavigationKey,
392
+ handleTypeahead,
393
+ ],
394
+ );
395
+
396
+ return {
397
+ selectedValues,
398
+ select,
399
+ activeValue,
400
+ registerOption,
401
+ getOptionId,
402
+ seedActiveValue,
403
+ clearActiveValue,
404
+ handleKeyDown,
405
+ moveCursor,
406
+ };
407
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./Listbox";
2
+ export * from "./ListboxContext";
3
+ export * from "./types";