Files
siftlode/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00

166 lines
5.9 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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<Row>[] = [
{
key: "name",
header: "Name",
cardPrimary: true,
render: (r) => (
<button data-nav-open="" onClick={() => onOpen(r.id)}>
{r.id}
</button>
),
},
{ key: "act", header: "", render: (r) => <button>act {r.id}</button> },
];
return render(
<DataTable rows={rows} columns={columns} rowKey={(r) => 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<Row>[] = [
{ 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) => (
<DataTable rows={list} columns={cols} rowKey={(r) => 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");
});
});