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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user