@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.
- package/README.md +15 -2
- package/package.json +1 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +1 -1
- package/src/Accordion/Accordion.tsx +7 -7
- package/src/Accordion/README.md +2 -2
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +3 -3
- package/src/Breadcrumb/Breadcrumb.tsx +7 -7
- package/src/Breadcrumb/README.md +2 -2
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +1 -1
- package/src/Breadcrumb/types.ts +1 -1
- package/src/Button/__tests__/Button.contract.test.tsx +1 -1
- package/src/Carousel/Carousel.tsx +6 -6
- package/src/Carousel/README.md +22 -22
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +5 -5
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +2 -2
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +1 -1
- package/src/Carousel/loopEngine.ts +1 -1
- package/src/Carousel/types.ts +1 -1
- package/src/Checkbox/Checkbox.tsx +2 -2
- package/src/CheckboxCard/CheckboxCard.tsx +1 -1
- package/src/CheckboxCard/README.md +1 -1
- package/src/Collapsible/README.md +2 -2
- package/src/Combobox/Combobox.tsx +508 -0
- package/src/Combobox/ComboboxContext.ts +23 -0
- package/src/Combobox/README.md +170 -0
- package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
- package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
- package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
- package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
- package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
- package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
- package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
- package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
- package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
- package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
- package/src/Combobox/hooks/index.ts +1 -0
- package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
- package/src/Combobox/index.ts +3 -0
- package/src/Combobox/types.ts +133 -0
- package/src/ContextMenu/ContextMenu.tsx +3 -3
- package/src/ContextMenu/README.md +1 -1
- package/src/DirectionProvider/DirectionProvider.tsx +5 -5
- package/src/DirectionProvider/README.md +4 -4
- package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
- package/src/Drawer/Drawer.tsx +5 -5
- package/src/Drawer/README.md +2 -2
- package/src/Dropdown/Dropdown.tsx +2 -2
- package/src/Dropdown/README.md +7 -5
- package/src/EmptyState/README.md +2 -2
- package/src/Field/README.md +1 -1
- package/src/Field/hooks/useFieldProps.ts +1 -1
- package/src/Fieldset/Fieldset.tsx +3 -3
- package/src/Fieldset/README.md +2 -2
- package/src/Input/Input.tsx +1 -1
- package/src/Input/README.md +2 -2
- package/src/Listbox/Listbox.tsx +475 -0
- package/src/Listbox/ListboxContext.ts +48 -0
- package/src/Listbox/README.md +248 -0
- package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
- package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
- package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
- package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
- package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
- package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
- package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
- package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
- package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
- package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
- package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
- package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
- package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
- package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
- package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
- package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
- package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
- package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
- package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
- package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
- package/src/Listbox/constants.ts +2 -0
- package/src/Listbox/hooks/index.ts +1 -0
- package/src/Listbox/hooks/useListboxRoot.ts +407 -0
- package/src/Listbox/index.ts +3 -0
- package/src/Listbox/types.ts +238 -0
- package/src/MillerColumns/MillerColumns.tsx +104 -30
- package/src/MillerColumns/README.md +90 -20
- package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
- package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
- package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
- package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
- package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
- package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
- package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
- package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
- package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
- package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
- package/src/MillerColumns/constants.ts +2 -0
- package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
- package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
- package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
- package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
- package/src/MillerColumns/index.ts +4 -0
- package/src/MillerColumns/types.ts +56 -6
- package/src/MillerColumns/utils.ts +69 -3
- package/src/Modal/Modal.tsx +4 -4
- package/src/Modal/README.md +5 -5
- package/src/Modal/types.ts +1 -1
- package/src/NavigationMenu/NavigationMenu.tsx +6 -6
- package/src/NavigationMenu/README.md +3 -3
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +1 -1
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +1 -1
- package/src/Popover/Popover.tsx +2 -2
- package/src/Popover/README.md +1 -1
- package/src/Portal/Portal.tsx +2 -2
- package/src/Portal/README.md +2 -2
- package/src/Progress/README.md +1 -1
- package/src/Radio/Radio.tsx +1 -1
- package/src/RadioCard/README.md +2 -1
- package/src/RadioCard/RadioCard.tsx +5 -2
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +23 -0
- package/src/RadioGroup/README.md +2 -2
- package/src/RadioGroup/RadioGroup.tsx +1 -1
- package/src/SegmentedControl/README.md +2 -2
- package/src/SegmentedControl/SegmentedControl.tsx +1 -1
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +1 -0
- package/src/Select/README.md +2 -2
- package/src/Select/Select.tsx +8 -8
- package/src/Select/__tests__/Select.placeholder.test.tsx +3 -3
- package/src/Select/__tests__/Select.typeahead.test.tsx +3 -3
- package/src/Select/__tests__/Select.value-mirror.test.tsx +1 -1
- package/src/Select/types.ts +2 -2
- package/src/SkipNav/README.md +3 -3
- package/src/SkipNav/SkipNav.tsx +5 -5
- package/src/Slider/README.md +37 -7
- package/src/Slider/Slider.tsx +9 -3
- package/src/Slot/Slot.tsx +2 -2
- package/src/Slot/__tests__/Slot.test.tsx +2 -2
- package/src/Slot/types.ts +1 -1
- package/src/SplitButton/README.md +225 -0
- package/src/SplitButton/SplitButton.tsx +457 -0
- package/src/SplitButton/SplitButtonContext.ts +26 -0
- package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
- package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
- package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
- package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
- package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
- package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
- package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
- package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
- package/src/SplitButton/index.ts +2 -0
- package/src/SplitButton/types.ts +186 -0
- package/src/Switch/README.md +1 -1
- package/src/Switch/Switch.tsx +2 -2
- package/src/Switch/types.ts +1 -1
- package/src/Table/README.md +110 -8
- package/src/Table/Table.tsx +206 -10
- package/src/Table/TableExpandableContext.ts +7 -0
- package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
- package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
- package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
- package/src/Table/types.ts +125 -0
- package/src/Tabs/README.md +4 -4
- package/src/Tabs/Tabs.tsx +5 -4
- package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
- package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
- package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
- package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
- package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
- package/src/Tabs/hooks/useTabsContent.ts +5 -0
- package/src/Tabs/hooks/useTabsRoot.ts +29 -5
- package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
- package/src/ToggleGroup/README.md +4 -4
- package/src/Tooltip/README.md +7 -7
- package/src/Tooltip/Tooltip.tsx +2 -2
- package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
- package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
- package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
- package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
- package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
- package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
- package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +40 -14
- package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
- package/src/Tree/README.md +69 -5
- package/src/Tree/Tree.tsx +1 -1
- package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
- package/src/Tree/hooks/useTreePath.ts +33 -1
- package/src/Tree/index.ts +6 -1
- package/src/Tree/utils.ts +46 -4
- package/src/VisuallyHidden/VisuallyHidden.tsx +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useTypeahead.ts +95 -0
- package/src/index.ts +6 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
- package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
- package/src/useLocalStorage/index.ts +1 -0
- package/src/useLocalStorage/useLocalStorage.ts +140 -0
- package/src/useMediaQuery/README.md +66 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
- package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
- package/src/useMediaQuery/index.ts +1 -0
- package/src/useMediaQuery/useMediaQuery.ts +49 -0
- package/src/usePagination/README.md +170 -0
- package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
- package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
- package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
- package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
- package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
- package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
- package/src/usePagination/index.ts +1 -0
- package/src/usePagination/usePagination.ts +292 -0
- package/src/utils/getKeyToActionMap.ts +1 -1
|
@@ -0,0 +1,368 @@
|
|
|
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, typeaheadOptions } from "./Listbox.fixtures";
|
|
8
|
+
|
|
9
|
+
function renderMultiple(props: Record<string, unknown> = {}) {
|
|
10
|
+
return render(
|
|
11
|
+
<Listbox.Root type="multiple" aria-label="Fruits" {...props}>
|
|
12
|
+
{fruits.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 selectionIs = (...names: string[]) =>
|
|
22
|
+
expect(
|
|
23
|
+
screen
|
|
24
|
+
.getAllByRole("option")
|
|
25
|
+
.filter((option) => option.getAttribute("aria-selected") === "true")
|
|
26
|
+
.map((option) => option.textContent),
|
|
27
|
+
).toEqual(names);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* APG's rearrangeable multi-select example implements the modifier shortcuts
|
|
31
|
+
* the pattern lists as optional. Ctrl+A is the select-all half.
|
|
32
|
+
*/
|
|
33
|
+
describe("Listbox multi-select keyboard — select all", () => {
|
|
34
|
+
it("selects every option with Ctrl+A", async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const onValueChange = vi.fn();
|
|
37
|
+
renderMultiple({ onValueChange });
|
|
38
|
+
|
|
39
|
+
await user.tab();
|
|
40
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
41
|
+
|
|
42
|
+
selectionIs("Apple", "Banana", "Cherry");
|
|
43
|
+
expect(onValueChange).toHaveBeenCalledWith(["apple", "banana", "cherry"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("deselects every option when Ctrl+A is pressed again", async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const onValueChange = vi.fn();
|
|
49
|
+
renderMultiple({ onValueChange });
|
|
50
|
+
|
|
51
|
+
await user.tab();
|
|
52
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
53
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
54
|
+
|
|
55
|
+
selectionIs();
|
|
56
|
+
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("extends a partial selection to all rather than clearing it", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
renderMultiple({ defaultValue: ["banana"] });
|
|
62
|
+
|
|
63
|
+
await user.tab();
|
|
64
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
65
|
+
|
|
66
|
+
selectionIs("Apple", "Banana", "Cherry");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("accepts Cmd+A for macOS", async () => {
|
|
70
|
+
const user = userEvent.setup();
|
|
71
|
+
renderMultiple();
|
|
72
|
+
|
|
73
|
+
await user.tab();
|
|
74
|
+
await user.keyboard("{Meta>}a{/Meta}");
|
|
75
|
+
|
|
76
|
+
selectionIs("Apple", "Banana", "Cherry");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("leaves disabled options out of select-all", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(
|
|
82
|
+
<Listbox.Root type="multiple" aria-label="Fruits">
|
|
83
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
84
|
+
<Listbox.Option value="banana" disabled>
|
|
85
|
+
Banana
|
|
86
|
+
</Listbox.Option>
|
|
87
|
+
</Listbox.Root>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await user.tab();
|
|
91
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
92
|
+
|
|
93
|
+
selectionIs("Apple");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does nothing in single-selection mode", async () => {
|
|
97
|
+
const user = userEvent.setup();
|
|
98
|
+
const onValueChange = vi.fn();
|
|
99
|
+
render(
|
|
100
|
+
<Listbox.Root type="single" onValueChange={onValueChange} aria-label="Fruits">
|
|
101
|
+
{fruits.map((fruit) => (
|
|
102
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
103
|
+
{fruit.label}
|
|
104
|
+
</Listbox.Option>
|
|
105
|
+
))}
|
|
106
|
+
</Listbox.Root>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await user.tab();
|
|
110
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
111
|
+
|
|
112
|
+
selectionIs();
|
|
113
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Shift+Arrow "moves focus and selects" the option it lands on. Deliberately
|
|
119
|
+
* does NOT wrap, unlike the plain arrows: extending a range off the end and
|
|
120
|
+
* round to the top selects something the user never travelled past.
|
|
121
|
+
*/
|
|
122
|
+
describe("Listbox multi-select keyboard — Shift+Arrow", () => {
|
|
123
|
+
it("selects the option it moves onto", async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
const onValueChange = vi.fn();
|
|
126
|
+
renderMultiple({ onValueChange });
|
|
127
|
+
|
|
128
|
+
await user.tab();
|
|
129
|
+
await user.keyboard("{Shift>}{ArrowDown}{/Shift}");
|
|
130
|
+
|
|
131
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
132
|
+
"aria-activedescendant",
|
|
133
|
+
screen.getByRole("option", { name: "Banana" }).id,
|
|
134
|
+
);
|
|
135
|
+
selectionIs("Banana");
|
|
136
|
+
expect(onValueChange).toHaveBeenCalledWith(["banana"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("accumulates a contiguous range", async () => {
|
|
140
|
+
const user = userEvent.setup();
|
|
141
|
+
renderMultiple();
|
|
142
|
+
|
|
143
|
+
await user.tab();
|
|
144
|
+
await user.keyboard("{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
|
|
145
|
+
|
|
146
|
+
selectionIs("Banana", "Cherry");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("extends upwards too", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
renderMultiple({ defaultValue: ["cherry"] });
|
|
152
|
+
|
|
153
|
+
await user.tab();
|
|
154
|
+
await user.keyboard("{Shift>}{ArrowUp}{/Shift}");
|
|
155
|
+
|
|
156
|
+
selectionIs("Banana", "Cherry");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("does not re-announce an option that is already selected", async () => {
|
|
160
|
+
const user = userEvent.setup();
|
|
161
|
+
const onValueChange = vi.fn();
|
|
162
|
+
renderMultiple({ defaultValue: ["apple", "banana"], onValueChange });
|
|
163
|
+
|
|
164
|
+
await user.tab();
|
|
165
|
+
await user.keyboard("{Shift>}{ArrowDown}{/Shift}");
|
|
166
|
+
|
|
167
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
168
|
+
"aria-activedescendant",
|
|
169
|
+
screen.getByRole("option", { name: "Banana" }).id,
|
|
170
|
+
);
|
|
171
|
+
selectionIs("Apple", "Banana");
|
|
172
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("stops at the last option instead of wrapping", async () => {
|
|
176
|
+
const user = userEvent.setup();
|
|
177
|
+
renderMultiple({ defaultValue: ["cherry"] });
|
|
178
|
+
|
|
179
|
+
await user.tab();
|
|
180
|
+
await user.keyboard("{Shift>}{ArrowDown}{/Shift}");
|
|
181
|
+
|
|
182
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
183
|
+
"aria-activedescendant",
|
|
184
|
+
screen.getByRole("option", { name: "Cherry" }).id,
|
|
185
|
+
);
|
|
186
|
+
selectionIs("Cherry");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("skips a disabled option while extending", async () => {
|
|
190
|
+
const user = userEvent.setup();
|
|
191
|
+
render(
|
|
192
|
+
<Listbox.Root type="multiple" aria-label="Fruits">
|
|
193
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
194
|
+
<Listbox.Option value="banana" disabled>
|
|
195
|
+
Banana
|
|
196
|
+
</Listbox.Option>
|
|
197
|
+
<Listbox.Option value="cherry">Cherry</Listbox.Option>
|
|
198
|
+
</Listbox.Root>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await user.tab();
|
|
202
|
+
await user.keyboard("{Shift>}{ArrowDown}{/Shift}");
|
|
203
|
+
|
|
204
|
+
selectionIs("Cherry");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("is a plain cursor move in single-selection mode", async () => {
|
|
208
|
+
const user = userEvent.setup();
|
|
209
|
+
const onValueChange = vi.fn();
|
|
210
|
+
render(
|
|
211
|
+
<Listbox.Root type="single" onValueChange={onValueChange} aria-label="Fruits">
|
|
212
|
+
{fruits.map((fruit) => (
|
|
213
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
214
|
+
{fruit.label}
|
|
215
|
+
</Listbox.Option>
|
|
216
|
+
))}
|
|
217
|
+
</Listbox.Root>,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
await user.tab();
|
|
221
|
+
await user.keyboard("{Shift>}{ArrowDown}{/Shift}");
|
|
222
|
+
|
|
223
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
224
|
+
"aria-activedescendant",
|
|
225
|
+
screen.getByRole("option", { name: "Banana" }).id,
|
|
226
|
+
);
|
|
227
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* APG: Ctrl+Shift+Home/End selects from the focused option through to the
|
|
233
|
+
* first/last option, and moves the cursor to that edge. Selections outside the
|
|
234
|
+
* swept range survive.
|
|
235
|
+
*/
|
|
236
|
+
describe("Listbox multi-select keyboard — Ctrl+Shift+Home/End", () => {
|
|
237
|
+
it("sweeps the selection to the end and parks the cursor there", async () => {
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
renderMultiple({ defaultValue: ["banana"] });
|
|
240
|
+
|
|
241
|
+
await user.tab();
|
|
242
|
+
await user.keyboard("{Control>}{Shift>}{End}{/Shift}{/Control}");
|
|
243
|
+
|
|
244
|
+
selectionIs("Banana", "Cherry");
|
|
245
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
246
|
+
"aria-activedescendant",
|
|
247
|
+
screen.getByRole("option", { name: "Cherry" }).id,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("sweeps the selection to the beginning and parks the cursor there", async () => {
|
|
252
|
+
const user = userEvent.setup();
|
|
253
|
+
renderMultiple({ defaultValue: ["banana"] });
|
|
254
|
+
|
|
255
|
+
await user.tab();
|
|
256
|
+
await user.keyboard("{Control>}{Shift>}{Home}{/Shift}{/Control}");
|
|
257
|
+
|
|
258
|
+
selectionIs("Apple", "Banana");
|
|
259
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
260
|
+
"aria-activedescendant",
|
|
261
|
+
screen.getByRole("option", { name: "Apple" }).id,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps selections that lie outside the swept range", async () => {
|
|
266
|
+
const user = userEvent.setup();
|
|
267
|
+
render(
|
|
268
|
+
<Listbox.Root type="multiple" defaultValue={["banana"]} aria-label="Fruits">
|
|
269
|
+
{typeaheadOptions.map((fruit) => (
|
|
270
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
271
|
+
{fruit.label}
|
|
272
|
+
</Listbox.Option>
|
|
273
|
+
))}
|
|
274
|
+
</Listbox.Root>,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
await user.tab();
|
|
278
|
+
await user.keyboard("{ArrowUp}{ArrowUp}");
|
|
279
|
+
await user.keyboard("{Control>}{Shift>}{Home}{/Shift}{/Control}");
|
|
280
|
+
|
|
281
|
+
selectionIs("Apple", "Apricot", "Banana");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("leaves disabled options unselected while sweeping", async () => {
|
|
285
|
+
const user = userEvent.setup();
|
|
286
|
+
render(
|
|
287
|
+
<Listbox.Root type="multiple" defaultValue={["cherry"]} aria-label="Fruits">
|
|
288
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
289
|
+
<Listbox.Option value="banana" disabled>
|
|
290
|
+
Banana
|
|
291
|
+
</Listbox.Option>
|
|
292
|
+
<Listbox.Option value="cherry">Cherry</Listbox.Option>
|
|
293
|
+
</Listbox.Root>,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await user.tab();
|
|
297
|
+
await user.keyboard("{Control>}{Shift>}{Home}{/Shift}{/Control}");
|
|
298
|
+
|
|
299
|
+
selectionIs("Apple", "Cherry");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("does nothing in single-selection mode", async () => {
|
|
303
|
+
const user = userEvent.setup();
|
|
304
|
+
const onValueChange = vi.fn();
|
|
305
|
+
render(
|
|
306
|
+
<Listbox.Root
|
|
307
|
+
type="single"
|
|
308
|
+
defaultValue="banana"
|
|
309
|
+
onValueChange={onValueChange}
|
|
310
|
+
aria-label="Fruits"
|
|
311
|
+
>
|
|
312
|
+
{fruits.map((fruit) => (
|
|
313
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
314
|
+
{fruit.label}
|
|
315
|
+
</Listbox.Option>
|
|
316
|
+
))}
|
|
317
|
+
</Listbox.Root>,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await user.tab();
|
|
321
|
+
await user.keyboard("{Control>}{Shift>}{End}{/Shift}{/Control}");
|
|
322
|
+
|
|
323
|
+
selectionIs("Banana");
|
|
324
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Both range operations pivot on the cursor. When options stream in after the
|
|
330
|
+
* listbox took focus there is no cursor to pivot on, so they must decline
|
|
331
|
+
* rather than sweep from a phantom position.
|
|
332
|
+
*/
|
|
333
|
+
describe("Listbox multi-select keyboard — no cursor to pivot on", () => {
|
|
334
|
+
it.each([
|
|
335
|
+
["Shift+ArrowDown", "{Shift>}{ArrowDown}{/Shift}"],
|
|
336
|
+
["Ctrl+Shift+End", "{Control>}{Shift>}{End}{/Shift}{/Control}"],
|
|
337
|
+
])("%s selects nothing", async (_name, keys) => {
|
|
338
|
+
const user = userEvent.setup();
|
|
339
|
+
const onValueChange = vi.fn();
|
|
340
|
+
const { rerender } = render(
|
|
341
|
+
<Listbox.Root
|
|
342
|
+
type="multiple"
|
|
343
|
+
onValueChange={onValueChange}
|
|
344
|
+
aria-label="Fruits"
|
|
345
|
+
/>,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await user.tab();
|
|
349
|
+
rerender(
|
|
350
|
+
<Listbox.Root
|
|
351
|
+
type="multiple"
|
|
352
|
+
onValueChange={onValueChange}
|
|
353
|
+
aria-label="Fruits"
|
|
354
|
+
>
|
|
355
|
+
{fruits.map((fruit) => (
|
|
356
|
+
<Listbox.Option key={fruit.value} value={fruit.value}>
|
|
357
|
+
{fruit.label}
|
|
358
|
+
</Listbox.Option>
|
|
359
|
+
))}
|
|
360
|
+
</Listbox.Root>,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
await user.keyboard(keys);
|
|
364
|
+
|
|
365
|
+
selectionIs();
|
|
366
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
function renderMultiple(props: { onValueChange?: (value: string[]) => void }) {
|
|
10
|
+
return render(
|
|
11
|
+
<Listbox.Root
|
|
12
|
+
type="multiple"
|
|
13
|
+
defaultValue={["apple"]}
|
|
14
|
+
aria-label="Fruits"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
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
|
+
|
|
26
|
+
describe("Listbox multiple mode", () => {
|
|
27
|
+
it("advertises itself as multi-selectable", () => {
|
|
28
|
+
renderMultiple({});
|
|
29
|
+
|
|
30
|
+
expect(screen.getByRole("listbox")).toHaveAttribute(
|
|
31
|
+
"aria-multiselectable",
|
|
32
|
+
"true",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("leaves a single-select listbox without aria-multiselectable", () => {
|
|
37
|
+
render(
|
|
38
|
+
<Listbox.Root type="single" aria-label="Fruits">
|
|
39
|
+
<Listbox.Option value="apple">Apple</Listbox.Option>
|
|
40
|
+
</Listbox.Root>,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(screen.getByRole("listbox")).not.toHaveAttribute(
|
|
44
|
+
"aria-multiselectable",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("adds to the selection rather than replacing it", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const onValueChange = vi.fn();
|
|
51
|
+
renderMultiple({ onValueChange });
|
|
52
|
+
|
|
53
|
+
await user.click(screen.getByRole("option", { name: "Cherry" }));
|
|
54
|
+
|
|
55
|
+
expect(onValueChange).toHaveBeenCalledWith(["apple", "cherry"]);
|
|
56
|
+
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
|
|
57
|
+
"aria-selected",
|
|
58
|
+
"true",
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByRole("option", { name: "Cherry" })).toHaveAttribute(
|
|
61
|
+
"aria-selected",
|
|
62
|
+
"true",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("toggles a selected option back off", async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
const onValueChange = vi.fn();
|
|
69
|
+
renderMultiple({ onValueChange });
|
|
70
|
+
|
|
71
|
+
await user.click(screen.getByRole("option", { name: "Apple" }));
|
|
72
|
+
|
|
73
|
+
expect(onValueChange).toHaveBeenCalledWith([]);
|
|
74
|
+
expect(screen.getByRole("option", { name: "Apple" })).toHaveAttribute(
|
|
75
|
+
"aria-selected",
|
|
76
|
+
"false",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("toggles the cursor option with Space", async () => {
|
|
81
|
+
const user = userEvent.setup();
|
|
82
|
+
const onValueChange = vi.fn();
|
|
83
|
+
renderMultiple({ onValueChange });
|
|
84
|
+
|
|
85
|
+
await user.tab();
|
|
86
|
+
await user.keyboard("{ArrowDown}");
|
|
87
|
+
await user.keyboard(" ");
|
|
88
|
+
|
|
89
|
+
expect(onValueChange).toHaveBeenCalledWith(["apple", "banana"]);
|
|
90
|
+
});
|
|
91
|
+
});
|