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
+97 -42
View File
@@ -2908,16 +2908,12 @@ export const REACTIVE_RUNTIME = String.raw`
}
/*
* Modal dialog behaviour: focus, focus restore, a Tab trap and a scroll lock.
* Modal dialog focus, focus restore, Tab trap and scroll lock. Shared by
* Modal and Drawer, and here rather than in them because a client function
* cannot hold the previously focused element across a close.
*
* These live here rather than in Modal.wrn and Drawer.wrn because every one
* of them is the same code, and because a client function cannot hold the
* "element that had focus before we opened" across a close -- state written
* after an await or inside a callback is dropped.
*
* Fixing focus also repairs Escape. Both components bind @keydown on their
* own root, so the handler only ever runs when focus is inside the dialog;
* until something moved focus there, closeOnEscape did nothing at all.
* Fixing focus also repairs Escape: both bind @keydown on their own root,
* so until focus moved inside, closeOnEscape did nothing.
*/
var DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]';
var FOCUSABLE_SELECTOR =
@@ -2927,13 +2923,8 @@ export const REACTIVE_RUNTIME = String.raw`
var dialogRestoreFocus = null;
var dialogScrollLock = null;
/*
* Open/closed is expressed by the data-show marker these components already
* emit, not by measured size. Measuring looks more thorough but made the
* trap and the scroll lock impossible to cover: the test DOM reports every
* element as zero-sized, so a dialog never counted as open and none of this
* behaviour ran under test.
*/
// data-show, not measured size: the test DOM reports everything as
// zero-sized, which is what left the trap uncovered.
function isDialogVisible(dialog) {
if (!dialog || !dialog.isConnected) return false;
if (dialog.hasAttribute("hidden")) return false;
@@ -2946,7 +2937,7 @@ export const REACTIVE_RUNTIME = String.raw`
var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR);
for (var index = 0; index < candidates.length; index += 1) {
var candidate = candidates[index];
// Same rule as the roving items: markers, not measurement.
// Markers, not measurement.
if (candidate.hasAttribute("hidden")) continue;
if (candidate.closest('[data-show="false"]')) continue;
found.push(candidate);
@@ -2978,8 +2969,7 @@ export const REACTIVE_RUNTIME = String.raw`
}
openDialogs.push(dialog);
// Prefer a real control so keyboard users land somewhere useful, and fall
// back to the panel itself, which carries tabindex="-1" for exactly this.
// Prefer a real control; the panel carries tabindex="-1" as a fallback.
var targets = focusableWithin(dialog);
var target = targets.length ? targets[0] : dialog;
if (target && target.focus) target.focus();
@@ -3016,8 +3006,7 @@ export const REACTIVE_RUNTIME = String.raw`
var dialog = openDialogs[openDialogs.length - 1];
var targets = focusableWithin(dialog);
if (!targets.length) {
// Nothing to cycle through; keep focus on the panel rather than letting
// Tab walk out into the page sitting behind the backdrop.
// Nothing to cycle; keep focus on the panel rather than the page behind.
event.preventDefault();
if (dialog.focus) dialog.focus();
return;
@@ -3057,33 +3046,23 @@ export const REACTIVE_RUNTIME = String.raw`
}
/*
* Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars
* and steppers. A container marked data-wrn-roving owns its
* [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so
* Tab reaches the group once, and the arrow keys move within it.
*
* This lives here rather than in each component because it is the same code
* five times over, and because focus bookkeeping cannot be held in component
* state -- a client function writing after it returns has that write dropped.
* Roving arrow-key focus. A container marked data-wrn-roving owns its
* [data-wrn-roving-item] descendants: one carries tabindex="0" so Tab
* reaches the group once, and the arrows move within it. Here rather than
* in five components because focus bookkeeping cannot live in component
* state.
*/
var ROVING_SELECTOR = "[data-wrn-roving]";
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
/*
* Templates stringify these, so data-wrn-roving="" and
* data-wrn-roving-item="false" mean "not this time". A bare [attr] selector
* matches either, so the value must be checked.
*/
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
// Templates stringify these: "" and "false" mean opted out, and a bare
// [attr] selector matches either, so the value must be checked.
// Bare means yes; only an explicit "false" opts out.
function rovingItemOff(value) {
return value === null || value === "false";
}
/*
* The container is stricter: it must name an axis. An empty value is what a
* template emits for {cond ? "horizontal" : ""}, so empty means off rather
* than defaulting to horizontal.
*/
// The container must name an axis; empty is what {cond ? "x" : ""} emits.
function rovingOrientation(container) {
var value = container.getAttribute("data-wrn-roving");
if (value === null || value === "" || value === "false") return "";
@@ -3100,8 +3079,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
if (candidate.hasAttribute("disabled")) continue;
if (candidate.getAttribute("aria-disabled") === "true") continue;
// Markers, not measurement: the test DOM reports every element as
// zero-sized, which is what left the dialog trap uncovered.
// Markers, not measurement (see isDialogVisible).
if (candidate.hasAttribute("hidden")) continue;
if (candidate.closest('[data-show="false"]')) continue;
found.push(candidate);
@@ -3193,6 +3171,82 @@ export const REACTIVE_RUNTIME = String.raw`
syncRovingGroups();
}
// Scrollspy. The marker goes onto the links, not into state: an observer
// callback fires after the client function returned, so that write is lost.
function applyScrollspyCurrent(nav, href) {
var links = nav.querySelectorAll('a[href^="#"]');
var changed = false;
var label = "";
for (var index = 0; index < links.length; index += 1) {
var link = links[index];
var current = link.getAttribute("href") === href;
if (current) label = (link.textContent || "").trim();
if ((link.getAttribute("data-active") === "true") !== current) changed = true;
link.setAttribute("data-active", current ? "true" : "false");
link.setAttribute("aria-current", current ? "location" : "false");
}
if (!changed) return;
// Named for the component output so a parent @change binding receives it.
nav.dispatchEvent(new CustomEvent("change", { detail: { href: href, label: label } }));
}
function wireScrollspy(nav) {
if (nav.__wrnScrollspyWired) return;
nav.__wrnScrollspyWired = true;
nav.addEventListener("click", function (event) {
var t = event.target;
var link = t && t.closest ? t.closest('a[href^="#"]') : null;
if (link && nav.contains(link)) applyScrollspyCurrent(nav, link.getAttribute("href"));
});
if (typeof IntersectionObserver === "undefined") return;
var links = nav.querySelectorAll('a[href^="#"]');
var targets = [];
for (var index = 0; index < links.length; index += 1) {
var href = links[index].getAttribute("href");
var section = document.getElementById(href.slice(1));
if (section) targets.push({ section: section, href: href });
}
if (!targets.length) return;
var visible = {};
var observer = new IntersectionObserver(
function (entries) {
for (var entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
visible[entries[entryIndex].target.id] = entries[entryIndex].isIntersecting;
}
// First visible section in document order wins, so up and down
// settle on the same link.
for (var pick = 0; pick < targets.length; pick += 1) {
if (visible[targets[pick].section.id]) {
applyScrollspyCurrent(nav, targets[pick].href);
return;
}
}
},
// Biased to the upper third: the current section is the one being read.
{ rootMargin: "-80px 0px -55% 0px" },
);
for (var watch = 0; watch < targets.length; watch += 1) observer.observe(targets[watch].section);
}
function setupScrollspy() {
if (window.__wrnexusScrollspyBound) return;
window.__wrnexusScrollspyBound = true;
var wireAll = function () {
var navs = document.querySelectorAll("[data-wrn-scrollspy]");
for (var index = 0; index < navs.length; index += 1) wireScrollspy(navs[index]);
};
wireAll();
if (typeof MutationObserver === "function") {
new MutationObserver(function () {
window.setTimeout(wireAll, 0);
}).observe(document.documentElement, { subtree: true, childList: true });
}
}
function hydrateScopes(root) {
var host = root || document;
@@ -5411,6 +5465,7 @@ export const REACTIVE_RUNTIME = String.raw`
setupAnchoredOverlays();
setupModalDialogs();
setupRovingFocus();
setupScrollspy();
window.__wrnexusRepositionAnchored = repositionAnchored;
window.__wrnexusHydrateScopes = hydrateScopes;
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
+2 -2
View File
@@ -898,9 +898,9 @@ Theme-aware, responsive pagination component.
Theme-aware, responsive scrollspy component.
- Mount: `data-component="Scrollspy"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Scrollspy"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `label: string = "On this page"`, `heading: string = ""`, `class: string = ""`
- Slots: `default`
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
- Outputs: `change({ href: string; label: string })`
### Sidebar
+12 -12
View File
@@ -10716,13 +10716,6 @@
"category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "color",
"type": "string",
@@ -10731,10 +10724,10 @@
"options": []
},
{
"name": "label",
"name": "size",
"type": "string",
"required": false,
"default": "\"Scrollspy\"",
"default": "\"default\"",
"options": []
},
{
@@ -10752,10 +10745,17 @@
"options": []
},
{
"name": "orientation",
"name": "label",
"type": "string",
"required": false,
"default": "\"horizontal\"",
"default": "\"On this page\"",
"options": []
},
{
"name": "heading",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
@@ -10770,7 +10770,7 @@
"outputs": [
{
"name": "change",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
"payloadType": "{ href: string; label: string }"
}
],
"events": ["change"],
+1 -1
View File
@@ -87,7 +87,7 @@ label: string = "Breadcrumb"
{:else}
<span
class="wire-breadcrumb__current"
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : ""}'
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
>
{#if item.icon}
+5 -5
View File
@@ -86,11 +86,11 @@ size: string = "default"
</button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}">
<nav class="wire-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
<summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span>
@@ -103,13 +103,13 @@ size: string = "default"
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : 'false'}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a>
{/each}
{:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a>
@@ -119,7 +119,7 @@ size: string = "default"
</div>
</details>
{:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
<a class="wire-navbar__menu-link" data-wrn-roving-item="true" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : 'false'}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
+157 -6
View File
@@ -1,21 +1,172 @@
// Scrollspy -- a table of contents that follows the reader.
//
// <Scrollspy items='[{"label":"Overview","href":"#overview"}]' />
//
// Each href points at an element on the page. The runtime observes those
// elements and moves aria-current to the link for whichever one is in view.
//
// The runtime writes the marker straight onto the links rather than into
// component state. An IntersectionObserver callback fires long after the
// client function that registered it has returned, and a state write made
// there is dropped -- so the DOM is the only place the answer can live.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Scrollspy {
outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { href: string; label: string })
}
props {
size: string = "default"
color: string = "primary"
label: string = "Scrollspy"
size: string = "default"
items: unknown[] = []
active: string = ""
orientation: string = "horizontal"
label: string = "On this page"
heading: string = ""
class: string = ""
}
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function isActive(item) {
return Boolean(item.href) && item.href === active
}
}
view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--scrollspy wire-next--{orientation} {class}" aria-label="{label}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
<nav
{...attrs}
data-ui-component="Scrollspy"
class='wire-scrollspy {class}'
data-color='{color}'
data-size='{size}'
data-wrn-scrollspy="true"
role="navigation"
aria-label='{label}'
>
<p class="wire-scrollspy__heading" data-show="heading">{heading}</p>
<ul class="wire-scrollspy__list">
{#each itemList() as item}
<li class="wire-scrollspy__item">
<a
class="wire-scrollspy__link"
href='{item.href || "#"}'
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "location" : "false"}'
>
<span class="wire-scrollspy__label">{item.label}</span>
</a>
</li>
{/each}
</ul>
<slot />
</nav>
}
style {
.wire-scrollspy {
--scrollspy-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-scrollspy[data-color="secondary"] {
--scrollspy-accent: var(--wire-color-secondary);
}
.wire-scrollspy[data-color="success"] {
--scrollspy-accent: var(--wire-color-success);
}
.wire-scrollspy[data-color="danger"] {
--scrollspy-accent: var(--wire-color-danger);
}
.wire-scrollspy[data-color="info"] {
--scrollspy-accent: var(--wire-color-info);
}
.wire-scrollspy[data-size="sm"] {
font-size: 0.82rem;
}
.wire-scrollspy[data-size="lg"] {
font-size: 1rem;
}
.wire-scrollspy__heading {
margin: 0 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-scrollspy__list {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
border-left: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
display: block;
padding: 0.3rem 0.75rem;
margin-left: -1px;
border-left: 2px solid transparent;
color: var(--wire-color-text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.wire-scrollspy__link:hover {
color: var(--wire-color-text);
}
.wire-scrollspy__link:focus-visible {
outline: 2px solid var(--scrollspy-accent);
outline-offset: -2px;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: var(--scrollspy-accent);
color: var(--scrollspy-accent);
font-weight: 600;
}
/*
* A table of contents is a sidebar affordance. On a phone it stops being
* a rail and becomes a horizontal strip that scrolls, so it costs one
* line rather than a screenful.
*/
@media (max-width: 767px) {
.wire-scrollspy__list {
grid-auto-flow: column;
grid-auto-columns: max-content;
overflow-x: auto;
border-left: 0;
border-bottom: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
margin-left: 0;
border-left: 0;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: transparent;
border-bottom-color: var(--scrollspy-accent);
}
}
}
}
+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);
});