diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 67c1fa0..19737bd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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; diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index babe438..4585afd 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -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 (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(`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("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(`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(`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 () => {