yukey

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}). */