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;