@kinetixui/ui 0.16.1 → 0.18.0
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/dist/index.d.ts +4 -4
- package/dist/index.js +498 -248
- package/package.json +3 -2
- package/src/components/audio-player.tsx +2 -2
- package/src/components/badge.tsx +2 -2
- package/src/components/banner.tsx +2 -2
- package/src/components/button.tsx +18 -14
- package/src/components/calendar.tsx +1 -1
- package/src/components/chart.tsx +2 -2
- package/src/components/checkbox.tsx +2 -2
- package/src/components/circular-progress.tsx +1 -1
- package/src/components/code-block.tsx +1 -1
- package/src/components/color-picker.tsx +6 -4
- package/src/components/data-grid.tsx +192 -11
- package/src/components/diff-viewer.tsx +9 -8
- package/src/components/fab.tsx +1 -1
- package/src/components/file-upload.tsx +13 -15
- package/src/components/inform.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-otp.tsx +2 -1
- package/src/components/input.tsx +3 -3
- package/src/components/json-viewer.tsx +12 -10
- package/src/components/kbd.tsx +1 -1
- package/src/components/list.tsx +41 -27
- package/src/components/markdown-editor.tsx +3 -2
- package/src/components/message-bubble.tsx +3 -3
- package/src/components/modal.tsx +2 -2
- package/src/components/multi-select.tsx +28 -5
- package/src/components/native-select.tsx +3 -3
- package/src/components/notification-center.tsx +2 -2
- package/src/components/progress.tsx +1 -1
- package/src/components/radio-group.tsx +3 -3
- package/src/components/scroll-area.tsx +1 -1
- package/src/components/select.tsx +3 -3
- package/src/components/skeleton.tsx +1 -1
- package/src/components/slider.tsx +8 -3
- package/src/components/sonner.tsx +1 -1
- package/src/components/spinner.tsx +3 -3
- package/src/components/stepper.tsx +3 -3
- package/src/components/switch.tsx +1 -1
- package/src/components/tab-bar.tsx +1 -1
- package/src/components/table-of-contents.tsx +1 -1
- package/src/components/tag.tsx +1 -1
- package/src/components/textarea.tsx +3 -3
- package/src/components/timeline.tsx +1 -1
- package/src/components/tooltip.tsx +1 -1
- package/src/components/tour.tsx +45 -2
- package/src/components/virtual-list.tsx +2 -1
- package/src/components-a11y.test.tsx +69 -0
- package/src/components-behavior.test.tsx +1 -1
- package/src/components-keyboard.test.tsx +774 -0
- package/src/lib/utils.test.ts +12 -0
- package/src/lib/utils.ts +22 -1
- package/src/stories/Checkbox.stories.tsx +1 -1
- package/src/stories/CircularProgress.stories.tsx +4 -4
- package/src/stories/InputOTP.stories.tsx +2 -2
- package/src/stories/List.stories.tsx +2 -2
- package/src/stories/MarkdownEditor.stories.tsx +1 -1
- package/src/stories/MultiSelect.stories.tsx +1 -0
- package/src/stories/NativeSelect.stories.tsx +1 -1
- package/src/stories/NumberInput.stories.tsx +3 -3
- package/src/stories/Progress.stories.tsx +3 -3
- package/src/stories/Select.stories.tsx +2 -2
- package/src/stories/Slider.stories.tsx +3 -3
- package/src/stories/Switch.stories.tsx +1 -1
- package/src/stories/ToggleGroup.stories.tsx +6 -6
- package/tailwind.config.ts +20 -1
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Checkbox } from "./components/checkbox";
|
|
6
|
+
import { ColorPicker } from "./components/color-picker";
|
|
7
|
+
import { FileUpload } from "./components/file-upload";
|
|
8
|
+
import { List, ListItem } from "./components/list";
|
|
9
|
+
import { DataGrid, type DataGridColumn } from "./components/data-grid";
|
|
10
|
+
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
DialogTrigger,
|
|
17
|
+
} from "./components/dialog";
|
|
18
|
+
import {
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuTrigger,
|
|
23
|
+
} from "./components/dropdown-menu";
|
|
24
|
+
import { MultiSelect } from "./components/multi-select";
|
|
25
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
|
|
26
|
+
import { RadioGroup, RadioGroupItem } from "./components/radio-group";
|
|
27
|
+
import { Slider } from "./components/slider";
|
|
28
|
+
import { Switch } from "./components/switch";
|
|
29
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/tabs";
|
|
30
|
+
import { Tour } from "./components/tour";
|
|
31
|
+
import { TreeItem, TreeView } from "./components/tree-view";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Keyboard / focus behaviour — the runtime half of accessibility that the static
|
|
35
|
+
* contrast check and axe can't judge: that a control is reachable and operable
|
|
36
|
+
* from the keyboard, that overlays move focus in and give it back, and that
|
|
37
|
+
* arrow keys respect reading direction. jsdom has no layout, so these assert
|
|
38
|
+
* on focus and ARIA state, not on geometry.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
describe("Dialog", () => {
|
|
42
|
+
function Example() {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<button>before</button>
|
|
46
|
+
<Dialog>
|
|
47
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
48
|
+
<DialogContent>
|
|
49
|
+
<DialogTitle>Edit profile</DialogTitle>
|
|
50
|
+
<DialogDescription>Change your details.</DialogDescription>
|
|
51
|
+
<input aria-label="Name" />
|
|
52
|
+
<button>Save</button>
|
|
53
|
+
</DialogContent>
|
|
54
|
+
</Dialog>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it("opens from the keyboard and moves focus inside", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(<Example />);
|
|
62
|
+
await user.tab(); // "before"
|
|
63
|
+
await user.tab(); // trigger
|
|
64
|
+
expect(screen.getByRole("button", { name: "Open" })).toHaveFocus();
|
|
65
|
+
await user.keyboard("{Enter}");
|
|
66
|
+
|
|
67
|
+
const dialog = await screen.findByRole("dialog", { name: "Edit profile" });
|
|
68
|
+
expect(dialog).toHaveAccessibleDescription("Change your details.");
|
|
69
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("traps Tab inside the dialog", async () => {
|
|
73
|
+
const user = userEvent.setup();
|
|
74
|
+
render(<Example />);
|
|
75
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
76
|
+
const dialog = await screen.findByRole("dialog");
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < 8; i++) {
|
|
79
|
+
await user.tab();
|
|
80
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < 8; i++) {
|
|
83
|
+
await user.tab({ shift: true });
|
|
84
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("closes on Escape and returns focus to the trigger", async () => {
|
|
89
|
+
const user = userEvent.setup();
|
|
90
|
+
render(<Example />);
|
|
91
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
92
|
+
await user.click(trigger);
|
|
93
|
+
await screen.findByRole("dialog");
|
|
94
|
+
|
|
95
|
+
await user.keyboard("{Escape}");
|
|
96
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
|
97
|
+
expect(trigger).toHaveFocus();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("DropdownMenu", () => {
|
|
102
|
+
function Example() {
|
|
103
|
+
return (
|
|
104
|
+
<DropdownMenu>
|
|
105
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
106
|
+
<DropdownMenuContent>
|
|
107
|
+
<DropdownMenuItem>Edit</DropdownMenuItem>
|
|
108
|
+
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
|
109
|
+
<DropdownMenuItem>Delete</DropdownMenuItem>
|
|
110
|
+
</DropdownMenuContent>
|
|
111
|
+
</DropdownMenu>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("opens with Enter, walks items with the arrow keys, closes with Escape", async () => {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
render(<Example />);
|
|
118
|
+
const trigger = screen.getByRole("button", { name: "Actions" });
|
|
119
|
+
trigger.focus();
|
|
120
|
+
await user.keyboard("{Enter}");
|
|
121
|
+
|
|
122
|
+
const menu = await screen.findByRole("menu");
|
|
123
|
+
expect(menu).toBeInTheDocument();
|
|
124
|
+
await waitFor(() => expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveFocus());
|
|
125
|
+
|
|
126
|
+
await user.keyboard("{ArrowDown}");
|
|
127
|
+
expect(screen.getByRole("menuitem", { name: "Duplicate" })).toHaveFocus();
|
|
128
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // Delete, then the end: Radix menus don't wrap by default
|
|
129
|
+
expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
|
|
130
|
+
await user.keyboard("{ArrowUp}");
|
|
131
|
+
expect(screen.getByRole("menuitem", { name: "Duplicate" })).toHaveFocus();
|
|
132
|
+
await user.keyboard("{Home}");
|
|
133
|
+
expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveFocus();
|
|
134
|
+
|
|
135
|
+
await user.keyboard("{Escape}");
|
|
136
|
+
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
|
|
137
|
+
expect(trigger).toHaveFocus();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("Popover", () => {
|
|
142
|
+
it("closes on Escape and returns focus to its trigger", async () => {
|
|
143
|
+
const user = userEvent.setup();
|
|
144
|
+
render(
|
|
145
|
+
<Popover>
|
|
146
|
+
<PopoverTrigger>Info</PopoverTrigger>
|
|
147
|
+
<PopoverContent>
|
|
148
|
+
<button>Inside</button>
|
|
149
|
+
</PopoverContent>
|
|
150
|
+
</Popover>,
|
|
151
|
+
);
|
|
152
|
+
const trigger = screen.getByRole("button", { name: "Info" });
|
|
153
|
+
await user.click(trigger);
|
|
154
|
+
expect(await screen.findByRole("button", { name: "Inside" })).toBeInTheDocument();
|
|
155
|
+
|
|
156
|
+
await user.keyboard("{Escape}");
|
|
157
|
+
await waitFor(() => expect(screen.queryByRole("button", { name: "Inside" })).not.toBeInTheDocument());
|
|
158
|
+
expect(trigger).toHaveFocus();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("Tabs", () => {
|
|
163
|
+
function Example({ dir }: { dir?: "ltr" | "rtl" }) {
|
|
164
|
+
const tabs = (
|
|
165
|
+
<Tabs defaultValue="a" dir={dir}>
|
|
166
|
+
<TabsList>
|
|
167
|
+
<TabsTrigger value="a">One</TabsTrigger>
|
|
168
|
+
<TabsTrigger value="b">Two</TabsTrigger>
|
|
169
|
+
<TabsTrigger value="c">Three</TabsTrigger>
|
|
170
|
+
</TabsList>
|
|
171
|
+
<TabsContent value="a">A</TabsContent>
|
|
172
|
+
<TabsContent value="b">B</TabsContent>
|
|
173
|
+
<TabsContent value="c">C</TabsContent>
|
|
174
|
+
</Tabs>
|
|
175
|
+
);
|
|
176
|
+
return dir ? <KinetixDirectionProvider dir={dir}>{tabs}</KinetixDirectionProvider> : tabs;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
it("is a single tab stop; ArrowRight moves to and selects the next tab", async () => {
|
|
180
|
+
const user = userEvent.setup();
|
|
181
|
+
render(<Example />);
|
|
182
|
+
await user.tab();
|
|
183
|
+
expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
|
|
184
|
+
expect(screen.getByRole("tab", { name: "Two" })).toHaveAttribute("tabindex", "-1");
|
|
185
|
+
|
|
186
|
+
await user.keyboard("{ArrowRight}");
|
|
187
|
+
expect(screen.getByRole("tab", { name: "Two" })).toHaveFocus();
|
|
188
|
+
expect(screen.getByRole("tab", { name: "Two" })).toHaveAttribute("aria-selected", "true");
|
|
189
|
+
expect(screen.getByRole("tabpanel")).toHaveTextContent("B");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("supports Home and End", async () => {
|
|
193
|
+
const user = userEvent.setup();
|
|
194
|
+
render(<Example />);
|
|
195
|
+
await user.tab();
|
|
196
|
+
await user.keyboard("{End}");
|
|
197
|
+
expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
|
|
198
|
+
await user.keyboard("{Home}");
|
|
199
|
+
expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("mirrors the arrow keys under RTL", async () => {
|
|
203
|
+
const user = userEvent.setup();
|
|
204
|
+
render(<Example dir="rtl" />);
|
|
205
|
+
await user.tab();
|
|
206
|
+
expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
|
|
207
|
+
|
|
208
|
+
// In a right-to-left list the *next* item is to the left.
|
|
209
|
+
await user.keyboard("{ArrowLeft}");
|
|
210
|
+
expect(screen.getByRole("tab", { name: "Two" })).toHaveFocus();
|
|
211
|
+
await user.keyboard("{ArrowRight}");
|
|
212
|
+
expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("RadioGroup", () => {
|
|
217
|
+
it("is one tab stop; arrows move and select; Tab leaves the group", async () => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
render(
|
|
220
|
+
<>
|
|
221
|
+
<RadioGroup defaultValue="a" aria-label="Plan">
|
|
222
|
+
<RadioGroupItem value="a" aria-label="Free" />
|
|
223
|
+
<RadioGroupItem value="b" aria-label="Pro" />
|
|
224
|
+
<RadioGroupItem value="c" aria-label="Team" />
|
|
225
|
+
</RadioGroup>
|
|
226
|
+
<button>after</button>
|
|
227
|
+
</>,
|
|
228
|
+
);
|
|
229
|
+
await user.tab();
|
|
230
|
+
expect(screen.getByRole("radio", { name: "Free" })).toHaveFocus();
|
|
231
|
+
|
|
232
|
+
// Radix moves focus on a timeout after keydown and only selects if the arrow key is still
|
|
233
|
+
// held when focus lands — user-event releases synchronously, so hold it like a real press.
|
|
234
|
+
await user.keyboard("{ArrowDown>}");
|
|
235
|
+
await waitFor(() => expect(screen.getByRole("radio", { name: "Pro" })).toHaveFocus());
|
|
236
|
+
await user.keyboard("{/ArrowDown}");
|
|
237
|
+
expect(screen.getByRole("radio", { name: "Pro" })).toBeChecked();
|
|
238
|
+
|
|
239
|
+
await user.tab();
|
|
240
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("Checkbox and Switch", () => {
|
|
245
|
+
it("Checkbox toggles with Space, not Enter", async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
247
|
+
render(<Checkbox aria-label="Accept terms" />);
|
|
248
|
+
await user.tab();
|
|
249
|
+
const box = screen.getByRole("checkbox", { name: "Accept terms" });
|
|
250
|
+
expect(box).toHaveFocus();
|
|
251
|
+
|
|
252
|
+
await user.keyboard("{Enter}");
|
|
253
|
+
expect(box).toHaveAttribute("aria-checked", "false");
|
|
254
|
+
await user.keyboard(" ");
|
|
255
|
+
expect(box).toHaveAttribute("aria-checked", "true");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("Switch toggles with Space and exposes role=switch", async () => {
|
|
259
|
+
const user = userEvent.setup();
|
|
260
|
+
render(<Switch aria-label="Notifications" />);
|
|
261
|
+
await user.tab();
|
|
262
|
+
const sw = screen.getByRole("switch", { name: "Notifications" });
|
|
263
|
+
expect(sw).toHaveFocus();
|
|
264
|
+
expect(sw).toHaveAttribute("aria-checked", "false");
|
|
265
|
+
await user.keyboard(" ");
|
|
266
|
+
expect(sw).toHaveAttribute("aria-checked", "true");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("a disabled Switch is skipped by Tab", async () => {
|
|
270
|
+
const user = userEvent.setup();
|
|
271
|
+
render(
|
|
272
|
+
<>
|
|
273
|
+
<button>first</button>
|
|
274
|
+
<Switch aria-label="Off limits" disabled />
|
|
275
|
+
<button>last</button>
|
|
276
|
+
</>,
|
|
277
|
+
);
|
|
278
|
+
await user.tab();
|
|
279
|
+
await user.tab();
|
|
280
|
+
expect(screen.getByRole("button", { name: "last" })).toHaveFocus();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Custom widgets ─────────────────────────────────────────────────────────
|
|
285
|
+
// These hand-roll their keyboard model (no Radix primitive underneath), so they
|
|
286
|
+
// are where a regression or a gap is most likely.
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
describe("TreeView", () => {
|
|
290
|
+
function Example() {
|
|
291
|
+
const [selected, setSelected] = React.useState<string | undefined>();
|
|
292
|
+
return (
|
|
293
|
+
<TreeView aria-label="Files" selected={selected} onSelectedChange={setSelected} defaultExpanded={[]}>
|
|
294
|
+
<TreeItem value="src" label="src">
|
|
295
|
+
<TreeItem value="app" label="app.ts" />
|
|
296
|
+
<TreeItem value="util" label="util.ts" />
|
|
297
|
+
</TreeItem>
|
|
298
|
+
<TreeItem value="docs" label="docs" />
|
|
299
|
+
</TreeView>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
it("is one tab stop and moves with ArrowDown/ArrowUp", async () => {
|
|
304
|
+
const user = userEvent.setup();
|
|
305
|
+
render(<Example />);
|
|
306
|
+
await user.tab();
|
|
307
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveFocus();
|
|
308
|
+
await user.keyboard("{ArrowDown}");
|
|
309
|
+
expect(screen.getByRole("treeitem", { name: /docs/ })).toHaveFocus();
|
|
310
|
+
await user.keyboard("{ArrowUp}");
|
|
311
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveFocus();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("ArrowRight expands, ArrowLeft collapses, and aria-expanded follows", async () => {
|
|
315
|
+
const user = userEvent.setup();
|
|
316
|
+
render(<Example />);
|
|
317
|
+
await user.tab();
|
|
318
|
+
const src = screen.getByRole("treeitem", { name: /src/ });
|
|
319
|
+
expect(src).toHaveAttribute("aria-expanded", "false");
|
|
320
|
+
|
|
321
|
+
await user.keyboard("{ArrowRight}");
|
|
322
|
+
expect(src).toHaveAttribute("aria-expanded", "true");
|
|
323
|
+
await user.keyboard("{ArrowRight}"); // into the first child
|
|
324
|
+
// A parent's accessible name includes its children's text, so match the child exactly.
|
|
325
|
+
expect(screen.getByRole("treeitem", { name: "app.ts" })).toHaveFocus();
|
|
326
|
+
await user.keyboard("{ArrowLeft}"); // back to the parent
|
|
327
|
+
expect(src).toHaveFocus();
|
|
328
|
+
await user.keyboard("{ArrowLeft}");
|
|
329
|
+
expect(src).toHaveAttribute("aria-expanded", "false");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("Enter selects the focused item", async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
render(<Example />);
|
|
335
|
+
await user.tab();
|
|
336
|
+
await user.keyboard("{ArrowDown}{Enter}");
|
|
337
|
+
expect(screen.getByRole("treeitem", { name: /docs/ })).toHaveAttribute("aria-selected", "true");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("MultiSelect", () => {
|
|
342
|
+
const options = [
|
|
343
|
+
{ value: "a", label: "Apple" },
|
|
344
|
+
{ value: "b", label: "Banana" },
|
|
345
|
+
{ value: "c", label: "Cherry" },
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
it("can be opened and an option chosen using only the keyboard", async () => {
|
|
349
|
+
const user = userEvent.setup();
|
|
350
|
+
render(<MultiSelect aria-label="Fruit" options={options} />);
|
|
351
|
+
await user.tab();
|
|
352
|
+
const combobox = screen.getByRole("combobox");
|
|
353
|
+
expect(combobox).toHaveFocus();
|
|
354
|
+
await user.keyboard("{Enter}");
|
|
355
|
+
expect(combobox).toHaveAttribute("aria-expanded", "true");
|
|
356
|
+
|
|
357
|
+
// Keys must now drive the option list (focus should be in its search input).
|
|
358
|
+
await user.keyboard("{ArrowDown}{Enter}");
|
|
359
|
+
await waitFor(() => expect(screen.getAllByText("Banana").length).toBeGreaterThan(1));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("closes with Escape and gives focus back to the combobox", async () => {
|
|
363
|
+
const user = userEvent.setup();
|
|
364
|
+
render(<MultiSelect aria-label="Fruit" options={options} />);
|
|
365
|
+
await user.tab();
|
|
366
|
+
await user.keyboard("{Enter}");
|
|
367
|
+
await user.keyboard("{Escape}");
|
|
368
|
+
await waitFor(() => expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"));
|
|
369
|
+
expect(screen.getByRole("combobox")).toHaveFocus();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("Tour", () => {
|
|
374
|
+
const steps = [
|
|
375
|
+
{ target: "#one", title: "Step one", content: "First." },
|
|
376
|
+
{ target: "#two", title: "Step two", content: "Second." },
|
|
377
|
+
];
|
|
378
|
+
function Example() {
|
|
379
|
+
const [open, setOpen] = React.useState(false);
|
|
380
|
+
const [i, setI] = React.useState(0);
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
<button id="one" onClick={() => setOpen(true)}>
|
|
384
|
+
Start tour
|
|
385
|
+
</button>
|
|
386
|
+
<button id="two">Other</button>
|
|
387
|
+
<Tour steps={steps} open={open} stepIndex={i} onStepIndexChange={setI} onOpenChange={setOpen} />
|
|
388
|
+
</>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
it("is a modal dialog with an accessible name taken from the step title", async () => {
|
|
393
|
+
const user = userEvent.setup();
|
|
394
|
+
render(<Example />);
|
|
395
|
+
await user.click(screen.getByRole("button", { name: "Start tour" }));
|
|
396
|
+
expect(await screen.findByRole("dialog", { name: "Step one" })).toBeInTheDocument();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("moves focus into the dialog when it opens", async () => {
|
|
400
|
+
const user = userEvent.setup();
|
|
401
|
+
render(<Example />);
|
|
402
|
+
await user.click(screen.getByRole("button", { name: "Start tour" }));
|
|
403
|
+
const dialog = await screen.findByRole("dialog");
|
|
404
|
+
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("closes on Escape and returns focus to where it was", async () => {
|
|
408
|
+
const user = userEvent.setup();
|
|
409
|
+
render(<Example />);
|
|
410
|
+
const start = screen.getByRole("button", { name: "Start tour" });
|
|
411
|
+
await user.click(start);
|
|
412
|
+
await screen.findByRole("dialog");
|
|
413
|
+
await user.keyboard("{Escape}");
|
|
414
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
|
415
|
+
expect(start).toHaveFocus();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("keeps Tab inside the dialog while it is open", async () => {
|
|
419
|
+
const user = userEvent.setup();
|
|
420
|
+
render(<Example />);
|
|
421
|
+
await user.click(screen.getByRole("button", { name: "Start tour" }));
|
|
422
|
+
const dialog = await screen.findByRole("dialog");
|
|
423
|
+
for (let i = 0; i < 6; i++) {
|
|
424
|
+
await user.tab();
|
|
425
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("DataGrid", () => {
|
|
431
|
+
type Row = { id: number; name: string };
|
|
432
|
+
const rows: Row[] = [
|
|
433
|
+
{ id: 1, name: "Cara" },
|
|
434
|
+
{ id: 2, name: "Abe" },
|
|
435
|
+
{ id: 3, name: "Bea" },
|
|
436
|
+
];
|
|
437
|
+
function setup(opts: { onCellEdit?: ReturnType<typeof vi.fn>; data?: Row[]; wrapper?: (n: React.ReactNode) => React.ReactNode } = {}) {
|
|
438
|
+
const onCellEdit = opts.onCellEdit ?? vi.fn();
|
|
439
|
+
const columns: DataGridColumn<Row>[] = [
|
|
440
|
+
{ id: "name", header: "Name", cell: (r) => r.name, value: (r) => r.name, sortable: true, editable: true, onCellEdit },
|
|
441
|
+
{ id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id, minWidth: 60, width: 100 },
|
|
442
|
+
];
|
|
443
|
+
const grid = <DataGrid columns={columns} data={opts.data ?? rows} height={200} getRowId={(r) => r.id} />;
|
|
444
|
+
render(
|
|
445
|
+
<>
|
|
446
|
+
{opts.wrapper ? opts.wrapper(grid) : grid}
|
|
447
|
+
<button>after</button>
|
|
448
|
+
</>,
|
|
449
|
+
);
|
|
450
|
+
return onCellEdit;
|
|
451
|
+
}
|
|
452
|
+
const header = (name: string) => screen.getByRole("columnheader", { name });
|
|
453
|
+
const cell = (r: number, c: number) => screen.getAllByRole("row")[r + 1]!.querySelectorAll<HTMLElement>('[role="gridcell"]')[c]!;
|
|
454
|
+
const names = () => screen.getAllByRole("gridcell").filter((c) => /^(Cara|Abe|Bea)$/.test(c.textContent ?? "")).map((c) => c.textContent);
|
|
455
|
+
|
|
456
|
+
it("is a single tab stop: Tab enters on the first header, the next Tab leaves the grid", async () => {
|
|
457
|
+
const user = userEvent.setup();
|
|
458
|
+
setup();
|
|
459
|
+
await user.tab();
|
|
460
|
+
expect(header("Name")).toHaveFocus();
|
|
461
|
+
expect(header("ID")).toHaveAttribute("tabindex", "-1");
|
|
462
|
+
expect(cell(0, 0)).toHaveAttribute("tabindex", "-1");
|
|
463
|
+
await user.tab();
|
|
464
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("exposes grid dimensions and row / column indexes (rows are virtualized, so they must be explicit)", () => {
|
|
468
|
+
setup();
|
|
469
|
+
const grid = screen.getByRole("grid");
|
|
470
|
+
expect(grid).toHaveAttribute("aria-rowcount", "4"); // header + 3
|
|
471
|
+
expect(grid).toHaveAttribute("aria-colcount", "2");
|
|
472
|
+
expect(screen.getAllByRole("row")[0]).toHaveAttribute("aria-rowindex", "1");
|
|
473
|
+
expect(screen.getAllByRole("row")[3]).toHaveAttribute("aria-rowindex", "4");
|
|
474
|
+
expect(cell(0, 1)).toHaveAttribute("aria-colindex", "2");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("moves between cells with the arrow keys, header row included, and stops at the edges", async () => {
|
|
478
|
+
const user = userEvent.setup();
|
|
479
|
+
setup();
|
|
480
|
+
await user.tab(); // Name header
|
|
481
|
+
await user.keyboard("{ArrowRight}");
|
|
482
|
+
expect(header("ID")).toHaveFocus();
|
|
483
|
+
await user.keyboard("{ArrowRight}"); // last column: stays
|
|
484
|
+
expect(header("ID")).toHaveFocus();
|
|
485
|
+
await user.keyboard("{ArrowDown}");
|
|
486
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
487
|
+
await user.keyboard("{ArrowLeft}");
|
|
488
|
+
expect(cell(0, 0)).toHaveFocus();
|
|
489
|
+
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); // past the last row: stays
|
|
490
|
+
expect(cell(2, 0)).toHaveFocus();
|
|
491
|
+
await user.keyboard("{ArrowUp}{ArrowUp}{ArrowUp}");
|
|
492
|
+
expect(header("Name")).toHaveFocus();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("Home / End go to the row ends; Ctrl+Home / Ctrl+End to the grid corners", async () => {
|
|
496
|
+
const user = userEvent.setup();
|
|
497
|
+
setup();
|
|
498
|
+
await user.tab();
|
|
499
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // row 1, Name
|
|
500
|
+
await user.keyboard("{End}");
|
|
501
|
+
expect(cell(1, 1)).toHaveFocus();
|
|
502
|
+
await user.keyboard("{Home}");
|
|
503
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
504
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
505
|
+
expect(cell(2, 1)).toHaveFocus();
|
|
506
|
+
await user.keyboard("{Control>}{Home}{/Control}");
|
|
507
|
+
expect(header("Name")).toHaveFocus();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("remembers the active cell: leaving and re-entering the grid returns to it", async () => {
|
|
511
|
+
const user = userEvent.setup();
|
|
512
|
+
setup();
|
|
513
|
+
await user.tab();
|
|
514
|
+
await user.keyboard("{ArrowDown}{ArrowRight}");
|
|
515
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
516
|
+
await user.tab(); // out
|
|
517
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
518
|
+
await user.tab({ shift: true }); // back in
|
|
519
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("sorts from the keyboard: Enter / Space on a sortable header", async () => {
|
|
523
|
+
const user = userEvent.setup();
|
|
524
|
+
setup();
|
|
525
|
+
await user.tab();
|
|
526
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "none");
|
|
527
|
+
await user.keyboard("{Enter}");
|
|
528
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "ascending");
|
|
529
|
+
expect(names()).toEqual(["Abe", "Bea", "Cara"]);
|
|
530
|
+
await user.keyboard(" ");
|
|
531
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "descending");
|
|
532
|
+
expect(names()).toEqual(["Cara", "Bea", "Abe"]);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("edits a cell from the keyboard: Enter to edit, Enter to commit, focus returns to the cell", async () => {
|
|
536
|
+
const user = userEvent.setup();
|
|
537
|
+
const onCellEdit = setup();
|
|
538
|
+
await user.tab();
|
|
539
|
+
await user.keyboard("{ArrowDown}");
|
|
540
|
+
const target = cell(0, 0);
|
|
541
|
+
expect(target).toHaveFocus();
|
|
542
|
+
|
|
543
|
+
await user.keyboard("{Enter}");
|
|
544
|
+
const input = within(target).getByRole("textbox");
|
|
545
|
+
expect(input).toHaveFocus();
|
|
546
|
+
await user.clear(input);
|
|
547
|
+
await user.type(input, "Zed{Enter}");
|
|
548
|
+
|
|
549
|
+
expect(onCellEdit).toHaveBeenCalledTimes(1);
|
|
550
|
+
expect(onCellEdit).toHaveBeenCalledWith(rows[0], 0, "Zed");
|
|
551
|
+
await waitFor(() => expect(within(target).queryByRole("textbox")).not.toBeInTheDocument());
|
|
552
|
+
expect(target).toHaveFocus();
|
|
553
|
+
await user.keyboard("{ArrowDown}"); // navigation still works after an edit
|
|
554
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("Escape cancels an edit without committing and returns focus to the cell", async () => {
|
|
558
|
+
const user = userEvent.setup();
|
|
559
|
+
const onCellEdit = setup();
|
|
560
|
+
await user.tab();
|
|
561
|
+
await user.keyboard("{ArrowDown}");
|
|
562
|
+
const target = cell(0, 0);
|
|
563
|
+
await user.keyboard("{F2}");
|
|
564
|
+
await user.type(within(target).getByRole("textbox"), "x{Escape}");
|
|
565
|
+
|
|
566
|
+
expect(onCellEdit).not.toHaveBeenCalled();
|
|
567
|
+
expect(target).toHaveFocus();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("Alt+Arrow reorders a column from its header, keeps focus on it, and announces the move", async () => {
|
|
571
|
+
const user = userEvent.setup();
|
|
572
|
+
setup();
|
|
573
|
+
await user.tab(); // Name header
|
|
574
|
+
const headersBefore = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
|
575
|
+
expect(headersBefore).toEqual(["Name", "ID"]);
|
|
576
|
+
|
|
577
|
+
await user.keyboard("{Alt>}{ArrowRight}{/Alt}");
|
|
578
|
+
await waitFor(() => expect(screen.getAllByRole("columnheader").map((h) => h.textContent)).toEqual(["ID", "Name"]));
|
|
579
|
+
expect(header("Name")).toHaveFocus();
|
|
580
|
+
expect(screen.getByRole("status")).toHaveTextContent("Name moved to position 2 of 2");
|
|
581
|
+
|
|
582
|
+
await user.keyboard("{Alt>}{ArrowRight}{/Alt}"); // already last
|
|
583
|
+
expect(screen.getByRole("status")).toHaveTextContent("already the last movable column");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("Shift+Arrow resizes a column from its header and announces the width", async () => {
|
|
587
|
+
const user = userEvent.setup();
|
|
588
|
+
setup();
|
|
589
|
+
await user.tab();
|
|
590
|
+
await user.keyboard("{ArrowRight}"); // ID header, width 100, min 60
|
|
591
|
+
expect(header("ID")).toHaveStyle({ width: "100px" });
|
|
592
|
+
await user.keyboard("{Shift>}{ArrowRight}{/Shift}");
|
|
593
|
+
expect(header("ID")).toHaveStyle({ width: "110px" });
|
|
594
|
+
expect(screen.getByRole("status")).toHaveTextContent("ID column width 110 pixels");
|
|
595
|
+
await user.keyboard("{Shift>}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{/Shift}");
|
|
596
|
+
expect(header("ID")).toHaveStyle({ width: "60px" }); // clamped to minWidth
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("advertises the header chords with aria-keyshortcuts", () => {
|
|
600
|
+
setup();
|
|
601
|
+
expect(header("Name")).toHaveAttribute("aria-keyshortcuts", expect.stringContaining("Alt+ArrowLeft"));
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("mirrors the arrow keys under RTL", async () => {
|
|
605
|
+
const user = userEvent.setup();
|
|
606
|
+
setup({ wrapper: (n) => <div dir="rtl" style={{ direction: "rtl" }}>{n}</div> });
|
|
607
|
+
await user.tab();
|
|
608
|
+
expect(header("Name")).toHaveFocus();
|
|
609
|
+
await user.keyboard("{ArrowLeft}"); // in RTL the next column is to the left
|
|
610
|
+
expect(header("ID")).toHaveFocus();
|
|
611
|
+
await user.keyboard("{ArrowRight}");
|
|
612
|
+
expect(header("Name")).toHaveFocus();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("with many rows (virtualized)", () => {
|
|
616
|
+
const many: Row[] = Array.from({ length: 200 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
|
|
617
|
+
|
|
618
|
+
it("scrolls the target row into the rendered window and focuses it", async () => {
|
|
619
|
+
const user = userEvent.setup();
|
|
620
|
+
setup({ data: many });
|
|
621
|
+
await user.tab();
|
|
622
|
+
await user.keyboard("{ArrowDown}");
|
|
623
|
+
for (let i = 0; i < 30; i++) await user.keyboard("{ArrowDown}");
|
|
624
|
+
await waitFor(() => expect(document.activeElement).toHaveTextContent("Person 31"));
|
|
625
|
+
expect(document.activeElement).toHaveAttribute("data-cell", "30:name");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("PageDown / Ctrl+End jump by a page and to the last row", async () => {
|
|
629
|
+
const user = userEvent.setup();
|
|
630
|
+
setup({ data: many });
|
|
631
|
+
await user.tab();
|
|
632
|
+
await user.keyboard("{ArrowDown}");
|
|
633
|
+
await user.keyboard("{PageDown}");
|
|
634
|
+
await waitFor(() => expect(document.activeElement?.getAttribute("data-cell")).toMatch(/^(\d+):name$/));
|
|
635
|
+
const jumped = Number(document.activeElement!.getAttribute("data-cell")!.split(":")[0]);
|
|
636
|
+
expect(jumped).toBeGreaterThan(2);
|
|
637
|
+
|
|
638
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
639
|
+
await waitFor(() => expect(document.activeElement).toHaveAttribute("data-cell", "199:id"));
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("stays reachable by Tab even after the active row scrolls out of the rendered window", async () => {
|
|
643
|
+
const user = userEvent.setup();
|
|
644
|
+
setup({ data: many });
|
|
645
|
+
await user.tab();
|
|
646
|
+
await user.keyboard("{ArrowDown}");
|
|
647
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
648
|
+
await waitFor(() => expect(document.activeElement).toHaveAttribute("data-cell", "199:id"));
|
|
649
|
+
await user.tab(); // out
|
|
650
|
+
const grid = screen.getByRole("grid");
|
|
651
|
+
grid.scrollTop = 0;
|
|
652
|
+
grid.dispatchEvent(new Event("scroll"));
|
|
653
|
+
await user.tab({ shift: true });
|
|
654
|
+
expect(grid.contains(document.activeElement)).toBe(true);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("ColorPicker", () => {
|
|
660
|
+
it("adjusts the saturation/value square with the arrow keys (Shift = bigger step)", async () => {
|
|
661
|
+
const user = userEvent.setup();
|
|
662
|
+
const onChange = vi.fn();
|
|
663
|
+
render(<ColorPicker value="#3b82f6" onChange={onChange} />);
|
|
664
|
+
const square = screen.getByRole("slider", { name: "Saturation and value" });
|
|
665
|
+
square.focus();
|
|
666
|
+
|
|
667
|
+
await user.keyboard("{ArrowRight}");
|
|
668
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
669
|
+
const one = onChange.mock.calls[0]![0] as string;
|
|
670
|
+
expect(one).toMatch(/^#[0-9a-f]{6}$/i);
|
|
671
|
+
expect(one.toLowerCase()).not.toBe("#3b82f6");
|
|
672
|
+
|
|
673
|
+
onChange.mockClear();
|
|
674
|
+
await user.keyboard("{Shift>}{ArrowRight}{/Shift}");
|
|
675
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("the hue rail is a named, keyboard-operable slider", async () => {
|
|
679
|
+
const user = userEvent.setup();
|
|
680
|
+
const onChange = vi.fn();
|
|
681
|
+
render(<ColorPicker value="#3b82f6" onChange={onChange} />);
|
|
682
|
+
const hue = screen.getByRole("slider", { name: "Hue" });
|
|
683
|
+
hue.focus();
|
|
684
|
+
await user.keyboard("{ArrowRight}");
|
|
685
|
+
expect(onChange).toHaveBeenCalled();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("commits the hex field on Enter", async () => {
|
|
689
|
+
const user = userEvent.setup();
|
|
690
|
+
const onChange = vi.fn();
|
|
691
|
+
render(<ColorPicker value="#3b82f6" onChange={onChange} />);
|
|
692
|
+
const hex = screen.getByRole("textbox");
|
|
693
|
+
await user.clear(hex);
|
|
694
|
+
await user.type(hex, "ff0000{Enter}");
|
|
695
|
+
expect(onChange).toHaveBeenLastCalledWith("#ff0000");
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe("Slider", () => {
|
|
700
|
+
it("names the thumb (the role=slider element) from aria-label and moves with the arrow keys", async () => {
|
|
701
|
+
const user = userEvent.setup();
|
|
702
|
+
const onValueChange = vi.fn();
|
|
703
|
+
render(<Slider aria-label="Volume" defaultValue={[50]} max={100} onValueChange={onValueChange} />);
|
|
704
|
+
const thumb = screen.getByRole("slider", { name: "Volume" });
|
|
705
|
+
await user.tab();
|
|
706
|
+
expect(thumb).toHaveFocus();
|
|
707
|
+
expect(thumb).toHaveAttribute("aria-valuenow", "50");
|
|
708
|
+
|
|
709
|
+
await user.keyboard("{ArrowRight}");
|
|
710
|
+
expect(onValueChange).toHaveBeenLastCalledWith([51]);
|
|
711
|
+
await user.keyboard("{End}");
|
|
712
|
+
expect(onValueChange).toHaveBeenLastCalledWith([100]);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
describe("MultiSelect chips", () => {
|
|
717
|
+
it("Backspace in the empty search field removes the last chip", async () => {
|
|
718
|
+
const user = userEvent.setup();
|
|
719
|
+
const onValueChange = vi.fn();
|
|
720
|
+
render(
|
|
721
|
+
<MultiSelect
|
|
722
|
+
aria-label="Fruit"
|
|
723
|
+
options={[
|
|
724
|
+
{ value: "a", label: "Apple" },
|
|
725
|
+
{ value: "b", label: "Banana" },
|
|
726
|
+
]}
|
|
727
|
+
defaultValue={["a", "b"]}
|
|
728
|
+
onValueChange={onValueChange}
|
|
729
|
+
/>,
|
|
730
|
+
);
|
|
731
|
+
await user.tab();
|
|
732
|
+
await user.keyboard("{Enter}");
|
|
733
|
+
await user.keyboard("{Backspace}");
|
|
734
|
+
expect(onValueChange).toHaveBeenLastCalledWith(["a"]);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe("List", () => {
|
|
739
|
+
it("a pressable row is a listitem containing a button, and Enter / Space activate it", async () => {
|
|
740
|
+
const user = userEvent.setup();
|
|
741
|
+
const onSelect = vi.fn();
|
|
742
|
+
render(
|
|
743
|
+
<List>
|
|
744
|
+
<ListItem title="Profile" onSelect={onSelect} />
|
|
745
|
+
<ListItem title="Static" />
|
|
746
|
+
</List>,
|
|
747
|
+
);
|
|
748
|
+
const items = screen.getAllByRole("listitem");
|
|
749
|
+
expect(items).toHaveLength(2);
|
|
750
|
+
const button = within(items[0]!).getByRole("button", { name: "Profile" });
|
|
751
|
+
await user.tab();
|
|
752
|
+
expect(button).toHaveFocus();
|
|
753
|
+
await user.keyboard("{Enter}");
|
|
754
|
+
await user.keyboard(" ");
|
|
755
|
+
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
756
|
+
expect(within(items[1]!).queryByRole("button")).not.toBeInTheDocument();
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe("FileUpload", () => {
|
|
761
|
+
it("has exactly one keyboard target — the Browse button — not a nested control or the hidden input", async () => {
|
|
762
|
+
const user = userEvent.setup();
|
|
763
|
+
render(
|
|
764
|
+
<>
|
|
765
|
+
<FileUpload />
|
|
766
|
+
<button>after</button>
|
|
767
|
+
</>,
|
|
768
|
+
);
|
|
769
|
+
await user.tab();
|
|
770
|
+
expect(screen.getByRole("button", { name: "Browse files" })).toHaveFocus();
|
|
771
|
+
await user.tab();
|
|
772
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
773
|
+
});
|
|
774
|
+
});
|