import { createRawSnippet, flushSync, tick, type Snippet } from "svelte";
import { describe, expect, it } from "vitest";
import {
Navbar,
arrangements,
NAVBAR_ALIGNMENTS,
NAVBAR_COLLAPSE,
navbar as navbarAppearance,
type NavbarLink,
} from "@reddb-io/design-system/base";
import NavbarMasthead from "./fixtures/NavbarMasthead.svelte";
import { labelInName } from "./label-in-name";
import { classes, render, rendered } from "./mount";
function raw(html: string): Snippet {
return createRawSnippet(() => ({ render: () => html }));
}
const BRAND = raw('reddb.io');
const ACTIONS = raw('');
const LINKS: readonly NavbarLink[] = [
{ id: "nodes", label: "Nodes", href: "/nodes", active: true },
{ id: "queries", label: "Queries", href: "/queries" },
{ id: "docs", label: "Docs", href: "/docs", disabled: true },
];
function mountNavbar(props: Record = {}): HTMLElement {
return rendered(
render(Navbar, {
links: LINKS,
brand: BRAND,
actions: ACTIONS,
collapse: "expanded",
...props,
}),
);
}
const rail = (nav: HTMLElement): HTMLElement | null =>
nav.querySelector('[data-navbar-arrangement="rail"]');
const compact = (nav: HTMLElement): HTMLElement | null =>
nav.querySelector('[data-navbar-arrangement="compact"]');
const panel = (nav: HTMLElement): HTMLElement | null => nav.querySelector("[data-navbar-panel]");
const toggle = (nav: HTMLElement): HTMLButtonElement =>
nav.querySelector("[data-navbar-toggle]")!;
const region = (where: Element, name: string): HTMLElement | null =>
where.querySelector(`[data-navbar-region="${name}"]`);
const linksIn = (where: Element): HTMLElement[] => [
...where.querySelectorAll("[data-navbar-link]"),
];
const labelsIn = (where: Element): string[] =>
linksIn(where).map((link) => link.textContent?.trim() ?? "");
const folded = (element: HTMLElement): boolean => element.hidden && classes(element).has("hidden");
async function settle(): Promise {
const deadline = Date.now() + 3_000;
let previous: Element | null = null;
let still = 0;
while (still < 6 && Date.now() < deadline) {
await tick();
await new Promise((resolve) => setTimeout(resolve, 5));
flushSync();
const active = document.activeElement;
still = active === previous ? still + 1 : 0;
previous = active;
}
}
function click(element: HTMLElement): void {
element.click();
flushSync();
}
function press(element: Element, key: string): void {
element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
flushSync();
}
async function type(key: string): Promise {
press(document.activeElement ?? document.body, key);
await settle();
}
const menu = (): HTMLElement | null => document.querySelector('[role="menu"]');
const focused = (): string =>
document.activeElement?.firstElementChild?.textContent?.trim() ??
document.activeElement?.textContent?.trim() ??
"";
describe("the Base Navbar composes a masthead", () => {
it("orders the brand, links, and actions regions", () => {
const bar = rail(mountNavbar())!;
expect(region(bar, "brand")!.querySelector("[data-brand]")).not.toBeNull();
expect(region(bar, "actions")!.querySelector("[data-action]")).not.toBeNull();
expect([...bar.children].map((child) => child.getAttribute("data-navbar-region"))).toEqual([
"brand",
"links",
"actions",
]);
});
it("renders links in order with native current-page semantics", () => {
const entries = linksIn(rail(mountNavbar())!);
expect(entries.map((entry) => entry.textContent?.trim())).toEqual(["Nodes", "Queries", "Docs"]);
expect(entries[0]!.tagName).toBe("A");
expect(entries[0]!.getAttribute("href")).toBe("/nodes");
expect(entries[0]!.getAttribute("aria-current")).toBe("page");
expect(entries[1]!.hasAttribute("aria-current")).toBe(false);
});
it("preserves a caller's explicit aria-current token", () => {
const nav = mountNavbar({
links: [{ id: "checkout", label: "Checkout", href: "/checkout", current: "step" }],
});
expect(linksIn(rail(nav)!)[0]!.getAttribute("aria-current")).toBe("step");
});
it("renders an entry without a destination as a Button action", () => {
const chosen: string[] = [];
const nav = mountNavbar({
links: [{ id: "search", label: "Search", onselect: () => chosen.push("search") }],
});
const action = linksIn(rail(nav)!)[0]!;
expect(action.tagName).toBe("BUTTON");
click(action);
expect(chosen).toEqual(["search"]);
});
it("keeps a disabled destination visible but unreachable", () => {
const docs = linksIn(rail(mountNavbar())!).find((link) => link.dataset.navbarLink === "docs")!;
expect(docs.getAttribute("aria-disabled")).toBe("true");
expect(docs.hasAttribute("href")).toBe(false);
});
it("omits an empty link list while leaving empty content regions", () => {
const bar = rail(mountNavbar({ links: [], brand: undefined, actions: undefined }))!;
expect(linksIn(bar)).toEqual([]);
expect(bar.querySelector("ul")).toBeNull();
expect(region(bar, "brand")!.textContent?.trim()).toBe("");
expect(region(bar, "actions")!.textContent?.trim()).toBe("");
});
it("names the navigation landmark", () => {
expect(mountNavbar().tagName).toBe("NAV");
expect(mountNavbar().getAttribute("aria-label")).toBe("Main");
expect(mountNavbar({ label: "Product" }).getAttribute("aria-label")).toBe("Product");
});
});
describe("the Base Navbar alignments", () => {
it("offers exactly start and center", () => {
expect([...NAVBAR_ALIGNMENTS].sort()).toEqual(["center", "start"]);
for (const align of NAVBAR_ALIGNMENTS) expect(mountNavbar({ align }).dataset.navbarAlign).toBe(align);
});
it("lets start-aligned links grow and pushes actions to the end", () => {
const bar = rail(mountNavbar({ align: "start" }))!;
expect(classes(region(bar, "links")!).has("grow")).toBe(true);
expect(classes(region(bar, "brand")!).has("shrink-0")).toBe(true);
expect(classes(region(bar, "actions")!).has("ms-auto")).toBe(true);
});
it("gives centered links equal growing regions on both sides", () => {
const bar = rail(mountNavbar({ align: "center" }))!;
for (const name of ["brand", "actions"]) {
expect(classes(region(bar, name)!).has("grow")).toBe(true);
expect(classes(region(bar, name)!).has("basis-0")).toBe(true);
}
expect(classes(region(bar, "links")!).has("shrink-0")).toBe(true);
expect(classes(region(bar, "links")!).has("justify-center")).toBe(true);
});
});
describe("the Base Navbar collapse arrangements", () => {
it("renders both arrangements responsively and one in pinned modes", () => {
expect(arrangements("responsive")).toEqual({ rail: true, compact: true });
expect(arrangements("expanded")).toEqual({ rail: true, compact: false });
expect(arrangements("collapsed")).toEqual({ rail: false, compact: true });
});
it("defines no collapse mode that renders nothing", () => {
expect([...NAVBAR_COLLAPSE].sort()).toEqual(["collapsed", "expanded", "responsive"]);
for (const collapse of NAVBAR_COLLAPSE) {
const shown = arrangements(collapse);
expect(shown.rail || shown.compact).toBe(true);
}
});
it("puts exactly the selected arrangements in the document", () => {
for (const collapse of NAVBAR_COLLAPSE) {
const nav = mountNavbar({ collapse });
const shown = arrangements(collapse);
expect(rail(nav) !== null, `${collapse} rail`).toBe(shown.rail);
expect(compact(nav) !== null, `${collapse} compact`).toBe(shown.compact);
expect(panel(nav) !== null, `${collapse} panel`).toBe(shown.compact);
}
});
it("leaves the responsive choice to CSS on its own container width, with equivalent links", () => {
// Container-first (ADR 0021): the Navbar collapses on its own width, from
// the DS's `3xl` container step, never on the window's.
const nav = mountNavbar({ collapse: "responsive" });
expect(classes(nav).has("@container/navbar")).toBe(true);
expect(classes(rail(nav)!).has("hidden")).toBe(true);
expect(classes(rail(nav)!).has("@3xl/navbar:flex")).toBe(true);
expect(classes(compact(nav)!).has("flex")).toBe(true);
expect(classes(compact(nav)!).has("@3xl/navbar:hidden")).toBe(true);
expect(classes(panel(nav)!).has("@3xl/navbar:hidden")).toBe(true);
for (const element of [nav, rail(nav)!, compact(nav)!, panel(nav)!]) {
expect([...classes(element)].some((name) => /^(?:max-)?(?:sm|md|lg|xl|2xl):/.test(name))).toBe(false);
}
expect(labelsIn(panel(nav)!)).toEqual(labelsIn(rail(nav)!));
});
});
describe("the Base Navbar collapsed disclosure", () => {
const mountCollapsed = (props: Record = {}): HTMLElement =>
mountNavbar({ collapse: "collapsed", ...props });
it("keeps the brand in the bar and folds links into the panel", () => {
const nav = mountCollapsed();
expect(region(compact(nav)!, "brand")!.querySelector("[data-brand]")).not.toBeNull();
expect(linksIn(compact(nav)!)).toEqual([]);
expect(labelsIn(panel(nav)!)).toEqual(["Nodes", "Queries", "Docs"]);
});
it("uses a real Button whose aria-controls target exists", () => {
const nav = mountCollapsed();
const button = toggle(nav);
expect(button.tagName).toBe("BUTTON");
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(button.getAttribute("aria-label")).toBe("Menu");
const controls = button.getAttribute("aria-controls");
expect(controls).toBeTruthy();
expect(document.getElementById(controls!)).toBe(panel(nav));
});
it("keeps the closed panel out of the accessibility tree", () => {
expect(folded(panel(mountCollapsed())!)).toBe(true);
});
it("opens and closes from the disclosure Button", () => {
const nav = mountCollapsed();
const button = toggle(nav);
click(button);
expect(button.getAttribute("aria-expanded")).toBe("true");
expect(folded(panel(nav)!)).toBe(false);
click(button);
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(folded(panel(nav)!)).toBe(true);
});
it("carries the actions region into the open panel", () => {
const nav = mountCollapsed();
click(toggle(nav));
expect(region(panel(nav)!, "actions")!.querySelector("[data-action]")).not.toBeNull();
});
it("closes on Escape and restores disclosure focus", async () => {
const nav = mountCollapsed();
const button = toggle(nav);
button.focus();
click(button);
press(button, "Escape");
await settle();
expect(folded(panel(nav)!)).toBe(true);
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(button);
});
it("closes after a panel link is chosen", () => {
const chosen: string[] = [];
const nav = mountCollapsed({
links: [{ id: "nodes", label: "Nodes", href: "#nodes", onselect: () => chosen.push("nodes") }],
});
click(toggle(nav));
click(linksIn(panel(nav)!)[0]!);
expect(chosen).toEqual(["nodes"]);
expect(folded(panel(nav)!)).toBe(true);
});
it("does not steal Escape while already closed", async () => {
const nav = mountCollapsed();
const button = toggle(nav);
button.focus();
press(button, "Escape");
await settle();
expect(document.activeElement).toBe(button);
expect(folded(panel(nav)!)).toBe(true);
});
});
describe("menus composed into Base Navbar actions", () => {
it("renders real menu Buttons including a custom avatar trigger", () => {
const nav = rendered(render(NavbarMasthead, { collapse: "expanded" }));
const triggers = region(rail(nav)!, "actions")!.querySelectorAll("[data-dropdown-menu-trigger]");
expect(triggers).toHaveLength(2);
for (const trigger of triggers) {
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
}
expect(nav.querySelector("[data-avatar]")).not.toBeNull();
// The avatar's initials lead its name, so speech input can say what it shows (WCAG 2.5.3).
expect(triggers[1]!.getAttribute("aria-label")).toBe("RD account");
for (const trigger of triggers) expect(labelInName(trigger)).toBe(true);
});
it("opens, walks, and closes an expanded masthead menu from the keyboard", async () => {
const nav = rendered(render(NavbarMasthead, { collapse: "expanded" }));
const trigger = region(rail(nav)!, "actions")!.querySelector("[data-dropdown-menu-trigger]")!;
trigger.focus();
press(trigger, "ArrowDown");
await settle();
expect(menu()).not.toBeNull();
expect(focused()).toBe("Invite accepted");
await type("ArrowDown");
expect(focused()).toBe("Backup finished");
await type("Escape");
expect(menu()).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("lets a portaled menu close before its collapsed parent panel", async () => {
const nav = rendered(render(NavbarMasthead, { collapse: "collapsed" }));
const button = toggle(nav);
click(button);
const trigger = panel(nav)!.querySelector("[data-dropdown-menu-trigger]")!;
trigger.focus();
press(trigger, "ArrowDown");
await settle();
expect(menu()).not.toBeNull();
await type("Escape");
expect(menu()).toBeNull();
expect(folded(panel(nav)!)).toBe(false);
await type("Escape");
expect(folded(panel(nav)!)).toBe(true);
expect(document.activeElement).toBe(button);
});
it("chooses a menu action in the panel without collapsing navigation", async () => {
const chosen: string[] = [];
const nav = rendered(
render(NavbarMasthead, {
collapse: "collapsed",
account: [
{ id: "profile", label: "Profile", onselect: () => chosen.push("profile") },
{ id: "sign-out", label: "Sign out", onselect: () => chosen.push("sign-out") },
],
}),
);
click(toggle(nav));
const triggers = panel(nav)!.querySelectorAll("[data-dropdown-menu-trigger]");
const account = triggers[triggers.length - 1]!;
account.focus();
press(account, "Enter");
await settle();
await type("ArrowDown");
expect(focused()).toBe("Sign out");
await type("Enter");
expect(chosen).toEqual(["sign-out"]);
expect(menu()).toBeNull();
expect(folded(panel(nav)!)).toBe(false);
});
});
describe("the Base Navbar appearance", () => {
it("wears the Theme masthead surface with a quiet boundary", () => {
const worn = classes(mountNavbar());
expect(worn.has("bg-navigation-surface")).toBe(true);
expect(worn.has("border-muted")).toBe(true);
expect(worn.has("font-sans")).toBe(true);
});
it("uses Density axis insets and gaps", () => {
const worn = classes(rail(mountNavbar())!);
expect(worn.has("min-h-[var(--reddb-spatial-control-height-lg)]")).toBe(true);
expect(worn.has("px-[var(--reddb-spatial-inset-md)]")).toBe(true);
expect(worn.has("gap-[var(--reddb-spatial-gap-lg)]")).toBe(true);
});
it("uses a canonical Button for the collapsed toggle", () => {
const worn = classes(toggle(mountNavbar({ collapse: "collapsed" })));
expect(worn.has("inline-flex")).toBe(true);
expect(worn.has("min-h-[var(--reddb-spatial-control-height-md)]")).toBe(true);
});
it("merges caller classes over its canonical appearance", () => {
const nav = mountNavbar({ class: "sticky" });
expect(classes(nav)).toEqual(new Set(navbarAppearance().root({ class: "sticky" }).split(/\s+/)));
});
});
describe("the Navbar current link", () => {
it("wears a neutral surface and a 2px Brand underline on the rail, a start bar in the panel", () => {
const nav = mountNavbar({ collapse: "responsive" });
const railCurrent = rail(nav)!.querySelector('[data-navbar-link="nodes"]')!;
const railOther = rail(nav)!.querySelector('[data-navbar-link="queries"]')!;
const panelCurrent = nav
.querySelector("[data-navbar-panel]")!
.querySelector('[data-navbar-link="nodes"]')!;
expect(railCurrent.getAttribute("aria-current")).toBe("page");
for (const [element, edge] of [
[railCurrent, "border-b-2"],
[railOther, "border-b-2"],
[panelCurrent, "border-s-2"],
] as const) {
const names = classes(element);
expect(names.has(edge)).toBe(true);
expect(names.has("border-transparent")).toBe(true);
expect(names.has("aria-[current]:border-foreground")).toBe(true);
expect([...names].some((name) => /border(?:-[bstexy])?-primary/.test(name))).toBe(false);
expect(names.has("aria-[current]:bg-foreground/10")).toBe(true);
expect(names.has("aria-[current]:font-medium")).toBe(true);
expect(names.has("aria-[current]:text-foreground")).toBe(true);
expect([...names].some((name) => /(?:^|:)bg-primary(?:\/|$)|text-primary/.test(name))).toBe(false);
}
});
});