@kinetixui/ui 0.23.3 → 0.24.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.
Files changed (80) hide show
  1. package/README.md +134 -0
  2. package/dist/index.d.ts +212 -31
  3. package/dist/index.js +1466 -1118
  4. package/dist/kx-src-hash.json +3 -0
  5. package/package.json +7 -7
  6. package/src/components/accordion.tsx +1 -1
  7. package/src/components/alert-dialog.tsx +7 -5
  8. package/src/components/avatar.tsx +4 -1
  9. package/src/components/banner.tsx +9 -3
  10. package/src/components/breadcrumb.tsx +1 -1
  11. package/src/components/button.tsx +1 -1
  12. package/src/components/card.tsx +77 -6
  13. package/src/components/checkbox.tsx +42 -8
  14. package/src/components/collapsible.tsx +41 -1
  15. package/src/components/command.tsx +2 -2
  16. package/src/components/context-menu.tsx +21 -13
  17. package/src/components/dialog.tsx +5 -4
  18. package/src/components/direction-provider.tsx +85 -1
  19. package/src/components/drawer.tsx +62 -18
  20. package/src/components/dropdown-menu.tsx +12 -4
  21. package/src/components/file-upload.tsx +1 -1
  22. package/src/components/hover-card.tsx +1 -1
  23. package/src/components/inform.tsx +9 -3
  24. package/src/components/input-group.tsx +27 -5
  25. package/src/components/input-otp.tsx +48 -5
  26. package/src/components/input.tsx +20 -3
  27. package/src/components/json-viewer.tsx +1 -1
  28. package/src/components/menubar.tsx +21 -15
  29. package/src/components/modal.tsx +3 -2
  30. package/src/components/multi-select.tsx +14 -5
  31. package/src/components/native-select.tsx +10 -2
  32. package/src/components/navigation-bar.tsx +1 -1
  33. package/src/components/navigation-menu.tsx +6 -6
  34. package/src/components/number-input.tsx +28 -10
  35. package/src/components/pagination.tsx +2 -2
  36. package/src/components/popover.tsx +29 -5
  37. package/src/components/radio-group.tsx +23 -8
  38. package/src/components/segmented-control.tsx +20 -4
  39. package/src/components/select.tsx +25 -8
  40. package/src/components/sheet.tsx +4 -3
  41. package/src/components/spinner.tsx +1 -1
  42. package/src/components/switch.tsx +31 -8
  43. package/src/components/table.tsx +121 -8
  44. package/src/components/tabs.tsx +29 -4
  45. package/src/components/tag.tsx +1 -1
  46. package/src/components/textarea.tsx +20 -3
  47. package/src/components/tooltip.tsx +14 -4
  48. package/src/components/tour.tsx +22 -7
  49. package/src/components/tree-view.tsx +1 -1
  50. package/src/components-behavior.test.tsx +74 -0
  51. package/src/components-direction-portal.test.tsx +482 -0
  52. package/src/components-form.test.tsx +435 -0
  53. package/src/components-icons.test.tsx +197 -0
  54. package/src/components-keyboard.test.tsx +542 -2
  55. package/src/components-overlay.test.tsx +381 -0
  56. package/src/components-rtl.test.tsx +258 -0
  57. package/src/components-selection.test.tsx +207 -0
  58. package/src/components-table-scroll.test.tsx +363 -0
  59. package/src/index.ts +2 -0
  60. package/src/navigation-menu-focus.test.tsx +35 -0
  61. package/src/stories/Card.stories.tsx +81 -1
  62. package/src/stories/Checkbox.stories.tsx +23 -1
  63. package/src/stories/ContextMenu.stories.tsx +1 -1
  64. package/src/stories/Input.stories.tsx +34 -0
  65. package/src/stories/InputGroup.stories.tsx +96 -2
  66. package/src/stories/InputOTP.stories.tsx +24 -1
  67. package/src/stories/MultiSelect.stories.tsx +25 -1
  68. package/src/stories/NativeSelect.stories.tsx +24 -1
  69. package/src/stories/NumberInput.stories.tsx +20 -1
  70. package/src/stories/Progress.stories.tsx +1 -1
  71. package/src/stories/RadioGroup.stories.tsx +33 -1
  72. package/src/stories/SegmentedControl.stories.tsx +21 -1
  73. package/src/stories/Select.stories.tsx +28 -1
  74. package/src/stories/Slider.stories.tsx +1 -1
  75. package/src/stories/Switch.stories.tsx +37 -1
  76. package/src/stories/Table.stories.tsx +35 -1
  77. package/src/stories/Tabs.stories.tsx +27 -2
  78. package/src/stories/Textarea.stories.tsx +34 -0
  79. package/src/usage/icons.tsx +60 -0
  80. package/tailwind.config.ts +118 -22
@@ -1,8 +1,24 @@
1
1
  import * as React from "react";
2
- import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ AlertDialog,
7
+ AlertDialogAction,
8
+ AlertDialogCancel,
9
+ AlertDialogContent,
10
+ AlertDialogDescription,
11
+ AlertDialogFooter,
12
+ AlertDialogTitle,
13
+ AlertDialogTrigger,
14
+ } from "./components/alert-dialog";
5
15
  import { Checkbox } from "./components/checkbox";
16
+ import {
17
+ ContextMenu,
18
+ ContextMenuContent,
19
+ ContextMenuItem,
20
+ ContextMenuTrigger,
21
+ } from "./components/context-menu";
6
22
  import { ColorPicker } from "./components/color-picker";
7
23
  import { FileUpload } from "./components/file-upload";
8
24
  import { List, ListItem } from "./components/list";
@@ -21,12 +37,22 @@ import {
21
37
  DropdownMenuItem,
22
38
  DropdownMenuTrigger,
23
39
  } from "./components/dropdown-menu";
40
+ import { Input } from "./components/input";
41
+ import { InputOTP, InputOTPGroup, InputOTPSlot } from "./components/input-otp";
42
+ import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarTrigger } from "./components/menubar";
43
+ import { Modal } from "./components/modal";
24
44
  import { MultiSelect } from "./components/multi-select";
25
45
  import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
26
46
  import { RadioGroup, RadioGroupItem } from "./components/radio-group";
47
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/select";
48
+ import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "./components/sheet";
27
49
  import { Slider } from "./components/slider";
28
50
  import { Switch } from "./components/switch";
51
+ import { Toggle } from "./components/toggle";
52
+ import { ToggleGroup, ToggleGroupItem } from "./components/toggle-group";
29
53
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/tabs";
54
+ import { Textarea } from "./components/textarea";
55
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
30
56
  import { Tour } from "./components/tour";
31
57
  import { TreeItem, TreeView } from "./components/tree-view";
32
58
 
@@ -40,6 +66,75 @@ import { TreeItem, TreeView } from "./components/tree-view";
40
66
 
41
67
  // kx-verify: interaction
42
68
 
69
+ beforeAll(() => {
70
+ // `input-otp` tracks which slot the caret is in by polling document.elementFromPoint, which jsdom
71
+ // does not implement. Unstubbed it throws from a timer after the test has finished — an unhandled
72
+ // rejection that vitest reports against whichever test happened to be running.
73
+ (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => null;
74
+ });
75
+
76
+ /**
77
+ * First in this module, deliberately.
78
+ *
79
+ * Radix's anchored ContextMenu stops opening once any modal overlay has mounted and unmounted in the
80
+ * same jsdom module: the trigger stays `data-state="closed"` and no `contextmenu` event, with or without
81
+ * coordinates, revives it — nor does Shift+F10. Measured against Dialog, Sheet and Drawer alike, and the
82
+ * reason `components-rtl.test.tsx` can open one is that it opens it first too. The state lives inside the
83
+ * primitive, not in anything this repository sets: clearing the body and documentElement style attributes
84
+ * that vaul leaves behind changes nothing.
85
+ *
86
+ * So the ordering is a real constraint rather than a preference, and `openContextMenu` below fails with
87
+ * that sentence rather than with a bare "unable to find role=menu" if someone moves this suite down.
88
+ */
89
+ describe("ContextMenu", () => {
90
+ function Example() {
91
+ return (
92
+ <ContextMenu>
93
+ <ContextMenuTrigger>Right-click here</ContextMenuTrigger>
94
+ <ContextMenuContent>
95
+ <ContextMenuItem>Back</ContextMenuItem>
96
+ <ContextMenuItem>Forward</ContextMenuItem>
97
+ <ContextMenuItem>Reload</ContextMenuItem>
98
+ </ContextMenuContent>
99
+ </ContextMenu>
100
+ );
101
+ }
102
+
103
+ async function openContextMenu() {
104
+ fireEvent.contextMenu(screen.getByText("Right-click here"));
105
+ try {
106
+ return await screen.findByRole("menu");
107
+ } catch {
108
+ throw new Error(
109
+ "ContextMenu did not open. This suite must run before any modal overlay in this module — Radix's " +
110
+ "anchored menu does not recover once one has mounted here. Move this describe back to the top.",
111
+ );
112
+ }
113
+ }
114
+
115
+ it("walks items with the arrows, jumps with Home and End, finds one by typing, and closes on Escape", async () => {
116
+ const user = userEvent.setup();
117
+ render(<Example />);
118
+ // One open, many assertions — the same trade components-rtl.test.tsx records for this component:
119
+ // Radix's ContextMenu is modal, and a second open in the same module never arrives, so a test per
120
+ // key would be testing the harness rather than the menu.
121
+ await openContextMenu();
122
+
123
+ await user.keyboard("{ArrowDown}");
124
+ expect(screen.getByRole("menuitem", { name: "Back" })).toHaveFocus();
125
+ await user.keyboard("{End}");
126
+ expect(screen.getByRole("menuitem", { name: "Reload" })).toHaveFocus();
127
+ await user.keyboard("{Home}");
128
+ expect(screen.getByRole("menuitem", { name: "Back" })).toHaveFocus();
129
+ // Typeahead: one letter is a jump, not a filter — the menu keeps every item.
130
+ await user.keyboard("f");
131
+ expect(screen.getByRole("menuitem", { name: "Forward" })).toHaveFocus();
132
+
133
+ await user.keyboard("{Escape}");
134
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
135
+ });
136
+ });
137
+
43
138
  describe("Dialog", () => {
44
139
  function Example() {
45
140
  return (
@@ -365,6 +460,20 @@ describe("MultiSelect", () => {
365
460
  await waitFor(() => expect(screen.getAllByText("Banana").length).toBeGreaterThan(1));
366
461
  });
367
462
 
463
+ it("opens with a vertical arrow key as well as with Enter", async () => {
464
+ const user = userEvent.setup();
465
+ render(<MultiSelect aria-label="Fruit" options={options} />);
466
+ await user.tab();
467
+ const combobox = screen.getByRole("combobox");
468
+ expect(combobox).toHaveAttribute("aria-expanded", "false");
469
+ // WAI-ARIA's combobox pattern lists Down Arrow as a way to open the popup, and it is what a
470
+ // keyboard user reaches for first. Before this it did nothing at all: Enter and Space were the
471
+ // only way in.
472
+ await user.keyboard("{ArrowDown}");
473
+ expect(combobox).toHaveAttribute("aria-expanded", "true");
474
+ await screen.findByPlaceholderText("Search…");
475
+ });
476
+
368
477
  it("closes with Escape and gives focus back to the combobox", async () => {
369
478
  const user = userEvent.setup();
370
479
  render(<MultiSelect aria-label="Fruit" options={options} />);
@@ -1049,6 +1158,25 @@ describe("Slider", () => {
1049
1158
  await user.keyboard("{End}");
1050
1159
  expect(onValueChange).toHaveBeenLastCalledWith([100]);
1051
1160
  });
1161
+
1162
+ it("steps by a page with PageUp and PageDown, and clamps at the ends of the range", async () => {
1163
+ const user = userEvent.setup();
1164
+ const onValueChange = vi.fn();
1165
+ render(<Slider aria-label="Volume" defaultValue={[50]} max={100} onValueChange={onValueChange} />);
1166
+ await user.tab();
1167
+ await user.keyboard("{PageUp}");
1168
+ expect(onValueChange).toHaveBeenLastCalledWith([60]);
1169
+ await user.keyboard("{PageDown}");
1170
+ expect(onValueChange).toHaveBeenLastCalledWith([50]);
1171
+
1172
+ await user.keyboard("{End}");
1173
+ onValueChange.mockClear();
1174
+ // At the maximum the thumb has nowhere to go: the key is accepted and reports nothing, rather
1175
+ // than wrapping round to the minimum.
1176
+ await user.keyboard("{ArrowUp}");
1177
+ expect(onValueChange).not.toHaveBeenCalled();
1178
+ expect(screen.getByRole("slider", { name: "Volume" })).toHaveAttribute("aria-valuenow", "100");
1179
+ });
1052
1180
  });
1053
1181
 
1054
1182
  describe("MultiSelect chips", () => {
@@ -1110,3 +1238,415 @@ describe("FileUpload", () => {
1110
1238
  expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
1111
1239
  });
1112
1240
  });
1241
+
1242
+ // kx-verify: interaction
1243
+
1244
+ /**
1245
+ * Toggle and ToggleGroup had no interaction or keyboard evidence at all — the only two members of the
1246
+ * selection-control family without any, which is how they came to be in this slice. What they support is
1247
+ * asserted here rather than assumed from the family: a Toggle is a button, so it answers to both Space and
1248
+ * Enter, where a Checkbox answers only to Space.
1249
+ */
1250
+ describe("Toggle", () => {
1251
+ it("toggles with Space and with Enter, and reports state through aria-pressed", async () => {
1252
+ const user = userEvent.setup();
1253
+ render(<Toggle aria-label="Bold" />);
1254
+ await user.tab();
1255
+ const toggle = screen.getByRole("button", { name: "Bold" });
1256
+ expect(toggle).toHaveFocus();
1257
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
1258
+
1259
+ // A button, not a checkbox: Enter activates it too.
1260
+ await user.keyboard("{Enter}");
1261
+ expect(toggle).toHaveAttribute("aria-pressed", "true");
1262
+ await user.keyboard(" ");
1263
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
1264
+ });
1265
+
1266
+ it("a disabled Toggle is skipped by Tab and does not respond to keys", async () => {
1267
+ const user = userEvent.setup();
1268
+ render(
1269
+ <>
1270
+ <button>first</button>
1271
+ <Toggle aria-label="Bold" disabled />
1272
+ <button>last</button>
1273
+ </>,
1274
+ );
1275
+ await user.tab();
1276
+ await user.tab();
1277
+ expect(screen.getByRole("button", { name: "last" })).toHaveFocus();
1278
+ expect(screen.getByRole("button", { name: "Bold" })).toHaveAttribute("aria-pressed", "false");
1279
+ });
1280
+ });
1281
+
1282
+ describe("ToggleGroup", () => {
1283
+ function Example({ type = "multiple" as "multiple" | "single" }) {
1284
+ return (
1285
+ <>
1286
+ <button>before</button>
1287
+ <ToggleGroup type={type as never} aria-label="Formatting">
1288
+ <ToggleGroupItem value="bold" aria-label="Bold" />
1289
+ <ToggleGroupItem value="italic" aria-label="Italic" />
1290
+ <ToggleGroupItem value="underline" aria-label="Underline" />
1291
+ </ToggleGroup>
1292
+ </>
1293
+ );
1294
+ }
1295
+
1296
+ it("is one tab stop, and the arrow keys move between items", async () => {
1297
+ const user = userEvent.setup();
1298
+ render(<Example />);
1299
+ await user.tab(); // before
1300
+ await user.tab(); // into the group, landing on the first item
1301
+ expect(screen.getByRole("button", { name: "Bold" })).toHaveFocus();
1302
+
1303
+ await user.keyboard("{ArrowRight}");
1304
+ expect(screen.getByRole("button", { name: "Italic" })).toHaveFocus();
1305
+ await user.keyboard("{ArrowRight}");
1306
+ expect(screen.getByRole("button", { name: "Underline" })).toHaveFocus();
1307
+ await user.keyboard("{ArrowLeft}");
1308
+ expect(screen.getByRole("button", { name: "Italic" })).toHaveFocus();
1309
+
1310
+ // A roving tabindex: Tab leaves the group rather than walking the rest of its items.
1311
+ await user.tab();
1312
+ expect(screen.getByRole("button", { name: "Italic" })).not.toHaveFocus();
1313
+ });
1314
+
1315
+ it("in multiple mode, Space presses items independently", async () => {
1316
+ const user = userEvent.setup();
1317
+ render(<Example />);
1318
+ await user.tab();
1319
+ await user.tab();
1320
+ await user.keyboard(" ");
1321
+ expect(screen.getByRole("button", { name: "Bold" })).toHaveAttribute("aria-pressed", "true");
1322
+
1323
+ await user.keyboard("{ArrowRight}");
1324
+ await user.keyboard(" ");
1325
+ // Both stay pressed — that is what multiple means, and what single must not do.
1326
+ expect(screen.getByRole("button", { name: "Bold" })).toHaveAttribute("aria-pressed", "true");
1327
+ expect(screen.getByRole("button", { name: "Italic" })).toHaveAttribute("aria-pressed", "true");
1328
+ });
1329
+
1330
+ it("in single mode, pressing one item releases the other", async () => {
1331
+ const user = userEvent.setup();
1332
+ render(<Example type="single" />);
1333
+ await user.tab();
1334
+ await user.tab();
1335
+ await user.keyboard(" ");
1336
+ expect(screen.getByRole("radio", { name: "Bold" })).toHaveAttribute("aria-checked", "true");
1337
+
1338
+ await user.keyboard("{ArrowRight}");
1339
+ await user.keyboard(" ");
1340
+ expect(screen.getByRole("radio", { name: "Italic" })).toHaveAttribute("aria-checked", "true");
1341
+ expect(screen.getByRole("radio", { name: "Bold" })).toHaveAttribute("aria-checked", "false");
1342
+ });
1343
+ });
1344
+
1345
+ describe("Input and Textarea", () => {
1346
+ it("are reached in source order by Tab and skipped when disabled", async () => {
1347
+ const user = userEvent.setup();
1348
+ render(
1349
+ <>
1350
+ <Input aria-label="First" />
1351
+ <Input aria-label="Skipped" disabled />
1352
+ <Textarea aria-label="Second" />
1353
+ </>,
1354
+ );
1355
+ await user.tab();
1356
+ expect(screen.getByLabelText("First")).toHaveFocus();
1357
+ await user.tab();
1358
+ // The disabled field is not a stop, so one Tab goes straight past it to the textarea.
1359
+ expect(screen.getByLabelText("Second")).toHaveFocus();
1360
+ await user.tab({ shift: true });
1361
+ expect(screen.getByLabelText("First")).toHaveFocus();
1362
+ });
1363
+
1364
+ it("stay focusable and readable when read-only, and reject typing", async () => {
1365
+ const user = userEvent.setup();
1366
+ render(<Input aria-label="Reference" readOnly defaultValue="KX-1024" />);
1367
+ const field = screen.getByLabelText("Reference") as HTMLInputElement;
1368
+ await user.tab();
1369
+ // Read-only is not disabled: it must still be reachable, or the value cannot be read or copied.
1370
+ expect(field).toHaveFocus();
1371
+ await user.keyboard("x");
1372
+ expect(field.value).toBe("KX-1024");
1373
+ });
1374
+ });
1375
+
1376
+ describe("Select", () => {
1377
+ function Example({ onValueChange }: { onValueChange?: (v: string) => void } = {}) {
1378
+ return (
1379
+ <Select onValueChange={onValueChange}>
1380
+ <SelectTrigger aria-label="Fruit">
1381
+ <SelectValue placeholder="Pick one" />
1382
+ </SelectTrigger>
1383
+ <SelectContent>
1384
+ <SelectItem value="apple">Apple</SelectItem>
1385
+ <SelectItem value="banana">Banana</SelectItem>
1386
+ <SelectItem value="cherry">Cherry</SelectItem>
1387
+ </SelectContent>
1388
+ </Select>
1389
+ );
1390
+ }
1391
+
1392
+ it("opens with Enter, walks options with the arrow keys and Home/End, and commits with Enter", async () => {
1393
+ const user = userEvent.setup();
1394
+ const onValueChange = vi.fn();
1395
+ render(<Example onValueChange={onValueChange} />);
1396
+ await user.tab();
1397
+ expect(screen.getByRole("combobox", { name: "Fruit" })).toHaveFocus();
1398
+ await user.keyboard("{Enter}");
1399
+ await screen.findByRole("listbox");
1400
+
1401
+ await user.keyboard("{ArrowDown}");
1402
+ await user.keyboard("{End}");
1403
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveFocus();
1404
+ await user.keyboard("{Home}");
1405
+ expect(screen.getByRole("option", { name: "Apple" })).toHaveFocus();
1406
+ await user.keyboard("{Enter}");
1407
+ expect(onValueChange).toHaveBeenCalledExactlyOnceWith("apple");
1408
+ });
1409
+
1410
+ it("jumps to an option by typing its first letter", async () => {
1411
+ const user = userEvent.setup();
1412
+ render(<Example />);
1413
+ await user.tab();
1414
+ await user.keyboard("{Enter}");
1415
+ await screen.findByRole("listbox");
1416
+ await user.keyboard("c");
1417
+ expect(screen.getByRole("option", { name: "Cherry" })).toHaveFocus();
1418
+ });
1419
+
1420
+ it("closes with Escape without choosing, and hands focus back to the trigger", async () => {
1421
+ const user = userEvent.setup();
1422
+ const onValueChange = vi.fn();
1423
+ render(<Example onValueChange={onValueChange} />);
1424
+ await user.tab();
1425
+ await user.keyboard("{Enter}");
1426
+ await screen.findByRole("listbox");
1427
+ await user.keyboard("{ArrowDown}");
1428
+ await user.keyboard("{Escape}");
1429
+ await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
1430
+ expect(onValueChange).not.toHaveBeenCalled();
1431
+ expect(screen.getByRole("combobox", { name: "Fruit" })).toHaveFocus();
1432
+ });
1433
+ });
1434
+
1435
+ describe("InputOTP", () => {
1436
+ function Example({ onChange }: { onChange?: (v: string) => void } = {}) {
1437
+ return (
1438
+ <InputOTP maxLength={4} onChange={onChange} aria-label="One-time code">
1439
+ <InputOTPGroup>
1440
+ {[0, 1, 2, 3].map((i) => (
1441
+ <InputOTPSlot key={i} index={i} data-testid={`slot-${i}`} />
1442
+ ))}
1443
+ </InputOTPGroup>
1444
+ </InputOTP>
1445
+ );
1446
+ }
1447
+ const chars = () => [0, 1, 2, 3].map((i) => screen.getByTestId(`slot-${i}`).textContent);
1448
+ /** The slot the caret is in is marked active (and carries the focus ring); there is one real input behind all four. */
1449
+ const caretAt = () => [0, 1, 2, 3].findIndex((i) => screen.getByTestId(`slot-${i}`).hasAttribute("data-active"));
1450
+
1451
+ it("fills one slot per keystroke and advances the caret", async () => {
1452
+ const user = userEvent.setup();
1453
+ const onChange = vi.fn();
1454
+ render(<Example onChange={onChange} />);
1455
+ await user.tab();
1456
+ expect(screen.getByRole("textbox", { name: "One-time code" })).toHaveFocus();
1457
+ expect(caretAt()).toBe(0);
1458
+
1459
+ await user.keyboard("12");
1460
+ expect(chars()).toEqual(["1", "2", "", ""]);
1461
+ expect(caretAt()).toBe(2);
1462
+ expect(onChange).toHaveBeenLastCalledWith("12");
1463
+ });
1464
+
1465
+ it("clears the last filled slot on Backspace and steps the caret back", async () => {
1466
+ const user = userEvent.setup();
1467
+ render(<Example />);
1468
+ await user.tab();
1469
+ await user.keyboard("123");
1470
+ await user.keyboard("{Backspace}");
1471
+ expect(chars()).toEqual(["1", "2", "", ""]);
1472
+ expect(caretAt()).toBe(2);
1473
+ });
1474
+
1475
+ it("accepts a pasted code into every slot at once", async () => {
1476
+ const user = userEvent.setup();
1477
+ const onChange = vi.fn();
1478
+ render(<Example onChange={onChange} />);
1479
+ await user.click(screen.getByRole("textbox", { name: "One-time code" }));
1480
+ await user.paste("4321");
1481
+ expect(chars()).toEqual(["4", "3", "2", "1"]);
1482
+ expect(onChange).toHaveBeenLastCalledWith("4321");
1483
+ });
1484
+ });
1485
+
1486
+ describe("AlertDialog", () => {
1487
+ function Example() {
1488
+ return (
1489
+ <AlertDialog>
1490
+ <AlertDialogTrigger>Delete account</AlertDialogTrigger>
1491
+ <AlertDialogContent>
1492
+ <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
1493
+ <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
1494
+ <AlertDialogFooter>
1495
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
1496
+ <AlertDialogAction>Continue</AlertDialogAction>
1497
+ </AlertDialogFooter>
1498
+ </AlertDialogContent>
1499
+ </AlertDialog>
1500
+ );
1501
+ }
1502
+
1503
+ it("opens with Enter and puts focus on the safe action, not the destructive one", async () => {
1504
+ const user = userEvent.setup();
1505
+ render(<Example />);
1506
+ screen.getByRole("button", { name: "Delete account" }).focus();
1507
+ await user.keyboard("{Enter}");
1508
+
1509
+ const alert = await screen.findByRole("alertdialog", { name: "Are you absolutely sure?" });
1510
+ expect(alert).toHaveAccessibleDescription("This action cannot be undone.");
1511
+ // The whole point of an alert dialog: the key that opened it must not also confirm it. Focus lands
1512
+ // on Cancel, so a second Enter dismisses rather than deletes.
1513
+ await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus());
1514
+ });
1515
+
1516
+ it("closes on Escape and returns focus to the trigger", async () => {
1517
+ const user = userEvent.setup();
1518
+ render(<Example />);
1519
+ const trigger = screen.getByRole("button", { name: "Delete account" });
1520
+ await user.click(trigger);
1521
+ await screen.findByRole("alertdialog");
1522
+
1523
+ await user.keyboard("{Escape}");
1524
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
1525
+ expect(trigger).toHaveFocus();
1526
+ });
1527
+ });
1528
+
1529
+ describe("Sheet", () => {
1530
+ function Example() {
1531
+ return (
1532
+ <Sheet>
1533
+ <SheetTrigger>Open</SheetTrigger>
1534
+ <SheetContent>
1535
+ <SheetTitle>Edit profile</SheetTitle>
1536
+ <SheetDescription>Make changes here.</SheetDescription>
1537
+ <button>Save</button>
1538
+ </SheetContent>
1539
+ </Sheet>
1540
+ );
1541
+ }
1542
+
1543
+ it("traps Tab inside the panel and closes on Escape, returning focus", async () => {
1544
+ const user = userEvent.setup();
1545
+ render(<Example />);
1546
+ const trigger = screen.getByRole("button", { name: "Open" });
1547
+ await user.click(trigger);
1548
+ const panel = await screen.findByRole("dialog", { name: "Edit profile" });
1549
+ expect(panel.contains(document.activeElement)).toBe(true);
1550
+
1551
+ for (let i = 0; i < 6; i++) {
1552
+ await user.tab();
1553
+ expect(panel.contains(document.activeElement)).toBe(true);
1554
+ }
1555
+
1556
+ await user.keyboard("{Escape}");
1557
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
1558
+ expect(trigger).toHaveFocus();
1559
+ });
1560
+ });
1561
+
1562
+ describe("Modal", () => {
1563
+ it("opens from the keyboard, names itself, and closes on Escape without taking the action", async () => {
1564
+ const user = userEvent.setup();
1565
+ const onAction = vi.fn();
1566
+ const onCancel = vi.fn();
1567
+ render(
1568
+ <Modal
1569
+ type="Destructive"
1570
+ title="Delete this item"
1571
+ description="This cannot be undone."
1572
+ trigger={<button>Delete</button>}
1573
+ onAction={onAction}
1574
+ onCancel={onCancel}
1575
+ />,
1576
+ );
1577
+ const trigger = screen.getByRole("button", { name: "Delete" });
1578
+ trigger.focus();
1579
+ await user.keyboard("{Enter}");
1580
+
1581
+ const dialog = await screen.findByRole("dialog", { name: "Delete this item" });
1582
+ expect(dialog.contains(document.activeElement)).toBe(true);
1583
+
1584
+ await user.keyboard("{Escape}");
1585
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
1586
+ expect(trigger).toHaveFocus();
1587
+ // Escape is a dismissal, not a decision: neither handler runs. A consumer who needs to know the
1588
+ // modal closed listens to `onOpenChange`, which is what that prop is for.
1589
+ expect(onAction).not.toHaveBeenCalled();
1590
+ expect(onCancel).not.toHaveBeenCalled();
1591
+ });
1592
+ });
1593
+
1594
+ describe("Tooltip", () => {
1595
+ it("appears on keyboard focus and describes its trigger", async () => {
1596
+ const user = userEvent.setup();
1597
+ render(
1598
+ <TooltipProvider>
1599
+ <Tooltip>
1600
+ <TooltipTrigger>Library</TooltipTrigger>
1601
+ <TooltipContent>Add to library</TooltipContent>
1602
+ </Tooltip>
1603
+ </TooltipProvider>,
1604
+ );
1605
+ await user.tab();
1606
+ const trigger = screen.getByRole("button", { name: /Library/ });
1607
+ expect(trigger).toHaveFocus();
1608
+
1609
+ // Not modal, and it must not become keyboard-inaccessible for that reason: focus alone shows it,
1610
+ // and the trigger points at it with aria-describedby so it is announced rather than merely drawn.
1611
+ await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument());
1612
+ expect(trigger).toHaveAccessibleDescription("Add to library");
1613
+ });
1614
+ });
1615
+
1616
+ describe("Menubar", () => {
1617
+ function Example() {
1618
+ return (
1619
+ <Menubar>
1620
+ <MenubarMenu>
1621
+ <MenubarTrigger>File</MenubarTrigger>
1622
+ <MenubarContent>
1623
+ <MenubarItem>New Tab</MenubarItem>
1624
+ <MenubarItem>New Window</MenubarItem>
1625
+ </MenubarContent>
1626
+ </MenubarMenu>
1627
+ <MenubarMenu>
1628
+ <MenubarTrigger>Edit</MenubarTrigger>
1629
+ <MenubarContent>
1630
+ <MenubarItem>Undo</MenubarItem>
1631
+ </MenubarContent>
1632
+ </MenubarMenu>
1633
+ </Menubar>
1634
+ );
1635
+ }
1636
+
1637
+ it("opens a menu with Enter, moves into its items, and closes on Escape", async () => {
1638
+ const user = userEvent.setup();
1639
+ render(<Example />);
1640
+ const file = screen.getByRole("menuitem", { name: "File" });
1641
+ file.focus();
1642
+ await user.keyboard("{Enter}");
1643
+
1644
+ await screen.findByRole("menu");
1645
+ await waitFor(() => expect(screen.getByRole("menuitem", { name: "New Tab" })).toHaveFocus());
1646
+ await user.keyboard("{ArrowDown}");
1647
+ expect(screen.getByRole("menuitem", { name: "New Window" })).toHaveFocus();
1648
+
1649
+ await user.keyboard("{Escape}");
1650
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
1651
+ });
1652
+ });