Merge branch 'move': the kit, testkit, and e2e suite carried in
commit 56b0416af1d1561582469fa446c07edaa5b7523a · parent 84d8916957 · parent 5c002c2529
· browse this tree
Ximianth, 2026-10-10 05:15:45 UTC
diff --git a/packages/e2e/harness.ts b/packages/e2e/harness.ts new file mode 100644 index 0000000..2a50241 --- /dev/null +++ b/packages/e2e/harness.ts @@ -0,0 +1,194 @@ +// The e2e harness: serves the built gallery bundle statically and launches a +// fresh headless Chrome for one suite run, and tears both down unconditionally. +// +// Scenarios know nothing about process management: they receive a connected +// CDP client and the server's base URL, navigate themselves, and assert. +// Everything here is per-run and ephemeral: fresh Chrome profile, ephemeral +// port for the static server, no shared state with anything else. + +import { type ChildProcess, spawn } from "node:child_process"; +import { accessSync, constants, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { extname, join, normalize } from "node:path"; +import { Cdp } from "yukey-testkit/cdp"; +import { sleep } from "yukey-testkit/sleep"; + +// The gallery's static dir, where `npm run build` in packages/yukey puts the bundle +// beside gallery.html (resolved from this file's location, packages/e2e/harness.ts). +const STATIC_DIR = join(import.meta.dirname, "..", "yukey", "static"); + +// The Chrome binary: headless-shell by default (what this machine has), +// overridable for other environments. +const CHROME_BIN = process.env.YUKEY_E2E_CHROME ?? "/headless-shell/headless-shell"; + +// The viewport for every scenario: realistic size, never a peephole. +const WINDOW_SIZE = "2000,1100"; + +const MIME: Record<string, string> = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +}; + +async function pollUntil( + what: string, + timeoutMs: number, + probe: () => Promise<boolean>, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probe()) return; + await sleep(100); + } + throw new Error(`timed out waiting for ${what}`); +} + +// Serves STATIC_DIR read-only on an ephemeral port. Paths are normalized and +// pinned under STATIC_DIR; anything else is a 404 (a test server, not a product). +function serveStatic(): Promise<{ baseUrl: string; server: Server }> { + const srv = createServer((req, res) => { + const rel = normalize(decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname)); + const file = join(STATIC_DIR, rel === "/" ? "gallery.html" : rel); + if (!file.startsWith(STATIC_DIR)) { + res.writeHead(404).end(); + return; + } + try { + const body = readFileSync(file); + res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); + return new Promise((resolve, reject) => { + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr === null || typeof addr === "string") { + reject(new Error("static server: unexpected address shape")); + return; + } + resolve({ baseUrl: `http://127.0.0.1:${addr.port}`, server: srv }); + }); + srv.on("error", reject); + }); +} + +export class Harness { + readonly baseUrl: string; + readonly cdp: Cdp; + private readonly server: Server; + private readonly chrome: ChildProcess; + private readonly profileDir: string; + private stopped = false; + + private constructor( + baseUrl: string, + cdp: Cdp, + server: Server, + chrome: ChildProcess, + profileDir: string, + ) { + this.baseUrl = baseUrl; + this.cdp = cdp; + this.server = server; + this.chrome = chrome; + this.profileDir = profileDir; + } + + static async start(): Promise<Harness> { + // Preflight: the bundle and the browser must exist before anything is spawned. + try { + accessSync(join(STATIC_DIR, "gallery.js"), constants.R_OK); + } catch { + throw new Error(`gallery bundle missing at ${STATIC_DIR}; run \`make build\` first`); + } + try { + accessSync(CHROME_BIN, constants.X_OK); + } catch { + throw new Error( + `chrome binary missing at ${CHROME_BIN}; ` + + "install headless-shell there, or point YUKEY_E2E_CHROME at a chromium binary", + ); + } + + const { baseUrl, server } = await serveStatic(); + const profileDir = mkdtempSync(join(tmpdir(), "yukey-e2e-chrome-")); + const chrome = spawn( + CHROME_BIN, + [ + "--headless", + "--no-sandbox", + "--disable-dev-shm-usage", + `--window-size=${WINDOW_SIZE}`, + "--remote-debugging-port=0", + `--user-data-dir=${profileDir}`, + "about:blank", + ], + // Its own process group, so teardown can kill the whole Chrome tree at once. + { stdio: "ignore", detached: true }, + ); + + const kill = () => { + server.close(); + if (chrome.pid !== undefined) { + try { + process.kill(-chrome.pid, "SIGKILL"); + } catch {} + } + rmSync(profileDir, { recursive: true, force: true }); + }; + + try { + // Chrome publishes its chosen debugging port in the profile dir. + let debugPort = 0; + await pollUntil("chrome DevToolsActivePort", 15000, async () => { + try { + const first = readFileSync(join(profileDir, "DevToolsActivePort"), "utf8").split("\n")[0]; + debugPort = Number(first); + return Number.isInteger(debugPort) && debugPort > 0; + } catch { + return false; + } + }); + + // Find the page target and connect. + let wsUrl = ""; + await pollUntil("chrome page target", 15000, async () => { + try { + const r = await fetch(`http://127.0.0.1:${debugPort}/json`); + const targets = (await r.json()) as { type: string; webSocketDebuggerUrl?: string }[]; + const page = targets.find((t) => t.type === "page"); + if (page?.webSocketDebuggerUrl === undefined) return false; + wsUrl = page.webSocketDebuggerUrl; + return true; + } catch { + return false; + } + }); + + const cdp = await Cdp.connect(wsUrl); + return new Harness(baseUrl, cdp, server, chrome, profileDir); + } catch (err) { + kill(); + throw err; + } + } + + // Idempotent, unconditional teardown: close the socket, stop the server, + // kill the Chrome process tree, remove the profile dir. + stop(): void { + if (this.stopped) return; + this.stopped = true; + try { + this.cdp.close(); + } catch {} + this.server.close(); + if (this.chrome.pid !== undefined) { + try { + process.kill(-this.chrome.pid, "SIGKILL"); + } catch {} + } + rmSync(this.profileDir, { recursive: true, force: true }); + } +} diff --git a/packages/e2e/main.ts b/packages/e2e/main.ts new file mode 100644 index 0000000..77a8c29 --- /dev/null +++ b/packages/e2e/main.ts @@ -0,0 +1,66 @@ +// The e2e suite entrypoint: `npm run test:e2e` (or `make check-e2e` from the root). +// +// One harness per run (the gallery bundle served statically + a fresh headless Chrome), +// all scenarios against it, unconditional teardown, non-zero exit on any failure. +// Scenario names as CLI args select a subset: `npm run test:e2e -- mc ip`. + +import { Checks, type Scenario } from "yukey-testkit/checks"; +import { Harness } from "./harness.ts"; +import { ipDrive } from "./scenarios/ip-drive.ts"; +import { lcDrive } from "./scenarios/lc-drive.ts"; +import { mcDrive } from "./scenarios/mc-drive.ts"; +import { swDrive } from "./scenarios/sw-drive.ts"; + +const ALL: Scenario[] = [mcDrive, swDrive, ipDrive, lcDrive]; + +async function main(): Promise<void> { + const wanted = process.argv.slice(2); + const unknown = wanted.filter((w) => !ALL.some((s) => s.name === w)); + if (unknown.length > 0) { + console.error( + `unknown scenarios: ${unknown.join(", ")} (have: ${ALL.map((s) => s.name).join(", ")})`, + ); + process.exit(2); + } + const chosen = wanted.length > 0 ? ALL.filter((s) => wanted.includes(s.name)) : ALL; + + const harness = await Harness.start(); + process.on("SIGINT", () => { + harness.stop(); + process.exit(130); + }); + process.on("SIGTERM", () => { + harness.stop(); + process.exit(143); + }); + + let failedScenarios = 0; + const summary: string[] = []; + try { + for (const scenario of chosen) { + console.log(`\n=== ${scenario.name} ===`); + const checks = new Checks(); + let aborted = false; + try { + await scenario.run({ cdp: harness.cdp, baseUrl: harness.baseUrl, checks }); + } catch (err) { + console.log(`ABORT ${scenario.name}: ${err instanceof Error ? err.message : String(err)}`); + aborted = true; + } + console.log(`${scenario.name}: ${checks.passed}/${checks.total} checks passed`); + summary.push( + `${scenario.name} ${checks.passed}/${checks.total}${aborted ? " (aborted)" : ""}`, + ); + if (aborted || checks.failed > 0) failedScenarios += 1; + } + } finally { + harness.stop(); + } + console.log(`\ntotal: ${summary.join(", ")}`); + process.exit(failedScenarios > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/e2e/scenarios/ip-drive.ts b/packages/e2e/scenarios/ip-drive.ts new file mode 100644 index 0000000..e3e93db --- /dev/null +++ b/packages/e2e/scenarios/ip-drive.ts @@ -0,0 +1,322 @@ +// Drives the sw-infopop gallery entry with TRUSTED input via CDP: keys via +// Input.dispatchKeyEvent and real mouse movement via Input.dispatchMouseEvent +// (synthetic pointerover can't prove real hover). Covers: keyboard i on every +// row flavor (sw-button's own claim, the plain-row/slider/aux data-info +// fallback), the i toggle (second i on the same anchor dismisses; scope is one +// keypress), hover raising the SAME content, pin precedence over hover (incl. +// i upgrading a hover popup to pinned), dismissal (nav keys close AND navigate, +// pointerdown closes, no resurrect), aria reflection wiring, edge clamping, +// and the bottom flip (short-viewport emulation). +// Ported from the Python original (demoscene.meta screenshots, stage-infopop). + +import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks"; +import { openGallery } from "yukey-testkit/gallery"; +import { sleep } from "yukey-testkit/sleep"; + +// State through the shadow roots: the popup's mode/content/position, the demo +// column's focused row and log, and the aria reflection on the current anchor. +const STATE_JS = ` +(() => { + const shell = document.querySelector("sw-gallery").shadowRoot; + const pop = shell.querySelector("sw-infopop"); + const col = shell.querySelector("swg-infopop-column")?.shadowRoot; + const rect = pop.getBoundingClientRect(); + const anchorRect = pop.anchor?.getBoundingClientRect() ?? null; + return JSON.stringify({ + mode: pop.mode, + text: pop.shadowRoot.textContent.trim(), + visible: pop.style.visibility === "visible", + ariaHidden: pop.getAttribute("aria-hidden"), + popRect: {x: rect.x, y: rect.y, w: rect.width, h: rect.height}, + anchorRect: anchorRect ? {x: anchorRect.x, y: anchorRect.y, w: anchorRect.width, h: anchorRect.height} : null, + anchorAriaWired: pop.anchor ? (pop.anchor.ariaDescribedByElements?.[0] === pop) : null, + rowFocused: col?.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null, + log: col?.querySelector(".log")?.textContent ?? "", + viewport: {w: window.innerWidth, h: window.innerHeight}, + }); +})() +`; + +interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +interface IpState { + mode: string; + text: string; + visible: boolean; + ariaHidden: string | null; + popRect: Rect; + anchorRect: Rect | null; + anchorAriaWired: boolean | null; + rowFocused: string | null; + log: string; + viewport: { w: number; h: number }; +} + +async function run(ctx: ScenarioContext): Promise<void> { + const { cdp, checks } = ctx; + const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail); + const st = async (): Promise<IpState> => JSON.parse((await cdp.js(STATE_JS)) as string); + const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => { + const box = await cdp.js(` + (() => { + const el = ${jsElExpr}; + const r = el.getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); + })() + `); + return JSON.parse(box as string); + }; + + const COL = + "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-column').shadowRoot"; + + await openGallery( + ctx, + "#sw-infopop", + "document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-infopop-column')", + ); + + // Park the mouse in dead space so no hover state lingers between phases. + await cdp.mouseMove(5, 5); + + // --- keyboard: i on every row flavor --- + let landed = false; + for (let i = 0; i < 12; i++) { + await cdp.key("Tab"); + const focused = await cdp.js( + `${COL}.querySelector('[data-mc-container]').classList.contains('mc-focus-within')`, + ); + if (focused === true) { + landed = true; + break; + } + } + let s = await st(); + check("tab-into-column", landed && s.rowFocused === "dock", JSON.stringify(s.rowFocused)); + + await cdp.key("i"); + s = await st(); + check("i-on-sw-button-pins", s.mode === "pinned" && s.visible, JSON.stringify(s)); + check("i-on-sw-button-content", s.text.includes("an sw-button answers i itself"), s.text); + check("aria-reflection-wired", s.anchorAriaWired === true, JSON.stringify(s.anchorAriaWired)); + check( + "popup-below-anchor", + s.anchorRect !== null && s.popRect.y > s.anchorRect.y, + JSON.stringify(s.popRect), + ); + await shot(cdp, "kb-pinned.png"); + + // The toggle: i again on the same anchor dismisses (no blink-reopen)... + await cdp.key("i"); + s = await st(); + check("i-toggles-off-sw-button", s.mode === "closed", JSON.stringify(s.mode)); + // ... and a fresh press raises it right back (suppression is one keypress). + await cdp.key("i"); + s = await st(); + check( + "i-reopens-sw-button", + s.mode === "pinned" && s.text.includes("an sw-button answers i itself"), + s.text, + ); + + // Navigation closes the popup AND still navigates (never eats keys). + await cdp.key("ArrowDown"); + s = await st(); + check("nav-key-closes", s.mode === "closed" && s.ariaHidden === "true", JSON.stringify(s.mode)); + check("nav-key-still-navigates", s.rowFocused === "survey", JSON.stringify(s.rowFocused)); + + // Plain text row: the controller's data-info fallback answers. + await cdp.key("i"); + s = await st(); + check("i-on-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text); + + // The toggle again, through the other producer (the controller fallback). + await cdp.key("i"); + s = await st(); + check("i-toggles-off-plain-row", s.mode === "closed", JSON.stringify(s.mode)); + await cdp.key("i"); + s = await st(); + check("i-reopens-plain-row", s.mode === "pinned" && s.text.includes("plain text row"), s.text); + + // One keypress closes the old popup and raises the next (i on the next row). + await cdp.key("ArrowDown"); + await cdp.key("i"); + s = await st(); + check("i-moves-with-focus", s.mode === "pinned" && s.text.includes("slider row"), s.text); + + // The slider still works under/after the popup: left/right adjust. + await cdp.key("ArrowRight"); + s = await st(); + check( + "slider-key-closes-and-acts", + s.mode === "closed" && s.log === "reactor power 45%", + JSON.stringify({ mode: s.mode, log: s.log }), + ); + + // The aux x carries its own story, distinct from its row's. + await cdp.key("ArrowDown"); + await cdp.key("i"); + s = await st(); + check("i-on-two-control-main", s.text.includes("probe telemetry feed"), s.text); + await cdp.key("ArrowRight"); + await cdp.key("i"); + s = await st(); + check("i-on-aux-x", s.text.includes("the x's own story"), s.text); + + // Escape closes; nothing resurrects. + await cdp.key("Escape"); + await sleep(800); + s = await st(); + check("escape-closes-no-resurrect", s.mode === "closed", JSON.stringify(s.mode)); + + // --- hover: the same content, raised by the mouse --- + let pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`); + await cdp.mouseMove(pt.x, pt.y); + await sleep(150); + s = await st(); + check("hover-waits-out-the-delay", s.mode === "closed", JSON.stringify(s.mode)); + await sleep(500); + s = await st(); + check( + "hover-raises-same-content", + s.mode === "hover" && s.text.includes("plain text row"), + s.text, + ); + await shot(cdp, "hover-same-content.png"); + + // Hover follows to the aux x (nearest carrier wins over the row's). + pt = await centerOf(`${COL}.querySelector('[data-mc-aux]')`); + await cdp.mouseMove(pt.x, pt.y); + await sleep(500); + s = await st(); + check( + "hover-nearest-carrier", + s.mode === "hover" && s.text.includes("the x's own story"), + s.text, + ); + + // Leaving all carriers closes. + await cdp.mouseMove(5, 5); + await sleep(100); + s = await st(); + check("hover-leave-closes", s.mode === "closed", JSON.stringify(s.mode)); + + // --- precedence: a pinned popup outranks hover --- + // (Real focus is still on the column from the keyboard phase.) + await cdp.key("Home"); + await cdp.key("i"); + s = await st(); + check( + "pin-for-precedence", + s.mode === "pinned" && s.text.includes("an sw-button answers i itself"), + s.text, + ); + pt = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`); + await cdp.mouseMove(pt.x, pt.y); + await sleep(600); + s = await st(); + check( + "hover-never-displaces-pin", + s.mode === "pinned" && s.text.includes("an sw-button answers i itself"), + s.text, + ); + await shot(cdp, "pin-outranks-hover.png"); + // After the pin closes, the parked hover does not resurrect... + await cdp.key("Escape"); + await sleep(600); + s = await st(); + check("no-hover-resurrect-after-pin", s.mode === "closed", JSON.stringify(s.mode)); + // ... but a fresh pointer move re-arms normally. + await cdp.mouseMove(pt.x, pt.y + 1); + await cdp.mouseMove(5, 5); + await sleep(50); + const pt2 = await centerOf(`${COL}.querySelector('[data-mc-row="survey"]')`); + await cdp.mouseMove(pt2.x, pt2.y); + await sleep(500); + s = await st(); + check("fresh-hover-re-arms", s.mode === "hover", JSON.stringify(s.mode)); + + // i on the element the mouse is hovering PINS the hover popup (only pinned + // popups toggle off). Focus must sit on the same row for i to answer. + await cdp.key("ArrowDown"); // focus the survey row; the keydown closes the hover popup + await cdp.mouseMove(5, 5); + await sleep(50); + await cdp.mouseMove(pt2.x, pt2.y); // a fresh move re-raises hover on survey + await sleep(500); + s = await st(); + check("hover-up-for-upgrade", s.mode === "hover", JSON.stringify(s.mode)); + await cdp.key("i"); + s = await st(); + check( + "i-upgrades-hover-to-pin", + s.mode === "pinned" && s.text.includes("plain text row"), + s.text, + ); + // And the freshly pinned popup toggles off like any other; reopen so the + // pointerdown check below still closes something real. + await cdp.key("i"); + s = await st(); + check("upgraded-pin-toggles-off", s.mode === "closed", JSON.stringify(s.mode)); + await cdp.key("i"); + s = await st(); + check("i-reopens-before-click", s.mode === "pinned", JSON.stringify(s.mode)); + + // Pointerdown closes (trusted click in dead space). + await cdp.click(5, 5); + s = await st(); + check("pointerdown-closes", s.mode === "closed", JSON.stringify(s.mode)); + + // --- edges: clamping and the flip --- + const EDGES = + "document.querySelector('sw-gallery').shadowRoot.querySelector('swg-infopop-edges').shadowRoot"; + pt = await centerOf(`${EDGES}.querySelectorAll('sw-button')[1]`); // far right + await cdp.mouseMove(pt.x, pt.y); + await sleep(500); + s = await st(); + const anchor = s.anchorRect as Rect; + const anchorCenter = anchor.x + anchor.w / 2; + const centeredX = anchorCenter - s.popRect.w / 2; + check( + "right-edge-clamps", + s.mode === "hover" && s.popRect.x + s.popRect.w <= s.viewport.w && s.popRect.x < centeredX - 1, + JSON.stringify({ pop: s.popRect, viewport: s.viewport }), + ); + await shot(cdp, "edge-clamp.png"); + await cdp.mouseMove(5, 5); + + // The flip: shrink the viewport so the edges row sits near the bottom, + // then hover it -- the popup must open ABOVE the anchor. + const targetH = await cdp.js(` + (() => { + const el = ${EDGES}.querySelectorAll('sw-button')[0]; + return Math.round(el.getBoundingClientRect().bottom + 20); + })() + `); + await cdp.cmd("Emulation.setDeviceMetricsOverride", { + width: 1280, + height: Number(targetH), + deviceScaleFactor: 1, + mobile: false, + }); + await sleep(200); + pt = await centerOf(`${EDGES}.querySelectorAll('sw-button')[0]`); // far left + await cdp.mouseMove(pt.x, pt.y); + await sleep(500); + s = await st(); + check( + "bottom-edge-flips-above", + s.mode === "hover" && s.anchorRect !== null && s.popRect.y + s.popRect.h <= s.anchorRect.y, + JSON.stringify({ pop: s.popRect, anchor: s.anchorRect }), + ); + check("left-edge-clamps", s.popRect.x >= 0, JSON.stringify(s.popRect)); + await shot(cdp, "edge-flip.png"); + await cdp.cmd("Emulation.clearDeviceMetricsOverride"); + await cdp.mouseMove(5, 5); +} + +export const ipDrive: Scenario = { name: "ip", run }; diff --git a/packages/e2e/scenarios/lc-drive.ts b/packages/e2e/scenarios/lc-drive.ts new file mode 100644 index 0000000..fc4cbf4 --- /dev/null +++ b/packages/e2e/scenarios/lc-drive.ts @@ -0,0 +1,806 @@ +// Drives the sw-list gallery entry (the list region component composed into a +// megacolumn console; sw-live-list under it) with TRUSTED input via CDP. +// +// The demos churn on their own 4s timers; the scenario STOPS those timers up +// front and drives data churn via JS at chosen moments -- data churn is +// programmatic in the real app too, and the thing under test is exactly the +// race between that and trusted keyboard input. Covers: navigation into and +// through live-list rows, ghost navigability (Enter-on-ghost inspects; aux +// gone), aux removal, x/Delete (each flashing the x as acknowledgment), the +// key-activity pin (holds the FOCUSED ghost through a sub-200ms key burst; +// decays keyPinMs=200 after the last key; an UNFOCUSED ghost collapses on its +// own schedule despite the same typing), the pointerSafety split (click-guard, +// the default: hovering defers nothing and a trusted click on a row displaced +// by an in-flight collapse is swallowed while rows above the motion stay +// clickable; freeze, the opt-in: the global hover pin, pending insertions, and +// the pointer-leave release), the churn lockout (an Enter landing right after +// a fallback move is DROPPED; flows again after the window), instant prepend +// under key burst (p), focus memory across Tab-away, the drain-to-empty + +// revive story, and the LIST NORMS, sw-list's own behavior: the header row +// (a nav stop whose focus washes the whole list region) and the add-row +// (a dives straight into text entry -- the direct marking on a text row; +// Enter commits via the cancelable sw-list-add event; Escape abandons), and +// the LIST-SCOPED KEYS: t (back to the list's header) and n (past the list), +// claimed by the sw-list host element off the bubbling megacolumn-key dispatch +// and answered with megacolumn-focus-request; outside the list's rows the +// letters are nobody's. Structural row ids are name-prefixed by sw-list +// (name="queue": queue-header, queue-add). +// Ported from the Python original (demoscene.meta screenshots, stage-livecolumn, +// updated in place by stage-list-component). + +import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks"; +import { openGallery } from "yukey-testkit/gallery"; +import { sleep } from "yukey-testkit/sleep"; + +// demo(i): the i-th swg-livecolumn-demo (0 = refill, 1 = drain). +const demo = (i: number) => + `document.querySelector('sw-gallery').shadowRoot.querySelectorAll('swg-livecolumn-demo')[${i}]`; + +// Full state of one demo column: every rendered rowslot with its phase-visible +// attributes, the machine-visible focus (ring classes + aria), and the log line. +const stateJs = (demoExpr: string) => ` +(() => { + const demo = ${demoExpr}; + const root = demo.shadowRoot; + const container = root.querySelector('[data-mc-container]'); + const rows = [...root.querySelectorAll('[data-key]')].map(el => ({ + key: el.dataset.key, + mcRow: el.dataset.mcRow ?? null, + disabled: el.getAttribute('aria-hidden') === 'true', // "closed": aria-hidden is the one signal now + hidden: el.getAttribute('aria-hidden'), + committed: el.querySelector('.task')?.classList.contains('committed') ?? false, + hasAux: el.querySelector('[data-mc-aux]') !== null, + })); + return JSON.stringify({ + rows, + aria: container.getAttribute('aria-activedescendant'), + focusWithin: container.classList.contains('mc-focus-within'), + emptyFocus: container.classList.contains('mc-empty-focus'), + textMode: container.classList.contains('mc-text-mode'), + inputValue: root.querySelector('.sw-list-add [data-mc-input]')?.value ?? null, + inputFocused: root.activeElement === root.querySelector('.sw-list-add [data-mc-input]'), + // The header-focus region wash: a computed background on the group wrapper + // (driven by the :has() rule in the demo's stylesheet, nothing script-side). + groupWashed: (() => { + const g = root.querySelector('sw-list'); + if (g === null) return null; + const bg = getComputedStyle(g).backgroundColor; + return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'; + })(), + groupOutline: root.querySelector('sw-list') === null ? null + : getComputedStyle(root.querySelector('sw-list')).outlineStyle, + rowFocused: root.querySelector('.mc-row-focused')?.dataset?.mcRow + ?? root.querySelector('.mc-row-focused')?.closest('[data-mc-row]')?.dataset?.mcRow ?? null, + auxFocused: root.querySelector('[data-mc-aux].mc-focused') !== null, + log: root.querySelector('.log')?.textContent ?? '', + idle: root.querySelector('.idle')?.textContent ?? null, + dataIds: demo.tasks.map(t => t.id), + }); +})() +`; + +interface LcRow { + key: string; + mcRow: string | null; + disabled: boolean; + hidden: string | null; + committed: boolean; + hasAux: boolean; +} + +interface LcState { + rows: LcRow[]; + aria: string | null; + focusWithin: boolean; + emptyFocus: boolean; + textMode: boolean; + inputValue: string | null; + inputFocused: boolean; + groupWashed: boolean | null; + groupOutline: string | null; + rowFocused: string | null; + auxFocused: boolean; + log: string; + idle: string | null; + dataIds: string[]; +} + +async function run(ctx: ScenarioContext): Promise<void> { + const { cdp, checks } = ctx; + const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail); + + await openGallery( + ctx, + "#sw-list", + "document.readyState === 'complete' && document.querySelector('sw-gallery')?.shadowRoot?.querySelectorAll('swg-livecolumn-demo')?.length === 2", + ); + + // Determinism: stop both demos' own churn timers; the scenario churns by hand. + await cdp.js(`clearInterval(${demo(0)}.timer); clearInterval(${demo(1)}.timer)`); + + const st = async (i = 0): Promise<LcState> => + JSON.parse((await cdp.js(stateJs(demo(i)))) as string); + const row = (s: LcState, key: string): LcRow | null => s.rows.find((r) => r.key === key) ?? null; + + // Data-side churn, like the real queue does between renders. + const dropHead = async (i = 0): Promise<void> => { + await cdp.js(`const d = ${demo(i)}; d.tasks = d.tasks.slice(1)`); + }; + + await cdp.mouseMove(5, 5); // park the mouse: no hover pin in play unless asked + + // --- phase 1: composition basics ------------------------------------- + let landed = false; + for (let i = 0; i < 12; i++) { + await cdp.key("Tab"); + if ((await st()).focusWithin) { + landed = true; + break; + } + } + let s = await st(); + check("tab-into-column", landed && s.rowFocused === "prepend", s.rowFocused ?? ""); + + const first = s.dataIds[0] as string; + // One down from the prepend row is the LIST HEADER ROW: a normal nav stop + // that speaks for the region. While it holds focus the whole list washes + // (sw-list's own :has() rule); the focus treatment stays on the header + // itself -- the sw-list host draws no outline of its own. + await cdp.key("ArrowDown"); + s = await st(); + check( + "header-row-is-a-nav-stop", + s.rowFocused === "queue-header" && s.aria === "mc-queue-header", + JSON.stringify([s.rowFocused, s.aria]), + ); + check("header-focus-washes-region", s.groupWashed === true, JSON.stringify(s.groupWashed)); + check("no-perimeter-outline-on-group", s.groupOutline === "none", s.groupOutline ?? ""); + const ring = await cdp.js(` + (() => { const h = ${demo(0)}.shadowRoot.querySelector('[data-mc-row="queue-header"]'); + return getComputedStyle(h).outlineStyle; })() + `); + check("ring-stays-on-header-row", ring === "solid", String(ring)); + await shot(cdp, "list-header-focused.png"); + + await cdp.key("ArrowDown"); + s = await st(); + check( + "arrow-into-live-row", + s.rowFocused === first && s.aria === `mc-${first}`, + JSON.stringify([s.rowFocused, s.aria]), + ); + check("wash-follows-header-only", s.groupWashed === false, JSON.stringify(s.groupWashed)); + + await cdp.key("ArrowRight"); + s = await st(); + check("right-reaches-aux", s.auxFocused, JSON.stringify(s.rows)); + + // Enter on the aux removes the task; the row ghosts in place, sheds its aux, + // STAYS focused (identity survives), and position snaps back to main. + // The row also FLASHES (sw-pressed) as instant acknowledgment: the fade to + // ghost alone was too quiet to confirm the keystroke landed. The flash is + // 140ms, so a MutationObserver armed before the keystroke catches it. + await cdp.js(` + (() => { + const root = ${demo(0)}.shadowRoot; + window.__flashSeen = 0; + window.__flashMo = new MutationObserver(muts => { + for (const m of muts) if (m.target.classList.contains('sw-pressed')) window.__flashSeen += 1; + }); + window.__flashMo.observe(root.querySelector('sw-live-list'), + { attributes: true, attributeFilter: ['class'], subtree: true }); + })() + `); + await cdp.key("Enter"); + s = await st(); + let g = row(s, first); + const flashes = await cdp.js("window.__flashMo.disconnect(), window.__flashSeen"); + check("aux-enter-removes", s.log.includes("removed") && !s.dataIds.includes(first), s.log); + check("removal-flashes-the-row", typeof flashes === "number" && flashes >= 1, String(flashes)); + check( + "ghost-in-place", + g !== null && g.mcRow === first && g.committed && !g.disabled, + JSON.stringify(g), + ); + check("ghost-sheds-aux", g !== null && !g.hasAux, JSON.stringify(g)); + check( + "ghost-keeps-focus", + s.rowFocused === first && !s.auxFocused, + JSON.stringify([s.rowFocused, s.auxFocused]), + ); + await shot(cdp, "ghost-focused.png"); + + // Enter on the ghost still means something: inspect, not error, not removal. + await cdp.key("Enter"); + s = await st(); + check("enter-on-ghost-inspects", s.log.includes("inspected a completed task"), s.log); + + // --- phase 2: the key-activity pin ------------------------------------ + // The pin defers ONLY the focused row's collapse (the keyboard aims by + // identity; only the aimed-at row needs holding still), and it decays + // keyPinMs=200 after the last key -- so holding the ghost takes a burst + // at sub-200ms cadence, and the moment the fingers stop, the world moves. + // Focus is on the ghost now; work IT (Enter inspects, focus never leaves) + // well past holdMs=1200 from the removal above. + for (let i = 0; i < 16; i++) { + await cdp.key("Enter"); + await sleep(100); + } + s = await st(); + g = row(s, first); + check("key-pin-holds-focused-ghost", g !== null && !g.disabled, JSON.stringify(g)); + await shot(cdp, "pin-held.png"); + + // Stop typing: the pin decays 200ms after the last key, the deferred + // collapse plays (300ms), and the fallback moves focus off the dying row. + await sleep(900); + s = await st(); + check("pin-decay-collapses-ghost", row(s, first) === null, JSON.stringify(s.rows)); + check( + "fallback-moved-focus", + s.rowFocused !== null && s.rowFocused !== first, + s.rowFocused ?? "", + ); + await shot(cdp, "post-fallback.png"); + + // An UNFOCUSED ghost collapses on its natural schedule despite continuous + // typing (the semantics the focused-only pin buys): x the focused row (the + // ghost keeps focus), prove it is still a nav stop (arrow off and back on), + // then move OFF it and keep pressing keys at pin-holding cadence on a + // neighbor -- left/right cycle within a two-control row, so row focus + // never returns to the ghost. It must collapse ~holdMs after its removal + // even though the pin never once decayed. + s = await st(); + const bkey = s.rowFocused as string; + await cdp.key("x"); + s = await st(); + check( + "x-ghost-keeps-focus", + !s.dataIds.includes(bkey) && s.rowFocused === bkey, + JSON.stringify([s.rowFocused, s.log]), + ); + await cdp.key("ArrowDown"); + await cdp.key("ArrowUp"); + s = await st(); + check("ghost-is-a-nav-stop", s.rowFocused === bkey, s.rowFocused ?? ""); + await cdp.key("ArrowDown"); // leave the ghost; it is nobody's aim from here on + let aliveEarly: boolean | null = null; + for (let i = 0; i < 18; i++) { + // ~1.9s of sub-200ms keys, spanning the ghost's 1.2s schedule + await cdp.key(i % 2 === 0 ? "ArrowRight" : "ArrowLeft"); + await sleep(100); + if (i === 6) { + // ~0.8s in: before the schedule lands, the ghost still holds + s = await st(); + g = row(s, bkey); + aliveEarly = g !== null && !g.disabled; + } + } + s = await st(); + g = row(s, bkey); + const goneLate = g === null || g.disabled; + check("unfocused-ghost-holds-until-due", aliveEarly === true, JSON.stringify(aliveEarly)); + check("unfocused-ghost-collapses-mid-typing", goneLate, JSON.stringify(g)); + await sleep(600); // let the collapse finish before the next phase + + // --- phase 3: the churn lockout --------------------------------------- + // Arrange: focus a row, drop it from the data (ghost), then wait for the + // collapse to START (aria-hidden appears; no keys during the wait, so the + // pin decays and only holdMs gates it) and immediately fire a trusted + // Enter: the fallback just moved focus, so the decisive key must be DROPPED. + s = await st(); + const victim = s.rowFocused as string; + const logBefore = s.log; + await dropHead(0); // victim is the head (fallback landed there after phase 2) + { + let began = false; + for (let i = 0; i < 60; i++) { + s = await st(); + g = row(s, victim); + if (g?.disabled) { + began = true; + break; + } + await sleep(40); + } + if (!began) throw new Error("victim never began collapsing"); + } + await cdp.key("Enter"); + s = await st(); + check("lockout-drops-decisive-key", s.log === logBefore, JSON.stringify([s.log, logBefore])); + check( + "lockout-fallback-landed", + s.rowFocused !== null && s.rowFocused !== victim, + s.rowFocused ?? "", + ); + // ... and after the 350ms window, decisive keys flow again. + await sleep(500); + await cdp.key("Enter"); + s = await st(); + check("post-lockout-key-flows", s.log.includes("inspected") && s.log !== logBefore, s.log); + + // --- phase 4: x / Delete / instant prepend under burst ----------------- + s = await st(); + const target = s.rowFocused as string; + await cdp.key("x"); + s = await st(); + check("x-removes-from-main", s.log.includes("removed") && !s.dataIds.includes(target), s.log); + + // p is the prepend button's column-scoped hotkey, marked DIRECT (a prepend + // is cheaply undone, the see-before-commit exception): it fires from ANY + // row, focus follows to the orders row, and the new head appears instantly + // (visible, not pending) although the key burst above has the key pin + // armed right now. + await cdp.key("p"); + s = await st(); + const newest = s.dataIds[0] as string; + const fresh = row(s, newest); + check("p-prepends-from-anywhere", s.log.includes("prepended"), s.log); + check("p-direct-moves-focus-to-actor", s.rowFocused === "prepend", s.rowFocused ?? ""); + check( + "prepend-visible-under-pin", + fresh !== null && fresh.hidden === null && !fresh.disabled, + JSON.stringify(fresh), + ); + + // Focus landed on the orders row with the direct p; move to the fresh live + // task for the Delete test (Home = prepend row, two down = past the header + // to the new data head). + await cdp.key("Home"); + await cdp.key("ArrowDown"); + await cdp.key("ArrowDown"); + s = await st(); + const deltarget = s.rowFocused as string; + await cdp.key("Delete"); + s = await st(); + check("delete-removes", s.log.includes("removed") && !s.dataIds.includes(deltarget), s.log); + + // --- phase 5: click-guard, the default pointer story --------------------- + // Hovering defers NOTHING now: a ghost under the pointer collapses on its + // own schedule, and an insertion appears immediately with the pointer inside. + // The protection is target-scoped instead: a trusted click whose target row + // is displaced by an in-flight motion is swallowed; rows above the motion + // answer clicks throughout; the guard dies ~50ms after the motion ends. + s = await st(); + const ghosts = s.rows.filter((r) => r.committed && !r.disabled).map((r) => r.key); + check("ghost-present-for-pointer-phase", ghosts.length > 0, JSON.stringify(s.rows)); + const gkey = ghosts[0] as string; + const gbox = JSON.parse( + (await cdp.js(` + (() => { const r = ${demo(0)}.shadowRoot.querySelector('[data-key="${gkey}"]').getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })() + `)) as string, + ) as { x: number; y: number }; + await cdp.mouseMove(gbox.x, gbox.y); // pointer parked ON the ghost, pre-expiry + await sleep(2200); // holdMs + collapse + slack, pointer inside throughout + s = await st(); + check("hover-does-not-defer-collapse", row(s, gkey) === null, JSON.stringify(s.rows)); + // An insertion under the hovering pointer: immediate, full-size, no pending + // phase. Park the pointer at the LIST's own center first (the collapse may + // have shrunk the list away from where the ghost was). + const lbox = JSON.parse( + (await cdp.js(` + (() => { const r = ${demo(0)}.shadowRoot.querySelector('sw-live-list').getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + Math.max(r.height/2, 4)}); })() + `)) as string, + ) as { x: number; y: number }; + await cdp.mouseMove(lbox.x, lbox.y); + await cdp.js(`${demo(0)}.prependTask()`); + await sleep(500); // entrance animation (300ms) settles + s = await st(); + let pend = s.dataIds[0] as string; + let prow = row(s, pend); + let pheight = await cdp.js(` + (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]'); + return el === null ? null : el.getBoundingClientRect().height; })() + `); + check( + "insertion-appears-under-pointer", + prow !== null && prow.hidden === null, + JSON.stringify(prow), + ); + check( + "inserted-row-holds-full-space", + typeof pheight === "number" && pheight > 20, + JSON.stringify(pheight), + ); + + // The trusted-click guard. Stock the queue so a mid-list collapse has rows + // both above and below it, and settle all entrances first. + await cdp.js(`${demo(0)}.prependTask(); ${demo(0)}.prependTask()`); + await sleep(600); + await cdp.key("Home"); // focus the prepend row: a stable not-a-task vantage + s = await st(); + const ids = s.dataIds; + const [above, cgVictim, below] = [ids[0] as string, ids[1] as string, ids[2] as string]; + + const rowCenter = async (k: string): Promise<{ x: number; y: number } | null> => { + const v = await cdp.js(` + (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${k}"]'); + if (el === null) return null; + const r = el.getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })() + `); + return v === null ? null : JSON.parse(v as string); + }; + + await cdp.js( + `(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${cgVictim}') })()`, + ); + { + let began = false; + for (let i = 0; i < 60; i++) { + // wait out the hold; catch the collapse the frame it starts + s = await st(); + g = row(s, cgVictim); + if (g?.disabled) { + began = true; + break; + } + await sleep(40); + } + if (!began) throw new Error("click-guard victim never began collapsing"); + } + // Mid-collapse (300ms window): the row below the collapse is being pulled + // upward -- a trusted click on it must be swallowed (not even a focus sync); + // the row above has not moved and its click lands. + let pb = (await rowCenter(below)) as { x: number; y: number }; + await cdp.click(pb.x, pb.y); + s = await st(); + check("mid-collapse-click-below-swallowed", s.rowFocused === "prepend", s.rowFocused ?? ""); + const pa = (await rowCenter(above)) as { x: number; y: number }; + await cdp.click(pa.x, pa.y); + s = await st(); + check("mid-collapse-click-above-lands", s.rowFocused === above, s.rowFocused ?? ""); + // After the motion (plus the 50ms guard tail), the same below-row click lands. + await sleep(600); + pb = (await rowCenter(below)) as { x: number; y: number }; + await cdp.click(pb.x, pb.y); + s = await st(); + check("post-motion-click-lands", s.rowFocused === below, s.rowFocused ?? ""); + await cdp.mouseMove(5, 5); + + // --- phase 5f: freeze, the opt-in quiescence mode ----------------------- + // pointerSafety is runtime-togglable; raise freeze on the live list and the + // old global hover pin story must hold: a hovered ghost defers past every + // timer, an insertion waits as a zero-height pending row, and pointer-leave + // releases both. + const LIST = `${demo(0)}.shadowRoot.querySelector('sw-live-list')`; + await cdp.js(`${LIST}.pointerSafety = 'freeze'`); + s = await st(); + const fkey = s.dataIds[0] as string; + const fbox = (await rowCenter(fkey)) as { x: number; y: number }; + await cdp.mouseMove(fbox.x, fbox.y); // pointer onto the head task row + await cdp.js( + `(() => { const dd = ${demo(0)}; dd.tasks = dd.tasks.filter(t => t.id !== '${fkey}') })()`, + ); + await sleep(2200); // far past holdMs + collapse; the hover pin holds + s = await st(); + g = row(s, fkey); + check("freeze-hover-pin-holds-ghost", g !== null && !g.disabled, JSON.stringify(s.rows)); + // While the pointer pins the list, an insertion defers as a PENDING row -- + // and a pending row must hold its display position at ZERO height. This + // pins the phantom-space state (held-p prepends with the pointer inside + // painted a blank band at the list top: the demo's [data-mc-row] + // min-height was clamping closed rowslots open to 24px). + await cdp.js(`${demo(0)}.prependTask()`); + await sleep(300); + s = await st(); + pend = s.dataIds[0] as string; + prow = row(s, pend); + pheight = await cdp.js(` + (() => { const el = ${demo(0)}.shadowRoot.querySelector('[data-key="${pend}"]'); + return el === null ? null : el.getBoundingClientRect().height; })() + `); + check( + "freeze-hover-defers-insertion", + prow !== null && prow.hidden === "true", + JSON.stringify(prow), + ); + check("freeze-pending-row-holds-no-space", pheight === 0, JSON.stringify(pheight)); + await cdp.mouseMove(5, 5); // pointer leaves: deferred motion plays + await sleep(800); + s = await st(); + check("freeze-pointer-leave-releases", row(s, fkey) === null, JSON.stringify(s.rows)); + prow = row(s, pend); + check( + "freeze-pointer-leave-shows-pending", + prow !== null && prow.hidden === null, + JSON.stringify(prow), + ); + await cdp.js(`${LIST}.pointerSafety = 'click-guard'`); // back to the default + + // Re-seat DOM focus on the column container before the Tab phases: a real + // mouse click natively focuses the list's tabindex=-1 item container (the + // nearest click-focusable ancestor of a row), and Tab order from there is + // not what phase 6 is testing. Arrangement, not assertion. + await cdp.js(`${demo(0)}.shadowRoot.querySelector('[data-mc-container]').focus()`); + + // --- phase 6: focus memory across Tab-away ------------------------------ + s = await st(); + const resume = s.rowFocused; + await cdp.key("Tab"); // leaves the refill column (next stop: drain column) + s = await st(); + check("tab-leaves-column", !s.focusWithin, JSON.stringify(s.focusWithin)); + await cdp.key("Tab", { shift: true }); + s = await st(); + check( + "focus-memory-on-return", + s.focusWithin && s.rowFocused === resume, + JSON.stringify([s.rowFocused, resume]), + ); + + // --- phase 7: the drain column: empty state and revival ----------------- + await cdp.key("Tab"); + let s1 = await st(1); + check("tab-into-drain-column", s1.focusWithin, JSON.stringify(s1)); + // The queue starts empty (renderEmpty shows), but the COLUMN is not: the + // prepend row is a focusable stop, so eager focus sits there. + check( + "drain-starts-empty", + s1.idle !== null && s1.rowFocused === "prepend" && s1.aria === "mc-prepend", + JSON.stringify([s1.idle, s1.rowFocused, s1.aria]), + ); + await shot(cdp, "drain-empty.png"); + + // p prepends a task; focus STAYS on the prepend row (it survives churn; + // nothing involuntary happened), and the new row is immediately reachable + // (two downs now: the list header row sits between prepend and the list). + await cdp.key("p"); + s1 = await st(1); + const drainNewest = s1.dataIds.length > 0 ? (s1.dataIds[0] as string) : null; + check( + "drain-revives", + drainNewest !== null && s1.rowFocused === "prepend", + JSON.stringify(s1.dataIds), + ); + await cdp.key("ArrowDown"); + await cdp.key("ArrowDown"); + s1 = await st(1); + check( + "drain-new-row-reachable", + s1.rowFocused === drainNewest && s1.aria === `mc-${drainNewest}`, + JSON.stringify([s1.rowFocused, s1.aria]), + ); + // Kill it with x and let it collapse: focus survives the LAST task dying, + // falling DOWN to the add-row (the fallback prefers downward, and the + // add-row is the list's permanent neighbor below); the empty state returns. + await cdp.key("x"); + s1 = await st(1); + check( + "drain-x-removes", + s1.log.includes("removed") && s1.rowFocused === drainNewest, + JSON.stringify([s1.log, s1.rowFocused]), + ); + await sleep(2200); // pin decays (200) + hold (1200) + collapse (300) + slack + s1 = await st(1); + check( + "drain-back-to-empty", + s1.idle !== null && s1.rowFocused === "queue-add", + JSON.stringify([s1.idle, s1.rowFocused]), + ); + await cdp.key("p"); + await cdp.key("ArrowDown"); + await cdp.key("ArrowDown"); + s1 = await st(1); + check( + "drain-revives-again", + s1.dataIds.length === 1 && s1.rowFocused === s1.dataIds[0], + JSON.stringify(s1.rowFocused), + ); + await shot(cdp, "drain-revived.png"); + + // --- phase 8: the add-row (append as a nav stop; commit/abandon) -------- + // Still in the drain column, one live task. The add-row is the append + // norm, and its marked a is DIRECT: one keystroke dives straight into + // text entry (direct on a text row means what Enter means there), earned + // under the easy-undo doctrine -- Escape abandons, nothing touches the + // queue until the Enter commit. Enter commits, Escape abandons. + s1 = await st(1); + const countBefore = s1.dataIds.length; + await cdp.key("a"); + s1 = await st(1); + check( + "a-dives-into-add-row", + s1.rowFocused === "queue-add" && s1.aria === "mc-queue-add", + JSON.stringify([s1.rowFocused, s1.aria]), + ); + check( + "a-dive-enters-text-mode", + s1.textMode && s1.inputFocused, + JSON.stringify([s1.textMode, s1.inputFocused]), + ); + // The triggering a must not type itself into the input it focused + // (the dive preventDefaults the keydown, same as Enter's dive). + check("a-does-not-type-itself", s1.inputValue === "", JSON.stringify(s1.inputValue)); + check("a-dive-appends-nothing", s1.dataIds.length === countBefore, JSON.stringify(s1.dataIds)); + + for (const ch of "calibrate the array") { + await cdp.key(ch); + } + s1 = await st(1); + check( + "typing-lands-in-add-input", + s1.inputValue === "calibrate the array", + JSON.stringify(s1.inputValue), + ); + await shot(cdp, "add-row-typing.png"); + + // Escape ABANDONS: text mode exits, the draft clears, nothing appended. + // (The trap this pins: change fires on blur-with-changed-value, and the + // exit blurs the input -- a change-driven commit would append here.) + const logBefore8 = s1.log; + await cdp.key("Escape"); + s1 = await st(1); + check( + "escape-abandons", + !s1.textMode && s1.inputValue === "" && s1.dataIds.length === countBefore, + JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds]), + ); + check( + "escape-appends-nothing-to-log", + s1.log === logBefore8, + JSON.stringify([s1.log, logBefore8]), + ); + + // Row focus survived the exit on the add-row, so plain Enter re-dives + // (the non-marked path, unchanged by the direct marking). + await cdp.key("Enter"); + s1 = await st(1); + check( + "enter-redives-from-row-focus", + s1.textMode && s1.inputFocused, + JSON.stringify([s1.textMode, s1.inputFocused]), + ); + + // Enter COMMITS: the task appends to the live data (animating in at the + // tail), the input clears, and row focus stayed on the add-row throughout. + for (const ch of "survey the relay") { + await cdp.key(ch); + } + await cdp.key("Enter"); + s1 = await st(1); + check( + "enter-commits-once", + s1.dataIds.length === countBefore + 1 && s1.log.includes("survey the relay appended"), + JSON.stringify([s1.dataIds, s1.log]), + ); + check( + "commit-clears-input", + s1.inputValue === "" && !s1.textMode, + JSON.stringify([s1.inputValue, s1.textMode]), + ); + const newId = s1.dataIds[s1.dataIds.length - 1] as string; + const freshTail = row(s1, newId); + check( + "committed-row-visible-at-tail", + freshTail !== null && freshTail.hidden === null && !freshTail.disabled, + JSON.stringify(freshTail), + ); + check("focus-stayed-on-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? ""); + // ArrowUp from the add-row reaches the appended task: it landed at the tail. + await cdp.key("ArrowUp"); + s1 = await st(1); + check("committed-row-reachable-above-add-row", s1.rowFocused === newId, s1.rowFocused ?? ""); + + // --- phase 9: the list-scoped keys (t and n) ----------------------------- + // The t/n norm: sw-list wires it itself (one megacolumn-key listener on + // the host element answering with megacolumn-focus-request), deliberately + // NOT gold markings. Scope is DOM containment: within the list's rows t + // returns to the header and n jumps past the list; outside it the letters + // are nobody's. Focus is on the tail task from phase 8 -- deep in the list. + await cdp.key("t"); + s1 = await st(1); + check( + "t-from-task-returns-to-header", + s1.rowFocused === "queue-header" && s1.aria === "mc-queue-header", + JSON.stringify([s1.rowFocused, s1.aria]), + ); + check("t-landing-washes-region", s1.groupWashed === true, JSON.stringify(s1.groupWashed)); + // n from the header (the header counts as within): past the whole list to + // the telemetry row, no arrowing through the tasks. + await cdp.key("n"); + s1 = await st(1); + check( + "n-jumps-past-the-list", + s1.rowFocused === "status" && s1.aria === "mc-status", + JSON.stringify([s1.rowFocused, s1.aria]), + ); + // The status row sits OUTSIDE the group: t there is unclaimed -- it falls + // through to normal resolution (no marking answers it) and nothing moves. + await cdp.key("t"); + s1 = await st(1); + check("t-outside-group-unclaimed", s1.rowFocused === "status", s1.rowFocused ?? ""); + // The add-row counts as within: t from it returns to the header too. + await cdp.key("ArrowUp"); + s1 = await st(1); + check("arrowup-from-status-is-add-row", s1.rowFocused === "queue-add", s1.rowFocused ?? ""); + await cdp.key("t"); + s1 = await st(1); + check("t-from-add-row-returns-to-header", s1.rowFocused === "queue-header", s1.rowFocused ?? ""); + // ... and n from a mid-list task row (not just the header) jumps past too. + await cdp.key("ArrowDown"); + s1 = await st(1); + check("arrowdown-into-list", s1.dataIds.includes(s1.rowFocused ?? ""), s1.rowFocused ?? ""); + await cdp.key("n"); + s1 = await st(1); + check("n-from-task-jumps-past", s1.rowFocused === "status", s1.rowFocused ?? ""); + // The prepend row is outside the group on the other side: t stays put. + await cdp.key("Home"); + await cdp.key("t"); + s1 = await st(1); + check("t-on-prepend-unclaimed", s1.rowFocused === "prepend", s1.rowFocused ?? ""); + // Text mode dominates: while typing in the add-row, t and n are just letters. + await cdp.key("a"); // the direct marking dives straight into text entry + await cdp.key("t"); + await cdp.key("n"); + s1 = await st(1); + check( + "text-mode-t-n-type", + s1.textMode && s1.inputValue === "tn" && s1.rowFocused === "queue-add", + JSON.stringify([s1.textMode, s1.inputValue, s1.rowFocused]), + ); + await cdp.key("Escape"); // abandon the probe draft + s1 = await st(1); + check( + "text-mode-probe-abandoned", + !s1.textMode && s1.inputValue === "", + JSON.stringify([s1.textMode, s1.inputValue]), + ); + + // --- phase 10: the sw-list component surface ----------------------------- + // Newly reachable since the list norms became a component (the + // list-component stage): the group identity for AT, and the cancelable + // commit (an owner that preventDefaults sw-list-add refuses the commit; + // the draft and text mode must survive so the user can fix the text). + // The demo's own handler never refuses, and a listener on the same element + // would run after it -- so the refusal is arranged upstream: a one-shot + // capture listener on the container cancels AND stops propagation (from + // sw-list's side that IS a refusing owner: sw-list-add came back + // defaultPrevented and nobody appended). + await cdp.js(` + ${demo(1)}.shadowRoot.querySelector('[data-mc-container]') + .addEventListener('sw-list-add', e => { e.preventDefault(); e.stopPropagation(); }, + { once: true, capture: true }) + `); + const role = await cdp.js(` + (() => { const l = ${demo(1)}.shadowRoot.querySelector('sw-list'); + return JSON.stringify([l.getAttribute('role'), l.getAttribute('aria-label')]); })() + `); + check( + "sw-list-group-identity", + JSON.stringify(JSON.parse(role as string)) === JSON.stringify(["group", "task queue (live)"]), + String(role), + ); + const countBefore10 = (await st(1)).dataIds.length; + // Focus sits ON the add-row after the phase-9 probe, where its own marking + // is deliberately a no-op (double-tap must not out-power single-tap) -- + // so step off it first, then dive by marking. Once in text mode every + // letter (t and n included) types. + await cdp.key("Home"); + await cdp.key("a"); + for (const ch of "rejected draft") { + await cdp.key(ch); + } + await cdp.key("Enter"); + s1 = await st(1); + check( + "refused-commit-keeps-draft-and-text-mode", + s1.textMode && s1.inputValue === "rejected draft" && s1.dataIds.length === countBefore10, + JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds.length]), + ); + // The once-listener is spent: the same Enter now commits. + await cdp.key("Enter"); + await sleep(100); + s1 = await st(1); + check( + "commit-flows-after-refusal", + !s1.textMode && + s1.inputValue === "" && + s1.dataIds.length === countBefore10 + 1 && + s1.log.includes("rejected draft appended"), + JSON.stringify([s1.textMode, s1.inputValue, s1.dataIds, s1.log]), + ); +} + +export const lcDrive: Scenario = { name: "lc", run }; diff --git a/packages/e2e/scenarios/mc-drive.ts b/packages/e2e/scenarios/mc-drive.ts new file mode 100644 index 0000000..2776940 --- /dev/null +++ b/packages/e2e/scenarios/mc-drive.ts @@ -0,0 +1,421 @@ +// Drives the megacolumn gallery demo with TRUSTED key events via CDP, +// reading focus/aria state between steps. Synthetic JS KeyboardEvents can't +// exercise real Tab traversal or real typing; these can. +// Ported from the Python original (demoscene.meta screenshots, stage-focus-model). + +import type { Scenario, ScenarioContext } from "yukey-testkit/checks"; +import { openGallery } from "yukey-testkit/gallery"; +import { sleep } from "yukey-testkit/sleep"; + +// Reads the interesting state through the shadow roots: which demo element holds +// real focus, its aria-activedescendant, ring positions, text mode, and the log line. +const STATE_JS = ` +(() => { + const shell = document.querySelector("sw-gallery").shadowRoot; + const demos = [...shell.querySelectorAll("swg-megacolumn-demo")]; + const active = shell.activeElement; + const out = { realFocus: null, columns: [] }; + for (const demo of demos) { + const sr = demo.shadowRoot; + const container = sr.querySelector("[data-mc-container]"); + const label = demo.getAttribute("label"); + if (demo === active) { + const inner = sr.activeElement; + out.realFocus = label + "/" + (inner === container ? "container" : (inner?.tagName ?? "host").toLowerCase()); + } + out.columns.push({ + label, + aria: container.getAttribute("aria-activedescendant"), + ringOn: sr.querySelector(".mc-focused")?.textContent?.trim()?.slice(0, 24) ?? null, + rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null, + textMode: container.classList.contains("mc-text-mode"), + focusWithin: container.classList.contains("mc-focus-within"), + log: sr.querySelector(".log")?.textContent ?? "", + inputValue: sr.querySelector("[data-mc-input]")?.value ?? null, + cargoCount: sr.querySelectorAll("[data-mc-aux]").length, + }); + } + return JSON.stringify(out); +})() +`; + +interface McColumn { + label: string; + aria: string | null; + ringOn: string | null; + rowFocused: string | null; + textMode: boolean; + focusWithin: boolean; + log: string; + inputValue: string | null; + cargoCount: number; +} + +interface McState { + realFocus: string | null; + columns: McColumn[]; +} + +// State of the nested-scroller demo: the focused row, the box's own scroll +// position, and the page scrollers that must NOT move. +const SCROLL_JS = ` +(() => { + const shell = document.querySelector("sw-gallery").shadowRoot; + const demo = shell.querySelector("swg-megacolumn-scroll-demo"); + const sr = demo.shadowRoot; + const box = sr.querySelector("[data-mc-container]"); + const focused = sr.querySelector(".mc-row-focused"); + const b = box.getBoundingClientRect(); + const r = focused?.getBoundingClientRect(); + return JSON.stringify({ + hasFocus: shell.activeElement === demo, + rowFocused: focused?.dataset?.mcRow ?? null, + boxTop: box.scrollTop, + rowInBox: r ? r.top >= b.top - 1 && r.bottom <= b.bottom + 1 : null, + mainTop: shell.querySelector("main").scrollTop, + windowY: window.scrollY, + log: sr.querySelector(".log")?.textContent ?? "", + }); +})() +`; + +interface McScrollState { + hasFocus: boolean; + rowFocused: string | null; + boxTop: number; + rowInBox: boolean | null; + mainTop: number; + windowY: number; + log: string; +} + +async function run(ctx: ScenarioContext): Promise<void> { + const { cdp, checks } = ctx; + const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail); + const st = async (): Promise<McState> => JSON.parse((await cdp.js(STATE_JS)) as string); + + // Fresh demo state every run: navigate to the entry, wait for the demo element. + await openGallery( + ctx, + "#megacolumn", + "document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')", + ); + + // The gallery seats initial focus in the entry's first tab stop on open: + // after a reload of #megacolumn that is the port container, no click, no Tab. + // (Seating happens a frame after render; poll briefly.) + let seated: string | null = null; + for (let i = 0; i < 20; i++) { + const s = await st(); + seated = s.realFocus; + if (seated === "port console/container") break; + await sleep(50); + } + check( + "initial-focus-seated-on-open", + seated === "port console/container", + JSON.stringify(seated), + ); + + // Start clean: blur the DEEP active element (document.activeElement is just + // the shadow host; blurring the host does not drop inner focus), then Tab + // until real focus lands back in the first container. + await cdp.js( + "(() => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; if (a) a.blur(); window.scrollTo(0,0); })()", + ); + let landed = false; + let s = await st(); + // The cap must exceed the containment cycle's full length: every gallery + // nav link plus the demo's stops. Hand-counting rotted once already (a cap + // of 12 = "six nav links" broke when the gallery grew); count the nav links + // live and add the demo's own stops with margin. + const navLinks = (await cdp.js( + "document.querySelector('sw-gallery').shadowRoot.querySelectorAll('nav a').length", + )) as number; + for (let i = 0; i < navLinks + 12; i++) { + await cdp.key("Tab"); + s = await st(); + if (s.realFocus === "port console/container") { + landed = true; + break; + } + } + check("tab-reaches-port-container", landed, JSON.stringify(s.realFocus)); + let port = s.columns[0] as McColumn; + check( + "initial-focus-first-row", + port.rowFocused === "beacon" && port.aria === "mc-beacon", + JSON.stringify(port), + ); + + // Tab again: ONE tab stop per column -> straight to starboard. + await cdp.key("Tab"); + s = await st(); + check( + "tab-skips-rows-to-starboard", + s.realFocus === "starboard console/container", + JSON.stringify(s.realFocus), + ); + check( + "port-memory-visible-when-blurred", + s.columns[0]?.rowFocused === "beacon" && s.columns[0]?.focusWithin === false, + JSON.stringify(s.columns[0]), + ); + + // Back to port; navigate: down x2 -> reactor slider. + await cdp.key("Tab", { shift: true }); + await cdp.key("ArrowDown"); + await cdp.key("ArrowDown"); + s = await st(); + port = s.columns[0] as McColumn; + check("arrows-move-row-focus", port.rowFocused === "power", JSON.stringify(port)); + + // Slider consumes left/right. + await cdp.key("ArrowRight"); + await cdp.key("ArrowRight"); + await cdp.key("ArrowLeft"); + s = await st(); + port = s.columns[0] as McColumn; + check("slider-eats-arrows", port.log.includes("reactor power 45%"), port.log); + check("slider-row-unmoved", port.rowFocused === "power", JSON.stringify(port)); + + // Down into cargo: two-control rows. Right reaches the aux x; Enter jettisons. + await cdp.key("ArrowDown"); + s = await st(); + port = s.columns[0] as McColumn; + check("gap-jump-into-cargo", port.rowFocused === "crate-ore", JSON.stringify(port)); + await cdp.key("ArrowRight"); + s = await st(); + port = s.columns[0] as McColumn; + check("right-reaches-aux", port.ringOn === "\u00d7", JSON.stringify(port)); + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check( + "enter-on-aux-removes", + port.log.includes("ore crate jettisoned") && port.cargoCount === 2, + JSON.stringify(port), + ); + check("vanished-row-falls-down", port.rowFocused === "crate-struts", JSON.stringify(port)); + + // x hotkey removes from main position too. + await cdp.key("x"); + s = await st(); + port = s.columns[0] as McColumn; + check( + "x-hotkey-removes", + port.log.includes("strut bundle jettisoned") && port.cargoCount === 1, + JSON.stringify(port), + ); + + // Enter on main inspects, does NOT remove. + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check( + "enter-on-main-inspects", + port.log.includes("fuel cells inspected") && port.cargoCount === 1, + JSON.stringify(port), + ); + + // Down to the hail row; Enter dives into the input; type; every key goes in. + await cdp.key("ArrowDown"); + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check( + "text-mode-entered", + port.textMode && s.realFocus === "port console/input", + JSON.stringify({ f: s.realFocus, tm: port.textMode }), + ); + for (const ch of "sos x") { + await cdp.key(ch); + } + s = await st(); + port = s.columns[0] as McColumn; + check("typing-goes-to-input", port.inputValue === "sos x", JSON.stringify(port.inputValue)); + check("x-did-not-jettison-while-typing", port.cargoCount === 1, String(port.cargoCount)); + + // Escape exits back to the container; row focus stays on hail. + await cdp.key("Escape"); + s = await st(); + port = s.columns[0] as McColumn; + check( + "escape-exits-text", + !port.textMode && s.realFocus === "port console/container", + JSON.stringify({ f: s.realFocus, tm: port.textMode }), + ); + check("row-focus-resumed-on-hail", port.rowFocused === "hail", JSON.stringify(port)); + + // Down reaches the offline encrypted row: visible-but-inactive rows are + // nav stops (i must be able to reach them; stride counts stay stable). + await cdp.key("ArrowDown"); + s = await st(); + port = s.columns[0] as McColumn; + check("offline-row-is-a-stop", port.rowFocused === "encrypted", JSON.stringify(port)); + // ... but activation is dead there: Enter changes nothing. + const beforeLog = port.log; + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check("offline-activation-dead", port.log === beforeLog, port.log); + + // Home jumps to the first row. + await cdp.key("Home"); + s = await st(); + port = s.columns[0] as McColumn; + check("home-jumps-to-first", port.rowFocused === "beacon", JSON.stringify(port)); + + // Enter activates the beacon row (plain activatable). + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check("enter-activates-row", port.log.includes("beacon cycled (1)"), port.log); + await cdp.key(" "); + s = await st(); + port = s.columns[0] as McColumn; + check("space-mirrors-enter", port.log.includes("beacon cycled (2)"), port.log); + + // Hotkey jumps: the gold letters move focus, Enter commits (see-before-commit). + // g leaps from the top section to the bottom one; the log must NOT change on + // the jump itself. + await cdp.key("g"); + s = await st(); + port = s.columns[0] as McColumn; + check("hotkey-jumps-to-gyros", port.rowFocused === "gyros", JSON.stringify(port)); + check("jump-does-not-activate", port.log.includes("beacon cycled (2)"), port.log); + await cdp.key("Enter"); + s = await st(); + port = s.columns[0] as McColumn; + check("enter-commits-after-jump", port.log.includes("gyros recalibrated"), port.log); + // The offline row is a jump target too: e lands there (i can ask why it's off). + await cdp.key("e"); + s = await st(); + port = s.columns[0] as McColumn; + check("jump-reaches-offline-row", port.rowFocused === "encrypted", JSON.stringify(port)); + // ... and b jumps back up to the beacon row for the phases below. + await cdp.key("b"); + s = await st(); + port = s.columns[0] as McColumn; + check("jump-back-to-beacon", port.rowFocused === "beacon", JSON.stringify(port)); + + // Tab away and back: focus memory. + await cdp.key("ArrowDown"); // -> dock, a memorable non-default row + await cdp.key("Tab"); + await cdp.key("Tab", { shift: true }); + s = await st(); + port = s.columns[0] as McColumn; + check( + "focus-memory-across-tab", + port.rowFocused === "dock" && s.realFocus === "port console/container", + JSON.stringify({ f: s.realFocus, r: port.rowFocused }), + ); + + // Starboard is untouched by all of this. + const sb = s.columns[1] as McColumn; + check( + "starboard-independent", + sb.rowFocused === "beacon" && sb.cargoCount === 3 && sb.log === "", + JSON.stringify(sb), + ); + + // --- pointer activation: a trusted CLICK is the same event as Enter --- + // Plain rows have no widget: their activation is the controller's synthesized + // megacolumn-key. A tap produces a click, so this is also the touch story. + const portCenter = async (selector: string): Promise<{ x: number; y: number }> => { + const v = await cdp.js(` + (() => { const el = document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-megacolumn-demo").shadowRoot.querySelector('${selector}'); + el.scrollIntoView({block: "nearest", behavior: "instant"}); + const r = el.getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); })() + `); + return JSON.parse(v as string); + }; + + // Click the beacon row (a plain activatable row: earlier Enter and Space + // left its counter at 2, so a working click reads 3). + let pt = await portCenter('[data-mc-row="beacon"]'); + await cdp.click(pt.x, pt.y); + s = await st(); + port = s.columns[0] as McColumn; + check("click-activates-plain-row", port.log.includes("beacon cycled (3)"), port.log); + check("click-focuses-clicked-row", port.rowFocused === "beacon", JSON.stringify(port)); + + // Click the surviving crate's aux ×: the synthesis carries the aux position, + // so the click removes exactly as Enter-on-aux does. + pt = await portCenter('[data-mc-row="crate-cells"] [data-mc-aux]'); + await cdp.click(pt.x, pt.y); + s = await st(); + port = s.columns[0] as McColumn; + check( + "click-on-aux-removes", + port.log.includes("fuel cells jettisoned") && port.cargoCount === 0, + JSON.stringify(port), + ); + + // --- nested scroller: the relay-bank variant (28 static rows in a 240px + // overflow-y box). The controller's scrollIntoView(block:"nearest") must + // scroll the BOX, never the page (the gallery's page scroller is the + // shell's <main>; window never scrolls in this layout, asserted anyway). --- + const sst = async (): Promise<McScrollState> => JSON.parse((await cdp.js(SCROLL_JS)) as string); + + // Tab from port down to the relay bank (starboard and the ops console are + // tab stops on the way; hunt, don't count). Tabbing in seats row focus on + // relay-01 and MAY scroll the page scroller to reveal the demo -- so the + // page-scroll baseline is recorded after landing, not before. + landed = false; + let ss = await sst(); + for (let i = 0; i < 8; i++) { + await cdp.key("Tab"); + ss = await sst(); + if (ss.hasFocus) { + landed = true; + break; + } + } + check( + "nested-tab-reaches-relay-bank", + landed && ss.rowFocused === "relay-01" && ss.boxTop === 0, + JSON.stringify(ss), + ); + const pageBefore = [ss.mainTop, ss.windowY] as const; + const pageSame = (v: McScrollState) => v.mainTop === pageBefore[0] && v.windowY === pageBefore[1]; + + // A hotkey jump across the scroller: 2 lands on relay-20, far off-screen + // from the top -- the box must scroll, the page must not. + await cdp.key("2"); + ss = await sst(); + check("nested-jump-lands-on-relay-20", ss.rowFocused === "relay-20", JSON.stringify(ss)); + check("nested-jump-scrolls-the-box", ss.boxTop > 0, JSON.stringify(ss)); + check( + "nested-jump-leaves-page-alone", + pageSame(ss), + JSON.stringify([pageBefore, ss.mainTop, ss.windowY]), + ); + check("nested-jump-row-visible-in-box", ss.rowInBox === true, JSON.stringify(ss)); + + // End slams to the last relay; Home slams back and the box must return + // exactly to the top (relay-01 flush at scrollTop 0). + await cdp.key("End"); + ss = await sst(); + check( + "nested-end-reaches-last-in-view", + ss.rowFocused === "relay-28" && ss.rowInBox === true, + JSON.stringify(ss), + ); + await cdp.key("Home"); + ss = await sst(); + check( + "nested-home-returns-box-to-top", + ss.rowFocused === "relay-01" && ss.boxTop === 0 && ss.rowInBox === true, + JSON.stringify(ss), + ); + check( + "nested-slams-leave-page-alone", + pageSame(ss), + JSON.stringify([pageBefore, ss.mainTop, ss.windowY]), + ); +} + +export const mcDrive: Scenario = { name: "mc", run }; diff --git a/packages/e2e/scenarios/sw-drive.ts b/packages/e2e/scenarios/sw-drive.ts new file mode 100644 index 0000000..0471c0f --- /dev/null +++ b/packages/e2e/scenarios/sw-drive.ts @@ -0,0 +1,572 @@ +// Drives the sw-button gallery entry with TRUSTED key events via CDP, +// reading state through the shadow roots between steps. Covers: standalone +// tab traversal + native activation + focused hotkey/i, the in-column claims +// (Enter/Space, column-scoped hotkeys, x/Delete on aux, i, disabled +// claim-and-decline), the two-column scoping proof, and activation feedback +// (the sw-held / sw-pressed classes; the 120ms flash is caught with a +// MutationObserver installed before the keystroke, not with a screenshot race). +// Ported from the Python original (demoscene.meta screenshots, stage-button). + +import { type Scenario, type ScenarioContext, shot } from "yukey-testkit/checks"; +import { openGallery } from "yukey-testkit/gallery"; +import { sleep } from "yukey-testkit/sleep"; + +// State through the shadow roots: which element holds real focus (host chain), +// each demo's log line, and the columns' focused rows. +const STATE_JS = ` +(() => { + const shell = document.querySelector("sw-gallery").shadowRoot; + const out = { realFocus: null, standalone: null, columns: [] }; + const active = shell.activeElement; + const sa = shell.querySelector("swg-button-standalone"); + if (sa) { + const sr = sa.shadowRoot; + out.standalone = { + log: sr.querySelector(".log")?.textContent ?? "", + logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false, + focusedLabel: sr.activeElement?.label ?? null, + }; + if (sa === active) out.realFocus = "standalone/" + (sr.activeElement?.label ?? "?"); + } + for (const col of shell.querySelectorAll("swg-button-column")) { + const sr = col.shadowRoot; + const container = sr.querySelector("[data-mc-container]"); + const label = col.getAttribute("label"); + if (col === active) out.realFocus = label + "/container"; + out.columns.push({ + label, + rowFocused: sr.querySelector(".mc-row-focused")?.dataset?.mcRow ?? null, + focusWithin: container.classList.contains("mc-focus-within"), + log: sr.querySelector(".log")?.textContent ?? "", + logIsInfo: sr.querySelector(".log")?.classList.contains("info") ?? false, + cargoRows: sr.querySelectorAll("[data-mc-aux]").length, + }); + } + return JSON.stringify(out); +})() +`; + +interface SwColumn { + label: string; + rowFocused: string | null; + focusWithin: boolean; + log: string; + logIsInfo: boolean; + cargoRows: number; +} + +interface SwState { + realFocus: string | null; + standalone: { log: string; logIsInfo: boolean; focusedLabel: string | null }; + columns: SwColumn[]; +} + +const READY_JS = + "document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-button-standalone')"; + +async function run(ctx: ScenarioContext): Promise<void> { + const { cdp, checks } = ctx; + const check = (name: string, cond: boolean, detail = "") => checks.check(name, cond, detail); + const st = async (): Promise<SwState> => JSON.parse((await cdp.js(STATE_JS)) as string); + const centerOf = async (jsElExpr: string): Promise<{ x: number; y: number }> => { + const box = await cdp.js(` + (() => { + const r = (${jsElExpr}).getBoundingClientRect(); + return JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2}); + })() + `); + return JSON.parse(box as string); + }; + + await openGallery(ctx, "#sw-button", READY_JS); + + // --- standalone variant --- + // The gallery seats initial focus in the entry's first tab stop on open: + // for #sw-button that is the first standalone button. (Seating happens a + // frame after render; poll briefly.) + let landed = false; + let s = await st(); + for (let i = 0; i < 20; i++) { + s = await st(); + if (s.realFocus === "standalone/Dock at station") { + landed = true; + break; + } + await sleep(50); + } + check("initial-focus-seats-standalone-button", landed, JSON.stringify(s.realFocus)); + + // Native Enter activation. + await cdp.key("Enter"); + s = await st(); + check("standalone-enter-activates", s.standalone.log === "docked", s.standalone.log); + + // The focused button's own hotkey. (Tab to units first: its hotkey is 7, + // distinct from Enter's effect, so the log proves the hotkey path.) + await cdp.key("Tab"); + s = await st(); + check("tab-to-units", s.realFocus === "standalone/units", JSON.stringify(s.realFocus)); + await cdp.key("7"); + s = await st(); + check("standalone-hotkey-activates", s.standalone.log === "units opened", s.standalone.log); + + // i for info on the focused button. + await cdp.key("i"); + s = await st(); + check( + "standalone-i-informs", + s.standalone.log === "info: opens the units registry" && s.standalone.logIsInfo, + s.standalone.log, + ); + + // The hotkey answers only while ITS button is focused: units' 7 from dock does nothing. + await cdp.key("Tab", { shift: true }); // back to dock + await cdp.key("7"); + s = await st(); + check( + "standalone-hotkey-needs-focus", + s.standalone.log === "info: opens the units registry", + s.standalone.log, + ); + + // Disabled: still a tab stop, Enter dead, i answers. (Four tabs: the + // standalone row gained the active 'cruise' example at stage-migrate-infocard: + // dock -> units -> transfer -> cruise -> self destruct.) + for (let i = 0; i < 4; i++) { + await cdp.key("Tab"); + } + s = await st(); + check( + "disabled-is-a-tab-stop", + s.realFocus === "standalone/self destruct", + JSON.stringify(s.realFocus), + ); + await cdp.key("Enter"); + s = await st(); + check( + "disabled-enter-dead", + s.standalone.log === "info: opens the units registry", + s.standalone.log, + ); + await cdp.key("i"); + s = await st(); + check( + "disabled-i-answers", + s.standalone.log === "info: offline: requires command authorization", + s.standalone.log, + ); + + // Mouse click on the dock button (trusted click via CDP). + const SA_ROOT = + 'document.querySelector("sw-gallery").shadowRoot.querySelector("swg-button-standalone")'; + let pt = await centerOf( + `[...${SA_ROOT}.shadowRoot.querySelectorAll("sw-button")].find(x => x.label === "Dock at station")`, + ); + await cdp.click(pt.x, pt.y); + s = await st(); + check("standalone-mouse-click-activates", s.standalone.log === "docked", s.standalone.log); + + // --- ops console (single column) variant --- + // The click above moved real focus onto the dock button, so Tab from there + // walks the remaining standalone buttons before reaching the container. + landed = false; + for (let i = 0; i < 8; i++) { + await cdp.key("Tab"); + s = await st(); + if (s.realFocus === "ops console/container") { + landed = true; + break; + } + } + check("tab-into-ops-console", landed, JSON.stringify(s.realFocus)); + const ops = (v: SwState): SwColumn => v.columns[0] as SwColumn; + check("ops-first-row-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s))); + + // Enter on the focused button row. + await cdp.key("Enter"); + s = await st(); + check("row-enter-activates", ops(s).log === "docked", ops(s).log); + + // Column-scoped hotkey: b (beacon) is a JUMP -- focus moves to the beacon + // row, nothing activates (see-before-commit: Enter is the commit). + await cdp.key("b"); + s = await st(); + check("column-hotkey-moves-focus", ops(s).rowFocused === "beacon", JSON.stringify(ops(s))); + check("column-hotkey-does-not-activate", ops(s).log === "docked", ops(s).log); + await cdp.key("Enter"); + s = await st(); + check("enter-after-jump-activates", ops(s).log === "beacon cycled", ops(s).log); + + // ... and d jumps back; Space mirrors Enter as the commit. + await cdp.key("d"); + s = await st(); + check( + "jump-back-to-dock", + ops(s).rowFocused === "dock" && ops(s).log === "beacon cycled", + JSON.stringify(ops(s)), + ); + await cdp.key(" "); + s = await st(); + check("row-space-activates", ops(s).log === "docked", ops(s).log); + + // i on the focused row's button. + await cdp.key("i"); + s = await st(); + check( + "row-i-informs", + ops(s).log === "info: requests docking clearance" && ops(s).logIsInfo, + ops(s).log, + ); + + // The disabled purge row: focusable, Space claims but declines. + await cdp.key("ArrowDown"); + await cdp.key("ArrowDown"); + s = await st(); + check("disabled-row-focusable", ops(s).rowFocused === "purge", JSON.stringify(ops(s))); + const MAIN_SCROLL = + "document.querySelector('sw-gallery').shadowRoot.querySelector('main').scrollTop"; + const before = await cdp.js(MAIN_SCROLL); + await cdp.key(" "); + s = await st(); + const after = await cdp.js(MAIN_SCROLL); + check( + "disabled-space-claimed-not-acted", + ops(s).log === "info: requests docking clearance", + ops(s).log, + ); + check("disabled-space-no-scroll", before === after, `${before} -> ${after}`); + await cdp.key("i"); + s = await st(); + check( + "disabled-row-i-answers", + ops(s).log === "info: offline: filters already clean", + ops(s).log, + ); + + // Cargo rows: x from main jettisons via the aux button. + await cdp.key("ArrowDown"); + s = await st(); + check("into-cargo", ops(s).rowFocused === "ore crate", JSON.stringify(ops(s))); + await cdp.key("Enter"); + s = await st(); + check("cargo-enter-inspects", ops(s).log === "ore crate inspected", ops(s).log); + await cdp.key("x"); + s = await st(); + check( + "x-jettisons-via-aux-button", + ops(s).log === "ore crate jettisoned" && ops(s).cargoRows === 1, + JSON.stringify(ops(s)), + ); + // Delete works too; first arrow onto the surviving crate row's aux and use Enter instead. + s = await st(); + check("focus-fell-to-next-crate", ops(s).rowFocused === "strut bundle", JSON.stringify(ops(s))); + await cdp.key("ArrowRight"); + await cdp.key("i"); + s = await st(); + check("aux-i-informs", ops(s).log.includes("jettisons this crate"), ops(s).log); + await cdp.key("Enter"); + s = await st(); + check( + "enter-on-aux-jettisons", + ops(s).log === "strut bundle jettisoned" && ops(s).cargoRows === 0, + JSON.stringify(ops(s)), + ); + + // A trusted mouse click on a row-mode sw-button: exactly ONE activation. + // The controller synthesizes Enter from unclaimed row clicks (pointer + // activation for plain rows); sw-button claims its own clicks so the + // synthesis skips it -- a broken dedup would log two activations here. + const OPS_BEACON = `document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-button-column").shadowRoot + .querySelector('[data-mc-row="beacon"] sw-button')`; + await cdp.js( + `window.__acts = 0; (${OPS_BEACON}).addEventListener('click', () => { window.__acts += 1 })`, + ); + pt = await centerOf(OPS_BEACON); + await cdp.click(pt.x, pt.y); + s = await st(); + check("row-button-click-activates", ops(s).log === "beacon cycled", ops(s).log); + const acts = await cdp.js("window.__acts"); + check("row-button-click-activates-exactly-once", acts === 1, JSON.stringify(acts)); + + // --- scoping variant: two columns, both mark d --- + await cdp.key("Tab"); + s = await st(); + check("tab-into-port", s.realFocus === "port console/container", JSON.stringify(s.realFocus)); + const port = (v: SwState): SwColumn => v.columns[1] as SwColumn; + const starboard = (v: SwState): SwColumn => v.columns[2] as SwColumn; + // The focused row's OWN hotkey does nothing: not a jump (already here), + // not an activation (double-tap must not out-power single-tap). + await cdp.key("d"); // focus sits on port's dock row + s = await st(); + check( + "own-hotkey-does-nothing", + port(s).rowFocused === "dock" && port(s).log === "", + JSON.stringify(port(s)), + ); + // From another row, d jumps only in the focused column. + await cdp.key("ArrowDown"); // -> beacon row + await cdp.key("d"); + s = await st(); + check( + "d-jumps-in-port", + port(s).rowFocused === "dock" && port(s).log === "", + JSON.stringify(port(s)), + ); + check( + "starboard-never-hears-d", + starboard(s).rowFocused === "dock" && starboard(s).log === "", + JSON.stringify(starboard(s)), + ); + await cdp.key("Enter"); + s = await st(); + check("port-enter-commits", port(s).log === "docked", port(s).log); + await cdp.key("Tab"); + s = await st(); + check( + "tab-into-starboard", + s.realFocus === "starboard console/container", + JSON.stringify(s.realFocus), + ); + await cdp.key("c"); // starboard's beacon letter: a jump + s = await st(); + check( + "starboard-own-letter-jumps", + starboard(s).rowFocused === "beacon", + JSON.stringify(starboard(s)), + ); + await cdp.key("Enter"); + s = await st(); + check("starboard-enter-commits", starboard(s).log === "beacon cycled", starboard(s).log); + await cdp.key("d"); // jump back to starboard's dock + await cdp.key("Enter"); + s = await st(); + check( + "d-jumps-in-starboard", + starboard(s).rowFocused === "dock" && starboard(s).log === "docked", + JSON.stringify(starboard(s)), + ); + check( + "port-log-unchanged", + port(s).log === "docked" && !port(s).focusWithin, + JSON.stringify(port(s)), + ); + + // --- scroll safety: a hotkey jump to an off-screen row scrolls it into view --- + // Browsers do not scroll aria-activedescendant targets on their own; the + // controller does, on every focus identity change. Shrink the viewport so + // the page overflows its scroller, park the scroller at the bottom (the ops + // console's dock row far off-screen above), and jump to it with d. + await cdp.cmd("Emulation.setDeviceMetricsOverride", { + width: 1440, + height: 400, + deviceScaleFactor: 1, + mobile: false, + }); + landed = false; + for (let i = 0; i < 16; i++) { + await cdp.key("Tab"); + s = await st(); + if (s.realFocus === "ops console/container") { + landed = true; + break; + } + } + check("scroll-tab-into-ops", landed, JSON.stringify(s.realFocus)); + check("scroll-memory-not-on-dock", ops(s).rowFocused !== "dock", JSON.stringify(ops(s))); + const DOCK_VIS = ` + (() => { + const m = document.querySelector('sw-gallery').shadowRoot.querySelector('main'); + const row = document.querySelector('sw-gallery').shadowRoot + .querySelector('swg-button-column').shadowRoot + .querySelector('[data-mc-row="dock"]'); + const mr = m.getBoundingClientRect(), rr = row.getBoundingClientRect(); + return JSON.stringify({scrollTop: m.scrollTop, + visible: rr.bottom > mr.top && rr.top < mr.bottom}); + })()`; + await cdp.js( + "const m = document.querySelector('sw-gallery').shadowRoot.querySelector('main'); m.scrollTop = m.scrollHeight", + ); + const visBefore = JSON.parse((await cdp.js(DOCK_VIS)) as string) as { + scrollTop: number; + visible: boolean; + }; + check("scroll-setup-dock-off-screen", !visBefore.visible, JSON.stringify(visBefore)); + await cdp.key("d"); // the jump: focus lands on dock, and the ring must be visible + s = await st(); + const visAfter = JSON.parse((await cdp.js(DOCK_VIS)) as string) as { + scrollTop: number; + visible: boolean; + }; + check("scroll-jump-landed-on-dock", ops(s).rowFocused === "dock", JSON.stringify(ops(s))); + check( + "scroll-jump-scrolled-into-view", + visAfter.visible && visAfter.scrollTop !== visBefore.scrollTop, + JSON.stringify([visBefore, visAfter]), + ); + await shot(cdp, "hotkey-jump.png"); + await cdp.cmd("Emulation.clearDeviceMetricsOverride"); + + // --- activation feedback: the sw-held / sw-pressed classes --- + // Reload to reset the demos; initial focus re-seats on the first standalone button. + await cdp.cmd("Page.reload", { ignoreCache: true }); + for (let i = 0; i < 50; i++) { + if ((await cdp.js(READY_JS)) === true) break; + await sleep(100); + } + for (let i = 0; i < 20; i++) { + s = await st(); + if (s.realFocus === "standalone/Dock at station") break; + await sleep(50); + } + + // A recorder beats a screenshot race: the observer is installed before the + // keystroke, so a 120ms flash cannot slip between CDP roundtrips. + // Each arm disconnects the previous observer: stale observers otherwise + // increment whatever __flash is current when their callbacks run. + // find: a JS expression yielding the sw-button host to watch. + const armFlash = async (find: string): Promise<void> => { + await cdp.js(` + (() => { + window.__flash?.obs.disconnect(); + const b = ${find}; + const obs = new MutationObserver(() => { + if (b.classList.contains("sw-pressed")) window.__flash.count += 1; + }); + obs.observe(b, { attributes: true, attributeFilter: ["class"] }); + window.__flash = { el: b, count: 0, obs }; + })() + `); + }; + const flashSeen = async (): Promise<boolean> => { + for (let i = 0; i < 30; i++) { + if ((await cdp.js("window.__flash.count > 0")) === true) return true; + await sleep(20); + } + return false; + }; + const flashCleared = async (): Promise<boolean> => { + for (let i = 0; i < 30; i++) { + if ((await cdp.js("!window.__flash.el.classList.contains('sw-pressed')")) === true) + return true; + await sleep(20); + } + return false; + }; + + const SA_BUTTON = (label: string) => + `[...document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-button-standalone").shadowRoot + .querySelectorAll("sw-button")].find(x => x.label === "${label}")`; + + // Keyboard activation flashes: trusted Enter on the focused standalone button. + await armFlash(SA_BUTTON("Dock at station")); + await cdp.key("Enter"); + check("flash-on-trusted-enter", await flashSeen()); + check("flash-clears-after-press-ms", await flashCleared()); + + // The focused button's hotkey flashes too (tab to units, hotkey 7). + await cdp.key("Tab"); + await armFlash(SA_BUTTON("units")); + await cdp.key("7"); + check("flash-on-trusted-hotkey", await flashSeen()); + check("hotkey-flash-clears", await flashCleared()); + + // Held under a trusted pointer: press without release, look, release. + pt = await centerOf(SA_BUTTON("Dock at station")); + await cdp.mouseDown(pt.x, pt.y); + const held = await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('sw-held')`); + check("held-while-pointer-down", held === true); + // The held look IS the flash look, and it sits still: the one screenshot + // a 120ms transient can't reliably give us. + await shot(cdp, "press-flash.png"); + await cdp.mouseUp(pt.x, pt.y); + const released = await cdp.js(`!(${SA_BUTTON("Dock at station")}).classList.contains('sw-held')`); + check("held-releases-on-pointer-up", released === true); + // The release click flashes Dock; let that settle before the disabled check + // so no mutation from it is in flight when the observer moves. + for (let i = 0; i < 30; i++) { + if ( + (await cdp.js(`(${SA_BUTTON("Dock at station")}).classList.contains('sw-pressed')`)) !== true + ) + break; + await sleep(20); + } + + // Disabled: neither held nor flashed (nothing happened; nothing may claim otherwise). + pt = await centerOf(SA_BUTTON("self destruct")); + await armFlash(SA_BUTTON("self destruct")); + await cdp.mouseDown(pt.x, pt.y); + const heldDisabled = await cdp.js( + `(${SA_BUTTON("self destruct")}).classList.contains('sw-held')`, + ); + await cdp.mouseUp(pt.x, pt.y); + check("disabled-never-held", heldDisabled !== true); + await sleep(300); // give a wrong flash time to have happened + check("disabled-never-flashed", (await cdp.js("window.__flash.count > 0")) !== true); + + // Row mode: Enter on the focused row flashes its button; a column-scoped + // hotkey JUMP flashes nothing (a focus move is navigation -- ring movement + // and flash must stay distinct vocabularies), and the Enter that follows + // flashes the landed row's button. + landed = false; + for (let i = 0; i < 8; i++) { + await cdp.key("Tab"); + s = await st(); + if (s.realFocus === "ops console/container") { + landed = true; + break; + } + } + check("feedback-tab-into-ops", landed, JSON.stringify(s.realFocus)); + const COL_BUTTON = (row: string) => + `document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-button-column").shadowRoot + .querySelector('[data-mc-row="${row}"] sw-button')`; + await armFlash(COL_BUTTON("dock")); + await cdp.key("Enter"); + check("row-flash-on-trusted-enter", await flashSeen()); + check("row-flash-clears", await flashCleared()); + await armFlash(COL_BUTTON("beacon")); + await cdp.key("b"); // a jump: focus moves to beacon, nothing activates + await sleep(300); // give a wrong flash time to have happened + s = await st(); + check("no-flash-on-hotkey-jump", (await cdp.js("window.__flash.count > 0")) !== true); + check("jump-landed-on-beacon", ops(s).rowFocused === "beacon", JSON.stringify(ops(s))); + await cdp.key("Enter"); // the commit: now the flash names the actor + check("flash-on-enter-after-jump", await flashSeen()); + + // Plain megacolumn rows (no widget): the demo host flashes the row itself. + await cdp.js("location.hash = '#megacolumn'"); + await cdp.cmd("Page.reload", { ignoreCache: true }); + for (let i = 0; i < 50; i++) { + if ( + (await cdp.js( + "document.readyState === 'complete' && !!document.querySelector('sw-gallery')?.shadowRoot?.querySelector('swg-megacolumn-demo')", + )) === true + ) + break; + await sleep(100); + } + landed = false; + for (let i = 0; i < 20; i++) { + if ( + (await cdp.js(`document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-megacolumn-demo").shadowRoot + .querySelector("[data-mc-container]").classList.contains("mc-focus-within")`)) === true + ) { + landed = true; + break; + } + await sleep(50); + } + check("plain-rows-focus-seated", landed); + await armFlash(`document.querySelector("sw-gallery").shadowRoot + .querySelector("swg-megacolumn-demo").shadowRoot + .querySelector('[data-mc-row="beacon"]')`); + await cdp.key("Enter"); + check("plain-row-flash-on-trusted-enter", await flashSeen()); + check("plain-row-flash-clears", await flashCleared()); +} + +export const swDrive: Scenario = { name: "sw", run }; diff --git a/packages/yukey-testkit/README.md b/packages/yukey-testkit/README.md new file mode 100644 index 0000000..5f83238 --- /dev/null +++ b/packages/yukey-testkit/README.md @@ -0,0 +1,36 @@ +yukey-testkit +============= + +A dependency-free CDP client for driving a page with *trusted* input, +and reading its state between steps. + +It exists because synthetic DOM events can't drive real focus traversal: +a `KeyboardEvent` dispatched from JavaScript won't move focus on Tab, +won't type into an input, and won't trigger hover. +Trusted input (`Input.dispatchKeyEvent` and friends over the Chrome DevTools Protocol) +is what a real keyboard and mouse produce, so a test built on it exercises the same paths a user does. +That's the only kind of test that can prove a keyboard-first UI actually works from the keyboard. + +Node 22+, using its built-in `WebSocket`. Nothing else. + + +What's in it +------------ + +- `yukey-testkit/cdp`: the `Cdp` class. + `Cdp.connect(wsUrl)` to a page target; then `key`, `click`, `mouseMove`, `wheel` for trusted input, + `js(expr)` for state reads, `screenshot()`, and `cmd` for any other protocol method. +- `yukey-testkit/checks`: the `Checks` collector (a PASS/FAIL line protocol with a summary), + the `Scenario` and `ScenarioContext` shapes a suite runner can share, and `shot` for optional screenshot evidence + (written only when `YUKEY_E2E_SHOTS_DIR` is set). +- `yukey-testkit/gallery`: `openGallery`, the setup etiquette for landing on a yukey gallery entry in a fresh document. +- `yukey-testkit/sleep`: a promise-returning sleep for polling loops. + +Launching Chrome and serving your page are yours to do; they vary too much per app to belong here. +Yukey's own `packages/e2e/harness.ts` is one worked example (static files on an ephemeral port, a headless Chrome with a throwaway profile). + + +License +------- + +SPDX-License-Identifier: Apache-2.0 OR MIT diff --git a/packages/yukey-testkit/src/cdp.ts b/packages/yukey-testkit/src/cdp.ts new file mode 100644 index 0000000..8d3f5b6 --- /dev/null +++ b/packages/yukey-testkit/src/cdp.ts @@ -0,0 +1,203 @@ +// A small typed CDP client: JSON-RPC over a websocket to one page target. +// +// This is the shared core of the e2e scenarios: TRUSTED input dispatch +// (Input.dispatchKeyEvent / Input.dispatchMouseEvent -- synthetic DOM events +// cannot drive real focus traversal, real typing, or real hover) plus +// Runtime.evaluate state reads between steps. +// Runs on Node 22's built-in WebSocket; no dependencies. + +// Key definitions for the non-printable keys the scenarios use: +// windowsVirtualKeyCode + code, exactly what a real keyboard produces. +const KEYDEFS: Record<string, [number, string]> = { + Tab: [9, "Tab"], + Enter: [13, "Enter"], + Escape: [27, "Escape"], + ArrowUp: [38, "ArrowUp"], + ArrowDown: [40, "ArrowDown"], + ArrowLeft: [37, "ArrowLeft"], + ArrowRight: [39, "ArrowRight"], + Home: [36, "Home"], + End: [35, "End"], + Delete: [46, "Delete"], + " ": [32, "Space"], +}; + +interface PendingCommand { + resolve: (result: unknown) => void; + reject: (err: Error) => void; +} + +// A protocol event (a message with a method and no id), e.g. Runtime.consoleAPICalled. +export interface CdpEvent { + method: string; + params: unknown; +} + +export class Cdp { + private readonly ws: WebSocket; + private msgid = 0; + private readonly pending = new Map<number, PendingCommand>(); + // Every protocol event received on this connection, in arrival order. + // Domains emit nothing until enabled (e.g. Runtime.enable for console capture), + // so this stays empty unless a scenario asks for events. + readonly events: CdpEvent[] = []; + + private constructor(ws: WebSocket) { + this.ws = ws; + ws.addEventListener("message", (ev) => this.onMessage(String(ev.data))); + } + + static async connect(wsUrl: string): Promise<Cdp> { + const ws = new WebSocket(wsUrl); + await new Promise<void>((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error(`websocket connect failed: ${wsUrl}`)), { + once: true, + }); + }); + return new Cdp(ws); + } + + close(): void { + this.ws.close(); + } + + private onMessage(raw: string): void { + const msg = JSON.parse(raw) as { + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: unknown; + }; + if (msg.method !== undefined) { + this.events.push({ method: msg.method, params: msg.params }); + return; + } + if (msg.id === undefined) return; + const waiter = this.pending.get(msg.id); + if (waiter === undefined) return; + this.pending.delete(msg.id); + if (msg.error !== undefined) { + waiter.reject(new Error(`CDP error: ${JSON.stringify(msg.error)}`)); + } else { + waiter.resolve(msg.result); + } + } + + cmd(method: string, params: Record<string, unknown> = {}): Promise<unknown> { + this.msgid += 1; + const id = this.msgid; + const sent = new Promise<unknown>((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + this.ws.send(JSON.stringify({ id, method, params })); + return sent; + } + + // Dispatch a trusted key press (down + up). Named keys come from KEYDEFS; + // anything else is treated as a printable character. + // Enter and Space carry their text payload on a full keyDown + // (rawKeyDown skips text insertion; native widgets need the text to act). + async key(key: string, opts: { shift?: boolean } = {}): Promise<void> { + const mods = opts.shift === true ? 8 : 0; + const def = KEYDEFS[key]; + if (def !== undefined) { + const [vk, code] = def; + const text = key === " " ? " " : key === "Enter" ? "\r" : ""; + const down: Record<string, unknown> = { + type: "rawKeyDown", + windowsVirtualKeyCode: vk, + code, + key, + modifiers: mods, + }; + if (text !== "") { + down.type = "keyDown"; + down.text = text; + } + await this.cmd("Input.dispatchKeyEvent", down); + await this.cmd("Input.dispatchKeyEvent", { + type: "keyUp", + windowsVirtualKeyCode: vk, + code, + key, + modifiers: mods, + }); + return; + } + const vk = key.toUpperCase().charCodeAt(0); + const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : ""; + await this.cmd("Input.dispatchKeyEvent", { + type: "keyDown", + windowsVirtualKeyCode: vk, + code, + key, + text: key, + modifiers: mods, + }); + await this.cmd("Input.dispatchKeyEvent", { + type: "keyUp", + windowsVirtualKeyCode: vk, + code, + key, + modifiers: mods, + }); + } + + async mouseMove(x: number, y: number): Promise<void> { + await this.cmd("Input.dispatchMouseEvent", { type: "mouseMoved", x, y }); + } + + async mouseDown(x: number, y: number, button = "left"): Promise<void> { + await this.cmd("Input.dispatchMouseEvent", { + type: "mousePressed", + x, + y, + button, + clickCount: 1, + }); + } + + async mouseUp(x: number, y: number, button = "left"): Promise<void> { + await this.cmd("Input.dispatchMouseEvent", { + type: "mouseReleased", + x, + y, + button, + clickCount: 1, + }); + } + + async click(x: number, y: number, button = "left"): Promise<void> { + await this.mouseDown(x, y, button); + await this.mouseUp(x, y, button); + } + + // A trusted wheel tick at (x, y). Positive deltaY scrolls down (the map + // reads that as zoom OUT); negative zooms in, anchored under the cursor. + async wheel(x: number, y: number, deltaY: number): Promise<void> { + await this.cmd("Input.dispatchMouseEvent", { + type: "mouseWheel", + x, + y, + deltaX: 0, + deltaY, + }); + } + + // Evaluate an expression in the page, returning its value by JSON. + // awaitPromise resolves a returned promise first (needed by probes that + // measure over a time window). + async js(expr: string, opts: { awaitPromise?: boolean } = {}): Promise<unknown> { + const params: Record<string, unknown> = { expression: expr, returnByValue: true }; + if (opts.awaitPromise === true) params.awaitPromise = true; + const r = (await this.cmd("Runtime.evaluate", params)) as { result?: { value?: unknown } }; + return r.result?.value; + } + + async screenshot(): Promise<Buffer> { + const r = (await this.cmd("Page.captureScreenshot", { format: "png" })) as { data: string }; + return Buffer.from(r.data, "base64"); + } +} diff --git a/packages/yukey-testkit/src/checks.ts b/packages/yukey-testkit/src/checks.ts new file mode 100644 index 0000000..261bb60 --- /dev/null +++ b/packages/yukey-testkit/src/checks.ts @@ -0,0 +1,53 @@ +// The check collector: the PASS/FAIL line protocol every scenario reports through. +// Same shape as the drivers always had: each check prints immediately, +// failures carry their detail, and the run summarizes at the end. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Cdp } from "./cdp.ts"; + +export interface ScenarioContext { + cdp: Cdp; + baseUrl: string; + checks: Checks; +} + +export interface Scenario { + // The short name used in run output and for selecting scenarios from the CLI. + name: string; + run(ctx: ScenarioContext): Promise<void>; +} + +export class Checks { + private readonly results: { name: string; ok: boolean }[] = []; + + check(name: string, cond: boolean, detail = ""): void { + this.results.push({ name, ok: cond }); + console.log((cond ? "PASS " : "FAIL ") + name + (detail !== "" && !cond ? ` ${detail}` : "")); + } + + skip(name: string, why: string): void { + console.log(`SKIP ${name} (${why})`); + } + + get passed(): number { + return this.results.filter((r) => r.ok).length; + } + + get total(): number { + return this.results.length; + } + + get failed(): number { + return this.total - this.passed; + } +} + +// Screenshots were stage evidence in the original drivers, never assertions: +// they write only when YUKEY_E2E_SHOTS_DIR names a destination, and no-op otherwise. +export async function shot(cdp: Cdp, name: string): Promise<void> { + const dir = process.env.YUKEY_E2E_SHOTS_DIR; + if (dir === undefined || dir === "") return; + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), await cdp.screenshot()); +} diff --git a/packages/yukey-testkit/src/gallery.ts b/packages/yukey-testkit/src/gallery.ts new file mode 100644 index 0000000..517708c --- /dev/null +++ b/packages/yukey-testkit/src/gallery.ts @@ -0,0 +1,32 @@ +// Opening a gallery entry: park the hash, force a fresh document, wait for readiness. +// This is the setup etiquette every gallery scenario shared as a long-lived-browser +// convention; here it is explicit and self-contained per scenario. + +import type { ScenarioContext } from "./checks.ts"; +import { sleep } from "./sleep.ts"; + +export async function openGallery( + ctx: ScenarioContext, + hash: string, + readyExpr: string, +): Promise<void> { + const { cdp } = ctx; + // A fresh browser starts on about:blank: get onto gallery.html first. + // (Hash changes on the current document are synchronous; cross-document + // navigation needs the load wait before the reload below can be aimed.) + const onGallery = await cdp.js("location.pathname === '/gallery.html'"); + if (onGallery !== true) { + await cdp.cmd("Page.navigate", { url: `${ctx.baseUrl}/gallery.html${hash}` }); + for (let i = 0; i < 50; i++) { + if ((await cdp.js("document.readyState === 'complete'")) === true) break; + await sleep(100); + } + } + await cdp.js(`location.hash = ${JSON.stringify(hash)}`); + await cdp.cmd("Page.reload", { ignoreCache: true }); + for (let i = 0; i < 50; i++) { + if ((await cdp.js(readyExpr)) === true) return; + await sleep(100); + } + throw new Error(`gallery entry ${hash} never became ready`); +} diff --git a/packages/yukey-testkit/src/sleep.ts b/packages/yukey-testkit/src/sleep.ts new file mode 100644 index 0000000..227aeb9 --- /dev/null +++ b/packages/yukey-testkit/src/sleep.ts @@ -0,0 +1,3 @@ +// A promise that resolves after ms. Scenarios use it to poll between steps; +// the harness that launches Chrome uses it the same way. +export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); diff --git a/packages/yukey-testkit/tsconfig.json b/packages/yukey-testkit/tsconfig.json index d991313..b97fee7 100644 --- a/packages/yukey-testkit/tsconfig.json +++ b/packages/yukey-testkit/tsconfig.json @@ -7,6 +7,7 @@ "types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, + "rewriteRelativeImportExtensions": true, "noEmit": true }, "include": ["src"] diff --git a/packages/yukey/README.md b/packages/yukey/README.md new file mode 100644 index 0000000..3655aab --- /dev/null +++ b/packages/yukey/README.md @@ -0,0 +1,42 @@ +yukey +===== + +*a ui framework for the keyboard lover* + +Lit web components for application UIs where the keyboard is on par with the mouse, or better. +The pitch, with a screenshot, is in the repository README; +the precepts the kit follows are in its `docs/`. + + +Install +------- + +``` +npm install yukey lit +``` + +`lit` is a peer dependency: the components are plain Lit elements and share your app's copy. + + +Use +--- + +Every module is importable by its file name: + +```ts +import "yukey/sw-button"; // registers the custom element +import { MegacolumnController } from "yukey/megacolumn-controller"; +import { installTokens } from "yukey/tokens"; + +installTokens(); // once, at startup: the design tokens into :root +``` + +Components (`sw-button`, `sw-list`, `sw-live-list`, `sw-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. + + +License +------- + +SPDX-License-Identifier: Apache-2.0 OR MIT diff --git a/packages/yukey/package.json b/packages/yukey/package.json index f6acb1d..705c093 100644 --- a/packages/yukey/package.json +++ b/packages/yukey/package.json @@ -3,7 +3,32 @@ "version": "0.0.0", "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": true, + "sideEffects": [ + "./src/sw-button.ts", + "./src/sw-infopop.ts", + "./src/sw-list.ts", + "./src/sw-live-list.ts", + "./src/gallery/sw-gallery.ts", + "./src/gallery/main.ts", + "./src/megacolumn.gallery.ts", + "./src/sw-button.gallery.ts", + "./src/sw-infopop.gallery.ts", + "./src/sw-list.gallery.ts", + "./src/sw-live-list.gallery.ts", + "./src/tokens.gallery.ts", + "./dist/sw-button.js", + "./dist/sw-infopop.js", + "./dist/sw-list.js", + "./dist/sw-live-list.js", + "./dist/gallery/sw-gallery.js", + "./dist/gallery/main.js", + "./dist/megacolumn.gallery.js", + "./dist/sw-button.gallery.js", + "./dist/sw-infopop.gallery.js", + "./dist/sw-list.gallery.js", + "./dist/sw-live-list.gallery.js", + "./dist/tokens.gallery.js" + ], "type": "module", "exports": { "./*": { diff --git a/packages/yukey/src/adopt-into-root.ts b/packages/yukey/src/adopt-into-root.ts new file mode 100644 index 0000000..2d05745 --- /dev/null +++ b/packages/yukey/src/adopt-into-root.ts @@ -0,0 +1,30 @@ +// Stylesheet adoption for components that render into their OWNER's tree. +// Megacolumn-composed components (sw-live-list mega mode, sw-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-sw-live-list"). + +import type { CSSResult } from "lit"; + +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`); + const sheet = styles.styleSheet; + if (sheet !== undefined) { + if (!root.adoptedStyleSheets.includes(sheet)) + root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + return; + } + const holder = root instanceof Document ? root.head : root; + if (holder.querySelector(`style[${marker}]`) !== null) return; + const style = document.createElement("style"); + style.setAttribute(marker, ""); + style.textContent = styles.cssText; + holder.appendChild(style); +} diff --git a/packages/yukey/src/focus-stack.test.ts b/packages/yukey/src/focus-stack.test.ts new file mode 100644 index 0000000..08a5368 --- /dev/null +++ b/packages/yukey/src/focus-stack.test.ts @@ -0,0 +1,292 @@ +// @vitest-environment jsdom +// +// Tests for the focus stack (focus-stack.ts): the journey semantics the +// guidelines' "Focus is a stack" norm demands. The rig registers plain-div +// surfaces (host > tabbable inner), plus shadow-root surfaces where composed +// containment is the thing under test; focus moves are real jsdom focus. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FocusStack, focusStackOf, installFocusStack } from "./focus-stack.js"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +// A surface rig: a host div holding one tabbable inner div. +interface Rig { + host: HTMLElement; + inner: HTMLElement; +} + +function makeSurface(parent: ParentNode = document.body): Rig { + const host = document.createElement("div"); + const inner = document.createElement("div"); + inner.tabIndex = 0; + host.appendChild(inner); + parent.appendChild(host); + return { host, inner }; +} + +describe("push and pop", () => { + let stack: FocusStack; + beforeEach(() => { + stack = new FocusStack(document); + }); + + it("journey-select-escape: pop returns focus to the origin surface", () => { + const a = makeSurface(); + const b = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + b.inner.focus(); + stack.closed("b", true); + expect(document.activeElement).toBe(a.inner); + }); + + it("journey-lateral-select: entered() on the top surface again keeps the origin", () => { + const a = makeSurface(); + const b = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + b.inner.focus(); + // A lateral move: the surface re-targets and re-seats; no push. + stack.entered("b"); + expect(stack.entries.length).toBe(1); + stack.closed("b", true); + expect(document.activeElement).toBe(a.inner); + }); + + it("journey-reopened-origin: origins are identity, resolved to the CURRENT element", () => { + const a = makeSurface(); + const b = makeSurface(); + let aNow = a; + stack.register({ id: "a", root: () => aNow.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + b.inner.focus(); + // The origin surface closes and reopens as a fresh element (a re-render). + a.host.remove(); + aNow = makeSurface(); + stack.closed("b", true); + expect(document.activeElement).toBe(aNow.inner); + }); + + it("journey-nested-origin: the origin is the INNERMOST containing surface", () => { + // An outer surface whose shadow root hosts an inner surface (the + // registry/filters composition): entering a third surface from inside + // the inner one records the inner one. + const outerHost = document.createElement("div"); + document.body.appendChild(outerHost); + const shadow = outerHost.attachShadow({ mode: "open" }); + const inner = makeSurface(shadow); + const c = makeSurface(); + stack.register({ id: "outer", root: () => outerHost }); + stack.register({ id: "inner", root: () => inner.host }); + stack.register({ id: "c", root: () => c.host }); + inner.inner.focus(); + expect(stack.path()).toEqual(["outer", "inner"]); + stack.entered("c"); + c.inner.focus(); + stack.closed("c", true); + expect(shadow.activeElement).toBe(inner.inner); + }); + + it("a shadow-rooted surface pops to its first tab stop inside the shadow", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: "open" }); + const container = document.createElement("div"); + container.tabIndex = 0; + shadow.appendChild(container); + const b = makeSurface(); + stack.register({ id: "panel", root: () => host }); + stack.register({ id: "b", root: () => b.host }); + container.focus(); + stack.entered("b"); + b.inner.focus(); + stack.closed("b", true); + expect(shadow.activeElement).toBe(container); + }); + + it("entered() from nowhere records a null origin", () => { + const b = makeSurface(); + stack.register({ id: "b", root: () => b.host }); + stack.entered("b"); + expect(stack.entries).toEqual([{ entered: "b", origin: null }]); + }); + + it("entered() with focus already inside the surface degrades the origin to null", () => { + const b = makeSurface(); + stack.register({ id: "b", root: () => b.host }); + b.inner.focus(); + stack.entered("b"); + expect(stack.entries).toEqual([{ entered: "b", origin: null }]); + }); + + it("one-entry-per-id: entering a surface buried deeper retires its old entry", () => { + const a = makeSurface(); + const b = makeSurface(); + const c = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + stack.register({ id: "c", root: () => c.host }); + a.inner.focus(); + stack.entered("b"); // {b <- a} + b.inner.focus(); + stack.entered("c"); // {c <- b} + c.inner.focus(); + stack.entered("b"); // b again: the old {b <- a} retires, {b <- c} pushes + expect(stack.entries).toEqual([ + { entered: "c", origin: "b" }, + { entered: "b", origin: "c" }, + ]); + }); +}); + +describe("dead entries and routing", () => { + let stack: FocusStack; + beforeEach(() => { + stack = new FocusStack(document); + }); + + it("journey-dead-origin: a dead origin unwinds to the fallback", () => { + const a = makeSurface(); + const b = makeSurface(); + const fallback = makeSurface(); + stack.setFallback(() => fallback.inner); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + b.inner.focus(); + a.host.remove(); // the origin died while b held focus + stack.closed("b", true); + expect(document.activeElement).toBe(fallback.inner); + }); + + it("unwinds past a dead origin to the innermost remaining open surface", () => { + const a = makeSurface(); + const b = makeSurface(); + const c = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + stack.register({ id: "c", root: () => c.host }); + stack.entered("a"); // {a <- null} + a.inner.focus(); + stack.entered("b"); // {b <- a} + b.inner.focus(); + stack.entered("c"); // {c <- b} + c.inner.focus(); + b.host.remove(); // c's origin dies; its own entry is swept on close + stack.closed("c", true); + expect(document.activeElement).toBe(a.inner); + expect(stack.entries).toEqual([{ entered: "a", origin: null }]); + }); + + it("journey-overlay-collapse: closing a surface sweeps entries of surfaces that died with it", () => { + // The registry/filters shape: the inner overlay unmounts when the panel + // does, without its own closed() call. + const panel = makeSurface(); + const overlay = makeSurface(panel.host); + const fallback = makeSurface(); + stack.setFallback(() => fallback.inner); + stack.register({ id: "panel", root: () => panel.host }); + stack.register({ id: "overlay", root: () => overlay.host }); + stack.entered("panel"); + panel.inner.focus(); + stack.entered("overlay"); + overlay.inner.focus(); + panel.host.remove(); // takes the overlay with it + stack.closed("panel", true); + expect(stack.entries).toEqual([]); + expect(document.activeElement).toBe(fallback.inner); + }); + + it("a held close with no entry still routes (a passive open that focus wandered into)", () => { + const a = makeSurface(); + const b = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + stack.entered("a"); + a.inner.focus(); + // b opened passively (no entered), the user tabbed in, then it closed. + b.inner.focus(); + stack.closed("b", true); + expect(document.activeElement).toBe(a.inner); + }); + + it("an unheld close retires the entry and moves nothing", () => { + const a = makeSurface(); + const b = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + // Focus never followed (or already left); the close must not yank it. + stack.closed("b", false); + expect(stack.entries).toEqual([]); + expect(document.activeElement).toBe(a.inner); + }); + + it("a live origin with no tab stop left is skipped like a dead one", () => { + const a = makeSurface(); + const b = makeSurface(); + const fallback = makeSurface(); + stack.setFallback(() => fallback.inner); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + b.inner.focus(); + a.inner.remove(); // the origin survives but lost its one tab stop + stack.closed("b", true); + expect(document.activeElement).toBe(fallback.inner); + }); + + it("fail closed: routing with no live stop and no fallback throws", () => { + const b = makeSurface(); + stack.register({ id: "b", root: () => b.host }); + stack.entered("b"); + b.inner.focus(); + expect(() => stack.closed("b", true)).toThrow(/no live origin and no fallback/); + }); +}); + +describe("registration and accessors", () => { + it("registering a duplicate id throws", () => { + const stack = new FocusStack(document); + const a = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + expect(() => stack.register({ id: "a", root: () => a.host })).toThrow(/already registered/); + }); + + it("entered() for an unregistered surface throws", () => { + const stack = new FocusStack(document); + expect(() => stack.entered("ghost")).toThrow(/no such surface/); + }); + + it("clear() retires every entry and routes nothing", () => { + const stack = new FocusStack(document); + const a = makeSurface(); + const b = makeSurface(); + stack.register({ id: "a", root: () => a.host }); + stack.register({ id: "b", root: () => b.host }); + a.inner.focus(); + stack.entered("b"); + stack.clear(); + expect(stack.entries).toEqual([]); + expect(document.activeElement).toBe(a.inner); + }); + + it("focusStackOf returns one stack per document; installFocusStack wires its fallback", () => { + const stack = focusStackOf(document); + expect(focusStackOf(document)).toBe(stack); + const fallback = makeSurface(); + expect(installFocusStack(document, () => fallback.inner)).toBe(stack); + }); +}); diff --git a/packages/yukey/src/focus-stack.ts b/packages/yukey/src/focus-stack.ts new file mode 100644 index 0000000..5f2fcc2 --- /dev/null +++ b/packages/yukey/src/focus-stack.ts @@ -0,0 +1,235 @@ +// 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 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. + +import { deepActiveElement, deepTabbables } from "./tab-cycle.js"; + +export interface FocusSurface { + // 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(): HTMLElement | null; +} + +interface StackEntry { + entered: string; + origin: string | null; +} + +// 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) { + if (cur === ancestor) return true; + cur = cur.parentNode ?? (cur instanceof ShadowRoot ? cur.host : null); + } + return false; +} + +export class FocusStack { + #doc: Document; + #surfaces = new Map<string, FocusSurface>(); + #entries: StackEntry[] = []; + #fallback: (() => HTMLElement | null) | undefined; + + constructor(doc: Document) { + this.#doc = doc; + } + + // The end of the line for pops: the page shell's one predictable focus home + // (the root surface -- sw-app points this at its map instance). + 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). + 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(): void { + this.#entries = []; + } + + register(surface: FocusSurface): void { + if (this.#surfaces.has(surface.id)) + throw new Error(`focus-stack: surface "${surface.id}" is already registered`); + this.#surfaces.set(surface.id, surface); + } + + unregister(id: string): void { + 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(): string[] { + const active = deepActiveElement(this.#doc); + if (active === null) return []; + const containing: { id: string; root: HTMLElement }[] = []; + for (const surface of this.#surfaces.values()) { + const root = this.#liveRoot(surface.id); + if (root !== null && composedContains(root, active)) + containing.push({ id: surface.id, root }); + } + // Surfaces containing one focus point form a containment chain, so this + // pairwise comparator is a total order on them. + containing.sort((a, b) => (a === b ? 0 : composedContains(a.root, b.root) ? -1 : 1)); + 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(id: string): void { + if (!this.#surfaces.has(id)) + throw new Error(`focus-stack: entered("${id}") but no such surface is registered`); + const top = this.#entries[this.#entries.length - 1]; + if (top?.entered === id) return; // replace-not-push: the journey origin holds + this.#entries = this.#entries.filter((e) => e.entered !== id); // one entry per id + const path = this.path(); + // The innermost containing surface that isn't the entered one itself + // (a self-origin would be a pop to nowhere). + const origin = [...path].reverse().find((s) => s !== id) ?? null; + 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(id: string, held: boolean): void { + let retired: StackEntry | undefined; + for (let i = this.#entries.length - 1; i >= 0; i--) { + if (this.#entries[i]?.entered === id) { + retired = this.#entries[i]; + this.#entries.splice(i, 1); + break; + } + } + // Sweep: an entry whose surface is dead has nothing left to pop back to + // it (a closing panel takes its inner overlays with it). + this.#entries = this.#entries.filter((e) => this.#liveRoot(e.entered) !== null); + if (!held) return; + const candidates: (string | null)[] = [retired?.origin ?? null]; + for (let i = this.#entries.length - 1; i >= 0; i--) { + candidates.push(this.#entries[i]?.entered ?? null); + } + for (const candidate of candidates) { + if (candidate === null) continue; + if (this.#focusSurface(candidate)) return; + } + const fallback = this.#fallback?.() ?? null; + if (fallback === null) + throw new Error( + `focus-stack: "${id}" closed holding focus with no live origin and no fallback`, + ); + fallback.focus(); + } + + #liveRoot(id: string): HTMLElement | null { + const root = this.#surfaces.get(id)?.root(); + 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(id: string): boolean { + const root = this.#liveRoot(id); + if (root === null) return false; + const base = root.shadowRoot ?? root; + const target = root.tabIndex >= 0 ? root : deepTabbables(base)[0]; + if (target === undefined) return false; + target.focus(); + return true; + } +} + +// 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>(); + +export function focusStackOf(doc: Document): FocusStack { + let stack = stacks.get(doc); + if (stack === undefined) { + stack = new FocusStack(doc); + stacks.set(doc, stack); + } + 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). +export function installFocusStack(doc: Document, fallback: () => HTMLElement | null): FocusStack { + const stack = focusStackOf(doc); + stack.setFallback(fallback); + return stack; +} diff --git a/packages/yukey/src/gallery/entry.ts b/packages/yukey/src/gallery/entry.ts new file mode 100644 index 0000000..9933ab1 --- /dev/null +++ b/packages/yukey/src/gallery/entry.ts @@ -0,0 +1,34 @@ +// The gallery-entry convention (this file is its SSOT): +// +// Each component ships a gallery entry file beside itself -- `foo.gallery.ts` next to +// `foo.ts` -- default-exporting a GalleryEntry. Registration is one import line in +// `registry.ts`. The `.gallery.ts` suffix keeps entries out of the app bundle (only +// the gallery entry point imports them) and out of the vitest net (`*.test.ts`). +// +// A variant is a caption plus a render function. For anything stateful or +// interactive, define a small demo custom element inside the .gallery.ts file and +// have the variant render it: the GalleryEntry surface stays declarative, and +// statefulness lives where we always put it -- in a Lit component. + +import type { TemplateResult } from "lit"; + +export interface GalleryVariant { + // One or two sentences: what this variant shows, and what to try or watch for. + caption: string; + // Wide variants get the full content column instead of the default 560px stage + // (reference tables and ramps want room; component demos usually don't). + wide?: boolean; + // Called on each shell render; returning fresh templates means a demo restarts + // cleanly when navigated away from and back to. + render: () => TemplateResult; +} + +export interface GalleryEntry { + // Hash-route slug; conventionally the element tag name (e.g. "sw-live-list"). + id: string; + title: string; + // What the component is for, in a sentence or two. This is living documentation: + // write it for the person deciding whether this component fits their screen. + description: string; + variants: GalleryVariant[]; +} diff --git a/packages/yukey/src/gallery/main.ts b/packages/yukey/src/gallery/main.ts new file mode 100644 index 0000000..5a08eb8 --- /dev/null +++ b/packages/yukey/src/gallery/main.ts @@ -0,0 +1,6 @@ +// Gallery entry point (esbuild bundles this to static/gallery.js; gallery.html hosts it). +// Registering <sw-gallery> is all that's needed; it pulls the entry registry in turn. +import { installTokens } from "../tokens.js"; +import "./sw-gallery.js"; + +installTokens(); diff --git a/packages/yukey/src/gallery/registry.ts b/packages/yukey/src/gallery/registry.ts new file mode 100644 index 0000000..72744a7 --- /dev/null +++ b/packages/yukey/src/gallery/registry.ts @@ -0,0 +1,20 @@ +// The one place gallery entries are registered: one import line per component. +// Keep the list alphabetical by id so the nav order is deterministic and merges stay clean. +// (See entry.ts for the convention itself.) + +import megacolumn from "../megacolumn.gallery.js"; +import swButton from "../sw-button.gallery.js"; +import swInfopop from "../sw-infopop.gallery.js"; +import swList from "../sw-list.gallery.js"; +import swLiveList from "../sw-live-list.gallery.js"; +import tokens from "../tokens.gallery.js"; +import type { GalleryEntry } from "./entry.js"; + +export const entries: GalleryEntry[] = [ + megacolumn, + swButton, + swInfopop, + swList, + swLiveList, + tokens, +]; diff --git a/packages/yukey/src/gallery/sw-gallery.ts b/packages/yukey/src/gallery/sw-gallery.ts new file mode 100644 index 0000000..af62b8a --- /dev/null +++ b/packages/yukey/src/gallery/sw-gallery.ts @@ -0,0 +1,164 @@ +// The component gallery shell: nav of registered entries on the left, the selected +// entry's variants on the right. Hash routing (#sw-live-list) so entries are +// linkable and reload survives. Styled entirely from the design tokens: the shell +// is the token vocabulary's first real consumer, and doubles as a proof of it. + +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import "../sw-infopop.js"; +import { deepTabbables, installTabContainment } from "../tab-cycle.js"; +import type { GalleryEntry } from "./entry.js"; +import { entries } from "./registry.js"; + +@customElement("sw-gallery") +export class SwGallery extends LitElement { + static styles = css` + :host { + display: grid; + grid-template-columns: 220px 1fr; + height: 100%; + font: var(--sw-type-body) var(--sw-font); + color: var(--sw-text); + background: var(--sw-bg-void); + } + nav { + border-right: 1px solid var(--sw-border-dim); + padding: var(--sw-space-md) 0; + overflow-y: auto; + } + nav .head { + padding: 0 var(--sw-space-lg) var(--sw-space-md); + color: var(--sw-text-dim); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + font-size: var(--sw-type-small); + } + nav a { + display: block; + min-height: var(--sw-hit-min-h); + box-sizing: border-box; + padding: var(--sw-space-xs) var(--sw-space-lg); + color: var(--sw-accent-bright); + text-decoration: none; + } + nav a:hover { + color: var(--sw-text-bright); + background: var(--sw-wash-hover); + } + nav a.current { + color: var(--sw-text-bright); + background: var(--sw-wash-selected); + } + nav a:focus-visible { + outline: var(--sw-focus-ring); + outline-offset: calc(-1 * var(--sw-focus-ring-width)); + } + main { + padding: var(--sw-space-xl) 28px; + overflow-y: auto; + } + main h1 { + font-size: var(--sw-type-head); + color: var(--sw-text-bright); + letter-spacing: var(--sw-tracking); + margin: 0 0 var(--sw-space-sm); + } + .description { + max-width: 70ch; + color: var(--sw-text-hi); + margin-bottom: var(--sw-space-xl); + } + .variant { + margin-bottom: var(--sw-space-xxl); + } + .variant .caption { + max-width: 70ch; + color: var(--sw-text-dim); + margin-bottom: var(--sw-space-sm); + } + .variant .stage { + border: 1px solid var(--sw-border-dim); + padding: var(--sw-space-lg); + max-width: 560px; + } + .variant .stage.wide { + max-width: none; + } + .quiet { + color: var(--sw-text-faint); + } + `; + + @state() private currentId = ""; + + // Hash -> selection. Bound once (constructor, not connectedCallback) because the + // shell lives for the page's whole life; there's nothing to unbind. Tab is + // contained per the kit default: the shell owns the page, so it installs + // the containment (see tab-cycle.ts). + constructor() { + super(); + window.addEventListener("hashchange", () => this.readHash()); + installTabContainment(document); + this.readHash(); + } + + private readHash(): void { + this.currentId = window.location.hash.replace(/^#/, ""); + } + + // Opening an entry seats keyboard focus in its content (first tab stop in main, + // typically a demo's megacolumn container), so a keyboard user starts interacting + // without a click. The index page has no stops in main; the nav is the start + // there. Deferred a frame: the entry's demo components render after this host, + // and their tab stops (tabindex on megacolumn containers) exist only post-render. + protected updated(changed: PropertyValues): void { + if (!changed.has("currentId")) return; + requestAnimationFrame(() => { + const region = this.renderRoot.querySelector("main"); + if (region === null) return; + const stop = deepTabbables(region)[0] ?? this.renderRoot.querySelector("nav a"); + if (stop instanceof HTMLElement) stop.focus(); + }); + } + + render(): TemplateResult { + const current = entries.find((e) => e.id === this.currentId); + return html` + <nav> + <div class="head">starweave gallery</div> + ${entries.map( + (e) => html`<a href=${`#${e.id}`} class=${e.id === this.currentId ? "current" : ""} + >${e.title}</a + >`, + )} + </nav> + <main>${current ? this.renderEntry(current) : this.renderIndex()}</main> + <sw-infopop></sw-infopop> + `; + } + + private renderEntry(entry: GalleryEntry): TemplateResult { + return html` + <h1>${entry.title}</h1> + <div class="description">${entry.description}</div> + ${entry.variants.map( + (v) => html`<div class="variant"> + <div class="caption">${v.caption}</div> + <div class="stage ${v.wide ? "wide" : ""}">${v.render()}</div> + </div>`, + )} + `; + } + + private renderIndex(): TemplateResult { + return html` + <h1>component gallery</h1> + <div class="description"> + Living documentation and dev harness for the starweave component library. + Pick a component from the nav. To add one: write a + <span class="quiet">foo.gallery.ts</span> beside the component and register it in + <span class="quiet">gallery/registry.ts</span>. + </div> + `; + } +} diff --git a/packages/yukey/src/infopop-place.test.ts b/packages/yukey/src/infopop-place.test.ts new file mode 100644 index 0000000..51ad68c --- /dev/null +++ b/packages/yukey/src/infopop-place.test.ts @@ -0,0 +1,90 @@ +// Tests for the pure placement rule (infopop-place.ts). Plain node env: geometry in, geometry out. + +import { describe, expect, it } from "vitest"; +import { + ANCHOR_GAP, + type Placement, + placeInfopop, + type Rect, + type Size, + VIEWPORT_MARGIN, +} from "./infopop-place.js"; + +const viewport: Size = { w: 1000, h: 800 }; +const popup: Size = { w: 200, h: 60 }; + +function anchorAt(x: number, y: number, w = 100, h = 24): Rect { + return { x, y, w, h }; +} + +function insideViewport(p: Placement): boolean { + return ( + p.x >= VIEWPORT_MARGIN && + p.y >= VIEWPORT_MARGIN && + p.x + popup.w <= viewport.w - VIEWPORT_MARGIN && + p.y + popup.h <= viewport.h - VIEWPORT_MARGIN + ); +} + +describe("preferred placement", () => { + it("goes below the anchor, horizontally centered", () => { + const anchor = anchorAt(400, 300); + const p = placeInfopop(anchor, popup, viewport); + expect(p.side).toBe("below"); + expect(p.y).toBe(300 + 24 + ANCHOR_GAP); + expect(p.x).toBe(400 + 50 - 100); // anchor center minus half the popup width + expect(insideViewport(p)).toBe(true); + }); + + it("never covers the anchor when a side fits", () => { + const anchor = anchorAt(400, 300); + const p = placeInfopop(anchor, popup, viewport); + expect(p.y).toBeGreaterThanOrEqual(anchor.y + anchor.h); + }); +}); + +describe("flipping", () => { + it("flips above when below would clip the viewport bottom", () => { + const anchor = anchorAt(400, 760); // 40px of room below: popup (60) cannot fit + const p = placeInfopop(anchor, popup, viewport); + expect(p.side).toBe("above"); + expect(p.y).toBe(760 - ANCHOR_GAP - popup.h); + expect(p.y + popup.h).toBeLessThanOrEqual(anchor.y); // above means above + expect(insideViewport(p)).toBe(true); + }); + + it("stays below when both sides fit", () => { + const p = placeInfopop(anchorAt(400, 390), popup, viewport); + expect(p.side).toBe("below"); + }); + + it("takes the roomier side when neither fits, clamped into the viewport", () => { + const shortViewport: Size = { w: 1000, h: 100 }; + // Anchor near the top: below has more room (66 vs 10), popup (60+gap) fits neither. + const below = placeInfopop(anchorAt(400, 10), popup, shortViewport); + expect(below.side).toBe("below"); + expect(below.y + popup.h).toBeLessThanOrEqual(shortViewport.h - VIEWPORT_MARGIN); + // Anchor near the bottom: above has more room. + const above = placeInfopop(anchorAt(400, 70), popup, shortViewport); + expect(above.side).toBe("above"); + expect(above.y).toBe(VIEWPORT_MARGIN); + }); +}); + +describe("horizontal clamping", () => { + it("clamps at the left edge", () => { + const p = placeInfopop(anchorAt(10, 300, 40), popup, viewport); + expect(p.x).toBe(VIEWPORT_MARGIN); + }); + + it("clamps at the right edge", () => { + const p = placeInfopop(anchorAt(950, 300, 40), popup, viewport); + expect(p.x).toBe(viewport.w - popup.w - VIEWPORT_MARGIN); + }); + + it("keeps a popup wider than the viewport pinned to the left margin", () => { + const wide: Size = { w: 2000, h: 60 }; + const p = placeInfopop(anchorAt(400, 300), wide, viewport); + expect(p.x).toBe(VIEWPORT_MARGIN); + }); +}); diff --git a/packages/yukey/src/infopop-place.ts b/packages/yukey/src/infopop-place.ts new file mode 100644 index 0000000..8170eb9 --- /dev/null +++ b/packages/yukey/src/infopop-place.ts @@ -0,0 +1,67 @@ +// 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. sw-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). + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Placement { + x: number; + y: number; + 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. +export const ANCHOR_GAP = 6; +export const VIEWPORT_MARGIN = 4; + +export function placeInfopop(anchor: Rect, popup: Size, viewport: Size): Placement { + const x = clamp( + anchor.x + anchor.w / 2 - popup.w / 2, + VIEWPORT_MARGIN, + Math.max(VIEWPORT_MARGIN, viewport.w - popup.w - VIEWPORT_MARGIN), + ); + + const belowY = anchor.y + anchor.h + ANCHOR_GAP; + const aboveY = anchor.y - ANCHOR_GAP - popup.h; + const fitsBelow = belowY + popup.h <= viewport.h - VIEWPORT_MARGIN; + const fitsAbove = aboveY >= VIEWPORT_MARGIN; + + if (fitsBelow) return { x, y: belowY, side: "below" }; + if (fitsAbove) return { x, y: aboveY, side: "above" }; + + // Degenerate: no side fits. Take the roomier side, clamped into the viewport. + const roomBelow = viewport.h - (anchor.y + anchor.h); + const roomAbove = anchor.y; + if (roomBelow >= roomAbove) { + return { + x, + y: Math.max(VIEWPORT_MARGIN, viewport.h - popup.h - VIEWPORT_MARGIN), + side: "below", + }; + } + return { x, y: VIEWPORT_MARGIN, side: "above" }; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(Math.max(v, lo), hi); +} diff --git a/packages/yukey/src/megacolumn-controller.test.ts b/packages/yukey/src/megacolumn-controller.test.ts new file mode 100644 index 0000000..d8155d0 --- /dev/null +++ b/packages/yukey/src/megacolumn-controller.test.ts @@ -0,0 +1,1468 @@ +// @vitest-environment jsdom +// +// Tests for the megacolumn binding layer (megacolumn-controller.ts). +// The controller only needs a ReactiveControllerHost with a renderRoot, so the +// host here is a plain fake around a detached-then-attached DOM tree: no Lit +// rendering, just the DOM contract (data-mc-* attributes) built with innerHTML +// and hostUpdated() called the way Lit would after a render. + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { + CLAIM_EXCUSE_MS, + FALLBACK_LOCKOUT_MS, + MegacolumnController, + type MegacolumnKeyDetail, +} from "./megacolumn-controller.js"; +import type { SwInfoDetail } from "./sw-info.js"; + +// jsdom has no layout and implements no scrollIntoView; the controller calls it +// on every focus-identity change (logical focus must never sit off-screen). +// A no-op stands in; the scroll-behavior tests below spy on it. +Element.prototype.scrollIntoView = (): void => {}; + +interface Rig { + ctl: MegacolumnController; + root: HTMLElement; + container: HTMLElement; + render: (innerHtml: string) => void; + key: (key: string, init?: KeyboardEventInit) => KeyboardEvent; +} + +function makeRig(innerHtml: string): Rig { + const root = document.createElement("div"); + document.body.replaceChildren(root); + const host = { + renderRoot: root, + addController(): void {}, + removeController(): void {}, + requestUpdate(): void {}, + updateComplete: Promise.resolve(true), + }; + const ctl = new MegacolumnController(host); + const render = (html: string): void => { + root.innerHTML = `<div data-mc-container>${html}</div>`; + ctl.hostUpdated(); + }; + render(innerHtml); + const container = root.querySelector<HTMLElement>("[data-mc-container]"); + if (container === null) throw new Error("unreachable: rig just rendered a container"); + const key = (k: string, init?: KeyboardEventInit): KeyboardEvent => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }); + container.dispatchEvent(e); + return e; + }; + return { ctl, root, container, render, key }; +} + +const threeRows = ` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + <div data-mc-row="c">gamma</div> +`; + +describe("container wiring", () => { + it("makes the container the single real focusable and points aria at the focused row", () => { + const { container } = makeRig(threeRows); + expect(container.tabIndex).toBe(0); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-a"); + }); + + it("derives row ids for aria but respects author-set ids", () => { + const { container } = makeRig(`<div data-mc-row="a" id="custom">alpha</div>`); + expect(container.getAttribute("aria-activedescendant")).toBe("custom"); + }); + + it("assigns listbox/option roles by default, respecting author overrides", () => { + const { container, root } = makeRig( + `<div data-mc-row="a">alpha</div><div data-mc-row="t" role="treeitem">tree</div>`, + ); + expect(container.getAttribute("role")).toBe("listbox"); + expect(root.querySelector(`[data-mc-row="a"]`)?.getAttribute("role")).toBe("option"); + expect(root.querySelector(`[data-mc-row="t"]`)?.getAttribute("role")).toBe("treeitem"); + }); + + it("re-derives stale derived ids when an element is reused for a new row identity", () => { + // Lit templates without repeat() reuse DOM nodes by position: the same element + // can reappear with a different data-mc-row. The derived id must follow. + const { root, container, ctl } = makeRig(`<div data-mc-row="task-1">first</div>`); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-task-1"); + const el = root.querySelector<HTMLElement>("[data-mc-row]"); + if (el === null) throw new Error("unreachable: row rendered above"); + el.dataset.mcRow = "task-2"; // reuse: same element, new identity + el.textContent = "second"; + ctl.hostUpdated(); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-task-2"); + expect(el.id).toBe("mc-task-2"); + }); + + it("throws when the renderRoot has no container", () => { + const { root, ctl } = makeRig(threeRows); + root.innerHTML = "<div>nothing here</div>"; + expect(() => ctl.hostUpdated()).toThrow(/no \[data-mc-container\]/); + }); + + it("throws on rows containing stray native tab stops", () => { + expect(() => makeRig(`<div data-mc-row="a"><button>x</button></div>`)).toThrow( + /native tab stop \(button\)/, + ); + // ... but tabindex=-1 marked widgets and data-mc-input are fine. + makeRig(`<div data-mc-row="a"><button tabindex="-1">x</button></div>`); + makeRig(`<div data-mc-row="a"><input data-mc-input></div>`); + }); + + it("clears aria-activedescendant when no row is focusable", () => { + const { container } = makeRig(`<div data-mc-row="a" aria-hidden="true">alpha</div>`); + expect(container.hasAttribute("aria-activedescendant")).toBe(false); + }); +}); + +describe("closed rows (aria-hidden)", () => { + // aria-hidden="true" is the one closed-row signal: hidden-from-AT and + // skipped-by-navigation ride the same attribute, so the two views cannot + // disagree. (The conditions that make the coupling sound are documented at + // closedToSight() in the controller.) + it("an aria-hidden row is registered but skipped by navigation", () => { + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="ghost" aria-hidden="true">collapsing</div> + <div data-mc-row="c">gamma</div> + `); + expect(ctl.nav.rows.find((r) => r.id === "ghost")).toMatchObject({ focusable: false }); + key("ArrowDown"); // from a: the closed row is not a stop + expect(ctl.nav.focusedId).toBe("c"); + }); + + it("a row inside an aria-hidden ancestor is skipped too", () => { + // A wrapper can close a whole subtree; a row inside it is exactly as gone + // as a row hidden directly (the hotkey scan already treats it that way). + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div aria-hidden="true"><div data-mc-row="wrapped">in a hidden wrapper</div></div> + <div data-mc-row="c">gamma</div> + `); + key("ArrowDown"); + expect(ctl.nav.focusedId).toBe("c"); + }); + + it('aria-hidden="false" does NOT close a row: matched by value, never presence', () => { + // aria-hidden="false" is known to behave inconsistently in browsers; a + // presence check would silently read it as hidden and make a visible row + // unreachable. The scan matches the value "true" only. + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b" aria-hidden="false">beta</div> + `); + expect(ctl.nav.rows.find((r) => r.id === "b")).toMatchObject({ focusable: true }); + key("ArrowDown"); + expect(ctl.nav.focusedId).toBe("b"); + }); + + it("no data attribute can make a visible row skippable", () => { + // data-mc-disabled is deleted from the contract: a visible row misapplying + // it once became unreachable by keyboard while staying clickable and + // AT-visible. The attribute now has no effect at all. + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b" data-mc-disabled>beta</div> + `); + expect(ctl.nav.rows.find((r) => r.id === "b")).toMatchObject({ focusable: true }); + key("ArrowDown"); + expect(ctl.nav.focusedId).toBe("b"); + }); +}); + +describe("keyboard navigation in the DOM", () => { + it("ArrowDown moves the ring classes and aria; the event default is consumed", () => { + const { container, root, key } = makeRig(threeRows); + container.focus(); + const e = key("ArrowDown"); + expect(e.defaultPrevented).toBe(true); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-b"); + const b = root.querySelector(`[data-mc-row="b"]`); + expect(b?.classList.contains("mc-row-focused")).toBe(true); + expect(b?.classList.contains("mc-focused")).toBe(true); // row IS the main control here + expect(root.querySelector(`[data-mc-row="a"]`)?.classList.contains("mc-focused")).toBe(false); + }); + + it("the ring lands on [data-mc-main] when a row declares one", () => { + const { root } = makeRig( + `<div data-mc-row="a"><span>label</span><span data-mc-main>widget</span></div>`, + ); + const main = root.querySelector("[data-mc-main]"); + expect(main?.classList.contains("mc-focused")).toBe(true); + }); + + it("modifier chords are ignored entirely", () => { + const { container, key } = makeRig(threeRows); + const e = key("ArrowDown", { ctrlKey: true }); + expect(e.defaultPrevented).toBe(false); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-a"); + }); + + it("ArrowRight moves the ring to the aux control and left brings it back", () => { + const { root, key } = makeRig( + `<div data-mc-row="a"><span data-mc-main>item</span><span data-mc-aux>x</span></div>`, + ); + key("ArrowRight"); + expect(root.querySelector("[data-mc-aux]")?.classList.contains("mc-focused")).toBe(true); + expect(root.querySelector("[data-mc-main]")?.classList.contains("mc-focused")).toBe(false); + key("ArrowLeft"); + expect(root.querySelector("[data-mc-main]")?.classList.contains("mc-focused")).toBe(true); + }); +}); + +describe("key forwarding", () => { + it("forwarded keys arrive as a megacolumn-key event on the focused control", () => { + const { root, key } = makeRig(threeRows); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + key("ArrowDown"); + key("Enter"); + expect(got).toEqual([{ key: "Enter", rowId: "b", position: "main" }]); + }); + + it("a handled megacolumn-key consumes the original keydown's default", () => { + const { root, key } = makeRig(threeRows); + root.addEventListener("megacolumn-key", (e) => e.preventDefault()); + const e = key(" "); + expect(e.defaultPrevented).toBe(true); + }); + + it("an unhandled megacolumn-key leaves the original keydown's default alone", () => { + const { key } = makeRig(threeRows); + const e = key("PageDown"); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("text-entry mode in the DOM", () => { + const textColumn = ` + <div data-mc-row="a">alpha</div> + <div data-mc-row="q"><input data-mc-input type="text"></div> + `; + + it("Enter dives real focus into the input; Escape returns it to the container", () => { + const { container, root, key } = makeRig(textColumn); + container.focus(); + key("ArrowDown"); + const e = key("Enter"); + expect(e.defaultPrevented).toBe(true); + const input = root.querySelector<HTMLInputElement>("[data-mc-input]"); + expect(document.activeElement).toBe(input); + expect(container.classList.contains("mc-text-mode")).toBe(true); + // Keys now reach the input natively; the container listener passes them. + const typing = key("x"); + expect(typing.defaultPrevented).toBe(false); + key("Escape"); + expect(document.activeElement).toBe(container); + expect(container.classList.contains("mc-text-mode")).toBe(false); + // Row focus resumed on the text row, not reset. + expect(container.getAttribute("aria-activedescendant")).toBe("mc-q"); + }); + + it("the input is taken out of the tab order", () => { + const { root } = makeRig(textColumn); + expect(root.querySelector<HTMLInputElement>("[data-mc-input]")?.tabIndex).toBe(-1); + }); + + it("clicking into the input syncs the machine (focusin path)", () => { + const { container, root, ctl } = makeRig(textColumn); + expect(ctl.nav.focusedId).toBe("a"); + root.querySelector<HTMLInputElement>("[data-mc-input]")?.focus(); + expect(ctl.nav.focusedId).toBe("q"); + expect(ctl.nav.textMode).toBe(true); + expect(container.classList.contains("mc-text-mode")).toBe(true); + }); + + it("focus leaving the container mid-text-entry exits text mode", () => { + const { container, key } = makeRig(textColumn); + const outside = document.createElement("button"); + document.body.append(outside); + container.focus(); + key("ArrowDown"); + key("Enter"); + outside.focus(); + expect(container.classList.contains("mc-text-mode")).toBe(false); + expect(container.classList.contains("mc-focus-within")).toBe(false); + }); +}); + +describe("focus memory across container blur", () => { + it("keeps the row focus while unfocused and resumes on return", () => { + const { container, key, root } = makeRig(threeRows); + const outside = document.createElement("button"); + document.body.append(outside); + container.focus(); + key("ArrowDown"); + expect(container.classList.contains("mc-focus-within")).toBe(true); + outside.focus(); + expect(container.classList.contains("mc-focus-within")).toBe(false); + // The memory stays visible: row keeps its focused classes while blurred. + expect(root.querySelector(`[data-mc-row="b"]`)?.classList.contains("mc-row-focused")).toBe( + true, + ); + container.focus(); + key("ArrowDown"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-c"); + }); +}); + +describe("mouse sync", () => { + it("mousedown on a row moves logical focus there", () => { + const { root, container, ctl } = makeRig(threeRows); + root + .querySelector(`[data-mc-row="c"]`) + ?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(ctl.nav.focusedId).toBe("c"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-c"); + }); + + it("mousedown on an aux control focuses the aux position", () => { + const { root, ctl } = makeRig( + `<div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux>x</span></div>`, + ); + root + .querySelector("[data-mc-aux]") + ?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(ctl.nav.position).toBe("aux"); + }); + + it("mousedown on a closed row is inert", () => { + const { root, ctl } = makeRig( + `<div data-mc-row="a">a</div><div data-mc-row="d" aria-hidden="true">d</div>`, + ); + root + .querySelector(`[data-mc-row="d"]`) + ?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(ctl.nav.focusedId).toBe("a"); + }); +}); + +describe("pointer activation (click synthesizes Enter)", () => { + // The activation story of the DOM contract: Enter, Space, and click are the + // same megacolumn-key event on the focused control. A click on a row's + // control dispatches the keyboard path's Enter, so hosts serve pointer and + // keyboard through one handler -- and touch works for free (a tap clicks). + + // Real input order: mousedown syncs focus first, then click activates. + const click = (el: Element): MouseEvent => { + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, composed: true })); + const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true }); + el.dispatchEvent(e); + return e; + }; + + it("a click on a plain row dispatches the same megacolumn-key as Enter", () => { + const { root, ctl } = makeRig(threeRows); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const row = root.querySelector(`[data-mc-row="c"]`); + if (row === null) throw new Error("row c missing"); + click(row); + expect(got).toEqual([{ key: "Enter", rowId: "c", position: "main" }]); + expect(ctl.nav.focusedId).toBe("c"); + }); + + it("the synthesized dispatch targets the row's control, not the click's inner target", () => { + // Same element the keyboard path dispatches on, so host acknowledgment + // code (flashPress on e.target) needs no pointer branch. + const { root } = makeRig( + `<div data-mc-row="a"><span data-mc-main><span class="deep">alpha</span></span></div>`, + ); + const targets: EventTarget[] = []; + root.addEventListener("megacolumn-key", (e) => { + if (e.target !== null) targets.push(e.target); + }); + const deep = root.querySelector(".deep"); + if (deep === null) throw new Error("inner span missing"); + click(deep); + expect(targets).toEqual([root.querySelector("[data-mc-main]")]); + }); + + it("a click on the aux control synthesizes at the aux position", () => { + const { root } = makeRig( + `<div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux>x</span></div>`, + ); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const aux = root.querySelector("[data-mc-aux]"); + if (aux === null) throw new Error("aux missing"); + click(aux); + expect(got).toEqual([{ key: "Enter", rowId: "a", position: "aux" }]); + }); + + it("by dispatch time the clicked row is focused (mousedown precedes click)", () => { + const { root, ctl } = makeRig(threeRows); + const focusAtDispatch: (string | null)[] = []; + root.addEventListener("megacolumn-key", () => { + focusAtDispatch.push(ctl.nav.focusedId); + }); + expect(ctl.nav.focusedId).toBe("a"); // clicking a row that is NOT the focused one + const row = root.querySelector(`[data-mc-row="b"]`); + if (row === null) throw new Error("row b missing"); + click(row); + expect(focusAtDispatch).toEqual(["b"]); + }); + + it("a claimed synthesis consumes the click; an unclaimed one leaves it alone", () => { + // The unclaimed case is the offline-row story: aria-disabled rows are + // focusable, the host declines their Enter, and a click behaves identically. + const { root } = makeRig( + `<div data-mc-row="a">alpha</div><div data-mc-row="off" aria-disabled="true">offline</div>`, + ); + root.addEventListener("megacolumn-key", (e) => { + if ((e as CustomEvent<MegacolumnKeyDetail>).detail.rowId === "a") e.preventDefault(); + }); + const a = root.querySelector(`[data-mc-row="a"]`); + const off = root.querySelector(`[data-mc-row="off"]`); + if (a === null || off === null) throw new Error("rows missing"); + expect(click(a).defaultPrevented).toBe(true); + expect(click(off).defaultPrevented).toBe(false); // declined, exactly like keyboard Enter + }); + + it("clicks on a text row's main never synthesize (the text dive owns activation there)", () => { + // Keyboard Enter at a text row's main routes to the text dive, never to a + // forward -- so a synthesized forward there would be an event no keyboard + // could produce. The input's own click already dives via the focusin path. + const { root } = makeRig( + `<div data-mc-row="q"><span class="lbl">hail</span><input data-mc-input type="text"></div>`, + ); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const input = root.querySelector("[data-mc-input]"); + const label = root.querySelector(".lbl"); + if (input === null || label === null) throw new Error("text row parts missing"); + click(input); + click(label); + expect(forwards).toBe(0); + }); + + it("a defaultPrevented click never synthesizes (a widget already activated on it)", () => { + // The dedup rule: a widget that activates on its own native click claims the + // click with preventDefault, the same convention as claiming a forwarded key. + const { root } = makeRig(threeRows); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const row = root.querySelector(`[data-mc-row="a"]`); + if (row === null) throw new Error("row a missing"); + row.addEventListener("click", (e) => e.preventDefault()); + click(row); + expect(forwards).toBe(0); + }); + + it("a click on a closed row is fully inert (no focus, no synthesis)", () => { + const { root, ctl } = makeRig( + `<div data-mc-row="a">a</div><div data-mc-row="d" aria-hidden="true">d</div>`, + ); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const row = root.querySelector(`[data-mc-row="d"]`); + if (row === null) throw new Error("row d missing"); + click(row); + expect(forwards).toBe(0); + expect(ctl.nav.focusedId).toBe("a"); + }); + + it("click synthesis ignores the churn lockout (a click names its own target)", () => { + // The lockout protects keystrokes already in flight when focus moved under + // them; a click carries its target in its coordinates and its mousedown just + // resolved the aim. (Moving-target clicks are the geometry owner's problem: + // sw-live-list's guard stops them in the capture phase before this synthesis.) + const { root, render, ctl, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(`<div data-mc-row="a">alpha</div><div data-mc-row="c">gamma</div>`); // b vanishes + expect(ctl.nav.focusedId).toBe("c"); // the involuntary fallback: the lockout is armed + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const row = root.querySelector(`[data-mc-row="a"]`); + if (row === null) throw new Error("row a missing"); + click(row); + expect(got).toEqual([{ key: "Enter", rowId: "a", position: "main" }]); + }); + + it("a claimed click opens the claim excuse like a claimed key (commands chain)", () => { + // A click whose action removes its row is the same controlled moment as a + // claimed x: the vanish is the user's own doing, the next key must flow. + const { root, render, ctl, key } = makeRig(threeRows); + root.addEventListener("megacolumn-key", (e) => { + if ((e as CustomEvent<MegacolumnKeyDetail>).detail.rowId === "b") e.preventDefault(); + }); + const row = root.querySelector(`[data-mc-row="b"]`); + if (row === null) throw new Error("row b missing"); + click(row); // the host "removes" b... + render(`<div data-mc-row="a">alpha</div><div data-mc-row="c">gamma</div>`); // ...consequence + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + key("Enter"); // chains immediately: no lockout stutter + expect(forwards).toBe(1); + }); +}); + +describe("re-render reconciliation", () => { + it("row churn across renders follows the machine's fallback rules", () => { + const { render, ctl, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `); + expect(ctl.nav.focusedId).toBe("c"); + }); +}); + +describe("scrolling the focused row into view", () => { + // Browsers do not scroll aria-activedescendant targets into view on their own, + // so the controller does it wherever focus state is applied: one place covers + // arrows, Home/End, hotkey jumps, and churn fallbacks alike. + let scrolls: MockInstance<Element["scrollIntoView"]>; + beforeEach(() => { + scrolls = vi.spyOn(Element.prototype, "scrollIntoView"); + }); + afterEach(() => { + scrolls.mockRestore(); + }); + const scrolledRows = (): unknown[] => + scrolls.mock.contexts.map((el) => (el as HTMLElement).dataset?.mcRow); + + it("scrolls the newly focused row on arrow movement, nearest-edge and instant", () => { + const { key } = makeRig(threeRows); + scrolls.mockClear(); // drop the initial eager-focus scroll + key("ArrowDown"); + expect(scrolledRows()).toEqual(["b"]); + expect(scrolls.mock.calls[0]).toEqual([{ block: "nearest", behavior: "instant" }]); + }); + + it("does not re-scroll while the focused row stays the same", () => { + const { render, key } = makeRig(threeRows); + scrolls.mockClear(); + key("z"); // an unclaimed forward: no focus change + render(threeRows); // a re-render that keeps the focused row + expect(scrolls).not.toHaveBeenCalled(); + }); + + it("scrolls the fallback row when churn yanks the focused row", () => { + const { render, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + scrolls.mockClear(); + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `); // fallback -> c + expect(scrolledRows()).toEqual(["c"]); + }); + + it("scrolls eager focus into view when a row appears in an empty column", () => { + const { render } = makeRig(`<div data-mc-row="a" aria-hidden="true">alpha</div>`); + scrolls.mockClear(); + render(`<div data-mc-row="a">alpha</div>`); // now focusable: eager focus lands + expect(scrolledRows()).toEqual(["a"]); + }); +}); + +describe("the churn lockout (involuntary focus moves)", () => { + // UX-PRINCIPLES: lock out interaction briefly during and slightly after a move. + // When the machine's fallback moves focus (the row under the user vanished), + // decisive keys must not land on the surprise neighbor. + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const churnAwayB = ` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `; + + it("drops decisive keys briefly after the fallback moves focus", () => { + const { render, ctl, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(churnAwayB); // fallback -> c + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("Enter"); + expect(forwards).toBe(0); + expect(e.defaultPrevented).toBe(true); // consumed, not left to the browser + }); + + it("navigation keys stay live during the lockout (reorienting is the point)", () => { + const { render, ctl, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(churnAwayB); // fallback -> c + key("ArrowUp"); + expect(ctl.nav.focusedId).toBe("a"); + }); + + it("hotkey focus moves work during the lockout (they are navigation)", () => { + const { render, ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + <div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div> + `); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div> + `); // fallback -> dock's neighbor... (b vanished; fallback -> dock) + key("Home"); // reorient to a, still locked out + expect(ctl.nav.focusedId).toBe("a"); + const e = key("d"); // a hotkey jump IS reorientation: it must flow + expect(ctl.nav.focusedId).toBe("dock"); + expect(e.defaultPrevented).toBe(true); + }); + + it("a hotkey jump opens no claim excuse: an ambient vanish after it still locks out", () => { + // A claim excuses the vanish it causes (an x removing its row); a jump is + // pure navigation and causes nothing, so a vanish right after one is ambient + // churn and the lockout must arm. + const { render, ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div> + <div data-mc-row="c">gamma</div> + `); + key("d"); // jump a -> dock; consumed, but no claim + expect(ctl.nav.focusedId).toBe("dock"); + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `); // ambient churn eats the landed row + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("Enter"); + expect(forwards).toBe(0); // locked out: the jump excused nothing + expect(e.defaultPrevented).toBe(true); + }); + + it("direct hotkey activations are swallowed by the lockout (decisive keys)", () => { + const { render, ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + <div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div> + `); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div> + `); // fallback -> dock; lockout armed + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("d"); + expect(forwards).toBe(0); // neither dispatch happened + expect(ctl.nav.focusedId).toBe("dock"); // unchanged (it was the fallback target already) + expect(e.defaultPrevented).toBe(true); + }); + + it("a direct text dive is swallowed by the lockout too (decisive keys)", () => { + // The dive rides the forward routing's direct-marking branch, so the + // lockout's forward swallow covers it: no text mode, no real focus move, + // and no jump either (direct markings are decisive, not navigation). + const { render, ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + <div data-mc-row="c">gamma</div> + <div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div> + `); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + <div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div> + `); // fallback -> c; lockout armed + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("h"); + expect(forwards).toBe(0); // not even the first-claim dispatch + expect(ctl.nav.focusedId).toBe("c"); // no jump + expect(ctl.nav.textMode).toBe(false); + expect(document.activeElement).not.toBe(root.querySelector("[data-mc-input]")); + expect(e.defaultPrevented).toBe(true); + // ... and after the window, the dive flows. + vi.advanceTimersByTime(FALLBACK_LOCKOUT_MS); + key("h"); + expect(ctl.nav.textMode).toBe(true); + expect(document.activeElement).toBe(root.querySelector("[data-mc-input]")); + }); + + it("decisive keys flow again after the lockout window", () => { + const { render, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(churnAwayB); + vi.advanceTimersByTime(FALLBACK_LOCKOUT_MS); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + key("Enter"); + expect(forwards).toBe(1); + }); + + it("a churn the focused row survives arms no lockout", () => { + const { render, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="b">beta</div> + <div data-mc-row="d">delta</div> + `); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + key("Enter"); + expect(forwards).toBe(1); + }); + + it("text-entry dives are decisive too: locked out alike", () => { + const withTextTail = ` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + <div data-mc-row="t"><input data-mc-input type="text" /></div> + `; + const { render, ctl, key, root } = makeRig(withTextTail); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="t"><input data-mc-input type="text" /></div> + `); // fallback falls downward to the surviving t + expect(ctl.nav.focusedId).toBe("t"); + key("Enter"); + expect(ctl.nav.textMode).toBe(false); + expect(root.querySelector("[data-mc-input]")).not.toBe(document.activeElement); + }); + + it("a vanish right after the user's own claimed key arms NO lockout (commands chain freely)", () => { + // The lockout protects against AMBIENT churn. A removal that is the direct + // consequence of a claimed key (jettison, x) is a controlled moment: the + // user's next command must flow, not drop. The controller cannot tell a + // consequence render from ambient churn, so the excuse is a short time window + // after any claim. + const { render, ctl, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + root.addEventListener("megacolumn-key", (e) => { + if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "x") e.preventDefault(); // claim: "removed" + }); + key("x"); // the user removes b... + render(churnAwayB); // ...and THIS render is that removal's consequence + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + key("Enter"); // the chained next command flows immediately + expect(forwards).toBe(1); + }); + + it("a stale claim does not excuse a vanish (the excuse window closes)", () => { + const { render, ctl, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + root.addEventListener("megacolumn-key", (e) => { + if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "Enter") e.preventDefault(); // claim: "inspected" + }); + key("Enter"); // claimed on b; nothing renders (host state unchanged) + vi.advanceTimersByTime(CLAIM_EXCUSE_MS + 1); // long past any consequence render + render(churnAwayB); // ambient churn, not the claim's consequence + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + key("x"); + expect(forwards).toBe(0); + }); +}); + +describe("the megacolumn-rescan event (out-of-render row changes)", () => { + it("re-scans the rows when a child announces DOM changes outside a host render", () => { + const { container, ctl } = makeRig(threeRows); + expect(ctl.nav.rows.length).toBe(3); + container.querySelector(`[data-mc-row="b"]`)?.remove(); + expect(ctl.nav.rows.length).toBe(3); // no render happened; the machine is stale + container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); + expect(ctl.nav.rows.map((r) => r.id)).toEqual(["a", "c"]); + }); + + it("a rescan that yanks the focused row arms the churn lockout", () => { + vi.useFakeTimers(); + try { + const { container, ctl, root, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + container.querySelector(`[data-mc-row="b"]`)?.remove(); + container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); + expect(ctl.nav.focusedId).toBe("c"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("Enter"); + expect(forwards).toBe(0); + expect(e.defaultPrevented).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("the megacolumn-focus-request event (programmatic focus moves)", () => { + // The public focus-move affordance: any element inside the container may ask + // for logical focus by row id, by dispatching this bubbling cancelable event. + // A request has a hotkey jump's nature exactly: pure navigation -- no + // activation, no claim, live during lockouts. The intended consumer is a + // wrapper element scoping keys to a row region (a list-group resolving t/n + // it claimed off the bubbling megacolumn-key dispatch). + // dispatchEvent never rethrows a listener's exception to the dispatcher: it + // surfaces as a window error event instead. The harmless-ignore tests assert + // on captured window errors, so a controller that throws on a stale request + // fails them with a clean assertion rather than an unhandled-error report. + const windowErrors: unknown[] = []; + const onWindowError = (e: Event): void => { + windowErrors.push(e); + e.preventDefault(); // handled here; keep the runner's unhandled-error net out of it + }; + beforeEach(() => { + vi.useFakeTimers(); + windowErrors.length = 0; + window.addEventListener("error", onWindowError); + }); + afterEach(() => { + window.removeEventListener("error", onWindowError); + vi.useRealTimers(); + }); + + const request = (from: Element, rowId: string): CustomEvent<{ rowId: string }> => { + const e = new CustomEvent("megacolumn-focus-request", { + detail: { rowId }, + bubbles: true, + cancelable: true, + }); + from.dispatchEvent(e); + return e; + }; + + it("moves logical focus to the named row and renders the move", () => { + const { ctl, root, container } = makeRig(threeRows); + const rowC = root.querySelector<HTMLElement>('[data-mc-row="c"]'); + if (rowC === null) throw new Error("unreachable: rig just rendered row c"); + const e = request(rowC, "c"); + expect(ctl.nav.focusedId).toBe("c"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-c"); + expect(rowC.classList.contains("mc-row-focused")).toBe(true); + expect(e.defaultPrevented).toBe(true); // the answer signal: the request landed + }); + + it("an unknown rowId is ignored without error (stale requests are harmless)", () => { + // The requester composes against a live column where rows vanish between + // its look and its ask; a stale request must do nothing, loudly nothing. + // (A rowId belonging to a DIFFERENT container is this same case: the + // controller only knows its own nav's rows.) + const { ctl, container } = makeRig(threeRows); + const e = request(container, "long-gone"); + expect(ctl.nav.focusedId).toBe("a"); + expect(e.defaultPrevented).toBe(false); // unanswered + expect(windowErrors).toEqual([]); // ignored means NO throw, not a caught one + }); + + it("an unfocusable (closed) target is ignored", () => { + const { ctl, container } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b" aria-hidden="true">beta</div> + `); + const e = request(container, "b"); + expect(ctl.nav.focusedId).toBe("a"); + expect(e.defaultPrevented).toBe(false); + expect(windowErrors).toEqual([]); + }); + + it("a request opens no claim excuse: an ambient vanish after it still locks out", () => { + // Same protection the hotkey jump pins: navigation causes no vanishes, so + // a vanish right after a request is ambient churn and the lockout must arm. + const { render, ctl, root, container, key } = makeRig(threeRows); + request(container, "c"); + expect(ctl.nav.focusedId).toBe("c"); + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="b">beta</div> + `); // ambient churn eats the landed row + expect(ctl.nav.focusedId).toBe("b"); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("Enter"); + expect(forwards).toBe(0); // locked out: the request excused nothing + expect(e.defaultPrevented).toBe(true); + }); + + it("resolves during the churn lockout (a request is not a key routing)", () => { + const { render, ctl, container, key } = makeRig(threeRows); + key("ArrowDown"); // -> b + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `); // fallback -> c, lockout armed + const e = request(container, "a"); + expect(ctl.nav.focusedId).toBe("a"); // reorientation stays live + expect(e.defaultPrevented).toBe(true); + }); + + it("is ignored during text entry (text entry dominates)", () => { + const { ctl, container, key } = makeRig(` + <div data-mc-row="write"><input data-mc-input></div> + <div data-mc-row="b">beta</div> + `); + key("Enter"); // dive into text entry on the write row + expect(ctl.nav.textMode).toBe(true); + const e = request(container, "b"); + expect(ctl.nav.focusedId).toBe("write"); + expect(ctl.nav.textMode).toBe(true); // still typing; the ask changed nothing + expect(e.defaultPrevented).toBe(false); + }); + + it("dispatched outside any container it reaches nobody (harmless)", () => { + const { ctl } = makeRig(threeRows); + const e = request(document.body, "c"); + expect(ctl.nav.focusedId).toBe("a"); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("column-scoped hotkey markings (data-mc-hotkey)", () => { + const markedColumn = ` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div> + `; + + it("a marked key the focused row declines moves focus to the marked row", () => { + const { ctl, container, root, key } = makeRig(markedColumn); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const e = key("d"); // focus is on row a, which declines + expect(got).toEqual([{ key: "d", rowId: "a", position: "main" }]); // the first-claim offer only: no activation dispatch + expect(ctl.nav.focusedId).toBe("dock"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-dock"); + expect(root.querySelector(`[data-mc-row="dock"]`)?.classList.contains("mc-row-focused")).toBe( + true, + ); + expect(e.defaultPrevented).toBe(true); // the jump consumed the keydown + }); + + it("the jump is a focus move, not an activation: Enter afterwards is what acts", () => { + // See-before-commit: the letter shows the user where they are; Enter commits. + const { root, key } = makeRig(markedColumn); + const acted: string[] = []; + root.addEventListener("megacolumn-key", (e) => { + const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail; + if (detail.key === "Enter" && detail.rowId === "dock") { + acted.push(detail.rowId); + e.preventDefault(); + } + }); + key("d"); + expect(acted).toEqual([]); + key("Enter"); + expect(acted).toEqual(["dock"]); + }); + + it("the focused row's claim outranks the marking (no jump)", () => { + const { ctl, root, key } = makeRig(markedColumn); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + e.preventDefault(); // the focused row handles everything + }); + key("d"); + expect(got).toEqual([{ key: "d", rowId: "a", position: "main" }]); + expect(ctl.nav.focusedId).toBe("a"); // claimed: focus stays put + }); + + it("hotkey lookup is case-insensitive (shifted letters still jump)", () => { + const { ctl, key } = makeRig(markedColumn); + key("D"); + expect(ctl.nav.focusedId).toBe("dock"); + }); + + it("the focused row's own hotkey does nothing, consumed", () => { + // A jump to where you already are; and NOT an activation -- double-tap must + // not out-power single-tap (Enter/Space stay the commit). Still consumed, + // like every marked key: the column advertises it, the browser never sees it. + const { ctl, root, key } = makeRig(` + <div data-mc-row="dock" data-mc-hotkey="d"><span data-mc-main>dock</span></div> + <div data-mc-row="b">beta</div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const e = key("d"); // focus starts on the dock row itself + expect(got).toEqual([{ key: "d", rowId: "dock", position: "main" }]); // the first-claim offer only + expect(ctl.nav.focusedId).toBe("dock"); + expect(e.defaultPrevented).toBe(true); + }); + + it("unmarked keys keep their native default when nobody claims", () => { + const { key } = makeRig(markedColumn); + const e = key("z"); + expect(e.defaultPrevented).toBe(false); + }); + + it("a direct marking dispatches the key on the marked element, with focus moved first", () => { + // data-mc-hotkey-direct: the opt-out of see-before-commit, for actions with + // an undo semantic. Focus lands on the acted row BEFORE the dispatch: the + // activation flash must be scrolled into view WITH the ring (scrolling an + // unfocused row in would scroll the ring out), and Enter then repeats the act. + const { ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock"><span data-mc-main data-mc-hotkey="d" data-mc-hotkey-direct>dock</span></div> + `); + const got: MegacolumnKeyDetail[] = []; + let target: EventTarget | null = null; + let focusedAtDispatch: string | null = null; + root.addEventListener("megacolumn-key", (e) => { + const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail; + got.push(detail); + if (detail.rowId === "dock" && detail.key === "d") { + target = e.target; + focusedAtDispatch = ctl.nav.focusedId; + e.preventDefault(); + } + }); + const e = key("d"); // focus is on row a, which declines + expect(got).toEqual([ + { key: "d", rowId: "a", position: "main" }, + { key: "d", rowId: "dock", position: "main" }, + ]); + expect(target).toBe(root.querySelector("[data-mc-hotkey]")); + expect(focusedAtDispatch).toBe("dock"); + expect(e.defaultPrevented).toBe(true); + }); + + it("a declined direct marking still consumes and still moves focus", () => { + // The focus move already happened (a real user-visible effect), so the key + // is spent even when no widget claims the dispatch. + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="dock"><span data-mc-main data-mc-hotkey="d" data-mc-hotkey-direct>dock</span></div> + `); + const e = key("d"); // nobody listens: both dispatches decline + expect(ctl.nav.focusedId).toBe("dock"); + expect(e.defaultPrevented).toBe(true); + }); + + it("the focused row's own DIRECT marking gets no second dispatch (already offered)", () => { + // The first dispatch bubbled through the whole focused row, so every listener + // there has had its chance; a second dispatch would just repeat the question. + const { root, key } = makeRig(` + <div data-mc-row="dock" data-mc-hotkey="d" data-mc-hotkey-direct><span data-mc-main>dock</span></div> + <div data-mc-row="b">beta</div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const e = key("d"); // focus starts on the dock row itself + expect(got).toEqual([{ key: "d", rowId: "dock", position: "main" }]); + expect(e.defaultPrevented).toBe(true); + }); + + it("a marking on a visible inactive row jumps focus there like anywhere else", () => { + // Inactive is a styling state (class + aria-disabled), never a navigation + // state: the row stays reachable so i can ask why it is off, and its direct + // marking still dispatches -- the marked widget is the authority on its own + // disabledness (it can claim the key and decline to act). + const { ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="off" aria-disabled="true" data-mc-hotkey="o" data-mc-hotkey-direct>offline thing</div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + key("o"); + expect(got.map((d) => d.rowId)).toEqual(["a", "off"]); + expect(ctl.nav.focusedId).toBe("off"); // focus follows the direct dispatch + }); + + it("a DIRECT marking on a text row dives straight into text entry (activation IS the dive)", () => { + // What direct-means-activate means for a text row: the same transition Enter + // produces there -- machine in text mode, real focus in the input -- in one + // keystroke. No second megacolumn-key dispatch: there is no widget to + // activate; the dive is the activation. + const { ctl, root, container, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const e = key("h"); // focus is on row a, which declines + expect(got).toEqual([{ key: "h", rowId: "a", position: "main" }]); // the first-claim offer only + expect(ctl.nav.focusedId).toBe("q"); + expect(ctl.nav.textMode).toBe(true); + const input = root.querySelector<HTMLInputElement>("[data-mc-input]"); + expect(document.activeElement).toBe(input); + // preventDefault is what keeps the triggering h out of the input that + // synchronously received focus (jsdom types nothing either way; the + // browser driver proves the same with trusted keys). + expect(e.defaultPrevented).toBe(true); + expect(input?.value).toBe(""); + // The exits work as from any dive: Escape returns real focus to the + // container with row memory on the text row. + key("Escape"); + expect(document.activeElement).toBe(container); + expect(ctl.nav.textMode).toBe(false); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-q"); + }); + + it("a NON-direct marking on a text row jumps only: focus lands, Enter then dives", () => { + const { ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="q"><span data-mc-hotkey="h">hail</span><input data-mc-input type="text"></div> + `); + key("h"); + expect(ctl.nav.focusedId).toBe("q"); + expect(ctl.nav.textMode).toBe(false); // a jump, not a dive + expect(document.activeElement).not.toBe(root.querySelector("[data-mc-input]")); + key("Enter"); // the see-before-commit step + expect(ctl.nav.textMode).toBe(true); + expect(document.activeElement).toBe(root.querySelector("[data-mc-input]")); + }); + + it("the focused text row's own DIRECT marking does not dive (Enter is the activation)", () => { + // Same rule as every own-row marking: the first dispatch already offered + // the key to the whole row, and double-tap must not out-power single-tap. + const { ctl, key } = makeRig(` + <div data-mc-row="q"><span data-mc-hotkey="h" data-mc-hotkey-direct>hail</span><input data-mc-input type="text"></div> + <div data-mc-row="b">beta</div> + `); + const e = key("h"); // focus starts on q itself + expect(ctl.nav.textMode).toBe(false); + expect(e.defaultPrevented).toBe(true); // consumed: a visible marking always answers its key + }); + + it("markings inside aria-hidden rows are not collected (invisible keys answer nothing)", () => { + // A live list's pending/collapsing rows are zero-height and aria-hidden; + // a marking in one must neither answer keys nor trip the duplicate throw + // against its visible twin (a ghost and its pending replacement can briefly + // coexist with the same marking). + const { root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div aria-hidden="true"><div data-mc-row="hid" data-mc-hotkey="h">hidden</div></div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + const e = key("h"); + expect(got.map((d) => d.rowId)).toEqual(["a"]); // the focused row's first claim only + expect(e.defaultPrevented).toBe(false); + }); + + it("a visible marking coexists with an aria-hidden duplicate (no duplicate throw)", () => { + const { ctl, key } = makeRig(` + <div data-mc-row="live-x" data-mc-hotkey="h"><span data-mc-main>visible</span></div> + <div aria-hidden="true"><div data-mc-row="ghost-x" data-mc-hotkey="h">ghost</div></div> + <div data-mc-row="b">beta</div> + `); + key("ArrowDown"); // -> b, so the marking resolution has to reach back up + key("h"); + expect(ctl.nav.focusedId).toBe("live-x"); + }); + + it("numeric markings work", () => { + const { ctl, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="preset" data-mc-hotkey="5">preset five</div> + `); + key("5"); + expect(ctl.nav.focusedId).toBe("preset"); + }); + + it("x and i markings are legal, and the focused row's own claim still outranks them", () => { + // The row norms answer x and i already, so marking them is a guideline + // violation rather than an error: the dispatch order keeps the norms true. + // The focused row's claim comes first; the marking is only consulted on decline. + const { root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="wipe" data-mc-hotkey="x"><span data-mc-main>wipe all</span></div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + const detail = (e as CustomEvent<MegacolumnKeyDetail>).detail; + got.push(detail); + if (detail.rowId === "a") e.preventDefault(); // the row claims, as an aux widget would + }); + key("x"); + expect(got.map((d) => d.rowId)).toEqual(["a"]); // claimed at the row; the marking never asked + }); + + it("an x marking moves focus when the focused row declines x", () => { + const { ctl, root, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="wipe" data-mc-hotkey="x"><span data-mc-main>wipe all</span></div> + `); + const got: MegacolumnKeyDetail[] = []; + root.addEventListener("megacolumn-key", (e) => { + got.push((e as CustomEvent<MegacolumnKeyDetail>).detail); + }); + key("x"); // row a has no aux and claims nothing: the marking answers as a jump + expect(got.map((d) => d.rowId)).toEqual(["a"]); + expect(ctl.nav.focusedId).toBe("wipe"); + }); + + it("an i marking outranks the data-info fallback (the jump consumes i)", () => { + // The container's [data-info] answer is the last resort for a fully + // unclaimed i; a column i marking resolves (as a focus move) before it. + const { ctl, container, key } = makeRig(` + <div data-mc-row="a" data-info="alpha's own story">alpha</div> + <div data-mc-row="atlas" data-mc-hotkey="i"><span data-mc-main>atlas</span></div> + `); + let infos = 0; + container.addEventListener("sw-info", () => { + infos += 1; + }); + key("i"); + expect(ctl.nav.focusedId).toBe("atlas"); + expect(infos).toBe(0); // the jump consumed i; the fallback never ran + }); + + it("a declined DIRECT i marking does not fall through to data-info (focus moved on)", () => { + // Once a marking matches, it resolves the key: the jump already landed focus + // on the marked row, and answering with the OLD row's data-info would narrate + // a row the ring has left. The fallback only answers keys no marking matched. + const { ctl, container, key } = makeRig(` + <div data-mc-row="a" data-info="alpha's own story">alpha</div> + <div data-mc-row="atlas" data-mc-hotkey="i" data-mc-hotkey-direct><span data-mc-main>atlas</span></div> + `); + let infos = 0; + container.addEventListener("sw-info", () => { + infos += 1; + }); + key("i"); // nobody claims either dispatch + expect(ctl.nav.focusedId).toBe("atlas"); + expect(infos).toBe(0); + }); + + it("throws on duplicate markings in one column, case-insensitively", () => { + expect(() => + makeRig(` + <div data-mc-row="a" data-mc-hotkey="d">a</div> + <div data-mc-row="b" data-mc-hotkey="d">b</div> + `), + ).toThrow(/duplicate hotkey marking "d".*"a".*"b"/); + expect(() => + makeRig(` + <div data-mc-row="a" data-mc-hotkey="d">a</div> + <div data-mc-row="b" data-mc-hotkey="D">b</div> + `), + ).toThrow(/duplicate hotkey marking/); + }); + + it("throws on malformed markings", () => { + expect(() => makeRig(`<div data-mc-row="a" data-mc-hotkey="dd">a</div>`)).toThrow( + /single \[a-z0-9\] character/, + ); + expect(() => makeRig(`<div data-mc-row="a" data-mc-hotkey="!">a</div>`)).toThrow( + /single \[a-z0-9\] character/, + ); + }); + + it("throws on a marking outside any row", () => { + expect(() => + makeRig(`<span data-mc-hotkey="d">stray</span><div data-mc-row="a">a</div>`), + ).toThrow(/outside any row/); + }); + + it("throws on a marking on an aux control (x/Delete are the aux norms)", () => { + expect(() => + makeRig(` + <div data-mc-row="a"><span data-mc-main>m</span><span data-mc-aux data-mc-hotkey="j">x</span></div> + `), + ).toThrow(/aux/); + }); +}); +describe("the i fallback for [data-info] carriers", () => { + it("answers an unclaimed i with sw-info from the row's data-info", () => { + const { root, container, key } = makeRig( + `<div data-mc-row="a" data-info="alpha is a placeholder row">alpha</div>`, + ); + const got: SwInfoDetail[] = []; + container.addEventListener("sw-info", (e) => { + got.push((e as CustomEvent<SwInfoDetail>).detail); + }); + const e = key("i"); + expect(got).toEqual([ + { content: "alpha is a placeholder row", source: root.querySelector(`[data-mc-row="a"]`) }, + ]); + expect(e.defaultPrevented).toBe(true); // answered means consumed + }); + + it("a widget's own i claim outranks the fallback", () => { + const { root, container, key } = makeRig( + `<div data-mc-row="a" data-info="row-level info">alpha</div>`, + ); + root.addEventListener("megacolumn-key", (e) => { + if ((e as CustomEvent<MegacolumnKeyDetail>).detail.key === "i") e.preventDefault(); + }); + let infos = 0; + container.addEventListener("sw-info", () => { + infos += 1; + }); + key("i"); + expect(infos).toBe(0); + }); + + it("the nearest carrier to the focused control wins (aux over row)", () => { + const { root, container, key } = makeRig(` + <div data-mc-row="a" data-info="the row's story"> + <span data-mc-main>item</span> + <span data-mc-aux data-info="the x's story">x</span> + </div> + `); + const got: SwInfoDetail[] = []; + container.addEventListener("sw-info", (e) => { + got.push((e as CustomEvent<SwInfoDetail>).detail); + }); + key("ArrowRight"); // onto the aux control + key("i"); + expect(got.map((d) => d.content)).toEqual(["the x's story"]); + expect(got[0]?.source).toBe(root.querySelector("[data-mc-aux]")); + key("ArrowLeft"); // back to main: the row's own info answers + key("i"); + expect(got.map((d) => d.content)).toEqual(["the x's story", "the row's story"]); + }); + + it("shifted I answers too", () => { + const { container, key } = makeRig(`<div data-mc-row="a" data-info="story">alpha</div>`); + const got: string[] = []; + container.addEventListener("sw-info", (e) => { + got.push((e as CustomEvent<SwInfoDetail>).detail.content); + }); + key("I", { shiftKey: true }); + expect(got).toEqual(["story"]); + }); + + it("i on a row without data-info stays unconsumed and dispatches nothing", () => { + const { container, key } = makeRig(threeRows); + let infos = 0; + container.addEventListener("sw-info", () => { + infos += 1; + }); + const e = key("i"); + expect(infos).toBe(0); + expect(e.defaultPrevented).toBe(false); + }); + + it("carriers above the row do not answer (the fallback is row-scoped)", () => { + // data-info on the container (or beyond) belongs to hover, not to the + // column's i: a row must not inherit info it does not carry. + const { container, key } = makeRig(threeRows); + container.dataset.info = "the container's own story"; + let infos = 0; + container.addEventListener("sw-info", () => { + infos += 1; + }); + const e = key("i"); + expect(infos).toBe(0); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("the megacolumn-focus-change event (focus identity announcements)", () => { + interface FocusChangeDetail { + rowId: string | null; + } + + function listen(target: HTMLElement): FocusChangeDetail[] { + const seen: FocusChangeDetail[] = []; + target.addEventListener("megacolumn-focus-change", (e) => { + seen.push((e as CustomEvent<FocusChangeDetail>).detail); + }); + return seen; + } + + it("announces arrow-key focus moves with the new row id", () => { + const { container, key } = makeRig(threeRows); + const seen = listen(container); + key("ArrowDown"); + expect(seen).toEqual([{ rowId: "b" }]); + key("ArrowDown"); + expect(seen).toEqual([{ rowId: "b" }, { rowId: "c" }]); + }); + + it("announces a hotkey jump", () => { + const { container, key } = makeRig(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="z"><span data-mc-hotkey="z">omega</span></div> + `); + const seen = listen(container); + key("z"); + expect(seen).toEqual([{ rowId: "z" }]); + }); + + it("announces a churn fallback (focused row vanished on re-render)", () => { + const { root, render, key } = makeRig(threeRows); + key("ArrowDown"); // focus b + // The event bubbles, so the listener sits on the render root: the rig's + // innerHTML render replaces the container element itself, as a real host's + // full re-render may. + const seen = listen(root); + render(` + <div data-mc-row="a">alpha</div> + <div data-mc-row="c">gamma</div> + `); + expect(seen).toEqual([{ rowId: "c" }]); + }); + + it("announces the focus emptying out (all rows gone) with null", () => { + const { root, render } = makeRig(threeRows); + const seen = listen(root); + render(""); + expect(seen).toEqual([{ rowId: null }]); + }); + + it("stays quiet when a re-render leaves focus where it was", () => { + const { root, render } = makeRig(threeRows); + const seen = listen(root); + render(threeRows); // same rows, same focus + expect(seen).toEqual([]); + }); + + it("stays quiet on within-row moves (main to aux is the same row)", () => { + const { container, key } = makeRig(` + <div data-mc-row="a"><span data-mc-main>alpha</span><span data-mc-aux>x</span></div> + `); + const seen = listen(container); + key("ArrowRight"); + expect(seen).toEqual([]); + }); +}); diff --git a/packages/yukey/src/megacolumn-controller.ts b/packages/yukey/src/megacolumn-controller.ts new file mode 100644 index 0000000..6b578cb --- /dev/null +++ b/packages/yukey/src/megacolumn-controller.ts @@ -0,0 +1,743 @@ +// 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): +// +// [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: sw-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 sw-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 sw-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 (sw-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 sw-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. +// +// 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. + +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import { css } from "lit"; +import { MegacolumnNav, type NavRow, type RowPosition } from "./megacolumn-nav.js"; +import { dispatchSwInfo } from "./sw-info.js"; + +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. +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. +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. +export const CLAIM_EXCUSE_MS = 350; + +// 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 { + return el.closest('[aria-hidden="true"]') !== null; +} + +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). + #lockedOut = false; + #lockoutTimer: number | undefined; + #claimExcuse = false; // a forwarded key was claimed within CLAIM_EXCUSE_MS + #claimExcuseTimer: number | undefined; + + constructor(host: Host) { + this.#host = host; + host.addController(this); + } + + hostConnected(): void {} + + hostDisconnected(): void { + // Listeners live on elements inside the host's shadow root and die with it; + // just drop our references. + this.#container = null; + this.#ringed = []; + this.#scrolledTo = null; // a re-attached tree may not be scrolled where we left it + this.#announcedFocus = null; + window.clearTimeout(this.#lockoutTimer); + this.#lockoutTimer = undefined; + this.#lockedOut = false; + window.clearTimeout(this.#claimExcuseTimer); + this.#claimExcuseTimer = undefined; + this.#claimExcuse = false; + } + + hostUpdated(): void { + const container = this.#host.renderRoot.querySelector<HTMLElement>("[data-mc-container]"); + if (container === null) throw new Error("megacolumn: no [data-mc-container] in renderRoot"); + if (container !== this.#container) { + this.#container = container; + container.tabIndex = 0; + if (!container.hasAttribute("role")) container.setAttribute("role", "listbox"); + container.addEventListener("keydown", (e) => this.onKeydown(e)); + container.addEventListener("mousedown", (e) => this.onMousedown(e)); + container.addEventListener("click", (e) => this.onClick(e)); + 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", () => { + this.scanRows(this.mustContainer()); + this.applyFocusState(); + }); + container.addEventListener("megacolumn-focus-request", (e) => this.onFocusRequest(e)); + } + this.scanRows(container); + this.applyFocusState(); + } + + // Read 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]")) { + const id = el.dataset.mcRow ?? ""; + const input = el.querySelector<HTMLElement>("[data-mc-input]"); + if (input !== null) input.tabIndex = -1; // never a tab stop; text mode dives in + // The container must stay the only tab stop: a stray natively-focusable + // element inside a row would fracture the focus model. Widgets that truly + // need real focus must be marked data-mc-input (text entry) instead. + for (const candidate of el.querySelectorAll( + "button, select, textarea, input, a[href], [tabindex]", + )) { + if (candidate.matches("[data-mc-input]")) continue; + if (candidate instanceof HTMLElement && candidate.tabIndex === -1) continue; + throw new Error( + `megacolumn: row "${id}" contains a native tab stop (${candidate.tagName.toLowerCase()}); ` + + "give it tabindex=-1 or mark it data-mc-input", + ); + } + // 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 + // 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; + // ... and a role for the relation to be meaningful to AT. + if (!el.hasAttribute("role")) el.setAttribute("role", "option"); + rows.push({ + id, + focusable: !closedToSight(el), + hasAux: el.querySelector("[data-mc-aux]") !== null, + textEntry: input !== null, + }); + } + if (this.nav.setRows(rows)) this.armLockout(); + 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. + private armLockout(): void { + if (this.#claimExcuse) return; + this.#lockedOut = true; + window.clearTimeout(this.#lockoutTimer); + this.#lockoutTimer = window.setTimeout(() => { + this.#lockedOut = false; + this.#lockoutTimer = undefined; + }, FALLBACK_LOCKOUT_MS); + } + + // A forwarded key was claimed: open the excuse window (see armLockout). + private noteClaim(): void { + this.#claimExcuse = true; + window.clearTimeout(this.#claimExcuseTimer); + this.#claimExcuseTimer = window.setTimeout(() => { + this.#claimExcuse = false; + this.#claimExcuseTimer = undefined; + }, 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. + 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. + private scanHotkeys(container: HTMLElement): void { + this.#hotkeys = new Map(); + for (const el of container.querySelectorAll<HTMLElement>("[data-mc-hotkey]")) { + if (closedToSight(el)) continue; + const marking = el.dataset.mcHotkey ?? ""; + if (!/^[a-z0-9]$/i.test(marking)) + throw new Error( + `megacolumn: hotkey marking "${marking}" must be a single [a-z0-9] character`, + ); + const char = marking.toLowerCase(); + const row = el.closest<HTMLElement>("[data-mc-row]"); + if (row === null) + throw new Error(`megacolumn: hotkey marking "${marking}" is outside any row`); + if (el.closest("[data-mc-aux]") !== null) + throw new Error( + `megacolumn: row "${row.dataset.mcRow}" marks its aux control with "${marking}"; ` + + "aux controls answer to x/Delete already", + ); + const prior = this.#hotkeys.get(char); + if (prior !== undefined) { + const priorRow = prior.closest<HTMLElement>("[data-mc-row]"); + throw new Error( + `megacolumn: duplicate hotkey marking "${char}" on rows ` + + `"${priorRow?.dataset.mcRow}" and "${row.dataset.mcRow}"`, + ); + } + this.#hotkeys.set(char, el); + } + } + + // 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. + private applyFocusState(): void { + const container = this.mustContainer(); + for (const el of this.#ringed) el.classList.remove("mc-focused", "mc-row-focused"); + this.#ringed = []; + + const focusedId = this.nav.focusedId; + if (focusedId === null) { + container.removeAttribute("aria-activedescendant"); + this.#scrolledTo = null; + } else { + const row = this.rowElement(focusedId); + const control = this.controlElement(row, this.nav.position); + container.setAttribute("aria-activedescendant", row.id); + 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 + // 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 + // animation would misreport causality during churn. + if (this.#scrolledTo !== focusedId) { + this.#scrolledTo = focusedId; + row.scrollIntoView({ block: "nearest", behavior: "instant" }); + } + } + container.classList.toggle("mc-text-mode", this.nav.textMode); + container.classList.toggle( + "mc-empty-focus", + focusedId === null && this.focusIsInside(container), + ); + // Announce focus IDENTITY changes (see the header's megacolumn-focus-change + // 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", { + detail: { rowId: focusedId }, + bubbles: true, + }), + ); + } + } + + private onKeydown(e: KeyboardEvent): void { + // Modifier chords are never ours: browser and OS shortcuts stay native. + if (e.ctrlKey || e.altKey || e.metaKey) return; + const routing = this.nav.handleKey(e.key); + // The churn lockout: decisive routings die here while armed (consumed, so the + // browser gets nothing either); navigation and mode exits stay live. Hotkey + // focus moves are navigation too -- reorienting is exactly what the user + // needs -- so non-direct markings still resolve as jumps; direct markings + // are decisive and are swallowed like every other forward. + if (this.#lockedOut && (routing.kind === "forward" || routing.kind === "enter-text")) { + if (routing.kind === "enter-text") this.nav.exitTextMode(); // undo handleKey's entry + if (routing.kind === "forward") { + const marked = this.#hotkeys.get(e.key.toLowerCase()); + if (marked !== undefined && !marked.hasAttribute("data-mc-hotkey-direct")) { + const row = this.rowElement(routing.rowId); + if (!row.contains(marked)) this.jumpToMarking(marked); + } + } + e.preventDefault(); + this.applyFocusState(); + return; + } + switch (routing.kind) { + case "nav": + e.preventDefault(); + break; + case "forward": { + const row = this.rowElement(routing.rowId); + const control = this.controlElement(row, routing.position); + const forwarded = new CustomEvent<MegacolumnKeyDetail>("megacolumn-key", { + detail: { key: e.key, rowId: routing.rowId, position: routing.position }, + bubbles: true, + cancelable: true, + }); + // `claimed` opens the lockout's excuse window (a widget acted; whatever + // vanish follows is the user's own doing); `consumed` only swallows the + // keydown. A hotkey jump is consumed but never claimed: navigation + // causes no vanishes, so it must not weaken the lockout's protection. + let claimed = !control.dispatchEvent(forwarded); + let consumed = claimed; + // The focused row declined; a column hotkey marking resolves the key. + // The marking's own row gets nothing beyond the consume: the first + // dispatch already bubbled through the whole focused row, and a jump to + // where you already are is no move (double-tap must not out-power + // single-tap; Enter/Space stay the activation). + const marked = this.#hotkeys.get(e.key.toLowerCase()); + if (!claimed && marked !== undefined) { + consumed = true; // a visible marking always answers its key + if (!row.contains(marked)) { + this.jumpToMarking(marked); + if (marked.hasAttribute("data-mc-hotkey-direct")) { + const markedRow = marked.closest<HTMLElement>("[data-mc-row]"); + const markedId = markedRow?.dataset.mcRow ?? ""; + if (this.nav.rows.find((r) => r.id === markedId)?.textEntry === true) { + // On a text row, activation IS the text dive: the same transition + // Enter produces on a focused text row, in one keystroke. No second + // megacolumn-key: there is no widget to activate. Not claimed -- + // like a jump, a dive causes no vanishes (nothing syncs to data + // until the input commits), so it must not weaken the lockout. + this.nav.noteTextEntered(markedId); + this.diveIntoText(markedId, e); + } else { + // 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", { + detail: { key: e.key, rowId: markedId, position: "main" }, + bubbles: true, + cancelable: true, + }), + ); + } + } + } + } + // Still unresolved and the key is i: the container answers for plain + // [data-info] carriers (the info norm's "almost any element"; widgets + // with their own i handling, like sw-button, claimed above and never + // reach this). Nearest carrier from the focused control up to the row, + // inclusive: a row must not inherit info from outside itself. + if (!consumed && e.key.toLowerCase() === "i") { + const carrier = this.infoCarrier(control, row); + if (carrier !== null) { + dispatchSwInfo(carrier, carrier.dataset.info ?? ""); + claimed = true; + consumed = true; + } + } + // A resolved key is fully consumed; an unresolved one keeps its native + // default (PageDown may still scroll the column, etc). + if (claimed) this.noteClaim(); + if (consumed) e.preventDefault(); + break; + } + case "enter-text": + this.diveIntoText(routing.rowId, e); + break; + case "exit-text": + e.preventDefault(); + this.mustContainer().focus(); + break; + case "pass": + return; // not ours; no state to re-render + } + 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. + 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]`); + e.preventDefault(); + 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. + private onFocusRequest(e: Event): void { + const detail: unknown = (e as CustomEvent).detail; + if (typeof detail !== "object" || detail === null) return; + const rowId = (detail as { rowId?: unknown }).rowId; + if (typeof rowId !== "string") return; + if (this.nav.textMode) return; // text entry dominates; jumps do not exist there + const row = this.nav.rows.find((r) => r.id === rowId); + if (row === undefined || row.focusable === false) return; + this.nav.focusRow(rowId, "main"); + e.preventDefault(); + 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. + private onMousedown(e: MouseEvent): void { + const target = e.target; + if (!(target instanceof Element)) return; + const rowEl = target.closest("[data-mc-row]"); + if (rowEl === null || !(rowEl instanceof HTMLElement)) return; + const id = rowEl.dataset.mcRow ?? ""; + const row = this.nav.rows.find((r) => r.id === id); + if (row === undefined || row.focusable === false) return; // click on a disabled row: inert + const position: RowPosition = target.closest("[data-mc-aux]") !== null ? "aux" : "main"; + this.nav.focusRow(id, position); + 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 (sw-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; + if (!(target instanceof Element)) return; + if (target.closest("[data-mc-input]") !== null) return; // input clicks dive via focusin + const rowEl = target.closest("[data-mc-row]"); + if (rowEl === null || !(rowEl instanceof HTMLElement)) return; + const id = rowEl.dataset.mcRow ?? ""; + const row = this.nav.rows.find((r) => r.id === id); + if (row === undefined || row.focusable === false) return; // closed rows: inert, like the mousedown sync + 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", { + detail: { key: "Enter", rowId: id, position }, + bubbles: true, + cancelable: true, + }); + // A claim opens the lockout's excuse window exactly as a claimed keydown + // forward does (a click-removal is the same controlled moment the user made + // happen), and marks the click consumed for any upstream listener. + if (!control.dispatchEvent(synthesized)) { + this.noteClaim(); + e.preventDefault(); + } + } + + private onFocusin(e: FocusEvent): void { + const container = this.mustContainer(); + container.classList.add("mc-focus-within"); + // A click (or programmatic focus) landed directly in a text input: + // text entry dominates, sync the machine to reality. + const target = e.target; + if (target instanceof Element && target.matches("[data-mc-input]")) { + const rowEl = target.closest("[data-mc-row]"); + if (rowEl instanceof HTMLElement) { + const id = rowEl.dataset.mcRow ?? ""; + if (this.nav.focusedId !== id || !this.nav.textMode) this.nav.noteTextEntered(id); + } + } + this.applyFocusState(); + } + + private onFocusout(e: FocusEvent): void { + const container = this.mustContainer(); + // Only react when focus leaves the container subtree entirely; movements + // within it (input <-> container) are handled by their own events. + const goingTo = e.relatedTarget; + if (goingTo instanceof Node && container.contains(goingTo)) return; + container.classList.remove("mc-focus-within"); + if (this.nav.textMode) this.nav.exitTextMode(); + this.applyFocusState(); + // Row focus memory is intrinsic to the machine: focusedId survives untouched, + // so regaining DOM focus resumes exactly where the user left. + } + + private mustContainer(): HTMLElement { + if (this.#container === null) throw new Error("megacolumn: container not wired yet"); + 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. + private infoCarrier(control: HTMLElement, row: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = control; + while (el !== null) { + if ((el.dataset.info ?? "") !== "") return el; + if (el === row) return null; + el = el.parentElement; + } + return null; + } + + private focusIsInside(container: HTMLElement): boolean { + const active = container.getRootNode() as Document | ShadowRoot; + return active.activeElement !== null && container.contains(active.activeElement); + } + + private rowElement(id: string): HTMLElement { + const el = this.mustContainer().querySelector<HTMLElement>(`[data-mc-row="${CSS.escape(id)}"]`); + // The machine only names rows it was given by scanRows, so a miss here means + // the DOM changed without a render (which is where re-scans happen): a bug. + if (el === null) throw new Error(`megacolumn: row "${id}" vanished from the DOM mid-dispatch`); + return el; + } + + private controlElement(row: HTMLElement, position: RowPosition): HTMLElement { + if (position === "aux") { + const aux = row.querySelector<HTMLElement>("[data-mc-aux]"); + if (aux === null) + throw new Error(`megacolumn: row "${row.dataset.mcRow}" lost its [data-mc-aux]`); + return aux; + } + return row.querySelector<HTMLElement>("[data-mc-main]") ?? row; + } +} + +// 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. +export const megacolumnStyles = css` + [data-mc-container] { + outline: none; /* the ring is drawn on the focused row instead */ + } + /* Navigation scrolls (scrollIntoView from applyFocusState) land the focused row + --sw-scroll-peek inside the scrollport edge, keeping about one neighbor row + visible beyond it: the user sees the list continue (or truly end) past where + they are. scroll-margin only affects programmatic scrolls into view; free + scrolling and PageUp/Down are untouched. */ + [data-mc-row] { + scroll-margin-block: var(--sw-scroll-peek); + } + [data-mc-container].mc-empty-focus { + outline: var(--sw-focus-ring); + outline-offset: calc(-1 * var(--sw-focus-ring-width)); + } + .mc-focus-within [data-mc-row].mc-row-focused { + outline: var(--sw-focus-ring); + outline-offset: calc(-1 * var(--sw-focus-ring-width)); + } + .mc-focus-within [data-mc-aux].mc-focused { + outline: var(--sw-focus-ring); + outline-offset: calc(-1 * var(--sw-focus-ring-width)); + } + /* Focus memory stays quietly visible while the container is unfocused, + so the user can see where focus will resume before tabbing back in. */ + .mc-row-focused { + background: var(--sw-wash-faint); + } + .mc-focus-within .mc-row-focused { + background: var(--sw-wash-selected); + } + /* Activation feedback for plain rows: a host that acts on a forwarded + activation calls flashPress on the event's target -- the row's control + element (the main control if the row has one, else the row itself). + So a two-control row flashes its main, never the x: the flash answers + "what did I just do", inside the row the ring outlines. Equal specificity, + later in the sheet: the flash outranks the focus washes above, while the + ring (an outline) coexists untouched. */ + [data-mc-row].sw-pressed, + [data-mc-row] [data-mc-main].sw-pressed { + background: var(--sw-wash-pressed); + } +`; diff --git a/packages/yukey/src/megacolumn-nav.test.ts b/packages/yukey/src/megacolumn-nav.test.ts new file mode 100644 index 0000000..b7334aa --- /dev/null +++ b/packages/yukey/src/megacolumn-nav.test.ts @@ -0,0 +1,454 @@ +// Tests for the megacolumn navigation state machine (megacolumn-nav.ts). +// Pure logic: every test drives the machine with rows and key names and asserts +// on (focusedId, position, textMode) plus the returned routing. The edge-case +// inventory comes from the guidelines' megacolumn norms: vanishing rows, gaps, +// two-control rows at the column edges, text-entry dominance, empty columns. + +import { describe, expect, it } from "vitest"; +import { MegacolumnNav, type NavRow } from "./megacolumn-nav.js"; + +// Row-list shorthand: "a b! c* d~" -> b is unfocusable, c has an aux control, +// d is a text-entry row. Keeps each test's column readable at a glance. +function rows(spec: string): NavRow[] { + if (spec === "") return []; + return spec.split(" ").map((word) => { + const id = word.replace(/[!*~]/g, ""); + return { + id, + focusable: !word.includes("!"), + hasAux: word.includes("*"), + textEntry: word.includes("~"), + }; + }); +} + +function navWith(spec: string): MegacolumnNav { + const nav = new MegacolumnNav(); + nav.setRows(rows(spec)); + return nav; +} + +describe("row registration", () => { + it("focuses the first focusable row eagerly", () => { + const nav = navWith("a b c"); + expect(nav.focusedId).toBe("a"); + expect(nav.position).toBe("main"); + }); + + it("skips leading unfocusable rows for the initial focus", () => { + expect(navWith("a! b! c d").focusedId).toBe("c"); + }); + + it("focuses nothing in an empty column", () => { + expect(navWith("").focusedId).toBeNull(); + }); + + it("focuses nothing in an all-unfocusable column", () => { + expect(navWith("a! b!").focusedId).toBeNull(); + }); + + it("rejects duplicate row ids loudly", () => { + const nav = new MegacolumnNav(); + expect(() => nav.setRows(rows("a b a"))).toThrow(/duplicate.*: a/); + }); + + it("rejects empty row ids loudly", () => { + const nav = new MegacolumnNav(); + expect(() => nav.setRows([{ id: "" }])).toThrow(/empty id/); + }); + + it("owns its copy of the row list (caller mutations after setRows are invisible)", () => { + const list = rows("a b"); + const nav = new MegacolumnNav(); + nav.setRows(list); + list.length = 0; + expect(nav.rows.length).toBe(2); + }); + + it("registration order IS display order: focus follows the list as given", () => { + // The machine imposes no sorting of its own; whoever calls setRows + // (the binding layer, during render) is stating the display order. + const nav = navWith("c a b"); + expect(nav.focusedId).toBe("c"); + nav.handleKey("ArrowDown"); + expect(nav.focusedId).toBe("a"); + }); +}); + +describe("up/down movement", () => { + it("moves down and up between adjacent rows", () => { + const nav = navWith("a b c"); + expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("b"); + expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("a"); + }); + + it("skips unfocusable rows in both directions (the gap jump)", () => { + const nav = navWith("a gap1! gap2! b"); + nav.handleKey("ArrowDown"); + expect(nav.focusedId).toBe("b"); + nav.handleKey("ArrowUp"); + expect(nav.focusedId).toBe("a"); + }); + + it("consumes but does not wrap at the bottom edge", () => { + const nav = navWith("a b"); + nav.handleKey("ArrowDown"); + expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("b"); + }); + + it("consumes but does not wrap at the top edge", () => { + const nav = navWith("a b"); + expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("a"); + }); + + it("stays put when trailing rows are all unfocusable", () => { + const nav = navWith("a b! c!"); + expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("a"); + }); + + it("single-row column: arrows consumed, focus stays", () => { + const nav = navWith("only"); + expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" }); + expect(nav.handleKey("ArrowUp")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("only"); + }); + + it("Home and End jump to the first/last focusable row", () => { + const nav = navWith("gap1! a b c gap2!"); + nav.handleKey("ArrowDown"); + expect(nav.handleKey("End")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("c"); + expect(nav.handleKey("Home")).toEqual({ kind: "nav" }); + expect(nav.focusedId).toBe("a"); + }); + + it("empty column: navigation keys consumed (no page scroll), others pass", () => { + const nav = navWith(""); + expect(nav.handleKey("ArrowDown")).toEqual({ kind: "nav" }); + expect(nav.handleKey("Home")).toEqual({ kind: "nav" }); + expect(nav.handleKey("Enter")).toEqual({ kind: "pass" }); + expect(nav.handleKey("x")).toEqual({ kind: "pass" }); + }); +}); + +describe("the two-control row (main + aux)", () => { + it("ArrowRight reaches the aux control, ArrowLeft returns to main", () => { + const nav = navWith("a* b"); + expect(nav.handleKey("ArrowRight")).toEqual({ kind: "nav" }); + expect(nav.position).toBe("aux"); + expect(nav.handleKey("ArrowLeft")).toEqual({ kind: "nav" }); + expect(nav.position).toBe("main"); + }); + + it("row changes land on the main control, from aux, in every direction", () => { + const nav = navWith("a* b* c*"); + nav.handleKey("ArrowDown"); // -> b + nav.handleKey("ArrowRight"); // b/aux + nav.handleKey("ArrowDown"); // -> c + expect(nav.focusedId).toBe("c"); + expect(nav.position).toBe("main"); + nav.handleKey("ArrowRight"); // c/aux + nav.handleKey("ArrowUp"); // -> b + expect(nav.position).toBe("main"); + nav.handleKey("ArrowRight"); // b/aux + nav.handleKey("Home"); // -> a + expect(nav.focusedId).toBe("a"); + expect(nav.position).toBe("main"); + }); + + it("aux works on the first and last rows of the column", () => { + const nav = navWith("a* b c*"); + nav.handleKey("ArrowRight"); + expect(nav.position).toBe("aux"); + // At the top edge, ArrowUp from aux: no row change happens, and the machine + // treats it as consumed navigation -- position snaps home to main, because + // the user asked to go somewhere and "main of where you are" is the landing. + nav.handleKey("ArrowUp"); + expect(nav.focusedId).toBe("a"); + expect(nav.position).toBe("main"); + nav.handleKey("End"); + nav.handleKey("ArrowRight"); + expect(nav.focusedId).toBe("c"); + expect(nav.position).toBe("aux"); + nav.handleKey("ArrowDown"); + expect(nav.position).toBe("main"); + }); + + it("ArrowRight twice stays on aux (one aux per row; nowhere further to go)", () => { + const nav = navWith("a*"); + nav.handleKey("ArrowRight"); + expect(nav.handleKey("ArrowRight")).toEqual({ kind: "nav" }); + expect(nav.position).toBe("aux"); + }); + + it("keys other than arrows forward to the aux control while it holds focus", () => { + const nav = navWith("a*"); + nav.handleKey("ArrowRight"); + expect(nav.handleKey("Enter")).toEqual({ kind: "forward", rowId: "a", position: "aux" }); + }); + + it("rows without aux forward left/right instead (sliders eat arrows)", () => { + const nav = navWith("slider"); + expect(nav.handleKey("ArrowRight")).toEqual({ + kind: "forward", + rowId: "slider", + position: "main", + }); + expect(nav.handleKey("ArrowLeft")).toEqual({ + kind: "forward", + rowId: "slider", + position: "main", + }); + }); +}); + +describe("key routing to widgets", () => { + it("forwards non-navigation keys to the focused row's main control", () => { + const nav = navWith("a b"); + for (const key of ["Enter", " ", "x", "Delete", "i", "PageDown"]) { + expect(nav.handleKey(key)).toEqual({ kind: "forward", rowId: "a", position: "main" }); + } + }); + + it("Tab is never claimed: it passes in row-nav mode and in text mode", () => { + // Tab is the browser's container-to-container movement key; forwarding it to a + // row widget would let a sloppy widget break leaving the column entirely. + const nav = navWith("a query~"); + expect(nav.handleKey("Tab")).toEqual({ kind: "pass" }); + nav.handleKey("ArrowDown"); + nav.handleKey("Enter"); // text mode + expect(nav.handleKey("Tab")).toEqual({ kind: "pass" }); + }); + + it("throws if the focused row was yanked without a setRows", () => { + // The binding layer reconciles rows on every render, so a stale focused id + // reaching handleKey means registration is broken: fail loudly. + const list = rows("a b"); + const nav = new MegacolumnNav(); + nav.setRows(list); + const first = list[0]; + if (first === undefined) throw new Error("unreachable: rows() made two rows"); + first.id = "mutated"; // row objects are shared; ids must not be mutated in place + expect(() => nav.handleKey("ArrowDown")).toThrow(/vanished without setRows/); + }); +}); + +describe("text-entry mode", () => { + it("Enter on a text row enters text mode; Space likewise", () => { + const nav = navWith("query~ b"); + expect(nav.handleKey("Enter")).toEqual({ kind: "enter-text", rowId: "query" }); + expect(nav.textMode).toBe(true); + const nav2 = navWith("query~ b"); + expect(nav2.handleKey(" ")).toEqual({ kind: "enter-text", rowId: "query" }); + }); + + it("text mode passes everything to the input, including arrows and space", () => { + const nav = navWith("query~ b"); + nav.handleKey("Enter"); + for (const key of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", " ", "x", "Home"]) { + expect(nav.handleKey(key)).toEqual({ kind: "pass" }); + } + expect(nav.focusedId).toBe("query"); // navigation did not move + }); + + it("Escape exits text mode and row focus resumes where it was", () => { + const nav = navWith("a query~ c"); + nav.handleKey("ArrowDown"); + nav.handleKey("Enter"); + expect(nav.handleKey("Escape")).toEqual({ kind: "exit-text", rowId: "query" }); + expect(nav.textMode).toBe(false); + expect(nav.focusedId).toBe("query"); + nav.handleKey("ArrowDown"); + expect(nav.focusedId).toBe("c"); + }); + + it("Enter also exits text mode (commit-and-leave)", () => { + const nav = navWith("query~"); + nav.handleKey("Enter"); + expect(nav.handleKey("Enter")).toEqual({ kind: "exit-text", rowId: "query" }); + expect(nav.textMode).toBe(false); + }); + + it("re-entry after exit works (enter, exit, navigate away and back, enter again)", () => { + const nav = navWith("query~ b"); + nav.handleKey("Enter"); + nav.handleKey("Escape"); + nav.handleKey("ArrowDown"); + nav.handleKey("ArrowUp"); + expect(nav.handleKey("Enter")).toEqual({ kind: "enter-text", rowId: "query" }); + expect(nav.textMode).toBe(true); + }); + + it("noteTextEntered syncs the machine when a mouse click lands in the input", () => { + const nav = navWith("a query~"); + expect(nav.focusedId).toBe("a"); + nav.noteTextEntered("query"); + expect(nav.focusedId).toBe("query"); + expect(nav.textMode).toBe(true); + expect(() => nav.noteTextEntered("a")).toThrow(/not a text row/); + }); + + it("exitTextMode syncs the machine when DOM focus leaves without an exit key", () => { + const nav = navWith("query~"); + nav.handleKey("Enter"); + nav.exitTextMode(); + expect(nav.textMode).toBe(false); + expect(() => nav.exitTextMode()).toThrow(/not in text mode/); + }); + + it("a text row that stops being one while focused drops text mode on setRows", () => { + const nav = navWith("query~"); + nav.handleKey("Enter"); + nav.setRows(rows("query")); + expect(nav.textMode).toBe(false); + expect(nav.focusedId).toBe("query"); + }); +}); + +describe("focus memory and vanishing rows", () => { + it("the focused row keeps focus across setRows churn around it", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + nav.setRows(rows("x a b c y")); + expect(nav.focusedId).toBe("b"); + }); + + it("the focused row keeps focus even if it moved in display order", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + nav.setRows(rows("b c a")); + expect(nav.focusedId).toBe("b"); + nav.handleKey("ArrowDown"); + expect(nav.focusedId).toBe("c"); + }); + + it("a vanished focused row falls to the next row below (old display order)", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + nav.setRows(rows("a c")); + expect(nav.focusedId).toBe("c"); + }); + + it("falls upward when nothing below survives", () => { + const nav = navWith("a b c"); + nav.handleKey("End"); // -> c + nav.setRows(rows("a b")); + expect(nav.focusedId).toBe("b"); + }); + + it("skips unfocusable survivors when falling", () => { + const nav = navWith("a b c d"); + nav.handleKey("ArrowDown"); // -> b + nav.setRows(rows("a c! d")); + expect(nav.focusedId).toBe("d"); + }); + + it("a focused row that became unfocusable is treated as vanished", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + nav.setRows(rows("a b! c")); + expect(nav.focusedId).toBe("c"); + }); + + it("wholesale replacement falls to the new first focusable row", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); + nav.setRows(rows("x! y z")); + expect(nav.focusedId).toBe("y"); + }); + + it("emptying the column clears focus; repopulating restores it eagerly", () => { + const nav = navWith("a b"); + nav.setRows([]); + expect(nav.focusedId).toBeNull(); + nav.setRows(rows("c d")); + expect(nav.focusedId).toBe("c"); + }); + + it("vanishing resets position to main and drops text mode", () => { + const nav = navWith("a* b~"); + nav.handleKey("ArrowRight"); // a/aux + nav.setRows(rows("b~ c")); + expect(nav.position).toBe("main"); + const nav2 = navWith("q~ r"); + nav2.handleKey("Enter"); // text mode in q + nav2.setRows(rows("r")); + expect(nav2.textMode).toBe(false); + expect(nav2.focusedId).toBe("r"); + }); + + it("a focused row that loses its aux while focus sits on the aux snaps to main", () => { + const nav = navWith("a* b"); + nav.handleKey("ArrowRight"); + expect(nav.position).toBe("aux"); + nav.setRows(rows("a b")); + expect(nav.focusedId).toBe("a"); + expect(nav.position).toBe("main"); + }); +}); + +describe("setRows reports involuntary focus moves", () => { + // The return value feeds the binding's churn lockout: true means the fallback + // moved focus somewhere the user did not send it. + it("reports false when the focused row survives churn", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + expect(nav.setRows(rows("x a b c y"))).toBe(false); + }); + + it("reports true when the fallback picks a neighbor", () => { + const nav = navWith("a b c"); + nav.handleKey("ArrowDown"); // -> b + expect(nav.setRows(rows("a c"))).toBe(true); + expect(nav.focusedId).toBe("c"); + }); + + it("reports true when the focused row went unfocusable", () => { + const nav = navWith("a b"); + expect(nav.setRows(rows("a! b"))).toBe(true); + expect(nav.focusedId).toBe("b"); + }); + + it("reports false when the column empties (nothing to aim at, nothing to protect)", () => { + const nav = navWith("a"); + expect(nav.setRows([])).toBe(false); + expect(nav.focusedId).toBeNull(); + }); + + it("reports false on the eager first focus of a filling column", () => { + const nav = new MegacolumnNav(); + expect(nav.setRows(rows("a b"))).toBe(false); + expect(nav.focusedId).toBe("a"); + }); +}); + +describe("direct focus (mouse clicks arrive here via the binding)", () => { + it("focusRow moves focus and lands on main by default", () => { + const nav = navWith("a b* c"); + nav.focusRow("b"); + expect(nav.focusedId).toBe("b"); + expect(nav.position).toBe("main"); + nav.focusRow("b", "aux"); + expect(nav.position).toBe("aux"); + }); + + it("focusRow rejects unknown, unfocusable, and aux-less targets loudly", () => { + const nav = navWith("a b! c"); + expect(() => nav.focusRow("nope")).toThrow(/unknown row id/); + expect(() => nav.focusRow("b")).toThrow(/not focusable/); + expect(() => nav.focusRow("c", "aux")).toThrow(/no aux control/); + }); + + it("focusRow exits text mode (clicking another row while typing)", () => { + const nav = navWith("q~ b"); + nav.handleKey("Enter"); + nav.focusRow("b"); + expect(nav.textMode).toBe(false); + expect(nav.focusedId).toBe("b"); + }); +}); diff --git a/packages/yukey/src/megacolumn-nav.ts b/packages/yukey/src/megacolumn-nav.ts new file mode 100644 index 0000000..0fc7dfd --- /dev/null +++ b/packages/yukey/src/megacolumn-nav.ts @@ -0,0 +1,281 @@ +// 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. +// +// 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 +// (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: 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?: boolean; + // 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?: 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. +export type KeyRouting = + | { kind: "nav" } + | { kind: "forward"; rowId: string; position: RowPosition } + | { kind: "enter-text"; rowId: string } + | { kind: "exit-text"; rowId: string } + | { kind: "pass" }; + +function isFocusable(row: NavRow): boolean { + return row.focusable ?? true; +} + +export class MegacolumnNav { + #rows: NavRow[] = []; + #focusedId: string | null = null; + #position: RowPosition = "main"; + #textMode = false; + + get rows(): readonly NavRow[] { + return this.#rows; + } + get focusedId(): string | null { + return this.#focusedId; + } + get position(): RowPosition { + return this.#position; + } + 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(rows: NavRow[]): boolean { + const seen = new Set<string>(); + for (const row of rows) { + if (row.id === "") throw new Error("megacolumn row with empty id"); + if (seen.has(row.id)) throw new Error(`duplicate megacolumn row id: ${row.id}`); + seen.add(row.id); + } + const oldRows = this.#rows; + this.#rows = rows.slice(); // own our copy; callers may mutate theirs later + + const hadFocus = this.#focusedId !== null; + const focused = this.#focusedId === null ? undefined : this.rowById(this.#focusedId); + if (focused !== undefined && isFocusable(focused)) { + if (this.#position === "aux" && focused.hasAux !== true) this.#position = "main"; + if (this.#textMode && focused.textEntry !== true) this.#textMode = false; + return false; + } + this.#textMode = false; + this.#position = "main"; + this.#focusedId = this.fallbackFrom(oldRows); + 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(id: string, position: RowPosition = "main"): void { + const row = this.rowById(id); + if (row === undefined) throw new Error(`focusRow: unknown row id: ${id}`); + if (!isFocusable(row)) throw new Error(`focusRow: row is not focusable: ${id}`); + if (position === "aux" && row.hasAux !== true) + throw new Error(`focusRow: row has no aux control: ${id}`); + this.#focusedId = id; + this.#position = position; + 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(id: string): void { + const row = this.rowById(id); + if (row === undefined) throw new Error(`noteTextEntered: unknown row id: ${id}`); + if (row.textEntry !== true) throw new Error(`noteTextEntered: not a text row: ${id}`); + if (!isFocusable(row)) throw new Error(`noteTextEntered: row is not focusable: ${id}`); + this.#focusedId = id; + this.#position = "main"; + 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(): 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(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.) + if (key === "Tab") return { kind: "pass" }; + + if (this.#textMode) { + if (key === "Escape" || key === "Enter") { + const id = this.mustFocusedId("in text mode"); + this.#textMode = false; + return { kind: "exit-text", rowId: id }; + } + return { kind: "pass" }; + } + + // No focusable rows (empty or all-unfocusable column): navigation keys are + // still consumed -- the arrows belong to this column while it holds DOM focus, + // and letting them scroll the page would be surprising -- but nothing moves. + // Other keys pass: there is no row widget to forward to. + if (this.#focusedId === null) { + if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") + return { kind: "nav" }; + return { kind: "pass" }; + } + const row = this.rowById(this.#focusedId); + if (row === undefined) + throw new Error(`focused row vanished without setRows: ${this.#focusedId}`); + + switch (key) { + case "ArrowDown": + return this.moveFocus(+1); + case "ArrowUp": + return this.moveFocus(-1); + case "Home": + return this.focusEdge("first"); + case "End": + return this.focusEdge("last"); + case "ArrowRight": + if (row.hasAux === true) { + this.#position = "aux"; + return { kind: "nav" }; + } + return { kind: "forward", rowId: row.id, position: this.#position }; + case "ArrowLeft": + if (row.hasAux === true) { + this.#position = "main"; + return { kind: "nav" }; + } + return { kind: "forward", rowId: row.id, position: this.#position }; + case "Enter": + case " ": + if (row.textEntry === true && this.#position === "main") { + this.#textMode = true; + return { kind: "enter-text", rowId: row.id }; + } + return { kind: "forward", rowId: row.id, position: this.#position }; + default: + return { kind: "forward", rowId: row.id, position: this.#position }; + } + } + + private rowById(id: string): NavRow | undefined { + return this.#rows.find((r) => r.id === id); + } + + private mustFocusedId(context: string): string { + if (this.#focusedId === null) throw new Error(`${context} with no focused row`); + 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. + private moveFocus(delta: 1 | -1): KeyRouting { + const focusedId = this.mustFocusedId("moveFocus"); + this.#position = "main"; + const start = this.#rows.findIndex((r) => r.id === focusedId); + if (start === -1) throw new Error(`focused row vanished without setRows: ${focusedId}`); + for (let i = start + delta; i >= 0 && i < this.#rows.length; i += delta) { + const candidate = this.#rows[i]; + if (candidate !== undefined && isFocusable(candidate)) { + this.#focusedId = candidate.id; + return { kind: "nav" }; + } + } + return { kind: "nav" }; + } + + private focusEdge(edge: "first" | "last"): KeyRouting { + const ordered = edge === "first" ? this.#rows : this.#rows.slice().reverse(); + const target = ordered.find(isFocusable); + if (target !== undefined) { + this.#focusedId = target.id; + this.#position = "main"; + } + 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. + private fallbackFrom(oldRows: NavRow[]): string | null { + const oldIndex = oldRows.findIndex((r) => r.id === this.#focusedId); + if (oldIndex !== -1) { + const survives = (candidate: NavRow | undefined): boolean => { + if (candidate === undefined) return false; + const now = this.rowById(candidate.id); + return now !== undefined && isFocusable(now); + }; + for (let i = oldIndex + 1; i < oldRows.length; i++) { + if (survives(oldRows[i])) return oldRows[i]?.id ?? null; + } + for (let i = oldIndex - 1; i >= 0; i--) { + if (survives(oldRows[i])) return oldRows[i]?.id ?? null; + } + } + return this.#rows.find(isFocusable)?.id ?? null; + } +} diff --git a/packages/yukey/src/megacolumn.gallery.ts b/packages/yukey/src/megacolumn.gallery.ts new file mode 100644 index 0000000..506631d --- /dev/null +++ b/packages/yukey/src/megacolumn.gallery.ts @@ -0,0 +1,512 @@ +// Gallery entry for the megacolumn focus model: MegacolumnNav + MegacolumnController. +// This demo element is the reference implementation for the primitives wave: +// it shows the full DOM contract (data-mc-* attributes), megacolumn-key handling +// for every row flavor the guidelines name, and the focus-ring rendering. +// Copy its shape when building megacolumn screens. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import { + MegacolumnController, + type MegacolumnKeyDetail, + megacolumnStyles, +} from "./megacolumn-controller.js"; +import { flashPress } from "./press-flash.js"; + +interface CargoItem { + id: string; + label: string; +} + +interface TaskItem { + id: string; + label: string; +} + +// The ship-systems rows are uniform plain activatable rows: id -> acknowledgment. +// They exist mostly to give the column vertical reach, so the hotkey jumps have +// somewhere worth jumping from and to. +const systemActions = new Map<string, string>([ + ["survey", "survey sweep started"], + ["vent", "heat sinks vented"], + ["antenna", "antenna array aligned"], + ["gyros", "gyros recalibrated"], +]); + +// One demo megacolumn: activatable rows, a slider row, a two-control cargo list, +// a text-entry row, a disabled row, and a live-churning task queue -- with section +// gaps between them that focus jumps across. +@customElement("swg-megacolumn-demo") +export class SwgMegacolumnDemo extends LitElement { + static styles = [ + megacolumnStyles, + css` + :host { + display: block; + } + [data-mc-container] { + border: 1px solid var(--sw-border-dim); + padding: var(--sw-space-md); + } + .title { + color: var(--sw-text-dim); + font-size: var(--sw-type-small); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin-bottom: var(--sw-space-sm); + } + .section { + color: var(--sw-text-faint); + font-size: var(--sw-type-micro); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin: var(--sw-space-xl) 0 var(--sw-space-xs); + } + .section:first-of-type { + margin-top: 0; + } + [data-mc-row] { + display: flex; + align-items: center; + gap: var(--sw-space-sm); + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + } + [data-mc-row].offline { + color: var(--sw-text-faint); + } + .grow { + flex: 1; + } + .count { + color: var(--sw-text-faint); + } + /* The hotkey-marking norm's gold letter, marked by hand on these plain + rows (sw-button renders its own; plain rows have no widget to do it). */ + .hk { + color: var(--sw-hotkey-accent); + } + .meter { + flex: 1; + /* the slider's whole visible affordance: at least the indicator + token tall (the ant rule covers the visible part of a control) */ + height: var(--sw-hit-indicator); + border: 1px solid var(--sw-border-dim); + position: relative; + } + .meter .fill { + position: absolute; + inset: 0 auto 0 0; + background: var(--sw-accent); + } + .pct { + color: var(--sw-text-hi); + width: 4ch; + text-align: right; + } + [data-mc-aux] { + color: var(--sw-danger); + border: 1px solid transparent; + min-width: var(--sw-hit-min-w); + min-height: var(--sw-hit-min-h); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + input[data-mc-input] { + flex: 1; + background: var(--sw-bg-well); + border: 1px solid var(--sw-border-dim); + color: var(--sw-text-bright); + font: inherit; + min-height: var(--sw-hit-min-h); + box-sizing: border-box; + padding: 0 var(--sw-space-sm); + } + .mc-text-mode input[data-mc-input] { + border-color: var(--sw-accent); + } + .log { + margin-top: var(--sw-space-md); + color: var(--sw-good); + font-size: var(--sw-type-ui); + min-height: 1.4em; + } + `, + ]; + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + + @property() label = "megacolumn"; + // The churn demo only runs when asked: two identical always-churning columns + // side by side would be visual noise. + @property({ type: Boolean }) churn = false; + + @state() private beacons = 0; + @state() private power = 40; + @state() private cargo: CargoItem[] = [ + { id: "crate-ore", label: "ore crate" }, + { id: "crate-struts", label: "strut bundle" }, + { id: "crate-cells", label: "fuel cells" }, + ]; + @state() private tasks: TaskItem[] = []; + @state() private lastAction = ""; + private taskSeq = 0; + private timer: number | undefined; + + connectedCallback(): void { + super.connectedCallback(); + if (this.churn) { + this.tasks = [this.newTask(), this.newTask(), this.newTask()]; + this.timer = window.setInterval(() => { + this.tasks = [...this.tasks.slice(1), this.newTask()]; + }, 3000); + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.clearInterval(this.timer); + } + + private newTask(): TaskItem { + this.taskSeq += 1; + const chores = ["survey field", "haul ore", "refit dock", "scan anomaly", "plot course"]; + return { + id: `task-${this.taskSeq}`, + label: `${chores[this.taskSeq % chores.length]} #${this.taskSeq}`, + }; + } + + // All forwarded keys arrive here (one listener on the container); the rowId + // names the row, the position says main vs aux. preventDefault = handled. + private onMegacolumnKey(e: Event): void { + const { detail } = e as CustomEvent<MegacolumnKeyDetail>; + const activate = detail.key === "Enter" || detail.key === " "; + // Plain rows have no widget to flash for them: the host acknowledges every + // action it takes -- removals included (playtest verdict: a disappearance + // alone is too quiet to confirm the keystroke landed). + // The forwarded event's target IS the row's control element (its main, or + // the row itself when it has no main) -- inside the row the ring outlines, + // so acknowledgment geometry sits within focus geometry. + const flash = (): void => { + if (e.target instanceof HTMLElement) flashPress(e.target); + }; + + if (detail.rowId === "beacon" && activate) { + this.beacons += 1; + this.lastAction = `beacon cycled (${this.beacons})`; + e.preventDefault(); + flash(); + return; + } + if (detail.rowId === "dock" && activate) { + this.lastAction = "dock requested"; + e.preventDefault(); + flash(); + return; + } + if (detail.rowId === "power") { + // A slider row: no aux control, so left/right forward here. + if (detail.key === "ArrowLeft" || detail.key === "ArrowRight") { + const delta = detail.key === "ArrowRight" ? 5 : -5; + this.power = Math.min(100, Math.max(0, this.power + delta)); + this.lastAction = `reactor power ${this.power}%`; + e.preventDefault(); + } + return; + } + const cargoItem = this.cargo.find((c) => c.id === detail.rowId); + if (cargoItem !== undefined) { + // The two-control norm: x/Delete remove from anywhere in the row (the + // hotkey guess the guidelines reward); Enter/Space only remove from the + // aux control -- on main they inspect (activation must not be destructive). + const remove = + detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); + if (remove) { + this.cargo = this.cargo.filter((c) => c.id !== cargoItem.id); + this.lastAction = `${cargoItem.label} jettisoned`; + e.preventDefault(); + // Removals flash too: the disappearance alone is too quiet to confirm + // the keystroke landed. On the ROW element -- these positionally-keyed + // rows persist while the list shifts up under them, so the flash marks + // the removal site (the control inside re-renders with the next crate). + const rowEl = e.target instanceof Element ? e.target.closest("[data-mc-row]") : null; + if (rowEl instanceof HTMLElement) flashPress(rowEl); + } else if (activate) { + this.lastAction = `${cargoItem.label} inspected`; + e.preventDefault(); + flash(); + } + return; + } + const sys = systemActions.get(detail.rowId); + if (sys !== undefined && activate) { + this.lastAction = sys; + e.preventDefault(); + flash(); + return; + } + const task = this.tasks.find((t) => t.id === detail.rowId); + if (task !== undefined && activate) { + this.lastAction = `${task.label} inspected`; + e.preventDefault(); + flash(); + } + } + + // Send on Enter-keydown, deliberately not @change: both text-mode exits blur + // the input and change fires on blur-with-changed-value, so a change-driven + // send would also fire on Escape (and on any click-away). Enter is the send. + private onQueryKeydown(e: KeyboardEvent): void { + if (e.key !== "Enter") return; + const input = e.currentTarget as HTMLInputElement; + if (input.value !== "") this.lastAction = `hail sent: "${input.value}"`; + } + + render(): TemplateResult { + return html` + <div class="title">${this.label}</div> + <div + data-mc-container + aria-label=${this.label} + @megacolumn-key=${this.onMegacolumnKey} + > + <div class="section">actions</div> + <div data-mc-row="beacon" data-mc-hotkey="b" data-info="cycles the nav beacon (Enter/Space; b jumps here)"> + <span class="grow">cycle <span class="hk">b</span>eacon</span> + <span class="count">${this.beacons === 0 ? "" : `×${this.beacons}`}</span> + </div> + <div data-mc-row="dock" data-mc-hotkey="d" data-info="asks the nearest station for a berth"> + <span class="grow">request <span class="hk">d</span>ocking</span> + </div> + <div data-mc-row="power" data-mc-hotkey="r" data-info="reactor output; left/right arrows adjust in 5% steps"> + <span><span class="hk">r</span>eactor</span> + <span class="meter"><span class="fill" style="width:${this.power}%"></span></span> + <span class="pct">${this.power}%</span> + </div> + + <div class="section">cargo manifest</div> + ${this.cargo.map( + // Cargo rows carry no hotkey markings on purpose: they churn, and a + // letter that vanishes with its row is a broken affordance. Letters + // mark landmarks; lists are for arrows. + (item) => html` + <div data-mc-row=${item.id} data-info="a crate in the hold; Enter inspects it"> + <span class="grow" data-mc-main>${item.label}</span> + <span data-mc-aux title="jettison" data-info="jettisons this crate (x or Delete from anywhere in the row)">×</span> + </div> + `, + )} + + <div class="section">comms</div> + <div data-mc-row="hail" data-mc-hotkey="h"> + <span><span class="hk">h</span>ail</span> + <input data-mc-input type="text" placeholder="enter to type…" @keydown=${this.onQueryKeydown} /> + </div> + <div data-mc-row="encrypted" data-mc-hotkey="e" class="offline" aria-disabled="true" data-info="offline: needs a cipher core the station cannot fabricate yet"> + <span class="grow"><span class="hk">e</span>ncrypted channel (offline)</span> + </div> + + <div class="section">ship systems</div> + <div data-mc-row="survey" data-mc-hotkey="s" data-info="runs a full survey sweep of the sector"> + <span class="grow">run <span class="hk">s</span>urvey sweep</span> + </div> + <div data-mc-row="vent" data-mc-hotkey="v" data-info="vents the heat sinks to space"> + <span class="grow"><span class="hk">v</span>ent heat sinks</span> + </div> + <div data-mc-row="antenna" data-mc-hotkey="a" data-info="re-aims the antenna array at the relay"> + <span class="grow"><span class="hk">a</span>lign antenna array</span> + </div> + <div data-mc-row="gyros" data-mc-hotkey="g" data-info="recalibrates the attitude gyros"> + <span class="grow">recalibrate <span class="hk">g</span>yros</span> + </div> + + ${ + this.churn + ? html` + <div class="section">task queue (live)</div> + ${this.tasks.map( + (t) => html`<div data-mc-row=${t.id}><span class="grow">${t.label}</span></div>`, + )} + ` + : "" + } + </div> + <div class="log">${this.lastAction}</div> + `; + } +} + +// The relay bank: a static column tall enough to overflow a bounded scrollbox. +// It exists to test scroll containment calmly -- the controller scrolls the focused +// row into view on every focus-identity change (block: nearest), and when the +// column lives inside its own overflow-y scroller, that scroll must happen in the +// box, not the page. The live task-queue demo is the only other tall column, and +// its churn makes scroll behavior impossible to observe; these rows never move. +const RELAY_COUNT = 28; +const relayIds = Array.from({ length: RELAY_COUNT }, (_, i) => String(i + 1).padStart(2, "0")); +// Markings at strategic distances (first, ~third, ~two-thirds, last), so one +// keystroke can jump across most of the scroller. Digits, because the flat +// "relay NN" labels contain no distinguishing letters to mark. +const relayHotkeys = new Map<string, string>([ + ["01", "1"], + ["10", "0"], + ["20", "2"], + ["28", "8"], +]); +const relayInfo = new Map<string, string>([ + ["01", "the bank's first relay; Home lands here from anywhere"], + ["10", "a mid-bank relay; the gold 0 jumps here"], + ["14", "an unmarked relay: arrows or a nearby jump reach it"], + ["20", "a deep-bank relay; the gold 2 jumps here"], + ["28", "the last relay; End lands here from anywhere"], +]); + +// A relay's label with its marking character rendered gold in place (the +// hotkey-marking norm, hand-marked as on the plain rows above). +function relayLabel(nn: string): TemplateResult { + const label = `relay ${nn}`; + const hk = relayHotkeys.get(nn); + if (hk === undefined) return html`${label}`; + const at = label.indexOf(hk); + return html`${label.slice(0, at)}<span class="hk">${hk}</span>${label.slice(at + 1)}`; +} + +@customElement("swg-megacolumn-scroll-demo") +export class SwgMegacolumnScrollDemo extends LitElement { + static styles = [ + megacolumnStyles, + css` + :host { + display: block; + } + .title { + color: var(--sw-text-dim); + font-size: var(--sw-type-small); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin-bottom: var(--sw-space-sm); + } + /* The bounded scrollport under test: the container IS the scroller, + so the focused element's own box answers native scrolling keys too. + ~7 rows visible of 28; the border is deliberately the full-strength + token so scroll containment is visually obvious (rows must move + inside a box that does not). */ + [data-mc-container] { + height: 240px; + overflow-y: auto; + border: 1px solid var(--sw-border); + } + [data-mc-row] { + display: flex; + align-items: center; + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + } + .hk { + color: var(--sw-hotkey-accent); + } + .log { + margin-top: var(--sw-space-md); + color: var(--sw-good); + font-size: var(--sw-type-ui); + min-height: 1.4em; + } + `, + ]; + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + + @state() private lastAction = ""; + + private onMegacolumnKey(e: Event): void { + const { detail } = e as CustomEvent<MegacolumnKeyDetail>; + if (detail.key !== "Enter" && detail.key !== " ") return; + this.lastAction = `${detail.rowId.replace("-", " ")} pinged`; + e.preventDefault(); + if (e.target instanceof HTMLElement) flashPress(e.target); + } + + render(): TemplateResult { + return html` + <div class="title">relay bank</div> + <div + data-mc-container + aria-label="relay bank" + @megacolumn-key=${this.onMegacolumnKey} + > + ${relayIds.map( + (nn) => html` + <div + data-mc-row=${`relay-${nn}`} + data-mc-hotkey=${ifDefined(relayHotkeys.get(nn))} + data-info=${ifDefined(relayInfo.get(nn))} + > + <span>${relayLabel(nn)}</span> + </div> + `, + )} + </div> + <div class="log">${this.lastAction}</div> + `; + } +} + +const entry: GalleryEntry = { + id: "megacolumn", + title: "megacolumn focus model", + description: + "The keyboard navigation machinery every megacolumn screen builds on: " + + "one real focusable per column, row focus as logical state (MegacolumnNav), " + + "rendered via aria-activedescendant and the focus-ring tokens (MegacolumnController). " + + "This demo is the reference implementation of the data-mc-* DOM contract.", + variants: [ + { + caption: + "Two independent columns: Tab moves between them (each column is ONE tab stop); " + + "up/down move the row focus inside, jumping the section gaps. " + + "The gold letters are hotkey jumps: b/d/r/h/e/s/v/a/g move focus straight to their row " + + "(from anywhere in the column), then Enter/Space commits -- try g from the top, or b from the bottom. " + + "Also try: Enter/Space on the action rows; left/right on the reactor slider; " + + "right-arrow onto a crate's × then Enter (or just x/Delete from anywhere in the row -- " + + "crates churn, so they get no letters: letters mark landmarks, arrows work lists); " + + "Enter on the hail row to type -- every key goes to the input, hotkeys included -- then Enter sends, Escape abandons; " + + "Home/End for the column edges. Tab away and back: the row focus is where you left it. " + + "Rows carry data-info, so i (or a lingering hover) explains any of them -- " + + "including the offline row, which stays a nav stop and a jump target (e lands there; " + + "i is how you ask why it's off) even though activation is dead there.", + wide: true, + render: () => html` + <div style="display:flex; gap:24px; align-items:flex-start;"> + <swg-megacolumn-demo style="flex:1" label="port console"></swg-megacolumn-demo> + <swg-megacolumn-demo style="flex:1" label="starboard console"></swg-megacolumn-demo> + </div> + `, + }, + { + caption: + "Focus memory under churn: the task queue loses its head and grows a tail every few seconds. " + + "Focus a task row and watch focus fall to the nearest surviving neighbor when the row vanishes; " + + "the rest of the column is unbothered.", + render: () => html`<swg-megacolumn-demo label="ops console" churn></swg-megacolumn-demo>`, + }, + { + caption: + "Scroll containment: 28 static relays in a 240px box -- the thing to WATCH is that the page never scrolls, only the box. " + + "Home/End slam between the edges; arrows creep across the boundary rows and the box gives way one row at a time " + + "(nearest-edge scrolling: no centering, no glide); " + + "the gold digits jump -- 1/0/2/8 land on relays 01/10/20/28, crossing most of the scroller in one keystroke, " + + "the ring arriving in view every time. " + + "Enter pings a relay; a few rows carry data-info, so i works mid-scroll.", + render: () => html`<swg-megacolumn-scroll-demo></swg-megacolumn-scroll-demo>`, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/press-flash.test.ts b/packages/yukey/src/press-flash.test.ts new file mode 100644 index 0000000..14d9e94 --- /dev/null +++ b/packages/yukey/src/press-flash.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +// +// Tests for the shared activation-flash helper (press-flash.ts). The full +// consumer behavior (pointer-held state, disabled suppression, re-arm) is +// exercised through sw-button in sw-button.test.ts; this file pins the helper's +// own contract: cascade-resolved duration and the fail-closed resolution error. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flashPress, pressMs } from "./press-flash.js"; +import { installTokens, tokenGroups } from "./tokens.js"; + +installTokens(); + +const PRESS_MS = ((): number => { + const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--sw-press-ms"); + if (t === undefined) throw new Error("--sw-press-ms token missing"); + return Number.parseFloat(t.value); +})(); + +describe("press-flash", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it("flashes any element for the token's duration", () => { + const el = document.createElement("div"); + document.body.append(el); + flashPress(el); + expect(el.classList.contains("sw-pressed")).toBe(true); + vi.advanceTimersByTime(PRESS_MS); + expect(el.classList.contains("sw-pressed")).toBe(false); + }); + + it("resolves seconds-denominated overrides too", () => { + const el = document.createElement("div"); + el.style.setProperty("--sw-press-ms", "0.3s"); + document.body.append(el); + expect(pressMs(el)).toBe(300); + }); + + it("fails loudly when the duration cannot resolve", () => { + const detached = document.createElement("div"); // no cascade to read from + expect(() => pressMs(detached)).toThrow(/--sw-press-ms did not resolve/); + }); +}); diff --git a/packages/yukey/src/press-flash.ts b/packages/yukey/src/press-flash.ts new file mode 100644 index 0000000..08e1fd8 --- /dev/null +++ b/packages/yukey/src/press-flash.ts @@ -0,0 +1,47 @@ +// Activation feedback, shared across widgets: the "sw-pressed" flash class that +// acknowledges a successful activation for --sw-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: +// - sw-button flashes its host element (and manages its own pointer-held +// "sw-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 .sw-pressed +// rendering lives in megacolumnStyles, beside the focus washes it must outrank. + +export const PRESSED_CLASS = "sw-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. +export function pressMs(el: Element): number { + const raw = getComputedStyle(el).getPropertyValue("--sw-press-ms").trim(); + const m = /^(\d+(?:\.\d+)?)(m?s)$/.exec(raw); + if (m === null) + throw new Error( + `press-flash: --sw-press-ms did not resolve on <${el.tagName.toLowerCase()}> ` + + `(got "${raw}"); are the design tokens installed?`, + ); + const n = Number(m[1]); + return m[2] === "s" ? n * 1000 : n; +} + +const timers = new WeakMap<HTMLElement, number>(); + +// Show the pressed look on el for --sw-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)); + timers.set( + el, + window.setTimeout(() => { + el.classList.remove(PRESSED_CLASS); + timers.delete(el); + }, pressMs(el)), + ); +} diff --git a/packages/yukey/src/sw-button.gallery.ts b/packages/yukey/src/sw-button.gallery.ts new file mode 100644 index 0000000..d03fd7a --- /dev/null +++ b/packages/yukey/src/sw-button.gallery.ts @@ -0,0 +1,277 @@ +// Gallery entry for sw-button: every marking form, both contexts, the disabled +// and info behaviors, and a two-column demo proving that hotkey scope is the +// focused column. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import { + MegacolumnController, + type MegacolumnKeyDetail, + megacolumnStyles, +} from "./megacolumn-controller.js"; +import "./sw-button.js"; +import { flashPress } from "./press-flash.js"; +import type { SwInfoDetail } from "./sw-info.js"; + +// Shared demo chrome: a title, a log line that shows what fired. +const demoStyles = css` + :host { + display: block; + } + .title { + color: var(--sw-text-dim); + font-size: var(--sw-type-small); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin-bottom: var(--sw-space-sm); + } + .log { + margin-top: var(--sw-space-md); + color: var(--sw-good); + font-size: var(--sw-type-ui); + min-height: 1.4em; + } + .log.info { + color: var(--sw-hotkey-accent); + } +`; + +// Standalone buttons: native tab stops, all marking forms side by side. +@customElement("swg-button-standalone") +export class SwgButtonStandalone extends LitElement { + static styles = [ + demoStyles, + css` + .row { + display: flex; + gap: var(--sw-space-lg); + align-items: center; + flex-wrap: wrap; + } + `, + ]; + + @state() private last = ""; + @state() private lastWasInfo = false; + + private act(what: string): void { + this.last = what; + this.lastWasInfo = false; + } + + private onInfo(e: Event): void { + const { content } = (e as CustomEvent<SwInfoDetail>).detail; + this.last = `info: ${content}`; + this.lastWasInfo = true; + } + + render(): TemplateResult { + return html` + <div class="row" @sw-info=${this.onInfo}> + <sw-button + label="Dock at station" + hotkey="d" + info="requests docking clearance at the selected station" + @click=${() => this.act("docked")} + ></sw-button> + <sw-button + label="units" + hotkey="7" + info="opens the units registry" + @click=${() => this.act("units opened")} + ></sw-button> + <sw-button + label="transfer" + hotkey="t" + icon="☰" + info="complex: several transfer modes live behind this one" + @click=${() => this.act("transfer menu")} + ></sw-button> + <sw-button + label="cruise" + hotkey="c" + info="the in-effect option: active but still clickable (re-issuing is the owner's call)" + active + @click=${() => this.act("cruise re-issued (harmless)")} + ></sw-button> + <sw-button + label="self destruct" + hotkey="s" + info="offline: requires command authorization" + disabled + @click=${() => this.act("BOOM (should never happen)")} + ></sw-button> + </div> + <div class="log ${this.lastWasInfo ? "info" : ""}">${this.last}</div> + `; + } +} + +// A megacolumn of sw-buttons: rows are the buttons, plus a two-control row +// composing an sw-button into the aux (small-x) slot. +@customElement("swg-button-column") +export class SwgButtonColumn extends LitElement { + static styles = [ + megacolumnStyles, + demoStyles, + css` + [data-mc-container] { + border: 1px solid var(--sw-border-dim); + padding: var(--sw-space-md); + } + [data-mc-row] { + display: flex; + align-items: center; + gap: var(--sw-space-sm); + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + } + [data-mc-row] sw-button:first-child, + [data-mc-row] > .grow { + flex: 1; + } + `, + ]; + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + + @property() label = "console"; + // Hotkeys are per-instance so the scoping demo can give two columns + // overlapping letters without tripping the per-column duplicate throw. + @property() dockKey = "d"; + @property() beaconKey = "b"; + + @state() private cargo = ["ore crate", "strut bundle"]; + @state() private last = ""; + @state() private lastWasInfo = false; + + private act(what: string): void { + this.last = what; + this.lastWasInfo = false; + } + + private onInfo(e: Event): void { + const { content } = (e as CustomEvent<SwInfoDetail>).detail; + this.last = `info: ${content}`; + this.lastWasInfo = true; + } + + private onMegacolumnKey(e: Event): void { + // The buttons claim their own keys; this listener only backs the rows whose + // MAIN control is plain text (the cargo rows: Enter inspects). Acting on a + // plain row includes acknowledging it: the flash a widget would give itself. + const { key, rowId, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail; + const item = this.cargo.find((c) => c === rowId); + if (item !== undefined && (key === "Enter" || key === " ") && position === "main") { + this.act(`${item} inspected`); + e.preventDefault(); + const row = (e.target as Element).closest<HTMLElement>("[data-mc-row]"); + if (row !== null) flashPress(row); + } + } + + render(): TemplateResult { + return html` + <div class="title">${this.label}</div> + <div + data-mc-container + aria-label=${this.label} + @megacolumn-key=${this.onMegacolumnKey} + @sw-info=${this.onInfo} + > + <div data-mc-row="dock"> + <sw-button + label="Dock at station" + hotkey=${this.dockKey} + info="requests docking clearance" + @click=${() => this.act("docked")} + ></sw-button> + </div> + <div data-mc-row="beacon"> + <sw-button + label="cycle beacon" + hotkey=${this.beaconKey} + info="cycles the nav beacon" + @click=${() => this.act("beacon cycled")} + ></sw-button> + </div> + <div data-mc-row="purge"> + <sw-button + label="purge filters" + info="offline: filters already clean" + disabled + @click=${() => this.act("purged (should never happen)")} + ></sw-button> + </div> + ${this.cargo.map( + (item) => html` + <div data-mc-row=${item}> + <span class="grow" data-mc-main>${item}</span> + <span data-mc-aux> + <sw-button + label="×" + info="jettisons this crate (x or Delete from anywhere in the row)" + @click=${() => { + this.cargo = this.cargo.filter((c) => c !== item); + this.act(`${item} jettisoned`); + }} + ></sw-button> + </span> + </div> + `, + )} + </div> + <div class="log ${this.lastWasInfo ? "info" : ""}">${this.last}</div> + `; + } +} + +const entry: GalleryEntry = { + id: "sw-button", + title: "sw-button", + description: + "The button that makes the keyboard norms structural: hotkey marking " + + "(letter-in-label in --sw-hotkey-accent, else the 70%-offset slot, an icon for " + + "complex buttons), the i info hook (sw-info events, rendered by the shell's " + + "sw-infopop -- press i on anything here and watch), token hit targets, and " + + "dual-context behavior -- a native focusable standalone, a megacolumn-key " + + "consumer inside rows.", + variants: [ + { + caption: + "Standalone: real tab stops with native Enter/Space. All marking forms: " + + "letter-in-label (Dock's D), the 70% slot (units' 7), the complex-button icon " + + "(transfer), the active in-effect option (cruise: washed, aria-current, still " + + "clickable), and disabled (dimmed, unclickable, but i still answers while focused). " + + "A button's hotkey and i work while it holds focus; watch the log.", + render: () => html`<swg-button-standalone></swg-button-standalone>`, + }, + { + caption: + "Inside a megacolumn: one tab stop for the whole column, buttons as rows. " + + "The marked letters are jumps: d or b from any row moves focus to that button " + + "(no arrowing needed), and Enter/Space then activates -- you see where you are " + + "before committing. Try: d, then Enter; i for info on the focused control; " + + "on a crate row, x or Delete jettisons via the aux ×, or right-arrow onto it and Enter. " + + "The disabled row still focuses and answers i, but nothing activates it.", + render: () => html`<swg-button-column label="ops console"></swg-button-column>`, + }, + { + caption: + "Hotkey scope IS the focused column: both columns mark d for dock, and d jumps only " + + "in the column that holds focus. Tab between them and try d then Enter in each; " + + "the other column never hears it.", + wide: true, + render: () => html` + <div style="display:flex; gap:24px; align-items:flex-start;"> + <swg-button-column style="flex:1" label="port console" dockKey="d" beaconKey="b"></swg-button-column> + <swg-button-column style="flex:1" label="starboard console" dockKey="d" beaconKey="c"></swg-button-column> + </div> + `, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/sw-button.test.ts b/packages/yukey/src/sw-button.test.ts new file mode 100644 index 0000000..241509d --- /dev/null +++ b/packages/yukey/src/sw-button.test.ts @@ -0,0 +1,731 @@ +// @vitest-environment jsdom +// +// Tests for sw-button (sw-button.ts): the marking logic, standalone activation, +// and row-mode claims driven through a real MegacolumnController rig -- the same +// fake-host pattern as megacolumn-controller.test.ts, but with live Lit rendering +// (sw-button's marking must be visible to the controller's scan synchronously). + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MegacolumnController } from "./megacolumn-controller.js"; +import type { SwButton } from "./sw-button.js"; +import "./sw-button.js"; +import type { SwInfoDetail } from "./sw-info.js"; +import { installTokens, tokenGroups } from "./tokens.js"; + +// jsdom implements no scrollIntoView; the controller scrolls the focused row +// into view on every focus identity change. A no-op stands in. +Element.prototype.scrollIntoView = (): void => {}; + +// The activation-feedback tests read the press duration from the cascade, the +// same way the entry points provide it: tokens installed at :root. +installTokens(); + +// The token's defined default, parsed here rather than imported from +// press-flash.ts: the tests must not derive expectations from the code under test. +const PRESS_MS = ((): number => { + const t = tokenGroups.flatMap((g) => g.tokens).find((t) => t.name === "--sw-press-ms"); + if (t === undefined) throw new Error("--sw-press-ms token missing"); + return Number.parseFloat(t.value); +})(); + +function makeButton(attrs: Partial<SwButton>): SwButton { + const el = document.createElement("sw-button"); + Object.assign(el, attrs); + return el; +} + +async function standalone(attrs: Partial<SwButton>): Promise<SwButton> { + const el = makeButton(attrs); + document.body.replaceChildren(el); + await el.updateComplete; + return el; +} + +function inner(el: SwButton): HTMLElement { + const btn = el.shadowRoot?.querySelector<HTMLElement>(".btn"); + if (!btn) throw new Error("sw-button rendered no .btn"); + return btn; +} + +describe("hotkey marking", () => { + it("marks the first case-insensitive occurrence of the hotkey in the label", async () => { + const el = await standalone({ label: "Dock at station", hotkey: "d" }); + const hk = inner(el).querySelector(".hk"); + expect(hk?.textContent).toBe("D"); // the label's own case is preserved + expect(inner(el).textContent).toBe("Dock at station"); + expect(inner(el).querySelector(".slot")).toBeNull(); + }); + + it("marks only the first occurrence when the letter repeats", async () => { + const el = await standalone({ label: "add radar", hotkey: "a" }); + expect(inner(el).querySelectorAll(".hk").length).toBe(1); + expect(inner(el).textContent).toBe("add radar"); + }); + + it("prefers letter-in-label over the slot when the letter occurs", async () => { + // The tokens-stage mockup showed "units" with a slotted 'u' -- but 'u' occurs + // in "units", so per the norm this is a letter-in-label case, not a slot case. + const el = await standalone({ label: "units", hotkey: "u" }); + expect(inner(el).querySelector(".hk")?.textContent).toBe("u"); + expect(inner(el).querySelector(".slot")).toBeNull(); + }); + + it("falls back to the slot for a truly absent letter", async () => { + const el = await standalone({ label: "engage", hotkey: "7" }); + expect(inner(el).querySelector(".hk")).toBeNull(); + expect(inner(el).querySelector(".slot")?.textContent).toBe("7"); + }); + + it("an icon claims the slot and suppresses letter-in-label marking", async () => { + const el = await standalone({ label: "dock", hotkey: "d", icon: "☰" }); + expect(inner(el).querySelector(".hk")).toBeNull(); + expect(inner(el).querySelector(".slot")?.textContent).toBe("☰"); + }); + + it("no hotkey, no marking, no slot", async () => { + const el = await standalone({ label: "plain" }); + expect(inner(el).querySelector(".hk")).toBeNull(); + expect(inner(el).querySelector(".slot")).toBeNull(); + }); + + it("mirrors info onto data-info synchronously (the hover-trigger carrier contract)", () => { + // sw-infopop's hover trigger finds carriers by [data-info]; the mirror makes + // sw-buttons carriers without the popup knowing their class. + const el = makeButton({ info: "docking clearance" }); + expect(el.dataset.info).toBe("docking clearance"); // before any render or connect + el.info = ""; + expect("info" in el.dataset).toBe(false); + }); + + it("mirrors the hotkey onto data-mc-hotkey synchronously, normalized", () => { + const el = makeButton({ hotkey: "D" }); + expect(el.dataset.mcHotkey).toBe("d"); // before any render or connect + el.hotkey = ""; + expect("mcHotkey" in el.dataset).toBe(false); + }); + + it("mirrors hotkeyDirect onto data-mc-hotkey-direct synchronously", () => { + const el = makeButton({ hotkey: "d", hotkeyDirect: true }); + expect("mcHotkeyDirect" in el.dataset).toBe(true); // before any render or connect + el.hotkeyDirect = false; + expect("mcHotkeyDirect" in el.dataset).toBe(false); + }); + + it("re-renders the marking when the hotkey changes after first render", async () => { + const el = await standalone({ label: "dock", hotkey: "d" }); + expect(inner(el).querySelector(".hk")?.textContent).toBe("d"); + el.hotkey = "o"; + await el.updateComplete; + expect(inner(el).querySelector(".hk")?.textContent).toBe("o"); + }); + + it("rejects malformed hotkeys", () => { + expect(() => makeButton({ hotkey: "dd" })).toThrow(/single \[a-z0-9\]/); + expect(() => makeButton({ hotkey: "!" })).toThrow(/single \[a-z0-9\]/); + }); + + it("x and i are legal markings (a guideline, not a rule: the row norms claim first)", () => { + expect(makeButton({ hotkey: "x" }).dataset.mcHotkey).toBe("x"); + expect(makeButton({ hotkey: "I" }).dataset.mcHotkey).toBe("i"); + }); + + it("sets aria-keyshortcuts when a hotkey is marked", async () => { + const el = await standalone({ label: "dock", hotkey: "d" }); + expect(inner(el).getAttribute("aria-keyshortcuts")).toBe("d"); + }); +}); + +describe("standalone context", () => { + it("renders a real native button: a tab stop with native click activation", async () => { + const el = await standalone({ label: "go" }); + const btn = inner(el); + expect(btn.tagName).toBe("BUTTON"); + let clicks = 0; + el.addEventListener("click", () => { + clicks += 1; + }); + btn.click(); // shadow retargeting presents this as the host's click + expect(clicks).toBe(1); + }); + + it("its hotkey activates while the button holds focus", async () => { + const el = await standalone({ label: "dock", hotkey: "d" }); + let clicks = 0; + el.addEventListener("click", () => { + clicks += 1; + }); + const e = new KeyboardEvent("keydown", { + key: "d", + bubbles: true, + composed: true, + cancelable: true, + }); + inner(el).dispatchEvent(e); + expect(clicks).toBe(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("modifier chords never trigger the hotkey", async () => { + const el = await standalone({ label: "dock", hotkey: "d" }); + let clicks = 0; + el.addEventListener("click", () => { + clicks += 1; + }); + inner(el).dispatchEvent( + new KeyboardEvent("keydown", { + key: "d", + ctrlKey: true, + bubbles: true, + composed: true, + cancelable: true, + }), + ); + expect(clicks).toBe(0); + }); + + it("i dispatches sw-info with the content and the anchor element", async () => { + const el = await standalone({ label: "dock", info: "requests docking clearance" }); + const got: SwInfoDetail[] = []; + document.body.addEventListener("sw-info", (e) => { + got.push((e as CustomEvent<SwInfoDetail>).detail); + }); + inner(el).dispatchEvent( + new KeyboardEvent("keydown", { key: "i", bubbles: true, composed: true, cancelable: true }), + ); + expect(got).toEqual([{ content: "requests docking clearance", source: el }]); + }); + + it("i without info does nothing", async () => { + const el = await standalone({ label: "dock" }); + let infos = 0; + document.body.addEventListener("sw-info", () => { + infos += 1; + }); + const e = new KeyboardEvent("keydown", { + key: "i", + bubbles: true, + composed: true, + cancelable: true, + }); + inner(el).dispatchEvent(e); + expect(infos).toBe(0); + expect(e.defaultPrevented).toBe(false); + }); +}); + +// Activation feedback: the held look under a pointer, and the flash that +// acknowledges every successful activation for --sw-press-ms (press-flash.ts). +// The classes land on the HOST element: jsdom computes custom properties on +// light-DOM elements but not through shadow boundaries, and the host is also +// where authors can observe the state from outside. +describe("activation feedback", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const pointer = (type: string, init: PointerEventInit = {}): PointerEvent => + new PointerEvent(type, { bubbles: true, composed: true, button: 0, ...init }); + + it("flashes on activation and clears after --sw-press-ms", async () => { + const el = await standalone({ label: "go" }); + el.click(); + expect(el.classList.contains("sw-pressed")).toBe(true); + vi.advanceTimersByTime(PRESS_MS - 1); + expect(el.classList.contains("sw-pressed")).toBe(true); + vi.advanceTimersByTime(1); + expect(el.classList.contains("sw-pressed")).toBe(false); + }); + + it("re-arms the flash on rapid re-activation (no early cutoff)", async () => { + const el = await standalone({ label: "go" }); + el.click(); + vi.advanceTimersByTime(PRESS_MS / 2); + el.click(); // second activation, half way through the first flash + vi.advanceTimersByTime(PRESS_MS / 2); // the first flash's timer would have fired here + expect(el.classList.contains("sw-pressed")).toBe(true); + vi.advanceTimersByTime(PRESS_MS / 2); + expect(el.classList.contains("sw-pressed")).toBe(false); + }); + + it("reads the flash duration from the live cascade (the accessibility knob)", async () => { + const el = await standalone({ label: "go" }); + el.style.setProperty("--sw-press-ms", "50ms"); + el.click(); + vi.advanceTimersByTime(49); + expect(el.classList.contains("sw-pressed")).toBe(true); + vi.advanceTimersByTime(1); + expect(el.classList.contains("sw-pressed")).toBe(false); + }); + + it("holds while the pointer is down, releases on pointerup", async () => { + const el = await standalone({ label: "go" }); + inner(el).dispatchEvent(pointer("pointerdown")); + expect(el.classList.contains("sw-held")).toBe(true); + inner(el).dispatchEvent(pointer("pointerup")); + expect(el.classList.contains("sw-held")).toBe(false); + }); + + it("releases the hold when the pointer leaves or is cancelled", async () => { + const el = await standalone({ label: "go" }); + inner(el).dispatchEvent(pointer("pointerdown")); + el.dispatchEvent(new PointerEvent("pointerleave")); // leave does not bubble; it fires on the host itself + expect(el.classList.contains("sw-held")).toBe(false); + inner(el).dispatchEvent(pointer("pointerdown")); + inner(el).dispatchEvent(pointer("pointercancel")); + expect(el.classList.contains("sw-held")).toBe(false); + }); + + it("only the primary button holds", async () => { + const el = await standalone({ label: "go" }); + inner(el).dispatchEvent(pointer("pointerdown", { button: 2 })); + expect(el.classList.contains("sw-held")).toBe(false); + }); + + it("disabled: neither holds nor flashes (nothing happened; nothing must claim otherwise)", async () => { + const el = await standalone({ label: "go", disabled: true }); + inner(el).dispatchEvent(pointer("pointerdown")); + expect(el.classList.contains("sw-held")).toBe(false); + inner(el).click(); // dies in the shadow-root suppressor + el.click(); // the direct host path the suppressor never sees + expect(el.classList.contains("sw-pressed")).toBe(false); + }); +}); + +describe("active state", () => { + it("renders the active look and claims aria-current (still clickable)", async () => { + const el = await standalone({ label: "hold", active: true }); + const btn = inner(el); + expect(btn.classList.contains("active")).toBe(true); + expect(btn.getAttribute("aria-current")).toBe("true"); + let clicks = 0; + el.addEventListener("click", () => { + clicks += 1; + }); + inner(el).click(); + expect(clicks).toBe(1); + }); + + it("inactive: no active class, no aria-current claim", async () => { + const el = await standalone({ label: "hold" }); + const btn = inner(el); + expect(btn.classList.contains("active")).toBe(false); + expect(btn.hasAttribute("aria-current")).toBe(false); + }); + + it("active is a live property: flips with the data", async () => { + const el = await standalone({ label: "hold", active: true }); + el.active = false; + await el.updateComplete; + expect(inner(el).classList.contains("active")).toBe(false); + expect(inner(el).hasAttribute("aria-current")).toBe(false); + }); +}); + +describe("disabled state", () => { + it("is aria-disabled and dimmed, never natively disabled (stays perceivable)", async () => { + const el = await standalone({ label: "go", disabled: true }); + const btn = inner(el); + expect(btn.getAttribute("aria-disabled")).toBe("true"); + expect(btn.hasAttribute("disabled")).toBe(false); + expect(btn.classList.contains("disabled")).toBe(true); + }); + + it("suppresses clicks before host listeners see them", async () => { + const el = await standalone({ label: "go", disabled: true }); + let clicks = 0; + el.addEventListener("click", () => { + clicks += 1; + }); + inner(el).click(); + expect(clicks).toBe(0); + }); + + it("suppresses the hotkey but still answers i (disabled things stay discoverable)", async () => { + const el = await standalone({ + label: "dock", + hotkey: "d", + info: "offline: no dock in range", + disabled: true, + }); + let clicks = 0; + const infos: string[] = []; + el.addEventListener("click", () => { + clicks += 1; + }); + document.body.addEventListener("sw-info", (e) => { + infos.push((e as CustomEvent<SwInfoDetail>).detail.content); + }); + inner(el).dispatchEvent( + new KeyboardEvent("keydown", { key: "d", bubbles: true, composed: true, cancelable: true }), + ); + inner(el).dispatchEvent( + new KeyboardEvent("keydown", { key: "i", bubbles: true, composed: true, cancelable: true }), + ); + expect(clicks).toBe(0); + expect(infos).toEqual(["offline: no dock in range"]); + }); +}); + +// Row mode, driven through a real controller: the same rig pattern as +// megacolumn-controller.test.ts, except rows carry live sw-button elements. +describe("inside a megacolumn row", () => { + interface Rig { + container: HTMLElement; + ctl: MegacolumnController; + key: (key: string) => KeyboardEvent; + button: (sel: string) => SwButton; + } + + async function makeRig(innerHtml: string): Promise<Rig> { + const root = document.createElement("div"); + document.body.replaceChildren(root); + root.innerHTML = `<div data-mc-container>${innerHtml}</div>`; + const container = root.querySelector<HTMLElement>("[data-mc-container]"); + if (container === null) throw new Error("unreachable: just rendered"); + // Let the sw-buttons complete their first render before the scan. + await Promise.all([...root.querySelectorAll("sw-button")].map((b) => b.updateComplete)); + const ctl = new MegacolumnController({ + renderRoot: root, + addController(): void {}, + removeController(): void {}, + requestUpdate(): void {}, + updateComplete: Promise.resolve(true), + }); + ctl.hostUpdated(); + const key = (k: string): KeyboardEvent => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + container.dispatchEvent(e); + return e; + }; + const button = (sel: string): SwButton => { + const b = root.querySelector<SwButton>(sel); + if (b === null) throw new Error(`no sw-button at ${sel}`); + return b; + }; + return { container, ctl, key, button }; + } + + it("renders non-focusable markup: the controller's tab-stop scan accepts it", async () => { + const { button } = await makeRig(` + <div data-mc-row="a"><sw-button label="alpha"></sw-button></div> + `); + expect(inner(button("sw-button")).tagName).toBe("DIV"); // no native button in row mode + }); + + it("throws when loose in a container outside any row", async () => { + // The throw happens in connectedCallback; custom-element reaction exceptions + // are reported to the window, not propagated to the DOM call that parsed the + // element in -- so observe the report. Loud is the point: this misuse must + // not pass silently just because nothing upstream can catch it. + const reported: string[] = []; + const onError = (e: ErrorEvent): void => { + reported.push(e.message); + e.preventDefault(); + }; + window.addEventListener("error", onError); + const root = document.createElement("div"); + document.body.replaceChildren(root); + root.innerHTML = `<div data-mc-container><sw-button label="stray"></sw-button></div>`; + window.removeEventListener("error", onError); + expect(reported.some((m) => /outside any row/.test(m))).toBe(true); + }); + + it("Enter and Space at its position activate as a click", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="alpha"></sw-button></div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + expect(key("Enter").defaultPrevented).toBe(true); + expect(key(" ").defaultPrevented).toBe(true); + expect(clicks).toBe(2); + }); + + it("a mouse click activates exactly once (the pointer-activation dedup)", async () => { + // The controller synthesizes a megacolumn-key Enter from unclaimed clicks on + // row controls; sw-button activates on its own native click and CLAIMS it + // (preventDefault, the same idiom as claiming a forwarded key), so the + // synthesis skips it and the author's @click fires once, not twice. + const { button } = await makeRig(` + <div data-mc-row="a"><sw-button label="alpha"></sw-button></div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true }); + inner(button("sw-button")).dispatchEvent(e); + expect(clicks).toBe(1); + expect(e.defaultPrevented).toBe(true); // claimed: the controller must not synthesize + }); + + it("standalone clicks stay unclaimed (the dedup mark is row-mode only)", async () => { + const el = await standalone({ label: "go" }); + const e = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true }); + inner(el).dispatchEvent(e); + expect(e.defaultPrevented).toBe(false); // no controller listening; native semantics stay native + }); + + it("keyboard Enter round-trips once: one click, one megacolumn-key (no synthesis echo)", async () => { + // The dedup's other direction: #claimActivation answers a forwarded Enter + // with this.click(), and THAT click bubbles past the controller too. The + // row-mode claim marks it, so the controller must not synthesize a second + // Enter from the keyboard's own acknowledgment click. + const { container, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="alpha"></sw-button></div> + `); + let clicks = 0; + let mcKeys = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + container.parentElement?.addEventListener("megacolumn-key", () => { + mcKeys += 1; + }); + container.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + expect(clicks).toBe(1); + expect(mcKeys).toBe(1); + }); + + it("its hotkey from another focused row moves focus here; Enter then activates", async () => { + // The see-before-commit rhythm: the letter is a jump, Enter is the commit. + const { ctl, key, button } = await makeRig(` + <div data-mc-row="a">plain row</div> + <div data-mc-row="dock"><sw-button label="dock" hotkey="d"></sw-button></div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + const e = key("d"); // focus is on row a + expect(ctl.nav.focusedId).toBe("dock"); + expect(clicks).toBe(0); // a jump, not an activation + expect(e.defaultPrevented).toBe(true); + key("Enter"); + expect(clicks).toBe(1); + }); + + it("its own hotkey while focused does nothing (Enter/Space are the activation)", async () => { + // Double-tap must not out-power single-tap: the letter only ever jumps. + const { ctl, key, button } = await makeRig(` + <div data-mc-row="dock"><sw-button label="dock" hotkey="d"></sw-button></div> + <div data-mc-row="b">plain row</div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + const e = key("d"); // focus starts on the dock row itself + expect(clicks).toBe(0); + expect(ctl.nav.focusedId).toBe("dock"); + expect(e.defaultPrevented).toBe(true); // consumed: the column advertises this key + }); + + it("hotkey-direct: the letter activates immediately, with focus moved to this row", async () => { + const { ctl, key, button } = await makeRig(` + <div data-mc-row="a">plain row</div> + <div data-mc-row="dock"><sw-button label="dock" hotkey="d" hotkey-direct></sw-button></div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + const e = key("d"); // focus is on row a + expect(clicks).toBe(1); + expect(ctl.nav.focusedId).toBe("dock"); // the acted row is the focused row: Enter repeats + expect(e.defaultPrevented).toBe(true); + key("Enter"); + expect(clicks).toBe(2); + }); + + it("hotkey-direct: its own hotkey while focused activates too (direct means direct)", async () => { + // A direct letter has the same power from every row, its own included: + // there is no jump for it to mean instead. + const { key, button } = await makeRig(` + <div data-mc-row="dock"><sw-button label="dock" hotkey="d" hotkey-direct></sw-button></div> + <div data-mc-row="b">plain row</div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + expect(key("d").defaultPrevented).toBe(true); // focus starts on the dock row + expect(clicks).toBe(1); + }); + + it("an x-marked button yields to the focused row's aux norm (dispatch order, not a rule)", async () => { + const { ctl, key, button } = await makeRig(` + <div data-mc-row="crate"> + <span data-mc-main>ore crate</span> + <span data-mc-aux><sw-button label="×"></sw-button></span> + </div> + <div data-mc-row="plain">plain row</div> + <div data-mc-row="wipe"><sw-button label="wipe all" hotkey="x"></sw-button></div> + `); + let auxClicks = 0; + button("[data-mc-aux] sw-button").addEventListener("click", () => { + auxClicks += 1; + }); + key("x"); // focus on the crate row: its aux claims first, the marking never hears + expect(auxClicks).toBe(1); + expect(ctl.nav.focusedId).toBe("crate"); + key("ArrowDown"); // -> the plain row, which has no aux and declines x + key("x"); // ... so the marking resolves: a jump to the wipe row + expect(ctl.nav.focusedId).toBe("wipe"); + expect(auxClicks).toBe(1); + }); + + it("an i-marked button yields to a focused button's own info (the popup norm survives)", async () => { + const { ctl, key } = await makeRig(` + <div data-mc-row="a"><sw-button label="dock" info="requests clearance"></sw-button></div> + <div data-mc-row="plain">plain row</div> + <div data-mc-row="atlas"><sw-button label="atlas" hotkey="i"></sw-button></div> + `); + const infos: string[] = []; + document.body.addEventListener("sw-info", (e) => { + infos.push((e as CustomEvent<SwInfoDetail>).detail.content); + }); + key("i"); // focus on the dock row: its own info claims i, the marking never hears + expect(infos).toEqual(["requests clearance"]); + expect(ctl.nav.focusedId).toBe("a"); + key("ArrowDown"); // -> the plain row, which declines i + key("i"); // ... so the marking resolves: a jump to the atlas row + expect(ctl.nav.focusedId).toBe("atlas"); + expect(infos).toEqual(["requests clearance"]); // a jump, not the info norm + }); + + it("duplicate sw-button hotkeys in one column throw at scan time", async () => { + await expect( + makeRig(` + <div data-mc-row="a"><sw-button label="dock" hotkey="d"></sw-button></div> + <div data-mc-row="b"><sw-button label="drift" hotkey="d"></sw-button></div> + `), + ).rejects.toThrow(/duplicate hotkey marking "d"/); + }); + + it("an aux sw-button answers x and Delete from anywhere in the row", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="crate"> + <span data-mc-main>ore crate</span> + <span data-mc-aux><sw-button label="×"></sw-button></span> + </div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + expect(key("x").defaultPrevented).toBe(true); // focus is at main position + expect(key("Delete").defaultPrevented).toBe(true); + expect(clicks).toBe(2); + }); + + it("a main sw-button does NOT answer x (removal is the aux norm)", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="alpha"></sw-button></div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + key("x"); + expect(clicks).toBe(0); + }); + + it("Enter at main does not activate the aux button (activation must not be destructive)", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="crate"> + <span data-mc-main>ore crate</span> + <span data-mc-aux><sw-button label="×"></sw-button></span> + </div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + key("Enter"); // main position: the aux button must stay quiet + expect(clicks).toBe(0); + key("ArrowRight"); // move to the aux control... + key("Enter"); // ...now Enter is at the aux position + expect(clicks).toBe(1); + }); + + it("i at its position dispatches sw-info", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="dock" info="requests clearance"></sw-button></div> + `); + const got: SwInfoDetail[] = []; + document.body.addEventListener("sw-info", (e) => { + got.push((e as CustomEvent<SwInfoDetail>).detail); + }); + const e = key("i"); + expect(got.length).toBe(1); + expect(got[0]?.content).toBe("requests clearance"); + expect(got[0]?.source).toBe(button("sw-button")); + expect(e.defaultPrevented).toBe(true); + }); + + it("disabled: claims its keys but does not act (Space must not fall through)", async () => { + const { ctl, key, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="dock" hotkey="d" hotkey-direct disabled></sw-button></div> + <div data-mc-row="b">plain row</div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + expect(key(" ").defaultPrevented).toBe(true); // claimed... + expect(clicks).toBe(0); // ...but declined + key("ArrowDown"); // move to row b; the disabled DIRECT hotkey still claims from afar + expect(key("d").defaultPrevented).toBe(true); + expect(clicks).toBe(0); + // The jump half of a direct activation still lands: the row is a nav stop + // (i can ask why it is off), only the action is suppressed. + expect(ctl.nav.focusedId).toBe("a"); + }); + + it("disabled, non-direct: its hotkey still jumps focus to it (i can ask why)", async () => { + const { ctl, key, button } = await makeRig(` + <div data-mc-row="a"><sw-button label="dock" hotkey="d" disabled></sw-button></div> + <div data-mc-row="b">plain row</div> + `); + let clicks = 0; + button("sw-button").addEventListener("click", () => { + clicks += 1; + }); + key("ArrowDown"); // -> b + expect(key("d").defaultPrevented).toBe(true); + expect(ctl.nav.focusedId).toBe("a"); // the jump lands; disabled rows are nav stops + expect(clicks).toBe(0); + key("Enter"); // Enter on the disabled button: claimed, declined + expect(clicks).toBe(0); + }); + + it("two buttons in a row each claim only their own position's Enter", async () => { + const { key, button } = await makeRig(` + <div data-mc-row="pair"> + <span data-mc-main><sw-button label="inspect"></sw-button></span> + <span data-mc-aux><sw-button label="×"></sw-button></span> + </div> + `); + let mains = 0; + let auxes = 0; + button("[data-mc-main] sw-button").addEventListener("click", () => { + mains += 1; + }); + button("[data-mc-aux] sw-button").addEventListener("click", () => { + auxes += 1; + }); + key("Enter"); + expect([mains, auxes]).toEqual([1, 0]); + key("ArrowRight"); + key("Enter"); + expect([mains, auxes]).toEqual([1, 1]); + }); +}); diff --git a/packages/yukey/src/sw-button.ts b/packages/yukey/src/sw-button.ts new file mode 100644 index 0000000..75da923 --- /dev/null +++ b/packages/yukey/src/sw-button.ts @@ -0,0 +1,394 @@ +// sw-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 --sw-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 `sw-info` event carrying this button's `info` text. +// sw-infopop renders the popup; `info` is also mirrored onto data-info, +// the attribute sw-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 --sw-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 -> sw-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 +// --sw-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 sw-pressed/sw-held classes live on the HOST element, outside classMap's +// managed set, so renders never clobber a flash in flight. + +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 { flashPress } from "./press-flash.js"; +import { dispatchSwInfo } from "./sw-info.js"; + +@customElement("sw-button") +export class SwButton extends LitElement { + static styles = css` + :host { + display: inline-flex; + } + .btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: var(--sw-hit-min-h); + min-width: var(--sw-hit-min-w); + padding: 2px var(--sw-space-md); + box-sizing: border-box; + background: none; + border: 1px solid var(--sw-border); + color: var(--sw-text); + font: inherit; + cursor: pointer; + user-select: none; + white-space: nowrap; + } + .btn:hover { + color: var(--sw-text-bright); + border-color: var(--sw-border-bright); + } + button.btn:focus-visible { + outline: var(--sw-focus-ring); + outline-offset: var(--sw-focus-ring-offset); + } + .hk, + .slot { + color: var(--sw-hotkey-accent); + } + /* the 70%-offset hotkey slot, per the guidelines' button-marking norm */ + .btn.slotted { + padding-right: calc(var(--sw-space-xxl) + var(--sw-space-md)); + } + .slot { + position: absolute; + left: 70%; + top: 50%; + transform: translate(-50%, -50%); + } + /* the aux (small-x) form: compact, danger-tinted, borderless like the norm */ + .btn.aux { + color: var(--sw-danger); + border-color: transparent; + padding: 0; + flex: none; + } + .btn.aux:hover { + border-color: var(--sw-danger-border); + } + .btn.disabled { + color: var(--sw-text-faint); + border-color: var(--sw-border-dim); + cursor: default; + } + /* the active (in-effect) option: selected wash, brighter text, still clickable */ + .btn.active { + color: var(--sw-text-bright); + background: var(--sw-wash-selected); + } + /* a dead hotkey must not advertise itself in the significant color */ + .btn.disabled .hk, + .btn.disabled .slot { + color: inherit; + } + /* activation feedback: the same look for held-under-a-pointer and for the + post-activation flash (see the file header) */ + :host(.sw-held) .btn, + :host(.sw-pressed) .btn { + background: var(--sw-wash-pressed); + border-color: var(--sw-accent-bright); + color: var(--sw-text-bright); + } + `; + + @property() label = ""; + @property() icon = ""; + @property({ type: Boolean, reflect: true }) disabled = false; + @property({ type: Boolean }) active = false; + + // The info content, mirrored synchronously onto data-info: that attribute is + // the carrier contract sw-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 = ""; + @property() + get info(): string { + return this.#info; + } + set info(v: string) { + this.#info = v; + if (v === "") delete this.dataset.info; + 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 = ""; + @property() + get hotkey(): string { + return this.#hotkey; + } + set hotkey(v: string) { + if (v !== "" && !/^[a-z0-9]$/i.test(v)) + throw new Error(`sw-button: hotkey "${v}" must be a single [a-z0-9] character`); + const norm = v.toLowerCase(); + this.#hotkey = norm; + if (norm === "") delete this.dataset.mcHotkey; + 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 = false; + @property({ type: Boolean, attribute: "hotkey-direct" }) + get hotkeyDirect(): boolean { + return this.#hotkeyDirect; + } + set hotkeyDirect(v: boolean) { + this.#hotkeyDirect = v; + if (v) this.dataset.mcHotkeyDirect = ""; + else delete this.dataset.mcHotkeyDirect; + } + + #row: HTMLElement | null = null; + #suppressorWired = false; + + connectedCallback(): void { + super.connectedCallback(); + this.#row = this.closest("[data-mc-row]"); + if (this.#row === null && this.closest("[data-mc-container]") !== null) + throw new Error( + "sw-button: inside a megacolumn container but outside any row; " + + "a loose tab stop would fracture the single-tab-stop model", + ); + if (this.#row !== null) { + this.#row.addEventListener("megacolumn-key", this.#onMegacolumnKey); + } else { + this.addEventListener("keydown", this.#onKeydown); + } + this.addEventListener("click", this.#onActivated); + this.addEventListener("pointerdown", this.#onPointerDown); + this.addEventListener("pointerup", this.#releaseHold); + this.addEventListener("pointercancel", this.#releaseHold); + this.addEventListener("pointerleave", this.#releaseHold); + if (!this.#suppressorWired) { + // Disabled clicks die here, before any author's @click on the host can + // see them -- capture at the render root beats the internal bubble. + this.renderRoot.addEventListener("click", this.#suppressDisabledClick, { capture: true }); + this.#suppressorWired = true; + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.#row?.removeEventListener("megacolumn-key", this.#onMegacolumnKey); + this.#row = null; + this.removeEventListener("keydown", this.#onKeydown); + this.removeEventListener("click", this.#onActivated); + this.removeEventListener("pointerdown", this.#onPointerDown); + this.removeEventListener("pointerup", this.#releaseHold); + this.removeEventListener("pointercancel", this.#releaseHold); + this.removeEventListener("pointerleave", this.#releaseHold); + this.#releaseHold(); + } + + // Row-mode claims; see the file header for the claim table. + #onMegacolumnKey = (e: Event): void => { + if (e.defaultPrevented) return; // someone earlier in the row claimed it + const { key, position } = (e as CustomEvent<MegacolumnKeyDetail>).detail; + const atUs = position === this.#position(); + const k = key.toLowerCase(); + 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 + // 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") { + this.#claimActivation(e); + } else if (k === "i" && atUs && this.info !== "") { + e.preventDefault(); + this.#dispatchInfo(); + } + }; + + // Standalone: 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(); + if (this.#hotkey !== "" && k === this.#hotkey) { + e.preventDefault(); + if (!this.disabled) this.click(); + } else if (k === "i" && this.info !== "") { + e.preventDefault(); + this.#dispatchInfo(); + } + }; + + #suppressDisabledClick = (e: Event): void => { + 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 = (e: Event): void => { + if (this.#row !== null) e.preventDefault(); + if (this.disabled) return; + flashPress(this); + }; + + #onPointerDown = (e: PointerEvent): void => { + if (e.button !== 0 || this.disabled) return; + this.classList.add("sw-held"); + }; + + #releaseHold = (): void => { + this.classList.remove("sw-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(e: Event): void { + e.preventDefault(); + if (this.disabled) return; + this.click(); + } + + #dispatchInfo(): void { + dispatchSwInfo(this, this.info); + } + + #position(): "main" | "aux" { + return this.closest("[data-mc-aux]") !== null ? "aux" : "main"; + } + + render(): TemplateResult { + const marked = this.#labelMarkIndex(); + const slotGlyph = this.icon !== "" ? this.icon : marked === -1 ? this.#hotkey : ""; + const classes = { + btn: true, + slotted: slotGlyph !== "", + aux: this.#position() === "aux", + active: this.active, + disabled: this.disabled, + }; + 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: + // .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>${ + slotGlyph !== "" ? html`<span class="slot" aria-hidden="true">${slotGlyph}</span>` : "" + }`; + // Row mode renders non-focusable markup (the container is the tab stop, and + // clicks then natively focus it); standalone renders a real native button. + return this.#row !== null + ? html`<div + class=${classMap(classes)} + aria-disabled=${ifDefined(ariaDisabled)} + aria-current=${ifDefined(ariaCurrent)} + aria-keyshortcuts=${ifDefined(ariaKeys)} + >${content}</div>` + : html`<button + type="button" + class=${classMap(classes)} + aria-disabled=${ifDefined(ariaDisabled)} + aria-current=${ifDefined(ariaCurrent)} + aria-keyshortcuts=${ifDefined(ariaKeys)} + >${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(): number { + if (this.#hotkey === "" || this.icon !== "") return -1; + return this.label.toLowerCase().indexOf(this.#hotkey); + } + + #content(marked: number): TemplateResult { + if (marked === -1) return html`${this.label}`; + return html`${this.label.slice(0, marked)}<span class="hk">${this.label[marked]}</span>${this.label.slice(marked + 1)}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "sw-button": SwButton; + } +} diff --git a/packages/yukey/src/sw-info.ts b/packages/yukey/src/sw-info.ts new file mode 100644 index 0000000..d63cf3c --- /dev/null +++ b/packages/yukey/src/sw-info.ts @@ -0,0 +1,34 @@ +// The info norm's wiring: the sw-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. sw-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 (sw-button) mirror it onto this attribute; +// plain markup writes the attribute directly. Empty means not a carrier. +// - `sw-info` (bubbles + composed) is the keyboard trigger's event: a producer +// that answered i dispatches it, sw-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: sw-button (its own i claim, standalone and in rows) and +// MegacolumnController (the fallback for plain [data-info] rows). + +export interface SwInfoDetail { + content: string; + // The element to anchor the popup to (see the retargeting note above). + source: HTMLElement; +} + +export function dispatchSwInfo(source: HTMLElement, content: string): void { + source.dispatchEvent( + new CustomEvent<SwInfoDetail>("sw-info", { + detail: { content, source }, + bubbles: true, + composed: true, + }), + ); +} diff --git a/packages/yukey/src/sw-infopop.gallery.ts b/packages/yukey/src/sw-infopop.gallery.ts new file mode 100644 index 0000000..93efd76 --- /dev/null +++ b/packages/yukey/src/sw-infopop.gallery.ts @@ -0,0 +1,198 @@ +// Gallery entry for sw-infopop: keyboard i and mouse hover raising the same +// authored content, the pin precedence, plain-row participation via data-info, +// and the edge flip. The popup instance under demonstration is the gallery +// shell's own -- one per app is the model, so the demos raise that one. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import { + MegacolumnController, + type MegacolumnKeyDetail, + megacolumnStyles, +} from "./megacolumn-controller.js"; +import "./sw-button.js"; + +// A megacolumn where every row flavor carries info: sw-buttons answer i +// themselves; the plain text main, the slider row, and the aux x are answered +// by the controller's [data-info] fallback. Hover hits the same attributes. +@customElement("swg-infopop-column") +export class SwgInfopopColumn extends LitElement { + static styles = [ + megacolumnStyles, + css` + :host { + display: block; + } + [data-mc-container] { + border: 1px solid var(--sw-border-dim); + padding: var(--sw-space-md); + } + [data-mc-row] { + display: flex; + align-items: center; + gap: var(--sw-space-sm); + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + } + [data-mc-row] sw-button:first-child { + flex: 1; + } + .grow { + flex: 1; + } + .meter { + flex: 1; + height: 8px; + border: 1px solid var(--sw-border-dim); + position: relative; + } + .meter .fill { + position: absolute; + inset: 0 auto 0 0; + background: var(--sw-accent); + } + .pct { + color: var(--sw-text-hi); + width: 4ch; + text-align: right; + } + [data-mc-aux] { + color: var(--sw-danger); + min-width: var(--sw-hit-min-w); + min-height: var(--sw-hit-min-h); + display: inline-flex; + align-items: center; + justify-content: center; + } + .log { + margin-top: var(--sw-space-md); + color: var(--sw-good); + font-size: var(--sw-type-ui); + min-height: 1.4em; + } + `, + ]; + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + + @property() label = "info everywhere"; + + @state() private power = 40; + @state() private last = ""; + + private onMegacolumnKey(e: Event): void { + const { key, rowId } = (e as CustomEvent<MegacolumnKeyDetail>).detail; + if (rowId === "survey" && (key === "Enter" || key === " ")) { + this.last = "survey queued"; + e.preventDefault(); + return; + } + if (rowId === "power" && (key === "ArrowLeft" || key === "ArrowRight")) { + const delta = key === "ArrowRight" ? 5 : -5; + this.power = Math.min(100, Math.max(0, this.power + delta)); + this.last = `reactor power ${this.power}%`; + e.preventDefault(); + return; + } + if (rowId === "probe" && (key === "x" || key === "Delete")) { + this.last = "probe recalled"; + e.preventDefault(); + } + } + + render(): TemplateResult { + return html` + <div data-mc-container aria-label=${this.label} @megacolumn-key=${this.onMegacolumnKey}> + <div data-mc-row="dock"> + <sw-button + label="Dock at station" + hotkey="d" + info="an sw-button answers i itself; hover reads the same data-info mirror" + @click=${() => { + this.last = "docked"; + }} + ></sw-button> + </div> + <div data-mc-row="survey" data-info="a plain text row: the megacolumn answers i for its data-info"> + <span class="grow">queue survey</span> + </div> + <div data-mc-row="power" data-info="a slider row: left/right adjust it, and i still explains it"> + <span>reactor</span> + <span class="meter"><span class="fill" style="width:${this.power}%"></span></span> + <span class="pct">${this.power}%</span> + </div> + <div data-mc-row="probe" data-info="this row's main story: the probe telemetry feed"> + <span class="grow" data-mc-main>recall probe</span> + <span data-mc-aux data-info="the x's own story: recalls the probe (x or Delete)">×</span> + </div> + </div> + <div class="log">${this.last}</div> + `; + } +} + +// Buttons pushed against the stage edges, so the preferred below-centered +// placement has to clamp (sides) or flip (bottom edge, when the page is short). +@customElement("swg-infopop-edges") +export class SwgInfopopEdges extends LitElement { + static styles = css` + :host { + display: block; + } + .corners { + display: flex; + justify-content: space-between; + } + `; + + render(): TemplateResult { + return html` + <div class="corners"> + <sw-button + label="far left" + info="anchored at the viewport's left: the popup clamps instead of centering" + ></sw-button> + <sw-button + label="far right" + info="anchored at the viewport's right: the popup clamps the other way" + ></sw-button> + </div> + `; + } +} + +const entry: GalleryEntry = { + id: "sw-infopop", + title: "sw-infopop", + description: + "The info norm's popup: keyboard i and mouse hover raise the SAME authored " + + "content (one data-info attribute per element, two triggers). Keyboard-raised " + + "popups pin until any key or click; hover popups follow the pointer and never " + + "displace a pinned one. The instance shown is the gallery shell's own popup: " + + "one per app, listening at its root.", + variants: [ + { + caption: + "Every row flavor carries info. Keyboard: focus the column, press i on each row -- " + + "the sw-button answers itself, the plain rows are answered by the megacolumn's data-info " + + "fallback, and on the probe row, right-arrow onto the × for its own story. " + + "i again on the same row dismisses (a toggle); i after moving to another row follows. " + + "Mouse: hover the same places and linger; the identical text appears. " + + "Precedence: press i, then wave the mouse over other rows -- the pinned popup holds " + + "until a key or click dismisses it. Navigation keys close the popup AND still navigate.", + render: () => html`<swg-infopop-column></swg-infopop-column>`, + }, + { + caption: + "Edge behavior: these buttons hug the stage's sides, so the below-centered placement " + + "clamps horizontally to stay inside the viewport. Hover or focus-and-i near a viewport " + + "edge (shrink the window height to see the bottom flip: the popup jumps above the anchor).", + wide: true, + render: () => html`<swg-infopop-edges></swg-infopop-edges>`, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/sw-infopop.test.ts b/packages/yukey/src/sw-infopop.test.ts new file mode 100644 index 0000000..b1736f0 --- /dev/null +++ b/packages/yukey/src/sw-infopop.test.ts @@ -0,0 +1,388 @@ +// @vitest-environment jsdom +// +// Tests for sw-infopop (sw-infopop.ts): triggers, precedence, and dismissal. +// Geometry is NOT tested here (jsdom does no layout; rects are all zero) -- +// the placement rule has its own pure tests in infopop-place.test.ts, and the +// browser verification covers the real flip. Hover timing runs on fake timers. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatchSwInfo } from "./sw-info.js"; +import { HOVER_DELAY_MS, type SwInfopop } from "./sw-infopop.js"; +import "./sw-infopop.js"; + +interface Rig { + pop: SwInfopop; + rowA: HTMLElement; + rowB: HTMLElement; + aux: HTMLElement; + bare: HTMLElement; +} + +// One popup plus a few carriers, the way an app shell hosts it: the popup and +// the content share a tree (jsdom needs no shadow hosts to prove the wiring; +// composed bubbling across roots is the platform's, not ours). +async function makeRig(): Promise<Rig> { + document.body.innerHTML = ` + <div id="rowA" data-info="alpha's story">alpha <span id="aux" data-info="the x's story">x</span></div> + <div id="rowB" data-info="beta's story">beta</div> + <div id="bare">no info here</div> + `; + const pop = document.createElement("sw-infopop"); + document.body.append(pop); + await pop.updateComplete; + const get = (id: string): HTMLElement => { + const el = document.getElementById(id); + if (el === null) throw new Error(`rig lost #${id}`); + return el; + }; + return { pop, rowA: get("rowA"), rowB: get("rowB"), aux: get("aux"), bare: get("bare") }; +} + +function hover(el: HTMLElement): void { + // MouseEvent rather than PointerEvent: jsdom listeners see the type string, + // and the handler reads nothing a PointerEvent would add. + el.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, composed: true })); +} + +function key(el: HTMLElement | Window, k: string): KeyboardEvent { + const e = new KeyboardEvent("keydown", { + key: k, + bubbles: true, + composed: true, + cancelable: true, + }); + el.dispatchEvent(e); + return e; +} + +async function settle(pop: SwInfopop): Promise<void> { + await pop.updateComplete; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + document.body.replaceChildren(); // disconnects the popup and its window listeners + vi.useRealTimers(); +}); + +describe("the keyboard trigger (sw-info)", () => { + it("opens pinned with the event's content, anchored to its source", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowA); + expect(pop.shadowRoot?.textContent).toContain("alpha's story"); + expect(pop.style.visibility).toBe("visible"); + expect(pop.hasAttribute("aria-hidden")).toBe(false); + }); + + it("a later sw-info replaces the popup (new anchor, new content)", async () => { + const { pop, rowA, rowB } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + dispatchSwInfo(rowB, "beta's story"); + await settle(pop); + expect(pop.anchor).toBe(rowB); + expect(pop.shadowRoot?.textContent).toContain("beta's story"); + }); +}); + +describe("the hover trigger", () => { + it("opens after the delay with the carrier's data-info", async () => { + const { pop, rowA } = await makeRig(); + hover(rowA); + expect(pop.mode).toBe("closed"); // not before the delay + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.mode).toBe("hover"); + expect(pop.anchor).toBe(rowA); + expect(pop.shadowRoot?.textContent).toContain("alpha's story"); + }); + + it("the nearest carrier in the path wins (aux inside a row)", async () => { + const { pop, aux } = await makeRig(); + hover(aux); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.anchor).toBe(aux); + expect(pop.shadowRoot?.textContent).toContain("the x's story"); + }); + + it("re-arms the delay when the carrier changes mid-wait", async () => { + const { pop, rowA, rowB } = await makeRig(); + hover(rowA); + vi.advanceTimersByTime(HOVER_DELAY_MS - 100); + hover(rowB); + vi.advanceTimersByTime(HOVER_DELAY_MS - 100); + expect(pop.mode).toBe("closed"); // neither timer has run to completion + vi.advanceTimersByTime(100); + await settle(pop); + expect(pop.anchor).toBe(rowB); + }); + + it("hovering away and back restarts the delay (no early open off a stale timer)", async () => { + const { pop, rowA, rowB } = await makeRig(); + hover(rowA); + vi.advanceTimersByTime(HOVER_DELAY_MS - 100); + hover(rowB); + hover(rowA); // back again: the original timer must not still be live + vi.advanceTimersByTime(150); // past the original deadline, before the new one + expect(pop.mode).toBe("closed"); + vi.advanceTimersByTime(HOVER_DELAY_MS - 150); + await settle(pop); + expect(pop.anchor).toBe(rowA); + }); + + it("non-carriers raise nothing", async () => { + const { pop, bare } = await makeRig(); + hover(bare); + vi.advanceTimersByTime(HOVER_DELAY_MS * 2); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("closes when the pointer leaves the carrier", async () => { + const { pop, rowA, bare } = await makeRig(); + hover(rowA); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.mode).toBe("hover"); + hover(bare); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("closes when the pointer leaves the window", async () => { + const { pop, rowA } = await makeRig(); + hover(rowA); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + rowA.dispatchEvent( + new MouseEvent("pointerout", { bubbles: true, composed: true, relatedTarget: null }), + ); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("closes when the pointer leaves for an element outside the popup's tree", async () => { + // The popup serves the tree it lives in (an app shell's shadow root). A + // destination outside that tree fires no pointermove the popup can hear, + // so the carrier's pointerout is the only leave signal there is. + document.body.innerHTML = ""; + const host = document.createElement("div"); + document.body.append(host); + const shell = host.attachShadow({ mode: "open" }); + shell.innerHTML = `<div id="carrier" data-info="in-shell story">thing</div>`; + const pop = document.createElement("sw-infopop"); + shell.append(pop); + await pop.updateComplete; + const carrier = shell.getElementById("carrier"); + if (carrier === null) throw new Error("rig lost the carrier"); + hover(carrier); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.mode).toBe("hover"); + carrier.dispatchEvent( + new MouseEvent("pointerout", { + bubbles: true, + composed: true, + relatedTarget: document.body, // outside the shell's shadow tree + }), + ); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); +}); + +describe("precedence: keyboard pins", () => { + it("hover never replaces a pinned popup", async () => { + const { pop, rowA, rowB } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + hover(rowB); + vi.advanceTimersByTime(HOVER_DELAY_MS * 2); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowA); + }); + + it("pointer movement never closes a pinned popup", async () => { + const { pop, rowA, bare } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + hover(bare); + await settle(pop); + expect(pop.mode).toBe("pinned"); + }); + + it("a skipped hover does not resurrect after the pinned popup closes", async () => { + // The pointer sits on a carrier while a pinned popup has precedence; when a + // key closes the pinned one, nothing should appear without a fresh action. + const { pop, rowA, rowB } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + hover(rowB); + vi.advanceTimersByTime(HOVER_DELAY_MS); // fires, sees pinned, skips + key(window, "Escape"); + vi.advanceTimersByTime(HOVER_DELAY_MS * 2); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("keyboard replaces an open hover popup (and pins)", async () => { + const { pop, rowA, rowB } = await makeRig(); + hover(rowB); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.mode).toBe("hover"); + dispatchSwInfo(rowA, "alpha's story"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowA); + }); +}); + +describe("dismissal", () => { + it("any keydown closes, and the key is never consumed", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + const e = key(rowA, "ArrowDown"); + await settle(pop); + expect(pop.mode).toBe("closed"); + expect(e.defaultPrevented).toBe(false); // navigation reaches its owner untouched + }); + + it("Escape that closes the popup is CONSUMED (one Escape, one layer)", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + const e = key(rowA, "Escape"); + await settle(pop); + expect(pop.mode).toBe("closed"); + expect(e.defaultPrevented).toBe(true); // the popup was the layer this Escape closed + }); + + it("Escape with no popup open passes untouched", async () => { + const { pop, rowA } = await makeRig(); + const e = key(rowA, "Escape"); + await settle(pop); + expect(pop.mode).toBe("closed"); + expect(e.defaultPrevented).toBe(false); // nothing closed, so the key belongs to outer layers + }); + + it("bare modifiers close nothing", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + key(rowA, "Shift"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + }); + + it("one keypress can close the old popup and raise the next (capture order)", async () => { + // The i norm in action: the source's keydown handler dispatches sw-info, + // and the popup's window-capture close has already run by then. + const { pop, rowA, rowB } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + rowB.addEventListener("keydown", (e) => { + if (e.key === "i") dispatchSwInfo(rowB, "beta's story"); + }); + key(rowB, "i"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowB); + }); + + it("i on the pinned popup's own anchor dismisses instead of re-raising (toggle)", async () => { + // The same wiring as the follow test above, but the second i lands on the + // element already anchoring the pinned popup: the user is asking it away. + const { pop, rowA } = await makeRig(); + rowA.addEventListener("keydown", (e) => { + if (e.key === "i") dispatchSwInfo(rowA, "alpha's story"); + }); + key(rowA, "i"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + key(rowA, "i"); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("the toggle suppression is scoped to one keypress (i works again afterward)", async () => { + const { pop, rowA } = await makeRig(); + rowA.addEventListener("keydown", (e) => { + if (e.key === "i") dispatchSwInfo(rowA, "alpha's story"); + }); + key(rowA, "i"); + key(rowA, "i"); // toggles off + key(rowA, "i"); // a fresh press: raises again + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowA); + }); + + it("i on a DIFFERENT anchor during the same keypress is never suppressed", async () => { + // Guards the follow behavior against the toggle: the capture close sees + // key i + pinned mode there too, but the arriving sw-info names another + // anchor, so it must open. + const { pop, rowA, rowB } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + rowB.addEventListener("keydown", (e) => { + if (e.key === "i") dispatchSwInfo(rowB, "beta's story"); + }); + key(rowB, "i"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowB); + }); + + it("i on a hover popup's anchor pins it (no toggle for unpinned popups)", async () => { + // The keyboard upgrade of a hover popup: only PINNED popups toggle off. + const { pop, rowA } = await makeRig(); + rowA.addEventListener("keydown", (e) => { + if (e.key === "i") dispatchSwInfo(rowA, "alpha's story"); + }); + hover(rowA); + vi.advanceTimersByTime(HOVER_DELAY_MS); + await settle(pop); + expect(pop.mode).toBe("hover"); + key(rowA, "i"); + await settle(pop); + expect(pop.mode).toBe("pinned"); + expect(pop.anchor).toBe(rowA); + }); + + it("pointerdown anywhere closes", async () => { + const { pop, rowA, bare } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + bare.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); + + it("scroll closes (anchor rects go stale)", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + document.dispatchEvent(new Event("scroll")); + await settle(pop); + expect(pop.mode).toBe("closed"); + }); +}); + +describe("announcement surface, never interaction", () => { + it("is role=tooltip, never focusable, aria-hidden while closed", async () => { + const { pop } = await makeRig(); + expect(pop.getAttribute("role")).toBe("tooltip"); + expect(pop.tabIndex).toBe(-1); + expect(pop.hasAttribute("tabindex")).toBe(false); + expect(pop.getAttribute("aria-hidden")).toBe("true"); + }); + + it("returns to aria-hidden when closed again", async () => { + const { pop, rowA } = await makeRig(); + dispatchSwInfo(rowA, "alpha's story"); + await settle(pop); + key(window, "Escape"); + await settle(pop); + expect(pop.getAttribute("aria-hidden")).toBe("true"); + expect(pop.style.visibility).toBe(""); + }); +}); diff --git a/packages/yukey/src/sw-infopop.ts b/packages/yukey/src/sw-infopop.ts new file mode 100644 index 0000000..8734822 --- /dev/null +++ b/packages/yukey/src/sw-infopop.ts @@ -0,0 +1,308 @@ +// sw-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 sw-info event and the data-info carrier attribute -- +// is sw-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 today; sw-app hosts one at migration), and every sw-info event and every +// [data-info] hover in that tree raises it. Two triggers: +// +// - Keyboard: an sw-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 sw-info +// arriving in the same dispatch is swallowed, so the second i dismisses +// instead of blinking closed-then-open. (A different anchor's sw-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. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { placeInfopop } from "./infopop-place.js"; +import type { SwInfoDetail } from "./sw-info.js"; + +export const HOVER_DELAY_MS = 350; + +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). +interface AriaReflection { + ariaDescribedByElements: Element[] | null; +} + +@customElement("sw-infopop") +export class SwInfopop extends LitElement { + static styles = css` + :host { + position: fixed; + left: 0; + top: 0; + z-index: 100; + max-width: 44ch; + visibility: hidden; + pointer-events: none; + box-sizing: border-box; + padding: var(--sw-space-sm) var(--sw-space-md); + background: var(--sw-bg-raised); + border: 1px solid var(--sw-border); + color: var(--sw-text-hi); + font: var(--sw-type-body) var(--sw-font); + } + `; + + @state() 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: 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 sw-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; + + get mode(): Mode { + return this.#mode; + } + get anchor(): HTMLElement | null { + return this.#anchor; + } + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute("role", "tooltip"); + this.setAttribute("aria-hidden", "true"); + 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 + // 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("sw-info", this.#onSwInfo); + root.addEventListener("pointermove", this.#onPointermove); + root.addEventListener("pointerout", this.#onPointerout); + // Dismissals, global by nature. Capture phase: closing must precede the + // target's own handling, so a close and a re-raise can share one keypress. + window.addEventListener("keydown", this.#onKeydown, { capture: true }); + window.addEventListener("pointerdown", this.#onPointerdown, { capture: true }); + window.addEventListener("scroll", this.#onScroll, { capture: true, passive: true }); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.#close(); + this.#candidate = null; + this.#dismissal = null; + const root = this.#root; + if (root !== null) { + root.removeEventListener("sw-info", this.#onSwInfo); + root.removeEventListener("pointermove", this.#onPointermove); + root.removeEventListener("pointerout", this.#onPointerout); + this.#root = null; + } + window.removeEventListener("keydown", this.#onKeydown, { capture: true }); + window.removeEventListener("pointerdown", this.#onPointerdown, { capture: true }); + window.removeEventListener("scroll", this.#onScroll, { capture: true }); + } + + #onSwInfo = (e: Event): void => { + const { content, source } = (e as CustomEvent<SwInfoDetail>).detail; + // The toggle's second half: this sw-info answers the very keypress that + // just dismissed a pinned popup on the same anchor (that keydown is still + // mid-dispatch), so it is a dismissal confirmed, not a request to reopen. + const d = this.#dismissal; + if (d !== null && d.anchor === source && d.keydown.eventPhase !== Event.NONE) return; + this.#cancelTimer(); + this.#open("pinned", source, content); + }; + + #onPointermove = (e: Event): void => { + this.#trackCarrier(nearestCarrier(e.composedPath())); + }; + + #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 + // 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(target: EventTarget): boolean { + if (!(target instanceof Node)) return false; + let root: Node | null = target.getRootNode(); + while (root !== null) { + if (root === this.#root) return true; + root = root instanceof ShadowRoot ? root.host.getRootNode() : null; + } + return false; + } + + // 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; + this.#cancelTimer(); + if (this.#mode === "hover" && carrier !== this.#anchor) this.#close(); + 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 -- + // 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; + this.#open("hover", carrier, carrier.dataset.info ?? ""); + }, HOVER_DELAY_MS); + } + } + + #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 + // 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. + // Other closing keys stay unconsumed on purpose (an arrow that dismisses the + // popup must still navigate; that is how the popup follows row focus). + if (e.key === "Shift" || e.key === "Control" || e.key === "Alt" || e.key === "Meta") return; + // The toggle's first half: i closing a pinned popup notes which anchor this + // keypress dismissed, so #onSwInfo can tell a same-anchor re-raise (toggle + // off) from a different-anchor follow (reopen there). + if (this.#mode === "pinned" && this.#anchor !== null && e.key.toLowerCase() === "i") { + this.#dismissal = { anchor: this.#anchor, keydown: e }; + } + if (this.#mode !== "closed") { + if (e.key === "Escape") e.preventDefault(); + this.#close(); + } + }; + + #onPointerdown = (): void => { + if (this.#mode !== "closed") this.#close(); + }; + + #onScroll = (): void => { + if (this.#mode !== "closed") this.#close(); + }; + + #open(mode: "pinned" | "hover", anchor: HTMLElement, content: string): void { + this.#unwireAria(); + this.#mode = mode; + this.#anchor = anchor; + this.content = content; + this.#wireAria(anchor); + // Re-render unconditionally: content alone may be unchanged while the + // anchor moved, and positioning rides the update cycle (see updated()). + this.requestUpdate(); + } + + #close(): void { + this.#cancelTimer(); + this.#unwireAria(); + this.#mode = "closed"; + this.#anchor = null; + this.requestUpdate(); + } + + #cancelTimer(): void { + if (this.#timer !== undefined) { + window.clearTimeout(this.#timer); + this.#timer = undefined; + } + } + + // 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 = ""; + this.setAttribute("aria-hidden", "true"); + return; + } + const a = this.#anchor.getBoundingClientRect(); + const self = this.getBoundingClientRect(); + const p = placeInfopop( + { x: a.x, y: a.y, w: a.width, h: a.height }, + { w: self.width, h: self.height }, + { w: window.innerWidth, h: window.innerHeight }, + ); + this.style.left = `${p.x}px`; + this.style.top = `${p.y}px`; + this.style.visibility = "visible"; + this.removeAttribute("aria-hidden"); + } + + #wireAria(anchor: HTMLElement): void { + if (!("ariaDescribedByElements" in anchor)) return; + (anchor as unknown as AriaReflection).ariaDescribedByElements = [this]; + } + + #unwireAria(): void { + const prev = this.#anchor; + if (prev === null || !("ariaDescribedByElements" in prev)) return; + (prev as unknown as AriaReflection).ariaDescribedByElements = null; + } + + render(): TemplateResult { + return html`${this.content}`; + } +} + +// 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; + } + return null; +} + +declare global { + interface HTMLElementTagNameMap { + "sw-infopop": SwInfopop; + } +} diff --git a/packages/yukey/src/sw-list.gallery.test.ts b/packages/yukey/src/sw-list.gallery.test.ts new file mode 100644 index 0000000..5fa3731 --- /dev/null +++ b/packages/yukey/src/sw-list.gallery.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +// +// Tests for the sw-list gallery demo AS A CONSUMER of the component: the owner's +// commit handler (a cancelable sw-list-add append), the task-key routing that must +// skip sw-list's structural rows, and the composed behaviors only a full console +// exhibits (the churn lockout swallowing t mid-fallback). The norm MECHANICS -- +// dive/commit/abandon, t/n resolution, group identity -- are sw-list's own and are +// pinned in sw-list.test.ts; this file deliberately does not re-test them. +// +// jsdom note: real browsers fire `change` on blur-with-changed-value and both +// text-mode exits blur the input, so the abandon test dispatches the change event +// by hand at exactly the moment a real browser would -- proving the demo's data is +// untouched even then. The trusted-input version runs in lc-drive.py. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "./sw-list.gallery.js"; +import type { SwList } from "./sw-list.js"; +import { installTokens } from "./tokens.js"; + +// The task removal path flashes the x (press-flash resolves --sw-press-ms from +// the live cascade and throws when it can't), so the tokens must be installed. +installTokens(); + +interface Demo extends HTMLElement { + updateComplete: Promise<boolean>; +} + +// jsdom has no matchMedia (sw-live-list asks about prefers-reduced-motion) and +// implements no scrollIntoView (the controller scrolls on focus identity change). +beforeEach(() => { + vi.useFakeTimers(); + window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList; +}); +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); +Element.prototype.scrollIntoView = (): void => {}; + +interface Rig { + demo: Demo; + root: ShadowRoot; + container: HTMLElement; + list: SwList; + input: HTMLInputElement; + key: (key: string, target?: HTMLElement) => KeyboardEvent; + focusedRow: () => string | null; + taskLabels: () => string[]; + settle: () => Promise<void>; +} + +// The drain variant (no refill attribute): starts empty, and its drain interval +// never fires under fake timers, so every task in the list is one a test put there. +async function makeRig(): Promise<Rig> { + const demo = document.createElement("swg-livecolumn-demo") as Demo; + document.body.appendChild(demo); + await demo.updateComplete; + const root = demo.shadowRoot; + if (root === null) throw new Error("demo did not render a shadow root"); + const container = root.querySelector<HTMLElement>("[data-mc-container]"); + const list = root.querySelector<SwList>("sw-list"); + if (container === null || list === null) throw new Error("demo missing container or sw-list"); + await list.updateComplete; + const input = list.querySelector<HTMLInputElement>("[data-mc-input]"); + if (input === null) throw new Error("demo missing the add input"); + // The task-removal path flashes the x (press-flash resolves --sw-press-ms from + // the live cascade and throws when it can't), and jsdom does not carry custom + // properties from the document's token stylesheet across the demo's shadow + // boundary -- so seed the property inline, inside the shadow tree. + container.style.setProperty("--sw-press-ms", "140ms"); + const key = (k: string, target: HTMLElement = container): KeyboardEvent => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + target.dispatchEvent(e); + return e; + }; + const focusedRow = (): string | null => + root.querySelector<HTMLElement>("[data-mc-row].mc-row-focused")?.dataset.mcRow ?? null; + const taskLabels = (): string[] => + [...list.querySelectorAll("[data-mc-row] .task .grow")].map((el) => el.textContent ?? ""); + const settle = async (): Promise<void> => { + await demo.updateComplete; + await list.updateComplete; + }; + return { demo, root, container, list, input, key, focusedRow, taskLabels, settle }; +} + +// Seed one task through the add-row (the drain queue starts empty) and land row +// focus back on it. +const seedTask = async (rig: Rig, label: string): Promise<void> => { + rig.key("a"); // sw-list's marked direct hotkey: dives into text entry + rig.input.value = label; + rig.key("Enter", rig.input); + await rig.settle(); +}; + +describe("the demo as an sw-list consumer", () => { + it("composes the console: prepend row, sw-list (header first), status row after", async () => { + const { container, key, focusedRow } = await makeRig(); + key("Home"); + expect(focusedRow()).toBe("prepend"); + key("ArrowDown"); + expect(focusedRow()).toBe("queue-header"); // sw-list's structural id: name="queue" + expect(container.getAttribute("aria-activedescendant")).toBe("mc-queue-header"); + key("End"); + expect(focusedRow()).toBe("status"); + }); + + it("commit appends a player task at the tail and logs it", async () => { + const rig = await makeRig(); + await seedTask(rig, "first"); + rig.key("Enter"); // still on the add-row: re-dive + rig.input.value = "second"; + rig.key("Enter", rig.input); + await rig.settle(); + expect(rig.taskLabels()).toEqual(["first", "second"]); + const rows = [...rig.list.querySelectorAll<HTMLElement>("sw-live-list [data-mc-row]")]; + expect(rows[1]?.querySelector(".origin")?.classList.contains("player")).toBe(true); + expect(rig.root.querySelector(".log")?.textContent).toBe("second appended"); + }); + + it("Escape abandons: the demo's data is untouched, even through the blur-change", async () => { + const rig = await makeRig(); + const logBefore = rig.root.querySelector(".log")?.textContent; + rig.key("a"); + rig.input.value = "changed my mind"; + rig.key("Escape", rig.input); + // The blur-change trap: a change-driven commit would append here. + rig.input.dispatchEvent(new Event("change", { bubbles: true })); + await rig.settle(); + expect(rig.taskLabels()).toEqual([]); + expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore); + }); + + it("task keys skip sw-list's structural rows (header and add-row decline quietly)", async () => { + const rig = await makeRig(); + const logBefore = rig.root.querySelector(".log")?.textContent; + rig.key("Home"); + rig.key("ArrowDown"); // the header + expect(rig.focusedRow()).toBe("queue-header"); + rig.key("x"); // must not trip the task handler's ghost path ("already gone") + expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore); + rig.key("End"); + rig.key("ArrowUp"); // the add-row (row focus, not text mode) + expect(rig.focusedRow()).toBe("queue-add"); + rig.key("x"); + expect(rig.root.querySelector(".log")?.textContent).toBe(logBefore); + }); + + it("x on a task row removes it (the owner's key semantics ride through sw-list)", async () => { + const rig = await makeRig(); + await seedTask(rig, "doomed"); + rig.key("t"); // sw-list's jump to the header + rig.key("ArrowDown"); // the task + expect(rig.focusedRow()).toBe("t1"); + rig.key("x"); + await rig.settle(); + expect(rig.root.querySelector(".log")?.textContent).toBe("doomed removed"); + }); + + it("t and n work through the demo layout (t to header, n to the status row)", async () => { + const rig = await makeRig(); + await seedTask(rig, "a task"); + rig.key("t"); + expect(rig.focusedRow()).toBe("queue-header"); + rig.key("n"); + expect(rig.focusedRow()).toBe("status"); + const e = rig.key("t"); // outside the list: nobody's letter + expect(e.defaultPrevented).toBe(false); + expect(rig.focusedRow()).toBe("status"); + }); + + it("the churn lockout swallows t mid-fallback and t flows again after", async () => { + // Composed behavior only the full console shows: a collapse under focus + // moves focus involuntarily, the lockout consumes forward routings, and + // t rides the forward routing -- so it is dead for the window, alive after. + // (Deliberate wrinkle, pinned in the playtest stage; see the controller header.) + const rig = await makeRig(); + await seedTask(rig, "first"); + rig.key("Enter"); + rig.input.value = "second"; + rig.key("Enter", rig.input); + await rig.settle(); + rig.key("t"); + rig.key("ArrowDown"); + expect(rig.focusedRow()).toBe("t1"); + rig.key("x"); // remove it: ghosts now, collapses at holdMs + await rig.settle(); + vi.advanceTimersByTime(1201); // hold (1200) expires, the collapse begins + await rig.settle(); // aria-hidden lands, rescan runs, the fallback moves focus + expect(rig.focusedRow()).toBe("t2"); + rig.key("t"); // in the lockout window: swallowed, no jump + expect(rig.focusedRow()).toBe("t2"); + vi.advanceTimersByTime(351); // the lockout window passes + await rig.settle(); + rig.key("t"); // ... and t flows again + expect(rig.focusedRow()).toBe("queue-header"); + }); +}); diff --git a/packages/yukey/src/sw-list.gallery.ts b/packages/yukey/src/sw-list.gallery.ts new file mode 100644 index 0000000..1e587c8 --- /dev/null +++ b/packages/yukey/src/sw-list.gallery.ts @@ -0,0 +1,406 @@ +// Gallery entry for the live megacolumn: sw-list (the list region: header row, +// live rows, add-row, t/n) composed into a megacolumn console. This demo is the +// rehearsal for the infocard's task queue: a queue that churns on its own (heads +// complete, assignments refill) AND under player command (prepend, remove, append), +// fully keyboard-drivable, with focus surviving every kind of row death. The demo +// is deliberately a THIN consumer: it owns the data, the row rendering, and what +// the keys MEAN on task rows; sw-list owns the list norms (sw-list.ts holds that +// contract); sw-live-list under it owns row lifecycle. Copy this shape at +// migration time. + +import { css, html, LitElement, nothing, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import { + MegacolumnController, + type MegacolumnKeyDetail, + megacolumnStyles, +} from "./megacolumn-controller.js"; +import { flashPress } from "./press-flash.js"; +import type { SwListAddDetail } from "./sw-list.js"; +import type { LiveListPhase } from "./sw-live-list.js"; +import "./sw-list.js"; +import "./sw-button.js"; + +interface QueueTask { + id: string; + label: string; + origin: "player" | "assignment"; +} + +const taskKey = (t: QueueTask): string => t.id; + +// The task-queue console: one megacolumn whose lower section is a live list. +// The list stamps row identity itself (sw-live-list.ts holds that contract); +// this owner renders row CONTENT (main/aux/data-info) and gives the keys meaning. +@customElement("swg-livecolumn-demo") +export class SwgLivecolumnDemo extends LitElement { + static styles = [ + megacolumnStyles, + css` + :host { + display: block; + } + [data-mc-container] { + border: 1px solid var(--sw-border-dim); + padding: var(--sw-space-md); + } + .title { + color: var(--sw-text-dim); + font-size: var(--sw-type-small); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin-bottom: var(--sw-space-sm); + } + .section { + color: var(--sw-text-faint); + font-size: var(--sw-type-micro); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + margin: var(--sw-space-xl) 0 var(--sw-space-xs); + } + .section:first-of-type { + margin-top: 0; + } + /* The list norms (header wash, ghosted add-row, hotkey gold) are sw-list's + own styles now; this sheet only spaces the list within the console. */ + sw-list { + margin-top: var(--sw-space-xl); + } + [data-mc-row] { + min-height: var(--sw-hit-min-h); + } + .action-row { + display: flex; + align-items: center; + padding: 2px var(--sw-space-sm); + } + .action-row sw-button { + flex: 1; + } + /* Task-row content (rendered by the live list into this shadow root, + so this stylesheet reaches it directly -- no itemStyles hop). */ + .task { + display: flex; + align-items: center; + gap: var(--sw-space-sm); + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + white-space: nowrap; + } + .task.committed { + opacity: 0.45; + } + .task .origin { + flex: none; + width: 88px; + font-size: var(--sw-type-small); + color: var(--sw-text-faint); + } + .task .origin.player { + color: var(--sw-good); + } + .task .grow { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + /* The ✓ occupies the same box the × does in live rows, so the glyph + column holds steady as rows commit (same size, same centering). */ + /* The glyph box: one element across phases (× live, ✓ committed), so an + acknowledgment flash on it survives the phase flip. Same box either way: + the column holds steady as rows commit. All rules keyed to the CLASS, + not [data-mc-aux]: the ghost drops that attribute in the same re-render, + and attribute-keyed styling would un-match mid-flash. */ + .task .glyph { + color: var(--sw-danger); + border: 1px solid transparent; + min-width: var(--sw-hit-min-w); + min-height: var(--sw-hit-min-h); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + .task .glyph.gone { + color: var(--sw-good); + } + /* The aux's own pressed look: danger-family, not the blue activation wash -- + ownership distinguishes the flashes, no enumeration of activation kinds. */ + .task .glyph.sw-pressed { + background: hsl(from var(--sw-danger) h calc(s * 2) calc(l * 0.75) / 0.6); + color: var(--sw-text-bright); + } + .idle { + color: var(--sw-text-faint); + padding: 2px var(--sw-space-sm); + line-height: var(--sw-hit-min-h); + } + .status-row { + display: flex; + align-items: center; + color: var(--sw-text-dim); + padding: 2px var(--sw-space-sm); + margin-top: var(--sw-space-xl); + } + .log { + margin-top: var(--sw-space-md); + color: var(--sw-good); + font-size: var(--sw-type-ui); + min-height: 1.4em; + } + `, + ]; + + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + + @property() label = "ops queue"; + // Refill keeps the queue steady (a completed head is replaced); without it the + // queue drains to the empty state and only player prepends fill it. + @property({ type: Boolean }) refill = false; + + @state() private tasks: QueueTask[] = []; + @state() private lastAction = ""; + private seq = 0; + private timer: number | undefined; + + connectedCallback(): void { + super.connectedCallback(); + if (this.refill) { + this.tasks = [this.newTask(), this.newTask(), this.newTask(), this.newTask()]; + this.timer = window.setInterval(() => this.churn(), 4000); + } else { + // Drain mode: heads still complete, nothing refills. + this.timer = window.setInterval(() => { + this.tasks = this.tasks.slice(1); + }, 4000); + } + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.clearInterval(this.timer); + } + + private newTask(origin: "player" | "assignment" = "assignment"): QueueTask { + this.seq += 1; + const chores = + origin === "player" + ? ["survey the anomaly", "escort the hauler", "scout the far gate"] + : ["move to waypoint", "load ore", "unload struts", "dock", "refuel"]; + return { + id: `t${this.seq}`, + label: `${chores[this.seq % chores.length]} (#${this.seq})`, + origin, + }; + } + + // One churn beat: the head completes (leaves the data -> ghosts) and new + // assignment work arrives at the tail. + private churn(): void { + this.tasks = [...this.tasks.slice(1), this.newTask()]; + } + + private prependTask(): void { + const task = this.newTask("player"); + this.tasks = [task, ...this.tasks]; + this.lastAction = `${task.label} prepended`; + } + + // The add-row's commit, arriving as sw-list's cancelable sw-list-add event + // (sw-list owns the dive/abandon/ghost mechanics and the nothing-syncs-until- + // commit contract; the owner just appends). Not cancelled here: any trimmed + // non-empty text is a legal task. + private onListAdd(e: Event): void { + const { text } = (e as CustomEvent<SwListAddDetail>).detail; + this.seq += 1; + this.tasks = [...this.tasks, { id: `t${this.seq}`, label: text, origin: "player" }]; + this.lastAction = `${text} appended`; + } + + // The queue rows' key semantics (the prepend row's keys are sw-button's own + // business -- direct-marked, it claims them before this handler could matter): + // x/Delete from anywhere in the row removes; Enter/Space on the aux removes; + // Enter/Space on main inspects (activation must not be destructive). + private onMegacolumnKey(e: Event): void { + const { detail } = e as CustomEvent<MegacolumnKeyDetail>; + // Only task rows have task semantics: sw-list's structural rows (the + // "<name>-header"/"<name>-add" id scheme is its contract) and the demo's + // own prepend/status rows decline everything here (the add-row's typing + // never reaches this handler; text entry dominates). + if ( + detail.rowId === "prepend" || + detail.rowId === "queue-header" || + detail.rowId === "queue-add" || + detail.rowId === "status" + ) + return; + const task = this.tasks.find((t) => t.id === detail.rowId); + const activate = detail.key === "Enter" || detail.key === " "; + const remove = + detail.key === "x" || detail.key === "Delete" || (activate && detail.position === "aux"); + if (remove) { + // A ghost's task is already out of the data: removing again is a no-op, + // and saying so quietly beats pretending something happened. + this.lastAction = task === undefined ? "already gone" : `${task.label} removed`; + if (task !== undefined) { + // Acknowledge on the aux x itself -- the control whose activation removal + // IS -- in its own danger-tinted pressed style. The glyph element survives + // the phase flip to ✓ (see renderTask), so the flash paints through it. + const aux = + e.target instanceof Element + ? e.target.closest("[data-mc-row]")?.querySelector("[data-mc-aux]") + : null; + if (aux instanceof HTMLElement) flashPress(aux); + this.tasks = this.tasks.filter((t) => t.id !== task.id); + } + e.preventDefault(); + return; + } + if (activate) { + this.lastAction = + task === undefined ? "inspected a completed task" : `${task.label} inspected`; + e.preventDefault(); + // Acknowledge on the row's main control (inside the focused row). + if (e.target instanceof HTMLElement) flashPress(e.target); + } + } + + // Row renderer, handed to the live list. The committed variant drops the aux + // control: a ghost is not removable (it is already gone from the data), and + // the list's scan-visible hasAux falls out of that automatically. + // One template for both phases, ON PURPOSE: the glyph span is the same element + // whether it shows the live × or the committed ✓, so an acknowledgment flash + // stamped on it survives the phase flip and paints (a per-phase template swap + // would destroy the very control being acknowledged -- the composition goof + // this demo exists to not make). data-mc-aux is conditionally present: ghosts + // drop the attribute (not the element), so the megacolumn scan still sees them + // as aux-less and the attribute-keyed styling flips with it. + private renderTask = (t: QueueTask, phase: LiveListPhase): TemplateResult => { + const done = phase === "committed"; + return html`<div class=${classMap({ task: true, committed: done })}> + <span class="origin ${t.origin}">${t.origin}</span> + <span class="grow" data-mc-main data-info=${ + done + ? "a completed task, held briefly; Enter still inspects it" + : "a queued task; Enter inspects it" + }>${t.label}</span> + <span class=${classMap({ glyph: true, gone: done })} + data-mc-aux=${done ? nothing : ""} + title=${done ? nothing : "remove"} + data-info=${done ? nothing : "removes this task (x or Delete from anywhere in the row)"} + >${done ? "✓" : "×"}</span> + </div>`; + }; + + private renderQueueEmpty = (): TemplateResult => + html`<div class="idle">queue empty (prepend something)</div>`; + + render(): TemplateResult { + return html` + <div class="title">${this.label}</div> + <div + data-mc-container + aria-label=${this.label} + @megacolumn-key=${this.onMegacolumnKey} + > + <div class="section">orders</div> + <div data-mc-row="prepend" class="action-row"> + <!-- Direct-marked (the deliberate exception to hotkeys-move-focus): + a prepend is cheaply undone -- x removes the new head -- so the + see-before-commit step would only slow the order down. --> + <sw-button + label="prepend priority task" + hotkey="p" + hotkey-direct + info="queues a player task at the HEAD of the queue; watch it appear instantly even mid-keyboard-burst" + @click=${() => this.prependTask()} + ></sw-button> + </div> + + <!-- The list region: sw-list owns the norms (header row speaking for the + region, ghosted add-row with the marked direct a, t/n jumps); this + owner supplies data, row rendering, and the commit handler. The + norm teaching in the info texts is sw-list's own appendix. --> + <sw-list + name="queue" + header="task queue (live)" + header-info="the task queue; every row is removable -- x or Delete" + addable + add-info="appends a task at the tail" + .items=${this.tasks} + .keyOf=${taskKey} + .renderItem=${this.renderTask} + .renderEmpty=${this.renderQueueEmpty} + @sw-list-add=${this.onListAdd} + ></sw-list> + + <!-- A stop AFTER the list, so n has somewhere to land (and the demo + shows what n is for: hopping past a long list without arrowing through + it). A plain informational row; no task semantics. --> + <div + data-mc-row="status" + class="status-row" + data-info="queue telemetry; n from anywhere in the list above jumps straight here" + > + telemetry: queue nominal + </div> + </div> + <div class="log">${this.lastAction}</div> + `; + } +} + +const entry: GalleryEntry = { + id: "sw-list", + title: "sw-list: the live megacolumn's list region", + description: + "The list region component, composed here into a megacolumn console: header row, live rows " + + "(sw-live-list inside), ghosted add-row, and the t/n list-scoped jumps -- the list norms as " + + "things the component does. The queue churns and stays fully keyboard-drivable: ghost rows " + + "stay navigable while they hold (a stationary ghost is safe for the keyboard exactly as it " + + "is for the mouse); collapsing rows leave navigation and focus falls to a neighbor under a " + + "brief decisive-key lockout; key activity pins the FOCUSED ghost's geometry (the keyboard " + + "aims by identity, so only the aimed-at row needs holding still). The mouse side is the " + + "default click-guard: motion proceeds under the pointer, and only a click on a row that is " + + "mid-displacement dies quietly. This is the rehearsal for the infocard's task queue.", + variants: [ + { + caption: + "A steady queue: the head completes every few seconds (ghosts ✓, holds, collapses) and new " + + "assignment work arrives. Try: arrow onto a task and just WORK it -- the ghost under your " + + "focus holds while you keep pressing keys (the key-activity pin), and collapses moments after " + + "you pause; ghosts you are NOT on collapse on their own schedule, each at its own moment. " + + "Removals flash the × itself, danger-tinted, before the row ghosts (x/Delete removes a task from anywhere in its row; " + + "right-arrow reaches the ×; Enter on a ghost still inspects it), and if focus was on a ghost " + + "when it collapsed, focus falls to the next row under a brief decisive-key lockout -- hammer " + + "Enter across the collapse and note nothing mis-fires. The LIST NORMS are sw-list's own " + + "behavior now: a header row (arrow onto it and the " + + "whole region washes -- group identity without a nested container; i there describes the list as " + + "a list) and the add-row at the tail. Two ways to add, both earning the direct marking under " + + "the easy-undo doctrine: the prepend " + + "button is hotkey-direct -- the exception to letters-move-focus: p acts immediately from any row " + + "(focus following to the orders row), justified because a prepend is cheaply undone -- x the new " + + "head and it is gone. The add-row's a dives straight into typing (direct on a text row means " + + "what Enter means there), justified because nothing touches the queue until the Enter commit " + + "(the owner hears a cancelable sw-list-add and appends; watch the task animate in) and Escape " + + "abandons the draft. While focus is anywhere " + + "in the list -- header, tasks, add-row -- t jumps back up to the header and n jumps past the " + + "list to the next stop after it: list-scoped keys sw-list wires itself, deliberately not " + + "gold-marked (they mean the same thing in every list).", + render: () => html`<swg-livecolumn-demo refill></swg-livecolumn-demo>`, + }, + { + caption: + "Drain to empty: nothing refills this queue. Prepend a few tasks (p), watch them complete and " + + "drain away; the empty state is the owner's renderEmpty render-prop (mega mode has no slots). " + + "Focus stays sane through the last row's death: the column itself keeps focus and the arrows " + + "revive it the moment a row exists again.", + render: () => html`<swg-livecolumn-demo label="drain queue"></swg-livecolumn-demo>`, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/sw-list.test.ts b/packages/yukey/src/sw-list.test.ts new file mode 100644 index 0000000..c49f0e2 --- /dev/null +++ b/packages/yukey/src/sw-list.test.ts @@ -0,0 +1,528 @@ +// @vitest-environment jsdom +// +// Tests for sw-list: the list region component (header row, live rows, add-row, +// t/n list-scoped keys). The rig is a minimal owner: a Lit host with a +// MegacolumnController, one row before the list, one after (n's landing), and +// an sw-list whose knobs the tests flip per case. The add-row's commit/abandon +// distinction is the subtle part: real browsers fire `change` on +// blur-with-changed-value and BOTH text-mode exits blur the input, so a commit +// hooked to @change would fire on Escape too -- jsdom fires no change on its +// own, so the abandon test dispatches one by hand at exactly the moment a real +// browser would. The trusted-input version of the story runs in lc-drive.py. + +import { html, LitElement, nothing, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MegacolumnController, megacolumnStyles } from "./megacolumn-controller.js"; +import type { SwList, SwListAddDetail } from "./sw-list.js"; +import type { LiveListPhase } from "./sw-live-list.js"; +import "./sw-list.js"; + +interface Item { + id: string; + label: string; +} + +// jsdom has no matchMedia (sw-live-list asks about prefers-reduced-motion) and +// implements no scrollIntoView (the controller scrolls on focus identity change). +beforeEach(() => { + vi.useFakeTimers(); + window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList; +}); +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); +Element.prototype.scrollIntoView = (): void => {}; + +// The minimal owner. `withAfter` controls whether n has a landing; the sw-list +// knobs pass straight through. +@customElement("swt-list-owner") +class SwtListOwner extends LitElement { + static styles = [megacolumnStyles]; + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: wires itself via addController + private mc = new MegacolumnController(this); + @property({ attribute: false }) items: Item[] = []; + @property() header = "the test list"; + @property({ attribute: false }) renderHeader?: () => TemplateResult; + @property({ type: Boolean }) addable = true; + @property({ type: Boolean }) withAfter = true; + + render(): TemplateResult { + return html`<div data-mc-container aria-label="test column"> + <div data-mc-row="before" data-info="the row before the list">before</div> + <sw-list + name="tl" + header=${this.header} + header-info="a list of test items" + .renderHeader=${this.renderHeader} + ?addable=${this.addable} + add-info="appends a test item" + .items=${this.items} + .keyOf=${(it: Item) => it.id} + .renderItem=${(it: Item, _phase: LiveListPhase) => + html`<div class="row-content">${it.label}</div>`} + ></sw-list> + ${this.withAfter ? html`<div data-mc-row="after">after</div>` : nothing} + </div>`; + } +} + +interface Rig { + owner: SwtListOwner; + root: ShadowRoot; + container: HTMLElement; + list: SwList<Item>; + input: HTMLInputElement | null; + key: (key: string, target?: HTMLElement) => KeyboardEvent; + focusedRow: () => string | null; + adds: SwListAddDetail[]; + settle: () => Promise<void>; +} + +async function makeRig(setup?: (owner: SwtListOwner) => void): Promise<Rig> { + const owner = document.createElement("swt-list-owner") as SwtListOwner; + setup?.(owner); + document.body.appendChild(owner); + await owner.updateComplete; + const root = owner.shadowRoot; + if (root === null) throw new Error("owner did not render a shadow root"); + const container = root.querySelector<HTMLElement>("[data-mc-container]"); + const list = root.querySelector<SwList<Item>>("sw-list"); + if (container === null || list === null) throw new Error("rig missing container or sw-list"); + await list.updateComplete; + const input = list.querySelector<HTMLInputElement>("[data-mc-input]"); + const adds: SwListAddDetail[] = []; + list.addEventListener("sw-list-add", (e) => { + adds.push((e as CustomEvent<SwListAddDetail>).detail); + }); + const key = (k: string, target: HTMLElement = container): KeyboardEvent => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + target.dispatchEvent(e); + return e; + }; + const focusedRow = (): string | null => + root.querySelector<HTMLElement>("[data-mc-row].mc-row-focused")?.dataset.mcRow ?? null; + const settle = async (): Promise<void> => { + await owner.updateComplete; + await list.updateComplete; + }; + return { owner, root, container, list, input, key, focusedRow, adds, settle }; +} + +const items = (...ids: string[]): Item[] => ids.map((id) => ({ id, label: `item ${id}` })); + +describe("structure", () => { + it("renders header row, live list, and add-row inside the element, ids from name", async () => { + const { list } = await makeRig(); + const rows = [...list.querySelectorAll<HTMLElement>("[data-mc-row]")]; + expect(rows.map((r) => r.dataset.mcRow)).toEqual(["tl-header", "tl-add"]); + expect(list.querySelector("sw-live-list")).not.toBeNull(); + expect(list.querySelector(".sw-list-header")?.textContent).toBe("the test list"); + }); + + it("passes items through to the inner sw-live-list (rows render, keyed)", async () => { + const { owner, list, settle } = await makeRig((o) => { + o.items = items("a", "b"); + }); + await settle(); + const live = list.querySelector("sw-live-list"); + await (live as LitElement | null)?.updateComplete; + const labels = [...list.querySelectorAll(".row-content")].map((el) => el.textContent); + expect(labels).toEqual(["item a", "item b"]); + owner.items = items("a", "b", "c"); + await settle(); + await (live as LitElement | null)?.updateComplete; + expect(list.querySelectorAll(".row-content").length).toBe(3); + }); + + it("headerless: no header row; not addable: no add-row", async () => { + const { list } = await makeRig((o) => { + o.header = ""; + o.addable = false; + }); + expect(list.querySelectorAll("[data-mc-row]").length).toBe(0); + expect(list.querySelector("sw-live-list")).not.toBeNull(); + }); + + it("the header row is a nav stop the controller sees (rescan announced)", async () => { + const { container, key, focusedRow } = await makeRig(); + key("Home"); + expect(focusedRow()).toBe("before"); + key("ArrowDown"); + expect(focusedRow()).toBe("tl-header"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-tl-header"); + }); + + it("announces its own rescan: a structural row appearing without a live-list update", async () => { + // Toggling addable re-renders sw-list but not the inner sw-live-list (the + // items reference is unchanged, so its properties report no change) -- the + // add-row's appearance is on sw-list's OWN announcement, nobody else's. + const { list, key, focusedRow, settle } = await makeRig((o) => { + o.addable = false; + }); + key("End"); + expect(focusedRow()).toBe("after"); + list.addable = true; + await settle(); + key("End"); + key("ArrowUp"); + expect(focusedRow()).toBe("tl-add"); // the fresh add-row is a nav stop + }); + + it("composes owner info with the norm teaching on both structural rows", async () => { + const { list } = await makeRig(); + const headerInfo = list.querySelector<HTMLElement>(".sw-list-header")?.dataset.info ?? ""; + expect(headerInfo).toContain("a list of test items"); + expect(headerInfo).toContain("t returns here"); + expect(headerInfo).toContain("n jumps past the list"); + expect(headerInfo).toContain("a dives into the add-row"); + const addInfo = list.querySelector<HTMLElement>(".sw-list-add")?.dataset.info ?? ""; + expect(addInfo).toContain("appends a test item"); + expect(addInfo).toContain("Escape abandons"); + }); + + it("marks the add hotkey letter in the label", async () => { + const { list } = await makeRig(); + const hk = list.querySelector(".sw-list-add .sw-list-hk"); + expect(hk?.textContent).toBe("a"); + const marked = list.querySelector<HTMLElement>("[data-mc-hotkey]"); + expect(marked?.dataset.mcHotkey).toBe("a"); + expect(marked?.hasAttribute("data-mc-hotkey-direct")).toBe(true); + }); +}); + +describe("owner-rendered header content (renderHeader)", () => { + it("renderHeader replaces the header row's visible content; the row stays the nav stop", async () => { + const { list, container, key, focusedRow } = await makeRig((o) => { + o.renderHeader = () => html`<div class="fancy">THE LIST · 3 / 9</div>`; + }); + const headerRow = list.querySelector<HTMLElement>(".sw-list-header"); + expect(headerRow?.querySelector(".fancy")?.textContent).toBe("THE LIST · 3 / 9"); + expect(headerRow?.dataset.mcRow).toBe("tl-header"); + key("Home"); + key("ArrowDown"); + expect(focusedRow()).toBe("tl-header"); + expect(container.getAttribute("aria-activedescendant")).toBe("mc-tl-header"); + }); + + it("the header string still names the group; renderHeader only changes the visible content", async () => { + const { list } = await makeRig((o) => { + o.renderHeader = () => html`<div>totally different content</div>`; + }); + expect(list.getAttribute("role")).toBe("group"); + expect(list.getAttribute("aria-label")).toBe("the test list"); + }); + + it("headerless stays headerless: renderHeader without a header renders no row", async () => { + const { list } = await makeRig((o) => { + o.header = ""; + o.renderHeader = () => html`<div class="fancy">orphan content</div>`; + }); + expect(list.querySelector(".sw-list-header")).toBeNull(); + expect(list.querySelector(".fancy")).toBeNull(); + }); +}); + +describe("group identity (the header-role decision)", () => { + it("with a header: role=group and an aria-label mirroring it; header stays an option", async () => { + const { list } = await makeRig(); + expect(list.getAttribute("role")).toBe("group"); + expect(list.getAttribute("aria-label")).toBe("the test list"); + expect(list.querySelector(".sw-list-header")?.getAttribute("role")).toBe("option"); + }); + + it("headerless: no group role, no label", async () => { + const { list } = await makeRig((o) => { + o.header = ""; + }); + expect(list.hasAttribute("role")).toBe(false); + expect(list.hasAttribute("aria-label")).toBe(false); + }); + + it("an author-set role is left alone", async () => { + const owner = document.createElement("swt-list-owner") as SwtListOwner; + document.body.appendChild(owner); + await owner.updateComplete; + const list = owner.shadowRoot?.querySelector<SwList<Item>>("sw-list"); + if (list === undefined || list === null) throw new Error("no sw-list"); + // Re-run the role logic with an author role in place: simulate by clearing + // ours, setting an author's, and forcing an update. + list.removeAttribute("role"); + list.setAttribute("role", "region"); + list.header = "renamed"; + await list.updateComplete; + expect(list.getAttribute("role")).toBe("region"); + expect(list.getAttribute("aria-label")).toBe("renamed"); + }); +}); + +describe("the add-row", () => { + it("the marked hotkey dives straight into text entry (direct on a text row)", async () => { + const { root, container, input, key, focusedRow, adds } = await makeRig(); + key("Home"); + key("a"); + expect(focusedRow()).toBe("tl-add"); + expect(container.classList.contains("mc-text-mode")).toBe(true); + expect(root.activeElement).toBe(input); // real DOM focus dove in + expect(input?.value).toBe(""); // the triggering a did not type itself + expect(adds).toEqual([]); // a dive, not a commit + }); + + it("Enter commits: one event, trimmed text, draft cleared, focus stays on the add-row", async () => { + const { container, input, key, focusedRow, adds } = await makeRig(); + if (input === null) throw new Error("no input"); + key("a"); + input.value = " calibrate sensors "; + key("Enter", input); + // Real browsers fire change on the blur that follows the exit; a + // change-driven commit would double-fire here. + input.dispatchEvent(new Event("change", { bubbles: true })); + expect(adds).toEqual([{ text: "calibrate sensors" }]); + expect(input.value).toBe(""); + expect(container.classList.contains("mc-text-mode")).toBe(false); + expect(focusedRow()).toBe("tl-add"); // Enter again re-dives + }); + + it("Escape abandons: no event, draft cleared, text mode exits", async () => { + const { container, input, key, adds } = await makeRig(); + if (input === null) throw new Error("no input"); + key("a"); + input.value = "changed my mind"; + key("Escape", input); + input.dispatchEvent(new Event("change", { bubbles: true })); // the blur-change trap + expect(adds).toEqual([]); + expect(input.value).toBe(""); + expect(container.classList.contains("mc-text-mode")).toBe(false); + }); + + it("an empty or whitespace Enter commits nothing and exits", async () => { + const { container, input, key, adds } = await makeRig(); + if (input === null) throw new Error("no input"); + key("a"); + input.value = " "; + key("Enter", input); + expect(adds).toEqual([]); + expect(input.value).toBe(""); + expect(container.classList.contains("mc-text-mode")).toBe(false); + }); + + it("a refused commit (preventDefault) keeps the draft and text mode", async () => { + const { root, container, list, input, key } = await makeRig(); + if (input === null) throw new Error("no input"); + list.addEventListener("sw-list-add", (e) => e.preventDefault(), { once: true }); + key("a"); + input.value = "rejected by validation"; + key("Enter", input); + expect(input.value).toBe("rejected by validation"); // the draft survives + expect(container.classList.contains("mc-text-mode")).toBe(true); // still editing + expect(root.activeElement).toBe(input); + }); + + it("typing syncs nothing: no events before the Enter commit", async () => { + const { input, key, adds } = await makeRig(); + if (input === null) throw new Error("no input"); + key("a"); + for (const ch of "draft") { + key(ch, input); + input.value += ch; + } + expect(adds).toEqual([]); + }); +}); + +describe("the list-scoped keys (t and n)", () => { + // Land row focus on a live item row inside the list. + const focusItem = async (rig: Rig, id: string): Promise<void> => { + const req = new CustomEvent("megacolumn-focus-request", { + detail: { rowId: id }, + bubbles: true, + cancelable: true, + }); + rig.list.dispatchEvent(req); + expect(req.defaultPrevented).toBe(true); + expect(rig.focusedRow()).toBe(id); + }; + + it("t from an item row lands on the header; claimed", async () => { + const rig = await makeRig((o) => { + o.items = items("a", "b"); + }); + await rig.settle(); + await focusItem(rig, "b"); + const e = rig.key("t"); + expect(rig.focusedRow()).toBe("tl-header"); + expect(e.defaultPrevented).toBe(true); + }); + + it("headerless: t lands on the first item row", async () => { + const rig = await makeRig((o) => { + o.header = ""; + o.items = items("a", "b"); + }); + await rig.settle(); + await focusItem(rig, "b"); + rig.key("t"); + expect(rig.focusedRow()).toBe("a"); + }); + + it("t from the add-row counts as within", async () => { + const rig = await makeRig(); + rig.key("a"); // dive + rig.key("Escape", rig.input ?? undefined); // back to row focus on the add-row + expect(rig.focusedRow()).toBe("tl-add"); + rig.key("t"); + expect(rig.focusedRow()).toBe("tl-header"); + }); + + it("n lands on the first stop after the list; claimed", async () => { + const rig = await makeRig((o) => { + o.items = items("a"); + }); + await rig.settle(); + await focusItem(rig, "a"); + const e = rig.key("n"); + expect(rig.focusedRow()).toBe("after"); + expect(e.defaultPrevented).toBe(true); + }); + + it("n with nothing after the list clamps: consumed, focus stays", async () => { + const rig = await makeRig((o) => { + o.withAfter = false; + o.items = items("a"); + }); + await rig.settle(); + await focusItem(rig, "a"); + const e = rig.key("n"); + expect(rig.focusedRow()).toBe("a"); + expect(e.defaultPrevented).toBe(true); + }); + + it("outside the list the letters are nobody's: unclaimed, no jump", async () => { + const rig = await makeRig(); + rig.key("Home"); + expect(rig.focusedRow()).toBe("before"); + const et = rig.key("t"); + expect(rig.focusedRow()).toBe("before"); + expect(et.defaultPrevented).toBe(false); + const en = rig.key("n"); + expect(rig.focusedRow()).toBe("before"); + expect(en.defaultPrevented).toBe(false); + }); + + it("inner scope beats a column marking: a claimed t never reaches data-mc-hotkey", async () => { + const rig = await makeRig((o) => { + o.items = items("a"); + }); + await rig.settle(); + rig.root.querySelector('[data-mc-row="before"]')?.setAttribute("data-mc-hotkey", "t"); + rig.container.dispatchEvent(new CustomEvent("megacolumn-rescan", { bubbles: true })); + await focusItem(rig, "a"); + rig.key("t"); + expect(rig.focusedRow()).toBe("tl-header"); // the list's t, not the marking's + // ... and outside the list the same marking resolves normally. + rig.key("n"); // to "after", outside the list + expect(rig.focusedRow()).toBe("after"); + rig.key("t"); + expect(rig.focusedRow()).toBe("before"); // the marking's jump, undisturbed + }); + + it("in text mode t and n type: no jump (text entry dominates)", async () => { + const rig = await makeRig(); + rig.key("a"); + const e = rig.key("t", rig.input ?? undefined); + expect(rig.focusedRow()).toBe("tl-add"); + expect(rig.container.classList.contains("mc-text-mode")).toBe(true); + expect(e.defaultPrevented).toBe(false); // the browser types it + }); +}); + +describe("fail closed", () => { + // Element lifecycle throws surface as window error events, not to the caller. + const reportsFrom = (act: () => void): string[] => { + const reported: string[] = []; + const onError = (e: ErrorEvent): void => { + reported.push(String(e.error?.message ?? e.message)); + e.preventDefault(); + }; + window.addEventListener("error", onError); + try { + act(); + } finally { + window.removeEventListener("error", onError); + } + return reported; + }; + + it("throws outside any [data-mc-container]", async () => { + // The connect throw surfaces immediately; the already-scheduled first render + // then raises the same complaint from the inner sw-live-list (jsdom reports + // custom-element reaction throws as window errors, asynchronously) -- so the + // capture stays open across the settled update. + let list: LitElement | undefined; + const reported: string[] = []; + const onError = (e: ErrorEvent): void => { + reported.push(String(e.error?.message ?? e.message)); + e.preventDefault(); + }; + window.addEventListener("error", onError); + try { + list = document.createElement("sw-list") as LitElement; + document.body.appendChild(list); + await list.updateComplete.catch(() => {}); + } finally { + window.removeEventListener("error", onError); + } + expect(reported.some((m) => /outside any \[data-mc-container\]/.test(m))).toBe(true); + }); + + it("throws on element children (no slots)", () => { + const container = document.createElement("div"); + container.setAttribute("data-mc-container", ""); + document.body.appendChild(container); + const reported = reportsFrom(() => { + const list = document.createElement("sw-list"); + list.innerHTML = "<div>nope</div>"; + container.appendChild(list); + }); + expect(reported.some((m) => /no slots/.test(m))).toBe(true); + }); + + it("throws on a malformed name", async () => { + const { list } = await makeRig(); + list.name = "not a token"; + await expect(list.updateComplete).rejects.toThrow(/name "not a token"/); + }); + + it("throws on a malformed add-hotkey", async () => { + const { list } = await makeRig(); + list.addHotkey = "ab"; + await expect(list.updateComplete).rejects.toThrow(/add-hotkey "ab"/); + }); + + it("two lists sharing one name in a column fail closed (duplicate structural row ids)", async () => { + // The documented contract: two lists in one column need distinct names. + // The collision surfaces from the controller's duplicate-row-id throw when + // its rescan sees the second "tl-header". Rescans run inside the second + // list's updated(), so jsdom reports the throw as a window error event. + const { container } = await makeRig(); + const reported: string[] = []; + const onError = (e: ErrorEvent): void => { + reported.push(String(e.error?.message ?? e.message)); + e.preventDefault(); + }; + window.addEventListener("error", onError); + try { + const twin = document.createElement("sw-list") as SwList<Item>; + twin.name = "tl"; + twin.header = "the twin"; + twin.keyOf = (it: Item) => it.id; + twin.renderItem = () => html`<div></div>`; + container.appendChild(twin); + await twin.updateComplete.catch(() => {}); + } finally { + window.removeEventListener("error", onError); + } + expect(reported.some((m) => /duplicate.*tl-header/.test(m))).toBe(true); + }); +}); diff --git a/packages/yukey/src/sw-list.ts b/packages/yukey/src/sw-list.ts new file mode 100644 index 0000000..3f4ce61 --- /dev/null +++ b/packages/yukey/src/sw-list.ts @@ -0,0 +1,392 @@ +// sw-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 sw-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, sw-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 sw-live-list untouched: the +// owner renders row CONTENT and keeps data identity; see +// sw-live-list.ts for their contracts. +// +// events: +// sw-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 sw-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?] <sw-live-list megacolumn> [add-row?], all inside this +// element. sw-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 sw-list-prefixed classes on +// the structural rows are stable; their look is the norm's look. +// +// 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 +// sw-list was never legal (rows come from renderItem, not markup). +// +// Like sw-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, sw-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. + +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 type { LiveListPhase } from "./sw-live-list.js"; +import "./sw-live-list.js"; + +export interface SwListAddDetail { + text: string; // trimmed, never empty +} + +// 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 swListStyles = css` + sw-list { + display: block; + } + .mc-focus-within sw-list:has(> .sw-list-header.mc-row-focused) { + background: var(--sw-wash-faint); + } + .sw-list-header { + display: flex; + align-items: center; + min-height: var(--sw-hit-min-h); + color: var(--sw-text-faint); + font-size: var(--sw-type-micro); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + padding: 2px var(--sw-space-sm); + box-sizing: border-box; + } + .sw-list-add { + display: flex; + align-items: center; + gap: var(--sw-space-sm); + min-height: var(--sw-hit-min-h); + padding: 2px var(--sw-space-sm); + color: var(--sw-text-faint); + box-sizing: border-box; + } + .sw-list-add input[data-mc-input] { + flex: 1; + font: inherit; + color: var(--sw-text-bright); + background: transparent; + border: 1px solid transparent; + min-height: var(--sw-hit-min-h); + box-sizing: border-box; + padding: 0 var(--sw-space-sm); + } + .mc-text-mode .sw-list-add.mc-row-focused input[data-mc-input] { + background: var(--sw-bg-well); + border-color: var(--sw-accent); + } + .sw-list-hk { + color: var(--sw-hotkey-accent); + } +`; + +@customElement("sw-list") +export class SwList<T = unknown> extends LitElement { + @property() name = "list"; + @property() header = ""; + @property({ attribute: "header-info" }) headerInfo = ""; + @property({ attribute: false }) renderHeader?: () => TemplateResult; + @property({ type: Boolean }) addable = false; + @property({ attribute: "add-label" }) addLabel = "add…"; + @property({ attribute: "add-hotkey" }) addHotkey = "a"; + @property({ attribute: "add-info" }) addInfo = ""; + @property({ attribute: false }) items: T[] = []; + @property({ attribute: false }) keyOf!: (item: T) => string; + @property({ attribute: false }) renderItem!: (item: T, phase: LiveListPhase) => TemplateResult; + @property({ attribute: false }) renderEmpty?: () => TemplateResult; + @property() resetKey = ""; + + // 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() { + super(); + // The t/n norm's scope IS this element: megacolumn-key dispatches bubble + // 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); + } + + // Light DOM, no slots (see the header). Styles adopt into the owner's root. + protected override createRenderRoot(): HTMLElement { + if (this.children.length > 0) + throw new Error( + "sw-list: renders into the light DOM and has no slots; rows come from renderItem", + ); + adoptIntoRoot(this.getRootNode(), swListStyles, "data-sw-list"); + return this; + } + + override connectedCallback(): void { + super.connectedCallback(); + const container = this.closest<HTMLElement>("[data-mc-container]"); + if (container === null) throw new Error("sw-list: outside any [data-mc-container]"); + this.#container = container; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#container = null; + } + + override willUpdate(): void { + if (!/^[a-z0-9-]+$/i.test(this.name)) + throw new Error( + `sw-list: name "${this.name}" must be a non-empty [a-z0-9-]+ token ` + + "(it namespaces the structural row ids)", + ); + if (this.addable && !/^[a-z0-9]$/i.test(this.addHotkey)) + throw new Error( + `sw-list: add-hotkey "${this.addHotkey}" must be a single [a-z0-9] character`, + ); + } + + protected override updated(): void { + // Structural rows appear and disappear on this element's own update + // schedule, after the owner's hostUpdated scan already ran -- announce, + // same contract as sw-live-list (re-scans are idempotent). + this.dispatchEvent(new CustomEvent("megacolumn-rescan", { 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. + if (this.header !== "") { + if (!this.hasAttribute("role")) this.setAttribute("role", "group"); + this.setAttribute("aria-label", this.header); + } else { + if (this.getAttribute("role") === "group") this.removeAttribute("role"); + this.removeAttribute("aria-label"); + } + } + + // 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 = (e: Event): void => { + const { detail } = e as CustomEvent<MegacolumnKeyDetail>; + if (detail.key !== "t" && detail.key !== "n") return; + e.preventDefault(); + if (detail.key === "t") { + // The list's top: its own rows from the top of the DOM order -- the + // header when there is one (always focusable), else the first live row. + for (const row of this.querySelectorAll<HTMLElement>("[data-mc-row]")) { + if (this.#requestFocus(row.dataset.mcRow ?? "")) return; + } + return; + } + // n: the first stop the column grants after this list, in DOM order. + if (this.#container === null) return; + for (const row of this.#container.querySelectorAll<HTMLElement>("[data-mc-row]")) { + if (this.contains(row)) continue; + if ((this.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING) === 0) continue; + if (this.#requestFocus(row.dataset.mcRow ?? "")) return; + } + }; + + // Ask the column to move logical focus to rowId; true when granted. + #requestFocus(rowId: string): boolean { + const req = new CustomEvent("megacolumn-focus-request", { + detail: { rowId }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(req); + 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 = (e: KeyboardEvent): void => { + const input = e.currentTarget as HTMLInputElement; + if (e.key === "Escape") { + input.value = ""; // abandon; the machine's exit-text routing proceeds + return; + } + if (e.key !== "Enter") return; + const text = input.value.trim(); + if (text === "") { + input.value = ""; + return; // an empty commit is no commit; the exit proceeds + } + const commit = new CustomEvent<SwListAddDetail>("sw-list-add", { + detail: { text }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(commit); + if (commit.defaultPrevented) { + // Commit refused (owner-side validation): keep the draft and stay in + // text mode so the user can fix the text rather than retype it. The + // machine's exit-text routing rides this same keydown's bubble to the + // container; stopping it here is what keeps text mode alive. + e.stopPropagation(); + return; + } + input.value = ""; // committed; the exit proceeds, focus stays on the add-row + }; + + override render(): TemplateResult { + return html` + ${ + this.header === "" + ? nothing + : html`<div + data-mc-row="${this.name}-header" + class="sw-list-header" + data-info=${this.#composedHeaderInfo()} + >${this.renderHeader?.() ?? this.header}</div>` + } + <sw-live-list + megacolumn + .items=${this.items} + .keyOf=${this.keyOf} + .renderItem=${this.renderItem} + .renderEmpty=${this.renderEmpty} + .resetKey=${this.resetKey} + ></sw-live-list> + ${ + this.addable + ? html`<div + data-mc-row="${this.name}-add" + class="sw-list-add" + data-info=${this.#composedAddInfo()} + > + <span data-mc-hotkey=${this.addHotkey} data-mc-hotkey-direct>+ ${this.#markedAddLabel()}</span> + <input data-mc-input type="text" @keydown=${this.#onAddKeydown} /> + </div>` + : nothing + } + `; + } + + // 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; ` : ""; + const norms = `${add}from any row in the list, ${t} and n jumps past the list`; + return this.headerInfo === "" ? norms : `${this.headerInfo}; ${norms}`; + } + + #composedAddInfo(): string { + const top = this.header === "" ? "the list's top" : "the list's header"; + const norms = + `${this.addHotkey} or Enter dives into typing, Enter commits, Escape abandons; ` + + `t jumps back to ${top}`; + return this.addInfo === "" ? norms : `${this.addInfo}; ${norms}`; + } + + // The add-row's hotkey marking: the letter accented in the label (first + // case-insensitive occurrence, sw-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="sw-list-hk">${this.addHotkey}</span>`; + return html`${this.addLabel.slice(0, idx)}<span class="sw-list-hk">${this.addLabel[idx]}</span>${this.addLabel.slice(idx + 1)}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "sw-list": SwList; + } +} diff --git a/packages/yukey/src/sw-live-list.gallery.ts b/packages/yukey/src/sw-live-list.gallery.ts new file mode 100644 index 0000000..3e6178f --- /dev/null +++ b/packages/yukey/src/sw-live-list.gallery.ts @@ -0,0 +1,129 @@ +// Gallery entry for sw-live-list, the keyed live list whose rows vanish safely +// under a pointer. The churn demo element below feeds it a queue that drains and +// refills on a timer, which is the component's home condition: data that changes +// out from under the user, mid-hover. The first two variants contrast the two +// pointerSafety modes on the same churn. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import type { LiveListPhase } from "./sw-live-list.js"; +import "./sw-live-list.js"; + +interface DemoRow { + id: string; + label: string; +} + +const rowStyles = css` + .row { + display: flex; + align-items: baseline; + gap: 10px; + line-height: 1.9; + white-space: nowrap; + } + .row.committed { + opacity: 0.45; + } + .row .mark { + flex: none; + width: 16px; + color: var(--sw-text-faint); + } + .row .mark.live { + color: var(--sw-good); + } +`; + +const renderRow = (item: DemoRow, phase: LiveListPhase): TemplateResult => + html`<div class="row ${phase === "committed" ? "committed" : ""}"> + <span class="mark ${phase}">${phase === "committed" ? "✓" : "▸"}</span> + <span>${item.label}</span> + </div>`; + +const keyOf = (item: DemoRow): string => item.id; + +// A queue that drains itself and gets new work, like a ship's task list does. +// Every beat the oldest row completes (commit -> hold -> collapse) and a fresh +// one enters at the tail; the interplay with a hovering pointer is the demo. +@customElement("swg-live-list-churn") +export class SwgLiveListChurn extends LitElement { + @property() pointerSafety: "click-guard" | "freeze" = "click-guard"; + @state() private items: DemoRow[] = []; + private seq = 0; + private timer: number | undefined; + + connectedCallback(): void { + super.connectedCallback(); + this.items = [this.newRow(), this.newRow(), this.newRow()]; + this.timer = window.setInterval(() => this.churn(), 2500); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + window.clearInterval(this.timer); + } + + private newRow(): DemoRow { + this.seq += 1; + const chores = ["move to waypoint", "load ore", "unload struts", "dock", "survey field"]; + return { id: `t${this.seq}`, label: `${chores[this.seq % chores.length]} (#${this.seq})` }; + } + + private churn(): void { + this.items = [...this.items.slice(1), this.newRow()]; + } + + render(): TemplateResult { + return html`<sw-live-list + .items=${this.items} + .keyOf=${keyOf} + .renderItem=${renderRow} + .itemStyles=${rowStyles} + .pointerSafety=${this.pointerSafety} + ></sw-live-list>`; + } +} + +const entry: GalleryEntry = { + id: "sw-live-list", + title: "sw-live-list", + description: + "A keyed live list whose rows vanish safely under a pointer. " + + "The owner supplies keying and rendering; this component owns row lifecycle: " + + "departed rows ghost and hold before collapsing, insertions grow in. " + + "Click safety is target-scoped by default (pointerSafety: click-guard): motion proceeds " + + "under the pointer, and a click is swallowed only while its target row is mid-displacement " + + "(or was, within the last 50ms) -- stale clicks die, everything else stays live. " + + "Freeze mode is the opt-in alternative: geometry holds still under the pointer instead.", + variants: [ + { + caption: + "Self-churning queue, default click safety (click-guard): the head row completes every few " + + "seconds and new work enters at the tail. Watch a row ghost (✓, dimmed), hold, then collapse -- " + + "with the pointer resting inside, nothing waits for it. Try clicking a row the instant one above " + + "it collapses: the click on the still-moving row dies quietly (no surprise target), while rows " + + "above the motion answer clicks throughout.", + render: () => html`<swg-live-list-churn></swg-live-list-churn>`, + }, + { + caption: + "The same churn in freeze mode (pointerSafety: freeze, the opt-in): rest the pointer inside " + + "the list and geometry freezes -- ghosts hold and insertions wait invisibly until the pointer " + + "leaves, and the deferred motion plays out one beat apart. For surfaces that want quiescence " + + "under the pointer; the price is a world that visibly waits for the mouse.", + render: () => html`<swg-live-list-churn pointerSafety="freeze"></swg-live-list-churn>`, + }, + { + caption: "Empty state: the owner provides the empty slot's content.", + render: () => html`<sw-live-list + .items=${[] as DemoRow[]} + .keyOf=${keyOf} + .renderItem=${renderRow} + ><div slot="empty" style="color:var(--sw-text-faint);">queue empty (assignment may refill it)</div></sw-live-list>`, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/sw-live-list.test.ts b/packages/yukey/src/sw-live-list.test.ts new file mode 100644 index 0000000..cdc8a52 --- /dev/null +++ b/packages/yukey/src/sw-live-list.test.ts @@ -0,0 +1,661 @@ +// @vitest-environment jsdom +// +// Tests for sw-live-list's megacolumn mode: light-DOM rendering, phase-dependent +// row identity stamping, the megacolumn-rescan announcement, the key-activity pin, +// the two pointerSafety modes (click-guard's target-scoped swallow; freeze's +// global hover pin + lockout), and the fail-closed contract checks. The full +// composition (this component under a real MegacolumnController) is exercised +// with the controller test rig's fake-host pattern. +// +// Shadow mode's behavior (hover pin, phase timing, resurrection...) was built and +// verified pre-vitest (the vanishing-rows study + browser drivers); these tests +// cover what THIS stage added, plus shadow-mode regression checks for the pieces +// it touched (class renames, the shared lifecycle stylesheet, renderEmpty). + +import { html, type TemplateResult } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MegacolumnController } from "./megacolumn-controller.js"; +import type { LiveListPhase, SwLiveList } from "./sw-live-list.js"; +import "./sw-live-list.js"; + +interface Item { + id: string; + label: string; +} + +const keyOf = (i: Item): string => i.id; + +// jsdom implements no scrollIntoView; the controller scrolls the focused row +// into view on every focus identity change. A no-op stands in. +Element.prototype.scrollIntoView = (): void => {}; +// The committed variant drops the aux control, like a real task row would: +// a ghost is not removable (it is already gone from the data). +const renderItem = (i: Item, phase: LiveListPhase): TemplateResult => + phase === "committed" + ? html`<span data-mc-main>${i.label} (done)</span>` + : html`<span data-mc-main>${i.label}</span><span data-mc-aux>×</span>`; + +const items = (...ids: string[]): Item[] => ids.map((id) => ({ id, label: `job ${id}` })); + +// jsdom has no matchMedia; the component asks it about prefers-reduced-motion. +beforeEach(() => { + vi.useFakeTimers(); + window.matchMedia ??= (query: string) => ({ matches: false, media: query }) as MediaQueryList; +}); +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +interface MegaRig { + root: HTMLElement; + container: HTMLElement; + list: SwLiveList<Item>; + ctl: MegacolumnController; + setItems: (next: Item[]) => Promise<void>; + key: (key: string) => KeyboardEvent; +} + +// A megacolumn owner around the list, driven the way the controller rig drives +// fake hosts: hostUpdated() stands in for the owner's render completing. +async function makeMegaRig(initial: Item[]): Promise<MegaRig> { + const root = document.createElement("div"); + document.body.appendChild(root); + const container = document.createElement("div"); + container.setAttribute("data-mc-container", ""); + root.appendChild(container); + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.setAttribute("megacolumn", ""); + list.keyOf = keyOf; + list.renderItem = renderItem; + list.items = initial; + container.appendChild(list); + await list.updateComplete; + const host = { + renderRoot: root, + addController(): void {}, + removeController(): void {}, + requestUpdate(): void {}, + updateComplete: Promise.resolve(true), + }; + const ctl = new MegacolumnController(host); + ctl.hostUpdated(); + const setItems = async (next: Item[]): Promise<void> => { + list.items = next; + await list.updateComplete; + }; + const key = (k: string): KeyboardEvent => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + container.dispatchEvent(e); + return e; + }; + return { root, container, list, ctl, setItems, key }; +} + +const rowEl = (root: HTMLElement, key: string): HTMLElement | null => + root.querySelector<HTMLElement>(`[data-key="${key}"]`); + +// Real pointer events are composed (they cross shadow boundaries); the click +// guard listens on the host, so tests must dispatch the same shape. +const fire = (el: Element, type: "click" | "mousedown"): void => { + el.dispatchEvent(new MouseEvent(type, { bubbles: true, composed: true, cancelable: true })); +}; + +const countItemClicks = (list: SwLiveList<Item>): (() => number) => { + let n = 0; + list.addEventListener("item-click", () => { + n += 1; + }); + return () => n; +}; + +describe("mega mode: light-DOM rendering and row identity", () => { + it("renders rows into the owner's tree (no shadow root) with data-mc-row stamped", async () => { + const { list, container } = await makeMegaRig(items("a", "b")); + expect(list.shadowRoot).toBeNull(); + const ids = [...container.querySelectorAll("[data-mc-row]")].map( + (el) => (el as HTMLElement).dataset.mcRow, + ); + expect(ids).toEqual(["a", "b"]); + }); + + it("the controller sees live rows as focusable stops with their aux controls", async () => { + const { ctl } = await makeMegaRig(items("a", "b")); + expect(ctl.nav.focusedId).toBe("a"); + expect(ctl.nav.rows.map((r) => ({ id: r.id, aux: r.hasAux }))).toEqual([ + { id: "a", aux: true }, + { id: "b", aux: true }, + ]); + }); + + it("a committed ghost stays a focusable stop, but sheds its aux", async () => { + const { ctl, setItems, root } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); // a leaves the data -> committed ghost + const ghost = rowEl(root, "a"); + expect(ghost?.dataset.mcRow).toBe("a"); + expect(ghost?.getAttribute("aria-hidden")).toBeNull(); + expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ + focusable: true, + hasAux: false, + }); + }); + + it("a collapsing row is registered but aria-hidden, and thereby not a stop", async () => { + const { ctl, setItems, list, root } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); + vi.advanceTimersByTime(list.holdMs); // hold expires -> collapse begins + await list.updateComplete; + const dying = rowEl(root, "a"); + expect(dying?.dataset.mcRow).toBe("a"); + expect(dying?.getAttribute("aria-hidden")).toBe("true"); + expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: false }); + }); + + it("the empty state is the renderEmpty render-prop", async () => { + const { list, container, setItems } = await makeMegaRig(items("a")); + list.renderEmpty = () => html`<div class="idle">nothing queued</div>`; + await setItems([]); + vi.advanceTimersByTime(list.holdMs + list.collapseMs); + await list.updateComplete; + expect(container.querySelector(".idle")?.textContent).toBe("nothing queued"); + }); +}); + +describe("mega mode: the rescan announcement", () => { + it("self-driven re-renders (phase timers) reach the controller without a host render", async () => { + const { ctl, setItems, list } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); // ghost a + expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: true }); + vi.advanceTimersByTime(list.holdMs); // collapse starts, NO host render happens + await list.updateComplete; + expect(ctl.nav.rows.find((r) => r.id === "a")).toMatchObject({ focusable: false }); + vi.advanceTimersByTime(list.collapseMs); // collapse ends, row drops + await list.updateComplete; + expect(ctl.nav.rows.map((r) => r.id)).toEqual(["b"]); + }); + + it("a focused ghost collapsing away moves focus by the fallback and arms the lockout", async () => { + const { ctl, setItems, list, root, key } = await makeMegaRig(items("a", "b", "c")); + expect(ctl.nav.focusedId).toBe("a"); + await setItems(items("b", "c")); // focused a ghosts; still a stop + expect(ctl.nav.focusedId).toBe("a"); + vi.advanceTimersByTime(list.holdMs); // ghost starts collapsing -> disabled + await list.updateComplete; + expect(ctl.nav.focusedId).toBe("b"); // fallback fell downward + // ... and the churn lockout is armed: a decisive key dies quietly. + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + const e = key("Enter"); + expect(forwards).toBe(0); + expect(e.defaultPrevented).toBe(true); + }); +}); + +describe("mega mode: the key-activity pin", () => { + // The pin defers ONLY the focused row's hold-expiry: the keyboard aims by + // identity, so other rows' geometry motion cannot invalidate its aim -- only + // the focused row itself needs holding still under a key burst. (Freeze + // mode's hover pin, by contrast, is global: pointers aim at positions.) + // The pin is mode-independent: identity aim needs it in both pointerSafety modes. + + it("the focused ghost defers while keys are active and collapses keyPinMs after the last", async () => { + const { setItems, list, root, key } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); // a ghosts; eager focus was on a, and stays + // Work the column without leaving the ghost (ArrowUp cannot move above the + // first row): keys land every 100ms, well past the hold's natural expiry. + for (let t = 0; t < list.holdMs + 400; t += 100) { + vi.advanceTimersByTime(100); + key("ArrowUp"); + } + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); // held past holdMs + vi.advanceTimersByTime(list.keyPinMs); // decay: the deferred collapse begins + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + vi.advanceTimersByTime(list.collapseMs); // ...and completes: row gone + await list.updateComplete; + expect(rowEl(root, "a")).toBeNull(); + }); + + it("a non-focused ghost collapses on its natural schedule despite ongoing keydowns", async () => { + const { setItems, list, root, key } = await makeMegaRig(items("a", "b")); + key("ArrowDown"); // focus moves to b; a is nobody's aim from here on + await setItems(items("b")); // a ghosts, unfocused + // Type continuously up to just before the hold expires; then cross the expiry + // with NO keydown in between (asserting the schedule itself, not a later + // keydown's sweep), the pin still armed from the last keystroke. + for (let t = 100; t < list.holdMs; t += 100) { + vi.advanceTimersByTime(100); + key("ArrowDown"); // b is last: focus stays put, the pin stays armed + } + vi.advanceTimersByTime(100); // the hold expires here, mid-pin + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // on schedule + }); + + it("ghosts removed at different moments collapse at different moments under typing", async () => { + const { setItems, list, root, key } = await makeMegaRig(items("a", "b", "c")); + key("ArrowDown"); + key("ArrowDown"); // focus c; the ghosts below are never the aim + await setItems(items("b", "c")); // a removed at t=0 + for (let t = 100; t <= 500; t += 100) { + vi.advanceTimersByTime(100); + key("ArrowDown"); + } + await setItems(items("c")); // b removed at t=500 + for (let t = 600; t < list.holdMs; t += 100) { + vi.advanceTimersByTime(100); + key("ArrowDown"); + } + vi.advanceTimersByTime(100); // t=holdMs: a's own schedule, mid-pin, no keydown since + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull(); // b's is 500ms out + for (let t = 100; t < 500; t += 100) { + vi.advanceTimersByTime(100); + key("ArrowDown"); + } + vi.advanceTimersByTime(100); // t=holdMs+500: b's schedule, likewise mid-pin + await list.updateComplete; + expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("a deferred ghost is released by the keydown after focus leaves it (no accumulation)", async () => { + const { setItems, list, root, key } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); // ghost a, focused + vi.advanceTimersByTime(list.holdMs - 50); + key("ArrowUp"); // arm the pin without moving + vi.advanceTimersByTime(50); // expiry under the pin, focused: deferred + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); + key("ArrowDown"); // focus leaves the ghost... + key("ArrowDown"); // ...and the next keydown sweeps the stale deferral + await list.updateComplete; + // Collapsing well before any pin decay: the deferral followed the aim. + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("every keydown re-arms the pin (working the focused ghost holds it indefinitely)", async () => { + const { setItems, list, root, key } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); // ghost a, focused + vi.advanceTimersByTime(list.holdMs - 50); + key("ArrowUp"); // pin armed just before the hold expires; focus stays on a + vi.advanceTimersByTime(50); // expiry defers under the pin + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(list.keyPinMs - 100); // almost decayed... + key("ArrowUp"); // ...re-armed (fresh keyPinMs from here) + } + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); + vi.advanceTimersByTime(list.keyPinMs + list.collapseMs); // decay, then the collapse + await list.updateComplete; + expect(rowEl(root, "a")).toBeNull(); + }); + + it("the key pin does NOT defer insertions (identity aim; feedback beats calm)", async () => { + const { setItems, root, key } = await makeMegaRig(items("a")); + key("ArrowDown"); + await setItems(items("new", "a")); // a keystroke-driven prepend, say + const fresh = rowEl(root, "new"); + expect(fresh?.getAttribute("aria-hidden")).toBeNull(); // visible immediately + }); +}); + +describe("click-guard (the default pointerSafety): motion proceeds, stale clicks die", () => { + // The default disentangles two principles the old hover pin conflated: + // clicks must not land on surprising targets (always on, target-scoped, + // brief), and freezing motion under the pointer (an opt-in mode, below). + + it("hovering defers nothing: an insertion appears immediately under the pointer", async () => { + const { list, setItems, root } = await makeMegaRig(items("a")); + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("new", "a")); + expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull(); // visible, not pending + }); + + it("hovering defers nothing: an expired hold collapses on its own schedule", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b")); + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("b")); // a ghosts under the pointer + vi.advanceTimersByTime(list.holdMs); + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // collapsing on schedule + vi.advanceTimersByTime(list.collapseMs); + await list.updateComplete; + expect(rowEl(root, "a")).toBeNull(); // gone, pointer still inside + }); + + it("never renders the whole-list pointer-events lockout", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b")); + await setItems(items("b")); + vi.advanceTimersByTime(list.holdMs); // collapse begins: motion in flight + await list.updateComplete; + expect(root.querySelector(".sw-ll-items")?.classList.contains("sw-ll-lockout")).toBe(false); + }); + + it("a click on a row below a collapsing row is swallowed mid-motion, and lands after", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b", "c")); + const clicks = countItemClicks(list); + await setItems(items("a", "c")); // b ghosts + vi.advanceTimersByTime(list.holdMs); // b begins collapsing: rows below it are moving + await list.updateComplete; + vi.advanceTimersByTime(100); // mid-motion + const c = rowEl(root, "c"); + if (c === null) throw new Error("row c missing"); + fire(c, "click"); + expect(clicks()).toBe(0); // swallowed: c was pulled upward by b's collapse + vi.advanceTimersByTime(list.collapseMs - 100); // motion ends, b removed + await list.updateComplete; + vi.advanceTimersByTime(60); // past the brief guard tail + fire(c, "click"); + expect(clicks()).toBe(1); // the same click lands once the world is still + }); + + it("a click on a row ABOVE the collapsing row lands even mid-motion (target-scoped)", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b", "c")); + const clicks = countItemClicks(list); + await setItems(items("a", "c")); // b ghosts + vi.advanceTimersByTime(list.holdMs); + await list.updateComplete; + vi.advanceTimersByTime(100); // mid-motion + const a = rowEl(root, "a"); + if (a === null) throw new Error("row a missing"); + fire(a, "click"); + expect(clicks()).toBe(1); // a never moved: it stays clickable throughout + }); + + it("a click just after the motion ended is still swallowed (the guard tail)", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b", "c")); + const clicks = countItemClicks(list); + await setItems(items("a", "c")); + vi.advanceTimersByTime(list.holdMs + list.collapseMs); // b's collapse completes, b removed + await list.updateComplete; + expect(rowEl(root, "b")).toBeNull(); + vi.advanceTimersByTime(20); // within CLICK_GUARD_MS of the motion's end + const c = rowEl(root, "c"); + if (c === null) throw new Error("row c missing"); + fire(c, "click"); + expect(clicks()).toBe(0); // the guard outlives the removed row, briefly + }); + + it("an entering row guards the rows it pushes down, until it settles", async () => { + const { list, setItems, root } = await makeMegaRig(items("a")); + const clicks = countItemClicks(list); + await setItems(items("new", "a")); // new grows in at the top; a moves down + vi.advanceTimersByTime(100); // mid-entrance + const a = rowEl(root, "a"); + if (a === null) throw new Error("row a missing"); + fire(a, "click"); + expect(clicks()).toBe(0); + vi.advanceTimersByTime(list.enterMs - 100 + 60); // entrance done + tail passed + fire(a, "click"); + expect(clicks()).toBe(1); + }); + + it("a swallowed mid-motion click synthesizes no megacolumn activation (the guard beats the controller)", async () => { + // The controller's pointer-activation synthesis listens for clicks on the + // container in the bubble phase; the guard stops propagation in the capture + // phase at the list host, upstream of every bubble listener -- so a swallowed + // click must produce no megacolumn-key either. The post-motion click both + // lands AND synthesizes, proving the listener was there for the guard to beat. + const { root, list, setItems } = await makeMegaRig(items("a", "b", "c")); + let forwards = 0; + root.addEventListener("megacolumn-key", () => { + forwards += 1; + }); + await setItems(items("a", "c")); // b ghosts + vi.advanceTimersByTime(list.holdMs); // b begins collapsing: c is a moving target + await list.updateComplete; + vi.advanceTimersByTime(100); // mid-motion + const c = rowEl(root, "c"); + if (c === null) throw new Error("row c missing"); + fire(c, "click"); + expect(forwards).toBe(0); // guarded: not even a synthesized activation + vi.advanceTimersByTime(list.collapseMs - 100); // motion ends + await list.updateComplete; + vi.advanceTimersByTime(60); // past the guard tail + fire(c, "click"); + expect(forwards).toBe(1); // the world is still: the click activates c + }); + + it("the megacolumn mousedown focus-sync respects the same guard", async () => { + const { ctl, list, setItems, root } = await makeMegaRig(items("a", "b", "c")); + const c = rowEl(root, "c"); + const a = rowEl(root, "a"); + if (c === null || a === null) throw new Error("rows missing"); + fire(c, "mousedown"); + expect(ctl.nav.focusedId).toBe("c"); // still world: mousedown syncs focus + await setItems(items("a", "c")); // b ghosts + vi.advanceTimersByTime(list.holdMs); // b collapsing + await list.updateComplete; + vi.advanceTimersByTime(100); // mid-motion + fire(a, "mousedown"); + expect(ctl.nav.focusedId).toBe("a"); // above the motion: sync lands + fire(c, "mousedown"); + expect(ctl.nav.focusedId).toBe("a"); // below the motion: sync swallowed + vi.advanceTimersByTime(list.collapseMs - 100); // motion ends + await list.updateComplete; + vi.advanceTimersByTime(60); + fire(c, "mousedown"); + expect(ctl.nav.focusedId).toBe("c"); // still again: sync lands + }); + + it("guards shadow-mode rows the same way (composed events cross the boundary)", async () => { + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.keyOf = keyOf; + list.renderItem = renderItem; + list.items = items("a", "b", "c"); + document.body.appendChild(list); + await list.updateComplete; + const clicks = countItemClicks(list); + list.items = items("a", "c"); // b ghosts + await list.updateComplete; + vi.advanceTimersByTime(list.holdMs); // b collapsing + await list.updateComplete; + vi.advanceTimersByTime(100); + const slot = (key: string): Element | null => + list.shadowRoot?.querySelector(`[data-key="${key}"]`) ?? null; + const c = slot("c"); + const a = slot("a"); + if (c === null || a === null) throw new Error("rows missing"); + fire(c, "click"); + expect(clicks()).toBe(0); // below the collapse: swallowed + fire(a, "click"); + expect(clicks()).toBe(1); // above it: lands + }); +}); + +describe("freeze mode (the opt-in): geometry holds still under the pointer", () => { + // The old default, preserved behind pointerSafety="freeze": hovering defers + // hold-expiry AND insertions, pointer-leave releases with the stagger, and the + // pointer-events lockout (+ graceMs tail) covers what motion does happen. + + it("the hover pin defers insertions (position aim; the freeze rule)", async () => { + const { list, setItems, root } = await makeMegaRig(items("a")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("new", "a")); + expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBe("true"); // pending + list.dispatchEvent(new Event("pointerleave")); + await list.updateComplete; + expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull(); + }); + + it("removal still ghosts immediately under the pointer (only geometry defers, never data)", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("b")); // a leaves the data + const ghost = rowEl(root, "a"); + expect(ghost?.textContent).toContain("(done)"); // committed variant, at once + expect(ghost?.getAttribute("aria-hidden")).toBeNull(); // held open, not collapsed + }); + + it("applies the pointer-events lockout while geometry is in motion", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b")); + list.pointerSafety = "freeze"; + await setItems(items("b")); // pointer outside: motion proceeds + vi.advanceTimersByTime(list.holdMs); // collapse begins + await list.updateComplete; + expect(root.querySelector(".sw-ll-items")?.classList.contains("sw-ll-lockout")).toBe(true); + }); + + it("a focused ghost held by both pins survives the key pin decaying (hover holds on)", async () => { + const { list, setItems, root, key } = await makeMegaRig(items("a", "b")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + key("ArrowUp"); // pin armed; focus stays on a + await setItems(items("b")); + vi.advanceTimersByTime(list.holdMs); // expired under both pins + vi.advanceTimersByTime(list.keyPinMs); // key pin decays; pointer still in + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); + list.dispatchEvent(new Event("pointerleave")); // last pin releases + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("a focused ghost held by both pins survives the pointer leaving (key pin holds on)", async () => { + const { list, setItems, root, key } = await makeMegaRig(items("a", "b")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("b")); + vi.advanceTimersByTime(list.holdMs - 50); + key("ArrowUp"); // typing resumes just before the hold expires; focus is on a + vi.advanceTimersByTime(50); // expired under both pins + list.dispatchEvent(new Event("pointerleave")); // hover releases; the key pin still holds + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); + vi.advanceTimersByTime(list.keyPinMs); // last pin releases + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("pointer-leave releases deferred collapses in hold-expiry order, staggered", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b", "c")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("b", "c")); // a removed at t=0 + vi.advanceTimersByTime(500); + await setItems(items("c")); // b removed at t=500 + vi.advanceTimersByTime(list.holdMs + 500); // both holds long expired under the pointer + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); + expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull(); + list.dispatchEvent(new Event("pointerleave")); + await list.updateComplete; + // The backlog does not detonate as one batch: a (expired first) goes now, + // b follows a beat later -- each exit stays narratable. + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); + expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBeNull(); + vi.advanceTimersByTime(80); + await list.updateComplete; + expect(rowEl(root, "b")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("switching back to click-guard releases the deferred backlog", async () => { + const { list, setItems, root } = await makeMegaRig(items("a", "b")); + list.pointerSafety = "freeze"; + list.dispatchEvent(new Event("pointerenter")); + await setItems(items("new", "b")); // a removed AND new inserted, both deferred + vi.advanceTimersByTime(list.holdMs + 100); // a's hold expires under the pointer + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBeNull(); // deferred ghost + expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBe("true"); // pending + list.pointerSafety = "click-guard"; // the pointer never left + await list.updateComplete; + expect(rowEl(root, "a")?.getAttribute("aria-hidden")).toBe("true"); // collapsing now + expect(rowEl(root, "new")?.getAttribute("aria-hidden")).toBeNull(); // entering now + }); +}); + +describe("mega mode: fail closed", () => { + // Contract-violation throws happen in custom-element reactions + // (createRenderRoot / connectedCallback), which report to the window instead + // of propagating to the DOM call that triggered them -- so observe the report. + function reportsFrom(act: () => void): string[] { + const reported: string[] = []; + const onError = (e: ErrorEvent): void => { + reported.push(e.message); + e.preventDefault(); + }; + window.addEventListener("error", onError); + act(); + window.removeEventListener("error", onError); + return reported; + } + + it("throws outside any [data-mc-container]", () => { + const reported = reportsFrom(() => { + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.setAttribute("megacolumn", ""); + list.keyOf = keyOf; + list.renderItem = renderItem; + document.body.appendChild(list); + }); + expect(reported.some((m) => /outside any \[data-mc-container\]/.test(m))).toBe(true); + }); + + it("throws on element children (mega mode has no slots)", () => { + const container = document.createElement("div"); + container.setAttribute("data-mc-container", ""); + document.body.appendChild(container); + const reported = reportsFrom(() => { + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.setAttribute("megacolumn", ""); + list.innerHTML = `<div slot="empty">nope</div>`; + list.keyOf = keyOf; + list.renderItem = renderItem; + container.appendChild(list); + }); + expect(reported.some((m) => /no slots/.test(m))).toBe(true); + }); + + it("throws on itemStyles in mega mode", async () => { + const { list } = await makeMegaRig(items("a")); + const { css } = await import("lit"); + list.itemStyles = css`.x { color: red; }`; + await expect(list.updateComplete).rejects.toThrow(/itemStyles has no effect/); + }); + + it("throws on an unknown pointerSafety value", async () => { + const { list } = await makeMegaRig(items("a")); + list.pointerSafety = "frozen" as typeof list.pointerSafety; // a markup typo, say + await expect(list.updateComplete).rejects.toThrow(/pointerSafety/); + }); +}); + +describe("shadow mode regression (the pieces this stage touched)", () => { + it("renders in its own shadow root, no data-mc-row stamped, empty slot alive", async () => { + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.keyOf = keyOf; + list.renderItem = renderItem; + list.items = items("a"); + document.body.appendChild(list); + await list.updateComplete; + expect(list.shadowRoot).not.toBeNull(); + const slot = list.shadowRoot?.querySelector(`[data-key="a"]`) as HTMLElement; + expect(slot).not.toBeNull(); + expect(slot.hasAttribute("data-mc-row")).toBe(false); + expect(list.shadowRoot?.querySelector("slot[name=empty]")).toBeNull(); // a row is visible + list.items = []; + await list.updateComplete; + vi.advanceTimersByTime(list.holdMs + list.collapseMs); + await list.updateComplete; + expect(list.shadowRoot?.querySelector("slot[name=empty]")).not.toBeNull(); + }); + + it("renderEmpty wins over the slot in shadow mode too", async () => { + const list = document.createElement("sw-live-list") as SwLiveList<Item>; + list.keyOf = keyOf; + list.renderItem = renderItem; + list.items = []; + list.renderEmpty = () => html`<div class="idle">idle</div>`; + document.body.appendChild(list); + await list.updateComplete; + expect(list.shadowRoot?.querySelector(".idle")).not.toBeNull(); + expect(list.shadowRoot?.querySelector("slot[name=empty]")).toBeNull(); + }); +}); diff --git a/packages/yukey/src/sw-live-list.ts b/packages/yukey/src/sw-live-list.ts new file mode 100644 index 0000000..2a702be --- /dev/null +++ b/packages/yukey/src/sw-live-list.ts @@ -0,0 +1,809 @@ +// sw-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: +// +// enter ──► live ──────────► committed ────► collapsing ──► removed +// (grow-in: (content updates (item left the (grid-rows (DOM dropped; +// motion) flow freely) data: full size, 1fr→0fr: the guard tail +// ▲ 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, sw-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 sw-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). + +import type { CSSResult, PropertyValues, TemplateResult } from "lit"; +import { adoptStyles, css, html, LitElement, nothing } from "lit"; +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. +export type LiveListPhase = "live" | "committed"; + +export interface ItemClickDetail<T> { + key: string; + item: T; + phase: LiveListPhase; +} + +// The row-lifecycle styles, shared by both render modes. Class names carry an +// sw-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. */ + .sw-ll-items.sw-ll-lockout { + pointer-events: none; + } + .sw-ll-items:focus { + outline: none; + } + .sw-ll-rowslot { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows var(--sw-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] + 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; + } + .sw-ll-rowslot.sw-ll-grew { + animation: sw-live-list-enter var(--sw-live-list-enter, 300ms) ease; + } + .sw-ll-rowslot.sw-ll-closed { + grid-template-rows: 0fr; + } + .sw-ll-rowslot > .sw-ll-clip { + overflow: hidden; + } + @keyframes sw-live-list-enter { + from { + grid-template-rows: 0fr; + } + } + /* Geometry snaps instantly under reduced motion; the hold phase stays (it is + semantics -- an announcement plus click safety -- not decoration), and the + grace lockout still covers the snap (the JS side zeroes its durations too). */ + @media (prefers-reduced-motion: reduce) { + .sw-ll-rowslot { + transition: none; + animation: none; + } + } +`; + +// 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` + sw-live-list { + display: block; + } + ${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. +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. +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. +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. +interface GuardEntry { + movedKey: string | null; + anchorKey: string | null; + until: number; +} + +interface RowState<T> { + key: string; + item: T; // last-known content; frozen at commit time for ghosts + 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: 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: boolean; + // 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 +} + +@customElement("sw-live-list") +export class SwLiveList<T = unknown> extends LitElement { + static styles = [ + css` + :host { + display: block; + } + `, + lifecycleStyles, + ]; + + @property({ attribute: false }) items: T[] = []; + @property({ attribute: false }) keyOf!: (item: T) => string; + @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. + @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. + @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. + @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. + @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.) + @property({ type: Number }) holdMs = 1200; + @property({ type: Number }) collapseMs = 300; + @property({ type: Number }) graceMs = 200; + @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. + @property({ type: Number }) keyPinMs = 200; + + // 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 + 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. + 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. + private epoch = 0; + // Mega mode plumbing: fixed at createRenderRoot (the attribute is static markup); + // keyPinned is the key-activity pin, its timer the decay. + private mega = false; + private megaContainer: HTMLElement | null = null; + private keyPinned = false; + private keyPinTimer: number | undefined; + + @query(".sw-ll-items") private itemsEl?: HTMLElement; + + constructor() { + super(); + // Pin listeners live on the host: under freeze mode's lockout the item + // container loses pointer-events and hit-testing falls through to the host, + // 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 + // both the row content's own listeners (an sw-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 + // every downstream consumer in one place, and the decision stays in the list + // (it owns the physics the guard is derived from). + this.addEventListener("click", this.onGuardedPointer, { capture: true }); + 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. + protected override createRenderRoot(): HTMLElement | DocumentFragment { + if (!this.hasAttribute("megacolumn")) return super.createRenderRoot(); + this.mega = true; + if (this.children.length > 0) + throw new Error( + "sw-live-list: megacolumn mode renders into the light DOM and has no slots; " + + "use the renderEmpty render-prop", + ); + adoptIntoRoot(this.getRootNode(), megaModeStyles, "data-sw-live-list"); + return this; + } + + override connectedCallback(): void { + super.connectedCallback(); // first connect runs createRenderRoot, deciding mega + if (this.mega) { + // The key-activity pin listens on the column's one real focusable: every + // keydown a user aims anywhere in the column lands there first. + const container = this.closest<HTMLElement>("[data-mc-container]"); + if (container === null) + throw new Error("sw-live-list: megacolumn mode outside any [data-mc-container]"); + this.megaContainer = container; + container.addEventListener("keydown", this.onKeyActivity); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.hardReset(); + this.pinned = false; // pointerleave may never fire on a removed element + this.megaContainer?.removeEventListener("keydown", this.onKeyActivity); + this.megaContainer = null; + window.clearTimeout(this.keyPinTimer); + this.keyPinTimer = undefined; + this.keyPinned = false; + } + + override willUpdate(changed: PropertyValues<this>): void { + if (this.mega && this.itemStyles !== undefined) + throw new Error( + "sw-live-list: itemStyles has no effect in megacolumn mode; " + + "style rows from the owner's own stylesheet", + ); + if (this.pointerSafety !== "click-guard" && this.pointerSafety !== "freeze") + throw new Error( + `sw-live-list: unknown pointerSafety "${this.pointerSafety}" ` + + '(the modes are "click-guard" and "freeze")', + ); + // Leaving freeze mode releases whatever the hover pin deferred (the pointer + // may never leave); entering it starts deferring from here on. releaseDeferred + // re-checks every deferral condition itself, so one unconditional call covers + // both directions. + if (changed.has("pointerSafety")) this.releaseDeferred(); + if (changed.has("resetKey") && this.hasReconciled) this.hardReset(); + if (changed.has("items") || changed.has("resetKey")) this.reconcile(); + } + + protected override updated(changed: PropertyValues<this>): void { + if (changed.has("itemStyles") && this.itemStyles !== undefined && this.shadowRoot !== null) { + const own = (this.constructor as typeof SwLiveList).elementStyles; + adoptStyles(this.shadowRoot, [...own, this.itemStyles]); + } + // 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 + // 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 })); + } + + // Whether freeze mode governs the pointer story right now (see the header). + private get frozen(): boolean { + return this.pointerSafety === "freeze"; + } + + // --- reconciliation: data ticks in, row phases advance --- + + private reconcile(): void { + const fresh = !this.hasReconciled; + this.hasReconciled = true; + const dataByKey = new Map<string, T>(); + for (const item of this.items) dataByKey.set(this.keyOf(item), item); + // Merged display order: existing rows keep their relative order; each new key + // splices in right after the nearest preceding data key that is already displayed. + const order = [...this.rows.keys()]; + let anchor = -1; // index in `order` of the last data key seen that is displayed + for (const key of dataByKey.keys()) { + const idx = order.indexOf(key); + if (idx !== -1) { + anchor = idx; + continue; + } + anchor += 1; + order.splice(anchor, 0, key); + } + const next = new Map<string, RowState<T>>(); + for (const key of order) { + const row = this.rows.get(key); + const item = dataByKey.get(key); + if (row === undefined) { + if (item === undefined) continue; // unreachable: order = rows ∪ data + const newRow: RowState<T> = { + key, + item, + phase: "live", + grew: false, + holdExpired: false, + holdExpiredSeq: 0, + timer: undefined, + }; + // Fresh renders (first populate, post-reset) skip the entrance: the rows were + // not new arrivals, the viewer was. No animation, no lockout. + if (!fresh) { + if (this.pinned && this.frozen) newRow.phase = "pending"; + else this.beginEnter(newRow); + } + next.set(key, newRow); + } else if (item !== undefined) { + if (row.phase === "committed" || row.phase === "collapsing") { + this.resurrect(row, item); + } else { + row.item = item; // content-only update; flows in every phase, pinned or not + } + next.set(key, row); + } else { + // Key absent from data: begin the exit (or continue one already underway). + if (row.phase === "pending") { + continue; // never shown: no farewell for a row nobody saw + } + if (row.phase === "live" || row.phase === "entering") this.beginHold(row); + next.set(key, row); + } + } + this.rows = next; + } + + // --- phase transitions (each clears the row's previous timer via setRowTimer) --- + + private beginEnter(row: RowState<T>): void { + row.phase = "entering"; + row.grew = true; + const d = this.motionDuration(this.enterMs); + this.beginMotion(row, d); + this.setRowTimer(row, d, () => { + row.phase = "live"; + this.requestUpdate(); + }); + } + + private beginHold(row: RowState<T>): void { + row.phase = "committed"; + // The item stays as last rendered: committed rows freeze their content. + this.setRowTimer(row, this.holdMs, () => { + if (this.collapseDeferred(row)) { + // Collapse deferred until no pin covers this row anymore; the seq + // records expiry order for the staggered release. + row.holdExpired = true; + row.holdExpiredSeq = this.expirySeq++; + return; + } + this.beginCollapse(row); + this.requestUpdate(); + }); + } + + private beginCollapse(row: RowState<T>): void { + row.phase = "collapsing"; + row.holdExpired = false; + const d = this.motionDuration(this.collapseMs); + this.beginMotion(row, d); + this.setRowTimer(row, d, () => { + this.removeRow(row); + this.requestUpdate(); + }); + } + + private resurrect(row: RowState<T>, item: T): void { + const wasCollapsing = row.phase === "collapsing"; + this.clearRowTimer(row); + row.phase = "live"; + row.holdExpired = false; + row.item = item; + // Growing back from mid-collapse is geometry motion too: the motion window + // covers it (lockout in freeze mode, the click guard in click-guard mode). + if (wasCollapsing) this.beginMotion(row, this.motionDuration(this.collapseMs)); + } + + private removeRow(row: RowState<T>): void { + // If real DOM focus is inside the row being dropped, move it out rather than + // letting it die silently: to the mega container (the column's one focusable) + // in mega mode, else to the list's own item container. + const slot = this.renderRoot.querySelector(`.sw-ll-rowslot[data-key="${CSS.escape(row.key)}"]`); + const focusRoot = this.shadowRoot ?? (this.getRootNode() as Document | ShadowRoot); + const active = focusRoot.activeElement; + if (slot !== null && active !== null && slot.contains(active)) { + (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 + // 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); + const anchor = idx > 0 ? (keys[idx - 1] ?? null) : null; + for (const g of this.guards) { + if (g.movedKey === row.key) { + g.movedKey = null; + g.anchorKey = anchor; + } else if (g.movedKey === null && g.anchorKey === row.key) { + g.anchorKey = anchor; + } + } + this.rows.delete(row.key); + } + + // --- 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.) + + 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. + private beginMotion(row: RowState<T>, durationMs: number): void { + const now = performance.now(); + this.guards = this.guards.filter((g) => g.until > now); + this.guards.push({ + movedKey: row.key, + anchorKey: null, + until: now + durationMs + CLICK_GUARD_MS, + }); + this.motionCount++; + if (this.graceTimer !== undefined) { + window.clearTimeout(this.graceTimer); + this.graceTimer = undefined; + } + const epoch = this.epoch; + window.setTimeout(() => { + if (this.epoch === epoch) this.endMotion(); + }, durationMs); + this.requestUpdate(); + } + + private endMotion(): void { + this.motionCount--; + if (this.motionCount === 0) { + this.graceTimer = window.setTimeout(() => { + this.graceTimer = undefined; + this.requestUpdate(); + }, this.graceMs); + } + this.requestUpdate(); + } + + private motionDuration(ms: number): number { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms; + } + + // --- 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). + private onPointerEnter = (): void => { + this.pinned = true; + }; + + private onPointerLeave = (): void => { + this.pinned = false; + this.releaseDeferred(); + }; + + // 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. + private onGuardedPointer = (e: Event): void => { + if (this.frozen) return; // freeze mode's lockout owns this story instead + const target = e.composedPath()[0]; + if (!(target instanceof Element)) return; + const slot = target.closest<HTMLElement>(".sw-ll-rowslot"); + if (slot === null) return; + const key = slot.dataset.key; + if (key === undefined || !this.isMovingTarget(key)) return; + e.stopPropagation(); + 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. + private isMovingTarget(key: string): boolean { + const now = performance.now(); + this.guards = this.guards.filter((g) => g.until > now); + if (this.guards.length === 0) return false; + const keys = [...this.rows.keys()]; + const idx = keys.indexOf(key); + for (const g of this.guards) { + if (g.movedKey !== null) { + if (idx >= keys.indexOf(g.movedKey)) return true; + } else if (g.anchorKey === null) { + return true; // the motion was at the top: everything below it moved + } else if (idx > keys.indexOf(g.anchorKey)) { + return true; + } + } + return false; + } + + // --- 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). + + private onKeyActivity = (): void => { + this.keyPinned = true; + window.clearTimeout(this.keyPinTimer); + // No epoch check here (unlike motion-end timers): the decay is valid across + // resets -- it only flips the pin off and re-examines whatever rows exist then. + this.keyPinTimer = window.setTimeout(() => { + this.keyPinTimer = undefined; + this.keyPinned = false; + this.releaseDeferred(); + }, this.keyPinMs); + // 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. + private releaseDeferred(): void { + let changed = false; + const backlog: RowState<T>[] = []; + for (const row of this.rows.values()) { + if (row.phase === "pending" && !this.hoverPinned) { + this.beginEnter(row); + changed = true; + } else if ( + row.phase === "committed" && + row.holdExpired && + row.timer === undefined && // not already scheduled by an earlier release + !this.collapseDeferred(row) + ) { + backlog.push(row); + } + } + backlog.sort((a, b) => a.holdExpiredSeq - b.holdExpiredSeq); + backlog.forEach((row, i) => { + if (i === 0) { + this.beginCollapse(row); + changed = true; + return; + } + this.setRowTimer(row, i * RELEASE_STAGGER_MS, () => { + if (this.collapseDeferred(row)) return; // re-deferred + this.beginCollapse(row); + this.requestUpdate(); + }); + }); + 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. + 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. + private isRowFocused(row: RowState<T>): boolean { + const slot = this.renderRoot.querySelector(`.sw-ll-rowslot[data-key="${CSS.escape(row.key)}"]`); + return slot?.classList.contains("mc-row-focused") ?? false; + } + + // --- timers and reset plumbing --- + + private setRowTimer(row: RowState<T>, ms: number, fn: () => void): void { + this.clearRowTimer(row); + row.timer = window.setTimeout(() => { + row.timer = undefined; + fn(); + }, ms); + } + + private clearRowTimer(row: RowState<T>): void { + if (row.timer !== undefined) { + window.clearTimeout(row.timer); + row.timer = undefined; + } + } + + private hardReset(): void { + this.epoch++; // orphans in-flight motion-end timers + for (const row of this.rows.values()) this.clearRowTimer(row); + this.rows.clear(); + this.motionCount = 0; + this.guards = []; // the old identity's motion cannot indict the new rows + if (this.graceTimer !== undefined) { + window.clearTimeout(this.graceTimer); + this.graceTimer = undefined; + } + this.hasReconciled = false; + // `pinned` survives: the pointer is still wherever it is. + } + + // --- rendering: a pure map over row state --- + + override render(): TemplateResult { + const rowList = [...this.rows.values()]; + const anyVisible = rowList.some((r) => r.phase !== "pending"); + const vars = `--sw-live-list-enter: ${this.enterMs}ms; --sw-live-list-collapse: ${this.collapseMs}ms;`; + const empty = anyVisible + ? nothing + : (this.renderEmpty?.() ?? (this.mega ? nothing : html`<slot name="empty"></slot>`)); + return html` + <div + class=${classMap({ "sw-ll-items": true, "sw-ll-lockout": this.frozen && this.locked })} + style=${vars} + tabindex="-1" + > + ${repeat( + rowList, + (r) => r.key, + (r) => this.renderRow(r), + )} + </div> + ${empty} + `; + } + + private renderRow(row: RowState<T>): TemplateResult { + // Closed rows are clipped visually (0fr); aria-hidden makes the AT view match, + // and (mega mode) is also what the controller's scan skips: one attribute, + // 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). + // 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. + return html`<div + class=${classMap({ "sw-ll-rowslot": true, "sw-ll-closed": closed, "sw-ll-grew": row.grew })} + data-key=${row.key} + data-mc-row=${this.mega ? row.key : nothing} + aria-hidden=${closed ? "true" : nothing} + @click=${() => this.onRowClick(row)} + > + <div class="sw-ll-clip">${this.renderItem(row.item, phase)}</div> + </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). + private onRowClick(row: RowState<T>): void { + const phase: LiveListPhase = row.phase === "committed" ? "committed" : "live"; + this.dispatchEvent( + new CustomEvent<ItemClickDetail<T>>("item-click", { + detail: { key: row.key, item: row.item, phase }, + bubbles: true, + composed: true, + }), + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + "sw-live-list": SwLiveList; + } +} diff --git a/packages/yukey/src/tab-cycle.test.ts b/packages/yukey/src/tab-cycle.test.ts new file mode 100644 index 0000000..a8f8cef --- /dev/null +++ b/packages/yukey/src/tab-cycle.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +// +// Tests for the tab-cycle helpers (tab-cycle.ts): the shadow-DOM-aware tabbable +// walk and the deep activeElement read that the gallery shell's Tab containment +// is built from. jsdom has no layout, which is fine: the walk is deliberately +// attribute-based (see the header of tab-cycle.ts). + +import { beforeEach, describe, expect, it } from "vitest"; +import { + containTab, + deepActiveElement, + deepTabbables, + installTabContainment, +} from "./tab-cycle.js"; + +function host(innerHtml: string): HTMLElement { + const el = document.createElement("div"); + el.attachShadow({ mode: "open" }).innerHTML = innerHtml; + return el; +} + +beforeEach(() => { + document.body.replaceChildren(); +}); + +describe("deepTabbables", () => { + it("collects tab stops in tree order", () => { + document.body.innerHTML = ` + <a href="#one" id="one">one</a> + <div>plain</div> + <div tabindex="0" id="two">container</div> + <button id="three">three</button> + `; + expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["one", "two", "three"]); + }); + + it("skips negative tabindex and unfocusable elements", () => { + document.body.innerHTML = ` + <input data-mc-input tabindex="-1"> + <div id="only" tabindex="0">container</div> + <span>text</span> + `; + expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["only"]); + }); + + it("skips aria-hidden subtrees", () => { + document.body.innerHTML = ` + <div aria-hidden="true"><button id="ghost">hidden</button></div> + <button id="live">live</button> + `; + expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["live"]); + }); + + it("descends into shadow roots in tree order", () => { + const inner = host(`<div tabindex="0" id="in-shadow">row container</div>`); + document.body.append( + Object.assign(document.createElement("a"), { href: "#a", id: "before", textContent: "a" }), + inner, + ); + const after = document.createElement("button"); + after.id = "after"; + document.body.append(after); + expect(deepTabbables(document.body).map((e) => e.id)).toEqual(["before", "in-shadow", "after"]); + }); +}); + +describe("deepActiveElement", () => { + it("descends the shadow activeElement chain", () => { + const outer = host(`<span></span>`); + document.body.append(outer); + const innerHost = host(`<div tabindex="0" id="deep">deep</div>`); + outer.shadowRoot?.append(innerHost); + const deep = innerHost.shadowRoot?.querySelector<HTMLElement>("#deep"); + deep?.focus(); + expect(deepActiveElement(document)).toBe(deep); + }); + + it("returns the light-DOM active element when no shadow is involved", () => { + document.body.innerHTML = `<button id="b">b</button>`; + const b = document.querySelector<HTMLElement>("#b"); + b?.focus(); + expect(deepActiveElement(document)).toBe(b); + }); +}); + +describe("containTab", () => { + function tab(shift = false): KeyboardEvent { + const e = new KeyboardEvent("keydown", { key: "Tab", shiftKey: shift, cancelable: true }); + containTab(e, document); + return e; + } + + beforeEach(() => { + document.body.innerHTML = ` + <button id="first">first</button> + <button id="mid">mid</button> + <button id="last">last</button> + `; + }); + + it("wraps Tab from the last stop to the first", () => { + document.querySelector<HTMLElement>("#last")?.focus(); + const e = tab(); + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement?.id).toBe("first"); + }); + + it("wraps shift-Tab from the first stop to the last", () => { + document.querySelector<HTMLElement>("#first")?.focus(); + const e = tab(true); + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement?.id).toBe("last"); + }); + + it("leaves interior stops to native traversal", () => { + document.querySelector<HTMLElement>("#mid")?.focus(); + const e = tab(); + expect(e.defaultPrevented).toBe(false); + expect(document.activeElement?.id).toBe("mid"); // jsdom has no native traversal; untouched + }); + + it("does nothing when focus is outside the tab order (a text-entry input)", () => { + document.body.insertAdjacentHTML("beforeend", `<input id="dive" tabindex="-1">`); + document.querySelector<HTMLElement>("#dive")?.focus(); + const e = tab(); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("installTabContainment", () => { + function pressTab(shift = false): KeyboardEvent { + const e = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: shift, + cancelable: true, + bubbles: true, + }); + document.activeElement?.dispatchEvent(e); + return e; + } + + beforeEach(() => { + document.body.innerHTML = ` + <button id="first">first</button> + <button id="last">last</button> + `; + }); + + it("installs once per document: first call true, repeats false, edges contained", () => { + // One test owns the whole install lifecycle: the WeakSet is per-document and + // jsdom shares one document across this file, so split assertions would + // couple to test order. + expect(installTabContainment(document)).toBe(true); + expect(installTabContainment(document)).toBe(false); + document.querySelector<HTMLElement>("#last")?.focus(); + const e = pressTab(); + expect(e.defaultPrevented).toBe(true); + expect(document.activeElement?.id).toBe("first"); + const back = pressTab(true); + expect(back.defaultPrevented).toBe(true); + expect(document.activeElement?.id).toBe("last"); + }); +}); diff --git a/packages/yukey/src/tab-cycle.ts b/packages/yukey/src/tab-cycle.ts new file mode 100644 index 0000000..6d7cb8e --- /dev/null +++ b/packages/yukey/src/tab-cycle.ts @@ -0,0 +1,79 @@ +// 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 +// (sw-live-list's collapsing rows), and a tab stop invisible to AT must not be +// a tab stop at all. + +function isTabbable(el: Element): boolean { + if (!(el instanceof HTMLElement)) return false; + if (el.tabIndex < 0) return false; + if (el.hasAttribute("tabindex")) return true; + return el.matches("a[href], button, input, select, textarea, [contenteditable='true']"); +} + +// All tab stops under `root` in tree order, descending open shadow roots. +export function deepTabbables(root: ParentNode): HTMLElement[] { + const out: HTMLElement[] = []; + const walk = (node: ParentNode): void => { + for (const child of node.children) { + if (child.getAttribute("aria-hidden") === "true") continue; + if (isTabbable(child)) out.push(child as HTMLElement); + if (child.shadowRoot !== null) walk(child.shadowRoot); + walk(child); + } + }; + walk(root); + return out; +} + +// 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) { + active = active.shadowRoot.activeElement; + } + 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. +export function containTab(e: KeyboardEvent, doc: Document): void { + if (e.key !== "Tab") return; + const stops = deepTabbables(doc.body); + const first = stops[0]; + const last = stops[stops.length - 1]; + if (first === undefined || last === undefined) return; + const active = deepActiveElement(doc); + const edge = e.shiftKey ? first : last; + if (active !== edge) return; + e.preventDefault(); + (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. +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). +export function installTabContainment(doc: Document): boolean { + if (installed.has(doc)) return false; + installed.add(doc); + doc.defaultView?.addEventListener("keydown", (e) => containTab(e, doc)); + return true; +} diff --git a/packages/yukey/src/tokens.gallery.ts b/packages/yukey/src/tokens.gallery.ts new file mode 100644 index 0000000..f9aaecf --- /dev/null +++ b/packages/yukey/src/tokens.gallery.ts @@ -0,0 +1,328 @@ +// Gallery entry for the design-token vocabulary: the living view of tokens.ts. +// Everything renders from tokenGroups itself, so this page cannot drift from the +// stylesheet -- edit a token value, rebuild, and the swatch, ramp, and demo all +// show the new truth. This page is the iteration venue for the look: change +// values in tokens.ts, watch here. + +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { GalleryEntry } from "./gallery/entry.js"; +import "./sw-button.js"; +import { type TokenDef, tokenGroups } from "./tokens.js"; + +// One demo element, four faces (palette / spacing / type / focus): the variants +// below pick a face. A single element keeps the styles in one block. +@customElement("swg-tokens") +export class SwgTokens extends LitElement { + static styles = css` + :host { + display: block; + font: var(--sw-type-body) var(--sw-font); + color: var(--sw-text); + } + .group { + margin-bottom: var(--sw-space-xl); + } + .group:last-child { + margin-bottom: 0; + } + .group-title { + color: var(--sw-text-dim); + letter-spacing: var(--sw-tracking-wide); + text-transform: uppercase; + font-size: var(--sw-type-small); + border-bottom: 1px solid var(--sw-border-dim); + padding-bottom: var(--sw-space-xs); + margin-bottom: var(--sw-space-xs); + } + .group-about { + color: var(--sw-text-faint); + max-width: 70ch; + margin-bottom: var(--sw-space-md); + } + + /* palette rows: swatch | name | value | role */ + .trow { + display: grid; + grid-template-columns: 72px 220px minmax(120px, 320px) 1fr; + gap: var(--sw-space-md); + align-items: center; + min-height: 28px; + margin-bottom: var(--sw-space-xs); + } + .swatch { + height: 28px; + border: 1px solid var(--sw-border-dim); + /* checkerboard underlay so translucent washes read as translucent */ + background: + repeating-conic-gradient(#2a3448 0 25%, #141c2c 0 50%) 0 0 / 14px 14px; + } + .swatch > div { + height: 100%; + } + .tname { + color: var(--sw-text-hi); + user-select: all; /* one click selects the whole name, ready to copy */ + } + .tvalue { + color: var(--sw-text-faint); + font-size: var(--sw-type-small); + overflow-wrap: anywhere; + } + .tabout { + color: var(--sw-text-dim); + } + + /* spacing & hit-target bars */ + .bar { + height: 14px; + background: var(--sw-accent); + } + .hitbox { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--sw-hit-min-h); + min-width: var(--sw-hit-min-w); + border: 1px solid var(--sw-border); + color: var(--sw-text-dim); + } + .antbox { + display: inline-flex; + width: 8px; + height: 8px; + border: 1px solid var(--sw-warn); + } + + /* type specimens */ + .specimen { + color: var(--sw-text-bright); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* focus & hotkey demos */ + button.demo { + background: none; + border: 1px solid var(--sw-border); + color: var(--sw-text); + font: inherit; + cursor: pointer; + min-height: var(--sw-hit-min-h); + min-width: var(--sw-hit-min-w); + padding: 2px var(--sw-space-md); + } + button.demo:hover { + color: var(--sw-text-bright); + border-color: var(--sw-border-bright); + } + button.demo:focus-visible { + outline: var(--sw-focus-ring); + outline-offset: var(--sw-focus-ring-offset); + } + .ring-always { + outline: var(--sw-focus-ring); + outline-offset: var(--sw-focus-ring-offset); + } + .demo-row { + display: flex; + align-items: center; + gap: var(--sw-space-lg); + margin: var(--sw-space-md) 0 var(--sw-space-lg); + } + .note { + color: var(--sw-text-faint); + max-width: 70ch; + } + `; + + @property() face: "palette" | "spacing" | "type" | "focus" = "palette"; + + render(): TemplateResult { + switch (this.face) { + case "palette": + return this.renderPalette(); + case "spacing": + return this.renderSpacing(); + case "type": + return this.renderType(); + case "focus": + return this.renderFocus(); + } + } + + private static paletteRow(t: TokenDef): TemplateResult { + return html`<div class="trow"> + <div class="swatch"><div style="background: var(${t.name});"></div></div> + <span class="tname">${t.name}</span> + <span class="tvalue">${t.value}</span> + <span class="tabout">${t.about}</span> + </div>`; + } + + private renderPalette(): TemplateResult { + const colorGroups = tokenGroups + .map((g) => ({ ...g, tokens: g.tokens.filter((t) => t.kind === "color") })) + .filter((g) => g.tokens.length > 0); + return html`${colorGroups.map( + (g) => html`<div class="group"> + <div class="group-title">${g.title}</div> + <div class="group-about">${g.about}</div> + ${g.tokens.map((t) => SwgTokens.paletteRow(t))} + </div>`, + )}`; + } + + private renderSpacing(): TemplateResult { + const spacing = tokenGroups.find((g) => g.id === "spacing"); + const hit = tokenGroups.find((g) => g.id === "hit"); + if (spacing === undefined || hit === undefined) return html`missing group`; + return html` + <div class="group"> + <div class="group-title">${spacing.title}</div> + <div class="group-about">${spacing.about}</div> + ${spacing.tokens.map( + (t) => html`<div class="trow"> + <div><div class="bar" style="width: var(${t.name});"></div></div> + <span class="tname">${t.name}</span> + <span class="tvalue">${t.value}</span> + <span class="tabout">${t.about}</span> + </div>`, + )} + </div> + <div class="group"> + <div class="group-title">${hit.title}</div> + <div class="group-about">${hit.about}</div> + <div class="demo-row"> + <span class="hitbox">min</span> + <span class="note">the smallest legal interactable + (${hit.tokens.map((t) => t.name).join(", ")})</span> + </div> + <div class="demo-row"> + <span class="antbox"></span> + <span class="note">8px by 8px, for contrast: the guidelines' "we are not ants" bound. + Nothing interactable may be this.</span> + </div> + </div> + `; + } + + private renderType(): TemplateResult { + const type = tokenGroups.find((g) => g.id === "type"); + if (type === undefined) return html`missing group`; + const sizes = type.tokens.filter((t) => t.name.startsWith("--sw-type-")); + const rest = type.tokens.filter((t) => !t.name.startsWith("--sw-type-")); + return html`<div class="group"> + <div class="group-title">${type.title}</div> + <div class="group-about">${type.about}</div> + ${sizes.map( + (t) => html`<div class="trow"> + <span class="tvalue">${t.value}</span> + <span class="tname">${t.name}</span> + <span class="specimen" style="font-size: var(${t.name});">Kestrel Reach 74/74</span> + <span class="tabout">${t.about}</span> + </div>`, + )} + ${rest.map( + (t) => html`<div class="trow"> + <span class="tvalue">${t.value}</span> + <span class="tname">${t.name}</span> + <span class="specimen" style="letter-spacing: var(${t.name}); text-transform: uppercase;" + >Kestrel Reach</span> + <span class="tabout">${t.about}</span> + </div>`, + )} + </div>`; + } + + private renderFocus(): TemplateResult { + const motion = tokenGroups.find((g) => g.id === "motion"); + if (motion === undefined) return html`missing group`; + return html` + <div class="group"> + <div class="group-title">focus ring</div> + <div class="demo-row"> + <button class="demo">tab to me</button> + <button class="demo">then to me</button> + <button class="demo ring-always">ring, always on</button> + </div> + <div class="note"> + The first two show the ring only on keyboard focus (:focus-visible); + the third wears it permanently for inspection. + Composite: --sw-focus-ring, offset by --sw-focus-ring-offset. + </div> + </div> + <div class="group"> + <div class="group-title">hotkey marking</div> + <div class="demo-row"> + <sw-button label="dock" hotkey="d"></sw-button> + <sw-button label="engage" hotkey="7"></sw-button> + </div> + <div class="note"> + The guidelines' button-marking norm, in --sw-hotkey-accent: + letter-in-label when the hotkey appears in the label, + else the letter in a slot 70% of the way across. + These are live sw-buttons; the full behavior tour is the sw-button entry. + </div> + </div> + <div class="group"> + <div class="group-title">${motion.title}</div> + <div class="group-about">${motion.about}</div> + ${motion.tokens.map( + (t) => html`<div class="trow"> + <span class="tvalue">${t.value}</span> + <span class="tname">${t.name}</span> + <span class="tabout">${t.about}</span> + </div>`, + )} + <div class="demo-row"> + <sw-button label="press me" hotkey="p"></sw-button> + <span class="note"> + Every activation (click, Enter/Space, hotkey) flashes the pressed look + for --sw-press-ms; holding the mouse down shows the same look for as + long as it is held. The wash is --sw-wash-pressed. + </span> + </div> + </div> + `; + } +} + +const entry: GalleryEntry = { + id: "tokens", + title: "design tokens", + description: + "The visual vocabulary everything else is built from: --sw-* custom properties, " + + "rendered live from their definitions in tokens.ts. Names are semantic roles and are " + + "the durable part; values are the iteration surface -- edit tokens.ts, rebuild, " + + "and this page shows the new truth. Intent notes live in docs/precepts-aesthetics.md.", + variants: [ + { + caption: + "The palette, by role. Swatches sit on a checkerboard so translucent washes read as " + + "translucent; wash values derive from their base color, so retinting a base retints its washes.", + wide: true, + render: () => html`<swg-tokens face="palette"></swg-tokens>`, + }, + { + caption: "The spacing ramp (gaps carry meaning) and the hit-target minimums.", + wide: true, + render: () => html`<swg-tokens face="spacing"></swg-tokens>`, + }, + { + caption: "The type ramp and tracking values, each rendered at its own size.", + wide: true, + render: () => html`<swg-tokens face="type"></swg-tokens>`, + }, + { + caption: + "Keyboard visibility: the focus ring (try tabbing through), the two hotkey-marking forms, " + + "and the activation flash (press things; watch them acknowledge).", + wide: true, + render: () => html`<swg-tokens face="focus"></swg-tokens>`, + }, + ], +}; + +export default entry; diff --git a/packages/yukey/src/tokens.test.ts b/packages/yukey/src/tokens.test.ts new file mode 100644 index 0000000..892d969 --- /dev/null +++ b/packages/yukey/src/tokens.test.ts @@ -0,0 +1,57 @@ +// Tests for the token vocabulary's structural invariants. Values are deliberately +// NOT pinned here: they're a v0 seed meant to be iterated hard, and a test that +// breaks on every retint would train people to update it blindly. What must hold +// still is the shape: well-formed names, no duplicates, and a stylesheet that +// actually contains every defined token. + +import { describe, expect, it } from "vitest"; +import { tokenGroups, tokensCssText } from "./tokens.js"; + +describe("the token vocabulary", () => { + const all = tokenGroups.flatMap((g) => g.tokens); + + it("names every token in the --sw- namespace, lowercase-and-dashes only", () => { + for (const t of all) { + expect(t.name).toMatch(/^--sw-[a-z][a-z0-9-]*$/); + } + }); + + it("has no duplicate token names", () => { + const names = all.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("has no duplicate group ids", () => { + const ids = tokenGroups.map((g) => g.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("describes every token and group (the gallery renders these)", () => { + for (const g of tokenGroups) { + expect(g.title).not.toBe(""); + expect(g.about).not.toBe(""); + } + for (const t of all) { + expect(t.about).not.toBe(""); + } + }); + + it("emits every token into the :root stylesheet, values non-empty", () => { + const cssText = tokensCssText(tokenGroups); + expect(cssText).toMatch(/^:root \{\n/); + expect(cssText).toMatch(/\n\}$/); + for (const t of all) { + expect(t.value).not.toBe(""); + expect(cssText).toContain(`${t.name}: ${t.value};`); + } + }); + + it("only references other tokens that exist (var() cycles aside, typos die here)", () => { + const names = new Set<string>(all.map((t) => t.name)); + for (const t of all) { + for (const m of t.value.matchAll(/var\((--[a-z0-9-]+)\)/g)) { + expect(names, `${t.name} references ${m[1]}`).toContain(m[1]); + } + } + }); +}); diff --git a/packages/yukey/src/tokens.ts b/packages/yukey/src/tokens.ts new file mode 100644 index 0000000..10d4cb4 --- /dev/null +++ b/packages/yukey/src/tokens.ts @@ -0,0 +1,451 @@ +// The design-token vocabulary: every color, gap, size, and type choice the UI is +// built from, as CSS custom properties. This file is the SSOT for token names and +// values; docs/precepts-aesthetics.md describes the intent, and the `tokens` gallery +// entry renders this structure live. +// +// Tokens are defined as data (tokenGroups) and the :root stylesheet is generated +// from it, so the stylesheet and the gallery's living documentation can never +// disagree. Both entry points call installTokens(); custom properties inherit +// through shadow boundaries, so components just write `var(--sw-...)`. +// +// Naming is the durable part: tokens are named for their semantic role, so values +// can be iterated hard (they will be) while the meaning holds still. The values +// here are a v0 seed extracted from the UI's de facto dark/teal SCADA palette, +// not an enshrinement. + +import type { CSSResult } from "lit"; +import { unsafeCSS } from "lit"; + +// 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: `--sw-${string}`; + value: string; + kind: TokenKind; + // 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: string; + title: string; + about: string; + tokens: TokenDef[]; +} + +export const tokenGroups: TokenGroup[] = [ + { + id: "surfaces", + title: "surfaces", + about: "Background layers, from the void of space up to floating overlays.", + tokens: [ + { + name: "--sw-bg-void", + value: "#05080f", + kind: "color", + about: "the deepest layer: the map background, the page behind everything", + }, + { + name: "--sw-bg-panel-solid", + value: "#0a1220", + kind: "color", + about: + "opaque panel surface: cards, sticky headers, toasts -- anywhere bleed-through would muddy", + }, + { + name: "--sw-bg-panel", + value: "color-mix(in srgb, var(--sw-bg-panel-solid) 94%, transparent)", + kind: "color", + about: "translucent panel surface: big workspace panels floating over the map", + }, + { + name: "--sw-bg-well", + value: "#0d1626", + kind: "color", + about: "inset areas within a panel: chart plots, code samples", + }, + { + name: "--sw-bg-raised", + value: "#101a2c", + kind: "color", + about: "small floating surfaces above panel content: tooltips, popovers", + }, + ], + }, + { + id: "borders", + title: "borders", + about: "Panel edges and separators. Brightness tracks prominence.", + tokens: [ + { + name: "--sw-border", + value: "#3d5578", + kind: "color", + about: "the standard chrome border: panel edges, buttons, separator bars", + }, + { + name: "--sw-border-dim", + value: "#1c2a40", + kind: "color", + about: "subtle borders: section rules, chart frames, quiet separations", + }, + { + name: "--sw-border-bright", + value: "#5a7290", + kind: "color", + about: "emphasized borders: hover states on bordered controls", + }, + ], + }, + { + id: "text", + title: "text", + about: "The gray-blue text ramp, five tiers from emphasized to nearly-ambient.", + tokens: [ + { + name: "--sw-text-bright", + value: "#dfe9f5", + kind: "color", + about: "primary emphasis: names, values, hover text -- what the eye should land on", + }, + { + name: "--sw-text-hi", + value: "#a8bdd8", + kind: "color", + about: "secondary emphasis: entity names in rows, highlighted prose", + }, + { + name: "--sw-text", + value: "#8194ac", + kind: "color", + about: "body text: the default reading color", + }, + { + name: "--sw-text-dim", + value: "#5a7290", + kind: "color", + about: "dim text: labels, section headings, quiet chrome", + }, + { + name: "--sw-text-faint", + value: "#48566b", + kind: "color", + about: "faint text: ids, empty states, the almost-not-there tier", + }, + ], + }, + { + id: "accent", + title: "accent", + about: + "The interactive blue family: selected and hovered things, meters, active chrome. " + + "Washes derive from the accent, so retinting it retints them.", + tokens: [ + { + name: "--sw-accent", + value: "#4f7ba8", + kind: "color", + about: "the interactive blue: meter fills, checkboxes, chart lines, attention borders", + }, + { + name: "--sw-accent-bright", + value: "#8fb3d9", + kind: "color", + about: "the accent as text: app title, score, links", + }, + { + name: "--sw-wash-pressed", + value: "hsl(from var(--sw-accent) h calc(s * 1.5) calc(l * 1.2) / 0.5)", + kind: "color", + about: + "background wash for a control being activated: held under a pointer, " + + "or flashing for --sw-press-ms after triggering. Deliberately more saturated " + + "and brighter than the accent it derives from: activation must outrank selection at a glance", + }, + { + name: "--sw-wash-selected", + value: "color-mix(in srgb, var(--sw-accent) 25%, transparent)", + kind: "color", + about: "background wash for the selected row / the toggled-on button", + }, + { + name: "--sw-wash-hover", + value: "color-mix(in srgb, var(--sw-accent) 12%, transparent)", + kind: "color", + about: "background wash for the hovered row", + }, + { + name: "--sw-wash-faint", + value: "color-mix(in srgb, var(--sw-accent) 6%, transparent)", + kind: "color", + about: "the quietest wash: help strips, gently set-off regions", + }, + ], + }, + { + id: "signals", + title: "signals", + about: "Status colors. Teal means working, amber means stuck, red means unreachable.", + tokens: [ + { + name: "--sw-good", + value: "#8fd4c0", + kind: "color", + about: + "the working-economy teal: producing, positive flow, and the map's route system (promises of movement)", + }, + { + name: "--sw-warn", + value: "#c8863c", + kind: "color", + about: "the amber alert: rejected commands, blocked production, negative flow, emergencies", + }, + { + name: "--sw-warn-border", + value: "#6b4a24", + kind: "color", + about: "border for amber-alert surfaces (the rejection toast)", + }, + { + name: "--sw-warn-wash", + value: "color-mix(in srgb, var(--sw-warn) 13%, transparent)", + kind: "color", + about: "background wash for rows in an emergency state", + }, + { + name: "--sw-warn-wash-hover", + value: "color-mix(in srgb, var(--sw-warn) 22%, transparent)", + kind: "color", + about: "hover wash for emergency rows", + }, + { + name: "--sw-warn-wash-selected", + value: "color-mix(in srgb, var(--sw-warn) 30%, transparent)", + kind: "color", + about: "selected wash for emergency rows", + }, + { + name: "--sw-danger", + value: "#e8a0a0", + kind: "color", + about: "the red alert: no connection, destructive controls (the row-remove x)", + }, + { + name: "--sw-danger-border", + value: "#7a4040", + kind: "color", + about: "border for red-alert surfaces (the no-connection toast)", + }, + ], + }, + { + id: "focus", + title: "keyboard & focus", + about: + "The guidelines demand focus be visible and hotkeys be marked; these tokens are how. " + + "The focus-model and button stages consume them heavily.", + tokens: [ + { + name: "--sw-hotkey-accent", + value: "#e8c86a", + kind: "color", + about: + "the significant color for hotkey marking: the letter-in-label tint, the 70%-offset hotkey slot. " + + "Deliberately the only gold in the palette: hotkeys must pop", + }, + { + name: "--sw-selection", + value: "#d8ecff", + kind: "color", + about: "the selection marker: the map's dashed ring around the selected entity", + }, + { + name: "--sw-focus-ring-color", + value: "#d8ecff", + kind: "color", + about: "the keyboard focus ring: same where-you-are family as selection, drawn solid", + }, + { + name: "--sw-focus-ring-width", + value: "2px", + kind: "size", + about: "focus ring stroke width", + }, + { + name: "--sw-focus-ring-offset", + value: "2px", + kind: "size", + about: "gap between a focused element and its ring (pair with outline-offset)", + }, + { + name: "--sw-focus-ring", + value: "var(--sw-focus-ring-width) solid var(--sw-focus-ring-color)", + kind: "composite", + about: "the whole ring, ready for `outline:`", + }, + { + name: "--sw-scroll-peek", + value: "28px", + kind: "size", + about: + "how much neighbor stays visible past the focused row when navigation scrolls: " + + "about one row, so the next row is always in view and a scroller's edge is " + + "only flush when the list truly ends there", + }, + ], + }, + { + id: "motion", + title: "motion", + about: + "Interaction-feedback durations. System parameters, not per-element choices: " + + "slowing one here slows it everywhere, which is what an accessibility mode needs.", + tokens: [ + { + name: "--sw-press-ms", + value: "140ms", + kind: "duration", + about: + "how long the activation flash lingers on a triggered control. " + + "Below ~100ms a flash risks being missed outright; 180ms already reads " + + "as sluggish. Tuned by hand at the 2026-08-23 playtest", + }, + ], + }, + { + id: "spacing", + title: "spacing", + about: + "The gap ramp. Gaps carry meaning: xs binds a cluster, sm relates neighbors, " + + "lg separates groups, xl+ separates sections (the gaps megacolumn focus jumps across).", + tokens: [ + { + name: "--sw-space-xs", + value: "4px", + kind: "size", + about: "within a cluster: icon-to-label", + }, + { + name: "--sw-space-sm", + value: "8px", + kind: "size", + about: "between related elements: button rows", + }, + { + name: "--sw-space-md", + value: "12px", + kind: "size", + about: "panel edge padding, roomier relations", + }, + { + name: "--sw-space-lg", + value: "16px", + kind: "size", + about: "between groups within a section", + }, + { name: "--sw-space-xl", value: "24px", kind: "size", about: "between sections" }, + { name: "--sw-space-xxl", value: "32px", kind: "size", about: "the biggest deliberate gap" }, + ], + }, + { + id: "hit", + title: "hit targets", + about: + "We are not ants. No interactable renders smaller than this -- and that includes the VISIBLE part, not just the hit zone.", + tokens: [ + { + name: "--sw-hit-min-h", + value: "24px", + kind: "size", + about: "minimum height of anything interactable", + }, + { + name: "--sw-hit-min-w", + value: "24px", + kind: "size", + about: "minimum width of anything interactable (icon-only buttons, checkboxes)", + }, + { + name: "--sw-hit-indicator", + value: "16px", + kind: "size", + about: + "minimum square for a control's VISIBLE state indicator (checkbox, disclosure, sort caret): drawn geometry sized by this, never a bare text glyph", + }, + ], + }, + { + id: "type", + title: "type", + about: "Monospace-heavy by design: this is a SCADA console, not a magazine.", + tokens: [ + { name: "--sw-font", value: "monospace", kind: "font", about: "the UI font stack" }, + { name: "--sw-type-micro", value: "10px", kind: "size", about: "column headers, overlines" }, + { + name: "--sw-type-small", + value: "11px", + kind: "size", + about: "fine print: ids, section headings", + }, + { + name: "--sw-type-ui", + value: "12px", + kind: "size", + about: "dense chrome: bars, tables, panels", + }, + { + name: "--sw-type-body", + value: "13px", + kind: "size", + about: "reading surfaces: cards, prose", + }, + { name: "--sw-type-head", value: "16px", kind: "size", about: "page and panel headings" }, + { name: "--sw-type-title", value: "18px", kind: "size", about: "the biggest: card names" }, + { + name: "--sw-tracking", + value: "0.08em", + kind: "size", + about: "letterspacing for emphasized titles at text size", + }, + { + name: "--sw-tracking-wide", + value: "0.12em", + kind: "size", + about: "letterspacing for uppercase overlines (section headings, app title)", + }, + ], + }, +]; + +// Renders the groups into one `:root { ... }` block, group titles as comments. +// Exported for the unit test; everything else should use `tokens` or installTokens. +export function tokensCssText(groups: TokenGroup[]): string { + const lines: string[] = [":root {"]; + for (const group of groups) { + lines.push(`\t/* ${group.title} */`); + for (const t of group.tokens) { + lines.push(`\t${t.name}: ${t.value};`); + } + } + lines.push("}"); + return lines.join("\n"); +} + +// unsafeCSS is safe here: the text is generated from the literal definitions in this +// file, never from runtime data (the same argument as sw-balance's GRID_TEMPLATE). +export const tokens: CSSResult = unsafeCSS(tokensCssText(tokenGroups)); + +// Installs the token definitions document-wide (a <style> in head). Custom +// properties inherit into every shadow root from there. Idempotent: entry points +// call it once, but a second call is harmless. +export function installTokens(doc: Document = document): void { + if (doc.head.querySelector("style[data-sw-tokens]") !== null) return; + const style = doc.createElement("style"); + style.setAttribute("data-sw-tokens", ""); + style.textContent = tokens.cssText; + doc.head.append(style); +} diff --git a/packages/yukey/static/gallery.html b/packages/yukey/static/gallery.html new file mode 100644 index 0000000..bbbd4b2 --- /dev/null +++ b/packages/yukey/static/gallery.html @@ -0,0 +1,24 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>yukey gallery</title> +<style> + html, body { + margin: 0; + height: 100%; + background: #05080f; + } + /* Height only: display comes from the component's own :host rule + (a document rule here would override it and break the shell's grid). */ + sw-gallery { + height: 100%; + } +</style> +</head> +<body> +<sw-gallery></sw-gallery> +<script type="module" src="/gallery.js"></script> +</body> +</html>