mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-20 19:03:02 +00:00
* Speed up the Nodes table: mount only on-screen rows and sort with a shared collator * Fit the Nodes table beside the detail panel and tidy virtualization Name takes a 22% share and the table's floor drops to 52rem, so 1280 and 1366 px laptops no longer scroll sideways with a node open. Long names get a title. Card dividers follow list position so heights stay put, cards and rows cache sizes apart, getItemKey stays stable, and the table reports its full row count to screen readers. Tests share one layout stub instead of a global offsetHeight shim.
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
|
|
import { render, fireEvent, screen } from "@testing-library/react";
|
|
import { DataTable, type Column } from "../../src/components/DataTable";
|
|
import { stubVirtualLayout } from "../virtual-layout";
|
|
|
|
it("keeps a stable column's default sort and focused button when its label changes", () => {
|
|
const data = [{ id: "a" }, { id: "b" }];
|
|
const translatedColumns = (header: string): Column<Row>[] => [{ id: "identity", header, cell: (r) => r.id, sortValue: (r) => r.id }];
|
|
const view = (header: string) => <DataTable columns={translatedColumns(header)} rows={data} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ id: "identity", direction: "desc" }} />;
|
|
const { container, rerender } = render(view("ID"));
|
|
const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent);
|
|
expect(order()).toEqual(["b", "a"]);
|
|
const button = screen.getByRole("button", { name: /^ID\s*▼$/ });
|
|
button.focus();
|
|
rerender(view("Identifiant"));
|
|
expect(order()).toEqual(["b", "a"]);
|
|
expect(screen.getByRole("button", { name: /^Identifiant\s*▼$/ })).toBe(button);
|
|
expect(button).toHaveFocus();
|
|
fireEvent.click(button);
|
|
expect(order()).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("retains header-based default and user sorting for callers without column IDs", () => {
|
|
const { container } = render(<DataTable columns={[{ header: "ID", cell: (r: Row) => r.id, sortValue: (r) => r.id }]} rows={rows} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ header: "ID", direction: "desc" }} />);
|
|
const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent);
|
|
expect(order()).toEqual(["b", "a"]);
|
|
fireEvent.click(screen.getByRole("button", { name: /^ID\s*▼$/ }));
|
|
expect(order()).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("sorts text with numeric runs in number order and empties last", () => {
|
|
const data = [{ id: "Node 10" }, { id: "" }, { id: "node 9" }, { id: "Node 1" }];
|
|
const { container } = render(<DataTable columns={[{ id: "name", header: "Name", cell: (r: Row) => r.id, sortValue: (r) => r.id }]} rows={data} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ id: "name" }} />);
|
|
const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent);
|
|
expect(order()).toEqual(["Node 1", "node 9", "Node 10", ""]);
|
|
fireEvent.click(screen.getByRole("button", { name: /^Name/ }));
|
|
expect(order()).toEqual(["Node 10", "node 9", "Node 1", ""]);
|
|
});
|
|
|
|
interface Row {
|
|
id: string;
|
|
}
|
|
const rows: Row[] = [{ id: "a" }, { id: "b" }];
|
|
const columns: Column<Row>[] = [{ header: "ID", cell: (r) => r.id }];
|
|
|
|
// Force the mobile media query to match so DataTable enters card mode.
|
|
function setMobile(matches: boolean) {
|
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})) as unknown as typeof window.matchMedia;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// jsdom does no layout, so the scroll metrics are stubbed onto the scroll container directly.
|
|
function setScroll(el: HTMLElement, { scrollTop, clientHeight, scrollHeight }: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
|
|
Object.defineProperty(el, "scrollTop", { value: scrollTop, configurable: true });
|
|
Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
|
|
Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
|
|
}
|
|
|
|
function renderTable(onEndReached?: () => void) {
|
|
const { container } = render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={() => {}}
|
|
emptyLabel="none"
|
|
onEndReached={onEndReached}
|
|
/>,
|
|
);
|
|
return container.querySelector(".overflow-y-auto") as HTMLElement;
|
|
}
|
|
|
|
describe("DataTable onEndReached", () => {
|
|
it("fires onEndReached when scrolled near the bottom", () => {
|
|
const onEndReached = vi.fn();
|
|
const scroller = renderTable(onEndReached);
|
|
setScroll(scroller, { scrollTop: 400, clientHeight: 500, scrollHeight: 1000 }); // 100px from bottom
|
|
fireEvent.scroll(scroller);
|
|
expect(onEndReached).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fire onEndReached when far from the bottom", () => {
|
|
const onEndReached = vi.fn();
|
|
const scroller = renderTable(onEndReached);
|
|
setScroll(scroller, { scrollTop: 0, clientHeight: 500, scrollHeight: 1000 }); // 500px from bottom
|
|
fireEvent.scroll(scroller);
|
|
expect(onEndReached).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("DataTable virtualization", () => {
|
|
const VIEWPORT_H = 500;
|
|
const ROW_H = 38;
|
|
const many = Array.from({ length: 1000 }, (_, i) => ({ id: `n${String(i).padStart(4, "0")}` }));
|
|
|
|
beforeEach(() => {
|
|
stubVirtualLayout(VIEWPORT_H, ROW_H);
|
|
});
|
|
|
|
function renderMany(mobile: boolean, virtualize = true) {
|
|
setMobile(mobile);
|
|
return render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={many}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={() => {}}
|
|
emptyLabel="none"
|
|
renderCard={(r) => <span>card-{r.id}</span>}
|
|
virtualize={virtualize}
|
|
/>,
|
|
).container;
|
|
}
|
|
|
|
it("renders every row unless asked to virtualize", () => {
|
|
expect(renderMany(false, false).querySelectorAll("tbody tr")).toHaveLength(many.length);
|
|
});
|
|
|
|
it("pins column widths so they don't follow the mounted rows", () => {
|
|
setMobile(false);
|
|
const pinned: Column<Row>[] = [{ header: "ID", cell: (r) => r.id, width: "16rem" }, { header: "Other", cell: () => "" }];
|
|
const { container } = render(<DataTable columns={pinned} rows={many} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" virtualize minWidth="40rem" />);
|
|
const table = container.querySelector("table")!;
|
|
expect(table).toHaveClass("table-fixed");
|
|
expect(table.style.minWidth).toBe("40rem");
|
|
expect([...table.querySelectorAll("colgroup col")].map((col) => (col as HTMLElement).style.width)).toEqual(["16rem", ""]);
|
|
});
|
|
|
|
it("renders only the rows near the viewport", () => {
|
|
const container = renderMany(false);
|
|
const rendered = container.querySelectorAll("tbody tr[data-index]");
|
|
expect(rendered.length).toBeGreaterThan(VIEWPORT_H / ROW_H);
|
|
expect(rendered.length).toBeLessThan(50);
|
|
expect(rendered[0].textContent).toBe("n0000");
|
|
});
|
|
|
|
it("keeps the scroll height of the whole list", () => {
|
|
const container = renderMany(false);
|
|
const rendered = container.querySelectorAll("tbody tr[data-index]").length;
|
|
const spacer = container.querySelector("tbody tr:last-child td") as HTMLElement;
|
|
expect(parseFloat(spacer.style.height)).toBe((many.length - rendered) * ROW_H);
|
|
});
|
|
|
|
it("renders only the cards near the viewport", () => {
|
|
const container = renderMany(true);
|
|
const rendered = container.querySelectorAll("button");
|
|
expect(rendered.length).toBeGreaterThan(VIEWPORT_H / ROW_H);
|
|
expect(rendered.length).toBeLessThan(50);
|
|
expect(rendered[0].textContent).toBe("card-n0000");
|
|
});
|
|
|
|
it("gives every card but the list's last a divider, wherever the mounted window ends", () => {
|
|
const cards = [...renderMany(true).querySelectorAll("button")];
|
|
expect(cards.every((card) => card.classList.contains("border-b"))).toBe(true);
|
|
const few = render(<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" renderCard={(r) => r.id} virtualize />).container;
|
|
expect([...few.querySelectorAll("button")].map((card) => card.classList.contains("border-b"))).toEqual([true, false]);
|
|
});
|
|
|
|
it("tells assistive tech the full row count, not just the mounted rows", () => {
|
|
const table = renderMany(false).querySelector("table")!;
|
|
expect(table).toHaveAttribute("aria-rowcount", String(many.length + 1));
|
|
expect(table.querySelector("thead tr")).toHaveAttribute("aria-rowindex", "1");
|
|
expect(table.querySelector("tbody tr[data-index='0']")).toHaveAttribute("aria-rowindex", "2");
|
|
});
|
|
|
|
it("leaves row counts to the DOM when every row is mounted", () => {
|
|
expect(renderMany(false, false).querySelector("table")).not.toHaveAttribute("aria-rowcount");
|
|
});
|
|
});
|
|
|
|
describe("DataTable card mode", () => {
|
|
it("renders cards instead of a table when mobile and renderCard is provided", () => {
|
|
setMobile(true);
|
|
const { container } = render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={() => {}}
|
|
emptyLabel="none"
|
|
renderCard={(r) => <span>card-{r.id}</span>}
|
|
/>,
|
|
);
|
|
expect(container.querySelector("table")).toBeNull();
|
|
expect(screen.getByText("card-a")).toBeInTheDocument();
|
|
expect(screen.getByText("card-b")).toBeInTheDocument();
|
|
});
|
|
|
|
it("still renders a table on desktop even with renderCard provided", () => {
|
|
setMobile(false);
|
|
const { container } = render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={() => {}}
|
|
emptyLabel="none"
|
|
renderCard={(r) => <span>card-{r.id}</span>}
|
|
/>,
|
|
);
|
|
expect(container.querySelector("table")).not.toBeNull();
|
|
expect(screen.queryByText("card-a")).toBeNull();
|
|
});
|
|
|
|
it("selects a card on click in card mode", () => {
|
|
setMobile(true);
|
|
const onSelect = vi.fn();
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={onSelect}
|
|
emptyLabel="none"
|
|
renderCard={(r) => <span>card-{r.id}</span>}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByText("card-a"));
|
|
expect(onSelect).toHaveBeenCalledWith("a");
|
|
});
|
|
|
|
it("still fires onEndReached in card mode", () => {
|
|
setMobile(true);
|
|
const onEndReached = vi.fn();
|
|
const { container } = render(
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(r) => r.id}
|
|
selectedKey={null}
|
|
onSelect={() => {}}
|
|
emptyLabel="none"
|
|
onEndReached={onEndReached}
|
|
renderCard={(r) => <span>card-{r.id}</span>}
|
|
/>,
|
|
);
|
|
const scroller = container.querySelector(".overflow-y-auto") as HTMLElement;
|
|
setScroll(scroller, { scrollTop: 400, clientHeight: 500, scrollHeight: 1000 });
|
|
fireEvent.scroll(scroller);
|
|
expect(onEndReached).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|