@kinetixui/ui 0.16.0 → 0.17.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.
@@ -0,0 +1,571 @@
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 { DataGrid, type DataGridColumn } from "./components/data-grid";
8
+ import { KinetixDirectionProvider } from "./components/direction-provider";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from "./components/dialog";
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuTrigger,
21
+ } from "./components/dropdown-menu";
22
+ import { MultiSelect } from "./components/multi-select";
23
+ import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
24
+ import { RadioGroup, RadioGroupItem } from "./components/radio-group";
25
+ import { Slider } from "./components/slider";
26
+ import { Switch } from "./components/switch";
27
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/tabs";
28
+ import { Tour } from "./components/tour";
29
+ import { TreeItem, TreeView } from "./components/tree-view";
30
+
31
+ /**
32
+ * Keyboard / focus behaviour — the runtime half of accessibility that the static
33
+ * contrast check and axe can't judge: that a control is reachable and operable
34
+ * from the keyboard, that overlays move focus in and give it back, and that
35
+ * arrow keys respect reading direction. jsdom has no layout, so these assert
36
+ * on focus and ARIA state, not on geometry.
37
+ */
38
+
39
+ describe("Dialog", () => {
40
+ function Example() {
41
+ return (
42
+ <>
43
+ <button>before</button>
44
+ <Dialog>
45
+ <DialogTrigger>Open</DialogTrigger>
46
+ <DialogContent>
47
+ <DialogTitle>Edit profile</DialogTitle>
48
+ <DialogDescription>Change your details.</DialogDescription>
49
+ <input aria-label="Name" />
50
+ <button>Save</button>
51
+ </DialogContent>
52
+ </Dialog>
53
+ </>
54
+ );
55
+ }
56
+
57
+ it("opens from the keyboard and moves focus inside", async () => {
58
+ const user = userEvent.setup();
59
+ render(<Example />);
60
+ await user.tab(); // "before"
61
+ await user.tab(); // trigger
62
+ expect(screen.getByRole("button", { name: "Open" })).toHaveFocus();
63
+ await user.keyboard("{Enter}");
64
+
65
+ const dialog = await screen.findByRole("dialog", { name: "Edit profile" });
66
+ expect(dialog).toHaveAccessibleDescription("Change your details.");
67
+ expect(dialog.contains(document.activeElement)).toBe(true);
68
+ });
69
+
70
+ it("traps Tab inside the dialog", async () => {
71
+ const user = userEvent.setup();
72
+ render(<Example />);
73
+ await user.click(screen.getByRole("button", { name: "Open" }));
74
+ const dialog = await screen.findByRole("dialog");
75
+
76
+ for (let i = 0; i < 8; i++) {
77
+ await user.tab();
78
+ expect(dialog.contains(document.activeElement)).toBe(true);
79
+ }
80
+ for (let i = 0; i < 8; i++) {
81
+ await user.tab({ shift: true });
82
+ expect(dialog.contains(document.activeElement)).toBe(true);
83
+ }
84
+ });
85
+
86
+ it("closes on Escape and returns focus to the trigger", async () => {
87
+ const user = userEvent.setup();
88
+ render(<Example />);
89
+ const trigger = screen.getByRole("button", { name: "Open" });
90
+ await user.click(trigger);
91
+ await screen.findByRole("dialog");
92
+
93
+ await user.keyboard("{Escape}");
94
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
95
+ expect(trigger).toHaveFocus();
96
+ });
97
+ });
98
+
99
+ describe("DropdownMenu", () => {
100
+ function Example() {
101
+ return (
102
+ <DropdownMenu>
103
+ <DropdownMenuTrigger>Actions</DropdownMenuTrigger>
104
+ <DropdownMenuContent>
105
+ <DropdownMenuItem>Edit</DropdownMenuItem>
106
+ <DropdownMenuItem>Duplicate</DropdownMenuItem>
107
+ <DropdownMenuItem>Delete</DropdownMenuItem>
108
+ </DropdownMenuContent>
109
+ </DropdownMenu>
110
+ );
111
+ }
112
+
113
+ it("opens with Enter, walks items with the arrow keys, closes with Escape", async () => {
114
+ const user = userEvent.setup();
115
+ render(<Example />);
116
+ const trigger = screen.getByRole("button", { name: "Actions" });
117
+ trigger.focus();
118
+ await user.keyboard("{Enter}");
119
+
120
+ const menu = await screen.findByRole("menu");
121
+ expect(menu).toBeInTheDocument();
122
+ await waitFor(() => expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveFocus());
123
+
124
+ await user.keyboard("{ArrowDown}");
125
+ expect(screen.getByRole("menuitem", { name: "Duplicate" })).toHaveFocus();
126
+ await user.keyboard("{ArrowDown}{ArrowDown}"); // Delete, then the end: Radix menus don't wrap by default
127
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toHaveFocus();
128
+ await user.keyboard("{ArrowUp}");
129
+ expect(screen.getByRole("menuitem", { name: "Duplicate" })).toHaveFocus();
130
+ await user.keyboard("{Home}");
131
+ expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveFocus();
132
+
133
+ await user.keyboard("{Escape}");
134
+ await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
135
+ expect(trigger).toHaveFocus();
136
+ });
137
+ });
138
+
139
+ describe("Popover", () => {
140
+ it("closes on Escape and returns focus to its trigger", async () => {
141
+ const user = userEvent.setup();
142
+ render(
143
+ <Popover>
144
+ <PopoverTrigger>Info</PopoverTrigger>
145
+ <PopoverContent>
146
+ <button>Inside</button>
147
+ </PopoverContent>
148
+ </Popover>,
149
+ );
150
+ const trigger = screen.getByRole("button", { name: "Info" });
151
+ await user.click(trigger);
152
+ expect(await screen.findByRole("button", { name: "Inside" })).toBeInTheDocument();
153
+
154
+ await user.keyboard("{Escape}");
155
+ await waitFor(() => expect(screen.queryByRole("button", { name: "Inside" })).not.toBeInTheDocument());
156
+ expect(trigger).toHaveFocus();
157
+ });
158
+ });
159
+
160
+ describe("Tabs", () => {
161
+ function Example({ dir }: { dir?: "ltr" | "rtl" }) {
162
+ const tabs = (
163
+ <Tabs defaultValue="a" dir={dir}>
164
+ <TabsList>
165
+ <TabsTrigger value="a">One</TabsTrigger>
166
+ <TabsTrigger value="b">Two</TabsTrigger>
167
+ <TabsTrigger value="c">Three</TabsTrigger>
168
+ </TabsList>
169
+ <TabsContent value="a">A</TabsContent>
170
+ <TabsContent value="b">B</TabsContent>
171
+ <TabsContent value="c">C</TabsContent>
172
+ </Tabs>
173
+ );
174
+ return dir ? <KinetixDirectionProvider dir={dir}>{tabs}</KinetixDirectionProvider> : tabs;
175
+ }
176
+
177
+ it("is a single tab stop; ArrowRight moves to and selects the next tab", async () => {
178
+ const user = userEvent.setup();
179
+ render(<Example />);
180
+ await user.tab();
181
+ expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
182
+ expect(screen.getByRole("tab", { name: "Two" })).toHaveAttribute("tabindex", "-1");
183
+
184
+ await user.keyboard("{ArrowRight}");
185
+ expect(screen.getByRole("tab", { name: "Two" })).toHaveFocus();
186
+ expect(screen.getByRole("tab", { name: "Two" })).toHaveAttribute("aria-selected", "true");
187
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("B");
188
+ });
189
+
190
+ it("supports Home and End", async () => {
191
+ const user = userEvent.setup();
192
+ render(<Example />);
193
+ await user.tab();
194
+ await user.keyboard("{End}");
195
+ expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
196
+ await user.keyboard("{Home}");
197
+ expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
198
+ });
199
+
200
+ it("mirrors the arrow keys under RTL", async () => {
201
+ const user = userEvent.setup();
202
+ render(<Example dir="rtl" />);
203
+ await user.tab();
204
+ expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
205
+
206
+ // In a right-to-left list the *next* item is to the left.
207
+ await user.keyboard("{ArrowLeft}");
208
+ expect(screen.getByRole("tab", { name: "Two" })).toHaveFocus();
209
+ await user.keyboard("{ArrowRight}");
210
+ expect(screen.getByRole("tab", { name: "One" })).toHaveFocus();
211
+ });
212
+ });
213
+
214
+ describe("RadioGroup", () => {
215
+ it("is one tab stop; arrows move and select; Tab leaves the group", async () => {
216
+ const user = userEvent.setup();
217
+ render(
218
+ <>
219
+ <RadioGroup defaultValue="a" aria-label="Plan">
220
+ <RadioGroupItem value="a" aria-label="Free" />
221
+ <RadioGroupItem value="b" aria-label="Pro" />
222
+ <RadioGroupItem value="c" aria-label="Team" />
223
+ </RadioGroup>
224
+ <button>after</button>
225
+ </>,
226
+ );
227
+ await user.tab();
228
+ expect(screen.getByRole("radio", { name: "Free" })).toHaveFocus();
229
+
230
+ // Radix moves focus on a timeout after keydown and only selects if the arrow key is still
231
+ // held when focus lands — user-event releases synchronously, so hold it like a real press.
232
+ await user.keyboard("{ArrowDown>}");
233
+ await waitFor(() => expect(screen.getByRole("radio", { name: "Pro" })).toHaveFocus());
234
+ await user.keyboard("{/ArrowDown}");
235
+ expect(screen.getByRole("radio", { name: "Pro" })).toBeChecked();
236
+
237
+ await user.tab();
238
+ expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
239
+ });
240
+ });
241
+
242
+ describe("Checkbox and Switch", () => {
243
+ it("Checkbox toggles with Space, not Enter", async () => {
244
+ const user = userEvent.setup();
245
+ render(<Checkbox aria-label="Accept terms" />);
246
+ await user.tab();
247
+ const box = screen.getByRole("checkbox", { name: "Accept terms" });
248
+ expect(box).toHaveFocus();
249
+
250
+ await user.keyboard("{Enter}");
251
+ expect(box).toHaveAttribute("aria-checked", "false");
252
+ await user.keyboard(" ");
253
+ expect(box).toHaveAttribute("aria-checked", "true");
254
+ });
255
+
256
+ it("Switch toggles with Space and exposes role=switch", async () => {
257
+ const user = userEvent.setup();
258
+ render(<Switch aria-label="Notifications" />);
259
+ await user.tab();
260
+ const sw = screen.getByRole("switch", { name: "Notifications" });
261
+ expect(sw).toHaveFocus();
262
+ expect(sw).toHaveAttribute("aria-checked", "false");
263
+ await user.keyboard(" ");
264
+ expect(sw).toHaveAttribute("aria-checked", "true");
265
+ });
266
+
267
+ it("a disabled Switch is skipped by Tab", async () => {
268
+ const user = userEvent.setup();
269
+ render(
270
+ <>
271
+ <button>first</button>
272
+ <Switch aria-label="Off limits" disabled />
273
+ <button>last</button>
274
+ </>,
275
+ );
276
+ await user.tab();
277
+ await user.tab();
278
+ expect(screen.getByRole("button", { name: "last" })).toHaveFocus();
279
+ });
280
+ });
281
+
282
+ // ── Custom widgets ─────────────────────────────────────────────────────────
283
+ // These hand-roll their keyboard model (no Radix primitive underneath), so they
284
+ // are where a regression or a gap is most likely.
285
+
286
+
287
+ describe("TreeView", () => {
288
+ function Example() {
289
+ const [selected, setSelected] = React.useState<string | undefined>();
290
+ return (
291
+ <TreeView aria-label="Files" selected={selected} onSelectedChange={setSelected} defaultExpanded={[]}>
292
+ <TreeItem value="src" label="src">
293
+ <TreeItem value="app" label="app.ts" />
294
+ <TreeItem value="util" label="util.ts" />
295
+ </TreeItem>
296
+ <TreeItem value="docs" label="docs" />
297
+ </TreeView>
298
+ );
299
+ }
300
+
301
+ it("is one tab stop and moves with ArrowDown/ArrowUp", async () => {
302
+ const user = userEvent.setup();
303
+ render(<Example />);
304
+ await user.tab();
305
+ expect(screen.getByRole("treeitem", { name: /src/ })).toHaveFocus();
306
+ await user.keyboard("{ArrowDown}");
307
+ expect(screen.getByRole("treeitem", { name: /docs/ })).toHaveFocus();
308
+ await user.keyboard("{ArrowUp}");
309
+ expect(screen.getByRole("treeitem", { name: /src/ })).toHaveFocus();
310
+ });
311
+
312
+ it("ArrowRight expands, ArrowLeft collapses, and aria-expanded follows", async () => {
313
+ const user = userEvent.setup();
314
+ render(<Example />);
315
+ await user.tab();
316
+ const src = screen.getByRole("treeitem", { name: /src/ });
317
+ expect(src).toHaveAttribute("aria-expanded", "false");
318
+
319
+ await user.keyboard("{ArrowRight}");
320
+ expect(src).toHaveAttribute("aria-expanded", "true");
321
+ await user.keyboard("{ArrowRight}"); // into the first child
322
+ // A parent's accessible name includes its children's text, so match the child exactly.
323
+ expect(screen.getByRole("treeitem", { name: "app.ts" })).toHaveFocus();
324
+ await user.keyboard("{ArrowLeft}"); // back to the parent
325
+ expect(src).toHaveFocus();
326
+ await user.keyboard("{ArrowLeft}");
327
+ expect(src).toHaveAttribute("aria-expanded", "false");
328
+ });
329
+
330
+ it("Enter selects the focused item", async () => {
331
+ const user = userEvent.setup();
332
+ render(<Example />);
333
+ await user.tab();
334
+ await user.keyboard("{ArrowDown}{Enter}");
335
+ expect(screen.getByRole("treeitem", { name: /docs/ })).toHaveAttribute("aria-selected", "true");
336
+ });
337
+ });
338
+
339
+ describe("MultiSelect", () => {
340
+ const options = [
341
+ { value: "a", label: "Apple" },
342
+ { value: "b", label: "Banana" },
343
+ { value: "c", label: "Cherry" },
344
+ ];
345
+
346
+ it("can be opened and an option chosen using only the keyboard", async () => {
347
+ const user = userEvent.setup();
348
+ render(<MultiSelect aria-label="Fruit" options={options} />);
349
+ await user.tab();
350
+ const combobox = screen.getByRole("combobox");
351
+ expect(combobox).toHaveFocus();
352
+ await user.keyboard("{Enter}");
353
+ expect(combobox).toHaveAttribute("aria-expanded", "true");
354
+
355
+ // Keys must now drive the option list (focus should be in its search input).
356
+ await user.keyboard("{ArrowDown}{Enter}");
357
+ await waitFor(() => expect(screen.getAllByText("Banana").length).toBeGreaterThan(1));
358
+ });
359
+
360
+ it("closes with Escape and gives focus back to the combobox", async () => {
361
+ const user = userEvent.setup();
362
+ render(<MultiSelect aria-label="Fruit" options={options} />);
363
+ await user.tab();
364
+ await user.keyboard("{Enter}");
365
+ await user.keyboard("{Escape}");
366
+ await waitFor(() => expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"));
367
+ expect(screen.getByRole("combobox")).toHaveFocus();
368
+ });
369
+ });
370
+
371
+ describe("Tour", () => {
372
+ const steps = [
373
+ { target: "#one", title: "Step one", content: "First." },
374
+ { target: "#two", title: "Step two", content: "Second." },
375
+ ];
376
+ function Example() {
377
+ const [open, setOpen] = React.useState(false);
378
+ const [i, setI] = React.useState(0);
379
+ return (
380
+ <>
381
+ <button id="one" onClick={() => setOpen(true)}>
382
+ Start tour
383
+ </button>
384
+ <button id="two">Other</button>
385
+ <Tour steps={steps} open={open} stepIndex={i} onStepIndexChange={setI} onOpenChange={setOpen} />
386
+ </>
387
+ );
388
+ }
389
+
390
+ it("is a modal dialog with an accessible name taken from the step title", async () => {
391
+ const user = userEvent.setup();
392
+ render(<Example />);
393
+ await user.click(screen.getByRole("button", { name: "Start tour" }));
394
+ expect(await screen.findByRole("dialog", { name: "Step one" })).toBeInTheDocument();
395
+ });
396
+
397
+ it("moves focus into the dialog when it opens", async () => {
398
+ const user = userEvent.setup();
399
+ render(<Example />);
400
+ await user.click(screen.getByRole("button", { name: "Start tour" }));
401
+ const dialog = await screen.findByRole("dialog");
402
+ await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
403
+ });
404
+
405
+ it("closes on Escape and returns focus to where it was", async () => {
406
+ const user = userEvent.setup();
407
+ render(<Example />);
408
+ const start = screen.getByRole("button", { name: "Start tour" });
409
+ await user.click(start);
410
+ await screen.findByRole("dialog");
411
+ await user.keyboard("{Escape}");
412
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
413
+ expect(start).toHaveFocus();
414
+ });
415
+
416
+ it("keeps Tab inside the dialog while it is open", async () => {
417
+ const user = userEvent.setup();
418
+ render(<Example />);
419
+ await user.click(screen.getByRole("button", { name: "Start tour" }));
420
+ const dialog = await screen.findByRole("dialog");
421
+ for (let i = 0; i < 6; i++) {
422
+ await user.tab();
423
+ expect(dialog.contains(document.activeElement)).toBe(true);
424
+ }
425
+ });
426
+ });
427
+
428
+ describe("DataGrid", () => {
429
+ type Row = { id: number; name: string };
430
+ const rows: Row[] = [
431
+ { id: 1, name: "Cara" },
432
+ { id: 2, name: "Abe" },
433
+ { id: 3, name: "Bea" },
434
+ ];
435
+ function setup(onCellEdit = vi.fn()) {
436
+ const columns: DataGridColumn<Row>[] = [
437
+ { id: "name", header: "Name", cell: (r) => r.name, value: (r) => r.name, sortable: true, editable: true, onCellEdit },
438
+ { id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id },
439
+ ];
440
+ render(<DataGrid columns={columns} data={rows} height={200} getRowId={(r) => r.id} />);
441
+ return onCellEdit;
442
+ }
443
+ const names = () => screen.getAllByRole("gridcell").filter((c) => /^(Cara|Abe|Bea)$/.test(c.textContent ?? "")).map((c) => c.textContent);
444
+
445
+ it("sorts from the keyboard: a sortable header is focusable and Enter / Space toggle it", async () => {
446
+ const user = userEvent.setup();
447
+ setup();
448
+ const header = screen.getByRole("columnheader", { name: "Name" });
449
+ expect(header).toHaveAttribute("aria-sort", "none");
450
+ await user.tab();
451
+ expect(header).toHaveFocus();
452
+
453
+ await user.keyboard("{Enter}");
454
+ expect(header).toHaveAttribute("aria-sort", "ascending");
455
+ expect(names()).toEqual(["Abe", "Bea", "Cara"]);
456
+ await user.keyboard(" ");
457
+ expect(header).toHaveAttribute("aria-sort", "descending");
458
+ expect(names()).toEqual(["Cara", "Bea", "Abe"]);
459
+ });
460
+
461
+ it("a header that is not sortable is not a tab stop", async () => {
462
+ const user = userEvent.setup();
463
+ setup();
464
+ expect(screen.getByRole("columnheader", { name: "ID" })).not.toHaveAttribute("tabindex");
465
+ await user.tab(); // Name header
466
+ await user.tab(); // first editable cell — the ID header was skipped
467
+ expect(screen.getAllByRole("gridcell")[0]).toHaveFocus();
468
+ });
469
+
470
+ it("edits a cell from the keyboard: Enter to edit, Enter to commit, focus returns to the cell", async () => {
471
+ const user = userEvent.setup();
472
+ const onCellEdit = setup();
473
+ await user.tab(); // sortable Name header
474
+ await user.tab(); // first editable cell
475
+ const cell = screen.getAllByRole("gridcell")[0]!;
476
+ expect(cell).toHaveFocus();
477
+
478
+ await user.keyboard("{Enter}");
479
+ const input = within(cell).getByRole("textbox");
480
+ expect(input).toHaveFocus();
481
+ await user.clear(input);
482
+ await user.type(input, "Zed{Enter}");
483
+
484
+ expect(onCellEdit).toHaveBeenCalledTimes(1);
485
+ expect(onCellEdit).toHaveBeenCalledWith(rows[0], 0, "Zed");
486
+ await waitFor(() => expect(within(cell).queryByRole("textbox")).not.toBeInTheDocument());
487
+ expect(cell).toHaveFocus();
488
+ });
489
+
490
+ it("Escape cancels an edit without committing and returns focus to the cell", async () => {
491
+ const user = userEvent.setup();
492
+ const onCellEdit = setup();
493
+ await user.tab();
494
+ await user.tab();
495
+ const cell = screen.getAllByRole("gridcell")[0]!;
496
+ await user.keyboard("{F2}");
497
+ await user.type(within(cell).getByRole("textbox"), "x{Escape}");
498
+
499
+ expect(onCellEdit).not.toHaveBeenCalled();
500
+ expect(cell).toHaveFocus();
501
+ });
502
+
503
+ // KNOWN GAP — the ARIA grid pattern also wants arrow-key movement between cells (roving tabindex),
504
+ // plus keyboard equivalents for column reorder and resize. Not implemented yet; `it.fails` turns
505
+ // this into an error the day it starts passing, so the gap can't be forgotten or silently closed.
506
+ it.fails("moves between cells with the arrow keys", async () => {
507
+ const user = userEvent.setup();
508
+ setup();
509
+ await user.tab();
510
+ await user.tab();
511
+ await user.keyboard("{ArrowRight}");
512
+ expect(screen.getAllByRole("gridcell")[1]).toHaveFocus();
513
+ });
514
+ });
515
+
516
+ describe("ColorPicker", () => {
517
+ it("adjusts the saturation/value square with the arrow keys (Shift = bigger step)", async () => {
518
+ const user = userEvent.setup();
519
+ const onChange = vi.fn();
520
+ render(<ColorPicker value="#3b82f6" onChange={onChange} />);
521
+ const square = screen.getByRole("slider", { name: "Saturation and value" });
522
+ square.focus();
523
+
524
+ await user.keyboard("{ArrowRight}");
525
+ expect(onChange).toHaveBeenCalledTimes(1);
526
+ const one = onChange.mock.calls[0]![0] as string;
527
+ expect(one).toMatch(/^#[0-9a-f]{6}$/i);
528
+ expect(one.toLowerCase()).not.toBe("#3b82f6");
529
+
530
+ onChange.mockClear();
531
+ await user.keyboard("{Shift>}{ArrowRight}{/Shift}");
532
+ expect(onChange).toHaveBeenCalledTimes(1);
533
+ });
534
+
535
+ it("the hue rail is a named, keyboard-operable slider", async () => {
536
+ const user = userEvent.setup();
537
+ const onChange = vi.fn();
538
+ render(<ColorPicker value="#3b82f6" onChange={onChange} />);
539
+ const hue = screen.getByRole("slider", { name: "Hue" });
540
+ hue.focus();
541
+ await user.keyboard("{ArrowRight}");
542
+ expect(onChange).toHaveBeenCalled();
543
+ });
544
+
545
+ it("commits the hex field on Enter", async () => {
546
+ const user = userEvent.setup();
547
+ const onChange = vi.fn();
548
+ render(<ColorPicker value="#3b82f6" onChange={onChange} />);
549
+ const hex = screen.getByRole("textbox");
550
+ await user.clear(hex);
551
+ await user.type(hex, "ff0000{Enter}");
552
+ expect(onChange).toHaveBeenLastCalledWith("#ff0000");
553
+ });
554
+ });
555
+
556
+ describe("Slider", () => {
557
+ it("names the thumb (the role=slider element) from aria-label and moves with the arrow keys", async () => {
558
+ const user = userEvent.setup();
559
+ const onValueChange = vi.fn();
560
+ render(<Slider aria-label="Volume" defaultValue={[50]} max={100} onValueChange={onValueChange} />);
561
+ const thumb = screen.getByRole("slider", { name: "Volume" });
562
+ await user.tab();
563
+ expect(thumb).toHaveFocus();
564
+ expect(thumb).toHaveAttribute("aria-valuenow", "50");
565
+
566
+ await user.keyboard("{ArrowRight}");
567
+ expect(onValueChange).toHaveBeenLastCalledWith([51]);
568
+ await user.keyboard("{End}");
569
+ expect(onValueChange).toHaveBeenLastCalledWith([100]);
570
+ });
571
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cn } from "./utils";
3
+
4
+ describe("cn", () => {
5
+ it("keeps a type-scale class and a text colour together", () => {
6
+ expect(cn("text-primary-foreground", "text-label-sm")).toBe("text-primary-foreground text-label-sm");
7
+ expect(cn("text-label-sm", "text-primary-foreground")).toBe("text-label-sm text-primary-foreground");
8
+ });
9
+ it("still lets a later type-scale class override an earlier one", () => {
10
+ expect(cn("text-label-sm", "text-body-md")).toBe("text-body-md");
11
+ });
12
+ });
package/src/lib/utils.ts CHANGED
@@ -1,5 +1,26 @@
1
1
  import { type ClassValue, clsx } from "clsx";
2
- import { twMerge } from "tailwind-merge";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+
4
+ // The Material type-scale utilities (`text-label-md`, `text-body-sm`, …) come
5
+ // from `fontSize` in tailwind.config.ts. Without this, tailwind-merge reads them
6
+ // as text *colours* and drops a real colour class (`text-primary-foreground`).
7
+ const twMerge = extendTailwindMerge({
8
+ extend: {
9
+ classGroups: {
10
+ "font-size": [
11
+ {
12
+ text: [
13
+ "display-lg", "display-md", "display-sm",
14
+ "headline-lg", "headline-md", "headline-sm",
15
+ "title-lg", "title-md", "title-sm", "title-dialog",
16
+ "label-lg", "label-md", "label-sm",
17
+ "body-lg", "body-md", "body-sm",
18
+ ],
19
+ },
20
+ ],
21
+ },
22
+ },
23
+ });
3
24
 
4
25
  /** Merges Tailwind class names, resolving conflicts (clsx + tailwind-merge). */
5
26
  export function cn(...inputs: ClassValue[]) {
@@ -8,7 +8,7 @@ import { Bell } from "lucide-react";
8
8
  const Demo = () => (
9
9
  <AppBar className="w-full max-w-2xl rounded-md border">
10
10
  <AppBarBrand>
11
- <span className="grid size-6 place-items-center rounded bg-primary text-[11px] font-bold text-primary-foreground">
11
+ <span className="grid size-6 place-items-center rounded bg-primary text-label-sm font-bold text-primary-foreground">
12
12
  A
13
13
  </span>
14
14
  Acme
@@ -59,7 +59,12 @@ export default {
59
59
  destructive: { DEFAULT: c("--destructive"), foreground: c("--destructive-foreground") },
60
60
  success: { DEFAULT: c("--success"), foreground: c("--success-foreground") },
61
61
  warning: { DEFAULT: c("--warning"), foreground: c("--warning-foreground") },
62
- info: { DEFAULT: c("--info"), foreground: c("--info-foreground") },
62
+ info: {
63
+ DEFAULT: c("--info"),
64
+ foreground: c("--info-foreground"),
65
+ // text on a tinted info surface (bg-info/10) — --info alone is 4.15:1 there
66
+ "on-container": c("--semantic-on-info-container"),
67
+ },
63
68
  tertiary: { DEFAULT: c("--tertiary"), foreground: c("--tertiary-foreground") },
64
69
  border: c("--border"),
65
70
  input: c("--input"),