Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
4.2 kB · 93 lines
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394/** * 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<K extends keyof HTMLElementTagNameMap>( tag: K, props: Partial<HTMLElementTagNameMap[K]> & 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<string, unknown>)[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<HTMLElement>("#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 // <body>, 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<HTMLElement>("h1") ?? root; landing.setAttribute("tabindex", "-1"); // start() has already scrolled to the top, and focusing must not undo it. landing.focus({ preventScroll: true });}