yukey

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);