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:
@@ -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