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
+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");
});