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