diff --git a/web/src/dom.ts b/web/src/dom.ts index 83b830e..25b35f8 100644 --- a/web/src/dom.ts +++ b/web/src/dom.ts @@ -59,9 +59,35 @@ export function onTeardown(fn: () => void): void { export function render(...children: Node[]): void { root ??= document.querySelector("#app")!; + + // Whether this render is about to destroy the element holding focus. Read + // before the swap, because afterwards the answer is always no. + // + // Most of the app moves between screens on a button inside the screen: + // "Play against a bot", launching a match, going back, "Go home" on the + // not-found screen, "Try again" on the error screen. Every one of those + // removes the button that was just pressed, and the browser drops focus to + // , so the next Tab restarts from the top of the document and a screen + // reader is told nothing at all about the page having changed. + const stealingFocus = root.contains(document.activeElement); + activeTeardown?.(); activeTeardown = pendingTeardown; pendingTeardown = null; root.replaceChildren(...children); root.removeAttribute("aria-busy"); + + if (!stealingFocus) return; + + // The new screen's heading, which is also what a screen reader reads on + // arrival — the announcement and the focus target are the same thing. Every + // screen names itself, the camo editor with a visually-hidden h1; #app is + // the fallback for one that does not. + // + // Nothing here runs when the player clicked a masthead link: that focus is + // outside #app and stays where they put it. + const landing = root.querySelector("h1") ?? root; + landing.setAttribute("tabindex", "-1"); + // start() has already scrolled to the top, and focusing must not undo it. + landing.focus({ preventScroll: true }); } diff --git a/web/src/styles.css b/web/src/styles.css index 632d0d8..dee7a8d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -102,6 +102,15 @@ body:has(.hero) { color: var(--muted); } +/* render() parks focus here when a screen change destroyed whatever held it, + so a screen reader names the new page and the next Tab carries on from it. + Neither is a control the player aimed at, so neither gets a ring. Only + tabindex="-1" is quietened: anything reachable by Tab keeps its own. */ +#app[tabindex="-1"]:focus, +#app h1[tabindex="-1"]:focus { + outline: none; +} + /* --- masthead ------------------------------------------------------------ Insignia, wordmark and nav on one line, sticky, about 3.5rem tall. It is the only chrome on the page, so it carries the brand on its own: the mark, the