diff --git a/TODO.md b/TODO.md index 2760f90..6cb8236 100644 --- a/TODO.md +++ b/TODO.md @@ -229,6 +229,12 @@ rediscovering them: it instead. Every one of those is a regex on a file, and they will all have to be rewritten the day there is a runner. + `render.test.mjs` is how far the fake goes: parentage, `contains`, + `replaceChildren` and one `activeElement`, which is enough to hold + render()'s focus rule from both sides. It is also about the limit — + it already asserts that the only selector ever passed is `h1`, because + answering a real one would mean writing a query engine. + - [ ] **Lighthouse scores a page 100 with an unlabelled field.** A placeholder counts as an accessible name, so `select-name` and `label` both pass on a field whose name vanishes as soon as anyone types. The sign-in handle diff --git a/web/scripts/render.test.mjs b/web/scripts/render.test.mjs new file mode 100644 index 0000000..334fa39 --- /dev/null +++ b/web/scripts/render.test.mjs @@ -0,0 +1,155 @@ +/** + * 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); +});