fix(web): keep focus on the dock or outline control that navigated; announce only

Navigations started from the dock (Previous/Next) or the course outline leave focus on the control used and only
announce the new title; other navigations (content links, library, back/forward) still focus the new h1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:56:37 +01:00
co-authored by Claude Opus 5.5
parent 4379d77b9c
commit bc267c6562
2 changed files with 32 additions and 4 deletions
+6 -3
View File
@@ -34,8 +34,10 @@ function useRouteScroll() {
}
/**
* After a navigation, focus moves to the new page's heading (its first h1, given tabindex -1) once it renders, and
* its text goes to a polite live region. The first page load keeps the browser's own focus.
* After a navigation, the new page's heading (its first h1) is announced in a polite live region once it renders,
* and takes focus (tabindex -1), unless the navigation came from the dock or the course outline: those controls
* stay put, so focus stays on the one that was used (Enter on Next twice goes two lessons on). The first page load
* keeps the browser's own focus.
*/
function useRouteFocus(): string {
const loc = useLocation();
@@ -52,9 +54,10 @@ function useRouteFocus(): string {
if (!h || h === last.current) return false;
last.current = h;
if (initial) return true;
setSaid(h.textContent ?? "");
if (document.activeElement?.closest(".stc-dock, .lr-side")) return true;
if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1");
h.focus({ preventScroll: true });
setSaid(h.textContent ?? "");
return true;
};
if (take()) return;
+26 -1
View File
@@ -87,7 +87,8 @@ async function open(p: string, ready: string) {
}
async function key(k: string, code: number) {
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: code });
// Enter needs its text to activate a focused button or link.
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: code, ...(k === "Enter" ? { text: "\r" } : {}) });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, windowsVirtualKeyCode: code });
}
@@ -181,6 +182,7 @@ run("navigation motion (headless Chrome)", () => {
await until(async () => (await title()) === "Code along", "third lesson");
await until(async () => (await m()).anims.includes("stc-page@lr lr-lesson"), "lesson entrance");
expect(await exists(".lr-main .stc-loading")).toBe(false);
// Focus was on <body> (a scripted click does not focus the button), so the new heading takes it.
await until(async () => (await focusedH1()) === "Code along", "focus on the h1");
await until(async () => (await announced()) === "Code along", "announcement");
expect(await chrome.eval<boolean>(`getComputedStyle(document.activeElement).outlineStyle === "none"`)).toBe(true);
@@ -192,6 +194,29 @@ run("navigation motion (headless Chrome)", () => {
await chrome.eval("history.go(-2), true");
await until(async () => (await title()) === "Introduction", "intro again");
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 3000);
await until(async () => (await focusedH1()) === "Introduction", "back/forward focuses the h1");
}, 30_000);
test("keyboard on the dock: Enter on Next twice goes two lessons on, focus stays on Next, titles are announced", async () => {
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
const onNext = () => chrome.eval<boolean>(`document.activeElement === document.querySelector(".stc-dock .stc-pill--primary")`);
await chrome.eval(`document.querySelector(".stc-dock .stc-pill--primary").focus(), true`);
await key("Enter", 13);
await until(async () => (await title()) === "Watch the clip", "second lesson");
await until(async () => (await announced()) === "Watch the clip", "announcement");
expect(await onNext()).toBe(true);
await key("Enter", 13);
await until(async () => (await title()) === "Code along", "third lesson");
await until(async () => (await announced()) === "Code along", "announcement");
expect(await onNext()).toBe(true);
expect(await path()).toBe("/c/sample/m1-code");
// The outline keeps focus too: a sidebar link activated from the keyboard stays focused.
await chrome.eval(`document.querySelector('.lr-les[href="/c/sample/m1-intro"]').focus(), true`);
await key("Enter", 13);
await until(async () => (await title()) === "Introduction", "intro");
await until(async () => (await announced()) === "Introduction", "announcement");
expect(await chrome.eval<boolean>(`document.activeElement === document.querySelector('.lr-les[href="/c/sample/m1-intro"]')`)).toBe(true);
}, 30_000);
test("a click right after a navigation is handled", async () => {