yk-live-list: contracts move onto YkLiveList, pointerSafety, and the other properties
commit 16d3a4310d3f00113a0db0a5f4e00df948c9ee31 · parent 473e406f17
· browse this tree
Ximianth, 2026-10-18 10:39:21 UTC
The two click-safety strategies and the "in both modes" rule move onto
pointerSafety, whose short summary they replace. The mode-independent
rules, the megacolumn-mode stamping rules, and the key-activity pin move
onto the YkLiveList class doc. resetKey absorbs the header's reset rule.
items, keyOf, renderItem, the four timing knobs, and ItemClickDetail, which
had no docs, get one line each read off the code. Every other documented
member, type, and constant gets its comment as a /** */ block, including
RowState's fields. Section dividers stay as they were.
The header keeps the one-line purpose, the lifecycle diagram (internal
phases, not the owner's vocabulary), and the state-machine note. Wording is
unchanged apart from lowercasing emphasis, backticks, name-first prefixes,
and {@link}s replacing "see the header"; lines break at clauses.
diff --git a/packages/yukey/src/yk-live-list.ts b/packages/yukey/src/yk-live-list.ts index 849a2b5..7fddad2 100644 --- a/packages/yukey/src/yk-live-list.ts +++ b/packages/yukey/src/yk-live-list.ts @@ -1,6 +1,8 @@ // yk-live-list: a keyed live list whose rows vanish safely under a pointer. -// Data replaces wholesale per tick (5Hz here); the owner supplies keying and -// rendering, this component owns row lifecycle and click safety: +// The public contract is on the YkLiveList class doc and its properties +// (pointerSafety carries the two click-safety strategies); the row phases owners see are LiveListPhase. +// +// The row lifecycle: // // enter ──► live ──────────► committed ────► collapsing ──► removed // (grow-in: (content updates (item left the (grid-rows (DOM dropped; @@ -8,93 +10,10 @@ // ▲ dimmed by owner, motion) lingers briefly) // └── resurrection ──┘ still clickable) // -// The safety goal, in one sentence: clicks must not land on surprising targets. -// Two strategies answer it -- the `pointerSafety` property picks one: -// -// - "click-guard" (the default): motion proceeds under the pointer. Hovering -// defers nothing -- insertions appear immediately, expired holds collapse on -// their own schedule -- and the protection is target-scoped and brief: a click -// (or mousedown) is swallowed only when its target row is a MOVING TARGET. -// A row is a moving target iff some geometry animation (entrance, collapse, -// resurrection grow-back) is running, or ended within CLICK_GUARD_MS, whose -// animating row sits at-or-above it: a collapse pulls every row below it -// upward, an entrance pushes them downward, and rows ABOVE the animating row -// have not moved and stay clickable throughout. All of it derives from the -// row state machine at click time (each motion records its top boundary as it -// begins) -- no geometry reads, and nothing runs while the list is idle. -// - "freeze" (the opt-in): geometry holds still under the pointer instead. -// While the pointer is inside the host, insertions wait as invisible "pending" -// rows and expired holds wait for pointer-leave; content-only updates keep -// flowing (ETAs tick under the cursor; nothing moves). A released backlog of -// deferred collapses plays out oldest-expiry-first, one RELEASE_STAGGER_MS -// beat apart -- each exit narrates itself, never one batch. What motion does -// happen (a pointer arriving mid-collapse) is covered by an interaction -// lockout (pointer-events: none on the item container) plus `graceMs` after -// the last motion ends. For surfaces that want quiescence under the pointer; -// the price is that the world visibly waits for the mouse. -// -// In BOTH modes the data is never deferred: a removed item ghosts immediately, -// an inserted item enters the row state immediately. Only geometry (freeze) or -// click delivery (click-guard) bends; the model acts as if no animation existed. -// -// The remaining rules, mode-independent: -// - The committed hold is never guarded or locked: a stationary row is a safe -// row, and selecting a just-done ship is still correct. -// - Display order: existing rows never reorder (a DOM move kills an in-flight CSS -// transition); new keys splice in next to their nearest already-displayed data -// neighbor. Owners must feed a stable sort for that to match the data order. -// - resetKey change (station switch, epoch change) drops all state instantly: -// no farewell parade of the old identity's rows. The first-ever populate renders -// the same way -- rows appear live, no enter animation, no lockout. -// -// Megacolumn mode (the static `megacolumn` attribute, set in markup): the list -// composes with the megacolumn focus model (megacolumn-controller.ts holds the -// DOM contract). Rows render into the OWNER's shadow tree (light DOM -- the -// controller's scan and aria-activedescendant cannot cross shadow boundaries), -// and this component stamps row identity itself, per phase: -// -// - live/entering/committed rows carry data-mc-row="<key>": navigation stops. -// Ghosts included -- a stationary ghost is safe and stays actionable for the -// keyboard exactly as it stays clickable for the mouse; its aux control is -// gone from the rendered content (renderItem's committed variant drops the x), -// so hasAux turns false in the scan by itself. -// - pending/collapsing rows are aria-hidden="true": zero-height, closed to -// sight -- and that one attribute is also what the controller's scan skips, -// so they are never navigation stops. Staying registered keeps every -// wrapper a row (widgets inside rely on a [data-mc-row] ancestor). -// When a focused ghost collapses, the machine's vanished-row fallback moves -// focus and the controller's churn lockout covers the move. -// - everything INSIDE a row stays the owner's renderItem business: -// data-mc-main/aux/input, data-info, hotkey markings, yk-buttons. -// -// The keyboard's version of the pointer story is the KEY-ACTIVITY PIN (both -// pointerSafety modes; the keyboard problem is the same in each): while keys -// are active in the ancestor [data-mc-container] (decaying `keyPinMs` after the -// last one), the FOCUSED row's expired hold is deferred -- the row the user is -// aiming at cannot collapse away mid-burst. Only the focused row's: a pointer -// aims at positions, but the keyboard aims by identity, and other rows' motion -// cannot invalidate that aim -- so unfocused ghosts collapse on their natural -// schedule (removals staged at different moments exit at different moments), -// and when the aim moves off a deferred ghost, the next keydown releases it. -// For the same reason the key pin never defers insertions: a keystroke that -// prepends a row must see it appear immediately. In freeze mode the hover pin -// and the key pin may hold the same ghost; it collapses when the last releases. -// In click-guard mode the list also swallows mousedowns on moving-target rows -// before they bubble to the megacolumn controller's focus-sync (a focus-sync -// onto a moving target is the same race as a click; the guard lives here -// because the list owns its own physics). -// After any self-driven re-render (phase timers fire outside host renders), the -// list dispatches `megacolumn-rescan` so the controller re-reads the rows. -// Mega mode has no shadow root, hence no slots: the empty state is the optional -// renderEmpty render-prop (which shadow mode also honors, over its default slot), -// and itemStyles is refused (the owner's own stylesheet reaches light-DOM rows). -// The row-lifecycle styles are adopted into the owner's root once, all their -// class names yk-ll- prefixed so nothing collides in the shared tree. -// // The row state machine lives here in component state, outside the template; -// render() is a pure map over it (keyed repeat() keeps per-row DOM identity across -// re-renders, so CSS transitions run undisturbed). Animation is pure CSS; timers -// only advance phases. Design per the vanishing-rows study (2026-08-20). +// render() is a pure map over it (keyed repeat() keeps per-row DOM identity across re-renders, so CSS transitions run undisturbed). +// Animation is pure CSS; timers only advance phases. +// Design per the vanishing-rows study (2026-08-20). import type { CSSResult, PropertyValues, TemplateResult } from "lit"; import { adoptStyles, css, html, LitElement, nothing } from "lit"; @@ -103,24 +22,32 @@ import { classMap } from "lit/directives/class-map.js"; import { repeat } from "lit/directives/repeat.js"; import { adoptIntoRoot } from "./adopt-into-root.js"; -// The phase vocabulary owners see: renderItem() renders the committed ("✓ done") -// variant itself -- this component only guarantees geometry and click-safety -// semantics. Composition note: morph ONE element's content across the phase -// flip rather than swapping whole templates. An acknowledgment visual on a -// control (a press flash on the x whose activation removed the row) can only -// survive into the committed render if the flashed element itself survives it; -// a per-phase template swap destroys the very control being acknowledged. +/** + * LiveListPhase is the phase vocabulary owners see: + * renderItem() renders the committed ("✓ done") variant itself -- + * this component only guarantees geometry and click-safety semantics. + * Composition note: morph one element's content across the phase flip rather than swapping whole templates. + * An acknowledgment visual on a control (a press flash on the x whose activation removed the row) + * can only survive into the committed render if the flashed element itself survives it; + * a per-phase template swap destroys the very control being acknowledged. + */ export type LiveListPhase = "live" | "committed"; +/** + * ItemClickDetail is the detail of an `item-click` event (bubbling, composed), + * dispatched when a click actually lands on a row: its key, its last-known item, and its {@link LiveListPhase}. + */ export interface ItemClickDetail<T> { key: string; item: T; phase: LiveListPhase; } -// The row-lifecycle styles, shared by both render modes. Class names carry an -// yk-ll- prefix because mega mode renders into the owner's tree, where bare -// names like "closed" would be collision bait. +/** + * lifecycleStyles is the row-lifecycle styles, shared by both render modes. + * Class names carry an yk-ll- prefix because mega mode renders into the owner's tree, + * where bare names like "closed" would be collision bait. + */ const lifecycleStyles = css` /* The lockout class only ever renders in freeze mode; click-guard swallows stale clicks per target instead of disabling the pointer wholesale. */ @@ -166,8 +93,11 @@ const lifecycleStyles = css` } `; -// The mega-mode sheet: the lifecycle styles plus the host display rule that -// :host would otherwise provide, adopted into the OWNER's root (once per root). +/** + * megaModeStyles is the mega-mode sheet: + * the lifecycle styles plus the host display rule that :host would otherwise provide, + * adopted into the owner's root (once per root). + */ const megaModeStyles = css` yk-live-list { display: block; @@ -175,27 +105,36 @@ const megaModeStyles = css` ${lifecycleStyles} `; -// Internal row phases. "pending" is an insertion deferred by freeze mode's -// hover pin: rendered closed (0fr, invisible, aria-hidden) so it holds its -// display position, shown only when the pointer leaves. +/** + * RowPhase is the internal row phases (the file header has the lifecycle diagram). + * "pending" is an insertion deferred by freeze mode's hover pin: + * rendered closed (0fr, invisible, aria-hidden) so it holds its display position, shown only when the pointer leaves. + */ type RowPhase = "pending" | "entering" | "live" | "committed" | "collapsing"; -// Spacing between deferred collapses when a release lets a backlog go: enough -// separation that each exit reads as its own event, short enough that a backlog -// of several rows still clears in well under a second. +/** + * RELEASE_STAGGER_MS is the spacing between deferred collapses when a release lets a backlog go: + * enough separation that each exit reads as its own event, + * short enough that a backlog of several rows still clears in well under a second. + */ const RELEASE_STAGGER_MS = 80; -// How long a row stays a swallowed click target after the motion that moved it -// ends (click-guard mode). Short on purpose: the guard covers a click already -// in flight toward a target that moved; anything much longer reads as lag. +/** + * CLICK_GUARD_MS is how long a row stays a swallowed click target after the motion that moved it ends (click-guard mode). + * Short on purpose: the guard covers a click already in flight toward a target that moved; + * anything much longer reads as lag. + */ const CLICK_GUARD_MS = 50; -// One geometry animation's guard record (click-guard mode): rows at-or-below -// the animating row are moving targets until `until` (the motion's duration plus -// the CLICK_GUARD_MS tail). While the animating row is displayed, movedKey names -// it; once it is removed from display (a finished collapse), anchorKey names the -// display neighbor that was just above it -- the topmost row that did NOT move -- -// and null means the motion happened at the very top, so every row moved. +/** + * GuardEntry is one geometry animation's guard record (click-guard mode): + * rows at-or-below the animating row are moving targets until `until` + * (the motion's duration plus the {@link CLICK_GUARD_MS} tail). + * While the animating row is displayed, movedKey names it; + * once it is removed from display (a finished collapse), + * anchorKey names the display neighbor that was just above it -- the topmost row that did not move -- + * and null means the motion happened at the very top, so every row moved. + */ interface GuardEntry { movedKey: string | null; anchorKey: string | null; @@ -204,19 +143,86 @@ interface GuardEntry { interface RowState<T> { key: string; - item: T; // last-known content; frozen at commit time for ghosts + + /** item is the last-known content; frozen at commit time for ghosts. */ + item: T; phase: RowPhase; - // Entered via the grow-in animation. Kept for the row's whole life: removing the - // animation class mid-play would snap the geometry, and keeping it after is inert. + + /** + * grew is whether the row entered via the grow-in animation. + * Kept for the row's whole life: + * removing the animation class mid-play would snap the geometry, and keeping it after is inert. + */ grew: boolean; - // The hold timer fired while a pin deferred the collapse; releaseDeferred plays - // it out once no pin (hover: any row; key: the focused row) still applies. + + /** + * holdExpired is whether the hold timer fired while a pin deferred the collapse; + * releaseDeferred plays it out once no pin (hover: any row; key: the focused row) still applies. + */ holdExpired: boolean; - // Order the holds expired in, for the staggered release (assigned at first expiry). + + /** holdExpiredSeq is the order the holds expired in, for the staggered release (assigned at first expiry). */ holdExpiredSeq: number; - timer: number | undefined; // the phase-advance timer; at most one per row + + /** timer is the phase-advance timer; at most one per row. */ + timer: number | undefined; } +/** + * YkLiveList is a keyed live list whose rows vanish safely under a pointer. + * Data replaces wholesale per tick (5Hz here); + * the owner supplies keying and rendering, this component owns row lifecycle and click safety. + * The safety goal, in one sentence: clicks must not land on surprising targets. + * Two strategies answer it -- the {@link pointerSafety} property picks one. + * + * The remaining rules, mode-independent: + * - The committed hold is never guarded or locked: + * a stationary row is a safe row, and selecting a just-done ship is still correct. + * - Display order: existing rows never reorder (a DOM move kills an in-flight CSS transition); + * new keys splice in next to their nearest already-displayed data neighbor. + * Owners must feed a stable sort for that to match the data order. + * - {@link resetKey} change drops all state instantly. + * + * Megacolumn mode (the static `megacolumn` attribute, set in markup): + * the list composes with the megacolumn focus model (`MegacolumnController` holds the DOM contract). + * Rows render into the owner's shadow tree + * (light DOM -- the controller's scan and aria-activedescendant cannot cross shadow boundaries), + * and this component stamps row identity itself, per phase: + * + * - live/entering/committed rows carry `data-mc-row="<key>"`: navigation stops. + * Ghosts included -- + * a stationary ghost is safe and stays actionable for the keyboard exactly as it stays clickable for the mouse; + * its aux control is gone from the rendered content (renderItem's committed variant drops the x), + * so hasAux turns false in the scan by itself. + * - pending/collapsing rows are aria-hidden="true": zero-height, closed to sight -- + * and that one attribute is also what the controller's scan skips, so they are never navigation stops. + * Staying registered keeps every wrapper a row (widgets inside rely on a [data-mc-row] ancestor). + * When a focused ghost collapses, + * the machine's vanished-row fallback moves focus and the controller's churn lockout covers the move. + * - everything inside a row stays the owner's renderItem business: + * data-mc-main/aux/input, data-info, hotkey markings, yk-buttons. + * + * The keyboard's version of the pointer story is the key-activity pin + * (both pointerSafety modes; the keyboard problem is the same in each): + * while keys are active in the ancestor [data-mc-container] (decaying {@link keyPinMs} after the last one), + * the focused row's expired hold is deferred -- the row the user is aiming at cannot collapse away mid-burst. + * Only the focused row's: a pointer aims at positions, but the keyboard aims by identity, + * and other rows' motion cannot invalidate that aim -- + * so unfocused ghosts collapse on their natural schedule (removals staged at different moments exit at different moments), + * and when the aim moves off a deferred ghost, the next keydown releases it. + * For the same reason the key pin never defers insertions: a keystroke that prepends a row must see it appear immediately. + * In freeze mode the hover pin and the key pin may hold the same ghost; it collapses when the last releases. + * In click-guard mode the list also swallows mousedowns on moving-target rows + * before they bubble to the megacolumn controller's focus-sync + * (a focus-sync onto a moving target is the same race as a click; the guard lives here because the list owns its own physics). + * After any self-driven re-render (phase timers fire outside host renders), + * the list dispatches `megacolumn-rescan` so the controller re-reads the rows. + * Mega mode has no shadow root, hence no slots: + * the empty state is the optional {@link renderEmpty} render-prop (which shadow mode also honors, over its default slot), + * and {@link itemStyles} is refused (the owner's own stylesheet reaches light-DOM rows). + * The row-lifecycle styles are adopted into the owner's root once, + * all their class names yk-ll- prefixed so nothing collides in the shared tree. + */ @customElement("yk-live-list") export class YkLiveList<T = unknown> extends LitElement { static styles = [ @@ -228,69 +234,140 @@ export class YkLiveList<T = unknown> extends LitElement { lifecycleStyles, ]; + /** items is the data, replaced wholesale per tick; rows are keyed from it by {@link keyOf}. */ @property({ attribute: false }) items: T[] = []; + + /** keyOf returns an item's stable key: row identity across ticks. */ @property({ attribute: false }) keyOf!: (item: T) => string; + + /** renderItem renders a row's content for its {@link LiveListPhase}; the committed variant is the owner's to draw. */ @property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; - // The empty state, rendered when no row is visible. In shadow mode the default - // slot alternative still works; mega mode has no shadow root, so this render-prop - // is the only way there. + + /** + * renderEmpty is the empty state, rendered when no row is visible. + * In shadow mode the default slot alternative still works; + * mega mode has no shadow root, so this render-prop is the only way there. + */ @property({ attribute: false }) renderEmpty?: () => TemplateResult; - // Owner-supplied styles for rendered items, adopted into this shadow root - // (the owner's own stylesheet can't reach in here). Shadow mode only: - // mega-mode rows live in the owner's tree, already inside its stylesheet's reach. + + /** + * itemStyles is owner-supplied styles for rendered items, adopted into this shadow root + * (the owner's own stylesheet can't reach in here). + * Shadow mode only: mega-mode rows live in the owner's tree, already inside its stylesheet's reach. + */ @property({ attribute: false }) itemStyles?: CSSResult; - // Identity of the data source. When it changes, all row state drops and the list - // renders fresh -- no exit animations for the old identity's rows. + + /** + * resetKey is the identity of the data source. + * When it changes (station switch, epoch change), all row state drops and the list renders fresh -- + * no exit animations for the old identity's rows. + * The first-ever populate renders the same way -- rows appear live, no enter animation, no lockout. + */ @property() resetKey = ""; - // Which strategy answers the click-safety goal (see the header). "click-guard" - // (the default): motion proceeds and stale clicks on moving-target rows are - // swallowed. "freeze": geometry defers while the pointer is inside the host. - // Runtime-togglable: a host may raise "freeze" as a quasi-lock and drop it again; - // leaving freeze releases whatever the hover pin was deferring. + + /** + * pointerSafety (attribute `pointer-safety`) is which strategy answers the click-safety goal: + * clicks must not land on surprising targets. + * + * - "click-guard" (the default): motion proceeds under the pointer. + * Hovering defers nothing -- insertions appear immediately, expired holds collapse on their own schedule -- + * and the protection is target-scoped and brief: + * a click (or mousedown) is swallowed only when its target row is a moving target. + * A row is a moving target iff some geometry animation (entrance, collapse, resurrection grow-back) is running, + * or ended within CLICK_GUARD_MS, whose animating row sits at-or-above it: + * a collapse pulls every row below it upward, an entrance pushes them downward, + * and rows above the animating row have not moved and stay clickable throughout. + * All of it derives from the row state machine at click time (each motion records its top boundary as it begins) -- + * no geometry reads, and nothing runs while the list is idle. + * - "freeze" (the opt-in): geometry holds still under the pointer instead. + * While the pointer is inside the host, insertions wait as invisible "pending" rows + * and expired holds wait for pointer-leave; + * content-only updates keep flowing (ETAs tick under the cursor; nothing moves). + * A released backlog of deferred collapses plays out oldest-expiry-first, one RELEASE_STAGGER_MS beat apart -- + * each exit narrates itself, never one batch. + * What motion does happen (a pointer arriving mid-collapse) is covered by an interaction lockout + * (pointer-events: none on the item container) plus {@link graceMs} after the last motion ends. + * For surfaces that want quiescence under the pointer; the price is that the world visibly waits for the mouse. + * + * In both modes the data is never deferred: + * a removed item ghosts immediately, an inserted item enters the row state immediately. + * Only geometry (freeze) or click delivery (click-guard) bends; the model acts as if no animation existed. + * + * Runtime-togglable: a host may raise "freeze" as a quasi-lock and drop it again; + * leaving freeze releases whatever the hover pin was deferring. + */ @property({ attribute: "pointer-safety" }) pointerSafety: "click-guard" | "freeze" = "click-guard"; - // Timing knobs, defaulted per the vanishing-rows study. - // (graceMs is freeze-mode machinery: how long the pointer-events lockout - // outlives the last motion. Click-guard's tail is CLICK_GUARD_MS, a constant: - // it is a click-race window, not a feel knob.) + + /** + * holdMs is the committed hold: how long a removed item's row stays, full size and clickable, before it collapses. + * The timing knobs (holdMs, collapseMs, graceMs, enterMs) are defaulted per the vanishing-rows study. + */ @property({ type: Number }) holdMs = 1200; + + /** collapseMs is the collapse animation's duration (`--yk-live-list-collapse`). */ @property({ type: Number }) collapseMs = 300; + + /** + * graceMs is freeze-mode machinery: how long the pointer-events lockout outlives the last motion. + * (Click-guard's tail is CLICK_GUARD_MS, a constant: it is a click-race window, not a feel knob.) + */ @property({ type: Number }) graceMs = 200; + + /** enterMs is the entrance animation's duration (`--yk-live-list-enter`). */ @property({ type: Number }) enterMs = 300; - // How long the key-activity pin outlives the last keydown (mega mode; see header). - // Short: it bridges the gaps inside one burst of typing, and the focused row's - // protection should die almost as soon as the fingers stop. + + /** + * keyPinMs is how long the key-activity pin outlives the last keydown (mega mode; see {@link YkLiveList}). + * Short: it bridges the gaps inside one burst of typing, + * and the focused row's protection should die almost as soon as the fingers stop. + */ @property({ type: Number }) keyPinMs = 200; - // Row state, insertion-ordered (= display order). Deliberately not @state: - // mutations call requestUpdate explicitly, and items changes re-render anyway. + /** + * rows is the row state, insertion-ordered (= display order). + * Deliberately not @state: mutations call requestUpdate explicitly, and items changes re-render anyway. + */ private rows = new Map<string, RowState<T>>(); - private motionCount = 0; // geometry animations in flight + + /** motionCount is the geometry animations in flight. */ + private motionCount = 0; private graceTimer: number | undefined; private pinned = false; private hasReconciled = false; - private expirySeq = 0; // orders deferred holds for the staggered release - // The click guard's motion records (click-guard mode; see isMovingTarget). - // Appended when a motion begins, pruned when consulted: no timers, no idle work. + + /** expirySeq orders deferred holds for the staggered release. */ + private expirySeq = 0; + + /** + * guards is the click guard's motion records (click-guard mode; see {@link isMovingTarget}). + * Appended when a motion begins, pruned when consulted: no timers, no idle work. + */ private guards: GuardEntry[] = []; - // Bumped on reset/disconnect. Motion-end timers are anonymous (self-paired with - // their motion start), so they check the epoch instead of being cleared by handle. + + /** + * epoch is bumped on reset/disconnect. + * Motion-end timers are anonymous (self-paired with their motion start), + * so they check the epoch instead of being cleared by handle. + */ private epoch = 0; - // Mega mode plumbing: fixed at createRenderRoot (the attribute is static markup); - // keyPinned is the key-activity pin, its timer the decay. + + /** mega is mega mode plumbing: fixed at createRenderRoot (the attribute is static markup). */ private mega = false; private megaContainer: HTMLElement | null = null; + + /** keyPinned is the key-activity pin, its timer the decay. */ private keyPinned = false; private keyPinTimer: number | undefined; @@ -314,10 +391,12 @@ export class YkLiveList<T = unknown> extends LitElement { this.addEventListener("mousedown", this.onGuardedPointer, { capture: true }); } - // Mega mode renders into the light DOM: the megacolumn scan and - // aria-activedescendant cannot cross a shadow boundary, so the rows must live - // in the owner's tree. Decided here (once, before the first render) off the - // static attribute; toggling it later is not a thing. + /** + * createRenderRoot decides mega mode. + * Mega mode renders into the light DOM: + * the megacolumn scan and aria-activedescendant cannot cross a shadow boundary, so the rows must live in the owner's tree. + * Decided here (once, before the first render) off the static attribute; toggling it later is not a thing. + */ protected override createRenderRoot(): HTMLElement | DocumentFragment { if (!this.hasAttribute("megacolumn")) return super.createRenderRoot(); this.mega = true; @@ -391,7 +470,7 @@ export class YkLiveList<T = unknown> extends LitElement { if (this.mega) this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); } - // Whether freeze mode governs the pointer story right now (see the header). + /** frozen is whether freeze mode governs the pointer story right now (see {@link pointerSafety}). */ private get frozen(): boolean { return this.pointerSafety === "freeze"; } @@ -536,18 +615,21 @@ export class YkLiveList<T = unknown> extends LitElement { } // --- lockout accounting: a counter of motions in flight, plus a grace linger --- - // (The lockout only renders in freeze mode; the counter itself is mode-neutral - // motion bookkeeping, and the guard entries recorded here are what click-guard - // mode consults instead.) + /** + * locked is whether a motion is in flight or its grace linger is still running. + * (The lockout only renders in freeze mode; the counter itself is mode-neutral motion bookkeeping, + * and the guard entries recorded here are what click-guard mode consults instead.) + */ private get locked(): boolean { return this.motionCount > 0 || this.graceTimer !== undefined; } - // Every motion start schedules its own end: the pairing survives any phase - // transition happening in between (e.g. a commit landing mid-enter). The - // motion's guard entry is recorded here too -- the one place every geometry - // animation already passes through. + /** + * beginMotion counts a geometry animation in, and every motion start schedules its own end: + * the pairing survives any phase transition happening in between (e.g. a commit landing mid-enter). + * The motion's guard entry is recorded here too -- the one place every geometry animation already passes through. + */ private beginMotion(row: RowState<T>, durationMs: number): void { const now = performance.now(); this.guards = this.guards.filter((g) => g.until > now); @@ -585,8 +667,11 @@ export class YkLiveList<T = unknown> extends LitElement { // --- pointer handling: presence tracking, and the mode-split safety story --- - // Pointer presence is tracked in both modes (cheap, and pointerSafety may flip - // mid-hover); only freeze mode DOES anything with it (see hoverPinned). + /** + * onPointerEnter and onPointerLeave track pointer presence, registered on the host in the constructor. + * Pointer presence is tracked in both modes (cheap, and pointerSafety may flip mid-hover); + * only freeze mode does anything with it (see {@link hoverPinned}). + */ private onPointerEnter = (): void => { this.pinned = true; }; @@ -596,18 +681,22 @@ export class YkLiveList<T = unknown> extends LitElement { this.releaseDeferred(); }; - // The hover pin: freeze mode's global geometry deferral. In click-guard mode - // a hovering pointer defers nothing. + /** + * hoverPinned is the hover pin: freeze mode's global geometry deferral. + * In click-guard mode a hovering pointer defers nothing. + */ private get hoverPinned(): boolean { return this.pinned && this.frozen; } - // The click guard (click-guard mode): swallow a pointer event whose target row - // is a moving target, in the capture phase, before any consumer -- row content - // handlers, this list's own @click, and (mega mode) the megacolumn controller's - // mousedown focus-sync on the ancestor container -- can act on it. The target - // comes off composedPath: in shadow mode the host listener sees e.target - // retargeted to the host itself. + /** + * onGuardedPointer is the click guard (click-guard mode), + * registered as the host's capture-phase click and mousedown listener in the constructor: + * swallow a pointer event whose target row is a moving target, in the capture phase, before any consumer -- + * row content handlers, this list's own @click, + * and (mega mode) the megacolumn controller's mousedown focus-sync on the ancestor container -- can act on it. + * The target comes off composedPath: in shadow mode the host listener sees e.target retargeted to the host itself. + */ private onGuardedPointer = (e: Event): void => { if (this.frozen) return; // freeze mode's lockout owns this story instead const target = e.composedPath()[0]; @@ -620,12 +709,14 @@ export class YkLiveList<T = unknown> extends LitElement { e.preventDefault(); }; - // Whether a click landing on this row right now would land somewhere surprising: - // some motion window (live, or ended within its CLICK_GUARD_MS tail) has its - // animating row at-or-above this row in display order. A collapse pulls every - // row below it upward and an entrance pushes them downward, so at-or-below the - // animating row means displaced; rows above it never moved. Pure state reads, - // only ever executed inside a pointer event's dispatch. + /** + * isMovingTarget is whether a click landing on this row right now would land somewhere surprising: + * some motion window (live, or ended within its {@link CLICK_GUARD_MS} tail) + * has its animating row at-or-above this row in display order. + * A collapse pulls every row below it upward and an entrance pushes them downward, + * so at-or-below the animating row means displaced; rows above it never moved. + * Pure state reads, only ever executed inside a pointer event's dispatch. + */ private isMovingTarget(key: string): boolean { const now = performance.now(); this.guards = this.guards.filter((g) => g.until > now); @@ -645,13 +736,16 @@ export class YkLiveList<T = unknown> extends LitElement { } // --- the key-activity pin (mega mode; both pointerSafety modes) --- - // While keys are active (decaying keyPinMs after the last), the FOCUSED row's - // expired hold defers: the row the user is aiming at cannot collapse away - // mid-burst. Only that row's -- the keyboard aims by identity, so other - // ghosts collapse on their natural schedule -- and insertions are never - // deferred (see the header: a keystroke that prepends a row must show it - // immediately). + /** + * onKeyActivity arms the key-activity pin, + * registered as the megacolumn container's keydown listener in connectedCallback. + * While keys are active (decaying {@link keyPinMs} after the last), the focused row's expired hold defers: + * the row the user is aiming at cannot collapse away mid-burst. + * Only that row's -- the keyboard aims by identity, so other ghosts collapse on their natural schedule -- + * and insertions are never deferred + * (see {@link YkLiveList}: a keystroke that prepends a row must show it immediately). + */ private onKeyActivity = (): void => { this.keyPinned = true; window.clearTimeout(this.keyPinTimer); @@ -669,12 +763,14 @@ export class YkLiveList<T = unknown> extends LitElement { this.releaseDeferred(); }; - // One pin released (or an aim moved): play out whatever motion no pin covers - // anymore. A backlog of deferred collapses is released in hold-expiry order, - // RELEASE_STAGGER_MS apart -- the oldest goes now, the rest are scheduled on - // their row timers (which resurrection already clears). A scheduled release - // re-checks the pins when it fires: a pin re-armed mid-stagger re-defers the - // row (holdExpired stays set) for the release after that. + /** + * releaseDeferred runs when one pin released (or an aim moved): + * play out whatever motion no pin covers anymore. + * A backlog of deferred collapses is released in hold-expiry order, {@link RELEASE_STAGGER_MS} apart -- + * the oldest goes now, the rest are scheduled on their row timers (which resurrection already clears). + * A scheduled release re-checks the pins when it fires: + * a pin re-armed mid-stagger re-defers the row (holdExpired stays set) for the release after that. + */ private releaseDeferred(): void { let changed = false; const backlog: RowState<T>[] = []; @@ -707,19 +803,23 @@ export class YkLiveList<T = unknown> extends LitElement { if (changed) this.requestUpdate(); } - // Whether a pin defers this row's collapse right now: the hover pin (freeze - // mode only) defers every row (positions demand global stillness), the key pin - // only the focused one (identity aim; see the header). The one predicate all - // deferral and release decisions share. + /** + * collapseDeferred is whether a pin defers this row's collapse right now: + * the hover pin (freeze mode only) defers every row (positions demand global stillness), + * the key pin only the focused one (identity aim; see {@link YkLiveList}). + * The one predicate all deferral and release decisions share. + */ private collapseDeferred(row: RowState<T>): boolean { return this.hoverPinned || (this.keyPinned && this.isRowFocused(row)); } - // Whether the megacolumn's logical focus sits on this row. The controller - // stamps mc-row-focused on the focused row element, which in mega mode is this - // list's own rowslot (the [data-mc-row] carrier) -- read here, never written. - // Read from the DOM rather than via aria-activedescendant so this stays - // independent of the controller's id-derivation rules. + /** + * isRowFocused is whether the megacolumn's logical focus sits on this row. + * The controller stamps mc-row-focused on the focused row element, + * which in mega mode is this list's own rowslot (the [data-mc-row] carrier) -- read here, never written. + * Read from the DOM rather than via aria-activedescendant + * so this stays independent of the controller's id-derivation rules. + */ private isRowFocused(row: RowState<T>): boolean { const slot = this.renderRoot.querySelector(`.yk-ll-rowslot[data-key="${CSS.escape(row.key)}"]`); return slot?.classList.contains("mc-row-focused") ?? false; @@ -804,9 +904,12 @@ export class YkLiveList<T = unknown> extends LitElement { </div>`; } - // Fires only when a click actually lands: closed rows are zero-height, and a - // click whose target row was displaced by in-flight motion was already stopped - // by the mode in force (click-guard's capture handler, or freeze's lockout). + /** + * onRowClick dispatches the `item-click` event ({@link ItemClickDetail}). + * Fires only when a click actually lands: + * closed rows are zero-height, and a click whose target row was displaced by in-flight motion + * was already stopped by the mode in force (click-guard's capture handler, or freeze's lockout). + */ private onRowClick(row: RowState<T>): void { const phase: LiveListPhase = row.phase === "committed" ? "committed" : "live"; this.dispatchEvent(