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