@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,90 @@
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 disabled options", () => {
8
+ it("marks a disabled option in the accessibility tree and for CSS", () => {
9
+ render(
10
+ <Listbox.Root type="single" aria-label="Fruits">
11
+ <Listbox.Option value="apple">Apple</Listbox.Option>
12
+ <Listbox.Option value="banana" disabled>
13
+ Banana
14
+ </Listbox.Option>
15
+ </Listbox.Root>,
16
+ );
17
+
18
+ const banana = screen.getByRole("option", { name: "Banana" });
19
+ expect(banana).toHaveAttribute("aria-disabled", "true");
20
+ expect(banana).toHaveAttribute("data-disabled", "");
21
+ expect(screen.getByRole("option", { name: "Apple" })).not.toHaveAttribute(
22
+ "data-disabled",
23
+ );
24
+ });
25
+
26
+ it("skips a disabled option when moving the cursor", async () => {
27
+ const user = userEvent.setup();
28
+ render(
29
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
30
+ <Listbox.Option value="apple">Apple</Listbox.Option>
31
+ <Listbox.Option value="banana" disabled>
32
+ Banana
33
+ </Listbox.Option>
34
+ <Listbox.Option value="cherry">Cherry</Listbox.Option>
35
+ </Listbox.Root>,
36
+ );
37
+
38
+ await user.tab();
39
+ await user.keyboard("{ArrowDown}");
40
+
41
+ expect(screen.getByRole("listbox")).toHaveAttribute(
42
+ "aria-activedescendant",
43
+ screen.getByRole("option", { name: "Cherry" }).id,
44
+ );
45
+ });
46
+
47
+ it("does not select a disabled option on click", async () => {
48
+ const user = userEvent.setup();
49
+ const onValueChange = vi.fn();
50
+ render(
51
+ <Listbox.Root
52
+ type="single"
53
+ onValueChange={onValueChange}
54
+ aria-label="Fruits"
55
+ >
56
+ <Listbox.Option value="apple">Apple</Listbox.Option>
57
+ <Listbox.Option value="banana" disabled>
58
+ Banana
59
+ </Listbox.Option>
60
+ </Listbox.Root>,
61
+ );
62
+
63
+ await user.click(screen.getByRole("option", { name: "Banana" }));
64
+
65
+ expect(onValueChange).not.toHaveBeenCalled();
66
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
67
+ "aria-selected",
68
+ "false",
69
+ );
70
+ });
71
+
72
+ it("seeds the cursor past a leading disabled option", async () => {
73
+ const user = userEvent.setup();
74
+ render(
75
+ <Listbox.Root type="single" aria-label="Fruits">
76
+ <Listbox.Option value="apple" disabled>
77
+ Apple
78
+ </Listbox.Option>
79
+ <Listbox.Option value="banana">Banana</Listbox.Option>
80
+ </Listbox.Root>,
81
+ );
82
+
83
+ await user.tab();
84
+
85
+ expect(screen.getByRole("listbox")).toHaveAttribute(
86
+ "aria-activedescendant",
87
+ screen.getByRole("option", { name: "Banana" }).id,
88
+ );
89
+ });
90
+ });
@@ -0,0 +1,27 @@
1
+ /** Three options, enough to exercise wrap-around in both directions. */
2
+ export const fruits = [
3
+ { value: "apple", label: "Apple" },
4
+ { value: "banana", label: "Banana" },
5
+ { value: "cherry", label: "Cherry" },
6
+ ] as const;
7
+
8
+ /**
9
+ * Cursor movement cases for a vertical listbox. `from` seeds the cursor via
10
+ * `defaultValue`; `expected` is the option the key should move it to.
11
+ */
12
+ export const cursorKeyCases = [
13
+ { key: "{ArrowDown}", from: "apple", expected: "Banana" },
14
+ { key: "{ArrowDown}", from: "cherry", expected: "Apple" },
15
+ { key: "{ArrowUp}", from: "banana", expected: "Apple" },
16
+ { key: "{ArrowUp}", from: "apple", expected: "Cherry" },
17
+ { key: "{Home}", from: "cherry", expected: "Apple" },
18
+ { key: "{End}", from: "apple", expected: "Cherry" },
19
+ ] as const;
20
+
21
+ /** Four options with overlapping prefixes, for typeahead cycling. */
22
+ export const typeaheadOptions = [
23
+ { value: "apple", label: "Apple" },
24
+ { value: "apricot", label: "Apricot" },
25
+ { value: "avocado", label: "Avocado" },
26
+ { value: "banana", label: "Banana" },
27
+ ] as const;
@@ -0,0 +1,97 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Listbox } from "../Listbox";
5
+
6
+ /**
7
+ * APG's grouped-options example names each group with `aria-labelledby`
8
+ * pointing at a *visible* heading inside the group, not an invisible
9
+ * `aria-label`. `Listbox.GroupLabel` renders that heading and wires the id.
10
+ */
11
+ describe("Listbox group label", () => {
12
+ it("names the group from a visible heading", () => {
13
+ render(
14
+ <Listbox.Root type="single" aria-label="Animals">
15
+ <Listbox.Group>
16
+ <Listbox.GroupLabel>Land</Listbox.GroupLabel>
17
+ <Listbox.Option value="cat">Cat</Listbox.Option>
18
+ </Listbox.Group>
19
+ </Listbox.Root>,
20
+ );
21
+
22
+ const group = screen.getByRole("group", { name: "Land" });
23
+ const heading = screen.getByText("Land");
24
+ expect(group).toHaveAttribute("aria-labelledby", heading.id);
25
+ expect(heading).toBeVisible();
26
+ // Only one naming attribute: emitting both would be misleading, since
27
+ // aria-labelledby silently outranks aria-label.
28
+ expect(group).not.toHaveAttribute("aria-label");
29
+ });
30
+
31
+ it("lets the rendered heading win over the label prop when both are given", () => {
32
+ render(
33
+ <Listbox.Root type="single" aria-label="Animals">
34
+ <Listbox.Group label="Water">
35
+ <Listbox.GroupLabel>Land</Listbox.GroupLabel>
36
+ <Listbox.Option value="cat">Cat</Listbox.Option>
37
+ </Listbox.Group>
38
+ </Listbox.Root>,
39
+ );
40
+
41
+ const group = screen.getByRole("group", { name: "Land" });
42
+ expect(group).toHaveAttribute(
43
+ "aria-labelledby",
44
+ screen.getByText("Land").id,
45
+ );
46
+ // The label prop is dropped rather than emitted alongside. The test above
47
+ // cannot show this: with no label prop, aria-label is absent either way.
48
+ expect(group).not.toHaveAttribute("aria-label");
49
+ });
50
+
51
+ it("keeps the heading out of the option list", () => {
52
+ render(
53
+ <Listbox.Root type="single" aria-label="Animals">
54
+ <Listbox.Group>
55
+ <Listbox.GroupLabel>Land</Listbox.GroupLabel>
56
+ <Listbox.Option value="cat">Cat</Listbox.Option>
57
+ </Listbox.Group>
58
+ </Listbox.Root>,
59
+ );
60
+
61
+ expect(screen.getByText("Land")).toHaveAttribute("role", "presentation");
62
+ expect(screen.getAllByRole("option")).toHaveLength(1);
63
+ });
64
+
65
+ it("still supports the invisible label prop", () => {
66
+ render(
67
+ <Listbox.Root type="single" aria-label="Animals">
68
+ <Listbox.Group label="Water">
69
+ <Listbox.Option value="cod">Cod</Listbox.Option>
70
+ </Listbox.Group>
71
+ </Listbox.Root>,
72
+ );
73
+
74
+ const group = screen.getByRole("group", { name: "Water" });
75
+ expect(group).toHaveAttribute("aria-label", "Water");
76
+ expect(group).not.toHaveAttribute("aria-labelledby");
77
+ });
78
+
79
+ it("gives each group its own heading id", () => {
80
+ render(
81
+ <Listbox.Root type="single" aria-label="Animals">
82
+ <Listbox.Group>
83
+ <Listbox.GroupLabel>Land</Listbox.GroupLabel>
84
+ <Listbox.Option value="cat">Cat</Listbox.Option>
85
+ </Listbox.Group>
86
+ <Listbox.Group>
87
+ <Listbox.GroupLabel>Water</Listbox.GroupLabel>
88
+ <Listbox.Option value="cod">Cod</Listbox.Option>
89
+ </Listbox.Group>
90
+ </Listbox.Root>,
91
+ );
92
+
93
+ expect(screen.getByText("Land").id).not.toBe(screen.getByText("Water").id);
94
+ expect(screen.getByRole("group", { name: "Land" })).toBeInTheDocument();
95
+ expect(screen.getByRole("group", { name: "Water" })).toBeInTheDocument();
96
+ });
97
+ });
@@ -0,0 +1,45 @@
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
+ describe("Listbox group", () => {
8
+ it("names each group for assistive technology", () => {
9
+ render(
10
+ <Listbox.Root type="single" aria-label="Fruits">
11
+ <Listbox.Group label="Citrus">
12
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
13
+ </Listbox.Group>
14
+ <Listbox.Group label="Berries">
15
+ <Listbox.Option value="fig">Fig</Listbox.Option>
16
+ </Listbox.Group>
17
+ </Listbox.Root>,
18
+ );
19
+
20
+ expect(screen.getByRole("group", { name: "Citrus" })).toBeInTheDocument();
21
+ expect(screen.getByRole("group", { name: "Berries" })).toBeInTheDocument();
22
+ });
23
+
24
+ it("navigates across group boundaries in DOM order", async () => {
25
+ const user = userEvent.setup();
26
+ render(
27
+ <Listbox.Root type="single" aria-label="Fruits">
28
+ <Listbox.Group label="Citrus">
29
+ <Listbox.Option value="lemon">Lemon</Listbox.Option>
30
+ </Listbox.Group>
31
+ <Listbox.Group label="Berries">
32
+ <Listbox.Option value="fig">Fig</Listbox.Option>
33
+ </Listbox.Group>
34
+ </Listbox.Root>,
35
+ );
36
+
37
+ await user.tab();
38
+ await user.keyboard("{ArrowDown}");
39
+
40
+ expect(screen.getByRole("listbox")).toHaveAttribute(
41
+ "aria-activedescendant",
42
+ screen.getByRole("option", { name: "Fig" }).id,
43
+ );
44
+ });
45
+ });
@@ -0,0 +1,144 @@
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 keyboard edge cases", () => {
10
+ it("absorbs typeahead on a listbox with nothing to navigate", async () => {
11
+ const user = userEvent.setup();
12
+ render(<Listbox.Root type="single" aria-label="Fruits" />);
13
+
14
+ await user.tab();
15
+ await user.keyboard("a");
16
+
17
+ expect(screen.getByRole("listbox")).not.toHaveAttribute(
18
+ "aria-activedescendant",
19
+ );
20
+ });
21
+
22
+ it("absorbs typeahead when every option is disabled", async () => {
23
+ const user = userEvent.setup();
24
+ render(
25
+ <Listbox.Root type="single" aria-label="Fruits">
26
+ {fruits.map((fruit) => (
27
+ <Listbox.Option key={fruit.value} value={fruit.value} disabled>
28
+ {fruit.label}
29
+ </Listbox.Option>
30
+ ))}
31
+ </Listbox.Root>,
32
+ );
33
+
34
+ await user.tab();
35
+ await user.keyboard("b");
36
+
37
+ expect(screen.getByRole("listbox")).not.toHaveAttribute(
38
+ "aria-activedescendant",
39
+ );
40
+ });
41
+
42
+ it("ignores arrow keys on a listbox with nothing to navigate", async () => {
43
+ const user = userEvent.setup();
44
+ render(<Listbox.Root type="single" aria-label="Fruits" />);
45
+
46
+ await user.tab();
47
+ await user.keyboard("{ArrowDown}{End}");
48
+
49
+ expect(screen.getByRole("listbox")).not.toHaveAttribute(
50
+ "aria-activedescendant",
51
+ );
52
+ });
53
+
54
+ it("has nothing to select when Enter arrives with no cursor", async () => {
55
+ const user = userEvent.setup();
56
+ const onValueChange = vi.fn();
57
+ const { rerender } = render(
58
+ <Listbox.Root
59
+ type="single"
60
+ onValueChange={onValueChange}
61
+ aria-label="Fruits"
62
+ />,
63
+ );
64
+
65
+ await user.tab();
66
+ rerender(
67
+ <Listbox.Root
68
+ type="single"
69
+ onValueChange={onValueChange}
70
+ aria-label="Fruits"
71
+ >
72
+ {fruits.map((fruit) => (
73
+ <Listbox.Option key={fruit.value} value={fruit.value}>
74
+ {fruit.label}
75
+ </Listbox.Option>
76
+ ))}
77
+ </Listbox.Root>,
78
+ );
79
+
80
+ await user.keyboard("{Enter}");
81
+
82
+ expect(onValueChange).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it.each([
86
+ ["{ArrowDown}", "Apple"],
87
+ ["{ArrowUp}", "Cherry"],
88
+ ])(
89
+ "%s enters the list at %s when options arrived after focus",
90
+ async (key, expected) => {
91
+ const user = userEvent.setup();
92
+ const { rerender } = render(
93
+ <Listbox.Root type="single" aria-label="Fruits" />,
94
+ );
95
+
96
+ await user.tab();
97
+ rerender(
98
+ <Listbox.Root type="single" aria-label="Fruits">
99
+ {fruits.map((fruit) => (
100
+ <Listbox.Option key={fruit.value} value={fruit.value}>
101
+ {fruit.label}
102
+ </Listbox.Option>
103
+ ))}
104
+ </Listbox.Root>,
105
+ );
106
+
107
+ await user.keyboard(key);
108
+
109
+ expect(screen.getByRole("listbox")).toHaveAttribute(
110
+ "aria-activedescendant",
111
+ screen.getByRole("option", { name: expected }).id,
112
+ );
113
+ },
114
+ );
115
+
116
+ // The search-suggestions case: the listbox is focused while still empty and
117
+ // its options stream in afterwards, so the cursor was never seeded. Typeahead
118
+ // has to cope with having no cursor to search from.
119
+ it("matches from the top when options arrive after focus", async () => {
120
+ const user = userEvent.setup();
121
+ const { rerender } = render(
122
+ <Listbox.Root type="single" aria-label="Fruits" />,
123
+ );
124
+
125
+ await user.tab();
126
+
127
+ rerender(
128
+ <Listbox.Root type="single" aria-label="Fruits">
129
+ {fruits.map((fruit) => (
130
+ <Listbox.Option key={fruit.value} value={fruit.value}>
131
+ {fruit.label}
132
+ </Listbox.Option>
133
+ ))}
134
+ </Listbox.Root>,
135
+ );
136
+
137
+ await user.keyboard("b");
138
+
139
+ expect(screen.getByRole("listbox")).toHaveAttribute(
140
+ "aria-activedescendant",
141
+ screen.getByRole("option", { name: "Banana" }).id,
142
+ );
143
+ });
144
+ });
@@ -0,0 +1,112 @@
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 { cursorKeyCases, fruits } from "./Listbox.fixtures";
8
+
9
+ describe("Listbox keyboard interaction", () => {
10
+ describe.each(cursorKeyCases)(
11
+ "$key with the cursor on $from",
12
+ ({ key, from, expected }) => {
13
+ it(`moves the cursor to ${expected}`, async () => {
14
+ const user = userEvent.setup();
15
+ render(
16
+ <Listbox.Root type="single" defaultValue={from} aria-label="Fruits">
17
+ {fruits.map((fruit) => (
18
+ <Listbox.Option key={fruit.value} value={fruit.value}>
19
+ {fruit.label}
20
+ </Listbox.Option>
21
+ ))}
22
+ </Listbox.Root>,
23
+ );
24
+
25
+ await user.tab();
26
+ await user.keyboard(key);
27
+
28
+ expect(screen.getByRole("listbox")).toHaveAttribute(
29
+ "aria-activedescendant",
30
+ screen.getByRole("option", { name: expected }).id,
31
+ );
32
+ });
33
+ },
34
+ );
35
+
36
+ it("does not change the selection while the cursor moves", async () => {
37
+ const user = userEvent.setup();
38
+ const onValueChange = vi.fn();
39
+ render(
40
+ <Listbox.Root
41
+ type="single"
42
+ defaultValue="apple"
43
+ onValueChange={onValueChange}
44
+ aria-label="Fruits"
45
+ >
46
+ {fruits.map((fruit) => (
47
+ <Listbox.Option key={fruit.value} value={fruit.value}>
48
+ {fruit.label}
49
+ </Listbox.Option>
50
+ ))}
51
+ </Listbox.Root>,
52
+ );
53
+
54
+ await user.tab();
55
+ await user.keyboard("{ArrowDown}");
56
+
57
+ expect(onValueChange).not.toHaveBeenCalled();
58
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
59
+ "aria-selected",
60
+ "true",
61
+ );
62
+ });
63
+
64
+ it.each(["{Enter}", " "])("selects the cursor option with %s", async (key) => {
65
+ const user = userEvent.setup();
66
+ const onValueChange = vi.fn();
67
+ render(
68
+ <Listbox.Root
69
+ type="single"
70
+ onValueChange={onValueChange}
71
+ aria-label="Fruits"
72
+ >
73
+ {fruits.map((fruit) => (
74
+ <Listbox.Option key={fruit.value} value={fruit.value}>
75
+ {fruit.label}
76
+ </Listbox.Option>
77
+ ))}
78
+ </Listbox.Root>,
79
+ );
80
+
81
+ await user.tab();
82
+ await user.keyboard("{ArrowDown}");
83
+ await user.keyboard(key);
84
+
85
+ expect(onValueChange).toHaveBeenCalledWith("banana");
86
+ expect(screen.getByRole("option", { name: "Banana" })).toHaveAttribute(
87
+ "aria-selected",
88
+ "true",
89
+ );
90
+ });
91
+
92
+ it("scrolls the newly active option into view", async () => {
93
+ const user = userEvent.setup();
94
+ render(
95
+ <Listbox.Root type="single" defaultValue="apple" aria-label="Fruits">
96
+ {fruits.map((fruit) => (
97
+ <Listbox.Option key={fruit.value} value={fruit.value}>
98
+ {fruit.label}
99
+ </Listbox.Option>
100
+ ))}
101
+ </Listbox.Root>,
102
+ );
103
+
104
+ const banana = screen.getByRole("option", { name: "Banana" });
105
+ const scrollIntoView = vi.spyOn(banana, "scrollIntoView");
106
+
107
+ await user.tab();
108
+ await user.keyboard("{ArrowDown}");
109
+
110
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
111
+ });
112
+ });
@@ -0,0 +1,86 @@
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 { typeaheadOptions } from "./Listbox.fixtures";
8
+
9
+ function renderListbox(props: Record<string, unknown> = {}) {
10
+ return render(
11
+ <Listbox.Root type="single" aria-label="Fruits" {...props}>
12
+ {typeaheadOptions.map((fruit) => (
13
+ <Listbox.Option key={fruit.value} value={fruit.value}>
14
+ {fruit.label}
15
+ </Listbox.Option>
16
+ ))}
17
+ </Listbox.Root>,
18
+ );
19
+ }
20
+
21
+ const cursorIsOn = (name: string) =>
22
+ expect(screen.getByRole("listbox")).toHaveAttribute(
23
+ "aria-activedescendant",
24
+ screen.getByRole("option", { name }).id,
25
+ );
26
+
27
+ /**
28
+ * Chorded shortcuts belong to the browser and to the consumer — APG's
29
+ * rearrangeable example binds Alt+Arrow to its toolbar. The listbox must not
30
+ * eat them: a bare "a" is typeahead, but Ctrl+A is select-all.
31
+ */
32
+ describe("Listbox modifier keys", () => {
33
+ it("leaves Ctrl+A alone rather than treating it as typeahead", async () => {
34
+ const user = userEvent.setup();
35
+ renderListbox();
36
+
37
+ await user.tab();
38
+ cursorIsOn("Apple");
39
+
40
+ await user.keyboard("{Control>}a{/Control}");
41
+
42
+ cursorIsOn("Apple");
43
+ });
44
+
45
+ it("leaves Cmd+A alone rather than treating it as typeahead", async () => {
46
+ const user = userEvent.setup();
47
+ renderListbox();
48
+
49
+ await user.tab();
50
+ await user.keyboard("{Meta>}a{/Meta}");
51
+
52
+ cursorIsOn("Apple");
53
+ });
54
+
55
+ it("leaves Alt+Arrow for the consumer's own shortcut", async () => {
56
+ const user = userEvent.setup();
57
+ const onKeyDown = vi.fn();
58
+ renderListbox({ onKeyDown });
59
+
60
+ await user.tab();
61
+ await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
62
+
63
+ cursorIsOn("Apple");
64
+ expect(onKeyDown).toHaveBeenCalled();
65
+ });
66
+
67
+ it("still runs typeahead for an unmodified character", async () => {
68
+ const user = userEvent.setup();
69
+ renderListbox();
70
+
71
+ await user.tab();
72
+ await user.keyboard("b");
73
+
74
+ cursorIsOn("Banana");
75
+ });
76
+
77
+ it("still moves the cursor for an unmodified arrow", async () => {
78
+ const user = userEvent.setup();
79
+ renderListbox();
80
+
81
+ await user.tab();
82
+ await user.keyboard("{ArrowDown}");
83
+
84
+ cursorIsOn("Apricot");
85
+ });
86
+ });