yukey

yk-list: contracts move onto YkList, its properties, and YkListAddDetail

commit 473e406f17cd2f984c768fb0795e5906803b9be0 · parent 67070847d2 · browse this tree
Ximianth, 2026-10-18 10:34:17 UTC

The "what an owner gets" bullets (header row, live rows, add-row,
list-scoped keys), the key-semantics and rendered-structure paragraphs, and
the light-DOM/fail-closed paragraph move onto the YkList class doc.  The
per-property block moves onto the properties, each named first with its
attribute where it differs; the five pass-through properties get one full
doc on items and pointers from the other four.  The yk-list-add event
contract moves onto YkListAddDetail.  Every other documented member gets
its comment as a /** */ block; ykListStyles likewise.

The header keeps the one-line purpose and the group-identity decision for
AT, which updated()'s body comment points at.  Wording is unchanged apart
from lowercasing emphasis, backticks (including on `<name>-header`, which
hover's markdown would otherwise eat as a tag), name-first prefixes, and
"no group identity claimed" -> "taken", since claim means something
specific in this kit; lines break at clauses.
diff --git a/packages/yukey/src/yk-list.ts b/packages/yukey/src/yk-list.ts
index d11e310..ad977c8 100644
--- a/packages/yukey/src/yk-list.ts
+++ b/packages/yukey/src/yk-list.ts
@@ -1,109 +1,16 @@
 // yk-list: the list region of a megacolumn, with the list norms built in.
-// An owner composes it into its column ([data-mc-container]) and gets, by
-// construction, the behaviors the guidelines ask every list to have:
-//
-//   - The HEADER ROW (optional): a normal nav stop that speaks for the region.
-//     While it holds focus the whole list gets the quiet group wash; the focus
-//     ring stays on the header row itself -- deliberately no perimeter border,
-//     which would read as a nested focus container.  Its data-info describes
-//     the list AS a list.  Headerless lists are legal (no header row renders).
-//   - The LIVE ROWS: an inner yk-live-list in megacolumn mode (that file owns
-//     the row-lifecycle contract: churn safety, ghosts, geometry, the key pin).
-//   - The ADD-ROW (optional): the append affordance as a permanent ghosted nav
-//     stop at the tail.  Its marked hotkey (default a) is DIRECT: it dives
-//     straight into text entry, earned under the guidelines' easy-undo clause,
-//     because NOTHING SYNCS UNTIL COMMIT: Enter dispatches the commit event,
-//     Escape abandons the draft, and the component holds no copy of the text --
-//     the input's value is the draft, and the owner's data is untouched until
-//     the owner itself acts on the commit.
-//   - The LIST-SCOPED KEYS: t jumps to the list's top (its header when it has
-//     one, else its first row) and n jumps past the list to the next stop after
-//     it.  Documented norms with defaults, deliberately NOT hotkey markings
-//     (every list wearing the same gold letters would be noise; the letters
-//     only mean this while focus is inside the list's own rows).  Scope is DOM
-//     containment: this element hears the bubbling megacolumn-key dispatch from
-//     its own rows before the controller's column-level marking resolution runs,
-//     so inner scope wins by dispatch order.  A claimed key with no landing is
-//     the clamp: consumed, focus stays -- same shape as the machine's no-wrap ends.
-//
-// The public surface:
-//
-//   properties (attributes in kebab-case where marked):
-//     name          Namespaces the structural row ids: the header row is
-//                   "<name>-header", the add-row "<name>-add".  Required
-//                   [a-z0-9-]+; default "list".  Two lists in one column need
-//                   distinct names (the controller throws on duplicate row ids).
-//     header        Header row text.  Empty (the default) means headerless:
-//                   no header row, no group identity claimed.
-//     renderHeader  Optional render-prop for the header ROW's visible content
-//                   (an owner composing its own header line -- live counts, a
-//                   column-label grid).  The `header` string stays the identity
-//                   switch and the group's aria-label: a stable name for AT
-//                   while the visible content varies.  Ignored when headerless.
-//     header-info   The owner's whole-list info text.  The component appends its
-//                   own norm teaching (t/n, the add hotkey) to whatever is given,
-//                   so the norms read the same in every list.
-//     addable       Renders the add-row.  Lists that cannot append (a cargo
-//                   manifest) just leave it off.
-//     add-label     The add-row's label; default "add…".  The hotkey letter is
-//                   marked in the label (first occurrence, yk-button's rule),
-//                   or shown after it when the label lacks the letter.
-//     add-hotkey    The marked direct hotkey; default "a" (single [a-z0-9]).
-//                   Override only when two addable lists share a column: the
-//                   controller throws on duplicate markings.
-//     add-info      The owner's info text for the add-row; norm teaching is
-//                   appended, as with header-info.
-//     items, keyOf, renderItem, renderEmpty, resetKey
-//                   Passed through to the inner yk-live-list untouched: the
-//                   owner renders row CONTENT and keeps data identity; see
-//                   yk-live-list.ts for their contracts.
-//
-//   events:
-//     yk-list-add   The add-row's commit (bubbling, cancelable; detail
-//                   { text: string }, trimmed, never empty).  Dispatched only on
-//                   the input's own Enter keydown -- never on change/blur, so an
-//                   Escape that blurs the input cannot masquerade as a commit.
-//                   Uncancelled: the component clears the draft and the text-mode
-//                   exit proceeds, with row focus remaining on the add-row (Enter
-//                   re-dives).  preventDefault() REFUSES the commit (owner-side
-//                   validation): the draft and text mode both survive, so the
-//                   user fixes the text instead of retyping it.
-//
-//   key semantics stay the owner's: megacolumn-key listeners (what Enter, x,
-//   Delete MEAN on a live row) belong on the owner's container, exactly as
-//   without yk-list.  The structural rows decline every key -- owners routing
-//   by rowId should skip "<name>-header" and "<name>-add" (that id scheme is
-//   public contract).  Owners claiming letters that overlap t/n or the add
-//   hotkey should respect a prior claim (check defaultPrevented), though
-//   wanting to is already a design smell.
-//
-//   rendered structure (public contract, since it renders into the owner's
-//   tree): [header row?] <yk-live-list megacolumn> [add-row?], all inside this
-//   element.  yk-live-list's feel knobs (holdMs, keyPinMs, pointerSafety...)
-//   are deliberately NOT mirrored here -- mirrored twins invite drift -- and are
-//   set on the inner element where they live.  The yk-list-prefixed classes on
-//   the structural rows are stable; their look is the norm's look.
+// The public contract is on the YkList class doc and its properties; yk-list-add is on YkListAddDetail.
 //
 // Group identity for AT (the header-role decision, settled for flat lists):
-// with a header, this element takes role="group" plus an aria-label mirroring
-// the header text -- the APG listbox pattern's grouping idiom, giving AT the
-// region identity the wash gives sighted users.  The header row itself STAYS a
-// role=option nav stop: reachable with arrows, stable stride counts, t lands on
-// it.  It is still announced as an option, but as the first option of a group
-// named like it -- descriptive, where the bare demo markup was misleading.
-// An author-set role on the host is left alone.  Trees are explicitly NOT this
-// component's business: a treeitem reframe (levels, expansion state) belongs to
-// a distinct tree vocabulary, and nothing here forecloses one -- nesting
-// yk-list was never legal (rows come from renderItem, not markup).
-//
-// Like yk-live-list's mega mode, this renders into the owner's tree (light
-// DOM): the megacolumn scan and aria-activedescendant cannot cross shadow
-// boundaries.  No shadow root, no slots; styles adopt into the owner's root
-// once, yk-list-prefixed.  After every update it dispatches megacolumn-rescan
-// (structural rows change under the controller's scan on this element's own
-// schedule, not the owner's).  Fail closed: connecting outside a
-// [data-mc-container] throws, element children throw, a malformed name or
-// add-hotkey throws.
+// with a header, this element takes role="group" plus an aria-label mirroring the header text --
+// the APG listbox pattern's grouping idiom, giving AT the region identity the wash gives sighted users.
+// The header row itself stays a role=option nav stop: reachable with arrows, stable stride counts, t lands on it.
+// It is still announced as an option, but as the first option of a group named like it --
+// descriptive, where the bare demo markup was misleading.
+// An author-set role on the host is left alone.
+// Trees are explicitly not this component's business:
+// a treeitem reframe (levels, expansion state) belongs to a distinct tree vocabulary,
+// and nothing here forecloses one -- nesting yk-list was never legal (rows come from renderItem, not markup).
 
 import { css, html, LitElement, nothing, type TemplateResult } from "lit";
 import { customElement, property } from "lit/decorators.js";
@@ -112,18 +19,30 @@ import type { MegacolumnKeyDetail } from "./megacolumn-controller.js";
 import type { LiveListPhase } from "./yk-live-list.js";
 import "./yk-live-list.js";
 
+/**
+ * YkListAddDetail is the detail of a `yk-list-add` event: the add-row's commit
+ * (bubbling, cancelable; detail { text: string }, trimmed, never empty).
+ * Dispatched only on the input's own Enter keydown --
+ * never on change/blur, so an Escape that blurs the input cannot masquerade as a commit.
+ * Uncancelled: the component clears the draft and the text-mode exit proceeds,
+ * with row focus remaining on the add-row (Enter re-dives).
+ * preventDefault() refuses the commit (owner-side validation):
+ * the draft and text mode both survive, so the user fixes the text instead of retyping it.
+ */
 export interface YkListAddDetail {
-	text: string; // trimmed, never empty
+	/** text is trimmed, never empty. */
+	text: string;
 }
 
-// The norm's look for the structural rows, adopted into the owner's root.
-// The wash rule is the header-row norm made structural: focus on the header
-// washes the whole region (group identity), keyed off the controller's
-// mc- classes; the ring stays the kit's standard row outline on the header.
-// The add-row idles ghosted (an affordance, not a form field); diving in
-// raises the well and the accent border.  The raised look is keyed to the
-// add-row's OWN focus class, not just the container's text mode: two lists in
-// one column must not both light up when one of them is being typed in.
+/**
+ * ykListStyles is the norm's look for the structural rows, adopted into the owner's root.
+ * The wash rule is the header-row norm made structural:
+ * focus on the header washes the whole region (group identity), keyed off the controller's mc- classes;
+ * the ring stays the kit's standard row outline on the header.
+ * The add-row idles ghosted (an affordance, not a form field); diving in raises the well and the accent border.
+ * The raised look is keyed to the add-row's own focus class, not just the container's text mode:
+ * two lists in one column must not both light up when one of them is being typed in.
+ */
 const ykListStyles = css`
 	yk-list {
 		display: block;
@@ -170,37 +89,149 @@ const ykListStyles = css`
 	}
 `;
 
+/**
+ * YkList is the list region of a megacolumn, with the list norms built in.
+ * An owner composes it into its column (`[data-mc-container]`) and gets, by construction,
+ * the behaviors the guidelines ask every list to have:
+ *
+ * - The header row (optional): a normal nav stop that speaks for the region.
+ *   While it holds focus the whole list gets the quiet group wash;
+ *   the focus ring stays on the header row itself --
+ *   deliberately no perimeter border, which would read as a nested focus container.
+ *   Its data-info describes the list as a list.
+ *   Headerless lists are legal (no header row renders).
+ * - The live rows: an inner yk-live-list in megacolumn mode
+ *   (that element owns the row-lifecycle contract: churn safety, ghosts, geometry, the key pin).
+ * - The add-row (optional): the append affordance as a permanent ghosted nav stop at the tail.
+ *   Its marked hotkey (default `a`) is direct: it dives straight into text entry,
+ *   earned under the guidelines' easy-undo clause, because nothing syncs until commit:
+ *   Enter dispatches the commit event, Escape abandons the draft, and the component holds no copy of the text --
+ *   the input's value is the draft, and the owner's data is untouched until the owner itself acts on the commit.
+ * - The list-scoped keys: `t` jumps to the list's top (its header when it has one, else its first row)
+ *   and `n` jumps past the list to the next stop after it.
+ *   Documented norms with defaults, deliberately not hotkey markings
+ *   (every list wearing the same gold letters would be noise;
+ *   the letters only mean this while focus is inside the list's own rows).
+ *   Scope is DOM containment:
+ *   this element hears the bubbling megacolumn-key dispatch from its own rows
+ *   before the controller's column-level marking resolution runs, so inner scope wins by dispatch order.
+ *   A claimed key with no landing is the clamp: consumed, focus stays --
+ *   same shape as the machine's no-wrap ends.
+ *
+ * Key semantics stay the owner's:
+ * megacolumn-key listeners (what Enter, x, Delete mean on a live row) belong on the owner's container,
+ * exactly as without yk-list.
+ * The structural rows decline every key --
+ * owners routing by rowId should skip `<name>-header` and `<name>-add` (that id scheme is public contract).
+ * Owners claiming letters that overlap `t`/`n` or the add hotkey should respect a prior claim (check defaultPrevented),
+ * though wanting to is already a design smell.
+ *
+ * Rendered structure (public contract, since it renders into the owner's tree):
+ * `[header row?] <yk-live-list megacolumn> [add-row?]`, all inside this element.
+ * yk-live-list's feel knobs (holdMs, keyPinMs, pointerSafety...) are deliberately not mirrored here --
+ * mirrored twins invite drift -- and are set on the inner element where they live.
+ * The yk-list-prefixed classes on the structural rows are stable; their look is the norm's look.
+ *
+ * Like yk-live-list's mega mode, this renders into the owner's tree (light DOM):
+ * the megacolumn scan and aria-activedescendant cannot cross shadow boundaries.
+ * No shadow root, no slots; styles adopt into the owner's root once, yk-list-prefixed.
+ * After every update it dispatches megacolumn-rescan
+ * (structural rows change under the controller's scan on this element's own schedule, not the owner's).
+ * Fail closed: connecting outside a `[data-mc-container]` throws, element children throw,
+ * a malformed name or add-hotkey throws.
+ */
 @customElement("yk-list")
 export class YkList<T = unknown> extends LitElement {
+	/**
+	 * name namespaces the structural row ids: the header row is `<name>-header`, the add-row `<name>-add`.
+	 * Required [a-z0-9-]+; default "list".
+	 * Two lists in one column need distinct names (the controller throws on duplicate row ids).
+	 */
 	@property()
 	name = "list";
+
+	/**
+	 * header is the header row text.
+	 * Empty (the default) means headerless: no header row, no group identity taken.
+	 */
 	@property()
 	header = "";
+
+	/**
+	 * headerInfo (attribute `header-info`) is the owner's whole-list info text.
+	 * The component appends its own norm teaching (`t`/`n`, the add hotkey) to whatever is given,
+	 * so the norms read the same in every list.
+	 */
 	@property({ attribute: "header-info" })
 	headerInfo = "";
+
+	/**
+	 * renderHeader is an optional render-prop for the header row's visible content
+	 * (an owner composing its own header line -- live counts, a column-label grid).
+	 * The `header` string stays the identity switch and the group's aria-label:
+	 * a stable name for AT while the visible content varies.
+	 * Ignored when headerless.
+	 */
 	@property({ attribute: false })
 	renderHeader?: () => TemplateResult;
+
+	/**
+	 * addable renders the add-row.
+	 * Lists that cannot append (a cargo manifest) just leave it off.
+	 */
 	@property({ type: Boolean })
 	addable = false;
+
+	/**
+	 * addLabel (attribute `add-label`) is the add-row's label; default "add…".
+	 * The hotkey letter is marked in the label (first occurrence, yk-button's rule),
+	 * or shown after it when the label lacks the letter.
+	 */
 	@property({ attribute: "add-label" })
 	addLabel = "add…";
+
+	/**
+	 * addHotkey (attribute `add-hotkey`) is the marked direct hotkey; default `a` (single [a-z0-9]).
+	 * Override only when two addable lists share a column: the controller throws on duplicate markings.
+	 */
 	@property({ attribute: "add-hotkey" })
 	addHotkey = "a";
+
+	/**
+	 * addInfo (attribute `add-info`) is the owner's info text for the add-row;
+	 * norm teaching is appended, as with {@link headerInfo}.
+	 */
 	@property({ attribute: "add-info" })
 	addInfo = "";
+
+	/**
+	 * items is passed through to the inner yk-live-list untouched,
+	 * as are {@link keyOf}, {@link renderItem}, {@link renderEmpty}, and {@link resetKey}:
+	 * the owner renders row content and keeps data identity; see yk-live-list for their contracts.
+	 */
 	@property({ attribute: false })
 	items: T[] = [];
+
+	/** keyOf is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	keyOf!: (item: T) => string;
+
+	/** renderItem is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	renderItem!: (item: T, phase: LiveListPhase) => TemplateResult;
+
+	/** renderEmpty is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property({ attribute: false })
 	renderEmpty?: () => TemplateResult;
+
+	/** resetKey is passed through to the inner yk-live-list untouched; see {@link items}. */
 	@property()
 	resetKey = "";
 
-	// The column this list belongs to; n walks its rows.  Set at connect (which
-	// fails closed when there is none).
+	/**
+	 * #container is the column this list belongs to; `n` walks its rows.
+	 * Set at connect (which fails closed when there is none).
+	 */
 	#container: HTMLElement | null = null;
 
 	constructor() {
@@ -212,7 +243,7 @@ export class YkList<T = unknown> extends LitElement {
 		this.addEventListener("megacolumn-key", this.#onListKey);
 	}
 
-	// Light DOM, no slots (see the header).  Styles adopt into the owner's root.
+	/** createRenderRoot is light DOM, no slots (see {@link YkList}).  Styles adopt into the owner's root. */
 	protected override createRenderRoot(): HTMLElement {
 		if (this.children.length > 0) {
 			throw new Error(
@@ -266,12 +297,15 @@ export class YkList<T = unknown> extends LitElement {
 		}
 	}
 
-	// The t/n list-scoped keys (see the file header).  Both resolve through
-	// megacolumn-focus-request, the controller's public focus-move affordance:
-	// asking is the filter -- the controller ignores requests for unfocusable or
-	// vanished rows and answers granted ones with preventDefault -- so each key
-	// walks its candidates until one lands.  No landing = the clamp: the key
-	// stays claimed and focus stays put.
+	/**
+	 * #onListKey is the `t`/`n` list-scoped keys (see {@link YkList}),
+	 * registered as this element's megacolumn-key listener in the constructor.
+	 * Both resolve through megacolumn-focus-request, the controller's public focus-move affordance:
+	 * asking is the filter --
+	 * the controller ignores requests for unfocusable or vanished rows and answers granted ones with preventDefault --
+	 * so each key walks its candidates until one lands.
+	 * No landing = the clamp: the key stays claimed and focus stays put.
+	 */
 	#onListKey = (e: Event): void => {
 		const { detail } = e as CustomEvent<MegacolumnKeyDetail>;
 		if (detail.key !== "t" && detail.key !== "n") return;
@@ -293,7 +327,7 @@ export class YkList<T = unknown> extends LitElement {
 		}
 	};
 
-	// Ask the column to move logical focus to rowId; true when granted.
+	/** #requestFocus asks the column to move logical focus to rowId; true when granted. */
 	#requestFocus(rowId: string): boolean {
 		const req = new CustomEvent("megacolumn-focus-request", {
 			detail: { rowId },
@@ -304,13 +338,14 @@ export class YkList<T = unknown> extends LitElement {
 		return req.defaultPrevented;
 	}
 
-	// The add-row's commit/abandon mechanics, on the input's REAL keydown (text
-	// mode gives the input native focus, so this sees every key before the
-	// container's exit handling bubbles).  Deliberately keydown, NOT @change:
-	// both text-mode exits blur the input, and change fires on
-	// blur-with-changed-value -- a change-driven commit would append on Escape
-	// too.  Nothing syncs anywhere until the Enter commit; Escape abandons by
-	// clearing the draft.
+	/**
+	 * #onAddKeydown is the add-row's commit/abandon mechanics, on the input's real keydown
+	 * (text mode gives the input native focus, so this sees every key before the container's exit handling bubbles).
+	 * Deliberately keydown, not @change:
+	 * both text-mode exits blur the input, and change fires on blur-with-changed-value --
+	 * a change-driven commit would append on Escape too.
+	 * Nothing syncs anywhere until the Enter commit; Escape abandons by clearing the draft.
+	 */
 	#onAddKeydown = (e: KeyboardEvent): void => {
 		const input = e.currentTarget as HTMLInputElement;
 		if (e.key === "Escape") {
@@ -374,8 +409,10 @@ export class YkList<T = unknown> extends LitElement {
 		`;
 	}
 
-	// The structural rows' info: the owner's text first, then the norm teaching --
-	// appended by the component so the norms read the same in every list.
+	/**
+	 * #composedHeaderInfo is the structural rows' info: the owner's text first, then the norm teaching --
+	 * appended by the component so the norms read the same in every list.
+	 */
 	#composedHeaderInfo(): string {
 		const t = this.header === "" ? "t returns to the top" : "t returns here";
 		const add = this.addable ? `${this.addHotkey} dives into the add-row at the tail; ` : "";
@@ -390,9 +427,11 @@ export class YkList<T = unknown> extends LitElement {
 		return this.addInfo === "" ? norms : `${this.addInfo}; ${norms}`;
 	}
 
-	// The add-row's hotkey marking: the letter accented in the label (first
-	// case-insensitive occurrence, yk-button's rule), or shown after the label
-	// when it lacks the letter -- a marking must be visible either way.
+	/**
+	 * #markedAddLabel is the add-row's hotkey marking:
+	 * the letter accented in the label (first case-insensitive occurrence, yk-button's rule),
+	 * or shown after the label when it lacks the letter -- a marking must be visible either way.
+	 */
 	#markedAddLabel(): TemplateResult {
 		const idx = this.addLabel.toLowerCase().indexOf(this.addHotkey.toLowerCase());
 		if (idx === -1) return html`${this.addLabel} <span class="yk-list-hk">${this.addHotkey}</span>`;