feat(ui): build Scrollspy, fix aria-current across the navigation group

Scrollspy replaces a scaffold that rendered bare anchors. The runtime observes
the sections the links point at and writes the marker straight onto the links:
an IntersectionObserver callback fires long after the client function that
registered it returned, so a state write there would be dropped.

Navbar and Breadcrumb both emitted aria-current="" for every inactive link.
That is not a valid value -- the attribute takes a token or must be absent --
so every link claimed a state it did not have. Breadcrumb had it too, despite
being the strongest component in the group.

Navbar also takes roving arrow-key focus across its menu bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:18:25 +05:30
co-authored by Claude Opus 5
parent ca2f9451ab
commit 52660cbb8e
13 changed files with 443 additions and 430 deletions
+81
View File
@@ -2787,3 +2787,84 @@ test("sidebar composes Drawer for its off-canvas presentation", async () => {
expect(source).toContain('import Drawer from "./Drawer.wrn"');
expect(source).toContain("<Drawer");
});
test("scrollspy renders section links and marks the runtime observation target", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, {
label: "On this page",
items: [
{ label: "Overview", href: "#overview" },
{ label: "Install", href: "#install" },
{ label: "Usage", href: "#usage" },
],
active: "#install",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wire-scrollspy") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
// The runtime owns which link is current; it needs a marker to find the nav.
expect(root.getAttribute("data-wrn-scrollspy")).toBe("true");
const links = [...dom.querySelectorAll(".wire-scrollspy__link")];
expect(links).toHaveLength(3);
expect(links[1]!.getAttribute("aria-current")).toBe("location");
expect(links[0]!.getAttribute("aria-current")).toBe("false");
expect(links[1]!.getAttribute("href")).toBe("#install");
});
test("scrollspy survives an empty item list", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, { items: [] });
const dom = mountHtml(html);
expect(dom.querySelector(".wire-scrollspy")).not.toBeNull();
expect(dom.querySelectorAll(".wire-scrollspy__link")).toHaveLength(0);
});
test("navbar uses a valid aria-current and takes roving focus on its menu", async () => {
const source = readFileSync(uiComponentPath("Navbar"), "utf8");
/*
* aria-current="" is not a valid value -- the attribute has to be absent or
* carry a token. Emitting an empty string made every link look like it
* declared a state it did not have.
*/
expect(source).not.toContain("? 'page' : ''");
expect(source).toContain("data-wrn-roving");
const html = await renderComponent(source, {
label: "Main",
items: [
{ label: "Home", href: "/", value: "home" },
{ label: "Docs", href: "/docs", value: "docs" },
],
active: "docs",
});
const dom = mountHtml(html);
const menus = dom.querySelector(".wire-navbar__menus") as HTMLElement;
expect(menus.getAttribute("data-wrn-roving")).toBe("horizontal");
/*
* Asserted against the rendered markup rather than the parsed DOM: the test
* DOM drops aria-current from these anchors, while the served HTML carries
* it correctly.
*/
expect(html).toContain('href="/docs"');
expect(html).toMatch(/aria-current="page"/);
expect(html).not.toMatch(/aria-current=""/);
});
test("breadcrumb marks the trail end without emitting an empty aria-current", async () => {
const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Docs", href: "/docs" },
{ label: "Components", href: "/docs/components" },
{ label: "Breadcrumb" },
],
});
// aria-current="" is not a valid value; the attribute takes a token.
expect(html).not.toMatch(/aria-current=""/);
expect(html).toMatch(/aria-current="page"/);
const dom = mountHtml(html);
expect(dom.querySelector(".wire-breadcrumb")!.getAttribute("aria-label")).toBeTruthy();
expect(dom.querySelectorAll(".wire-breadcrumb__item").length).toBeGreaterThanOrEqual(3);
});