focus-stack: the surface vocabulary, the entered/closed contract, and the journey rules move onto their declarations
commit f7a6e80c129ea904de2a619f51050184ce7e6e9c · parent 16d3a4310d
· browse this tree
Ximianth, 2026-10-18 10:41:35 UTC
FocusSurface takes the "surfaces" paragraph and the identity-not-references rule; register takes the registration sentence and its example. entered and closed each merge their header entry with their member doc, and take the journey rules they implement (replace-not-push and one-entry-per-id on entered; pop-skips-dead and fail-closed on closed, with the first-tab-stop note). The per-document shape goes onto FocusStack, focusStackOf (which had no doc), and installFocusStack, with the usage snippets as code spans. Every other documented member gets its comment as a /** */ block. The header keeps the purpose paragraph. Wording is unchanged apart from lowercasing emphasis, backticks, name-first prefixes, and the two merges, which drop only sentences the other half already said; lines break at clauses.
diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts index 4e67aab..de9ec52 100644 --- a/packages/yukey/src/focus-stack.ts +++ b/packages/yukey/src/focus-stack.ts @@ -1,74 +1,33 @@ // The focus stack: the kit's answer to "where does focus go when this closes?" -// (the guidelines' "Focus is a stack" norm). Opening a component that contains -// focus PUSHES the place focus came from; closing it POPS back there; dead -// entries are skipped; the page shell's fallback is the end of the line. -// Focus falling to <body> is always a bug, and this file is the one mechanism -// that prevents it -- page shells must not grow bespoke relocation logic beside it. +// (the guidelines' "Focus is a stack" norm). +// Opening a component that contains focus pushes the place focus came from; closing it pops back there; +// dead entries are skipped; the page shell's fallback is the end of the line. +// Focus falling to <body> is always a bug, and this file is the one mechanism that prevents it -- +// page shells must not grow bespoke relocation logic beside it. // -// The shape mirrors tab containment (tab-cycle.ts): one stack per document, -// installed by the page shell: -// -// installFocusStack(document, () => <the fallback element>); -// -// and components participate through the same per-document accessor: -// -// const stack = focusStackOf(this.ownerDocument); -// -// SURFACES are the stack's vocabulary: a surface is anything that can hold -// focus and open/close (a panel, a card, an anchored overlay). A component -// registers itself as a surface while connected (and unregisters on -// disconnect), naming itself with a stable id: -// -// stack.register({ id: CARD_SURFACE, root: () => this }); -// -// Everything is IDENTITY, never element references: entries record surface -// ids, and root() is re-queried on every use -- a surface that closed and -// reopened is the same journey stop, resolved to whatever element carries the -// id now. A root that is gone or disconnected reads as dead. -// -// The OWNER of a surface's open/close lifecycle makes two calls: -// -// entered(id) right before seating focus in a user-opened surface, -// while focus still sits wherever the user came from. -// Pushes { entered: id, origin: <the innermost registered -// surface holding focus, or null> }. -// closed(id, held) when the surface closes (any reason: Escape, ×, deselect, -// data death). `held` is whether the surface contained the -// focus as the close began -- the owner computes it BEFORE -// the DOM changes (path() is the tool). Retires the -// surface's entry always; routes focus only when held. -// -// The journey rules these calls implement: -// -// replace-not-push entered(id) while id is already the top entry is a -// no-op: lateral moves (selecting ship B while ship A's -// card is open) re-target the surface without burying -// the journey origin. -// one-entry-per-id entered(id) with an older entry buried deeper retires -// that entry first: a surface is one journey stop, and -// the stack stays bounded. -// pop-skips-dead a held close routes to the retired entry's origin if -// it is alive and focusable; otherwise it unwinds to the -// innermost remaining entry's surface (entries whose -// surfaces died are swept first); otherwise the fallback. -// fail-closed routing with nowhere to go throws. The number of -// places focus may land on "nothing" is between zero and -// one in the whole UI, and that one is the page shell's -// fallback surface -- never a silent drop to <body>. -// -// Popping focuses the surface's first tab stop (for a megacolumn host that is -// its one [data-mc-container], whose machine's row memory then restores the -// row -- the stack returns to the right CONTAINER; the machine takes it from -// there). The walk descends the surface's shadow root, so hosts register -// themselves and nothing needs to export inner elements. +// The contract is on the declarations: FocusStack (the per-document shape), FocusSurface (the vocabulary), +// and the entered/closed pair (the journey rules). import { deepActiveElement, deepTabbables } from "./tab-cycle.js"; +/** + * FocusSurface is the stack's vocabulary: + * a surface is anything that can hold focus and open/close (a panel, a card, an anchored overlay). + * A component registers itself as one with {@link FocusStack.register}. + * + * Everything is identity, never element references: + * entries record surface ids, and root() is re-queried on every use -- + * a surface that closed and reopened is the same journey stop, resolved to whatever element carries the id now. + * A root that is gone or disconnected reads as dead. + */ export interface FocusSurface { - // Stable identity, unique per document. Components use their tag name. + /** id is the stable identity, unique per document. Components use their tag name. */ id: string; - // The surface's live root element (usually the registering host itself). - // Re-queried on every use; null or a disconnected element reads as dead. + + /** + * root is the surface's live root element (usually the registering host itself). + * Re-queried on every use; null or a disconnected element reads as dead. + */ root(): HTMLElement | null; } @@ -77,8 +36,10 @@ interface StackEntry { origin: string | null; } -// Whether `node` sits inside `ancestor`, crossing shadow boundaries upward -// (a shadow root's contents are composed-inside its host). +/** + * composedContains is whether `node` sits inside `ancestor`, crossing shadow boundaries upward + * (a shadow root's contents are composed-inside its host). + */ function composedContains(ancestor: Node, node: Node | null): boolean { let cur: Node | null = node; while (cur !== null) { @@ -88,6 +49,15 @@ function composedContains(ancestor: Node, node: Node | null): boolean { return false; } +/** + * FocusStack is one stack per document. + * The shape mirrors tab containment (tab-cycle.ts): + * the page shell installs it with {@link installFocusStack}, + * and components participate through the same per-document accessor, {@link focusStackOf}. + * + * The owner of a surface's open/close lifecycle makes two calls, {@link entered} and {@link closed}; + * the journey rules are documented on them. + */ export class FocusStack { #doc: Document; #surfaces = new Map<string, FocusSurface>(); @@ -98,26 +68,34 @@ export class FocusStack { this.#doc = doc; } - // The end of the line for pops: the page shell's one predictable focus home - // (its root surface). + /** setFallback sets the end of the line for pops: the page shell's one predictable focus home (its root surface). */ setFallback(fallback: () => HTMLElement | null): void { this.#fallback = fallback; } - // The entries, oldest first (introspection for tests and debugging; the - // stack's behavior is entirely through entered/closed). + /** + * entries is the entries, oldest first + * (introspection for tests and debugging; the stack's behavior is entirely through entered/closed). + */ get entries(): readonly StackEntry[] { return this.#entries; } - // Retire every entry, routing nothing: a fresh-world boundary. Page shells - // never need this (their surfaces close through closed()); test rigs that - // bulldoze the DOM between cases do, because a per-document stack outlives - // a document.body.replaceChildren(). + /** + * clear retires every entry, routing nothing: a fresh-world boundary. + * Page shells never need this (their surfaces close through closed()); + * test rigs that bulldoze the DOM between cases do, + * because a per-document stack outlives a document.body.replaceChildren(). + */ clear(): void { this.#entries = []; } + /** + * register adds a surface; a component registers itself while connected (and unregisters on disconnect), + * naming itself with a stable id: `stack.register({ id: CARD_SURFACE, root: () => this })`. + * Registering an id twice throws. + */ register(surface: FocusSurface): void { if (this.#surfaces.has(surface.id)) { throw new Error(`focus-stack: surface "${surface.id}" is already registered`); @@ -129,9 +107,11 @@ export class FocusStack { this.#surfaces.delete(id); } - // The registered surfaces currently containing the deep-active element, - // outermost first. Owners snapshot this BEFORE a DOM change to compute - // `held` for closed() (containment is unknowable after the unmount). + /** + * path is the registered surfaces currently containing the deep-active element, outermost first. + * Owners snapshot this before a DOM change to compute `held` for closed() + * (containment is unknowable after the unmount). + */ path(): string[] { const active = deepActiveElement(this.#doc); if (active === null) return []; @@ -148,8 +128,19 @@ export class FocusStack { return containing.map((c) => c.id); } - // A user-opened surface is about to seat focus: record where focus came from. - // Call while focus still sits at the origin (before the seat moves it). + /** + * entered records where focus came from: a user-opened surface is about to seat focus. + * Call right before seating focus in the surface, while focus still sits wherever the user came from + * (before the seat moves it). + * Pushes { entered: id, origin: the innermost registered surface holding focus, or null }. + * + * The journey rules it implements: + * - replace-not-push: entered(id) while id is already the top entry is a no-op: + * lateral moves (selecting ship B while ship A's card is open) + * re-target the surface without burying the journey origin. + * - one-entry-per-id: entered(id) with an older entry buried deeper retires that entry first: + * a surface is one journey stop, and the stack stays bounded. + */ entered(id: string): void { if (!this.#surfaces.has(id)) { throw new Error(`focus-stack: entered("${id}") but no such surface is registered`); @@ -164,9 +155,27 @@ export class FocusStack { this.#entries.push({ entered: id, origin }); } - // A surface closed. Retires its entry and sweeps entries whose surfaces - // died with it; when the surface held focus, routes focus to the journey's - // nearest live stop (see the header's pop-skips-dead rule). + /** + * closed is the call when a surface closes (any reason: Escape, ×, deselect, data death). + * `held` is whether the surface contained the focus as the close began -- + * the owner computes it before the DOM changes ({@link path} is the tool). + * Retires its entry always, and sweeps entries whose surfaces died with it; + * when the surface held focus, routes focus to the journey's nearest live stop. + * + * The journey rules it implements: + * - pop-skips-dead: a held close routes to the retired entry's origin if it is alive and focusable; + * otherwise it unwinds to the innermost remaining entry's surface (entries whose surfaces died are swept first); + * otherwise the fallback. + * - fail-closed: routing with nowhere to go throws. + * The number of places focus may land on "nothing" is between zero and one in the whole UI, + * and that one is the page shell's fallback surface -- never a silent drop to `<body>`. + * + * Popping focuses the surface's first tab stop + * (for a megacolumn host that is its one [data-mc-container], + * whose machine's row memory then restores the row -- + * the stack returns to the right container; the machine takes it from there). + * The walk descends the surface's shadow root, so hosts register themselves and nothing needs to export inner elements. + */ closed(id: string, held: boolean): void { let retired: StackEntry | undefined; for (let i = this.#entries.length - 1; i >= 0; i--) { @@ -202,8 +211,10 @@ export class FocusStack { return root?.isConnected ? root : null; } - // Seat focus on a surface's first tab stop. False when the surface is dead - // or has no stop to give (routing then unwinds past it). + /** + * #focusSurface seats focus on a surface's first tab stop. + * False when the surface is dead or has no stop to give (routing then unwinds past it). + */ #focusSurface(id: string): boolean { const root = this.#liveRoot(id); if (root === null) return false; @@ -215,11 +226,14 @@ export class FocusStack { } } -// One stack per document, lazily (a WeakSet-of-documents shape, like tab -// containment's): components can participate without caring whether a shell -// installed a fallback yet, and tests get a real stack for free. +/** + * stacks is one stack per document, lazily (a WeakSet-of-documents shape, like tab containment's): + * components can participate without caring whether a shell installed a fallback yet, + * and tests get a real stack for free. + */ const stacks = new WeakMap<Document, FocusStack>(); +/** focusStackOf is the per-document accessor components participate through: `focusStackOf(this.ownerDocument)`. */ export function focusStackOf(doc: Document): FocusStack { let stack = stacks.get(doc); if (stack === undefined) { @@ -229,9 +243,11 @@ export function focusStackOf(doc: Document): FocusStack { return stack; } -// The one call a page shell makes: the document's stack, with the shell's -// fallback wired. Idempotent in the same way tab containment is (repeat -// installs just re-point the fallback). +/** + * installFocusStack is the one call a page shell makes: the document's stack, with the shell's fallback wired: + * `installFocusStack(document, () => <the fallback element>)`. + * Idempotent in the same way tab containment is (repeat installs just re-point the fallback). + */ export function installFocusStack(doc: Document, fallback: () => HTMLElement | null): FocusStack { const stack = focusStackOf(doc); stack.setFallback(fallback);