/** * A screen change must not drop the keyboard. * * Most of this app moves between screens on a button inside the screen — * "Play against a bot", launching a match, going back from the waiting screen, * "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 a removed element's * focus goes to : the next Tab restarts from the top of the document, and * a screen reader is told nothing about the page having changed. Nothing * errors, the screen renders, and a mouse never notices. * * The rule has two halves and the second is the one that is easy to get wrong. * render() takes focus only when it destroyed the element that held it. A * masthead link lives outside #app, so clicking Home must leave focus on Home; * a render that always grabbed the heading would yank it away every time. * * Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; // Enough of the DOM for render(): parentage, so contains() can answer; a // children array, so replaceChildren() can drop the old screen; and one // document-wide activeElement, which is the thing under test. class FakeElement { constructor(tag) { this.tagName = tag.toUpperCase(); this.attributes = {}; this.children = []; this.parent = null; } setAttribute(name, value) { this.attributes[name] = value; } removeAttribute(name) { delete this.attributes[name]; } append(...nodes) { for (const node of nodes) { node.parent = this; this.children.push(node); } } replaceChildren(...nodes) { for (const old of this.children) old.parent = null; this.children = []; this.append(...nodes); } contains(node) { for (let p = node; p; p = p.parent) if (p === this) return true; return false; } /** Only ever asked for "h1", so that is all this answers. */ querySelector(selector) { assert.equal(selector, "h1", `unexpected selector ${selector}`); for (const child of this.children) { if (child.tagName === "H1") return child; const deeper = child.querySelector?.(selector); if (deeper) return deeper; } return null; } focus(options) { globalThis.document.activeElement = this; this.focusOptions = options; } } const body = new FakeElement("body"); const app = new FakeElement("div"); globalThis.document = { activeElement: body, querySelector: (selector) => (selector === "#app" ? app : null), createElement: (tag) => new FakeElement(tag), }; const { render } = await import("../src/dom.ts"); /** A screen: a heading and a button, the shape every screen here has. */ function screen(heading = "Somewhere") { const h1 = new FakeElement("h1"); h1.textContent = heading; const section = new FakeElement("section"); section.append(new FakeElement("button")); return [h1, section]; } test("focus lands on the new heading when the render destroyed it", () => { const [h1, section] = screen("Before"); render(h1, section); // The player is on a button inside the screen — "Go home", "Play against a // bot", whichever put them there. const pressed = section.children[0]; document.activeElement = pressed; const next = screen("After"); render(...next); assert.equal( document.activeElement, next[0], "focus did not follow the render", ); assert.equal(document.activeElement.textContent, "After"); assert.equal( next[0].attributes.tabindex, "-1", "the heading cannot take focus without one", ); // start() has already scrolled to the top; focusing must not undo it. assert.deepEqual(next[0].focusOptions, { preventScroll: true }); }); test("focus outside #app is left alone", () => { render(...screen("Before")); // A masthead link: outside the screen, and still there afterwards. const navLink = new FakeElement("a"); body.append(navLink); document.activeElement = navLink; const next = screen("After"); render(...next); assert.equal( document.activeElement, navLink, "render stole focus from a control it never touched", ); assert.equal(next[0].attributes.tabindex, undefined); }); test("a screen with no heading falls back to #app", () => { const section = new FakeElement("section"); const pressed = new FakeElement("button"); section.append(pressed); render(section); document.activeElement = pressed; render(new FakeElement("section")); assert.equal(document.activeElement, app); assert.equal(app.attributes.tabindex, "-1"); }); test("nothing focused at all is not treated as focus to rescue", () => { render(...screen("Before")); document.activeElement = body; const next = screen("After"); render(...next); assert.equal(document.activeElement, body); assert.equal(next[0].attributes.tabindex, undefined); });