yukey

megacolumn-nav: the model moves onto MegacolumnNav, the routing table onto handleKey

commit 46d9ddfb0a36dadc53d27a6b5a79ad87cadb1e4c · parent 43140f51d8 · browse this tree
Ximianth, 2026-10-18 10:44:56 UTC

The model paragraph and the identity-not-indices paragraph move onto the
MegacolumnNav class doc; the key routing table becomes bullets on handleKey,
whose doc pointed at it; the focusedId invariant moves onto the focusedId
getter.  rows, position, and textMode, which had no docs, get one line each.
NavRow's fields, KeyRouting, and every other documented member get their
comments as /** */ blocks.

The header keeps the purpose and the future-2D-table note.  Wording is
unchanged apart from lowercasing emphasis, backticks on `i`, and name-first
prefixes; lines break at clauses.
diff --git a/packages/yukey/src/megacolumn-nav.ts b/packages/yukey/src/megacolumn-nav.ts
index ed4805f..e089dc1 100644
--- a/packages/yukey/src/megacolumn-nav.ts
+++ b/packages/yukey/src/megacolumn-nav.ts
@@ -1,66 +1,44 @@
 // The megacolumn navigation state machine: pure logic, no DOM, no timers, no async.
-// Events in, state out.  The Lit binding layer (megacolumn-controller.ts) feeds it
-// key names and row lists and acts on the routing decisions it returns; this file
-// never touches a DOM type, which is what makes it testable to exhaustion.
+// Events in, state out.
+// The Lit binding layer (megacolumn-controller.ts) feeds it key names and row lists
+// and acts on the routing decisions it returns;
+// this file never touches a DOM type, which is what makes it testable to exhaustion.
+// The model is on the MegacolumnNav class doc; the key routing table is on handleKey.
 //
-// The model it implements is the megacolumn from docs/precepts-we-love-the-keyboard.md:
-// focus granularity is the row; up/down move between focusable rows (skipping
-// unfocusable ones and jumping section gaps); a row may have a main control plus
-// one aux control (the small-x norm), reached with ArrowRight and left again with
-// ArrowLeft; row changes always land on the main control.  Keys the column does
-// not consume are forwarded to the focused row's widget.  Text entry dominates:
-// once entered, every key belongs to the input except the explicit exits.
-//
-// Key routing at a glance (row-nav mode):
-//   Tab                         -> pass, always (container-to-container is the browser's)
-//   ArrowUp/ArrowDown/Home/End  -> navigation, always consumed (no wrap at the ends)
-//   ArrowRight/ArrowLeft        -> within-row movement if the row has an aux control,
-//                                  otherwise forwarded (sliders consume them; a row
-//                                  therefore can't be both a slider and a two-control row)
-//   Enter/Space on a text row   -> enter text-entry mode (spacebar mirrors enter, per the guidelines)
-//   everything else             -> forwarded to the focused row's widget
-// and in text-entry mode:
-//   Tab                         -> pass, likewise
-//   Escape/Enter                -> exit text-entry mode
-//   everything else             -> passed untouched to the natively-focused input
-//
-// Rows are identified by stable ids, never indices: rows come and go (live lists
-// churn), and focus must follow identity.  setRows() reconciles the whole ordered
-// list per render; when the focused row vanishes, focus falls to the nearest
-// focusable row below it (in the old display order), else the nearest above,
-// else the first focusable in the new list.
-//
-// Invariant: focusedId is non-null exactly when at least one focusable row exists.
-// (Focus is eager: there is no "container focused but no row focused" limbo,
-// so aria-activedescendant always has a referent when it matters.)
-//
-// A future 2D-table variant would generalize the row list to rows-of-cells and
-// re-point ArrowLeft/Right at cell movement instead of within-row movement
+// A future 2D-table variant would generalize the row list to rows-of-cells
+// and re-point ArrowLeft/Right at cell movement instead of within-row movement
 // (which is exactly why sliders are banned from tables in the guidelines);
 // the reconcile/fallback/memory core would carry over unchanged.
 
 export type RowPosition = "main" | "aux";
 
 export interface NavRow {
-	// Stable identity.  Must be unique within the column and non-empty; violations throw.
+	/** id is the stable identity.  Must be unique within the column and non-empty; violations throw. */
 	id: string;
-	// Focusable rows are navigation stops; unfocusable rows (rows closed to sight,
-	// registered headings) are skipped.  Visible-but-inactive rows stay focusable:
-	// the keyboard must reach them (for i, and for stride-count stability).
-	// Defaults to true.
+
+	/**
+	 * focusable rows are navigation stops;
+	 * unfocusable rows (rows closed to sight, registered headings) are skipped.
+	 * Visible-but-inactive rows stay focusable:
+	 * the keyboard must reach them (for `i`, and for stride-count stability).
+	 * Defaults to true.
+	 */
 	focusable?: boolean;
-	// The two-control-row norm: a main control plus one aux control (the small-x).
+
+	/** hasAux is the two-control-row norm: a main control plus one aux control (the small-x). */
 	hasAux?: boolean;
-	// The main control is a text input; Enter/Space on it enters text-entry mode.
+
+	/** textEntry means the main control is a text input; Enter/Space on it enters text-entry mode. */
 	textEntry?: boolean;
 }
 
-// What the caller should do with a key, decided by the machine.
-// "nav" means the machine consumed it (state may have changed): prevent the default.
-// "forward" means the key belongs to the focused row's widget.
-// "enter-text"/"exit-text" instruct the binding to move real DOM focus
-// (into the row's input / back to the container).
-// "pass" means the key is not ours: let the browser have it.
+/**
+ * KeyRouting is what the caller should do with a key, decided by the machine.
+ * - "nav" means the machine consumed it (state may have changed): prevent the default.
+ * - "forward" means the key belongs to the focused row's widget.
+ * - "enter-text"/"exit-text" instruct the binding to move real DOM focus (into the row's input / back to the container).
+ * - "pass" means the key is not ours: let the browser have it.
+ */
 export type KeyRouting =
 	| { kind: "nav" }
 	| { kind: "forward"; rowId: string; position: RowPosition }
@@ -72,34 +50,64 @@ function isFocusable(row: NavRow): boolean {
 	return row.focusable ?? true;
 }
 
+/**
+ * MegacolumnNav implements the megacolumn model from docs/precepts-we-love-the-keyboard.md:
+ * focus granularity is the row;
+ * up/down move between focusable rows (skipping unfocusable ones and jumping section gaps);
+ * a row may have a main control plus one aux control (the small-x norm),
+ * reached with ArrowRight and left again with ArrowLeft;
+ * row changes always land on the main control.
+ * Keys the column does not consume are forwarded to the focused row's widget.
+ * Text entry dominates: once entered, every key belongs to the input except the explicit exits.
+ *
+ * Rows are identified by stable ids, never indices:
+ * rows come and go (live lists churn), and focus must follow identity.
+ * {@link setRows} reconciles the whole ordered list per render;
+ * when the focused row vanishes, focus falls to the nearest focusable row below it (in the old display order),
+ * else the nearest above, else the first focusable in the new list.
+ */
 export class MegacolumnNav {
 	#rows: NavRow[] = [];
 	#focusedId: string | null = null;
 	#position: RowPosition = "main";
 	#textMode = false;
 
+	/** rows is the ordered row list as last given to {@link setRows}. */
 	get rows(): readonly NavRow[] {
 		return this.#rows;
 	}
+
+	/**
+	 * focusedId is the focused row's id.
+	 * Invariant: focusedId is non-null exactly when at least one focusable row exists.
+	 * (Focus is eager: there is no "container focused but no row focused" limbo,
+	 * so aria-activedescendant always has a referent when it matters.)
+	 */
 	get focusedId(): string | null {
 		return this.#focusedId;
 	}
+
+	/** position is the focused control within the focused row. */
 	get position(): RowPosition {
 		return this.#position;
 	}
+
+	/** textMode is whether text entry is in progress. */
 	get textMode(): boolean {
 		return this.#textMode;
 	}
 
-	// Reconcile the whole ordered row list (display order).  Focus follows row id:
-	// a surviving focused row keeps focus even if it moved; a vanished (or newly
-	// unfocusable) one falls to the nearest focusable neighbor, preferring downward
-	// in the old display order.  Row changes of any kind land on the main control,
-	// and a focused row that lost its aux or textEntry nature sheds the dependent state.
-	// Returns true when the fallback moved focus involuntarily (a row the user was on
-	// gave way to a neighbor): the binding uses this to lock out decisive keys briefly.
-	// Focus appearing in a previously-empty column is not involuntary movement
-	// (the user was not aiming at any row), so that returns false.
+	/**
+	 * setRows reconciles the whole ordered row list (display order).
+	 * Focus follows row id: a surviving focused row keeps focus even if it moved;
+	 * a vanished (or newly unfocusable) one falls to the nearest focusable neighbor, preferring downward in the old display order.
+	 * Row changes of any kind land on the main control,
+	 * and a focused row that lost its aux or textEntry nature sheds the dependent state.
+	 * Returns true when the fallback moved focus involuntarily (a row the user was on gave way to a neighbor):
+	 * the binding uses this to lock out decisive keys briefly.
+	 * Focus appearing in a previously-empty column is not involuntary movement (the user was not aiming at any row),
+	 * so that returns false.
+	 */
 	setRows(rows: NavRow[]): boolean {
 		const seen = new Set<string>();
 		for (const row of rows) {
@@ -123,9 +131,10 @@ export class MegacolumnNav {
 		return hadFocus && this.#focusedId !== null;
 	}
 
-	// Move focus to a row directly (the binding calls this for mouse clicks).
-	// Unknown ids, unfocusable rows, and aux positions on aux-less rows are caller
-	// bugs: fail loudly rather than limp.
+	/**
+	 * focusRow moves focus to a row directly (the binding calls this for mouse clicks).
+	 * Unknown ids, unfocusable rows, and aux positions on aux-less rows are caller bugs: fail loudly rather than limp.
+	 */
 	focusRow(id: string, position: RowPosition = "main"): void {
 		const row = this.rowById(id);
 		if (row === undefined) throw new Error(`focusRow: unknown row id: ${id}`);
@@ -138,8 +147,11 @@ export class MegacolumnNav {
 		this.#textMode = false;
 	}
 
-	// Real DOM focus landed in a text row's input without going through handleKey
-	// (a mouse click on the input).  Text entry dominates: sync the machine to reality.
+	/**
+	 * noteTextEntered is for when real DOM focus landed in a text row's input without going through handleKey
+	 * (a mouse click on the input).
+	 * Text entry dominates: sync the machine to reality.
+	 */
 	noteTextEntered(id: string): void {
 		const row = this.rowById(id);
 		if (row === undefined) throw new Error(`noteTextEntered: unknown row id: ${id}`);
@@ -150,15 +162,31 @@ export class MegacolumnNav {
 		this.#textMode = true;
 	}
 
-	// Real DOM focus left the input without an exit key (the container blurred).
-	// Calling this outside text mode is a caller bug.
+	/**
+	 * exitTextMode is for when real DOM focus left the input without an exit key (the container blurred).
+	 * Calling this outside text mode is a caller bug.
+	 */
 	exitTextMode(): void {
 		if (!this.#textMode) throw new Error("exitTextMode: not in text mode");
 		this.#textMode = false;
 	}
 
-	// Route one key (a KeyboardEvent.key value; the binding filters out
-	// ctrl/alt/meta chords before asking).  See the routing table in the file header.
+	/**
+	 * handleKey routes one key (a KeyboardEvent.key value; the binding filters out ctrl/alt/meta chords before asking).
+	 *
+	 * Key routing at a glance (row-nav mode):
+	 * - Tab: pass, always (container-to-container is the browser's)
+	 * - ArrowUp/ArrowDown/Home/End: navigation, always consumed (no wrap at the ends)
+	 * - ArrowRight/ArrowLeft: within-row movement if the row has an aux control,
+	 *   otherwise forwarded (sliders consume them; a row therefore can't be both a slider and a two-control row)
+	 * - Enter/Space on a text row: enter text-entry mode (spacebar mirrors enter, per the guidelines)
+	 * - everything else: forwarded to the focused row's widget
+	 *
+	 * and in text-entry mode:
+	 * - Tab: pass, likewise
+	 * - Escape/Enter: exit text-entry mode
+	 * - everything else: passed untouched to the natively-focused input
+	 */
 	handleKey(key: string): KeyRouting {
 		// Tab is never ours: it is the browser's container-to-container movement,
 		// in every mode.  (Forwarding it to a widget could break leaving the column.)
@@ -230,11 +258,13 @@ export class MegacolumnNav {
 		return this.#focusedId;
 	}
 
-	// Step to the next focusable row in `delta` direction, skipping unfocusable rows.
-	// No wrap: at the ends the key is still consumed ("nav" with no change), so the
-	// page never scrolls out from under a keyboard user probing the column's bounds.
-	// Position snaps to main even when no move happens: after ANY up/down press the
-	// user is on a main control, never left lingering on an aux (delete) control.
+	/**
+	 * moveFocus steps to the next focusable row in `delta` direction, skipping unfocusable rows.
+	 * No wrap: at the ends the key is still consumed ("nav" with no change),
+	 * so the page never scrolls out from under a keyboard user probing the column's bounds.
+	 * Position snaps to main even when no move happens:
+	 * after any up/down press the user is on a main control, never left lingering on an aux (delete) control.
+	 */
 	private moveFocus(delta: 1 | -1): KeyRouting {
 		const focusedId = this.mustFocusedId("moveFocus");
 		this.#position = "main";
@@ -260,10 +290,13 @@ export class MegacolumnNav {
 		return { kind: "nav" };
 	}
 
-	// The vanished-focus fallback: scan the OLD display order downward from where the
-	// focused row was, then upward, taking the first row that is present AND focusable
-	// in the NEW list; if the old position gives no anchor, take the new list's first
-	// focusable row; an all-unfocusable or empty column focuses nothing.
+	/**
+	 * fallbackFrom is the vanished-focus fallback:
+	 * scan the old display order downward from where the focused row was, then upward,
+	 * taking the first row that is present and focusable in the new list;
+	 * if the old position gives no anchor, take the new list's first focusable row;
+	 * an all-unfocusable or empty column focuses nothing.
+	 */
 	private fallbackFrom(oldRows: NavRow[]): string | null {
 		const oldIndex = oldRows.findIndex((r) => r.id === this.#focusedId);
 		if (oldIndex !== -1) {