Files
siftlode/frontend/src/components/ui/Pager.dom.test.tsx
T
peter 955c23d321 feat(tables): a glyph pager with first/last, and tablet-width columns step aside
The pager is four named arrows round the page box (first, previous,
next, last). A table hides a tabletHidden column below 64rem of its own
width, as narrowHidden does below 42rem, through one shared helper that
also hides the column's <col>. The Channels table drops Prio, About and
Length there: at 1024 the channel name had 60 px.
2026-09-27 07:12:51 +02:00

174 lines
6.0 KiB
TypeScript

// @vitest-environment jsdom
import { useLayoutEffect } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
import Pager from "./Pager";
afterEach(cleanup);
// Rows per page is a short fixed set, so it is the house's segmented control (debt burn 2026-09),
// not a native <select>: one named group, the current size pressed, "All" as size 0.
describe("Pager · rows per page", () => {
it("offers each size and All as one named group, and reports the pick", () => {
const onSizeChange = vi.fn();
render(
<Pager
page={0}
size={25}
total={120}
pageSizeOptions={[10, 25, 50, 100]}
onPageChange={() => {}}
onSizeChange={onSizeChange}
sizeLabel="Rows per page"
/>,
);
const group = screen.getByRole("group", { name: "Rows per page" });
expect(group.querySelector('[aria-pressed="true"]')?.textContent).toBe("25");
fireEvent.click(screen.getByRole("button", { name: "50" }));
fireEvent.click(screen.getByRole("button", { name: "datatable.all" }));
expect(onSizeChange.mock.calls).toEqual([[50], [0]]);
});
});
// The page box DERIVES the current page (E27 S2): it holds a draft only while being edited, so the
// parent moving the page needs no re-sync effect. The layout-phase probe is what proves "derived"
// rather than "re-synced after paint" — a passive effect could not have run yet when it reads.
describe("Pager · the page box follows the page without an effect", () => {
function Probe({ page, painted }: { page: number; painted: string[] }) {
useLayoutEffect(() => {
painted.push(
(document.querySelector('input[type="number"]') as HTMLInputElement | null)?.value ?? "",
);
});
return (
<Pager
page={page}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={() => {}}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>
);
}
it("shows the new page in the first frame after the parent moves it", () => {
const painted: string[] = [];
const { rerender } = render(<Probe page={0} painted={painted} />);
expect(screen.getByLabelText("datatable.pager.pageLabel")).toHaveProperty("value", "1");
painted.length = 0;
rerender(<Probe page={4} painted={painted} />);
expect(painted[0]).toBe("5");
});
it("keeps what you type until you commit it, then follows the page again", () => {
const onPageChange = vi.fn();
render(
<Pager
page={0}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "7" } });
expect(box).toHaveProperty("value", "7");
fireEvent.keyDown(box, { key: "Enter" });
expect(onPageChange).toHaveBeenCalledWith(6);
// The parent in this test never moves, so dropping the draft must put page 1 back — proof the
// box stopped holding its own copy rather than merely being overwritten.
expect(box).toHaveProperty("value", "1");
});
it("discards an unparseable entry instead of committing it", () => {
const onPageChange = vi.fn();
render(
<Pager
page={2}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "abc" } });
fireEvent.blur(box);
expect(onPageChange).not.toHaveBeenCalled();
expect(box).toHaveProperty("value", "3");
});
});
// Glyphs only (the owner, 2026-09-27): First / Previous / Next / Last are four arrows that carry their
// names as the accessible name and the tooltip, and show no words at any width (the words once pushed
// a phone row 18px past a 375 screen). The size label stays one line rather than a word per line.
describe("Pager · four named arrows", () => {
function renderAt(page: number, onPageChange = vi.fn()) {
render(
<Pager
page={page}
size={10}
total={120}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
return onPageChange;
}
const NAMES = ["first", "prev", "next", "last"].map((k) => `datatable.pager.${k}`);
it("draws each as a glyph with its name as the tooltip, and no visible word", () => {
renderAt(5);
for (const name of NAMES) {
const btn = screen.getByRole("button", { name });
expect(btn.getAttribute("title")).toBe(name);
expect(btn.textContent).toBe("");
expect(btn.querySelector("svg")).not.toBeNull();
}
expect(screen.getByText("Rows per page").className).toContain("whitespace-nowrap");
});
it("jumps to the first, previous, next and last page", () => {
const onPageChange = renderAt(5);
for (const name of NAMES) fireEvent.click(screen.getByRole("button", { name }));
expect(onPageChange.mock.calls).toEqual([[0], [4], [6], [11]]);
});
it("turns off the pair that points past an edge", () => {
renderAt(0);
const off = (k: string) =>
(screen.getByRole("button", { name: `datatable.pager.${k}` }) as HTMLButtonElement)
.disabled ||
screen.getByRole("button", { name: `datatable.pager.${k}` }).getAttribute("aria-disabled") ===
"true";
expect([off("first"), off("prev"), off("next"), off("last")]).toEqual([
true,
true,
false,
false,
]);
cleanup();
renderAt(11);
expect([off("first"), off("prev"), off("next"), off("last")]).toEqual([
false,
false,
true,
true,
]);
});
});