diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 63ad9ccb..ad2738b0 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -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); }; diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 71a178dc..bef6017c 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -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( + `
+ + + +
`, + ); + 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( + `
+ + + +
`, + ); + 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( + `
+ + +
`, + ); + 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( + `
+ + +
`, + ); + 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( + `
+ +
+ + +
+ +
`, + ); + 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"); +});