// @vitest-environment jsdom // // DataTable's `keyboardNav` (E11 S4c): the rows are a keyboard list — `j`/`k` walk them and Enter // clicks the element a column marks `data-nav-open`. It used to render its rows twice (a table and, // below `md`, a card list, one hidden by CSS), so `j` past the last row could carry on into the hidden // copy. Now it mounts exactly one layout, chosen by the `md` media query. import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import DataTable, { type Column } from "./DataTable"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); type Row = { id: string }; const rows: Row[] = [{ id: "a" }, { id: "b" }]; function table(onOpen: (id: string) => void, keyboardNav = true) { const columns: Column[] = [ { key: "name", header: "Name", cardPrimary: true, render: (r) => ( ), }, { key: "act", header: "", render: (r) => }, ]; return render( r.id} keyboardNav={keyboardNav} />, ); } function press(key: string) { act(() => { (document.activeElement ?? document.body).dispatchEvent( new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), ); }); } /** The viewport `md` question, answered as asked (jsdom has no matchMedia of its own). */ function viewport(wide: boolean) { vi.stubGlobal("matchMedia", (query: string) => ({ matches: query === "(min-width: 48rem)" ? wide : false, media: query, addEventListener: () => {}, removeEventListener: () => {}, })); } /** The focused row: which copy (TR / DIV) and which row. */ const focused = () => { const el = document.activeElement as HTMLElement; return `${el.tagName} ${el.querySelector("[data-nav-open]")?.textContent ?? ""}`; }; describe("DataTable · keyboardNav", () => { it("walks the table's rows, stops at the last one, and opens a row on Enter", () => { const opened: string[] = []; viewport(true); table((id) => opened.push(id)); press("j"); expect(focused()).toBe("TR a"); press("j"); expect(focused()).toBe("TR b"); // The last row is the last stop — there is no second copy after it to walk into. press("j"); expect(focused()).toBe("TR b"); press("Enter"); expect(opened).toEqual(["b"]); }); it("walks the card list when the viewport is narrow", () => { viewport(false); table(() => {}); press("j"); expect(focused()).toBe("DIV a"); }); it("mounts one layout, never both", () => { viewport(true); const wide = table(() => {}); expect(wide.container.querySelectorAll("[data-nav-index]")).toHaveLength(2); expect(wide.container.querySelector("table")).not.toBeNull(); cleanup(); viewport(false); const narrow = table(() => {}); expect(narrow.container.querySelectorAll("[data-nav-index]")).toHaveLength(2); expect(narrow.container.querySelector("table")).toBeNull(); }); // `useListNav` reads its container when its `enabled` changes. With the flag left on across a // breakpoint, the layout mounted by a resize was never registered — `j` did nothing — while the // unmounted one stayed registered (deep review, debt burn). it("keeps the keys working on whichever layout a resize mounts", () => { let wide = true; const listeners = new Set<() => void>(); vi.stubGlobal("matchMedia", (query: string) => ({ get matches() { return query === "(min-width: 48rem)" ? wide : false; }, media: query, addEventListener: (_: string, l: () => void) => listeners.add(l), removeEventListener: (_: string, l: () => void) => listeners.delete(l), })); const resize = (next: boolean) => { wide = next; act(() => listeners.forEach((l) => l())); }; table(() => {}); resize(false); expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1); press("j"); expect(focused()).toBe("DIV a"); resize(true); expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1); (document.activeElement as HTMLElement).blur(); press("j"); expect(focused()).toBe("TR a"); }); it("leaves the rows out of the keyboard without keyboardNav", () => { const { container } = table(() => {}, false); expect(container.querySelector("[data-nav-index]")).toBeNull(); press("j"); expect(document.activeElement).toBe(document.body); }); }); describe("DataTable · reveal", () => { const many: Row[] = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}` })); const cols: Column[] = [ { key: "name", header: "Name", cardPrimary: true, render: (r) => r.id }, ]; const shown = () => [...document.querySelectorAll("tbody tr")].map((tr) => tr.textContent); it("turns to the page that now holds a moved row on each new seq", () => { viewport(true); const view = (list: Row[], reveal: { key: string; seq: number } | null) => ( r.id} reveal={reveal} /> ); const { rerender } = render(view(many, { key: "r22", seq: 0 })); // The mount's seq is not a request: the first page stays. expect(shown()[0]).toBe("r0"); rerender(view(many, { key: "r22", seq: 1 })); expect(shown()).toContain("r22"); expect(shown()[0]).toBe("r20"); // r22 bubbled up to the top: the next seq follows it back to page one. rerender(view([many[22]!, ...many.filter((r) => r.id !== "r22")], { key: "r22", seq: 2 })); expect(shown()[0]).toBe("r22"); }); });