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();
|
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) {
|
function hydrateScopes(root) {
|
||||||
var host = root || document;
|
var host = root || document;
|
||||||
|
|
||||||
@@ -5264,6 +5380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries;
|
window.__wrnexusHydrateAsyncBoundaries = hydrateAsyncBoundaries;
|
||||||
setupAnchoredOverlays();
|
setupAnchoredOverlays();
|
||||||
setupModalDialogs();
|
setupModalDialogs();
|
||||||
|
setupRovingFocus();
|
||||||
window.__wrnexusRepositionAnchored = repositionAnchored;
|
window.__wrnexusRepositionAnchored = repositionAnchored;
|
||||||
window.__wrnexusHydrateScopes = hydrateScopes;
|
window.__wrnexusHydrateScopes = hydrateScopes;
|
||||||
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
|
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();
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||||
expect(win.document.querySelector("#out")?.textContent).toBe("2");
|
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