@kinetixui/ui 0.17.0 → 0.19.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 +16 -1
- package/dist/index.js +455 -238
- package/package.json +2 -2
- package/src/components/audio-player.tsx +2 -2
- package/src/components/badge.tsx +1 -1
- package/src/components/banner.tsx +1 -1
- package/src/components/button.tsx +8 -8
- 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 +4 -0
- package/src/components/data-grid.tsx +276 -24
- 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/input-group.tsx +1 -1
- package/src/components/input-otp.tsx +2 -1
- package/src/components/input.tsx +2 -2
- package/src/components/json-viewer.tsx +12 -10
- 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/multi-select.tsx +14 -3
- package/src/components/native-select.tsx +2 -2
- 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 +2 -2
- package/src/components/skeleton.tsx +1 -1
- package/src/components/slider.tsx +2 -2
- 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/textarea.tsx +2 -2
- package/src/components/timeline.tsx +1 -1
- package/src/components/tooltip.tsx +1 -1
- package/src/components/virtual-list.tsx +2 -1
- package/src/components-a11y.test.tsx +4 -25
- package/src/components-behavior.test.tsx +1 -1
- package/src/components-keyboard.test.tsx +388 -34
- package/src/stories/AppBar.stories.tsx +1 -1
- package/src/stories/Checkbox.stories.tsx +1 -1
- package/src/stories/CircularProgress.stories.tsx +4 -4
- package/src/stories/DataGrid.stories.tsx +2 -1
- 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 +14 -0
|
@@ -4,7 +4,9 @@ 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 {
|
|
7
|
+
import { FileUpload } from "./components/file-upload";
|
|
8
|
+
import { List, ListItem } from "./components/list";
|
|
9
|
+
import { DataGrid, type DataGridColumn, type DataGridSelection } from "./components/data-grid";
|
|
8
10
|
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
9
11
|
import {
|
|
10
12
|
Dialog,
|
|
@@ -432,84 +434,376 @@ describe("DataGrid", () => {
|
|
|
432
434
|
{ id: 2, name: "Abe" },
|
|
433
435
|
{ id: 3, name: "Bea" },
|
|
434
436
|
];
|
|
435
|
-
function setup(
|
|
437
|
+
function setup(
|
|
438
|
+
opts: {
|
|
439
|
+
onCellEdit?: ReturnType<typeof vi.fn>;
|
|
440
|
+
data?: Row[];
|
|
441
|
+
wrapper?: (n: React.ReactNode) => React.ReactNode;
|
|
442
|
+
selectable?: boolean;
|
|
443
|
+
onSelectionChange?: (s: DataGridSelection | null) => void;
|
|
444
|
+
} = {},
|
|
445
|
+
) {
|
|
446
|
+
const onCellEdit = opts.onCellEdit ?? vi.fn();
|
|
436
447
|
const columns: DataGridColumn<Row>[] = [
|
|
437
448
|
{ 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 },
|
|
449
|
+
{ id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id, minWidth: 60, width: 100 },
|
|
439
450
|
];
|
|
440
|
-
|
|
451
|
+
const grid = (
|
|
452
|
+
<DataGrid
|
|
453
|
+
columns={columns}
|
|
454
|
+
data={opts.data ?? rows}
|
|
455
|
+
height={200}
|
|
456
|
+
getRowId={(r) => r.id}
|
|
457
|
+
selectable={opts.selectable}
|
|
458
|
+
onSelectionChange={opts.onSelectionChange}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
render(
|
|
462
|
+
<>
|
|
463
|
+
{opts.wrapper ? opts.wrapper(grid) : grid}
|
|
464
|
+
<button>after</button>
|
|
465
|
+
</>,
|
|
466
|
+
);
|
|
441
467
|
return onCellEdit;
|
|
442
468
|
}
|
|
469
|
+
const header = (name: string) => screen.getByRole("columnheader", { name });
|
|
470
|
+
const cell = (r: number, c: number) => screen.getAllByRole("row")[r + 1]!.querySelectorAll<HTMLElement>('[role="gridcell"]')[c]!;
|
|
443
471
|
const names = () => screen.getAllByRole("gridcell").filter((c) => /^(Cara|Abe|Bea)$/.test(c.textContent ?? "")).map((c) => c.textContent);
|
|
444
472
|
|
|
445
|
-
it("
|
|
473
|
+
it("is a single tab stop: Tab enters on the first header, the next Tab leaves the grid", async () => {
|
|
446
474
|
const user = userEvent.setup();
|
|
447
475
|
setup();
|
|
448
|
-
const header = screen.getByRole("columnheader", { name: "Name" });
|
|
449
|
-
expect(header).toHaveAttribute("aria-sort", "none");
|
|
450
476
|
await user.tab();
|
|
451
|
-
expect(header).toHaveFocus();
|
|
477
|
+
expect(header("Name")).toHaveFocus();
|
|
478
|
+
expect(header("ID")).toHaveAttribute("tabindex", "-1");
|
|
479
|
+
expect(cell(0, 0)).toHaveAttribute("tabindex", "-1");
|
|
480
|
+
await user.tab();
|
|
481
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
482
|
+
});
|
|
452
483
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
expect(
|
|
458
|
-
expect(
|
|
484
|
+
it("exposes grid dimensions and row / column indexes (rows are virtualized, so they must be explicit)", () => {
|
|
485
|
+
setup();
|
|
486
|
+
const grid = screen.getByRole("grid");
|
|
487
|
+
expect(grid).toHaveAttribute("aria-rowcount", "4"); // header + 3
|
|
488
|
+
expect(grid).toHaveAttribute("aria-colcount", "2");
|
|
489
|
+
expect(screen.getAllByRole("row")[0]).toHaveAttribute("aria-rowindex", "1");
|
|
490
|
+
expect(screen.getAllByRole("row")[3]).toHaveAttribute("aria-rowindex", "4");
|
|
491
|
+
expect(cell(0, 1)).toHaveAttribute("aria-colindex", "2");
|
|
459
492
|
});
|
|
460
493
|
|
|
461
|
-
it("
|
|
494
|
+
it("moves between cells with the arrow keys, header row included, and stops at the edges", async () => {
|
|
462
495
|
const user = userEvent.setup();
|
|
463
496
|
setup();
|
|
464
|
-
expect(screen.getByRole("columnheader", { name: "ID" })).not.toHaveAttribute("tabindex");
|
|
465
497
|
await user.tab(); // Name header
|
|
466
|
-
await user.
|
|
467
|
-
expect(
|
|
498
|
+
await user.keyboard("{ArrowRight}");
|
|
499
|
+
expect(header("ID")).toHaveFocus();
|
|
500
|
+
await user.keyboard("{ArrowRight}"); // last column: stays
|
|
501
|
+
expect(header("ID")).toHaveFocus();
|
|
502
|
+
await user.keyboard("{ArrowDown}");
|
|
503
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
504
|
+
await user.keyboard("{ArrowLeft}");
|
|
505
|
+
expect(cell(0, 0)).toHaveFocus();
|
|
506
|
+
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); // past the last row: stays
|
|
507
|
+
expect(cell(2, 0)).toHaveFocus();
|
|
508
|
+
await user.keyboard("{ArrowUp}{ArrowUp}{ArrowUp}");
|
|
509
|
+
expect(header("Name")).toHaveFocus();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("Home / End go to the row ends; Ctrl+Home / Ctrl+End to the grid corners", async () => {
|
|
513
|
+
const user = userEvent.setup();
|
|
514
|
+
setup();
|
|
515
|
+
await user.tab();
|
|
516
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // row 1, Name
|
|
517
|
+
await user.keyboard("{End}");
|
|
518
|
+
expect(cell(1, 1)).toHaveFocus();
|
|
519
|
+
await user.keyboard("{Home}");
|
|
520
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
521
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
522
|
+
expect(cell(2, 1)).toHaveFocus();
|
|
523
|
+
await user.keyboard("{Control>}{Home}{/Control}");
|
|
524
|
+
expect(header("Name")).toHaveFocus();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("remembers the active cell: leaving and re-entering the grid returns to it", async () => {
|
|
528
|
+
const user = userEvent.setup();
|
|
529
|
+
setup();
|
|
530
|
+
await user.tab();
|
|
531
|
+
await user.keyboard("{ArrowDown}{ArrowRight}");
|
|
532
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
533
|
+
await user.tab(); // out
|
|
534
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
535
|
+
await user.tab({ shift: true }); // back in
|
|
536
|
+
expect(cell(0, 1)).toHaveFocus();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("sorts from the keyboard: Enter / Space on a sortable header", async () => {
|
|
540
|
+
const user = userEvent.setup();
|
|
541
|
+
setup();
|
|
542
|
+
await user.tab();
|
|
543
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "none");
|
|
544
|
+
await user.keyboard("{Enter}");
|
|
545
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "ascending");
|
|
546
|
+
expect(names()).toEqual(["Abe", "Bea", "Cara"]);
|
|
547
|
+
await user.keyboard(" ");
|
|
548
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "descending");
|
|
549
|
+
expect(names()).toEqual(["Cara", "Bea", "Abe"]);
|
|
468
550
|
});
|
|
469
551
|
|
|
470
552
|
it("edits a cell from the keyboard: Enter to edit, Enter to commit, focus returns to the cell", async () => {
|
|
471
553
|
const user = userEvent.setup();
|
|
472
554
|
const onCellEdit = setup();
|
|
473
|
-
await user.tab();
|
|
474
|
-
await user.
|
|
475
|
-
const
|
|
476
|
-
expect(
|
|
555
|
+
await user.tab();
|
|
556
|
+
await user.keyboard("{ArrowDown}");
|
|
557
|
+
const target = cell(0, 0);
|
|
558
|
+
expect(target).toHaveFocus();
|
|
477
559
|
|
|
478
560
|
await user.keyboard("{Enter}");
|
|
479
|
-
const input = within(
|
|
561
|
+
const input = within(target).getByRole("textbox");
|
|
480
562
|
expect(input).toHaveFocus();
|
|
481
563
|
await user.clear(input);
|
|
482
564
|
await user.type(input, "Zed{Enter}");
|
|
483
565
|
|
|
484
566
|
expect(onCellEdit).toHaveBeenCalledTimes(1);
|
|
485
567
|
expect(onCellEdit).toHaveBeenCalledWith(rows[0], 0, "Zed");
|
|
486
|
-
await waitFor(() => expect(within(
|
|
487
|
-
expect(
|
|
568
|
+
await waitFor(() => expect(within(target).queryByRole("textbox")).not.toBeInTheDocument());
|
|
569
|
+
expect(target).toHaveFocus();
|
|
570
|
+
await user.keyboard("{ArrowDown}"); // navigation still works after an edit
|
|
571
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
488
572
|
});
|
|
489
573
|
|
|
490
574
|
it("Escape cancels an edit without committing and returns focus to the cell", async () => {
|
|
491
575
|
const user = userEvent.setup();
|
|
492
576
|
const onCellEdit = setup();
|
|
493
577
|
await user.tab();
|
|
494
|
-
await user.
|
|
495
|
-
const
|
|
578
|
+
await user.keyboard("{ArrowDown}");
|
|
579
|
+
const target = cell(0, 0);
|
|
496
580
|
await user.keyboard("{F2}");
|
|
497
|
-
await user.type(within(
|
|
581
|
+
await user.type(within(target).getByRole("textbox"), "x{Escape}");
|
|
498
582
|
|
|
499
583
|
expect(onCellEdit).not.toHaveBeenCalled();
|
|
500
|
-
expect(
|
|
584
|
+
expect(target).toHaveFocus();
|
|
501
585
|
});
|
|
502
586
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
587
|
+
it("Alt+Arrow reorders a column from its header, keeps focus on it, and announces the move", async () => {
|
|
588
|
+
const user = userEvent.setup();
|
|
589
|
+
setup();
|
|
590
|
+
await user.tab(); // Name header
|
|
591
|
+
const headersBefore = screen.getAllByRole("columnheader").map((h) => h.textContent);
|
|
592
|
+
expect(headersBefore).toEqual(["Name", "ID"]);
|
|
593
|
+
|
|
594
|
+
await user.keyboard("{Alt>}{ArrowRight}{/Alt}");
|
|
595
|
+
await waitFor(() => expect(screen.getAllByRole("columnheader").map((h) => h.textContent)).toEqual(["ID", "Name"]));
|
|
596
|
+
expect(header("Name")).toHaveFocus();
|
|
597
|
+
expect(screen.getByRole("status")).toHaveTextContent("Name moved to position 2 of 2");
|
|
598
|
+
|
|
599
|
+
await user.keyboard("{Alt>}{ArrowRight}{/Alt}"); // already last
|
|
600
|
+
expect(screen.getByRole("status")).toHaveTextContent("already the last movable column");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("Shift+Arrow resizes a column from its header and announces the width", async () => {
|
|
507
604
|
const user = userEvent.setup();
|
|
508
605
|
setup();
|
|
509
606
|
await user.tab();
|
|
607
|
+
await user.keyboard("{ArrowRight}"); // ID header, width 100, min 60
|
|
608
|
+
expect(header("ID")).toHaveStyle({ width: "100px" });
|
|
609
|
+
await user.keyboard("{Shift>}{ArrowRight}{/Shift}");
|
|
610
|
+
expect(header("ID")).toHaveStyle({ width: "110px" });
|
|
611
|
+
expect(screen.getByRole("status")).toHaveTextContent("ID column width 110 pixels");
|
|
612
|
+
await user.keyboard("{Shift>}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{/Shift}");
|
|
613
|
+
expect(header("ID")).toHaveStyle({ width: "60px" }); // clamped to minWidth
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("advertises the header chords with aria-keyshortcuts", () => {
|
|
617
|
+
setup();
|
|
618
|
+
expect(header("Name")).toHaveAttribute("aria-keyshortcuts", expect.stringContaining("Alt+ArrowLeft"));
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("mirrors the arrow keys under RTL", async () => {
|
|
622
|
+
const user = userEvent.setup();
|
|
623
|
+
setup({ wrapper: (n) => <div dir="rtl" style={{ direction: "rtl" }}>{n}</div> });
|
|
510
624
|
await user.tab();
|
|
625
|
+
expect(header("Name")).toHaveFocus();
|
|
626
|
+
await user.keyboard("{ArrowLeft}"); // in RTL the next column is to the left
|
|
627
|
+
expect(header("ID")).toHaveFocus();
|
|
511
628
|
await user.keyboard("{ArrowRight}");
|
|
512
|
-
expect(
|
|
629
|
+
expect(header("Name")).toHaveFocus();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe("range selection (opt-in)", () => {
|
|
633
|
+
const selectedCells = () => screen.getAllByRole("gridcell").filter((c) => c.getAttribute("aria-selected") === "true");
|
|
634
|
+
|
|
635
|
+
it("is off by default: no aria-multiselectable, no aria-selected, Shift+Arrow just moves", async () => {
|
|
636
|
+
const user = userEvent.setup();
|
|
637
|
+
setup();
|
|
638
|
+
expect(screen.getByRole("grid")).not.toHaveAttribute("aria-multiselectable");
|
|
639
|
+
expect(cell(0, 0)).not.toHaveAttribute("aria-selected");
|
|
640
|
+
await user.tab();
|
|
641
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
642
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
643
|
+
expect(cell(0, 0)).not.toHaveAttribute("aria-selected");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("declares the grid multi-selectable and marks every cell selected=false until something is selected", async () => {
|
|
647
|
+
setup({ selectable: true });
|
|
648
|
+
expect(screen.getByRole("grid")).toHaveAttribute("aria-multiselectable", "true");
|
|
649
|
+
expect(cell(0, 0)).toHaveAttribute("aria-selected", "false");
|
|
650
|
+
expect(selectedCells()).toHaveLength(0);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("Shift+Arrow extends a rectangle from the anchor and reports it", async () => {
|
|
654
|
+
const user = userEvent.setup();
|
|
655
|
+
const onSelectionChange = vi.fn();
|
|
656
|
+
setup({ selectable: true, onSelectionChange });
|
|
657
|
+
await user.tab();
|
|
658
|
+
await user.keyboard("{ArrowDown}"); // Name, row 0 (anchor)
|
|
659
|
+
await user.keyboard("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
|
|
660
|
+
|
|
661
|
+
expect(selectedCells()).toHaveLength(4); // rows 0-1 x Name, ID
|
|
662
|
+
expect(cell(1, 1)).toHaveFocus();
|
|
663
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, 1], columns: ["name", "id"] });
|
|
664
|
+
expect(screen.getByRole("status")).toHaveTextContent("4 cells selected");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("shrinks when Shift moves back toward the anchor", async () => {
|
|
668
|
+
const user = userEvent.setup();
|
|
669
|
+
setup({ selectable: true });
|
|
670
|
+
await user.tab();
|
|
671
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
|
|
672
|
+
expect(selectedCells()).toHaveLength(3);
|
|
673
|
+
await user.keyboard("{Shift>}{ArrowUp}{/Shift}");
|
|
674
|
+
expect(selectedCells()).toHaveLength(2);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("a plain arrow key collapses the selection", async () => {
|
|
678
|
+
const user = userEvent.setup();
|
|
679
|
+
const onSelectionChange = vi.fn();
|
|
680
|
+
setup({ selectable: true, onSelectionChange });
|
|
681
|
+
await user.tab();
|
|
682
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
683
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
684
|
+
await user.keyboard("{ArrowDown}");
|
|
685
|
+
expect(selectedCells()).toHaveLength(0);
|
|
686
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(null);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("Ctrl+A selects every cell; Escape clears it", async () => {
|
|
690
|
+
const user = userEvent.setup();
|
|
691
|
+
setup({ selectable: true });
|
|
692
|
+
await user.tab();
|
|
693
|
+
await user.keyboard("{ArrowDown}");
|
|
694
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
695
|
+
expect(selectedCells()).toHaveLength(6); // 3 rows x 2 columns
|
|
696
|
+
await user.keyboard("{Escape}");
|
|
697
|
+
expect(selectedCells()).toHaveLength(0);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("Shift+click extends from the previously active cell", async () => {
|
|
701
|
+
const user = userEvent.setup();
|
|
702
|
+
setup({ selectable: true });
|
|
703
|
+
await user.tab();
|
|
704
|
+
await user.keyboard("{ArrowDown}"); // active: row 0, Name
|
|
705
|
+
await user.keyboard("{Shift>}");
|
|
706
|
+
await user.click(cell(2, 1));
|
|
707
|
+
await user.keyboard("{/Shift}");
|
|
708
|
+
expect(selectedCells()).toHaveLength(6);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("a plain click clears the selection", async () => {
|
|
712
|
+
const user = userEvent.setup();
|
|
713
|
+
setup({ selectable: true });
|
|
714
|
+
await user.tab();
|
|
715
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
716
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
717
|
+
await user.click(cell(2, 0));
|
|
718
|
+
expect(selectedCells()).toHaveLength(0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("Ctrl+C copies the range as tab-separated values", async () => {
|
|
722
|
+
const user = userEvent.setup();
|
|
723
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
724
|
+
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
725
|
+
setup({ selectable: true });
|
|
726
|
+
await user.tab();
|
|
727
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
|
|
728
|
+
await user.keyboard("{Control>}c{/Control}");
|
|
729
|
+
expect(writeText).toHaveBeenCalledWith("Cara\t1\nAbe\t2");
|
|
730
|
+
expect(screen.getByRole("status")).toHaveTextContent("Copied 4 cells");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("Shift+click extends from the anchor even after it has scrolled out of the rendered window", async () => {
|
|
734
|
+
const user = userEvent.setup();
|
|
735
|
+
const onSelectionChange = vi.fn();
|
|
736
|
+
const many = Array.from({ length: 300 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
|
|
737
|
+
setup({ selectable: true, data: many, onSelectionChange });
|
|
738
|
+
await user.tab();
|
|
739
|
+
await user.keyboard("{ArrowDown}"); // active + anchor: row 0
|
|
740
|
+
const grid = screen.getByRole("grid");
|
|
741
|
+
grid.scrollTop = 4000;
|
|
742
|
+
grid.dispatchEvent(new Event("scroll"));
|
|
743
|
+
await waitFor(() => expect(screen.queryByText("Person 1")).not.toBeInTheDocument());
|
|
744
|
+
|
|
745
|
+
const target = screen.getAllByRole("gridcell").find((c) => c.getAttribute("data-cell")?.endsWith(":name"))!;
|
|
746
|
+
const targetRow = Number(target.getAttribute("data-cell")!.split(":")[0]);
|
|
747
|
+
expect(targetRow).toBeGreaterThan(50);
|
|
748
|
+
|
|
749
|
+
await user.keyboard("{Shift>}");
|
|
750
|
+
await user.click(target);
|
|
751
|
+
await user.keyboard("{/Shift}");
|
|
752
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, targetRow], columns: ["name"] });
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("re-sorting clears the selection (it refers to displayed rows)", async () => {
|
|
756
|
+
const user = userEvent.setup();
|
|
757
|
+
setup({ selectable: true });
|
|
758
|
+
await user.tab();
|
|
759
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
760
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
761
|
+
await user.keyboard("{ArrowUp}{ArrowUp}{Enter}"); // Name header, sort
|
|
762
|
+
expect(selectedCells()).toHaveLength(0);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
describe("with many rows (virtualized)", () => {
|
|
767
|
+
const many: Row[] = Array.from({ length: 200 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
|
|
768
|
+
|
|
769
|
+
it("scrolls the target row into the rendered window and focuses it", async () => {
|
|
770
|
+
const user = userEvent.setup();
|
|
771
|
+
setup({ data: many });
|
|
772
|
+
await user.tab();
|
|
773
|
+
await user.keyboard("{ArrowDown}");
|
|
774
|
+
for (let i = 0; i < 30; i++) await user.keyboard("{ArrowDown}");
|
|
775
|
+
await waitFor(() => expect(document.activeElement).toHaveTextContent("Person 31"));
|
|
776
|
+
expect(document.activeElement).toHaveAttribute("data-cell", "30:name");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("PageDown / Ctrl+End jump by a page and to the last row", async () => {
|
|
780
|
+
const user = userEvent.setup();
|
|
781
|
+
setup({ data: many });
|
|
782
|
+
await user.tab();
|
|
783
|
+
await user.keyboard("{ArrowDown}");
|
|
784
|
+
await user.keyboard("{PageDown}");
|
|
785
|
+
await waitFor(() => expect(document.activeElement?.getAttribute("data-cell")).toMatch(/^(\d+):name$/));
|
|
786
|
+
const jumped = Number(document.activeElement!.getAttribute("data-cell")!.split(":")[0]);
|
|
787
|
+
expect(jumped).toBeGreaterThan(2);
|
|
788
|
+
|
|
789
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
790
|
+
await waitFor(() => expect(document.activeElement).toHaveAttribute("data-cell", "199:id"));
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("stays reachable by Tab even after the active row scrolls out of the rendered window", async () => {
|
|
794
|
+
const user = userEvent.setup();
|
|
795
|
+
setup({ data: many });
|
|
796
|
+
await user.tab();
|
|
797
|
+
await user.keyboard("{ArrowDown}");
|
|
798
|
+
await user.keyboard("{Control>}{End}{/Control}");
|
|
799
|
+
await waitFor(() => expect(document.activeElement).toHaveAttribute("data-cell", "199:id"));
|
|
800
|
+
await user.tab(); // out
|
|
801
|
+
const grid = screen.getByRole("grid");
|
|
802
|
+
grid.scrollTop = 0;
|
|
803
|
+
grid.dispatchEvent(new Event("scroll"));
|
|
804
|
+
await user.tab({ shift: true });
|
|
805
|
+
expect(grid.contains(document.activeElement)).toBe(true);
|
|
806
|
+
});
|
|
513
807
|
});
|
|
514
808
|
});
|
|
515
809
|
|
|
@@ -569,3 +863,63 @@ describe("Slider", () => {
|
|
|
569
863
|
expect(onValueChange).toHaveBeenLastCalledWith([100]);
|
|
570
864
|
});
|
|
571
865
|
});
|
|
866
|
+
|
|
867
|
+
describe("MultiSelect chips", () => {
|
|
868
|
+
it("Backspace in the empty search field removes the last chip", async () => {
|
|
869
|
+
const user = userEvent.setup();
|
|
870
|
+
const onValueChange = vi.fn();
|
|
871
|
+
render(
|
|
872
|
+
<MultiSelect
|
|
873
|
+
aria-label="Fruit"
|
|
874
|
+
options={[
|
|
875
|
+
{ value: "a", label: "Apple" },
|
|
876
|
+
{ value: "b", label: "Banana" },
|
|
877
|
+
]}
|
|
878
|
+
defaultValue={["a", "b"]}
|
|
879
|
+
onValueChange={onValueChange}
|
|
880
|
+
/>,
|
|
881
|
+
);
|
|
882
|
+
await user.tab();
|
|
883
|
+
await user.keyboard("{Enter}");
|
|
884
|
+
await user.keyboard("{Backspace}");
|
|
885
|
+
expect(onValueChange).toHaveBeenLastCalledWith(["a"]);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
describe("List", () => {
|
|
890
|
+
it("a pressable row is a listitem containing a button, and Enter / Space activate it", async () => {
|
|
891
|
+
const user = userEvent.setup();
|
|
892
|
+
const onSelect = vi.fn();
|
|
893
|
+
render(
|
|
894
|
+
<List>
|
|
895
|
+
<ListItem title="Profile" onSelect={onSelect} />
|
|
896
|
+
<ListItem title="Static" />
|
|
897
|
+
</List>,
|
|
898
|
+
);
|
|
899
|
+
const items = screen.getAllByRole("listitem");
|
|
900
|
+
expect(items).toHaveLength(2);
|
|
901
|
+
const button = within(items[0]!).getByRole("button", { name: "Profile" });
|
|
902
|
+
await user.tab();
|
|
903
|
+
expect(button).toHaveFocus();
|
|
904
|
+
await user.keyboard("{Enter}");
|
|
905
|
+
await user.keyboard(" ");
|
|
906
|
+
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
907
|
+
expect(within(items[1]!).queryByRole("button")).not.toBeInTheDocument();
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
describe("FileUpload", () => {
|
|
912
|
+
it("has exactly one keyboard target — the Browse button — not a nested control or the hidden input", async () => {
|
|
913
|
+
const user = userEvent.setup();
|
|
914
|
+
render(
|
|
915
|
+
<>
|
|
916
|
+
<FileUpload />
|
|
917
|
+
<button>after</button>
|
|
918
|
+
</>,
|
|
919
|
+
);
|
|
920
|
+
await user.tab();
|
|
921
|
+
expect(screen.getByRole("button", { name: "Browse files" })).toHaveFocus();
|
|
922
|
+
await user.tab();
|
|
923
|
+
expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
|
|
924
|
+
});
|
|
925
|
+
});
|
|
@@ -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-
|
|
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 /> };
|
|
@@ -19,6 +19,7 @@ const Demo = () => {
|
|
|
19
19
|
return (
|
|
20
20
|
<DataGrid
|
|
21
21
|
className="w-full"
|
|
22
|
+
selectable
|
|
22
23
|
height={280}
|
|
23
24
|
data={data}
|
|
24
25
|
getRowId={(row) => row.id}
|
|
@@ -45,7 +46,7 @@ const meta = {
|
|
|
45
46
|
title: "Data Display/DataGrid",
|
|
46
47
|
parameters: {
|
|
47
48
|
layout: "padded",
|
|
48
|
-
docs: { source: { code: "<DataGrid\\n height={280}\\n data={data}\\n getRowId={(row) => row.id}\\n columns={[\\n { id: \"id\", header: \"ID\", cell: (row) => row.id, value: (row) => row.id, sortable: true, pinned: \"left\" },\\n { id: \"name\", header: \"Name\", cell: (row) => row.name, value: (row) => row.name, editable: true, onCellEdit: updateName },\\n { id: \"qty\", header: \"Qty\", cell: (row) => row.qty, value: (row) => row.qty, sortable: true },\\n ]}\\n/>", language: "tsx" } },
|
|
49
|
+
docs: { source: { code: "<DataGrid\\n selectable\\n height={280}\\n data={data}\\n getRowId={(row) => row.id}\\n columns={[\\n { id: \"id\", header: \"ID\", cell: (row) => row.id, value: (row) => row.id, sortable: true, pinned: \"left\" },\\n { id: \"name\", header: \"Name\", cell: (row) => row.name, value: (row) => row.name, editable: true, onCellEdit: updateName },\\n { id: \"qty\", header: \"Qty\", cell: (row) => row.qty, value: (row) => row.qty, sortable: true },\\n ]}\\n/>", language: "tsx" } },
|
|
49
50
|
},
|
|
50
51
|
} satisfies Meta;
|
|
51
52
|
|
|
@@ -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 = {
|
|
@@ -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 /> };
|