feat(csr): runtime-owned 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
arrow keys move within it, with Home/End, wrap-around and skip-disabled.

Written once here rather than five times across Tabs, Nav, MegaMenu, Sidebar
and Stepper, and because focus bookkeeping cannot live in component state --
a client function writing after it returns has that write dropped.

Item visibility is checked via hidden and data-show rather than measured
size: the test DOM reports every element as zero-sized, which is exactly what
left the dialog focus trap uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 16:38:44 +05:30
co-authored by Claude Opus 5
parent e8e1a2623b
commit f94004648d
2 changed files with 216 additions and 0 deletions
+117
View File
@@ -3047,6 +3047,122 @@ export const REACTIVE_RUNTIME = String.raw`
syncDialogs();
}
/*
* 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.
*/
var ROVING_SELECTOR = "[data-wrn-roving]";
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
function rovingItems(container) {
var found = [];
var candidates = container.querySelectorAll(ROVING_ITEM_SELECTOR);
for (var index = 0; index < candidates.length; index += 1) {
var candidate = candidates[index];
// A nested group owns its own items; do not steal them.
if (candidate.closest(ROVING_SELECTOR) !== container) continue;
if (candidate.hasAttribute("disabled")) continue;
if (candidate.getAttribute("aria-disabled") === "true") continue;
/*
* Deliberately not a size check. The test DOM reports every element as
* zero-sized, so measuring here would make the whole feature impossible
* to cover -- the same trap the dialog visibility check fell into.
*/
if (candidate.hasAttribute("hidden")) continue;
if (candidate.closest('[data-show="false"]')) continue;
found.push(candidate);
}
return found;
}
function rovingActiveIndex(items) {
for (var index = 0; index < items.length; index += 1) {
var item = items[index];
if (item.getAttribute("aria-selected") === "true") return index;
var current = item.getAttribute("aria-current");
if (current === "page" || current === "step" || current === "true") return index;
}
return 0;
}
function applyRovingTabindex(items, activeIndex) {
for (var index = 0; index < items.length; index += 1) {
items[index].setAttribute("tabindex", index === activeIndex ? "0" : "-1");
}
}
function syncRovingGroup(container) {
var items = rovingItems(container);
if (!items.length) return;
applyRovingTabindex(items, rovingActiveIndex(items));
}
function syncRovingGroups() {
var groups = document.querySelectorAll(ROVING_SELECTOR);
for (var index = 0; index < groups.length; index += 1) syncRovingGroup(groups[index]);
}
function handleRovingKeydown(event) {
var target = event.target;
if (!target || !target.closest) return;
var item = target.closest(ROVING_ITEM_SELECTOR);
if (!item) return;
var container = item.closest(ROVING_SELECTOR);
if (!container) return;
var items = rovingItems(container);
var index = items.indexOf(item);
if (index === -1) return;
var orientation = container.getAttribute("data-wrn-roving") || "horizontal";
var horizontal = orientation === "horizontal" || orientation === "both";
var vertical = orientation === "vertical" || orientation === "both";
var key = event.key;
var next = -1;
if ((horizontal && key === "ArrowRight") || (vertical && key === "ArrowDown")) {
next = (index + 1) % items.length;
} else if ((horizontal && key === "ArrowLeft") || (vertical && key === "ArrowUp")) {
next = (index - 1 + items.length) % items.length;
} else if (key === "Home") {
next = 0;
} else if (key === "End") {
next = items.length - 1;
} else {
return;
}
event.preventDefault();
applyRovingTabindex(items, next);
if (items[next].focus) items[next].focus();
}
function setupRovingFocus() {
if (window.__wrnexusRovingBound) return;
window.__wrnexusRovingBound = true;
document.addEventListener("keydown", handleRovingKeydown, true);
if (typeof MutationObserver === "function") {
new MutationObserver(function () {
window.setTimeout(syncRovingGroups, 0);
}).observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-selected", "aria-current", "disabled", "aria-disabled", "hidden"],
});
}
syncRovingGroups();
}
function hydrateScopes(root) {
var host = root || document;
@@ -5264,6 +5380,7 @@ export const REACTIVE_RUNTIME = String.raw`
window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries;
setupAnchoredOverlays();
setupModalDialogs();
setupRovingFocus();
window.__wrnexusRepositionAnchored = repositionAnchored;
window.__wrnexusHydrateScopes = hydrateScopes;
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
+99
View File
@@ -894,3 +894,102 @@ test("comments are ignored inside interpreted statements", () => {
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("2");
});
test("roving focus moves with arrow keys and wraps", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
const c = doc.querySelector("#c") as unknown as HTMLElement;
expect(a.getAttribute("tabindex")).toBe("0");
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1");
a.focus();
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(doc.activeElement!.id).toBe("b");
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
expect(a.getAttribute("tabindex")).toBe("-1");
doc
.querySelector("#b")!
.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
expect(doc.activeElement!.id).toBe("a");
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
expect(doc.activeElement!.id).toBe("c");
c.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(doc.activeElement!.id).toBe("a");
});
test("roving focus honours Home and End and skips disabled items", () => {
const win = mount(
`<div data-wrn-roving="vertical">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" disabled>B</button>
<button data-wrn-roving-item id="c">C</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(doc.activeElement!.id).toBe("c");
doc
.querySelector("#c")!
.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(doc.activeElement!.id).toBe("a");
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(doc.activeElement!.id).toBe("c");
});
test("horizontal roving ignores vertical arrows so the page still scrolls", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
const a = win.document.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(win.document.activeElement!.id).toBe("a");
});
test("roving tabindex starts on the selected item, not the first", () => {
const win = mount(
`<div data-wrn-roving="horizontal">
<button data-wrn-roving-item id="a">A</button>
<button data-wrn-roving-item id="b" aria-selected="true">B</button>
</div>`,
);
expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1");
});
test("nested roving groups do not capture the outer group items", () => {
const win = mount(
`<div data-wrn-roving="horizontal" id="outer">
<button data-wrn-roving-item id="a">A</button>
<div data-wrn-roving="vertical" id="inner">
<button data-wrn-roving-item id="x">X</button>
<button data-wrn-roving-item id="y">Y</button>
</div>
<button data-wrn-roving-item id="b">B</button>
</div>`,
);
const doc = win.document;
const a = doc.querySelector("#a") as unknown as HTMLElement;
a.focus();
a.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
expect(doc.activeElement!.id).toBe("b");
});