/** * The two things every screen is built out of: a way to make an element, and a * way to put a screen on the page. */ // Looked up on the first render rather than at import. The masthead's account // control imports el() from here and runs on the blog's pages too, where there // is no app and never will be a render; a top-level query would also put a DOM // call in the way of unit-testing anything that imports this file. let root: HTMLElement | null = null; /** * The keys that name an attribute rather than a property. `aria-*` and * `data-*` are the whole set an element has no property for, and both are * hyphenated, which is what el() keys off at runtime. * * Intersected raw rather than wrapped in Partial: Partial collapses a * template-literal index signature, and the keys go back to being rejected. * An index signature demands nothing, so these stay optional anyway, and an * unknown key is still an error. */ type Attributes = { [key: `aria-${string}`]: string; [key: `data-${string}`]: string; }; export function el( tag: K, props: Partial & Attributes = {}, children: (Node | string)[] = [], ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); for (const [key, value] of Object.entries(props)) { // A hyphenated key has to go through setAttribute. Assigning one instead — // which is what a bare Object.assign here used to do — puts an expando // property called "aria-label" on the element and nothing ever reads it: // no error, no attribute, and a control with no accessible name. Every // other key is a property, which is what keeps className, textContent and // the on* handlers working. if (key.includes("-")) node.setAttribute(key, String(value)); else (node as Record)[key] = value; } node.append(...children); return node; } // A screen that owns timers registers its teardown while it is being built, // and render() promotes it only once that screen is actually on the page. // Doing it in one step would have a screen tear itself down: the arguments to // render(...waitingScreen()) are evaluated before render runs. The nav can // replace any screen at any moment, and a waiting screen left polling will // eventually redirect the browser out from under whatever replaced it. let activeTeardown: (() => void) | null = null; let pendingTeardown: (() => void) | null = null; export function onTeardown(fn: () => void): void { pendingTeardown = fn; } 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 }); }