events: every event name is an exported constant beside its detail type
commit bdc1230fa7d231c65bd73e3db457d7e2ad8b2e44 · parent f2c451cb3e
· browse this tree
Ximianth, 2026-10-24 10:52:26 UTC
MEGACOLUMN_KEY_EVENT, MEGACOLUMN_FOCUS_CHANGE_EVENT, MEGACOLUMN_RESCAN_EVENT, MEGACOLUMN_FOCUS_REQUEST_EVENT, YK_INFO_EVENT, YK_LIST_ADD_EVENT. Every dispatch and listener in the kit goes through them; the tests keep the string literals, since they pin the wire names. megacolumn-focus-request gets a MegacolumnFocusRequestDetail interface (yk-list dispatched it untyped), and the rescan and focus-request paragraphs move from the MegacolumnController class doc onto those two declarations, words unchanged, with the class doc linking them. yk-button and yk-live-list now import the controller module at runtime for a constant where before they had a type import or none. The module is side-effect-free (sideEffects lists only the four components), so bundlers reduce the import to the string.
diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts index 329cbe5..69d1061 100644 --- a/packages/yukey/src/megacolumn-controller.ts +++ b/packages/yukey/src/megacolumn-controller.ts @@ -6,9 +6,11 @@ // so registration is rendering and can never drift from it. // // The DOM contract and the event contracts are documented on the declarations that carry them: -// MegacolumnController (the attributes it scans, megacolumn-rescan, megacolumn-focus-request), +// MegacolumnController (the attributes it scans), // MegacolumnKeyDetail (megacolumn-key: forwarded keys, claims, pointer activation), -// and MegacolumnFocusChangeDetail (megacolumn-focus-change). +// 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. // // 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 -- @@ -40,6 +42,9 @@ 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 @@ -68,6 +73,9 @@ export interface MegacolumnKeyDetail { position: RowPosition; } +/** 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). @@ -85,6 +93,36 @@ export interface MegacolumnFocusChangeDetail { rowId: string | null; } +/** + * 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; @@ -182,23 +220,8 @@ export function closedToSight(el: HTMLElement): boolean { * * Forwarded keys and pointer activation arrive as {@link MegacolumnKeyDetail} events; * focus identity changes are announced as {@link MegacolumnFocusChangeDetail} events. - * - * 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. - * - * 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. + * 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. @@ -269,11 +292,11 @@ export class MegacolumnController implements ReactiveController { container.addEventListener("focusout", (e) => this.onFocusout(e)); // Components whose rows change outside host renders announce it with this // event (see the MegacolumnController class doc); a render-driven scan happens in hostUpdated as always. - container.addEventListener("megacolumn-rescan", () => { + 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(); @@ -442,7 +465,7 @@ export class MegacolumnController implements ReactiveController { 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, }), @@ -479,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, @@ -515,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, @@ -571,7 +594,7 @@ export class MegacolumnController implements ReactiveController { } /** - * onFocusRequest handles the megacolumn-focus-request event (see {@link MegacolumnController}): + * 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. @@ -633,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, diff --git a/packages/yukey/src/yk-button.ts b/packages/yukey/src/yk-button.ts index 31a2ddf..b46f6e7 100644 --- a/packages/yukey/src/yk-button.ts +++ b/packages/yukey/src/yk-button.ts @@ -51,7 +51,7 @@ 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"; @@ -274,7 +274,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); } @@ -293,7 +293,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); diff --git a/packages/yukey/src/yk-info.ts b/packages/yukey/src/yk-info.ts index 6801083..9d9df8f 100644 --- a/packages/yukey/src/yk-info.ts +++ b/packages/yukey/src/yk-info.ts @@ -2,6 +2,9 @@ // 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: @@ -30,7 +33,7 @@ export interface YkInfoDetail { */ 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 e48c481..7d110d8 100644 --- a/packages/yukey/src/yk-infopop.ts +++ b/packages/yukey/src/yk-infopop.ts @@ -5,7 +5,7 @@ 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; @@ -132,7 +132,7 @@ export class YkInfopop extends LitElement { // 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 @@ -149,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; diff --git a/packages/yukey/src/yk-list.ts b/packages/yukey/src/yk-list.ts index ad977c8..945f63d 100644 --- a/packages/yukey/src/yk-list.ts +++ b/packages/yukey/src/yk-list.ts @@ -15,10 +15,19 @@ 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). @@ -240,7 +249,7 @@ 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); } /** createRenderRoot is light DOM, no slots (see {@link YkList}). Styles adopt into the owner's root. */ @@ -284,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. @@ -329,7 +338,7 @@ export class YkList<T = unknown> extends LitElement { /** #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, @@ -358,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, diff --git a/packages/yukey/src/yk-live-list.ts b/packages/yukey/src/yk-live-list.ts index 05d63a9..4f141d6 100644 --- a/packages/yukey/src/yk-live-list.ts +++ b/packages/yukey/src/yk-live-list.ts @@ -21,6 +21,7 @@ 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"; +import { MEGACOLUMN_RESCAN_EVENT } from "./megacolumn-controller.js"; /** * LiveListPhase is the phase vocabulary owners see: @@ -467,7 +468,7 @@ export class YkLiveList<T = unknown> extends LitElement { // 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 })); } /** frozen is whether freeze mode governs the pointer story right now (see {@link pointerSafety}). */