yukey

yk-infopop: the trigger, dismissal, and announcement-surface contract moves onto the class doc

commit 43140f51d879948b47dddebdb32c7937981dfbcb · parent f7a6e80c12 · browse this tree
Ximianth, 2026-10-18 10:43:26 UTC

The header's whole contract (one per app, the two triggers and the i
toggle, the dismissal list, the announcement-surface rules, placement) is
the component's and reaches dist/yk-infopop.d.ts on the YkInfopop class
doc.  "yk-info.ts" and "infopop-place.ts" become {@link}s to YkInfoDetail
and placeInfopop, both already imported.  HOVER_DELAY_MS, Mode, and the
mode/anchor getters, which had no docs, get one line each.  Every other
documented member gets its comment as a /** */ block.

The header keeps the purpose and the norm's doc pointer.  Wording is
unchanged apart from lowercasing emphasis, backticks on `i`, and name-first
prefixes; lines break at clauses.
diff --git a/packages/yukey/src/yk-infopop.ts b/packages/yukey/src/yk-infopop.ts
index f233a4c..e5da36d 100644
--- a/packages/yukey/src/yk-infopop.ts
+++ b/packages/yukey/src/yk-infopop.ts
@@ -1,65 +1,71 @@
-// yk-infopop: the info norm's popup surface -- the keyboard's tooltip and the
-// mouse's tooltip as ONE element showing one authored content per anchor
-// (the wiring SSOT -- the yk-info event and the data-info carrier attribute --
-// is yk-info.ts; the norm is docs/precepts-we-love-the-keyboard.md "info pops").
-//
-// One per app: place it once in the app shell's render (the gallery shell hosts
-// one), and every yk-info event and every
-// [data-info] hover in that tree raises it.  Two triggers:
-//
-//   - Keyboard: a yk-info event (composed, so it bubbles here from any open
-//     shadow root below) opens the popup PINNED: pointer movement never closes
-//     or replaces it.  Any later keydown closes it -- heard in capture phase,
-//     so the very key that closes an old popup can raise the next one (i on
-//     another row closes-then-reopens), which is how the popup "follows" row
-//     focus without needing focus-change events.  One exception to re-raising:
-//     i on the anchor the pinned popup already shows is a TOGGLE -- the close
-//     notes which anchor that keypress dismissed, and the matching yk-info
-//     arriving in the same dispatch is swallowed, so the second i dismisses
-//     instead of blinking closed-then-open.  (A different anchor's yk-info,
-//     or any i once that keypress ends, opens normally; an unpinned hover
-//     popup never toggles -- i upgrades it to pinned.)  No key is consumed
-//     except the one Escape that closes the popup (Escape unwinds one layer
-//     at a time, and the popup is the innermost layer): all other navigation
-//     under an open popup reaches the megacolumn untouched.
-//   - Hover: pointermove anywhere in the tree; the nearest [data-info] carrier
-//     in the composed path becomes the candidate, and after a short delay the
-//     popup opens unpinned.  It closes when the pointer leaves the carrier.
-//     Hover never replaces a pinned popup -- not even after the pinned one
-//     closes (no popup appears without a fresh pointer or key action).
-//
-// Dismissal, the complete list: any keydown (bare modifiers excepted),
-// pointerdown anywhere, scroll anywhere (anchor rects go stale; closing beats
-// re-anchoring for a transient surface), and hover-carrier leave (hover-raised
-// only).  Focus moves need no listener of their own: every user-driven focus
-// change starts with a keydown or pointerdown, which already close.
-//
-// The popup is an announcement surface, never an interaction one:
-// pointer-events none, never focusable, never a tab stop.  role=tooltip on the
-// host; where the browser reflects ariaDescribedByElements, the anchor is wired
-// aria-describedby to the popup across shadow trees (the popup's tree is a
-// shadow-including ancestor of every anchor's tree, which element reflection
-// allows; the content-attribute form cannot cross trees, so absent reflection
-// support the wiring is skipped rather than half-done).
-//
-// Placement is infopop-place.ts's pure rule (below-centered, flip, clamp),
-// fed fresh rects at open time.
+// yk-infopop: the info norm's popup surface --
+// the keyboard's tooltip and the mouse's tooltip as one element showing one authored content per anchor.
+// The norm is docs/precepts-we-love-the-keyboard.md "info pops"; the contract is on the YkInfopop class doc.
 
 import { css, html, LitElement, type TemplateResult } from "lit";
 import { customElement, state } from "lit/decorators.js";
 import { placeInfopop } from "./infopop-place.js";
 import type { YkInfoDetail } from "./yk-info.js";
 
+/** HOVER_DELAY_MS is the short delay between a hover carrier becoming the candidate and the popup opening unpinned. */
 export const HOVER_DELAY_MS = 350;
 
+/** Mode is the popup's state: closed, pinned (raised by a `yk-info` event), or hover (raised by the pointer). */
 type Mode = "closed" | "pinned" | "hover";
 
-// The element-reflection shape for cross-tree aria-describedby, feature-detected
-// at use (typescript's DOM lib does not carry it yet).
+/**
+ * AriaReflection is the element-reflection shape for cross-tree aria-describedby,
+ * feature-detected at use (typescript's DOM lib does not carry it yet).
+ */
 interface AriaReflection {
 	ariaDescribedByElements: Element[] | null;
 }
 
+/**
+ * YkInfopop is the info norm's popup surface:
+ * the keyboard's tooltip and the mouse's tooltip as one element showing one authored content per anchor
+ * (the wiring SSOT -- the yk-info event and the data-info carrier attribute -- is {@link YkInfoDetail}).
+ *
+ * One per app: place it once in the app shell's render (the gallery shell hosts one),
+ * and every yk-info event and every [data-info] hover in that tree raises it.
+ * Two triggers:
+ *
+ * - Keyboard: a yk-info event (composed, so it bubbles here from any open shadow root below) opens the popup pinned:
+ *   pointer movement never closes or replaces it.
+ *   Any later keydown closes it -- heard in capture phase,
+ *   so the very key that closes an old popup can raise the next one (`i` on another row closes-then-reopens),
+ *   which is how the popup "follows" row focus without needing focus-change events.
+ *   One exception to re-raising: `i` on the anchor the pinned popup already shows is a toggle --
+ *   the close notes which anchor that keypress dismissed,
+ *   and the matching yk-info arriving in the same dispatch is swallowed,
+ *   so the second `i` dismisses instead of blinking closed-then-open.
+ *   (A different anchor's yk-info, or any `i` once that keypress ends, opens normally;
+ *   an unpinned hover popup never toggles -- `i` upgrades it to pinned.)
+ *   No key is consumed except the one Escape that closes the popup
+ *   (Escape unwinds one layer at a time, and the popup is the innermost layer):
+ *   all other navigation under an open popup reaches the megacolumn untouched.
+ * - Hover: pointermove anywhere in the tree;
+ *   the nearest [data-info] carrier in the composed path becomes the candidate,
+ *   and after a short delay ({@link HOVER_DELAY_MS}) the popup opens unpinned.
+ *   It closes when the pointer leaves the carrier.
+ *   Hover never replaces a pinned popup -- not even after the pinned one closes
+ *   (no popup appears without a fresh pointer or key action).
+ *
+ * Dismissal, the complete list: any keydown (bare modifiers excepted), pointerdown anywhere,
+ * scroll anywhere (anchor rects go stale; closing beats re-anchoring for a transient surface),
+ * and hover-carrier leave (hover-raised only).
+ * Focus moves need no listener of their own:
+ * every user-driven focus change starts with a keydown or pointerdown, which already close.
+ *
+ * The popup is an announcement surface, never an interaction one:
+ * pointer-events none, never focusable, never a tab stop.
+ * role=tooltip on the host;
+ * where the browser reflects ariaDescribedByElements, the anchor is wired aria-describedby to the popup across shadow trees
+ * (the popup's tree is a shadow-including ancestor of every anchor's tree, which element reflection allows;
+ * the content-attribute form cannot cross trees, so absent reflection support the wiring is skipped rather than half-done).
+ *
+ * Placement is {@link placeInfopop}'s pure rule (below-centered, flip, clamp), fed fresh rects at open time.
+ */
 @customElement("yk-infopop")
 export class YkInfopop extends LitElement {
 	static styles = css`
@@ -84,22 +90,31 @@ export class YkInfopop extends LitElement {
 	private content = "";
 	#mode: Mode = "closed";
 	#anchor: HTMLElement | null = null;
-	// The [data-info] carrier currently under the pointer (tracked in every mode,
-	// so hover state stays coherent while a pinned popup has precedence).
+
+	/**
+	 * #candidate is the [data-info] carrier currently under the pointer
+	 * (tracked in every mode, so hover state stays coherent while a pinned popup has precedence).
+	 */
 	#candidate: HTMLElement | null = null;
 	#timer: number | undefined;
 	#root: Node | null = null;
-	// Set when an i keydown dismisses a pinned popup: the producer answering that
-	// same keypress will usually dispatch a fresh yk-info for the same anchor
-	// (our capture close runs first), and swallowing that arrival is what makes
-	// i a toggle.  Scoped by the keydown event itself: its eventPhase returns to
-	// NONE once its dispatch ends, so a later i escapes suppression naturally,
-	// with no timers or clearing bookkeeping.
+
+	/**
+	 * #dismissal is set when an `i` keydown dismisses a pinned popup:
+	 * the producer answering that same keypress will usually dispatch a fresh yk-info for the same anchor
+	 * (our capture close runs first), and swallowing that arrival is what makes `i` a toggle.
+	 * Scoped by the keydown event itself:
+	 * its eventPhase returns to NONE once its dispatch ends,
+	 * so a later `i` escapes suppression naturally, with no timers or clearing bookkeeping.
+	 */
 	#dismissal: { anchor: HTMLElement; keydown: KeyboardEvent } | null = null;
 
+	/** mode is the popup's current state. */
 	get mode(): Mode {
 		return this.#mode;
 	}
+
+	/** anchor is the element the open popup describes; null when closed. */
 	get anchor(): HTMLElement | null {
 		return this.#anchor;
 	}
@@ -168,8 +183,10 @@ export class YkInfopop extends LitElement {
 		if (to === null || !this.#inServedTree(to)) this.#trackCarrier(null);
 	};
 
-	// Whether a node lives in the tree this popup serves, shadow-inclusively
-	// (the served root, or any shadow tree nested under it).
+	/**
+	 * #inServedTree is whether a node lives in the tree this popup serves, shadow-inclusively
+	 * (the served root, or any shadow tree nested under it).
+	 */
 	#inServedTree(target: EventTarget): boolean {
 		if (!(target instanceof Node)) return false;
 		let root: Node | null = target.getRootNode();
@@ -180,8 +197,10 @@ export class YkInfopop extends LitElement {
 		return false;
 	}
 
-	// The carrier under the pointer changed.  Hover popups live and die by it;
-	// pinned popups ignore it entirely.
+	/**
+	 * #trackCarrier notes that the carrier under the pointer changed.
+	 * Hover popups live and die by it; pinned popups ignore it entirely.
+	 */
 	#trackCarrier(carrier: HTMLElement | null): void {
 		if (carrier === this.#candidate) return;
 		this.#candidate = carrier;
@@ -255,9 +274,11 @@ export class YkInfopop extends LitElement {
 		}
 	}
 
-	// Positioning happens after render: the content must be laid out before the
-	// popup can be measured.  visibility (not display) hides the closed popup,
-	// so measurement works before the reveal.
+	/**
+	 * updated is where positioning happens, after render:
+	 * the content must be laid out before the popup can be measured.
+	 * visibility (not display) hides the closed popup, so measurement works before the reveal.
+	 */
 	protected updated(): void {
 		if (this.#mode === "closed" || this.#anchor === null) {
 			this.style.visibility = "";
@@ -293,8 +314,10 @@ export class YkInfopop extends LitElement {
 	}
 }
 
-// The nearest [data-info] carrier in a composed event path.  Nearest-wins gives
-// nesting for free: an aux x inside an info-bearing row shows its own story.
+/**
+ * nearestCarrier is the nearest [data-info] carrier in a composed event path.
+ * Nearest-wins gives nesting for free: an aux x inside an info-bearing row shows its own story.
+ */
 function nearestCarrier(path: EventTarget[]): HTMLElement | null {
 	for (const t of path) {
 		if (t instanceof HTMLElement && (t.dataset.info ?? "") !== "") return t;