megacolumn-controller: contracts move onto the declarations that carry them
commit 67070847d256403d46b53fe18d52278d25297011 · parent 62e529c9f2
· browse this tree
Ximianth, 2026-10-18 10:30:59 UTC
The DOM contract (the data attributes the scan reads, as bullets) and the
megacolumn-rescan and megacolumn-focus-request contracts now live on the
MegacolumnController class doc, so they reach dist/megacolumn-controller.d.ts
and hover. The megacolumn-key contract, including the definition of a
claim (preventDefault on the forwarded event) and pointer activation, lives
on MegacolumnKeyDetail; the focus-change contract on
MegacolumnFocusChangeDetail, whose "see the header's event contract" pointer
is thereby resolved. Every other documented member, public or private, gets
its comment as a /** */ block starting with its name; trailing field
comments become blocks too. The constants, Host, closedToSight, and
megacolumnStyles (class table now bullets) likewise.
The header keeps the module's purpose, the churn lockout, and the
column-scoped hotkey resolution: cross-cutting intent with no single
declaration. "See the header" references become {@link}s to the
declaration that now holds the content. Wording is unchanged apart from
lowercasing emphasis, backticks on attribute and class names, and the
name-first prefixes; lines break at clauses.
diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts index 2d1a2d5..4315c93 100644 --- a/packages/yukey/src/megacolumn-controller.ts +++ b/packages/yukey/src/megacolumn-controller.ts @@ -1,222 +1,236 @@ -// 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, megacolumn-rescan, megacolumn-focus-request), +// MegacolumnKeyDetail (megacolumn-key: forwarded keys, claims, pointer activation), +// and MegacolumnFocusChangeDetail (megacolumn-focus-change). // -// [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"; +/** + * 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. +/** + * 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. +/** + * 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. +/** + * 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. + */ 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: 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 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. + * + * 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) { @@ -265,7 +279,7 @@ export class MegacolumnController implements ReactiveController { 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]")) { @@ -306,10 +320,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 +336,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 +346,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 +399,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"); @@ -535,10 +557,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 +570,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 MegacolumnController}): + * 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 +590,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 +607,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; @@ -650,8 +681,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 +720,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 */