A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// DESIGN.md B7: the nav rail's row. Icon-only, its name is the module's full name (with an unread
|
|
// count, which was only ever drawn); with the labels on, the name is the short label the user SEES
|
|
// (WCAG 2.5.3), the full one staying in the tooltip. "You are here" is aria-current plus the tint.
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { Activity, Bell, Shield, Users } from "lucide-react";
|
|
import { NavRailAction, NavRailGroup, NavRailItem } from "./NavRail";
|
|
|
|
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("NavRailItem", () => {
|
|
it("icon-only: the full name, the count in it, and the current page marked", () => {
|
|
render(
|
|
<NavRailItem
|
|
icon={Bell}
|
|
label="Notifications"
|
|
shortLabel="Alerts"
|
|
showLabel={false}
|
|
active
|
|
badge={3}
|
|
href="/?page=notifications"
|
|
onClick={() => {}}
|
|
/>,
|
|
);
|
|
const row = screen.getByRole("link", { name: "Notifications (3)" });
|
|
expect(row.getAttribute("aria-current")).toBe("page");
|
|
expect(row.className).toContain("bg-accent/15");
|
|
expect(row.textContent).not.toContain("Alerts");
|
|
});
|
|
|
|
it("labelled: the name is the visible short label", () => {
|
|
render(
|
|
<NavRailItem
|
|
icon={Bell}
|
|
label="Notifications"
|
|
shortLabel="Alerts"
|
|
showLabel
|
|
href="/?page=notifications"
|
|
onClick={() => {}}
|
|
/>,
|
|
);
|
|
const row = screen.getByRole("link", { name: "Alerts" });
|
|
expect(row.getAttribute("aria-current")).toBeNull();
|
|
});
|
|
|
|
// Audit: labelled, the name was the row's text, the corner badge first, so 20 unread was heard
|
|
// as "9+Alerts" where the icon-only row says "Notifications (20)".
|
|
it("labelled with a count: the visible label, then the exact count", () => {
|
|
render(
|
|
<NavRailItem
|
|
icon={Bell}
|
|
label="Notifications"
|
|
shortLabel="Alerts"
|
|
showLabel
|
|
badge={20}
|
|
href="/?page=notifications"
|
|
onClick={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
|
|
});
|
|
|
|
// Owner's call: a middle-click on a rail row opens the page in a new tab. The row is a real link
|
|
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
|
|
it("is a link to its page, navigating in-app only on a plain left click", () => {
|
|
const onClick = vi.fn();
|
|
render(
|
|
<NavRailItem
|
|
icon={Bell}
|
|
label="Notifications"
|
|
shortLabel="Alerts"
|
|
showLabel={false}
|
|
href="/?page=notifications"
|
|
onClick={onClick}
|
|
/>,
|
|
);
|
|
const row = screen.getByRole("link", { name: "Notifications" });
|
|
expect(row.getAttribute("href")).toBe("/?page=notifications");
|
|
// Whether the row left the click to the browser, read after the app's own handler ran; then the
|
|
// click is stopped here, as jsdom cannot follow a link.
|
|
const leftToBrowser: boolean[] = [];
|
|
const record = (e: Event) => {
|
|
leftToBrowser.push(!e.defaultPrevented);
|
|
e.preventDefault();
|
|
};
|
|
window.addEventListener("click", record);
|
|
try {
|
|
fireEvent.click(row, { button: 0 });
|
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
for (const init of [
|
|
{ button: 1 },
|
|
{ button: 0, ctrlKey: true },
|
|
{ button: 0, metaKey: true },
|
|
])
|
|
fireEvent.click(row, init);
|
|
} finally {
|
|
window.removeEventListener("click", record);
|
|
}
|
|
expect(leftToBrowser).toEqual([false, true, true, true]);
|
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// F32: the rail's footer (language, About, Sign out) drew 32 and 36 squares with 16 and 20px glyphs
|
|
// beside the 36-tall full-width rows, so its spacing and glyphs broke the column. An action is a row too.
|
|
describe("NavRailAction", () => {
|
|
it("is a named rail row, not a toolbar icon button", () => {
|
|
render(<NavRailAction icon={Bell} label="About" onClick={() => {}} />);
|
|
const row = screen.getByRole("button", { name: "About" });
|
|
expect(row.className).toContain("h-9");
|
|
expect(row.className).toContain("w-full");
|
|
expect(row.querySelector("svg")?.getAttribute("class")).toContain("size-4.5");
|
|
});
|
|
|
|
it("the nav rail holds no toolbar icon button", () => {
|
|
const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8");
|
|
expect(src).not.toMatch(/<IconButton[\s>]/);
|
|
});
|
|
});
|
|
|
|
// The owner's pick: the five admin rows sat below the fold at 133% zoom behind a fade alone, so
|
|
// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are.
|
|
describe("NavRailGroup", () => {
|
|
const pages = (active: string | null, onSelect = vi.fn()) =>
|
|
[
|
|
{ id: "scheduler", icon: Activity, label: "Scheduler" },
|
|
{ id: "users", icon: Users, label: "Users" },
|
|
].map((p) => ({ ...p, href: `/?page=${p.id}`, active: p.id === active, onSelect }));
|
|
|
|
it("is one row, current while any of its pages is, opening a panel of links", () => {
|
|
render(
|
|
<NavRailGroup
|
|
icon={Shield}
|
|
label="Administration"
|
|
shortLabel="Admin"
|
|
showLabel={false}
|
|
pages={pages("users")}
|
|
/>,
|
|
);
|
|
const row = screen.getByRole("button", { name: "Administration" });
|
|
expect(row.getAttribute("aria-current")).toBe("page");
|
|
expect(screen.queryByRole("link")).toBeNull();
|
|
fireEvent.click(row);
|
|
// jsdom never positions the panel, so it stays visibility:hidden and nameless to the role query.
|
|
const panel = screen.getByRole("dialog", { hidden: true });
|
|
expect(panel.getAttribute("aria-label")).toBe("Administration");
|
|
const links = Array.from(panel.querySelectorAll("a"));
|
|
expect(links.map((a) => [a.textContent, a.getAttribute("href")])).toEqual([
|
|
["Scheduler", "/?page=scheduler"],
|
|
["Users", "/?page=users"],
|
|
]);
|
|
expect(links.map((a) => a.getAttribute("aria-current"))).toEqual([null, "page"]);
|
|
});
|
|
|
|
it("is not current while none of its pages is", () => {
|
|
render(
|
|
<NavRailGroup
|
|
icon={Shield}
|
|
label="Administration"
|
|
shortLabel="Admin"
|
|
showLabel
|
|
pages={pages(null)}
|
|
/>,
|
|
);
|
|
const row = screen.getByRole("button", { name: "Admin" });
|
|
expect(row.getAttribute("aria-current")).toBeNull();
|
|
expect(row.className).not.toContain("bg-accent/15");
|
|
});
|
|
|
|
it("a plain click goes to the page and closes the panel; a middle or Ctrl click is the browser's", () => {
|
|
const onSelect = vi.fn();
|
|
render(
|
|
<NavRailGroup
|
|
icon={Shield}
|
|
label="Administration"
|
|
shortLabel="Admin"
|
|
showLabel={false}
|
|
pages={pages(null, onSelect)}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Administration" }));
|
|
const link = () =>
|
|
screen.getByRole("dialog", { hidden: true }).querySelector<HTMLAnchorElement>("a")!;
|
|
const leftToBrowser: boolean[] = [];
|
|
const record = (e: Event) => {
|
|
leftToBrowser.push(!e.defaultPrevented);
|
|
e.preventDefault();
|
|
};
|
|
window.addEventListener("click", record);
|
|
try {
|
|
fireEvent.click(link(), { button: 1 });
|
|
fireEvent.click(link(), { button: 0, ctrlKey: true });
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
fireEvent.click(link(), { button: 0 });
|
|
} finally {
|
|
window.removeEventListener("click", record);
|
|
}
|
|
expect(leftToBrowser).toEqual([true, true, false]);
|
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
|
|
});
|
|
});
|