feat(ui): build the Nav component with submenus and roving focus
Replaces a scaffold that rendered bare anchors. Flat or nested to three levels, icons, badges, disabled items, aria-current on the active link, arrow-key roving focus, and a disclosure arrow that rotates on open. Three levels rather than arbitrary depth because this template language has no component recursion, so each level is written out. On a phone the bar becomes a toggle and submenus stack inline rather than floating: a hover-opened overlay cannot be reached on touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2540,3 +2540,77 @@ test("clickable stepper opts into roving focus", async () => {
|
||||
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
|
||||
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBe(2);
|
||||
});
|
||||
|
||||
test("nav renders links, marks the active one, and shows icons and badges", async () => {
|
||||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{ label: "Home", href: "/", value: "home", icon: "icon-[lucide--house]" },
|
||||
{ label: "Inbox", href: "/inbox", value: "inbox", badge: "9" },
|
||||
{ label: "Archive", href: "/archive", value: "archive", disabled: true },
|
||||
],
|
||||
active: "inbox",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
|
||||
expect(dom.querySelector(".wire-nav")!.getAttribute("role")).toBe("navigation");
|
||||
const current = dom.querySelector('[aria-current="page"]');
|
||||
expect(current!.textContent).toContain("Inbox");
|
||||
// Every item renders a badge span; the ones without a badge are empty and
|
||||
// carry data-show="false", so match on content rather than first-in-document.
|
||||
const badges = [...dom.querySelectorAll(".wire-nav__badge")]
|
||||
.map((node) => node.textContent!.trim())
|
||||
.filter(Boolean);
|
||||
expect(badges).toContain("9");
|
||||
expect(dom.querySelector(".wire-nav__icon")).not.toBeNull();
|
||||
expect(dom.querySelector('[aria-disabled="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("nav renders a submenu with a disclosure arrow and opts into roving focus", async () => {
|
||||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||||
const html = await renderComponent(source, {
|
||||
items: [
|
||||
{
|
||||
label: "Products",
|
||||
value: "products",
|
||||
items: [
|
||||
{ label: "Overview", href: "/p", value: "p-overview" },
|
||||
{
|
||||
label: "More",
|
||||
value: "p-more",
|
||||
items: [{ label: "Deep", href: "/d", value: "deep" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
active: "p-overview",
|
||||
});
|
||||
const dom = mountHtml(html);
|
||||
|
||||
expect(dom.querySelector('[data-wrn-roving="horizontal"]')).not.toBeNull();
|
||||
expect(dom.querySelectorAll("[data-wrn-roving-item]").length).toBeGreaterThan(0);
|
||||
expect(dom.querySelector(".wire-nav__arrow")).not.toBeNull();
|
||||
expect(dom.querySelector(".wire-nav__submenu")).not.toBeNull();
|
||||
expect(dom.querySelector(".wire-nav__submenu--level3")).not.toBeNull();
|
||||
// Every second-level item renders a third-level list; only the one whose
|
||||
// item actually has children is populated, so search across them all.
|
||||
const thirdLevel = [...dom.querySelectorAll(".wire-nav__submenu--level3")]
|
||||
.map((node) => node.textContent!.trim())
|
||||
.filter(Boolean);
|
||||
expect(thirdLevel.join(" ")).toContain("Deep");
|
||||
});
|
||||
|
||||
test("nav renders its shell with no items and rejects a non-array", async () => {
|
||||
const source = readFileSync(uiComponentPath("Nav"), "utf8");
|
||||
const html = await renderComponent(source, { items: [], active: "" });
|
||||
const dom = mountHtml(html);
|
||||
expect(dom.querySelector(".wire-nav")).not.toBeNull();
|
||||
expect(dom.querySelectorAll(".wire-nav__link").length).toBe(0);
|
||||
|
||||
// A declared unknown[] prop is coerced by the framework, which rejects a
|
||||
// non-array before the component runs. The itemList() guard is the second
|
||||
// line of defence for a missing prop, not a way to render junk.
|
||||
await expect(renderComponent(source, { items: "not-an-array", active: "" })).rejects.toThrow(
|
||||
"Expected an array prop",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user