@kinetixui/ui 0.17.0 → 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.
Files changed (60) hide show
  1. package/dist/index.js +377 -237
  2. package/package.json +2 -2
  3. package/src/components/audio-player.tsx +2 -2
  4. package/src/components/badge.tsx +1 -1
  5. package/src/components/banner.tsx +1 -1
  6. package/src/components/button.tsx +8 -8
  7. package/src/components/calendar.tsx +1 -1
  8. package/src/components/chart.tsx +2 -2
  9. package/src/components/checkbox.tsx +2 -2
  10. package/src/components/circular-progress.tsx +1 -1
  11. package/src/components/code-block.tsx +1 -1
  12. package/src/components/color-picker.tsx +4 -0
  13. package/src/components/data-grid.tsx +169 -25
  14. package/src/components/diff-viewer.tsx +9 -8
  15. package/src/components/fab.tsx +1 -1
  16. package/src/components/file-upload.tsx +13 -15
  17. package/src/components/input-group.tsx +1 -1
  18. package/src/components/input-otp.tsx +2 -1
  19. package/src/components/input.tsx +2 -2
  20. package/src/components/json-viewer.tsx +12 -10
  21. package/src/components/list.tsx +41 -27
  22. package/src/components/markdown-editor.tsx +3 -2
  23. package/src/components/message-bubble.tsx +3 -3
  24. package/src/components/multi-select.tsx +14 -3
  25. package/src/components/native-select.tsx +2 -2
  26. package/src/components/notification-center.tsx +2 -2
  27. package/src/components/progress.tsx +1 -1
  28. package/src/components/radio-group.tsx +3 -3
  29. package/src/components/scroll-area.tsx +1 -1
  30. package/src/components/select.tsx +2 -2
  31. package/src/components/skeleton.tsx +1 -1
  32. package/src/components/slider.tsx +2 -2
  33. package/src/components/sonner.tsx +1 -1
  34. package/src/components/spinner.tsx +3 -3
  35. package/src/components/stepper.tsx +3 -3
  36. package/src/components/switch.tsx +1 -1
  37. package/src/components/tab-bar.tsx +1 -1
  38. package/src/components/table-of-contents.tsx +1 -1
  39. package/src/components/textarea.tsx +2 -2
  40. package/src/components/timeline.tsx +1 -1
  41. package/src/components/tooltip.tsx +1 -1
  42. package/src/components/virtual-list.tsx +2 -1
  43. package/src/components-a11y.test.tsx +4 -25
  44. package/src/components-behavior.test.tsx +1 -1
  45. package/src/components-keyboard.test.tsx +236 -33
  46. package/src/stories/AppBar.stories.tsx +1 -1
  47. package/src/stories/Checkbox.stories.tsx +1 -1
  48. package/src/stories/CircularProgress.stories.tsx +4 -4
  49. package/src/stories/InputOTP.stories.tsx +2 -2
  50. package/src/stories/List.stories.tsx +2 -2
  51. package/src/stories/MarkdownEditor.stories.tsx +1 -1
  52. package/src/stories/MultiSelect.stories.tsx +1 -0
  53. package/src/stories/NativeSelect.stories.tsx +1 -1
  54. package/src/stories/NumberInput.stories.tsx +3 -3
  55. package/src/stories/Progress.stories.tsx +3 -3
  56. package/src/stories/Select.stories.tsx +2 -2
  57. package/src/stories/Slider.stories.tsx +3 -3
  58. package/src/stories/Switch.stories.tsx +1 -1
  59. package/src/stories/ToggleGroup.stories.tsx +6 -6
  60. package/tailwind.config.ts +14 -0
@@ -4,6 +4,8 @@ import userEvent from "@testing-library/user-event";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
  import { Checkbox } from "./components/checkbox";
6
6
  import { ColorPicker } from "./components/color-picker";
7
+ import { FileUpload } from "./components/file-upload";
8
+ import { List, ListItem } from "./components/list";
7
9
  import { DataGrid, type DataGridColumn } from "./components/data-grid";
8
10
  import { KinetixDirectionProvider } from "./components/direction-provider";
9
11
  import {
@@ -432,84 +434,225 @@ describe("DataGrid", () => {
432
434
  { id: 2, name: "Abe" },
433
435
  { id: 3, name: "Bea" },
434
436
  ];
435
- function setup(onCellEdit = vi.fn()) {
437
+ function setup(opts: { onCellEdit?: ReturnType<typeof vi.fn>; data?: Row[]; wrapper?: (n: React.ReactNode) => React.ReactNode } = {}) {
438
+ const onCellEdit = opts.onCellEdit ?? vi.fn();
436
439
  const columns: DataGridColumn<Row>[] = [
437
440
  { 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 },
441
+ { id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id, minWidth: 60, width: 100 },
439
442
  ];
440
- render(<DataGrid columns={columns} data={rows} height={200} getRowId={(r) => r.id} />);
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
+ );
441
450
  return onCellEdit;
442
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]!;
443
454
  const names = () => screen.getAllByRole("gridcell").filter((c) => /^(Cara|Abe|Bea)$/.test(c.textContent ?? "")).map((c) => c.textContent);
444
455
 
445
- it("sorts from the keyboard: a sortable header is focusable and Enter / Space toggle it", async () => {
456
+ it("is a single tab stop: Tab enters on the first header, the next Tab leaves the grid", async () => {
446
457
  const user = userEvent.setup();
447
458
  setup();
448
- const header = screen.getByRole("columnheader", { name: "Name" });
449
- expect(header).toHaveAttribute("aria-sort", "none");
450
459
  await user.tab();
451
- expect(header).toHaveFocus();
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
+ });
452
466
 
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"]);
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");
459
475
  });
460
476
 
461
- it("a header that is not sortable is not a tab stop", async () => {
477
+ it("moves between cells with the arrow keys, header row included, and stops at the edges", async () => {
462
478
  const user = userEvent.setup();
463
479
  setup();
464
- expect(screen.getByRole("columnheader", { name: "ID" })).not.toHaveAttribute("tabindex");
465
480
  await user.tab(); // Name header
466
- await user.tab(); // first editable cell — the ID header was skipped
467
- expect(screen.getAllByRole("gridcell")[0]).toHaveFocus();
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"]);
468
533
  });
469
534
 
470
535
  it("edits a cell from the keyboard: Enter to edit, Enter to commit, focus returns to the cell", async () => {
471
536
  const user = userEvent.setup();
472
537
  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();
538
+ await user.tab();
539
+ await user.keyboard("{ArrowDown}");
540
+ const target = cell(0, 0);
541
+ expect(target).toHaveFocus();
477
542
 
478
543
  await user.keyboard("{Enter}");
479
- const input = within(cell).getByRole("textbox");
544
+ const input = within(target).getByRole("textbox");
480
545
  expect(input).toHaveFocus();
481
546
  await user.clear(input);
482
547
  await user.type(input, "Zed{Enter}");
483
548
 
484
549
  expect(onCellEdit).toHaveBeenCalledTimes(1);
485
550
  expect(onCellEdit).toHaveBeenCalledWith(rows[0], 0, "Zed");
486
- await waitFor(() => expect(within(cell).queryByRole("textbox")).not.toBeInTheDocument());
487
- expect(cell).toHaveFocus();
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();
488
555
  });
489
556
 
490
557
  it("Escape cancels an edit without committing and returns focus to the cell", async () => {
491
558
  const user = userEvent.setup();
492
559
  const onCellEdit = setup();
493
560
  await user.tab();
494
- await user.tab();
495
- const cell = screen.getAllByRole("gridcell")[0]!;
561
+ await user.keyboard("{ArrowDown}");
562
+ const target = cell(0, 0);
496
563
  await user.keyboard("{F2}");
497
- await user.type(within(cell).getByRole("textbox"), "x{Escape}");
564
+ await user.type(within(target).getByRole("textbox"), "x{Escape}");
498
565
 
499
566
  expect(onCellEdit).not.toHaveBeenCalled();
500
- expect(cell).toHaveFocus();
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");
501
584
  });
502
585
 
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 () => {
586
+ it("Shift+Arrow resizes a column from its header and announces the width", async () => {
507
587
  const user = userEvent.setup();
508
588
  setup();
509
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> });
510
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();
511
611
  await user.keyboard("{ArrowRight}");
512
- expect(screen.getAllByRole("gridcell")[1]).toHaveFocus();
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
+ });
513
656
  });
514
657
  });
515
658
 
@@ -569,3 +712,63 @@ describe("Slider", () => {
569
712
  expect(onValueChange).toHaveBeenLastCalledWith([100]);
570
713
  });
571
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
+ });
@@ -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-label-sm font-bold text-primary-foreground">
11
+ <span className="grid size-6 place-items-center rounded bg-primary text-[11px] font-bold text-primary-foreground">
12
12
  A
13
13
  </span>
14
14
  Acme
@@ -24,6 +24,6 @@ const meta = {
24
24
 
25
25
  export default meta;
26
26
 
27
- export const Playground: StoryObj<typeof meta> = { render: (args) => <Checkbox {...args} /> };
27
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <Checkbox aria-label="Accept terms" {...args} /> };
28
28
 
29
29
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -12,9 +12,9 @@ const Demo = () => {
12
12
  }, []);
13
13
  return (
14
14
  <div className="flex items-center gap-6">
15
- <CircularProgress value={v} showValue />
16
- <CircularProgress value={v} size={64} strokeWidth={6} showValue />
17
- <CircularProgress value={v} />
15
+ <CircularProgress value={v} showValue aria-label="Upload progress" />
16
+ <CircularProgress value={v} size={64} strokeWidth={6} showValue aria-label="Upload progress" />
17
+ <CircularProgress value={v} aria-label="Upload progress" />
18
18
  </div>
19
19
  );
20
20
  };
@@ -37,6 +37,6 @@ const meta = {
37
37
 
38
38
  export default meta;
39
39
 
40
- export const Playground: StoryObj<typeof meta> = { render: (args) => <CircularProgress {...args} /> };
40
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <CircularProgress aria-label="Upload progress" {...args} /> };
41
41
 
42
42
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -5,7 +5,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
5
5
  import { InputOTP, InputOTPGroup, InputOTPSlot } from "@kinetixui/ui";
6
6
 
7
7
  const Demo = () => (
8
- <InputOTP maxLength={6}>
8
+ <InputOTP maxLength={6} aria-label="One-time code">
9
9
  <InputOTPGroup>
10
10
  {Array.from({ length: 6 }).map((_, i) => (
11
11
  <InputOTPSlot key={i} index={i} />
@@ -18,7 +18,7 @@ const meta = {
18
18
  title: "Form Inputs/InputOTP",
19
19
  parameters: {
20
20
  layout: "centered",
21
- docs: { source: { code: "<InputOTP maxLength={6}>\\n <InputOTPGroup>\\n <InputOTPSlot index={0} />\\n ...\\n <InputOTPSlot index={5} />\\n </InputOTPGroup>\\n</InputOTP>", language: "tsx" } },
21
+ docs: { source: { code: "<InputOTP maxLength={6} aria-label=\\\"One-time code\\\">\\n <InputOTPGroup>\\n <InputOTPSlot index={0} />\\n ...\\n <InputOTPSlot index={5} />\\n </InputOTPGroup>\\n</InputOTP>", language: "tsx" } },
22
22
  },
23
23
  } satisfies Meta;
24
24
 
@@ -8,7 +8,7 @@ import { Bell, Settings, User } from "lucide-react";
8
8
  const Demo = () => (
9
9
  <List className="w-full max-w-sm rounded-md border">
10
10
  <ListItem leading={<User className="size-5 text-muted-foreground" />} title="Profile" description="Name, photo, and personal details" trailing={<Badge variant="outline">2</Badge>} onSelect={() => {}} />
11
- <ListItem leading={<Bell className="size-5 text-muted-foreground" />} title="Notifications" description="Push, email and SMS preferences" trailing={<Switch />} />
11
+ <ListItem leading={<Bell className="size-5 text-muted-foreground" />} title="Notifications" description="Push, email and SMS preferences" trailing={<Switch aria-label="Notifications" />} />
12
12
  <ListItem leading={<Settings className="size-5 text-muted-foreground" />} title="Settings" onSelect={() => {}} />
13
13
  </List>
14
14
  );
@@ -17,7 +17,7 @@ const meta = {
17
17
  title: "Data Display/List",
18
18
  parameters: {
19
19
  layout: "centered",
20
- docs: { source: { code: "<List>\\n <ListItem\\n leading={<User />}\\n title=\"Profile\"\\n description=\"Name, photo, and personal details\"\\n onSelect={() => router.push(\"/profile\")}\\n />\\n <ListItem leading={<Bell />} title=\"Notifications\" trailing={<Switch />} />\\n</List>", language: "tsx" } },
20
+ docs: { source: { code: "<List>\\n <ListItem\\n leading={<User />}\\n title=\"Profile\"\\n description=\"Name, photo, and personal details\"\\n onSelect={() => router.push(\"/profile\")}\\n />\\n <ListItem leading={<Bell />} title=\"Notifications\" trailing={<Switch aria-label=\"Notifications\" />} />\\n</List>", language: "tsx" } },
21
21
  },
22
22
  } satisfies Meta;
23
23
 
@@ -9,7 +9,7 @@ const Demo = () => {
9
9
  const [text, setText] = React.useState(
10
10
  "## Release notes\n\nShipped **MarkdownEditor** — a toolbar over a plain textarea, not `contenteditable`.\n\n- Bold, italic, links\n- Bulleted and numbered lists\n- An optional preview pane",
11
11
  );
12
- return <MarkdownEditor className="w-full" value={text} onChange={setText} rows={8} />;
12
+ return <MarkdownEditor aria-label="Release notes" className="w-full" value={text} onChange={setText} rows={8} />;
13
13
  };
14
14
 
15
15
  const meta = {
@@ -8,6 +8,7 @@ const Demo = () => {
8
8
  const [value, setValue] = React.useState(["react", "swiftui"]);
9
9
  return (
10
10
  <MultiSelect
11
+ aria-label="Platforms"
11
12
  className="w-full max-w-sm"
12
13
  value={value}
13
14
  onValueChange={setValue}
@@ -5,7 +5,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
5
5
  import { NativeSelect, NativeSelectOptGroup, NativeSelectOption, Select } from "@kinetixui/ui";
6
6
 
7
7
  const Demo = () => (
8
- <NativeSelect className="max-w-xs" defaultValue="">
8
+ <NativeSelect aria-label="Fruit" className="max-w-xs" defaultValue="">
9
9
  <NativeSelectOption value="" disabled>
10
10
  Select a fruit…
11
11
  </NativeSelectOption>
@@ -6,7 +6,7 @@ import { NumberInput } from "@kinetixui/ui";
6
6
 
7
7
  const Demo = () => {
8
8
  const [v, setV] = React.useState(2);
9
- return <NumberInput value={v} onChange={setV} min={0} max={10} className="w-32" />;
9
+ return <NumberInput aria-label="Quantity" value={v} onChange={setV} min={0} max={10} className="w-32" />;
10
10
  };
11
11
 
12
12
  const meta = {
@@ -18,12 +18,12 @@ const meta = {
18
18
  },
19
19
  parameters: {
20
20
  layout: "centered",
21
- docs: { source: { code: "<NumberInput value={qty} onChange={setQty} min={0} max={10} />", language: "tsx" } },
21
+ docs: { source: { code: "<NumberInput aria-label=\\\"Quantity\\\" value={qty} onChange={setQty} min={0} max={10} />", language: "tsx" } },
22
22
  },
23
23
  } satisfies Meta;
24
24
 
25
25
  export default meta;
26
26
 
27
- export const Playground: StoryObj<typeof meta> = { render: (args) => <NumberInput {...args} className="w-32" /> };
27
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <NumberInput aria-label="Quantity" {...args} className="w-32" /> };
28
28
 
29
29
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -10,7 +10,7 @@ const Demo = () => {
10
10
  const t = setTimeout(() => setV(66), 600);
11
11
  return () => clearTimeout(t);
12
12
  }, []);
13
- return <Progress value={v} className="w-[60%]" />;
13
+ return <Progress value={v} aria-label="Upload progress" className="w-[60%]" />;
14
14
  };
15
15
 
16
16
  const meta = {
@@ -20,12 +20,12 @@ const meta = {
20
20
  argTypes: { value: { control: { type: "range", min: 0, max: 100 } } },
21
21
  parameters: {
22
22
  layout: "centered",
23
- docs: { source: { code: "const [value, setValue] = React.useState(13)\\n// ...\\n<Progress value={value} />", language: "tsx" } },
23
+ docs: { source: { code: "const [value, setValue] = React.useState(13)\\n// ...\\n<Progress value={value} aria-label=\\\"Upload progress\\\" />", language: "tsx" } },
24
24
  },
25
25
  } satisfies Meta;
26
26
 
27
27
  export default meta;
28
28
 
29
- export const Playground: StoryObj<typeof meta> = { render: (args) => <Progress {...args} className="w-60" /> };
29
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <Progress aria-label="Upload progress" {...args} className="w-60" /> };
30
30
 
31
31
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
6
6
 
7
7
  const Demo = () => (
8
8
  <Select>
9
- <SelectTrigger className="w-[220px]">
9
+ <SelectTrigger className="w-[220px]" aria-label="Fruit">
10
10
  <SelectValue placeholder="Select a fruit" />
11
11
  </SelectTrigger>
12
12
  <SelectContent>
@@ -32,7 +32,7 @@ export default meta;
32
32
 
33
33
  export const Playground: StoryObj<typeof meta> = { render: (args) => (
34
34
  <Select {...args}>
35
- <SelectTrigger className="w-[220px]">
35
+ <SelectTrigger className="w-[220px]" aria-label="Fruit">
36
36
  <SelectValue placeholder="Select a fruit" />
37
37
  </SelectTrigger>
38
38
  <SelectContent>
@@ -4,7 +4,7 @@ import * as React from "react";
4
4
  import type { Meta, StoryObj } from "@storybook/react-vite";
5
5
  import { Slider } from "@kinetixui/ui";
6
6
 
7
- const Demo = () => <Slider defaultValue={[50]} max={100} step={1} className="w-[60%]" />;
7
+ const Demo = () => <Slider aria-label="Volume" defaultValue={[50]} max={100} step={1} className="w-[60%]" />;
8
8
 
9
9
  const meta = {
10
10
  title: "Form Inputs/Slider",
@@ -13,12 +13,12 @@ const meta = {
13
13
  argTypes: { disabled: { control: "boolean" } },
14
14
  parameters: {
15
15
  layout: "centered",
16
- docs: { source: { code: "<Slider defaultValue={[50]} max={100} step={1} />", language: "tsx" } },
16
+ docs: { source: { code: "<Slider aria-label=\"Volume\" defaultValue={[50]} max={100} step={1} />", language: "tsx" } },
17
17
  },
18
18
  } satisfies Meta;
19
19
 
20
20
  export default meta;
21
21
 
22
- export const Playground: StoryObj<typeof meta> = { render: (args) => <Slider {...args} defaultValue={[50]} max={100} step={1} className="w-60" /> };
22
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <Slider aria-label="Volume" {...args} defaultValue={[50]} max={100} step={1} className="w-60" /> };
23
23
 
24
24
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -24,6 +24,6 @@ const meta = {
24
24
 
25
25
  export default meta;
26
26
 
27
- export const Playground: StoryObj<typeof meta> = { render: (args) => <Switch {...args} /> };
27
+ export const Playground: StoryObj<typeof meta> = { render: (args) => <Switch aria-label="Airplane mode" {...args} /> };
28
28
 
29
29
  export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -7,9 +7,9 @@ import { Bold, Italic, Underline } from "lucide-react";
7
7
 
8
8
  const Demo = () => (
9
9
  <ToggleGroup type="multiple">
10
- <ToggleGroupItem value="bold"><Bold className="size-4" /></ToggleGroupItem>
11
- <ToggleGroupItem value="italic"><Italic className="size-4" /></ToggleGroupItem>
12
- <ToggleGroupItem value="underline"><Underline className="size-4" /></ToggleGroupItem>
10
+ <ToggleGroupItem value="bold" aria-label="Bold"><Bold className="size-4" /></ToggleGroupItem>
11
+ <ToggleGroupItem value="italic" aria-label="Italic"><Italic className="size-4" /></ToggleGroupItem>
12
+ <ToggleGroupItem value="underline" aria-label="Underline"><Underline className="size-4" /></ToggleGroupItem>
13
13
  </ToggleGroup>
14
14
  );
15
15
 
@@ -28,9 +28,9 @@ export default meta;
28
28
 
29
29
  export const Playground: StoryObj<typeof meta> = { render: (args) => (
30
30
  <ToggleGroup {...args}>
31
- <ToggleGroupItem value="bold"><Bold className="size-4" /></ToggleGroupItem>
32
- <ToggleGroupItem value="italic"><Italic className="size-4" /></ToggleGroupItem>
33
- <ToggleGroupItem value="underline"><Underline className="size-4" /></ToggleGroupItem>
31
+ <ToggleGroupItem value="bold" aria-label="Bold"><Bold className="size-4" /></ToggleGroupItem>
32
+ <ToggleGroupItem value="italic" aria-label="Italic"><Italic className="size-4" /></ToggleGroupItem>
33
+ <ToggleGroupItem value="underline" aria-label="Underline"><Underline className="size-4" /></ToggleGroupItem>
34
34
  </ToggleGroup>
35
35
  ) };
36
36
 
@@ -66,6 +66,20 @@ export default {
66
66
  "on-container": c("--semantic-on-info-container"),
67
67
  },
68
68
  tertiary: { DEFAULT: c("--tertiary"), foreground: c("--tertiary-foreground") },
69
+ // Role tokens layered over `primary` (which stays the shadcn-compatible source). `action`, `link`
70
+ // and `focus` default to it, so a theme that only sets `--primary` still works; override them
71
+ // individually to split the interactive colour, link colour and focus colour. `brand` is identity
72
+ // (logos, headers), not a control colour.
73
+ brand: { DEFAULT: c("--brand"), foreground: c("--brand-foreground") },
74
+ action: {
75
+ DEFAULT: c("--action"),
76
+ foreground: c("--action-foreground"),
77
+ // explicit states for native ports; on the web, hover/pressed derive from `action` (bg-action/90, /85)
78
+ hover: c("--action-hover"),
79
+ pressed: c("--action-pressed"),
80
+ },
81
+ link: c("--link"),
82
+ focus: c("--focus"),
69
83
  border: c("--border"),
70
84
  input: c("--input"),
71
85
  ring: c("--ring"),