Merge branch 'prepublish-cleanup'
commit f60e200ebc526cb695a3ebac4226671f7b82618f · parent 30239003e0 · parent 7d6001756d
· browse this tree
warpfork, 2026-10-24 12:53:41 UTC
diff --git a/docs/hacking/README.md b/docs/hacking/README.md index 32fd096..cc20ceb 100644 --- a/docs/hacking/README.md +++ b/docs/hacking/README.md @@ -36,13 +36,10 @@ This is an npm workspace. The root `package.json` is private and fans out to `p Things worth knowing -------------------- -- The published packages ship `dist/` (`.js` + `.d.ts` + maps), not `.ts`. +- The published packages ship `dist/` (`.js` + `.d.ts`), not `.ts`. Lit's decorators require `experimentalDecorators` and `useDefineForClassFields: false`; compiling here means consumers don't have to match those flags. Consequence: anything consuming yukey via a `file:` dependency must `make build` here first. -- `packages/e2e` imports `yukey-testkit` through the workspace symlink. - The testkit's `exports` map carries a `yukey-source` condition pointing at `src/*.ts`; - the e2e package's tsconfig (`customConditions`) and its node invocation (`--conditions=yukey-source`) both select it, - so typechecking and running the suite never require a testkit build. - The default condition still resolves to `dist/`, which is what every outside consumer gets. +- `packages/e2e` imports `yukey-testkit` through the workspace symlink and resolves to its `dist/`, like any outside consumer would. + The e2e package's `check` and `test:e2e` scripts build the testkit first, so this never needs remembering. - The e2e suite needs a Chromium. It defaults to `/headless-shell/headless-shell`; `YUKEY_E2E_CHROME` overrides. Chrome is launched with `--no-sandbox` (fine for headless-shell in a container; worth knowing if you point it at a desktop Chrome). diff --git a/packages/e2e/package.json b/packages/e2e/package.json index ce82995..cd6a712 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -4,8 +4,8 @@ "description": "yukey's own end-to-end suite: serves the gallery bundle, launches a headless Chrome, drives the gallery entries with trusted input", "type": "module", "scripts": { - "check": "tsc --noEmit", - "test:e2e": "node --conditions=yukey-source main.ts" + "check": "npm run build --workspace yukey-testkit && tsc --noEmit", + "test:e2e": "npm run build --workspace yukey-testkit && node main.ts" }, "dependencies": { "yukey-testkit": "*" diff --git a/packages/e2e/tsconfig.json b/packages/e2e/tsconfig.json index 45f5612..6df2cfe 100644 --- a/packages/e2e/tsconfig.json +++ b/packages/e2e/tsconfig.json @@ -12,10 +12,7 @@ "strict": true, "noUncheckedIndexedAccess": true, "allowImportingTsExtensions": true, - "noEmit": true, - "customConditions": [ - "yukey-source" - ] + "noEmit": true }, "include": [ "." diff --git a/packages/yukey-testkit/package.json b/packages/yukey-testkit/package.json index 1795f15..043d1f4 100644 --- a/packages/yukey-testkit/package.json +++ b/packages/yukey-testkit/package.json @@ -6,17 +6,14 @@ "type": "module", "exports": { "./cdp": { - "yukey-source": "./src/cdp.ts", "types": "./dist/cdp.d.ts", "default": "./dist/cdp.js" }, "./checks": { - "yukey-source": "./src/checks.ts", "types": "./dist/checks.d.ts", "default": "./dist/checks.js" }, "./sleep": { - "yukey-source": "./src/sleep.ts", "types": "./dist/sleep.d.ts", "default": "./dist/sleep.js" } diff --git a/packages/yukey-testkit/tsconfig.build.json b/packages/yukey-testkit/tsconfig.build.json index 30afe55..3ff5e9f 100644 --- a/packages/yukey-testkit/tsconfig.build.json +++ b/packages/yukey-testkit/tsconfig.build.json @@ -4,9 +4,7 @@ "noEmit": false, "rootDir": "src", "outDir": "dist", - "declaration": true, - "declarationMap": true, - "sourceMap": true + "declaration": true }, "include": [ "src" diff --git a/packages/yukey/README.md b/packages/yukey/README.md index d211c2f..0bfbccf 100644 --- a/packages/yukey/README.md +++ b/packages/yukey/README.md @@ -33,7 +33,7 @@ installTokens(); // once, at startup: Components (`yk-button`, `yk-list`, `yk-live-list`, `yk-infopop`) register themselves on import. Mechanisms (`megacolumn-controller`, `tab-cycle`, `focus-stack`, `press-flash`, and so on) are plain exports. -The gallery (`yukey/gallery/main`) is the living documentation: every component, every variant, driven by the real thing. +The gallery (`packages/yukey/src/gallery/` in the repository; not part of the package) is the living documentation: every component, every variant, driven by the real thing. License diff --git a/packages/yukey/package.json b/packages/yukey/package.json index 3f297da..1f317c0 100644 --- a/packages/yukey/package.json +++ b/packages/yukey/package.json @@ -4,30 +4,10 @@ "description": "a ui framework for the keyboard lover: Lit web components where the keyboard is on par with the mouse", "license": "Apache-2.0 OR MIT", "sideEffects": [ - "./src/yk-button.ts", - "./src/yk-infopop.ts", - "./src/yk-list.ts", - "./src/yk-live-list.ts", - "./src/gallery/yk-gallery.ts", - "./src/gallery/main.ts", - "./src/megacolumn.gallery.ts", - "./src/yk-button.gallery.ts", - "./src/yk-infopop.gallery.ts", - "./src/yk-list.gallery.ts", - "./src/yk-live-list.gallery.ts", - "./src/tokens.gallery.ts", "./dist/yk-button.js", "./dist/yk-infopop.js", "./dist/yk-list.js", - "./dist/yk-live-list.js", - "./dist/gallery/yk-gallery.js", - "./dist/gallery/main.js", - "./dist/megacolumn.gallery.js", - "./dist/yk-button.gallery.js", - "./dist/yk-infopop.gallery.js", - "./dist/yk-list.gallery.js", - "./dist/yk-live-list.gallery.js", - "./dist/tokens.gallery.js" + "./dist/yk-live-list.js" ], "type": "module", "exports": { @@ -44,12 +24,12 @@ "LICENSE-MIT" ], "scripts": { - "build": "tsc -p tsconfig.build.json && esbuild gallery=src/gallery/main.ts --bundle --format=esm --outdir=static --sourcemap=inline", + "build": "tsc -p tsconfig.build.json && esbuild gallery=src/gallery/main.ts --bundle --ignore-annotations --format=esm --outdir=static --sourcemap=inline", "check": "tsc --noEmit && vitest run", "test": "vitest run", "test:watch": "vitest", "clean": "rm -rf dist static/gallery.js", - "prepack": "npm run build" + "prepack": "npm run clean && npm run build" }, "devDependencies": { "esbuild": "0.28.2", diff --git a/packages/yukey/src/adopt-into-root.ts b/packages/yukey/src/adopt-into-root.ts index 9955dfb..f8ebaf3 100644 --- a/packages/yukey/src/adopt-into-root.ts +++ b/packages/yukey/src/adopt-into-root.ts @@ -1,17 +1,18 @@ -// Stylesheet adoption for components that render into their OWNER's tree. -// Megacolumn-composed components (yk-live-list mega mode, yk-list) render light-DOM -// rows -- the megacolumn scan and aria-activedescendant cannot cross shadow -// boundaries -- so their styles must reach the owner's root, not a shadow root of -// their own. This appends a sheet to the root's adopted stylesheets, once per root. -// -// By hand, because lit's adoptStyles() REPLACES a root's sheets and the root here -// belongs to the owner. Where constructed stylesheets are unsupported (jsdom), -// fall back to one marked <style> tag -- the same fallback shape lit itself uses; -// `marker` is the attribute name that makes the fallback idempotent (one per -// adopter class, e.g. "data-yk-live-list"). +// Stylesheet adoption for components that render into their owner's tree. import type { CSSResult } from "lit"; +/** + * adoptIntoRoot appends a sheet to the root's adopted stylesheets, once per root. + * Megacolumn-composed components (yk-live-list mega mode, yk-list) render light-DOM rows -- + * the megacolumn scan and aria-activedescendant cannot cross shadow boundaries -- + * so their styles must reach the owner's root, not a shadow root of their own. + * + * By hand, because lit's adoptStyles() replaces a root's sheets and the root here belongs to the owner. + * Where constructed stylesheets are unsupported (jsdom), fall back to one marked `<style>` tag -- + * the same fallback shape lit itself uses; + * `marker` is the attribute name that makes the fallback idempotent (one per adopter class, e.g. "data-yk-live-list"). + */ export function adoptIntoRoot(root: Node, styles: CSSResult, marker: string): void { if (!(root instanceof ShadowRoot || root instanceof Document)) { throw new Error(`adopt-into-root (${marker}): not inside any document or shadow root`); diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts index 4e67aab..de9ec52 100644 --- a/packages/yukey/src/focus-stack.ts +++ b/packages/yukey/src/focus-stack.ts @@ -1,74 +1,33 @@ // The focus stack: the kit's answer to "where does focus go when this closes?" -// (the guidelines' "Focus is a stack" norm). Opening a component that contains -// focus PUSHES the place focus came from; closing it POPS back there; dead -// entries are skipped; the page shell's fallback is the end of the line. -// Focus falling to <body> is always a bug, and this file is the one mechanism -// that prevents it -- page shells must not grow bespoke relocation logic beside it. +// (the guidelines' "Focus is a stack" norm). +// Opening a component that contains focus pushes the place focus came from; closing it pops back there; +// dead entries are skipped; the page shell's fallback is the end of the line. +// Focus falling to <body> is always a bug, and this file is the one mechanism that prevents it -- +// page shells must not grow bespoke relocation logic beside it. // -// The shape mirrors tab containment (tab-cycle.ts): one stack per document, -// installed by the page shell: -// -// installFocusStack(document, () => <the fallback element>); -// -// and components participate through the same per-document accessor: -// -// const stack = focusStackOf(this.ownerDocument); -// -// SURFACES are the stack's vocabulary: a surface is anything that can hold -// focus and open/close (a panel, a card, an anchored overlay). A component -// registers itself as a surface while connected (and unregisters on -// disconnect), naming itself with a stable id: -// -// stack.register({ id: CARD_SURFACE, root: () => this }); -// -// Everything is IDENTITY, never element references: entries record surface -// ids, and root() is re-queried on every use -- a surface that closed and -// reopened is the same journey stop, resolved to whatever element carries the -// id now. A root that is gone or disconnected reads as dead. -// -// The OWNER of a surface's open/close lifecycle makes two calls: -// -// entered(id) right before seating focus in a user-opened surface, -// while focus still sits wherever the user came from. -// Pushes { entered: id, origin: <the innermost registered -// surface holding focus, or null> }. -// closed(id, held) when the surface closes (any reason: Escape, ×, deselect, -// data death). `held` is whether the surface contained the -// focus as the close began -- the owner computes it BEFORE -// the DOM changes (path() is the tool). Retires the -// surface's entry always; routes focus only when held. -// -// The journey rules these calls implement: -// -// replace-not-push entered(id) while id is already the top entry is a -// no-op: lateral moves (selecting ship B while ship A's -// card is open) re-target the surface without burying -// the journey origin. -// one-entry-per-id entered(id) with an older entry buried deeper retires -// that entry first: a surface is one journey stop, and -// the stack stays bounded. -// pop-skips-dead a held close routes to the retired entry's origin if -// it is alive and focusable; otherwise it unwinds to the -// innermost remaining entry's surface (entries whose -// surfaces died are swept first); otherwise the fallback. -// fail-closed routing with nowhere to go throws. The number of -// places focus may land on "nothing" is between zero and -// one in the whole UI, and that one is the page shell's -// fallback surface -- never a silent drop to <body>. -// -// Popping focuses the surface's first tab stop (for a megacolumn host that is -// its one [data-mc-container], whose machine's row memory then restores the -// row -- the stack returns to the right CONTAINER; the machine takes it from -// there). The walk descends the surface's shadow root, so hosts register -// themselves and nothing needs to export inner elements. +// The contract is on the declarations: FocusStack (the per-document shape), FocusSurface (the vocabulary), +// and the entered/closed pair (the journey rules). import { deepActiveElement, deepTabbables } from "./tab-cycle.js"; +/** + * FocusSurface is the stack's vocabulary: + * a surface is anything that can hold focus and open/close (a panel, a card, an anchored overlay). + * A component registers itself as one with {@link FocusStack.register}. + * + * Everything is identity, never element references: + * entries record surface ids, and root() is re-queried on every use -- + * a surface that closed and reopened is the same journey stop, resolved to whatever element carries the id now. + * A root that is gone or disconnected reads as dead. + */ export interface FocusSurface { - // Stable identity, unique per document. Components use their tag name. + /** id is the stable identity, unique per document. Components use their tag name. */ id: string; - // The surface's live root element (usually the registering host itself). - // Re-queried on every use; null or a disconnected element reads as dead. + + /** + * root is the surface's live root element (usually the registering host itself). + * Re-queried on every use; null or a disconnected element reads as dead. + */ root(): HTMLElement | null; } @@ -77,8 +36,10 @@ interface StackEntry { origin: string | null; } -// Whether `node` sits inside `ancestor`, crossing shadow boundaries upward -// (a shadow root's contents are composed-inside its host). +/** + * composedContains is whether `node` sits inside `ancestor`, crossing shadow boundaries upward + * (a shadow root's contents are composed-inside its host). + */ function composedContains(ancestor: Node, node: Node | null): boolean { let cur: Node | null = node; while (cur !== null) { @@ -88,6 +49,15 @@ function composedContains(ancestor: Node, node: Node | null): boolean { return false; } +/** + * FocusStack is one stack per document. + * The shape mirrors tab containment (tab-cycle.ts): + * the page shell installs it with {@link installFocusStack}, + * and components participate through the same per-document accessor, {@link focusStackOf}. + * + * The owner of a surface's open/close lifecycle makes two calls, {@link entered} and {@link closed}; + * the journey rules are documented on them. + */ export class FocusStack { #doc: Document; #surfaces = new Map<string, FocusSurface>(); @@ -98,26 +68,34 @@ export class FocusStack { this.#doc = doc; } - // The end of the line for pops: the page shell's one predictable focus home - // (its root surface). + /** setFallback sets the end of the line for pops: the page shell's one predictable focus home (its root surface). */ setFallback(fallback: () => HTMLElement | null): void { this.#fallback = fallback; } - // The entries, oldest first (introspection for tests and debugging; the - // stack's behavior is entirely through entered/closed). + /** + * entries is the entries, oldest first + * (introspection for tests and debugging; the stack's behavior is entirely through entered/closed). + */ get entries(): readonly StackEntry[] { return this.#entries; } - // Retire every entry, routing nothing: a fresh-world boundary. Page shells - // never need this (their surfaces close through closed()); test rigs that - // bulldoze the DOM between cases do, because a per-document stack outlives - // a document.body.replaceChildren(). + /** + * clear retires every entry, routing nothing: a fresh-world boundary. + * Page shells never need this (their surfaces close through closed()); + * test rigs that bulldoze the DOM between cases do, + * because a per-document stack outlives a document.body.replaceChildren(). + */ clear(): void { this.#entries = []; } + /** + * register adds a surface; a component registers itself while connected (and unregisters on disconnect), + * naming itself with a stable id: `stack.register({ id: CARD_SURFACE, root: () => this })`. + * Registering an id twice throws. + */ register(surface: FocusSurface): void { if (this.#surfaces.has(surface.id)) { throw new Error(`focus-stack: surface "${surface.id}" is already registered`); @@ -129,9 +107,11 @@ export class FocusStack { this.#surfaces.delete(id); } - // The registered surfaces currently containing the deep-active element, - // outermost first. Owners snapshot this BEFORE a DOM change to compute - // `held` for closed() (containment is unknowable after the unmount). + /** + * path is the registered surfaces currently containing the deep-active element, outermost first. + * Owners snapshot this before a DOM change to compute `held` for closed() + * (containment is unknowable after the unmount). + */ path(): string[] { const active = deepActiveElement(this.#doc); if (active === null) return []; @@ -148,8 +128,19 @@ export class FocusStack { return containing.map((c) => c.id); } - // A user-opened surface is about to seat focus: record where focus came from. - // Call while focus still sits at the origin (before the seat moves it). + /** + * entered records where focus came from: a user-opened surface is about to seat focus. + * Call right before seating focus in the surface, while focus still sits wherever the user came from + * (before the seat moves it). + * Pushes { entered: id, origin: the innermost registered surface holding focus, or null }. + * + * The journey rules it implements: + * - replace-not-push: entered(id) while id is already the top entry is a no-op: + * lateral moves (selecting ship B while ship A's card is open) + * re-target the surface without burying the journey origin. + * - one-entry-per-id: entered(id) with an older entry buried deeper retires that entry first: + * a surface is one journey stop, and the stack stays bounded. + */ entered(id: string): void { if (!this.#surfaces.has(id)) { throw new Error(`focus-stack: entered("${id}") but no such surface is registered`); @@ -164,9 +155,27 @@ export class FocusStack { this.#entries.push({ entered: id, origin }); } - // A surface closed. Retires its entry and sweeps entries whose surfaces - // died with it; when the surface held focus, routes focus to the journey's - // nearest live stop (see the header's pop-skips-dead rule). + /** + * closed is the call when a surface closes (any reason: Escape, ×, deselect, data death). + * `held` is whether the surface contained the focus as the close began -- + * the owner computes it before the DOM changes ({@link path} is the tool). + * Retires its entry always, and sweeps entries whose surfaces died with it; + * when the surface held focus, routes focus to the journey's nearest live stop. + * + * The journey rules it implements: + * - pop-skips-dead: a held close routes to the retired entry's origin if it is alive and focusable; + * otherwise it unwinds to the innermost remaining entry's surface (entries whose surfaces died are swept first); + * otherwise the fallback. + * - fail-closed: routing with nowhere to go throws. + * The number of places focus may land on "nothing" is between zero and one in the whole UI, + * and that one is the page shell's fallback surface -- never a silent drop to `<body>`. + * + * Popping focuses the surface's first tab stop + * (for a megacolumn host that is its one [data-mc-container], + * whose machine's row memory then restores the row -- + * the stack returns to the right container; the machine takes it from there). + * The walk descends the surface's shadow root, so hosts register themselves and nothing needs to export inner elements. + */ closed(id: string, held: boolean): void { let retired: StackEntry | undefined; for (let i = this.#entries.length - 1; i >= 0; i--) { @@ -202,8 +211,10 @@ export class FocusStack { return root?.isConnected ? root : null; } - // Seat focus on a surface's first tab stop. False when the surface is dead - // or has no stop to give (routing then unwinds past it). + /** + * #focusSurface seats focus on a surface's first tab stop. + * False when the surface is dead or has no stop to give (routing then unwinds past it). + */ #focusSurface(id: string): boolean { const root = this.#liveRoot(id); if (root === null) return false; @@ -215,11 +226,14 @@ export class FocusStack { } } -// One stack per document, lazily (a WeakSet-of-documents shape, like tab -// containment's): components can participate without caring whether a shell -// installed a fallback yet, and tests get a real stack for free. +/** + * stacks is one stack per document, lazily (a WeakSet-of-documents shape, like tab containment's): + * components can participate without caring whether a shell installed a fallback yet, + * and tests get a real stack for free. + */ const stacks = new WeakMap<Document, FocusStack>(); +/** focusStackOf is the per-document accessor components participate through: `focusStackOf(this.ownerDocument)`. */ export function focusStackOf(doc: Document): FocusStack { let stack = stacks.get(doc); if (stack === undefined) { @@ -229,9 +243,11 @@ export function focusStackOf(doc: Document): FocusStack { return stack; } -// The one call a page shell makes: the document's stack, with the shell's -// fallback wired. Idempotent in the same way tab containment is (repeat -// installs just re-point the fallback). +/** + * installFocusStack is the one call a page shell makes: the document's stack, with the shell's fallback wired: + * `installFocusStack(document, () => <the fallback element>)`. + * Idempotent in the same way tab containment is (repeat installs just re-point the fallback). + */ export function installFocusStack(doc: Document, fallback: () => HTMLElement | null): FocusStack { const stack = focusStackOf(doc); stack.setFallback(fallback); diff --git a/packages/yukey/src/infopop-place.ts b/packages/yukey/src/infopop-place.ts index f8b3880..23bbc54 100644 --- a/packages/yukey/src/infopop-place.ts +++ b/packages/yukey/src/infopop-place.ts @@ -1,15 +1,5 @@ // The infopop placement rule: pure geometry, no DOM types, no measurement. -// Given the anchor's rectangle, the popup's size, and the viewport size, decide -// where the popup goes. yk-infopop.ts feeds it measured values and applies the result. -// -// The rule (deterministic on purpose; simple beats clever for a transient surface): -// -// - Preferred: below the anchor, horizontally centered on it. -// - If below would clip the viewport bottom: flip above. -// - If both sides clip (the popup is taller than either side's room): take the -// roomier side. Only in this degenerate case may the popup overlap the anchor, -// because then there is nowhere it fits at all. -// - X is clamped so the popup stays inside the viewport (with a small margin). +// yk-infopop feeds it measured values and applies the result. export interface Rect { x: number; @@ -29,11 +19,23 @@ export interface Placement { side: "below" | "above"; } -// Gap between the anchor and the popup, and the minimum clearance kept to the -// viewport edges. Exported for the component's stylesheet-independent tests. +/** ANCHOR_GAP is the gap between the anchor and the popup. Exported for the component's stylesheet-independent tests. */ export const ANCHOR_GAP = 6; + +/** VIEWPORT_MARGIN is the minimum clearance kept to the viewport edges. Exported for the component's stylesheet-independent tests. */ export const VIEWPORT_MARGIN = 4; +/** + * placeInfopop decides where the popup goes, + * given the anchor's rectangle, the popup's size, and the viewport size. + * + * The rule (deterministic on purpose; simple beats clever for a transient surface): + * - Preferred: below the anchor, horizontally centered on it. + * - If below would clip the viewport bottom: flip above. + * - If both sides clip (the popup is taller than either side's room): take the roomier side. + * Only in this degenerate case may the popup overlap the anchor, because then there is nowhere it fits at all. + * - X is clamped so the popup stays inside the viewport (with a small margin). + */ export function placeInfopop(anchor: Rect, popup: Size, viewport: Size): Placement { const x = clamp( anchor.x + anchor.w / 2 - popup.w / 2, diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts index 2d1a2d5..69d1061 100644 --- a/packages/yukey/src/megacolumn-controller.ts +++ b/packages/yukey/src/megacolumn-controller.ts @@ -1,222 +1,259 @@ -// The Lit binding layer for the megacolumn navigation model: connects the pure -// state machine (megacolumn-nav.ts) to a component's rendered DOM. This file owns -// every DOM concern the machine deliberately doesn't: the single real focusable, -// event wiring, aria-activedescendant, focus-ring classes, and forwarding keys to -// row widgets. It is a Lit ReactiveController: hostUpdated() re-scans the rendered -// rows after every render, so registration IS rendering and can never drift from it. +// The Lit binding layer for the megacolumn navigation model: +// connects the pure state machine (megacolumn-nav.ts) to a component's rendered DOM. +// This file owns every DOM concern the machine deliberately doesn't: +// the single real focusable, event wiring, aria-activedescendant, focus-ring classes, and forwarding keys to row widgets. +// It is a Lit ReactiveController: hostUpdated() re-scans the rendered rows after every render, +// so registration is rendering and can never drift from it. // -// The DOM contract (data attributes; see megacolumn.gallery.ts for the -// reference implementation): +// The DOM contract and the event contracts are documented on the declarations that carry them: +// MegacolumnController (the attributes it scans), +// MegacolumnKeyDetail (megacolumn-key: forwarded keys, claims, pointer activation), +// MegacolumnFocusChangeDetail (megacolumn-focus-change), +// MegacolumnFocusRequestDetail (megacolumn-focus-request), and MEGACOLUMN_RESCAN_EVENT (megacolumn-rescan). +// Each event's name is an exported constant beside its detail type. // -// [data-mc-container] the megacolumn root, ONE per host component. -// Becomes the single real focusable (tabindex=0 assigned). -// Gets role="listbox" if the author set no role -// (aria-activedescendant only means something on an -// element with a composite role). -// [data-mc-row="<id>"] a row, id stable and unique. Scanned in DOM order; -// DOM order is the display order the machine navigates. -// Gets role="option" if the author set no role -// (override for trees/menus: treeitem, menuitem...). -// Section gaps are simply elements without this attribute. -// aria-hidden="true" on a row (or a wrapper around rows): closed to -// sight, and therefore registered but skipped by -// navigation. One attribute carries both meanings -// so the AT view and the keyboard view cannot -// disagree (see closedToSight below). -// Only for rows that are actually invisible (the -// zero-height churn phases of a live list), never -// for rows that are merely inactive: a visible-but- -// offline row stays a nav stop, so i can reach it -// to ask why, and so the stride count between its -// neighbors doesn't shift with activatability -// (style those with class + aria-disabled instead). -// [data-mc-main] the row's main control (optional; defaults to the row -// element itself). Gets the focus-ring class. -// [data-mc-aux] the row's one aux control (the small-x norm). -// Its presence makes ArrowRight/Left move within the row. -// [data-mc-input] the row's text input. Its presence makes Enter/Space -// enter text-entry mode (real DOM focus dives in; -// tabindex=-1 assigned so it is never a tab stop). -// [data-mc-hotkey="<char>"] a column-scoped hotkey marking on a control inside a row -// (a single a-z0-9 character, case-insensitive; aux controls -// answer to x/Delete and cannot carry markings). Pressing the -// key, when the focused row declines it, MOVES LOGICAL FOCUS -// to the marked row: a marking is a jump affordance, and the -// user activates with Enter/Space having seen where they are -// (the guidelines' see-before-commit flow). At most one -// marking per character per column; duplicates throw at scan -// time. Guideline: leave x and i unmarked -- the row norms -// answer them (x removes via the aux control, i informs), and -// because the focused row's claim always comes first, marking -// them only catches declines from rows without those norms. -// Legal, but do it only with a reason. -// [data-mc-hotkey-direct] alongside data-mc-hotkey: this marking ACTIVATES on press -// (dispatched to the marked control) instead of only moving -// focus -- and focus also lands on the acted row, so the -// activation flash is scrolled into view with the ring, and -// Enter repeats the action. On a text row, activation means -// what Enter means there: the press dives straight into -// text-entry mode (no second dispatch; the dive is the -// activation). The name is deliberately long: -// skipping see-before-commit is legitimate where an undo -// semantic exists (the guidelines' example: a map move order -// is cheap to cancel, so just give the order; a text dive is -// undone by Escape, nothing committed). If reaching -// for this makes you think twice, that is the design working. -// [data-info="<content>"] the info norm's carrier attribute (SSOT: yk-info.ts). -// When a forwarded i comes back unclaimed, the container -// answers for the nearest carrier from the focused control -// up to the row (inclusive), dispatching yk-info itself -- -// so plain rows get the i norm with no widget code. -// -// Same-shadow-tree note: aria-activedescendant can only reference an id in the same -// tree as the container, and this binding enforces that shape by construction -- -// rows are found with querySelectorAll under the container, which cannot cross -// shadow boundaries or reach slotted light-DOM content. Render rows in the host's -// own shadow root (render-props style, as yk-live-list does); slotting rows in from -// outside is not supported and scans as an empty column. -// -// Keys the machine forwards are delivered as a cancelable `megacolumn-key` -// CustomEvent on the focused row's control element (bubbling within the shadow -// root, so a host may listen once on the container). Synthetic KeyboardEvents -// are useless here -- untrusted events don't drive native widget behavior -- so -// the contract is explicit: row widgets act on megacolumn-key, and a listener that -// handled the key calls preventDefault() on it to also swallow the original -// keydown's default action. The one place that needs REAL key events is text -// entry, and that is exactly what text-entry mode's real focus dive provides. -// -// Pointer activation rides the same contract: a click on a row's control (main -// or aux -- and any tap, since touch produces clicks) dispatches the same -// megacolumn-key with key "Enter", so Enter, Space, and click are one event and -// hosts serve every input modality through one handler. A widget that already -// activates on its own native click (yk-button in row mode) claims the click -// with preventDefault -- the same claim idiom keys use -- and the synthesis -// skips claimed clicks, so nothing double-fires. Clicks on [data-mc-input] -// never synthesize (the real-focus dive in onFocusin owns that story), and a -// text row's main synthesizes nothing either: keyboard Enter there is the text -// dive, not a forward, and the synthesis must not invent events no keyboard -// could produce. -// -// Rows may change outside any host render (a live list advancing its own row -// phases on timers, for example). A component that does that dispatches a -// bubbling `megacolumn-rescan` CustomEvent after updating its DOM, and the -// controller re-scans; renders re-scan automatically via hostUpdated as always. -// -// Focus identity announcements: whenever the FOCUSED ROW's identity changes -// (arrows, Home/End, hotkey jumps, churn fallbacks, granted focus requests -- -// every cause, because the dispatch lives in the one sink they all reach), -// the controller dispatches a bubbling `megacolumn-focus-change` CustomEvent -// on the container with detail { rowId } (null when the column emptied out). -// Pure announcement, never cancelable: listeners that mirror focus into some -// other surface (a preview pane, a map highlight) subscribe here instead of -// polling. Within-row moves (main to aux) are not identity changes and stay -// silent. Dispatched after the focused row is scrolled into view, so a -// listener reading the row's geometry sees its settled position. -// -// Programmatic focus moves: any element inside the container may ask for -// logical focus by dispatching a bubbling, cancelable `megacolumn-focus-request` -// CustomEvent with detail { rowId }. A granted request moves focus exactly as -// a hotkey jump does, and shares a jump's whole nature: no activation, no claim -// (navigation causes no vanishes, so it must not weaken the churn lockout), and -// it resolves even DURING a lockout -- reorientation stays live. The controller -// answers a granted request with preventDefault on it, so the requester can see -// it landed. A request naming a row this column does not know, or a row that -// is not focusable, or arriving during text entry, is ignored: requesters -// compose against live columns where rows vanish between look and ask, so a -// stale request must be harmless (and text entry dominates keys and focus both). -// The intended consumer is a wrapper element scoping keys to a row region: it -// hears the bubbling megacolumn-key dispatch from its own rows BEFORE any -// container-level resolution (inner scope wins by dispatch order), claims the -// keys it defines, and requests the focus move they mean. -// -// The churn lockout: when a scan finds the focused row gone and the machine's -// fallback moves focus to a neighbor, that move was nobody's keystroke -- and a -// decisive key already in flight must not land on the surprise neighbor -// (the guidelines' no-race-conditions rule; the same reasoning as yk-live-list's -// click guard, transposed to focus). For FALLBACK_LOCKOUT_MS after an involuntary -// focus move, `forward` and `enter-text` routings are consumed without dispatch; +// The churn lockout: when a scan finds the focused row gone and the machine's fallback moves focus to a neighbor, +// that move was nobody's keystroke -- +// and a decisive key already in flight must not land on the surprise neighbor +// (the guidelines' no-race-conditions rule; the same reasoning as yk-live-list's click guard, transposed to focus). +// For FALLBACK_LOCKOUT_MS after an involuntary focus move, `forward` and `enter-text` routings are consumed without dispatch; // navigation keys stay live, because reorienting is exactly what the user needs -- // and that includes hotkey focus moves, which resolve even while locked out. -// One exception: a vanish within CLAIM_EXCUSE_MS of a CLAIMED forwarded key is -// treated as that key's own consequence (an x removing its row) -- a controlled -// moment the user made happen, so the next command chains without a stutter. +// One exception: a vanish within CLAIM_EXCUSE_MS of a claimed forwarded key +// is treated as that key's own consequence (an x removing its row) -- +// a controlled moment the user made happen, so the next command chains without a stutter. // -// Column-scoped hotkeys ride the same contract: when the focused row declines a -// forwarded key (nobody preventDefaulted the first dispatch) and the key matches a -// [data-mc-hotkey] marking elsewhere in the column, the marking resolves -- a -// plain marking as a focus move to the marked row, a direct marking as a second -// dispatch of the same megacolumn-key on the marked element (detail names the -// marked row) with focus following the claim, or, on a text row, as the text -// dive itself (focus lands and real focus enters the input, exactly as Enter -// there would). The focused row always gets first -// claim by construction; the focused column is the whole hotkey namespace, so a -// user can hit a marked key without arrowing to its row first. The focused row's -// OWN marking resolves to nothing (a jump to where you already are), consumed: +// Column-scoped hotkeys ride the same contract: +// when the focused row declines a forwarded key (nobody preventDefaulted the first dispatch) +// and the key matches a [data-mc-hotkey] marking elsewhere in the column, the marking resolves -- +// a plain marking as a focus move to the marked row, +// a direct marking as a second dispatch of the same megacolumn-key on the marked element (detail names the marked row) +// with focus following the claim, +// or, on a text row, as the text dive itself (focus lands and real focus enters the input, exactly as Enter there would). +// The focused row always gets first claim by construction; +// the focused column is the whole hotkey namespace, so a user can hit a marked key without arrowing to its row first. +// The focused row's own marking resolves to nothing (a jump to where you already are), consumed: // double-tap must not out-power single-tap, and Enter/Space stays the activation. -// Markings inside closed (aria-hidden) content are never collected (see -// scanHotkeys), and every visible row is a nav stop -- so a collected marking -// always has a reachable row to jump to. -// -// Fail closed: rows the machine knows but the DOM lacks (or vice versa mid- -// dispatch), stray native tab stops inside rows, and duplicate row ids all throw. +// Markings inside closed (aria-hidden) content are never collected (see scanHotkeys), +// and every visible row is a nav stop -- so a collected marking always has a reachable row to jump to. import type { ReactiveController, ReactiveControllerHost } from "lit"; import { css } from "lit"; import { MegacolumnNav, type NavRow, type RowPosition } from "./megacolumn-nav.js"; import { dispatchYkInfo } from "./yk-info.js"; +/** MEGACOLUMN_KEY_EVENT is the name of the event whose detail is {@link MegacolumnKeyDetail}. */ +export const MEGACOLUMN_KEY_EVENT = "megacolumn-key"; + +/** + * MegacolumnKeyDetail is the detail of a `megacolumn-key` event. + * Keys the machine forwards are delivered as a cancelable `megacolumn-key` CustomEvent on the focused row's control element + * (bubbling within the shadow root, so a host may listen once on the container). + * Synthetic KeyboardEvents are useless here -- untrusted events don't drive native widget behavior -- + * so the contract is explicit: row widgets act on megacolumn-key, + * and a listener that handled the key calls preventDefault() on it to also swallow the original keydown's default action. + * That preventDefault is a claim: later listeners and the controller treat the key as handled. + * The one place that needs real key events is text entry, + * and that is exactly what text-entry mode's real focus dive provides. + * + * Pointer activation rides the same contract: + * a click on a row's control (main or aux -- and any tap, since touch produces clicks) + * dispatches the same megacolumn-key with key "Enter", + * so Enter, Space, and click are one event and hosts serve every input modality through one handler. + * A widget that already activates on its own native click (yk-button in row mode) claims the click with preventDefault -- + * the same claim idiom keys use -- and the synthesis skips claimed clicks, so nothing double-fires. + * Clicks on [data-mc-input] never synthesize (the real-focus dive in onFocusin owns that story), + * and a text row's main synthesizes nothing either: + * keyboard Enter there is the text dive, not a forward, + * and the synthesis must not invent events no keyboard could produce. + */ export interface MegacolumnKeyDetail { key: string; rowId: string; position: RowPosition; } -// The megacolumn-focus-change detail: the row now holding logical focus -// (null when the column emptied out). See the header's event contract. +/** MEGACOLUMN_FOCUS_CHANGE_EVENT is the name of the event whose detail is {@link MegacolumnFocusChangeDetail}. */ +export const MEGACOLUMN_FOCUS_CHANGE_EVENT = "megacolumn-focus-change"; + +/** + * MegacolumnFocusChangeDetail is the `megacolumn-focus-change` detail: + * the row now holding logical focus (null when the column emptied out). + * Whenever the focused row's identity changes + * (arrows, Home/End, hotkey jumps, churn fallbacks, granted focus requests -- + * every cause, because the dispatch lives in the one sink they all reach), + * the controller dispatches a bubbling `megacolumn-focus-change` CustomEvent on the container with this detail. + * Pure announcement, never cancelable: + * listeners that mirror focus into some other surface (a preview pane, a map highlight) subscribe here instead of polling. + * Within-row moves (main to aux) are not identity changes and stay silent. + * Dispatched after the focused row is scrolled into view, + * so a listener reading the row's geometry sees its settled position. + */ export interface MegacolumnFocusChangeDetail { rowId: string | null; } -// How long decisive keys stay locked out after an involuntary focus move. -// Long enough to swallow a keystroke already in flight when the row vanished; -// short enough that a user who SAW the move barely notices. A v0 feel constant. +/** + * MEGACOLUMN_RESCAN_EVENT is the name of the `megacolumn-rescan` event, which has no detail. + * Rows may change outside any host render (a live list advancing its own row phases on timers, for example). + * A component that does that dispatches a bubbling `megacolumn-rescan` CustomEvent after updating its DOM, + * and the controller re-scans; renders re-scan automatically via hostUpdated as always. + */ +export const MEGACOLUMN_RESCAN_EVENT = "megacolumn-rescan"; + +/** MEGACOLUMN_FOCUS_REQUEST_EVENT is the name of the event whose detail is {@link MegacolumnFocusRequestDetail}. */ +export const MEGACOLUMN_FOCUS_REQUEST_EVENT = "megacolumn-focus-request"; + +/** + * MegacolumnFocusRequestDetail is the `megacolumn-focus-request` detail: a programmatic focus move. + * Any element inside the container may ask for logical focus + * by dispatching a bubbling, cancelable `megacolumn-focus-request` CustomEvent with this detail. + * A granted request moves focus exactly as a hotkey jump does, and shares a jump's whole nature: + * no activation, no claim (navigation causes no vanishes, so it must not weaken the churn lockout), + * and it resolves even during a lockout -- reorientation stays live. + * The controller answers a granted request with preventDefault on it, so the requester can see it landed. + * A request naming a row this column does not know, or a row that is not focusable, or arriving during text entry, is ignored: + * requesters compose against live columns where rows vanish between look and ask, + * so a stale request must be harmless (and text entry dominates keys and focus both). + * The intended consumer is a wrapper element scoping keys to a row region: + * it hears the bubbling megacolumn-key dispatch from its own rows before any container-level resolution + * (inner scope wins by dispatch order), claims the keys it defines, and requests the focus move they mean. + */ +export interface MegacolumnFocusRequestDetail { + rowId: string; +} + +/** + * FALLBACK_LOCKOUT_MS is how long decisive keys stay locked out after an involuntary focus move. + * Long enough to swallow a keystroke already in flight when the row vanished; + * short enough that a user who saw the move barely notices. A v0 feel constant. + */ export const FALLBACK_LOCKOUT_MS = 350; -// How long a claimed forwarded key excuses a subsequent vanish from the lockout -// (the claim's own consequence arriving as a render). Sized for render latency -// only: in a live list the claimed removal ghosts first and collapses much later, -// by which point the user is idle (the key pin decayed) and a lockout is harmless. +/** + * CLAIM_EXCUSE_MS is how long a claimed forwarded key excuses a subsequent vanish from the lockout + * (the claim's own consequence arriving as a render). + * Sized for render latency only: + * in a live list the claimed removal ghosts first and collapses much later, + * by which point the user is idle (the key pin decayed) and a lockout is harmless. + */ export const CLAIM_EXCUSE_MS = 350; -// The host shape we need: a Lit element (renderRoot + addController). +/** Host is the host shape we need: a Lit element (renderRoot + addController). */ type Host = ReactiveControllerHost & { renderRoot: DocumentFragment | HTMLElement }; -// Whether an element is closed to sight: aria-hidden="true" on it or an ancestor. -// This is the ONE closed-row predicate -- scanRows keys navigation-skip off it and -// scanHotkeys keys marking-collection off it, so the AT view and the keyboard view -// cannot disagree. Two conditions make the coupling sound: -// - The invariant: a megacolumn never aria-hides VISIBLE content. Hidden-from-AT -// and skipped-from-keyboard are the same predicate only while that holds; an -// aria-hidden row must be invisible (a live list's zero-height churn phases). -// - Match by VALUE, never by attribute presence: aria-hidden="false" behaves -// inconsistently in browsers and must never read as hidden here. -function closedToSight(el: HTMLElement): boolean { +/** + * closedToSight is whether an element is closed to sight: aria-hidden="true" on it or an ancestor. + * This is the one closed-row predicate -- + * scanRows keys navigation-skip off it and scanHotkeys keys marking-collection off it, + * so the AT view and the keyboard view cannot disagree. + * Two conditions make the coupling sound: + * - The invariant: a megacolumn never aria-hides visible content. + * Hidden-from-AT and skipped-from-keyboard are the same predicate only while that holds; + * an aria-hidden row must be invisible (a live list's zero-height churn phases). + * - Match by value, never by attribute presence: + * aria-hidden="false" behaves inconsistently in browsers and must never read as hidden here. + */ +export function closedToSight(el: HTMLElement): boolean { return el.closest("[aria-hidden=\"true\"]") !== null; } +/** + * MegacolumnController binds a host component's rendered rows to the megacolumn navigation model. + * It reads the host's DOM contract (data attributes; see megacolumn.gallery.ts for the reference implementation): + * + * - `[data-mc-container]`: the megacolumn root, one per host component. + * Becomes the single real focusable (tabindex=0 assigned). + * Gets role="listbox" if the author set no role + * (aria-activedescendant only means something on an element with a composite role). + * - `[data-mc-row="<id>"]`: a row, id stable and unique. + * Scanned in DOM order; DOM order is the display order the machine navigates. + * Gets role="option" if the author set no role (override for trees/menus: treeitem, menuitem...). + * Section gaps are simply elements without this attribute. + * - `aria-hidden="true"` on a row (or a wrapper around rows): closed to sight, + * and therefore registered but skipped by navigation. + * One attribute carries both meanings so the AT view and the keyboard view cannot disagree (see {@link closedToSight}). + * Only for rows that are actually invisible (the zero-height churn phases of a live list), + * never for rows that are merely inactive: + * a visible-but-offline row stays a nav stop, so i can reach it to ask why, + * and so the stride count between its neighbors doesn't shift with activatability + * (style those with class + aria-disabled instead). + * - `[data-mc-main]`: the row's main control (optional; defaults to the row element itself). + * Gets the focus-ring class. + * - `[data-mc-aux]`: the row's one aux control (the small-x norm). + * Its presence makes ArrowRight/Left move within the row. + * - `[data-mc-input]`: the row's text input. + * Its presence makes Enter/Space enter text-entry mode + * (real DOM focus dives in; tabindex=-1 assigned so it is never a tab stop). + * - `[data-mc-hotkey="<char>"]`: a column-scoped hotkey marking on a control inside a row + * (a single a-z0-9 character, case-insensitive; aux controls answer to x/Delete and cannot carry markings). + * Pressing the key, when the focused row declines it, moves logical focus to the marked row: + * a marking is a jump affordance, + * and the user activates with Enter/Space having seen where they are (the guidelines' see-before-commit flow). + * At most one marking per character per column; duplicates throw at scan time. + * Guideline: leave x and i unmarked -- the row norms answer them (x removes via the aux control, i informs), + * and because the focused row's claim always comes first, + * marking them only catches declines from rows without those norms. + * Legal, but do it only with a reason. + * - `[data-mc-hotkey-direct]` alongside data-mc-hotkey: + * this marking activates on press (dispatched to the marked control) instead of only moving focus -- + * and focus also lands on the acted row, + * so the activation flash is scrolled into view with the ring, and Enter repeats the action. + * On a text row, activation means what Enter means there: + * the press dives straight into text-entry mode (no second dispatch; the dive is the activation). + * The name is deliberately long: + * skipping see-before-commit is legitimate where an undo semantic exists + * (the guidelines' example: a map move order is cheap to cancel, so just give the order; + * a text dive is undone by Escape, nothing committed). + * If reaching for this makes you think twice, that is the design working. + * - `[data-info="<content>"]`: the info norm's carrier attribute (SSOT: `YkInfoDetail`, yk-info.ts). + * When a forwarded i comes back unclaimed, + * the container answers for the nearest carrier from the focused control up to the row (inclusive), + * dispatching yk-info itself -- so plain rows get the i norm with no widget code. + * + * Same-shadow-tree note: aria-activedescendant can only reference an id in the same tree as the container, + * and this binding enforces that shape by construction -- + * rows are found with querySelectorAll under the container, + * which cannot cross shadow boundaries or reach slotted light-DOM content. + * Render rows in the host's own shadow root (render-props style, as yk-live-list does); + * slotting rows in from outside is not supported and scans as an empty column. + * + * Forwarded keys and pointer activation arrive as {@link MegacolumnKeyDetail} events; + * focus identity changes are announced as {@link MegacolumnFocusChangeDetail} events. + * Rows changed outside a host render are announced to the controller with {@link MEGACOLUMN_RESCAN_EVENT}; + * programmatic focus moves are asked for with {@link MegacolumnFocusRequestDetail} events. + * + * Fail closed: rows the machine knows but the DOM lacks (or vice versa mid-dispatch), + * stray native tab stops inside rows, and duplicate row ids all throw. + * + * @see {@link megacolumnStyles} for the focus-ring classes this controller maintains. + */ export class MegacolumnController implements ReactiveController { readonly nav = new MegacolumnNav(); #host: Host; #container: HTMLElement | null = null; - #ringed: Element[] = []; // elements currently carrying mc- focus classes - #hotkeys = new Map<string, HTMLElement>(); // lowercased marking char -> marked element - #scrolledTo: string | null = null; // last row id scrolled into view (scroll on identity change only) - #announcedFocus: string | null = null; // last focus identity announced via megacolumn-focus-change - // The churn lockout (see the header): armed by involuntary focus moves, - // disarmed by its own timer, excused by a recent claim (see armLockout). + + /** #ringed is the elements currently carrying mc- focus classes. */ + #ringed: Element[] = []; + + /** #hotkeys maps lowercased marking char -> marked element. */ + #hotkeys = new Map<string, HTMLElement>(); + + /** #scrolledTo is the last row id scrolled into view (scroll on identity change only). */ + #scrolledTo: string | null = null; + + /** #announcedFocus is the last focus identity announced via megacolumn-focus-change. */ + #announcedFocus: string | null = null; + + /** + * #lockedOut is the churn lockout (see the file header): + * armed by involuntary focus moves, disarmed by its own timer, excused by a recent claim (see armLockout). + */ #lockedOut = false; #lockoutTimer: number | undefined; - #claimExcuse = false; // a forwarded key was claimed within CLAIM_EXCUSE_MS + + /** #claimExcuse is whether a forwarded key was claimed within CLAIM_EXCUSE_MS. */ + #claimExcuse = false; #claimExcuseTimer: number | undefined; constructor(host: Host) { @@ -254,18 +291,18 @@ export class MegacolumnController implements ReactiveController { container.addEventListener("focusin", (e) => this.onFocusin(e)); container.addEventListener("focusout", (e) => this.onFocusout(e)); // Components whose rows change outside host renders announce it with this - // event (see the header); a render-driven scan happens in hostUpdated as always. - container.addEventListener("megacolumn-rescan", () => { + // event (see the MegacolumnController class doc); a render-driven scan happens in hostUpdated as always. + container.addEventListener(MEGACOLUMN_RESCAN_EVENT, () => { this.scanRows(this.mustContainer()); this.applyFocusState(); }); - container.addEventListener("megacolumn-focus-request", (e) => this.onFocusRequest(e)); + container.addEventListener(MEGACOLUMN_FOCUS_REQUEST_EVENT, (e) => this.onFocusRequest(e)); } this.scanRows(container); this.applyFocusState(); } - // Read the rendered rows (DOM order = display order) into the machine. + /** scanRows reads the rendered rows (DOM order = display order) into the machine. */ private scanRows(container: HTMLElement): void { const rows: NavRow[] = []; for (const el of container.querySelectorAll<HTMLElement>("[data-mc-row]")) { @@ -289,7 +326,7 @@ export class MegacolumnController implements ReactiveController { } // aria-activedescendant needs a referencable id; derive one from the row id. // The mc- prefix is reserved for this derivation: Lit reuses DOM nodes by - // position, so an element can reappear under a NEW row identity carrying its + // position, so an element can reappear under a new row identity carrying its // old derived id, which must be re-derived (author-set ids are left alone). const derivedId = `mc-${id}`; if (el.id === "" || (el.id.startsWith("mc-") && el.id !== derivedId)) el.id = derivedId; @@ -306,10 +343,12 @@ export class MegacolumnController implements ReactiveController { this.scanHotkeys(container); } - // Arm (or re-arm) the churn lockout: the machine's fallback just moved focus - // without a user keystroke. Excused when a claimed forward landed within - // CLAIM_EXCUSE_MS: that vanish is the claim's own consequence (an x removing - // its row), a moment the user made happen, and the next command must chain. + /** + * armLockout arms (or re-arms) the churn lockout: the machine's fallback just moved focus without a user keystroke. + * Excused when a claimed forward landed within {@link CLAIM_EXCUSE_MS}: + * that vanish is the claim's own consequence (an x removing its row), a moment the user made happen, + * and the next command must chain. + */ private armLockout(): void { if (this.#claimExcuse) return; this.#lockedOut = true; @@ -320,7 +359,7 @@ export class MegacolumnController implements ReactiveController { }, FALLBACK_LOCKOUT_MS); } - // A forwarded key was claimed: open the excuse window (see armLockout). + /** noteClaim records that a forwarded key was claimed: open the excuse window (see {@link armLockout}). */ private noteClaim(): void { this.#claimExcuse = true; window.clearTimeout(this.#claimExcuseTimer); @@ -330,22 +369,26 @@ export class MegacolumnController implements ReactiveController { }, CLAIM_EXCUSE_MS); } - // Move logical focus to a marking's row (the hotkey-jump resolution; see the - // header). A collected marking always sits in a focusable row: closed content - // never yields a marking, and closed is the only thing unfocusable -- so - // focusRow's own fail-closed throws cover any state that violates that. + /** + * jumpToMarking moves logical focus to a marking's row (the hotkey-jump resolution; see the file header). + * A collected marking always sits in a focusable row: + * closed content never yields a marking, and closed is the only thing unfocusable -- + * so focusRow's own fail-closed throws cover any state that violates that. + */ private jumpToMarking(marked: HTMLElement): void { const markedRow = marked.closest<HTMLElement>("[data-mc-row]"); this.nav.focusRow(markedRow?.dataset.mcRow ?? ""); } - // Collect the column's [data-mc-hotkey] markings (see the DOM contract above). - // All the marking rules are enforced here, at scan time, so a bad marking fails - // on the render that introduces it rather than on the keypress that trips it. - // Markings inside closed (aria-hidden) content are not collected: an invisible - // control cannot advertise a key, so it must not answer one (and a hidden row's - // marking -- a collapsing live-list ghost, say -- must not collide with its - // visible twin). Same predicate as the row scan: closedToSight. + /** + * scanHotkeys collects the column's [data-mc-hotkey] markings (see the DOM contract on {@link MegacolumnController}). + * All the marking rules are enforced here, at scan time, + * so a bad marking fails on the render that introduces it rather than on the keypress that trips it. + * Markings inside closed (aria-hidden) content are not collected: + * an invisible control cannot advertise a key, so it must not answer one + * (and a hidden row's marking -- a collapsing live-list ghost, say -- must not collide with its visible twin). + * Same predicate as the row scan: {@link closedToSight}. + */ private scanHotkeys(container: HTMLElement): void { this.#hotkeys = new Map(); for (const el of container.querySelectorAll<HTMLElement>("[data-mc-hotkey]")) { @@ -379,9 +422,11 @@ export class MegacolumnController implements ReactiveController { } } - // Render the machine's state into the DOM: aria-activedescendant on the - // container, ring classes on the focused control, mode classes on the container. - // Called after every render and after every state-changing event; idempotent. + /** + * applyFocusState renders the machine's state into the DOM: + * aria-activedescendant on the container, ring classes on the focused control, mode classes on the container. + * Called after every render and after every state-changing event; idempotent. + */ private applyFocusState(): void { const container = this.mustContainer(); for (const el of this.#ringed) el.classList.remove("mc-focused", "mc-row-focused"); @@ -398,7 +443,7 @@ export class MegacolumnController implements ReactiveController { row.classList.add("mc-row-focused"); control.classList.add("mc-focused"); this.#ringed = [row, control]; - // Logical focus must never sit off-screen, and browsers do NOT scroll + // Logical focus must never sit off-screen, and browsers do not scroll // aria-activedescendant targets into view on their own -- so every focus // identity change scrolls here, covering arrows, Home/End, hotkey jumps, // and churn fallbacks in one place. Nearest-edge and instant: a smooth @@ -413,14 +458,14 @@ export class MegacolumnController implements ReactiveController { "mc-empty-focus", focusedId === null && this.focusIsInside(container), ); - // Announce focus IDENTITY changes (see the header's megacolumn-focus-change + // Announce focus identity changes (the MegacolumnFocusChangeDetail // contract). Dispatched here, the one place every cause converges -- arrows, // Home/End, hotkey jumps, churn fallbacks, focus requests -- and after the // scroll above, so a listener reading anchor geometry sees the settled row. if (focusedId !== this.#announcedFocus) { this.#announcedFocus = focusedId; container.dispatchEvent( - new CustomEvent<MegacolumnFocusChangeDetail>("megacolumn-focus-change", { + new CustomEvent<MegacolumnFocusChangeDetail>(MEGACOLUMN_FOCUS_CHANGE_EVENT, { detail: { rowId: focusedId }, bubbles: true, }), @@ -457,7 +502,7 @@ export class MegacolumnController implements ReactiveController { case "forward": { const row = this.rowElement(routing.rowId); const control = this.controlElement(row, routing.position); - const forwarded = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", { + const forwarded = new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, { detail: { key: e.key, rowId: routing.rowId, position: routing.position }, bubbles: true, cancelable: true, @@ -493,7 +538,7 @@ export class MegacolumnController implements ReactiveController { // Markings never sit on aux controls (the scan throws), so the position // in the marked row is always main. claimed = !marked.dispatchEvent( - new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", { + new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, { detail: { key: e.key, rowId: markedId, position: "main" }, bubbles: true, cancelable: true, @@ -535,10 +580,12 @@ export class MegacolumnController implements ReactiveController { this.applyFocusState(); } - // Dive real DOM focus into a text row's input (the enter-text routing, and a - // direct marking resolving on a text row). preventDefault keeps the triggering - // key from typing into the input that synchronously receives focus -- for the - // marked-key path that key is a letter, which WOULD otherwise land as text. + /** + * diveIntoText dives real DOM focus into a text row's input + * (the enter-text routing, and a direct marking resolving on a text row). + * preventDefault keeps the triggering key from typing into the input that synchronously receives focus -- + * for the marked-key path that key is a letter, which would otherwise land as text. + */ private diveIntoText(rowId: string, e: KeyboardEvent): void { const input = this.rowElement(rowId).querySelector<HTMLElement>("[data-mc-input]"); if (input === null) throw new Error(`megacolumn: text row "${rowId}" lost its [data-mc-input]`); @@ -546,11 +593,13 @@ export class MegacolumnController implements ReactiveController { input.focus(); } - // The megacolumn-focus-request event (see the header): a programmatic focus - // move with a hotkey jump's nature -- pure navigation, no claim, live during - // lockouts. Stale or malformed requests are ignored rather than thrown: the - // requester composes against a live column, and a row can vanish between its - // look and its ask. Granted requests answer with preventDefault. + /** + * onFocusRequest handles the megacolumn-focus-request event (see {@link MegacolumnFocusRequestDetail}): + * a programmatic focus move with a hotkey jump's nature -- pure navigation, no claim, live during lockouts. + * Stale or malformed requests are ignored rather than thrown: + * the requester composes against a live column, and a row can vanish between its look and its ask. + * Granted requests answer with preventDefault. + */ private onFocusRequest(e: Event): void { const detail: unknown = (e as CustomEvent).detail; if (typeof detail !== "object" || detail === null) return; @@ -564,8 +613,10 @@ export class MegacolumnController implements ReactiveController { this.applyFocusState(); } - // Mouse clicks sync logical focus. mousedown (not click) so the machine is - // already coherent when the browser's default focus movement fires focusin. + /** + * onMousedown syncs logical focus to mouse clicks. + * mousedown (not click) so the machine is already coherent when the browser's default focus movement fires focusin. + */ private onMousedown(e: MouseEvent): void { const target = e.target; if (!(target instanceof Element)) return; @@ -579,16 +630,19 @@ export class MegacolumnController implements ReactiveController { this.applyFocusState(); } - // Pointer activation (see the header): a click on a row's control dispatches - // the same megacolumn-key the keyboard's Enter dispatches -- one activation - // path for every input modality. mousedown already moved logical focus - // (mousedown precedes click), so by dispatch time the machine agrees with - // what the user sees; the dispatch targets the clicked control regardless. - // The churn lockout is deliberately not consulted: it protects identity-aimed - // keystrokes from surprise focus moves, while a click names its target - // geometrically and its own mousedown just resolved the aim. Moving-target - // clicks are the geometry owner's problem, stopped upstream in the capture - // phase (yk-live-list's click guard) before this bubble listener can hear them. + /** + * onClick is pointer activation (see {@link MegacolumnKeyDetail}): + * a click on a row's control dispatches the same megacolumn-key the keyboard's Enter dispatches -- + * one activation path for every input modality. + * mousedown already moved logical focus (mousedown precedes click), + * so by dispatch time the machine agrees with what the user sees; + * the dispatch targets the clicked control regardless. + * The churn lockout is deliberately not consulted: + * it protects identity-aimed keystrokes from surprise focus moves, + * while a click names its target geometrically and its own mousedown just resolved the aim. + * Moving-target clicks are the geometry owner's problem, + * stopped upstream in the capture phase (yk-live-list's click guard) before this bubble listener can hear them. + */ private onClick(e: MouseEvent): void { if (e.defaultPrevented) return; // a widget activated on this click already and claimed it const target = e.target; @@ -602,7 +656,7 @@ export class MegacolumnController implements ReactiveController { const position: RowPosition = target.closest("[data-mc-aux]") !== null ? "aux" : "main"; if (row.textEntry === true && position === "main") return; // Enter there is the text dive, not a forward const control = this.controlElement(rowEl, position); - const synthesized = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", { + const synthesized = new CustomEvent<MegacolumnKeyDetail>(MEGACOLUMN_KEY_EVENT, { detail: { key: "Enter", rowId: id, position }, bubbles: true, cancelable: true, @@ -650,8 +704,10 @@ export class MegacolumnController implements ReactiveController { return this.#container; } - // The nearest [data-info] carrier from the focused control upward, stopping at - // the row (inclusive). Empty data-info means "not a carrier", same as absent. + /** + * infoCarrier finds the nearest [data-info] carrier from the focused control upward, stopping at the row (inclusive). + * Empty data-info means "not a carrier", same as absent. + */ private infoCarrier(control: HTMLElement, row: HTMLElement): HTMLElement | null { let el: HTMLElement | null = control; while (el !== null) { @@ -687,22 +743,25 @@ export class MegacolumnController implements ReactiveController { } } -// The focus-ring rendering, from the design tokens. Import into any host's -// static styles; the classes are maintained by the controller: -// mc-focus-within on the container while real DOM focus is anywhere inside -// mc-focused on the focused row's control (main or aux) -// mc-row-focused on the focused row element (for whole-row washes) -// mc-text-mode on the container during text entry -// mc-empty-focus on the container when focused with no focusable rows -// -// The ring is drawn on the ROW, not the control: the container sizes the -// focusable unit (the guidelines' "rows are rows" norm), so the ring draws the -// same shape on every row in a region instead of struggle-hugging whatever -// content the focused control happens to have. It sits inset (negative -// offset) so the shape is identical even for rows flush against the -// container's scroll edges. The aux position keeps its own hugging ring ON -// TOP of the row ring: within-row movement must stay visible, and the aux is -// a small fixed-size control, so its ring is consistent by construction. +/** + * megacolumnStyles is the focus-ring rendering, from the design tokens. + * Import into any host's static styles; the classes are maintained by the controller: + * - `mc-focus-within`: on the container while real DOM focus is anywhere inside + * - `mc-focused`: on the focused row's control (main or aux) + * - `mc-row-focused`: on the focused row element (for whole-row washes) + * - `mc-text-mode`: on the container during text entry + * - `mc-empty-focus`: on the container when focused with no focusable rows + * + * The ring is drawn on the row, not the control: + * the container sizes the focusable unit (the guidelines' "rows are rows" norm), + * so the ring draws the same shape on every row in a region + * instead of struggle-hugging whatever content the focused control happens to have. + * It sits inset (negative offset) so the shape is identical even for rows flush against the container's scroll edges. + * The aux position keeps its own hugging ring on top of the row ring: + * within-row movement must stay visible, and the aux is a small fixed-size control, so its ring is consistent by construction. + * + * @see {@link MegacolumnController} + */ export const megacolumnStyles = css` [data-mc-container] { outline: none; /* the ring is drawn on the focused row instead */ 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) { diff --git a/packages/yukey/src/press-flash.ts b/packages/yukey/src/press-flash.ts index 0db8626..8b382cd 100644 --- a/packages/yukey/src/press-flash.ts +++ b/packages/yukey/src/press-flash.ts @@ -1,22 +1,23 @@ -// Activation feedback, shared across widgets: the "yk-pressed" flash class that -// acknowledges a successful activation for --yk-press-ms, so keyboard triggers -// (Enter/Space, hotkeys) are as visible as a mouse press. The duration is one -// system-wide design token, read from the live cascade at flash time -- so an -// accessibility mode can slow every flash by restyling one custom property. -// -// Consumers and their class carriers: -// - yk-button flashes its host element (and manages its own pointer-held -// "yk-held" class beside it); its styles render both. -// - Megacolumn hosts with plain activatable rows call flashPress(rowElement) -// when they act on a forwarded activation; the row-flavored .yk-pressed -// rendering lives in megacolumnStyles, beside the focus washes it must outrank. +// Activation feedback, shared across widgets: +// the "yk-pressed" flash class that acknowledges a successful activation for --yk-press-ms, +// so keyboard triggers (Enter/Space, hotkeys) are as visible as a mouse press. +/** + * PRESSED_CLASS is the flash class. Consumers and their class carriers: + * - yk-button flashes its host element (and manages its own pointer-held "yk-held" class beside it); + * its styles render both. + * - Megacolumn hosts with plain activatable rows call flashPress(rowElement) when they act on a forwarded activation; + * the row-flavored .yk-pressed rendering lives in megacolumnStyles, beside the focus washes it must outrank. + */ export const PRESSED_CLASS = "yk-pressed"; -// The flash duration, resolved from the cascade at el. Throws when the property -// does not resolve (tokens not installed, or a detached element): a feedback -// system that silently picks its own duration would hide exactly the -// misconfiguration it exists to surface. +/** + * pressMs is the flash duration, resolved from the cascade at el. + * The duration is one system-wide design token, read from the live cascade at flash time -- + * so an accessibility mode can slow every flash by restyling one custom property. + * Throws when the property does not resolve (tokens not installed, or a detached element): + * a feedback system that silently picks its own duration would hide exactly the misconfiguration it exists to surface. + */ export function pressMs(el: Element): number { const raw = getComputedStyle(el).getPropertyValue("--yk-press-ms").trim(); const m = /^(\d+(?:\.\d+)?)(m?s)$/.exec(raw); @@ -32,9 +33,11 @@ export function pressMs(el: Element): number { const timers = new WeakMap<HTMLElement, number>(); -// Show the pressed look on el for --yk-press-ms. A re-flash while one is still -// showing re-arms the timer, so rapid activations read as sustained activity -// instead of getting cut short by the first flash's expiry. +/** + * flashPress shows the pressed look on el for --yk-press-ms. + * A re-flash while one is still showing re-arms the timer, + * so rapid activations read as sustained activity instead of getting cut short by the first flash's expiry. + */ export function flashPress(el: HTMLElement): void { el.classList.add(PRESSED_CLASS); window.clearTimeout(timers.get(el)); diff --git a/packages/yukey/src/tab-cycle.ts b/packages/yukey/src/tab-cycle.ts index e43c250..abbc946 100644 --- a/packages/yukey/src/tab-cycle.ts +++ b/packages/yukey/src/tab-cycle.ts @@ -1,18 +1,9 @@ -// Shadow-DOM-aware Tab containment for page shells: the kit's default is that -// the Tab key never leaves the page and wanders into browser chrome (the -// guidelines' "Focus containment and lifecycle" norm). A shell installs it -// once with installTabContainment(document); the helpers underneath -// (deepTabbables, deepActiveElement, containTab) stay exported for tests and -// for shells that need the walk itself (the gallery seats focus with it). -// -// The walk is attribute-based (tabIndex >= 0 on the usual focusable tags plus -// anything with an explicit tabindex), not layout-based: it does not check -// visibility or computed styles. That is deliberate -- the callers' content is -// megacolumn containers and native buttons, all visible by construction, and -// staying layout-free keeps the walk cheap and jsdom-testable. aria-hidden -// subtrees are skipped: components here hide closed content that way -// (yk-live-list's collapsing rows), and a tab stop invisible to AT must not be -// a tab stop at all. +// Shadow-DOM-aware Tab containment for page shells: +// the kit's default is that the Tab key never leaves the page and wanders into browser chrome +// (the guidelines' "Focus containment and lifecycle" norm). +// A shell installs it once with installTabContainment(document); +// the helpers underneath (deepTabbables, deepActiveElement, containTab) stay exported for tests +// and for shells that need the walk itself (the gallery seats focus with it). function isTabbable(el: Element): boolean { if (!(el instanceof HTMLElement)) return false; @@ -21,7 +12,17 @@ function isTabbable(el: Element): boolean { return el.matches("a[href], button, input, select, textarea, [contenteditable='true']"); } -// All tab stops under `root` in tree order, descending open shadow roots. +/** + * deepTabbables is all tab stops under `root` in tree order, descending open shadow roots. + * + * The walk is attribute-based (tabIndex >= 0 on the usual focusable tags plus anything with an explicit tabindex), + * not layout-based: it does not check visibility or computed styles. + * That is deliberate -- the callers' content is megacolumn containers and native buttons, all visible by construction, + * and staying layout-free keeps the walk cheap and jsdom-testable. + * aria-hidden subtrees are skipped: + * components here hide closed content that way (yk-live-list's collapsing rows), + * and a tab stop invisible to AT must not be a tab stop at all. + */ export function deepTabbables(root: ParentNode): HTMLElement[] { const out: HTMLElement[] = []; const walk = (node: ParentNode): void => { @@ -36,7 +37,7 @@ export function deepTabbables(root: ParentNode): HTMLElement[] { return out; } -// The really-focused element, descending the shadow activeElement chain. +/** deepActiveElement is the really-focused element, descending the shadow activeElement chain. */ export function deepActiveElement(doc: Document): Element | null { let active = doc.activeElement; while (active?.shadowRoot?.activeElement != null) { @@ -45,11 +46,13 @@ export function deepActiveElement(doc: Document): Element | null { return active; } -// Tab containment: keep Tab cycling within the document instead of escaping to -// the browser chrome. Only the edges are intercepted (last stop wrapping to -// first, first back to last); interior Tabs stay native, so within-page traversal -// is exactly the browser's own. Focus outside the tab order (a text-entry dive -// into a tabindex=-1 input) is left alone: Escape/Enter own that exit path. +/** + * containTab is Tab containment: keep Tab cycling within the document instead of escaping to the browser chrome. + * Only the edges are intercepted (last stop wrapping to first, first back to last); + * interior Tabs stay native, so within-page traversal is exactly the browser's own. + * Focus outside the tab order (a text-entry dive into a tabindex=-1 input) is left alone: + * Escape/Enter own that exit path. + */ export function containTab(e: KeyboardEvent, doc: Document): void { if (e.key !== "Tab") return; const stops = deepTabbables(doc.body); @@ -63,14 +66,17 @@ export function containTab(e: KeyboardEvent, doc: Document): void { (e.shiftKey ? last : first).focus(); } -// Documents where containment is already installed. A WeakSet (not a flag on -// the document) so tests with fresh jsdom documents never see stale state. +/** + * installed is the documents where containment is already installed. + * A WeakSet (not a flag on the document) so tests with fresh jsdom documents never see stale state. + */ const installed = new WeakSet<Document>(); -// Install Tab containment on a document: the one call a page shell makes to -// get the kit's default. Idempotent per document (repeat calls add nothing and -// return false); the listener lives for the document's life, matching the -// shells that call it (they never uninstall themselves either). +/** + * installTabContainment installs Tab containment on a document: the one call a page shell makes to get the kit's default. + * Idempotent per document (repeat calls add nothing and return false); + * the listener lives for the document's life, matching the shells that call it (they never uninstall themselves either). + */ export function installTabContainment(doc: Document): boolean { if (installed.has(doc)) return false; installed.add(doc); diff --git a/packages/yukey/src/themes/default.ts b/packages/yukey/src/themes/default.ts index c088354..5fe6392 100644 --- a/packages/yukey/src/themes/default.ts +++ b/packages/yukey/src/themes/default.ts @@ -1,15 +1,18 @@ -// The kit's default theme: dark, teal-accented, SCADA-console. installTokens() with -// no argument installs this one. -// -// Values only; the names and their roles are the vocabulary in ../tokens.ts. A theme -// supplies every name (the Theme type enforces it) and nothing else. To restyle the -// kit, copy this file, change values, and pass yours to installTokens. -// -// These values are a first palette, not an enshrinement: iterate them freely. The -// `tokens` gallery entry shows whatever theme is installed. +// The kit's default theme. import type { Theme } from "../tokens.js"; +/** + * defaultTheme is the kit's default theme: dark, teal-accented, SCADA-console. + * installTokens() with no argument installs this one. + * + * Values only; the names and their roles are the vocabulary in tokens.ts. + * A theme supplies every name (the Theme type enforces it) and nothing else. + * To restyle the kit, copy this file, change values, and pass yours to installTokens. + * + * These values are a first palette, not an enshrinement: iterate them freely. + * The `tokens` gallery entry shows whatever theme is installed. + */ export const defaultTheme: Theme = { "--yk-bg-void": "#05080f", "--yk-bg-panel-solid": "#0a1220", diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts index afb5543..adfba7e 100644 --- a/packages/yukey/src/tokens.ts +++ b/packages/yukey/src/tokens.ts @@ -1,41 +1,39 @@ -// The design-token vocabulary: every color, gap, size, and type choice the kit's -// components are styled with, as CSS custom properties. This file is the SSOT for -// token NAMES and their roles; docs/precepts-aesthetics.md describes the intent, and -// the `tokens` gallery entry renders the vocabulary live. -// -// Values live in a Theme (themes/default.ts is the one the kit ships). The split -// is the point: components write `var(--yk-...)` against names that hold still, -// and an application restyles the whole kit by installing a different theme. -// A Theme is typed against the vocabulary, so a theme missing a token is a compile -// error rather than a silently unstyled element. -// -// installTokens(theme) puts the theme's values on :root; custom properties inherit -// through shadow boundaries from there, so that one call is the whole setup. +// The design-token vocabulary: +// every color, gap, size, and type choice the kit's components are styled with, as CSS custom properties. +// This file is the SSOT for token names and their roles; +// docs/precepts-aesthetics.md describes the intent, and the `tokens` gallery entry renders the vocabulary live. +// Values live in a Theme; installTokens is the whole setup. import type { CSSResult } from "lit"; import { unsafeCSS } from "lit"; import { defaultTheme } from "./themes/default.js"; -// What a token's value IS (the W3C design-tokens draft calls this $type). -// The gallery picks preview renderings from it; nothing else branches on it. +/** + * TokenKind is what a token's value is (the W3C design-tokens draft calls this $type). + * The gallery picks preview renderings from it; nothing else branches on it. + */ export type TokenKind = "color" | "size" | "font" | "composite" | "duration"; export interface TokenDef { name: `--yk-${string}`; kind: TokenKind; - // The role, in a sentence: what kind of thing should use this token. + + /** about is the role, in a sentence: what kind of thing should use this token. */ about: string; } export interface TokenGroup { - // Stable handle for consumers that single out a group (the gallery's type specimen); - // title is display text and free to change. + /** + * id is a stable handle for consumers that single out a group (the gallery's type specimen); + * title is display text and free to change. + */ id: string; title: string; about: string; tokens: readonly TokenDef[]; } +/** tokenGroups is the vocabulary, grouped, in display order. */ export const tokenGroups = [ { id: "surfaces", @@ -363,15 +361,26 @@ export const tokenGroups = [ }, ] as const satisfies readonly TokenGroup[]; -// Every token name in the vocabulary, as a type. Derived from tokenGroups, so adding -// a token there is the one edit; every Theme then has to supply it. +/** + * TokenName is every token name in the vocabulary, as a type. + * Derived from {@link tokenGroups}, so adding a token there is the one edit; every Theme then has to supply it. + */ export type TokenName = (typeof tokenGroups)[number]["tokens"][number]["name"]; -// A complete set of values for the vocabulary: one CSS value per token name. +/** + * Theme is a complete set of values for the vocabulary: one CSS value per token name + * (themes/default.ts is the one the kit ships). + * The split is the point: components write `var(--yk-...)` against names that hold still, + * and an application restyles the whole kit by installing a different theme. + * A Theme is typed against the vocabulary, + * so a theme missing a token is a compile error rather than a silently unstyled element. + */ export type Theme = Readonly<Record<TokenName, string>>; -// Renders a theme into one `:root { ... }` block, in vocabulary order, group titles as comments. -// Exported for the unit test; everything else should use installTokens. +/** + * themeCssText renders a theme into one `:root { ... }` block, in vocabulary order, group titles as comments. + * Exported for the unit test; everything else should use {@link installTokens}. + */ export function themeCssText(theme: Theme, groups: readonly TokenGroup[] = tokenGroups): string { const lines: string[] = [":root {"]; for (const group of groups) { @@ -384,16 +393,20 @@ export function themeCssText(theme: Theme, groups: readonly TokenGroup[] = token return lines.join("\n"); } -// A theme as a lit CSSResult, for callers that compose stylesheets by hand. -// unsafeCSS is safe here: the text is generated from the vocabulary and the theme's -// literal values, never from runtime data. +/** + * themeStyles is a theme as a lit CSSResult, for callers that compose stylesheets by hand. + * unsafeCSS is safe here: the text is generated from the vocabulary and the theme's literal values, never from runtime data. + */ export function themeStyles(theme: Theme): CSSResult { return unsafeCSS(themeCssText(theme)); } -// Installs a theme document-wide (a <style> in head). Custom properties inherit -// into every shadow root from there. With no argument, the kit's default theme. -// Idempotent: a second call is harmless and leaves the first theme in place. +/** + * installTokens installs a theme document-wide (a `<style>` in head); + * custom properties inherit through shadow boundaries into every shadow root from there, so that one call is the whole setup. + * With no argument, the kit's default theme. + * Idempotent: a second call is harmless and leaves the first theme in place. + */ export function installTokens(theme: Theme = defaultTheme, doc: Document = document): void { if (doc.head.querySelector("style[data-yk-tokens]") !== null) return; const style = doc.createElement("style"); diff --git a/packages/yukey/src/yk-button.ts b/packages/yukey/src/yk-button.ts index 89dcd1c..ea4dd0c 100644 --- a/packages/yukey/src/yk-button.ts +++ b/packages/yukey/src/yk-button.ts @@ -1,85 +1,66 @@ // yk-button: the button that makes the guidelines' keyboard norms structural. // Use it and the norms happen; there is nothing to remember per screen. -// -// The norms it embodies (docs/precepts-we-love-the-keyboard.md, "buttons always show their hotkey" -// and the megacolumn sections): -// -// - Hotkey marking: if the hotkey letter occurs in the label, its first -// occurrence renders in --yk-hotkey-accent; otherwise the letter shows in a -// slot 70% of the way across. A complex button (more than one concept of -// activation) sets `icon` instead, which claims that slot: a single letter -// would mislead, so the icon says "more here; ask i". -// - The i norm: pressing i (forwarded in a row, or while focused standalone) -// dispatches a composed `yk-info` event carrying this button's `info` text. -// yk-infopop renders the popup; `info` is also mirrored onto data-info, -// the attribute yk-infopop's hover trigger finds carriers by -// (one authored content, two triggers). -// i answers even when disabled: it is how you ask why a thing is off. -// - Hit targets from --yk-hit-min-h/w. -// - Active (`active`): the in-effect option among sibling choices, marked with -// the selected wash and aria-current -- and STILL clickable: re-issuing an -// in-effect choice is the norm's contract (owners decide whether re-issue -// means anything). Not aria-pressed: these are not toggles; activating an -// active button never un-presses it. -// - Disabled is aria-disabled, never the native disabled attribute: the button -// stays perceivable (and standalone: focusable), activation is suppressed. -// -// Dual context, detected at connect time: -// -// - Inside a megacolumn row (a [data-mc-row] ancestor in the same shadow tree): -// renders non-focusable markup -- the container must stay the column's only -// tab stop, and non-focusable content also gives clicks their native behavior -// of focusing the container. Activation arrives as megacolumn-key events, -// heard on the row element so the button answers row-scoped keys even when -// the forward targeted the row's other control. Claims (first claim wins; -// a claim preventDefaults, and a disabled button claims but declines): -// Enter/Space at this button's own position -> activate -// its hotkey, only when hotkeyDirect -> activate (via the column- -// scoped marking contract: -// data-mc-hotkey plus -// data-mc-hotkey-direct on the -// host). WITHOUT hotkeyDirect -// the button declines its own -// hotkey: that decline lets the -// controller resolve the marking -// as a focus move to this row, -// and Enter/Space then activate -// (see-before-commit). -// x/Delete from any position, aux buttons only-> activate (the removal norm) -// i at this button's own position -> yk-info -// - Standalone (no [data-mc-container] ancestor): a real native button; Tab -// reaches it, Enter/Space activate natively, the focus ring is the tokens'. -// Its hotkey and i answer only while it holds focus (a broader standalone -// hotkey scope is an app-level concern, not this component's). -// - Inside a container but outside any row: fail closed (throws at connect). -// A native tab stop loose in a container would quietly fracture the -// single-tab-stop model, and the scan cannot see into this shadow root. -// -// Activation is a `click` event on this element in every context and by every -// trigger (mouse, Enter/Space, hotkey): authors write ONE @click handler. -// In row mode every host click is also CLAIMED (preventDefault) as it passes: -// the megacolumn controller synthesizes an Enter-equivalent megacolumn-key from -// unclaimed clicks on row controls (its pointer-activation norm for plain rows), -// and the claim tells it this widget already owns click-activation -- without it, -// a mouse click would activate twice, once natively and once synthesized. -// Standalone clicks stay unclaimed: no controller listens, and a native button's -// click default is not this component's to consume. -// -// Activation feedback: every successful activation flashes the pressed look for -// --yk-press-ms (press-flash.ts), so keyboard triggers are as visible as a mouse -// press; a pointer held down shows the same look for as long as it stays down. -// Disabled buttons show neither: nothing happened, nothing must claim otherwise. -// The yk-pressed/yk-held classes live on the HOST element, outside classMap's -// managed set, so renders never clobber a flash in flight. +// (The norms: docs/precepts-we-love-the-keyboard.md, "buttons always show their hotkey" and the megacolumn sections. +// Each property's doc names the norm it carries; the YkButton class doc has the per-context contract.) import { css, html, LitElement, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import type { MegacolumnKeyDetail } from "./megacolumn-controller.js"; +import { MEGACOLUMN_KEY_EVENT, type MegacolumnKeyDetail } from "./megacolumn-controller.js"; import { flashPress } from "./press-flash.js"; import { dispatchYkInfo } from "./yk-info.js"; +/** + * YkButton is a button with the guidelines' keyboard norms built in: + * a marked hotkey, the `i` norm, the aux (small-x) form inside megacolumn rows, + * and one `click` event for every trigger. + * Hit targets from `--yk-hit-min-h`/`--yk-hit-min-w`. + * + * Dual context, detected at connect time: + * + * - Inside a megacolumn row (a `[data-mc-row]` ancestor in the same shadow tree): + * renders non-focusable markup -- the container must stay the column's only tab stop, + * and non-focusable content also gives clicks their native behavior of focusing the container. + * Activation arrives as megacolumn-key events, + * heard on the row element so the button answers row-scoped keys even when the forward targeted the row's other control. + * The button claims keys: it preventDefaults the forwarded `megacolumn-key` event, + * so later listeners and the controller treat the key as handled + * (the claim idiom is defined on {@link MegacolumnKeyDetail}). + * First claim wins, and a disabled button claims but declines to act. What it claims: + * - Enter/Space at this button's own position: activate. + * - Its hotkey, only when {@link hotkeyDirect}: activate + * (via the column-scoped marking contract: `data-mc-hotkey` plus `data-mc-hotkey-direct` on the host). + * Without hotkeyDirect the button declines its own hotkey: + * that decline lets the controller resolve the marking as a focus move to this row, + * and Enter/Space then activate (see-before-commit). + * - x/Delete from any position, aux buttons only: activate (the removal norm). + * - `i` at this button's own position: yk-info. + * - Standalone (no `[data-mc-container]` ancestor): a real native button; + * Tab reaches it, Enter/Space activate natively, the focus ring is the tokens'. + * Its hotkey and `i` answer only while it holds focus + * (a broader standalone hotkey scope is an app-level concern, not this component's). + * - Inside a container but outside any row: fail closed (throws at connect). + * A native tab stop loose in a container would quietly fracture the single-tab-stop model, + * and the scan cannot see into this shadow root. + * + * Activation is a `click` event on this element in every context and by every trigger (mouse, Enter/Space, hotkey): + * authors write one @click handler. + * In row mode every host click is also claimed (preventDefault) as it passes: + * the megacolumn controller synthesizes an Enter-equivalent megacolumn-key from unclaimed clicks on row controls + * (its pointer-activation norm for plain rows), + * and the claim tells it this widget already owns click-activation -- + * without it, a mouse click would activate twice, once natively and once synthesized. + * Standalone clicks stay unclaimed: no controller listens, + * and a native button's click default is not this component's to consume. + * + * Activation feedback: every successful activation flashes the pressed look for `--yk-press-ms` (see {@link flashPress}), + * so keyboard triggers are as visible as a mouse press; + * a pointer held down shows the same look for as long as it stays down. + * Disabled buttons show neither: nothing happened, nothing must claim otherwise. + * The `yk-pressed`/`yk-held` classes live on the host element, outside classMap's managed set, + * so renders never clobber a flash in flight. + */ @customElement("yk-button") export class YkButton extends LitElement { static styles = css` @@ -152,7 +133,7 @@ export class YkButton extends LitElement { color: inherit; } /* activation feedback: the same look for held-under-a-pointer and for the - post-activation flash (see the file header) */ + post-activation flash (see the class doc) */ :host(.yk-held) .btn, :host(.yk-pressed) .btn { background: var(--yk-wash-pressed); @@ -161,20 +142,56 @@ export class YkButton extends LitElement { } `; + /** + * label is the visible text. + * Hotkey marking: if the hotkey letter occurs in the label, its first occurrence renders in `--yk-hotkey-accent`; + * otherwise the letter shows in a slot 70% of the way across. + */ @property() label = ""; + + /** + * icon is a glyph for the hotkey slot. + * A complex button (more than one concept of activation) sets this instead of relying on the letter, + * and it takes that slot: + * a single letter would mislead, so the icon says "more here; ask `i`". + */ @property() icon = ""; + + /** + * disabled suppresses activation. + * Disabled is `aria-disabled`, never the native disabled attribute: + * the button stays perceivable (and standalone: focusable). + * `i` answers even when disabled: it is how you ask why a thing is off. + */ @property({ type: Boolean, reflect: true }) disabled = false; + + /** + * active marks the in-effect option among sibling choices, with the selected wash and `aria-current` -- + * and still clickable: re-issuing an in-effect choice is the norm's contract + * (owners decide whether re-issue means anything). + * Not `aria-pressed`: these are not toggles; activating an active button never un-presses it. + */ @property({ type: Boolean }) active = false; - // The info content, mirrored synchronously onto data-info: that attribute is - // the carrier contract yk-infopop's hover trigger finds elements by - // (decision-info-content: one authored content, two triggers), and mirroring - // keeps this property a typed accessor over the same single source. + /** + * #info is the info content, mirrored synchronously onto `data-info`: + * that attribute is the carrier contract yk-infopop's hover trigger finds elements by + * (decision-info-content: one authored content, two triggers), + * and mirroring keeps the `info` property a typed accessor over the same single source. + */ #info = ""; + + /** + * info is the text for the `i` norm: + * pressing `i` (forwarded in a row, or while focused standalone) + * dispatches a composed `yk-info` event carrying this text; yk-infopop renders the popup. + * Also mirrored onto `data-info` for yk-infopop's hover trigger. + * Empty means not a carrier. + */ @property() get info(): string { return this.#info; @@ -185,13 +202,22 @@ export class YkButton extends LitElement { else this.dataset.info = v; } - // The hotkey, normalized to lowercase, mirrored synchronously onto - // data-mc-hotkey so the megacolumn controller's scan sees the marking without - // waiting for this component's own render. Validation matches the scan's - // (single [a-z0-9]) so a bad hotkey fails loudly in standalone contexts too, - // where no scan exists. x and i are legal but conventionally left unmarked: - // the row norms answer them (see the megacolumn DOM contract's guideline). + /** + * #hotkey is the hotkey, normalized to lowercase, mirrored synchronously onto `data-mc-hotkey` + * so the megacolumn controller's scan sees the marking without waiting for this component's own render. + * Validation (in the setter) matches the scan's (single `[a-z0-9]`) + * so a bad hotkey fails loudly in standalone contexts too, where no scan exists. + */ #hotkey = ""; + + /** + * hotkey is a single `[a-z0-9]` character, case-insensitive; setting anything else throws. + * In a megacolumn row, pressing it moves logical focus to this row and Enter/Space then activate (see-before-commit); + * with {@link hotkeyDirect} the press activates outright. + * Standalone, it activates while the button holds focus. + * `x` and `i` are legal but conventionally left unmarked: + * the row norms answer them (see the megacolumn DOM contract's guideline). + */ @property() get hotkey(): string { return this.#hotkey; @@ -206,15 +232,22 @@ export class YkButton extends LitElement { else this.dataset.mcHotkey = norm; } - // Direct activation: the marked hotkey activates this button immediately - // (focus moving to its row as part of the act) instead of only moving focus - // there. Mirrored synchronously onto data-mc-hotkey-direct, the controller's - // half of the contract, the same way hotkey mirrors onto data-mc-hotkey. - // The deliberately long name is friction: skipping see-before-commit is - // legitimate where an undo semantic exists (the guidelines' example: a map - // move order is cheap to cancel, so just give the order). No undo? Leave - // this off and let the letter jump; Enter is close by. + /** + * #hotkeyDirect is mirrored synchronously onto `data-mc-hotkey-direct`, the controller's half of the contract, + * the same way hotkey mirrors onto `data-mc-hotkey`. + */ #hotkeyDirect = false; + + /** + * hotkeyDirect (attribute `hotkey-direct`) is direct activation: + * the marked hotkey activates this button immediately + * (focus moving to its row as part of the act) instead of only moving focus there. + * + * The deliberately long name is friction: + * skipping see-before-commit is only legitimate where an undo semantic exists + * (the guidelines' example: a map move order is cheap to cancel, so just give the order). + * No undo? Leave this off and let the letter jump; Enter is close by. + */ @property({ type: Boolean, attribute: "hotkey-direct" }) get hotkeyDirect(): boolean { return this.#hotkeyDirect; @@ -238,7 +271,7 @@ export class YkButton extends LitElement { ); } if (this.#row !== null) { - this.#row.addEventListener("megacolumn-key", this.#onMegacolumnKey); + this.#row.addEventListener(MEGACOLUMN_KEY_EVENT, this.#onMegacolumnKey); } else { this.addEventListener("keydown", this.#onKeydown); } @@ -257,7 +290,7 @@ export class YkButton extends LitElement { disconnectedCallback(): void { super.disconnectedCallback(); - this.#row?.removeEventListener("megacolumn-key", this.#onMegacolumnKey); + this.#row?.removeEventListener(MEGACOLUMN_KEY_EVENT, this.#onMegacolumnKey); this.#row = null; this.removeEventListener("keydown", this.#onKeydown); this.removeEventListener("click", this.#onActivated); @@ -268,7 +301,7 @@ export class YkButton extends LitElement { this.#releaseHold(); } - // Row-mode claims; see the file header for the claim table. + /** #onMegacolumnKey answers forwarded keys in row mode; the {@link YkButton} class doc has the list of which keys it claims. */ #onMegacolumnKey = (e: Event): void => { if (e.defaultPrevented) return; // someone earlier in the row claimed it const { key, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail; @@ -277,7 +310,7 @@ export class YkButton extends LitElement { if ((key === "Enter" || key === " ") && atUs) { this.#claimActivation(e); } else if (this.#hotkey !== "" && k === this.#hotkey && this.#hotkeyDirect) { - // A non-direct hotkey is deliberately NOT claimed: the decline is what + // A non-direct hotkey is deliberately not claimed: the decline is what // lets the controller resolve the marking as a focus move to this row. this.#claimActivation(e); } else if ((k === "x" || key === "Delete") && this.#position() === "aux") { @@ -288,7 +321,7 @@ export class YkButton extends LitElement { } }; - // Standalone: the hotkey and i answer while the button holds focus. + /** #onKeydown is standalone mode: the hotkey and `i` answer while the button holds focus. */ #onKeydown = (e: KeyboardEvent): void => { if (e.ctrlKey || e.altKey || e.metaKey) return; const k = e.key.toLowerCase(); @@ -305,12 +338,15 @@ export class YkButton extends LitElement { if (this.disabled) e.stopPropagation(); }; - // Every successful activation is a click on this host, whatever the trigger, - // so the flash hooks here and keyboard activations get the same visual - // acknowledgment as a mouse press. The disabled check repeats the - // suppressor's: a direct host-level click() never crosses the shadow root. - // In row mode the click is claimed even when disabled: nothing may activate, - // but the controller must not synthesize an activation from it either. + /** + * #onActivated is registered as the host's `click` listener in connectedCallback. + * Every activation path ends in a `click` event on this host (the keyboard paths call `this.click()`), + * so this one listener sees every activation and gives it the flash; + * that is how keyboard activations get the same visual acknowledgment as a mouse press. + * The disabled check repeats the suppressor's: a direct host-level click() never crosses the shadow root. + * In row mode the click is claimed (preventDefault) even when disabled: + * nothing may activate, but the controller must not synthesize an activation from it either. + */ #onActivated = (e: Event): void => { if (this.#row !== null) e.preventDefault(); if (this.disabled) return; @@ -326,8 +362,10 @@ export class YkButton extends LitElement { this.classList.remove("yk-held"); }; - // A claimed key is consumed whether or not the button acts: a disabled - // button's Space must not fall through and scroll the column. + /** + * #claimActivation consumes a claimed key whether or not the button acts: + * a disabled button's Space must not fall through and scroll the column. + */ #claimActivation(e: Event): void { e.preventDefault(); if (this.disabled) return; @@ -355,7 +393,7 @@ export class YkButton extends LitElement { const ariaDisabled = this.disabled ? "true" : undefined; const ariaCurrent = this.active ? "true" : undefined; const ariaKeys = this.#hotkey !== "" ? this.#hotkey : undefined; - // The label wrapper keeps the (possibly marking-split) label ONE flex item: + // The label wrapper keeps the (possibly marking-split) label one flex item: // .btn is a flex container, and three anonymous items would have the // whitespace at their edges trimmed ("cycle beacon" -> "cyclebeacon"). const content = html`<span class="label">${this.#content(marked)}</span>${ @@ -379,9 +417,11 @@ export class YkButton extends LitElement { >${content}</button>`; } - // Index of the letter-in-label marking: the first case-insensitive occurrence - // of the hotkey. -1 when unmarked (no hotkey, no occurrence, or an icon - // claiming the slot -- a complex button must not imply one letter is the story). + /** + * #labelMarkIndex is the index of the letter-in-label marking: the first case-insensitive occurrence of the hotkey. + * -1 when unmarked (no hotkey, no occurrence, or an icon claiming the slot -- + * a complex button must not imply one letter is the story). + */ #labelMarkIndex(): number { if (this.#hotkey === "" || this.icon !== "") return -1; return this.label.toLowerCase().indexOf(this.#hotkey); diff --git a/packages/yukey/src/yk-info.ts b/packages/yukey/src/yk-info.ts index 279a9c9..9d9df8f 100644 --- a/packages/yukey/src/yk-info.ts +++ b/packages/yukey/src/yk-info.ts @@ -1,31 +1,39 @@ // The info norm's wiring: the yk-info event shape and the data-info carrier contract. -// This file is the SSOT for both. (The norm itself: docs/precepts-we-love-the-keyboard.md, -// "info pops" -- i means "info, please" on almost any element.) -// -// One authored content, two triggers: -// -// - `data-info="<content>"` on any element makes it a CARRIER. yk-infopop's -// hover trigger finds carriers by this attribute, and the megacolumn -// controller answers otherwise-unclaimed i forwards for them. Components -// with an info property (yk-button) mirror it onto this attribute; -// plain markup writes the attribute directly. Empty means not a carrier. -// - `yk-info` (bubbles + composed) is the keyboard trigger's event: a producer -// that answered i dispatches it, yk-infopop renders it. The detail carries -// the anchor element because composed events retarget: a listener outside -// the shadow tree sees only the host chain, never the real source. -// -// Producers today: yk-button (its own i claim, standalone and in rows) and -// MegacolumnController (the fallback for plain [data-info] rows). +// This file is the SSOT for both, on YkInfoDetail. +// (The norm itself: docs/precepts-we-love-the-keyboard.md, "info pops" -- i means "info, please" on almost any element.) +/** YK_INFO_EVENT is the name of the event whose detail is {@link YkInfoDetail}. */ +export const YK_INFO_EVENT = "yk-info"; + +/** + * YkInfoDetail is the `yk-info` event's detail, and the SSOT for the info norm's wiring. + * One authored content, two triggers: + * + * - `data-info="<content>"` on any element makes it a carrier. + * yk-infopop's hover trigger finds carriers by this attribute, + * and the megacolumn controller answers otherwise-unclaimed `i` forwards for them. + * Components with an info property (yk-button) mirror it onto this attribute; plain markup writes the attribute directly. + * Empty means not a carrier. + * - `yk-info` (bubbles + composed) is the keyboard trigger's event: + * a producer that answered `i` dispatches it ({@link dispatchYkInfo}), yk-infopop renders it. + * The detail carries the anchor element because composed events retarget: + * a listener outside the shadow tree sees only the host chain, never the real source. + */ export interface YkInfoDetail { content: string; - // The element to anchor the popup to (see the retargeting note above). + + /** source is the element to anchor the popup to (see the retargeting note on {@link YkInfoDetail}). */ source: HTMLElement; } +/** + * dispatchYkInfo dispatches a `yk-info` event from source. + * Producers today: yk-button (its own `i` claim, standalone and in rows) + * and MegacolumnController (the fallback for plain [data-info] rows). + */ export function dispatchYkInfo(source: HTMLElement, content: string): void { source.dispatchEvent( - new CustomEvent<YkInfoDetail>("yk-info", { + new CustomEvent<YkInfoDetail>(YK_INFO_EVENT, { detail: { content, source }, bubbles: true, composed: true, diff --git a/packages/yukey/src/yk-infopop.ts b/packages/yukey/src/yk-infopop.ts index f233a4c..7d110d8 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"; +import { YK_INFO_EVENT, 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; } @@ -111,13 +126,13 @@ export class YkInfopop extends LitElement { const root = this.getRootNode(); this.#root = root; // Triggers, scoped to the tree this popup serves. The hover carrier is - // tracked from pointermove, NOT pointerover: boundary events are suppressed + // tracked from pointermove, not pointerover: boundary events are suppressed // at this root whenever a move stays inside one descendant shadow root // (target and relatedTarget both retarget to the same host, so the pair is // never dispatched here) -- a row-to-aux move inside a component would be // invisible. pointermove has no relatedTarget, is never suppressed, and // its composedPath still names the deep target through open shadow roots. - root.addEventListener("yk-info", this.#onYkInfo); + root.addEventListener(YK_INFO_EVENT, this.#onYkInfo); root.addEventListener("pointermove", this.#onPointermove); root.addEventListener("pointerout", this.#onPointerout); // Dismissals, global by nature. Capture phase: closing must precede the @@ -134,7 +149,7 @@ export class YkInfopop extends LitElement { this.#dismissal = null; const root = this.#root; if (root !== null) { - root.removeEventListener("yk-info", this.#onYkInfo); + root.removeEventListener(YK_INFO_EVENT, this.#onYkInfo); root.removeEventListener("pointermove", this.#onPointermove); root.removeEventListener("pointerout", this.#onPointerout); this.#root = null; @@ -162,14 +177,16 @@ export class YkInfopop extends LitElement { #onPointerout = (e: Event): void => { // Movement within the served tree is fully handled by pointermove. The // cases only pointerout can see: the pointer leaving the window entirely - // (relatedTarget null), and the pointer leaving for an element OUTSIDE + // (relatedTarget null), and the pointer leaving for an element outside // the tree this popup serves -- no pointermove will reach us from there. const to = (e as PointerEvent).relatedTarget; 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; @@ -190,7 +209,7 @@ export class YkInfopop extends LitElement { if (carrier !== null && !(this.#mode === "hover" && carrier === this.#anchor)) { this.#timer = window.setTimeout(() => { this.#timer = undefined; - // A pinned popup outranks hover: skip, and do NOT linger a re-try -- + // A pinned popup outranks hover: skip, and do not linger a re-try -- // after the pinned one closes, a popup should only appear on a fresh // pointer or key action, never spontaneously. if (this.#mode === "pinned" || carrier !== this.#candidate) return; @@ -202,7 +221,7 @@ export class YkInfopop extends LitElement { #onKeydown = (e: KeyboardEvent): void => { // Bare modifiers close nothing (Shift held for a capital must not blink the // popup away); every other key closes. Almost never consumed or stopped -- - // the megacolumn (and everything else) hears the key untouched -- with ONE + // the megacolumn (and everything else) hears the key untouched -- with one // exception: an Escape that closes the popup is consumed. Escape unwinds // layers one at a time (the guidelines' focus-lifecycle norm), and the popup // is the innermost layer: the same press must not also close a panel below. @@ -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; diff --git a/packages/yukey/src/yk-list.ts b/packages/yukey/src/yk-list.ts index d11e310..945f63d 100644 --- a/packages/yukey/src/yk-list.ts +++ b/packages/yukey/src/yk-list.ts @@ -1,129 +1,57 @@ // 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"; import { adoptIntoRoot } from "./adopt-into-root.js"; -import type { MegacolumnKeyDetail } from "./megacolumn-controller.js"; +import { + MEGACOLUMN_FOCUS_REQUEST_EVENT, + MEGACOLUMN_KEY_EVENT, + MEGACOLUMN_RESCAN_EVENT, + type MegacolumnFocusRequestDetail, + type MegacolumnKeyDetail, +} from "./megacolumn-controller.js"; import type { LiveListPhase } from "./yk-live-list.js"; import "./yk-live-list.js"; +/** YK_LIST_ADD_EVENT is the name of the event whose detail is {@link YkListAddDetail}. */ +export const YK_LIST_ADD_EVENT = "yk-list-add"; + +/** + * 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 +98,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() { @@ -209,10 +249,10 @@ export class YkList<T = unknown> extends LitElement { // from row controls, so this listener only ever hears the list's own rows -- // and hears them before the container's marking resolution (inner scope // wins by dispatch order, for free). - this.addEventListener("megacolumn-key", this.#onListKey); + this.addEventListener(MEGACOLUMN_KEY_EVENT, 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( @@ -253,7 +293,7 @@ export class YkList<T = unknown> extends LitElement { // Structural rows appear and disappear on this element's own update // schedule, after the owner's hostUpdated scan already ran -- announce, // same contract as yk-live-list (re-scans are idempotent). - this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); + this.dispatchEvent(new CustomEvent(MEGACOLUMN_RESCAN_EVENT, { bubbles: true })); // Group identity for AT (the header-role decision; see the file header). // An author-set role is left alone; only our own role="group" is retracted // when the header goes away. @@ -266,12 +306,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,9 +336,9 @@ 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", { + const req = new CustomEvent<MegacolumnFocusRequestDetail>(MEGACOLUMN_FOCUS_REQUEST_EVENT, { detail: { rowId }, bubbles: true, cancelable: true, @@ -304,13 +347,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") { @@ -323,7 +367,7 @@ export class YkList<T = unknown> extends LitElement { input.value = ""; return; // an empty commit is no commit; the exit proceeds } - const commit = new CustomEvent<YkListAddDetail>("yk-list-add", { + const commit = new CustomEvent<YkListAddDetail>(YK_LIST_ADD_EVENT, { detail: { text }, bubbles: true, cancelable: true, @@ -374,8 +418,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 +436,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>`; diff --git a/packages/yukey/src/yk-live-list.ts b/packages/yukey/src/yk-live-list.ts index 849a2b5..4f141d6 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"; @@ -102,25 +21,34 @@ import { customElement, property, query } from "lit/decorators.js"; 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. +import { MEGACOLUMN_RESCAN_EVENT } from "./megacolumn-controller.js"; + +/** + * 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. */ @@ -136,7 +64,7 @@ const lifecycleStyles = css` transition: grid-template-rows var(--yk-live-list-collapse, 300ms) ease; /* The rowslot is pure geometry machinery and must be free to reach 0 height (closed rows, and both ends of the grow/collapse animations). Owners set - hit-target floors on their row CONTENT; in mega mode a [data-mc-row] + hit-target floors on their row content; in mega mode a [data-mc-row] min-height rule would land on this element and clamp the animations open (entrances pop, collapses snap, pending rows hold blank space). */ min-height: 0; @@ -166,8 +94,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 +106,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 +144,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 +235,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; @@ -304,7 +382,7 @@ export class YkLiveList<T = unknown> extends LitElement { // which keeps hover coverage. this.addEventListener("pointerenter", this.onPointerEnter); this.addEventListener("pointerleave", this.onPointerLeave); - // The click guard intercepts in the CAPTURE phase on the host: it must beat + // The click guard intercepts in the capture phase on the host: it must beat // both the row content's own listeners (a yk-button acting on a stale click) // and, in mega mode, the megacolumn controller's bubble-phase mousedown // focus-sync on the ancestor container -- stopping propagation here covers @@ -314,10 +392,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; @@ -385,13 +465,13 @@ export class YkLiveList<T = unknown> extends LitElement { } // Mega mode: any completed render may have changed the row set under the // megacolumn scan -- phase timers re-render outside host renders entirely, and - // even host-driven data changes land here AFTER the owner's own hostUpdated + // even host-driven data changes land here after the owner's own hostUpdated // scan ran (this element updates asynchronously, later in the same task). // Announce every time; re-scans are idempotent and O(rows). - if (this.mega) this.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); + if (this.mega) this.dispatchEvent(new CustomEvent(MEGACOLUMN_RESCAN_EVENT, { 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"; } @@ -519,7 +599,7 @@ export class YkLiveList<T = unknown> extends LitElement { (this.megaContainer ?? this.itemsEl)?.focus(); } // The row's guard entries outlive it (the guard tail); re-anchor them to the - // display neighbor above, which is the topmost row that did NOT move. Entries + // display neighbor above, which is the topmost row that did not move. Entries // already anchored on this row re-anchor the same way (a cascade of collapses). const keys = [...this.rows.keys()]; const idx = keys.indexOf(row.key); @@ -536,18 +616,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 +668,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 +682,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 +710,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 +737,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); @@ -662,19 +757,21 @@ export class YkLiveList<T = unknown> extends LitElement { this.keyPinned = false; this.releaseDeferred(); }, this.keyPinMs); - // Focus may have moved OFF a deferred ghost since it was deferred; sweep so a + // Focus may have moved off a deferred ghost since it was deferred; sweep so a // stale deferral dies with the aim rather than riding out continuous typing. // (This listener may run before the controller's on the same keydown, so a // focus-moving key can be one keydown late here -- and the decay backstops it.) 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 +804,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; @@ -789,7 +890,7 @@ export class YkLiveList<T = unknown> extends LitElement { // closed to sight and closed to navigation together. const closed = row.phase === "pending" || row.phase === "collapsing"; const phase: LiveListPhase = row.phase === "committed" || row.phase === "collapsing" ? "committed" : "live"; - // Mega mode stamps row identity for the megacolumn scan (see the header). + // Mega mode stamps row identity for the megacolumn scan (the MegacolumnController class doc has the attribute contract). // classMap (not string interpolation) because the megacolumn controller stamps // its focus classes on these elements imperatively, and an interpolated class // attribute would wipe them on the next content tick. @@ -804,9 +905,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( diff --git a/packages/yukey/tsconfig.build.json b/packages/yukey/tsconfig.build.json index 98ded3f..c06f451 100644 --- a/packages/yukey/tsconfig.build.json +++ b/packages/yukey/tsconfig.build.json @@ -4,10 +4,8 @@ "noEmit": false, "rootDir": "src", "outDir": "dist", - "declaration": true, - "declarationMap": true, - "sourceMap": true + "declaration": true }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.gallery.ts", "src/gallery"] }