From 70dd1ea16645f0328afd5de8a36e6a9f6440b56d Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 15 Jul 2026 18:10:16 -0400 Subject: [PATCH 1/2] spike(dom): implement minimal dom on EventTarget --- examples/focus/package.json | 49 ++++ examples/focus/src/index.ts | 227 +++++++++++++++ packages/dom/README.md | 197 +++++++++++++ packages/dom/package.json | 48 +++ packages/dom/src/index.ts | 1 + packages/dom/src/lib/events.ts | 180 ++++++++++++ packages/dom/src/lib/focus.ts | 420 +++++++++++++++++++++++++++ packages/dom/src/lib/mod.ts | 16 + packages/dom/src/lib/node.ts | 209 +++++++++++++ packages/dom/src/lib/root.ts | 66 +++++ packages/dom/src/lib/types.ts | 64 ++++ packages/dom/src/lib/validate.ts | 54 ++++ packages/dom/test/events.test.ts | 350 ++++++++++++++++++++++ packages/dom/test/focus.test.ts | 285 ++++++++++++++++++ packages/dom/test/focusgroup.test.ts | 274 +++++++++++++++++ packages/dom/test/root.test.ts | 289 ++++++++++++++++++ packages/dom/test/signal.test.ts | 138 +++++++++ packages/dom/test/suite.ts | 1 + packages/dom/tsconfig.json | 6 + pnpm-lock.yaml | 211 +++++++++++++- 20 files changed, 3072 insertions(+), 13 deletions(-) create mode 100644 examples/focus/package.json create mode 100644 examples/focus/src/index.ts create mode 100644 packages/dom/README.md create mode 100644 packages/dom/package.json create mode 100644 packages/dom/src/index.ts create mode 100644 packages/dom/src/lib/events.ts create mode 100644 packages/dom/src/lib/focus.ts create mode 100644 packages/dom/src/lib/mod.ts create mode 100644 packages/dom/src/lib/node.ts create mode 100644 packages/dom/src/lib/root.ts create mode 100644 packages/dom/src/lib/types.ts create mode 100644 packages/dom/src/lib/validate.ts create mode 100644 packages/dom/test/events.test.ts create mode 100644 packages/dom/test/focus.test.ts create mode 100644 packages/dom/test/focusgroup.test.ts create mode 100644 packages/dom/test/root.test.ts create mode 100644 packages/dom/test/signal.test.ts create mode 100644 packages/dom/test/suite.ts create mode 100644 packages/dom/tsconfig.json diff --git a/examples/focus/package.json b/examples/focus/package.json new file mode 100644 index 0000000..966750c --- /dev/null +++ b/examples/focus/package.json @@ -0,0 +1,49 @@ +{ + "name": "focus", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "dev": "bsh dev", + "build": "bsh build", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "dependencies": { + "@bomb.sh/dom": "workspace:*", + "@bomb.sh/tty": "catalog:*" + }, + "devDependencies": { + "@bomb.sh/tools": "latest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/playground.git", + "directory": "examples/focus" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + }, + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + } + } +} diff --git a/examples/focus/src/index.ts b/examples/focus/src/index.ts new file mode 100644 index 0000000..f7a8a95 --- /dev/null +++ b/examples/focus/src/index.ts @@ -0,0 +1,227 @@ +// oxlint-disable bombshell-dev/no-generic-error +import { + createNodeData, + createRoot, + FocusGroupManager, + FocusManager, + type Node, + type Root, +} from '@bomb.sh/dom'; +import { + alternateBuffer, + close, + createInput, + createTerm, + cursor, + fit, + grow, + type KeyEvent, + type Op, + open, + percent, + rgba, + settings, + text, +} from '@bomb.sh/tty'; +import { stdin, stdout } from 'node:process'; + +const GRAY = rgba(100, 100, 100); + +// Terminal key events, carried on the standard Event vocabulary. Bubbling +// does the routing: an input consumes a key with stopPropagation(); anything +// it ignores reaches the root handler. +class KeyboardEvent extends Event { + constructor(readonly detail: KeyEvent) { + super(detail.type, { bubbles: true, cancelable: true }); + } +} + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>('demo:layout', () => []); + +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { + node.data.set(layoutKey, body); +} + +function makeTextInput(root: Root, parent: Node, name: string): void { + const node = root.createElement(name); + parent.append(node); + node.setAttribute('tabindex', 0); + node.setAttribute('value', ''); + layout(node, () => { + const color = node.getAttribute('focused') ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.getAttribute('value') ?? '')), + close(), + ]; + }); + node.addEventListener('keydown', (event) => { + const { key, code } = (event as KeyboardEvent).detail; + const value = String(node.getAttribute('value') ?? ''); + if (key.length === 1) { + node.setAttribute('value', `${value}${key}`); + event.stopPropagation(); + } else if (code === 'Backspace') { + node.setAttribute('value', value.slice(0, -1)); + event.stopPropagation(); + } + // anything else bubbles up to the root Tab/Backtab/arrow handler + }); +} + +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { + height: grow(), + width: grow(), + direction: 'ttb', + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + }), + ...children, + close(), + ]; +} + +function containerBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: 0xfff, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + height: fit(), + width: grow(), + direction: 'ttb', + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + ...children, + close(), + ]; +} + +function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} + +if (!stdin.isTTY) { + throw new Error('dom demo requires an interactive TTY'); +} + +const root = createRoot(); + +layout(root.documentElement, screenBody); + +const container = root.createElement('input-1'); +root.documentElement.append(container); +layout(container, containerBody); + +makeTextInput(root, container, 'input-1-1'); +makeTextInput(root, container, 'input-1-2'); +makeTextInput(root, root.documentElement, 'input-2'); + +// document.activeElement analog; seeds focus now that inputs exist (input-1-1). +const focus = new FocusManager(root.documentElement); + +// The two grouped inputs collapse into a single Tab stop, entered at the +// last-focused input (memory). ArrowUp/ArrowDown move within the group — +// listbox defaults: block axis, no wrap. +const group = new FocusGroupManager(focus, container, 'listbox'); + +// Tab/Backtab/arrow navigation lives at the root; it only sees keys the +// focused input let bubble. The group methods no-op while focus is outside +// the group, so binding them here is safe. +const navigate = (event: Event): void => { + const { code } = (event as KeyboardEvent).detail; + if (code === 'Tab') { + focus.next(); + } else if (code === 'Backtab') { + focus.previous(); + } else if (code === 'ArrowDown') { + group.next(); + } else if (code === 'ArrowUp') { + group.previous(); + } +}; +root.documentElement.addEventListener('keydown', navigate); +root.documentElement.addEventListener('keyrepeat', navigate); + +const { columns, rows } = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + +let term = await createTerm({ height: rows, width: columns }); + +function render(): void { + const ops = walk(root.documentElement); + const { output } = term.render(ops); + stdout.write(output); +} + +root.addEventListener('change', render); + +const tty = settings(cursor(false), alternateBuffer()); +stdin.setRawMode(true); +stdout.write(tty.apply); + +function shutdown(): void { + stdout.write(tty.revert); + stdin.setRawMode(false); + stdin.off('data', feed); + stdin.pause(); +} + +const input = await createInput(); +let rescan: ReturnType | undefined; + +function feed(bytes: Uint8Array): void { + clearTimeout(rescan); + const result = input.scan(bytes); + if (result.pending) { + rescan = setTimeout(() => feed(new Uint8Array()), result.pending.delay); + } + for (const event of result.events) { + if (event.type === 'keydown' && event.ctrl && event.code === 'c') { + shutdown(); + return; + } + if (event.type === 'resize') { + void createTerm({ height: event.height, width: event.width }).then((next) => { + term = next; + render(); + }); + continue; + } + // All the input routing: dispatch at the focused node; capture, target, + // and bubble listeners do the rest. + focus.activeElement.dispatchEvent(new KeyboardEvent(event as KeyEvent)); + } +} +stdin.on('data', feed); + +render(); diff --git a/packages/dom/README.md b/packages/dom/README.md new file mode 100644 index 0000000..5c03fb8 --- /dev/null +++ b/packages/dom/README.md @@ -0,0 +1,197 @@ +# @bomb.sh/dom + +Headless component tree with DOM-style event propagation. Zero dependencies. + +`@bomb.sh/dom` is a headless interaction tree for TUIs, stated entirely in the +platform's vocabulary. Nodes **are** `EventTarget`s; the tree API is +document-shaped (`createElement`/`append`/`insertBefore`, attributes, +`getElementById`, `tabindex`); events route by capture/target/bubble +propagation; a `change` event on the root signals "re-render". If you know the +DOM, you already know this package. + +## The question this package answers + +> Can we just leverage `EventTarget` on the nodes to manage bubbling? + +**Yes.** Node's native `EventTarget` provides listener storage, error +isolation, and the full `Event` flag machinery (`stopPropagation`, +`stopImmediatePropagation`, `preventDefault`, `once`, `signal`). What it lacks +is a tree — `dispatchEvent` invokes every listener on a single target as if +`AT_TARGET`. `PropagationTarget` (see `src/lib/events.ts`) adds the tree walk +on top, and empirically that needs exactly two shims: + +1. **Identity.** Each per-node native dispatch resets `event.target` to that + node, so the real target, phase, and path are pinned as own properties on + the event instance, shadowing the prototype getters. `currentTarget` needs + no shim — native dispatch sets it correctly per node, and nulls it after. +2. **Phase filtering.** A bare `EventTarget` fires `{capture: true}` listeners + on every dispatch, so listeners register through a thin wrapper that + consults the pinned phase. The wrapper also owns `once`/`signal`, because a + native `once` would consume a listener whose phase never matched. + +Between per-node dispatches, `event.cancelBubble` (the legacy readable alias +for the stop-propagation flag) tells the walk when to halt. One divergence +from the DOM: the flag can't be cleared from the outside, so a stopped event +can't be re-dispatched — `dispatchEvent` throws and asks for a fresh event. + +## The IR question + +> Can we avoid an IR / VDOM that we normalize to ops? + +**Yes — retained interaction tree, immediate-mode render.** This package's tree is +_not_ a render tree and never normalizes to ops. It holds interaction state +(focus, values, event listeners); rendering stays a pure function that walks +the tree and produces `Op[]` fresh each frame — components are functions +returning ops. The bridge between the two worlds is one string: `node.id` +becomes the renderer's element key, so element identity in the renderer +follows node identity in the interaction tree for free. No reconciliation, no +diffing, no ops in this package. + +```ts +// compose with an op-based renderer (e.g. @bomb.sh/tty): read state, return ops +function textInput(node: Node): Op[] { + return [ + open(node.id, { border: node.getAttribute('focused') ? focusedBorder : border }), + text(String(node.getAttribute('value') ?? '')), + close(), + ]; +} +root.addEventListener('change', () => render(textInput(input))); +``` + +## The tree: document-shaped + +Creation is separate from insertion, like the DOM — and insertion doubles as +reordering: + +- `root.createElement(localName)` returns a **detached** node; `parent.append(...nodes)` + and `parent.insertBefore(node, reference)` attach it. Detached nodes are not + resolvable via `root.getElementById` (connected-only, like the DOM). +- Inserting an **already-attached** node moves it, state-preservingly: no + signal abort, no lifecycle events, listeners intact. This is the DOM's new + `moveBefore()` semantics applied to all insertions — reordering is + re-insertion, not a separate API. +- State is **attributes**: `getAttribute`/`setAttribute`/`hasAttribute`/ + `removeAttribute`, with a frozen `node.attributes` snapshot for renderers. + Focusability is literally `setAttribute('tabindex', 0)`. + +Deliberate divergences, documented rather than hidden: attribute values are +JsonValue (renderers need structure; the DOM's string-only rule buys nothing +headless), `getAttribute` returns `undefined` for missing attributes (`null` +is a legal value here), and `remove()` is **terminal** — it destroys the +subtree and aborts `node.signal`. The DOM's detached-but-alive limbo exists +for GC and adoption; a TUI tree doesn't need it, and the entire `node.signal` +lifetime story depends on removal being final. Moves cover the legitimate +reason to detach-and-reattach. + +## Node lifetime: `node.signal` + +Every node owns an `AbortSignal` that aborts when the node is removed (or the +root destroyed), descendants first, in reverse creation order. Hand it to +anything whose lifetime should match the node's: + +```ts +// a listener installed on an ancestor, cleaned up when the node dies +root.documentElement.addEventListener('keydown', onKey, { signal: node.signal }); + +// a spinner that stops when its node is removed — no manual cleanup +import { setTimeout as delay } from 'node:timers/promises'; + +async function spin(node: Node): Promise { + const frames = ['⠋', '⠙', '⠹', '⠸']; + try { + for (let i = 0; ; i++) { + node.setAttribute('frame', frames[i % frames.length]!); + await delay(80, undefined, { signal: node.signal }); + } + } catch { + // aborted — the node was removed + } +} +``` + +The same signal flows into `fetch`, `node:timers/promises`, streams, and +this package's own `addEventListener` — one primitive, already understood by +every web developer. One caveat: cancellation is **cooperative** — an async +function keeps running past its awaits unless the awaited thing honors the +signal (platform APIs do; arbitrary user code may not). `FocusManager` +dogfoods the signal: its bookkeeping is registered with +`{ signal: root.signal }` and disappears with its container. + +## Focus: `FocusManager` and `focusgroup` + +Focus is structured the way the DOM structures it — authoritative state held +by an owner, not a property scanned for: + +- **Focusability is `tabindex`**, like the DOM: `setAttribute('tabindex', 0)` + joins sequential traversal; `-1` is focusable only via `focus()`. The + `focused` attribute is the derived projection renderers read (`:focus`), + written only on transitions. +- **`FocusManager`** is the `document.activeElement` analog: an O(1) pointer, + `focus()`, and sequential `next()`/`previous()` (Tab) traversal. Focus + changes fire `blur`/`focusout` at the old node and `focus`/`focusin` at the + new — `focus`/`blur` don't bubble, `focusin`/`focusout` do, and all four + carry `relatedTarget`, matching the browser. +- **`focusgroup`** implements the + [Open UI scoped focusgroup explainer](https://open-ui.org/components/scoped-focusgroup.explainer/) + (shipped in Chrome 150) as an attribute with the same token grammar: + `'toolbar'`, `'tablist'`, `'listbox nomemory'`, `'menu wrap'`, … + The attribute alone is honored declaratively by `FocusManager`: a group + collapses to a single tab stop, entered at the last-focused item (memory) or + its first item — the explainer's guaranteed tab stop algorithm. `'none'` + opts a subtree out; nested groups are independent segments. +- **`FocusGroupManager`** is the imperative half — arrow-key traversal + (`next`/`previous`/`first`/`last`) within one group, axis metadata for key + binding, and memory tracking via the bubbling `focusin` event. Its methods + no-op while focus is outside the group, so arrows can be bound globally. + +```ts +const focus = new FocusManager(root.documentElement); +const tabs = new FocusGroupManager(focus, tabBar, 'tablist'); // inline, wrap, memory + +// Tab collapses the group to one stop; arrows move within it +if (code === 'Tab') focus.next(); +if (code === 'ArrowRight') tabs.next(); +if (code === 'ArrowLeft') tabs.previous(); +``` + +Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid +tokens, and an opted-out element doesn't split the group into separate +tab-stop segments — it just becomes its own stop. + +## Sketch + +```ts +import { createRoot, FocusManager } from '@bomb.sh/dom'; + +const root = createRoot(); +const input = root.createElement('input'); +root.documentElement.append(input); +input.setAttribute('tabindex', 0); +input.setAttribute('value', ''); + +input.addEventListener('keydown', (event) => { + const { key } = (event as KeyEvent).detail; + if (key.length === 1) { + input.setAttribute('value', `${input.getAttribute('value')}${key}`); + event.stopPropagation(); // consumed — root never sees it + } +}); + +const focus = new FocusManager(root.documentElement); + +root.documentElement.addEventListener('keydown', (event) => { + if ((event as KeyEvent).detail.code === 'Tab') focus.next(); // bubbled here +}); + +root.addEventListener('change', render); + +// all the input routing there is: dispatch at the focused node +focus.activeElement.dispatchEvent(new KeyEvent(raw)); +``` + +Run the demo: + +```sh +pnpm playground -e focus +``` diff --git a/packages/dom/package.json b/packages/dom/package.json new file mode 100644 index 0000000..d0c1da1 --- /dev/null +++ b/packages/dom/package.json @@ -0,0 +1,48 @@ +{ + "name": "@bomb.sh/dom", + "version": "0.0.0", + "private": true, + "license": "ISC", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/playground.git", + "directory": "packages/dom" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "dev": "bsh dev --dts", + "build": "bsh build --dts", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "devDependencies": { + "@bomb.sh/tools": "latest", + "@types/node": "^26.0.0", + "vitest": "^4.1.2" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + } + } +} diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts new file mode 100644 index 0000000..6e6ba54 --- /dev/null +++ b/packages/dom/src/index.ts @@ -0,0 +1 @@ +export * from './lib/mod.ts'; diff --git a/packages/dom/src/lib/events.ts b/packages/dom/src/lib/events.ts new file mode 100644 index 0000000..a06885e --- /dev/null +++ b/packages/dom/src/lib/events.ts @@ -0,0 +1,180 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params + +// The EventTarget experiment: can native EventTarget manage capture/bubble +// propagation over a parent-linked tree, without reimplementing the DOM? +// +// Native EventTarget provides listener storage, error isolation, and the Event +// flag machinery (stopPropagation, stopImmediatePropagation, preventDefault). +// What it lacks is a tree — dispatchEvent invokes every listener on a single +// target as if AT_TARGET. PropagationTarget adds the tree walk, which needs +// exactly two shims: +// +// 1. Identity. Each per-node native dispatch resets `event.target` to that +// node, so the real target, phase, and path are pinned as own properties on +// the event instance, shadowing the prototype getters. `currentTarget` +// needs no shim — native dispatch sets it correctly per node. +// +// 2. Phase filtering. A bare EventTarget fires `{capture: true}` listeners on +// every dispatch, so listeners are registered through a thin wrapper that +// consults the pinned phase. The wrapper also owns `once` and `signal`, +// because native `once` would consume a listener whose phase never matched. +// +// Between per-node dispatches, `event.cancelBubble` (the legacy readable alias +// for the stop-propagation flag) tells the walk when to halt. + +type Listener = EventListener | EventListenerObject; + +// Wrappers keyed by capture flag, per (type, callback) — mirroring the DOM's +// (type, callback, capture) listener identity for dedupe and removal. +interface WrapperPair { + bubble?: EventListener; + capture?: EventListener; +} + +function normalizeOptions( + options: AddEventListenerOptions | boolean | undefined, +): AddEventListenerOptions { + return typeof options === 'boolean' ? { capture: options } : (options ?? {}); +} + +function pin(event: Event, key: string, value: unknown): void { + Object.defineProperty(event, key, { value, configurable: true }); +} + +const inFlight = new WeakSet(); + +export class PropagationTarget extends EventTarget { + #listeners = new Map>(); + + // The DOM spec's "get the parent" hook. Subclasses with a tree override this. + protected getParentTarget(): PropagationTarget | undefined { + return undefined; + } + + override addEventListener( + type: string, + callback: Listener | null, + options?: AddEventListenerOptions | boolean, + ): void { + if (!callback) { + return; + } + const opts = normalizeOptions(options); + const capture = opts.capture === true; + if (opts.signal?.aborted) { + return; + } + let byCallback = this.#listeners.get(type); + if (!byCallback) { + byCallback = new Map(); + this.#listeners.set(type, byCallback); + } + let pair = byCallback.get(callback); + if (!pair) { + pair = {}; + byCallback.set(callback, pair); + } + const slot = capture ? 'capture' : 'bubble'; + if (pair[slot]) { + return; + } + const wrapper = (event: Event): void => { + const phase = event.eventPhase; + if (phase === Event.CAPTURING_PHASE && !capture) { + return; + } + if (phase === Event.BUBBLING_PHASE && capture) { + return; + } + // `once` is consumed here, after the phase check, so a capture-once + // listener survives bubble walks that never match it. + if (opts.once) { + this.removeEventListener(type, callback, { capture }); + } + if (typeof callback === 'function') { + callback.call(this, event); + } else { + callback.handleEvent(event); + } + }; + pair[slot] = wrapper; + opts.signal?.addEventListener( + 'abort', + () => this.removeEventListener(type, callback, { capture }), + { once: true }, + ); + super.addEventListener(type, wrapper); + } + + override removeEventListener( + type: string, + callback: Listener | null, + options?: EventListenerOptions | boolean, + ): void { + if (!callback) { + return; + } + const capture = normalizeOptions(options).capture === true; + const byCallback = this.#listeners.get(type); + const pair = byCallback?.get(callback); + const slot = capture ? 'capture' : 'bubble'; + const wrapper = pair?.[slot]; + if (!byCallback || !pair || !wrapper) { + return; + } + delete pair[slot]; + if (!pair.bubble && !pair.capture) { + byCallback.delete(callback); + } + if (byCallback.size === 0) { + this.#listeners.delete(type); + } + super.removeEventListener(type, wrapper); + } + + override dispatchEvent(event: Event): boolean { + if (inFlight.has(event)) { + throw new Error('This event is already being dispatched'); + } + if (event.cancelBubble) { + // A previous dispatch stopped this event, and native EventTarget offers + // no way to clear the flag (the DOM resets it on re-dispatch; the legacy + // cancelBubble setter ignores `false`). Fail loud instead of silently + // dispatching an event no walk will carry. + throw new Error('This event was stopped by a previous dispatch; create a fresh event'); + } + const ancestors: PropagationTarget[] = []; + for (let t = this.getParentTarget(); t; t = t.getParentTarget()) { + ancestors.push(t); + } + inFlight.add(event); + pin(event, 'target', this); + pin(event, 'composedPath', () => [this, ...ancestors]); + try { + pin(event, 'eventPhase', Event.CAPTURING_PHASE); + for (let i = ancestors.length - 1; i >= 0; i--) { + EventTarget.prototype.dispatchEvent.call(ancestors[i], event); + if (event.cancelBubble) { + return !event.defaultPrevented; + } + } + pin(event, 'eventPhase', Event.AT_TARGET); + EventTarget.prototype.dispatchEvent.call(this, event); + if (event.bubbles && !event.cancelBubble) { + pin(event, 'eventPhase', Event.BUBBLING_PHASE); + for (const ancestor of ancestors) { + EventTarget.prototype.dispatchEvent.call(ancestor, event); + if (event.cancelBubble) { + break; + } + } + } + return !event.defaultPrevented; + } finally { + inFlight.delete(event); + pin(event, 'eventPhase', Event.NONE); + pin(event, 'composedPath', () => []); + } + } +} diff --git a/packages/dom/src/lib/focus.ts b/packages/dom/src/lib/focus.ts new file mode 100644 index 0000000..e76e7a8 --- /dev/null +++ b/packages/dom/src/lib/focus.ts @@ -0,0 +1,420 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params + +// DOM-shaped focus: authoritative state held by an owner, not derived by +// scanning the tree on every query — the way the platform does it: +// +// - Focusability is the `tabindex` attribute, like the DOM: `0` (or any +// non-negative number) joins sequential traversal; `-1` is focusable only +// programmatically. The `focused` attribute is a derived projection for +// renderers (`:focus`), written only on transitions. +// - `FocusManager` is `document`'s focus machinery: an `activeElement` pointer, +// `focus()`, and sequential (Tab) traversal. +// - `focusgroup` is an attribute on a container using the Open UI +// scoped-focusgroup token grammar (`'toolbar'`, `'tablist wrap'`, +// `'listbox nomemory'`, ...) — "the explainer" below: +// https://open-ui.org/components/scoped-focusgroup.explainer/ +// FocusManager honors it declaratively: +// a group collapses to a single tab stop (entry = last-focused memory, else +// first item), exactly like the explainer's guaranteed tab stop algorithm. +// - `FocusGroupManager` is the imperative side of the attribute: arrow-key +// traversal (`next`/`previous`/`first`/`last`) within the group, plus memory +// tracking via a bubbling `focusin` listener. +// +// Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid +// tokens, and an opted-out (`'none'`) element does not split the group into +// separate tab-stop segments — it just becomes its own stop. +import type { Node } from './types.ts'; +import { createNodeData } from './types.ts'; + +export type FocusEventType = 'focus' | 'blur' | 'focusin' | 'focusout'; + +// DOM FocusEvent: `relatedTarget` is the other side of the transition — where +// focus is going (blur/focusout) or where it came from (focus/focusin). +// Like the DOM, focus/blur do not bubble; focusin/focusout do. +export class FocusEvent extends Event { + constructor( + type: FocusEventType, + readonly relatedTarget: Node | undefined, + ) { + super(type, { bubbles: type === 'focusin' || type === 'focusout' }); + } +} + +// In sequential (Tab/arrow) traversal, like tabindex="0" in the DOM. +function isSequentiallyFocusable(node: Node): boolean { + const tabindex = node.getAttribute('tabindex'); + return typeof tabindex === 'number' && tabindex >= 0; +} + +// Focusable at all — includes tabindex="-1" (programmatic focus only). +function isFocusable(node: Node): boolean { + return typeof node.getAttribute('tabindex') === 'number'; +} + +// --------------------------------------------------------------------------- +// focusgroup token grammar (Open UI scoped focusgroup) + +export interface FocusGroupConfig { + behavior?: 'toolbar' | 'tablist' | 'radiogroup' | 'listbox' | 'menu' | 'menubar'; + axis: 'inline' | 'block' | 'both'; + wrap: boolean; + memory: boolean; +} + +const BEHAVIORS: Record> = { + toolbar: { axis: 'inline', wrap: false }, + tablist: { axis: 'inline', wrap: true }, + radiogroup: { axis: 'both', wrap: true }, + listbox: { axis: 'block', wrap: false }, + menu: { axis: 'block', wrap: true }, + menubar: { axis: 'inline', wrap: true }, +}; + +// Token strings are parsed on every stop computation (i.e., every Tab), so +// results are memoized by the exact attribute string. The set of distinct +// focusgroup values in an app is tiny; the map is effectively bounded. +const parsed = new Map(); + +export function parseFocusgroup(value: string): FocusGroupConfig { + const cached = parsed.get(value); + if (cached) { + return cached; + } + const tokens = value.split(/\s+/).filter(Boolean); + const behavior = tokens.find((t) => t in BEHAVIORS) as FocusGroupConfig['behavior']; + const base = behavior ? BEHAVIORS[behavior as string]! : { axis: 'both' as const, wrap: false }; + const axis = tokens.includes('inline') + ? 'inline' + : tokens.includes('block') + ? 'block' + : base.axis; + const wrap = tokens.includes('wrap') ? true : tokens.includes('nowrap') ? false : base.wrap; + const config: FocusGroupConfig = Object.freeze({ + behavior, + axis, + wrap, + memory: !tokens.includes('nomemory'), + }); + parsed.set(value, config); + return config; +} + +const noneCache = new Map(); + +function hasNoneToken(value: string): boolean { + let none = noneCache.get(value); + if (none === undefined) { + none = value.split(/\s+/).includes('none'); + noneCache.set(value, none); + } + return none; +} + +// A node declares a group when its `focusgroup` attribute is a non-`none` +// string. +function groupValue(node: Node): string | undefined { + const value = node.getAttribute('focusgroup'); + if (typeof value !== 'string') { + return undefined; + } + return hasNoneToken(value) ? undefined : value; +} + +function optsOut(node: Node): boolean { + const value = node.getAttribute('focusgroup'); + return typeof value === 'string' && hasNoneToken(value); +} + +// The segment a node's tab stop collapses into: the nearest ancestor-or-self +// declaring a group, stopping at opt-outs (a `none` subtree is independent of +// every enclosing group) and at the traversal root. Nearest wins, so an item +// in a nested group belongs to the nested segment, not the outer one. +function segmentOf(node: Node, root: Node): Node | undefined { + for (let n: Node | undefined = node; n && n !== root; n = n.parent) { + if (groupValue(n) !== undefined) { + return n; + } + if (optsOut(n)) { + return undefined; + } + } + return undefined; +} + +// Last-focused item per group container — the explainer's focus memory. +// Private to this module; validity is checked at read time via `node.signal`. +const memoryKey = createNodeData('focus:memory'); + +// Items of a group segment: the container (if focusable) and its focusable +// descendants, excluding subtrees that opt out (`none`) or declare their own +// nested group (independent segments per the explainer). +function segmentItems(group: Node): Node[] { + const items: Node[] = []; + const walk = (node: Node): void => { + if (node !== group && typeof node.getAttribute('focusgroup') === 'string') { + return; + } + if (isSequentiallyFocusable(node)) { + items.push(node); + } + for (const child of node.children) { + walk(child); + } + }; + walk(group); + return items; +} + +// Every sequentially focusable node in tree order, groups flattened — used for +// removal successors, where "next focusable thing" matters more than tab stops. +function flatFocusables(root: Node): Node[] { + const result: Node[] = []; + const walk = (node: Node): void => { + if (isSequentiallyFocusable(node)) { + result.push(node); + } + for (const child of node.children) { + walk(child); + } + }; + walk(root); + return result; +} + +// --------------------------------------------------------------------------- + +interface TabStop { + stop: Node; + // Set when this stop is the collapsed entry of a focusgroup segment. + segment: Node | undefined; +} + +// The `document.activeElement` analog: authoritative focus state for a subtree, +// with sequential (Tab) traversal that honors declarative `focusgroup` props. +// Construct one per root (or per container for a nested focus scope); its +// bookkeeping is registered with `{ signal: root.signal }` and dies with it. +export class FocusManager { + #active: Node | undefined; + + constructor(readonly root: Node) { + const seed = this.#stops().find(({ stop }) => stop !== root); + if (seed) { + this.focus(seed.stop); + } + // Dispatched before detach, so successor computation sees the full tree. + root.addEventListener('remove', (event) => this.#onRemove(event.target as Node), { + signal: root.signal, + }); + } + + // Never null: falls back to the root, like document.activeElement's body + // fallback. + get activeElement(): Node { + return this.#active ?? this.root; + } + + // Accepts any node with a tabindex, including -1 (programmatic focus, like + // the DOM). Sequential traversal only visits tabindex >= 0. + focus(node: Node): void { + if (!isFocusable(node)) { + throw new Error('Cannot focus a node without a tabindex attribute'); + } + if (node === this.#active) { + return; + } + const old = this.#active; + if (old) { + old.removeAttribute('focused'); + old.dispatchEvent(new FocusEvent('blur', node)); + old.dispatchEvent(new FocusEvent('focusout', node)); + } + this.#active = node; + node.setAttribute('focused', true); + node.dispatchEvent(new FocusEvent('focus', old)); + node.dispatchEvent(new FocusEvent('focusin', old)); + } + + next(): void { + this.#move(1); + } + + previous(): void { + this.#move(-1); + } + + // Tab stops in tree order: plain focusables, plus one collapsed stop per + // focusgroup segment (the explainer's guaranteed tab stop algorithm). + #stops(): TabStop[] { + const result: TabStop[] = []; + const walk = (node: Node, group: Node | undefined): void => { + let next = group; + const value = groupValue(node); + if (value !== undefined && node !== this.root) { + const entry = this.#entryOf(node, value); + if (entry) { + result.push({ stop: entry, segment: node }); + } + next = node; + } else if (optsOut(node)) { + // Opted out of the enclosing group: independently tabbable. + next = undefined; + if (isSequentiallyFocusable(node)) { + result.push({ stop: node, segment: undefined }); + } + } else if (!group && isSequentiallyFocusable(node)) { + result.push({ stop: node, segment: undefined }); + } + for (const child of node.children) { + walk(child, next); + } + }; + walk(this.root, undefined); + return result; + } + + // Where Tab lands when entering a group: memory if alive and valid, else + // the segment's first item. + #entryOf(group: Node, value: string): Node | undefined { + if (parseFocusgroup(value).memory) { + const memory = group.data.get(memoryKey); + if ( + memory && + !memory.signal.aborted && + isSequentiallyFocusable(memory) && + group.contains(memory) + ) { + return memory; + } + } + return segmentItems(group)[0]; + } + + #move(delta: number): void { + const stops = this.#stops(); + if (stops.length === 0) { + return; + } + const active = this.#active; + let idx = -1; + if (active) { + const segment = segmentOf(active, this.root); + idx = segment + ? stops.findIndex((s) => s.segment === segment) + : stops.findIndex((s) => s.stop === active); + } + if (idx === -1) { + this.focus(delta > 0 ? stops[0]!.stop : stops[stops.length - 1]!.stop); + return; + } + if (stops.length === 1) { + return; + } + this.focus(stops[(idx + delta + stops.length) % stops.length]!.stop); + } + + #onRemove(removed: Node): void { + const active = this.#active; + if (!active || !removed.contains(active)) { + return; + } + const chain = flatFocusables(this.root); + const start = chain.indexOf(active); + for (let i = 1; i < chain.length; i++) { + const candidate = chain[(start + i + chain.length) % chain.length]!; + if (!removed.contains(candidate)) { + this.focus(candidate); + return; + } + } + this.#active = undefined; + } +} + +// The imperative half of the `focusgroup` attribute: arrow-key traversal within +// one group. Declares the group by writing the token string to the container's +// `focusgroup` attribute (renderer-visible, exactly like the DOM) and tracks +// focus memory via the bubbling `focusin` event. +export class FocusGroupManager { + readonly config: FocusGroupConfig; + #controller = new AbortController(); + + constructor( + readonly focus: FocusManager, + readonly container: Node, + tokens = '', + ) { + this.config = parseFocusgroup(tokens); + container.setAttribute('focusgroup', tokens); + container.addEventListener( + 'focusin', + (event) => { + const target = event.target as Node; + if (this.config.memory && this.items.includes(target)) { + container.data.set(memoryKey, target); + } + }, + { signal: AbortSignal.any([container.signal, this.#controller.signal]) }, + ); + } + + // Advisory for key binding: which arrow keys the app should route here + // (headless code has no keyboard; the explainer's axis is a key concern). + get axis(): FocusGroupConfig['axis'] { + return this.config.axis; + } + + get items(): Node[] { + return segmentItems(this.container); + } + + // Arrow-key traversal only acts while focus is inside the group, like the + // DOM behavior — so apps can bind arrows globally and let the group no-op. + next(): void { + this.#move(1); + } + + previous(): void { + this.#move(-1); + } + + first(): void { + const items = this.items; + if (items.length > 0 && this.#index(items) !== -1) { + this.focus.focus(items[0]!); + } + } + + last(): void { + const items = this.items; + if (items.length > 0 && this.#index(items) !== -1) { + this.focus.focus(items[items.length - 1]!); + } + } + + // Removes the group declaration: items dissolve back into individual tab + // stops. Listener bookkeeping is aborted; the container itself lives on. + dispose(): void { + this.#controller.abort(); + this.container.removeAttribute('focusgroup'); + this.container.data.set(memoryKey, undefined); + } + + #index(items: Node[]): number { + return items.indexOf(this.focus.activeElement); + } + + #move(delta: number): void { + const items = this.items; + const idx = this.#index(items); + if (idx === -1) { + return; + } + const target = this.config.wrap + ? items[(idx + delta + items.length) % items.length] + : items[idx + delta]; + if (target && target !== items[idx]) { + this.focus.focus(target); + } + } +} diff --git a/packages/dom/src/lib/mod.ts b/packages/dom/src/lib/mod.ts new file mode 100644 index 0000000..a4da40f --- /dev/null +++ b/packages/dom/src/lib/mod.ts @@ -0,0 +1,16 @@ +export type { JsonValue, Node, NodeData, NodeDataKey, Root } from './types.ts'; + +export { createNodeData } from './types.ts'; + +export { createRoot } from './root.ts'; + +export { PropagationTarget } from './events.ts'; + +export { + FocusEvent, + type FocusEventType, + type FocusGroupConfig, + FocusGroupManager, + FocusManager, + parseFocusgroup, +} from './focus.ts'; diff --git a/packages/dom/src/lib/node.ts b/packages/dom/src/lib/node.ts new file mode 100644 index 0000000..4fb7415 --- /dev/null +++ b/packages/dom/src/lib/node.ts @@ -0,0 +1,209 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params +import { PropagationTarget } from './events.ts'; +import type { JsonValue, Node, NodeData, NodeDataKey } from './types.ts'; +import { validateJsonValue } from './validate.ts'; + +class NodeDataImpl implements NodeData { + #map: Map = new Map(); + + get(key: NodeDataKey): T | undefined { + return this.#map.get(key.symbol) as T | undefined; + } + + set(key: NodeDataKey, value: T): void { + this.#map.set(key.symbol, value); + } + + expect(key: NodeDataKey): T { + const val = this.#map.get(key.symbol); + if (val !== undefined) { + return val as T; + } else if (key.defaultValue !== undefined) { + return key.defaultValue; + } else { + throw new Error(`NodeData '${key.symbol.description}' not found`); + } + } +} + +// Shared per-tree bookkeeping; owned by the root, threaded to every node. +// `registry` holds CONNECTED nodes only, so getElementById behaves like the +// DOM's (detached nodes are not resolvable by id). +export interface TreeState { + registry: Map; + documentElement: NodeImpl | undefined; + nextId(): string; + markDirty(): void; +} + +export class NodeImpl extends PropagationTarget implements Node { + _attributes: Record = {}; + _children: NodeImpl[] = []; + _parent: NodeImpl | undefined; + readonly data: NodeData = new NodeDataImpl(); + readonly #tree: TreeState; + readonly #controller = new AbortController(); + + constructor( + readonly id: string, + readonly localName: string, + tree: TreeState, + ) { + super(); + this.#tree = tree; + } + + protected override getParentTarget(): PropagationTarget | undefined { + return this._parent; + } + + get attributes(): Record { + return Object.freeze({ ...this._attributes }); + } + + get children(): Iterable { + return this._children.values(); + } + + get parent(): Node | undefined { + return this._parent; + } + + get isConnected(): boolean { + if (this === this.#tree.documentElement) { + return true; + } + return this._parent ? this._parent.isConnected : false; + } + + get signal(): AbortSignal { + return this.#controller.signal; + } + + getAttribute(name: string): JsonValue | undefined { + return this._attributes[name]; + } + + setAttribute(name: string, value: JsonValue): void { + validateJsonValue(value); + this._attributes[name] = value; + this.#tree.markDirty(); + } + + hasAttribute(name: string): boolean { + return name in this._attributes; + } + + removeAttribute(name: string): void { + if (name in this._attributes) { + delete this._attributes[name]; + this.#tree.markDirty(); + } + } + + contains(other: Node): boolean { + for (let n: Node | undefined = other; n; n = n.parent) { + if (n === this) { + return true; + } + } + return false; + } + + append(...nodes: Node[]): void { + for (const node of nodes) { + this.#insert(node as NodeImpl, this._children.length); + } + } + + insertBefore(node: Node, reference: Node): void { + const index = this._children.indexOf(reference as NodeImpl); + if (index === -1) { + throw new Error('insertBefore: `reference` is not a child of this node'); + } + this.#insert(node as NodeImpl, index); + } + + // Attach (or move) `child` at `index`. An already-attached child relocates + // state-preservingly — no signal abort, no lifecycle events — matching the + // DOM's `moveBefore()` semantics rather than remove-and-reinsert. + #insert(child: NodeImpl, index: number): void { + if (child.#tree !== this.#tree) { + throw new Error('Cannot insert a node from another tree'); + } + if (child === this.#tree.documentElement) { + throw new Error('Cannot insert the document element'); + } + if (child.contains(this)) { + throw new Error('Cannot insert a node into its own subtree'); + } + if (child.signal.aborted) { + throw new Error('Cannot insert a removed node'); + } + const wasConnected = child.isConnected; + let at = index; + if (child._parent) { + const from = child._parent._children.indexOf(child); + child._parent._children.splice(from, 1); + // Moving forward under the same parent: account for the vacated slot. + if (child._parent === this && from < at) { + at -= 1; + } + } + this._children.splice(at, 0, child); + child._parent = this; + const isConnected = child.isConnected; + if (isConnected && !wasConnected) { + child.#register(); + } else if (!isConnected && wasConnected) { + child.#unregister(); + } + this.#tree.markDirty(); + } + + #register(): void { + this.#tree.registry.set(this.id, this); + for (const child of this._children) { + child.#register(); + } + } + + #unregister(): void { + this.#tree.registry.delete(this.id); + for (const child of this._children) { + child.#unregister(); + } + } + + // Internal teardown — not on the public `Node` interface. Aborts each + // node's signal depth-first, children before parents in reverse creation + // order. Used by `remove` and by `root.destroy()`. + destroy(): void { + for (const child of [...this._children].reverse()) { + child.destroy(); + } + this.#controller.abort(); + } + + remove(): void { + if (this === this.#tree.documentElement) { + throw new Error('Cannot remove the document element'); + } + if (this._parent) { + // Announce before detaching, so `remove` bubbles through the + // still-attached ancestor path — extensions (e.g. focus) react by + // listening on an ancestor. Descendants get no event of their own — + // their teardown notification is their aborting signal. + this.dispatchEvent(new Event('remove', { bubbles: true })); + if (this.isConnected) { + this.#unregister(); + } + const index = this._parent._children.indexOf(this); + this._parent._children.splice(index, 1); + this._parent = undefined; + } + this.destroy(); + this.#tree.markDirty(); + } +} diff --git a/packages/dom/src/lib/root.ts b/packages/dom/src/lib/root.ts new file mode 100644 index 0000000..dc49127 --- /dev/null +++ b/packages/dom/src/lib/root.ts @@ -0,0 +1,66 @@ +import { NodeImpl, type TreeState } from './node.ts'; +import type { Node, Root } from './types.ts'; + +class RootImpl extends EventTarget implements Root { + readonly documentElement: NodeImpl; + #tree: TreeState; + #destroyed = false; + #scheduled = false; + + constructor() { + super(); + let counter = 0; + const tree: TreeState = { + registry: new Map(), + documentElement: undefined, + nextId: () => `node-${++counter}`, + markDirty: () => this.#invalidate(), + }; + this.#tree = tree; + const node = new NodeImpl(tree.nextId(), '', tree); + tree.documentElement = node; + tree.registry.set(node.id, node); + this.documentElement = node; + } + + createElement(localName = ''): Node { + // Detached until appended, like document.createElement. Not resolvable + // via getElementById until connected. + return new NodeImpl(this.#tree.nextId(), localName, this.#tree); + } + + getElementById(id: string): Node | undefined { + return this.#tree.registry.get(id); + } + + // Coalesce change notifications per microtask: a burst of synchronous + // mutations — one dispatched input event's worth of listener work, or + // imperative tree building — produces a single `change`. Renderers see final + // state only. + #invalidate(): void { + if (this.#destroyed || this.#scheduled) { + return; + } + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + if (!this.#destroyed) { + this.dispatchEvent(new Event('change')); + } + }); + } + + destroy(): void { + if (this.#destroyed) { + return; + } + this.#destroyed = true; + // Aborts every node's signal depth-first and forgets the tree. + this.documentElement.destroy(); + this.#tree.registry.clear(); + } +} + +export function createRoot(): Root { + return new RootImpl(); +} diff --git a/packages/dom/src/lib/types.ts b/packages/dom/src/lib/types.ts new file mode 100644 index 0000000..ac6e343 --- /dev/null +++ b/packages/dom/src/lib/types.ts @@ -0,0 +1,64 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface NodeDataKey { + readonly symbol: symbol; + readonly defaultValue?: T; +} + +export function createNodeData(name: string, defaultValue?: T): NodeDataKey { + return { symbol: Symbol(name), defaultValue }; +} + +export interface NodeData { + get(key: NodeDataKey): T | undefined; + set(key: NodeDataKey, value: T): void; + expect(key: NodeDataKey): T; +} + +// A DOM-shaped element: an EventTarget in a tree that carries state as +// attributes and separates creation from insertion, like the document API. +// Deliberate divergences from the platform, documented in the README: +// - attributes are JsonValue-valued, not strings (they feed renderers) +// - `getAttribute` returns `undefined` for a missing attribute, not `null` +// (null is a legal attribute VALUE here) +// - `remove()` is terminal — it destroys the subtree and aborts `signal`; +// there is no detached-but-alive limbo. Reordering uses moves instead: +// `append`/`insertBefore` relocate an attached node state-preservingly +// (the DOM's `moveBefore()` semantics). +export interface Node extends EventTarget { + readonly id: string; + readonly localName: string; + readonly attributes: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly isConnected: boolean; + readonly data: NodeData; + // Aborts when this node is removed (or the root destroyed). Hand it to + // anything whose lifetime should match the node's: listeners on ancestors + // (`{ signal }`), timers, fetch, streams. Cancellation is cooperative. + readonly signal: AbortSignal; + getAttribute(name: string): JsonValue | undefined; + setAttribute(name: string, value: JsonValue): void; + hasAttribute(name: string): boolean; + removeAttribute(name: string): void; + contains(other: Node): boolean; + append(...nodes: Node[]): void; + insertBefore(node: Node, reference: Node): void; + remove(): void; +} + +// The document analog: owns the tree, creates (detached) nodes, resolves +// connected nodes by id, and emits a `change` Event (coalesced per microtask) +// whenever the tree may have changed. +export interface Root extends EventTarget { + readonly documentElement: Node; + createElement(localName?: string): Node; + getElementById(id: string): Node | undefined; + destroy(): void; +} diff --git a/packages/dom/src/lib/validate.ts b/packages/dom/src/lib/validate.ts new file mode 100644 index 0000000..29781e9 --- /dev/null +++ b/packages/dom/src/lib/validate.ts @@ -0,0 +1,54 @@ +// oxlint-disable bombshell-dev/no-generic-error +import type { JsonValue } from './types.ts'; + +export function validateJsonValue(value: unknown): asserts value is JsonValue { + if (value === undefined) { + throw new Error('undefined is not a valid JsonValue'); + } + if (typeof value === 'number') { + if (Number.isNaN(value)) { + throw new Error('NaN is not a valid JsonValue'); + } + if (!Number.isFinite(value)) { + throw new Error(`${value} is not a valid JsonValue`); + } + return; + } + if (typeof value === 'string' || typeof value === 'boolean' || value === null) { + return; + } + if (typeof value === 'function') { + throw new Error('functions are not valid JsonValues'); + } + if (typeof value === 'symbol') { + throw new Error('symbols are not valid JsonValues'); + } + if (typeof value === 'bigint') { + throw new Error('bigints are not valid JsonValues'); + } + if (value instanceof Date) { + throw new Error('Date instances are not valid JsonValues'); + } + if (value instanceof Map) { + throw new Error('Map instances are not valid JsonValues'); + } + if (value instanceof Set) { + throw new Error('Set instances are not valid JsonValues'); + } + if (value instanceof RegExp) { + throw new Error('RegExp instances are not valid JsonValues'); + } + if (Array.isArray(value)) { + for (const item of value) { + validateJsonValue(item); + } + return; + } + if (typeof value === 'object' && value !== null) { + for (const key of Object.keys(value)) { + validateJsonValue((value as Record)[key]); + } + return; + } + throw new Error(`${String(value)} is not a valid JsonValue`); +} diff --git a/packages/dom/test/events.test.ts b/packages/dom/test/events.test.ts new file mode 100644 index 0000000..1d2a2aa --- /dev/null +++ b/packages/dom/test/events.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from '../test/suite.ts'; +import { createRoot, type Node, type Root } from '../src/index.ts'; + +// A three-deep tree: documentElement -> mid -> leaf. +function tree(): { root: Root; top: Node; mid: Node; leaf: Node } { + const root = createRoot(); + const top = root.documentElement; + const mid = root.createElement('mid'); + const leaf = root.createElement('leaf'); + mid.append(leaf); + top.append(mid); + return { root, top, mid, leaf }; +} + +describe('dispatch at a target', () => { + it('invokes listeners on the target with correct identity', () => { + const { root, leaf } = tree(); + const seen: { target: unknown; currentTarget: unknown; phase: number }[] = []; + leaf.addEventListener('ping', (event) => { + seen.push({ + target: event.target, + currentTarget: event.currentTarget, + phase: event.eventPhase, + }); + }); + const handled = leaf.dispatchEvent(new Event('ping')); + expect(handled).toBe(true); + expect(seen).toEqual([{ target: leaf, currentTarget: leaf, phase: Event.AT_TARGET }]); + root.destroy(); + }); + + it('returns false when preventDefault is called on a cancelable event', () => { + const { root, leaf } = tree(); + leaf.addEventListener('ping', (event) => event.preventDefault()); + expect(leaf.dispatchEvent(new Event('ping', { cancelable: true }))).toBe(false); + root.destroy(); + }); + + it('custom Event subclasses pass through untouched', () => { + class KeyEvent extends Event { + constructor(readonly key: string) { + super('keydown', { bubbles: true }); + } + } + const { root, top, leaf } = tree(); + let key = ''; + top.addEventListener('keydown', (event) => { + key = (event as KeyEvent).key; + }); + leaf.dispatchEvent(new KeyEvent('a')); + expect(key).toEqual('a'); + root.destroy(); + }); +}); + +describe('bubbling', () => { + it('bubbles target -> mid -> top in order', () => { + const { root, top, mid, leaf } = tree(); + const order: string[] = []; + for (const [name, node] of [ + ['top', top], + ['mid', mid], + ['leaf', leaf], + ] as const) { + node.addEventListener('ping', (event) => { + order.push(`${name}:${event.eventPhase}`); + expect(event.target).toBe(leaf); + expect(event.currentTarget).toBe(node); + }); + } + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual([ + `leaf:${Event.AT_TARGET}`, + `mid:${Event.BUBBLING_PHASE}`, + `top:${Event.BUBBLING_PHASE}`, + ]); + root.destroy(); + }); + + it("non-bubbling events do not reach ancestors' bubble listeners", () => { + const { root, top, leaf } = tree(); + let topSaw = false; + top.addEventListener('ping', () => { + topSaw = true; + }); + leaf.dispatchEvent(new Event('ping')); + expect(topSaw).toBe(false); + root.destroy(); + }); + + it('stopPropagation halts ancestors but finishes the current node', () => { + const { root, top, mid, leaf } = tree(); + const order: string[] = []; + leaf.addEventListener('ping', (event) => { + order.push('leaf-1'); + event.stopPropagation(); + }); + leaf.addEventListener('ping', () => order.push('leaf-2')); + mid.addEventListener('ping', () => order.push('mid')); + top.addEventListener('ping', () => order.push('top')); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual(['leaf-1', 'leaf-2']); + root.destroy(); + }); + + it('stopImmediatePropagation halts remaining listeners on the same node too', () => { + const { root, top, leaf } = tree(); + const order: string[] = []; + leaf.addEventListener('ping', (event) => { + order.push('leaf-1'); + event.stopImmediatePropagation(); + }); + leaf.addEventListener('ping', () => order.push('leaf-2')); + top.addEventListener('ping', () => order.push('top')); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual(['leaf-1']); + root.destroy(); + }); + + it('stopPropagation in a bubble listener halts remaining ancestors', () => { + const { root, top, mid, leaf } = tree(); + const order: string[] = []; + mid.addEventListener('ping', (event) => { + order.push('mid'); + event.stopPropagation(); + }); + top.addEventListener('ping', () => order.push('top')); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual(['mid']); + root.destroy(); + }); + + it('preventDefault anywhere on the path makes dispatchEvent return false', () => { + const { root, top, leaf } = tree(); + top.addEventListener('ping', (event) => event.preventDefault()); + const handled = leaf.dispatchEvent(new Event('ping', { bubbles: true, cancelable: true })); + expect(handled).toBe(false); + root.destroy(); + }); +}); + +describe('capture phase', () => { + it('capture runs top -> mid before target, bubble runs after', () => { + const { root, top, mid, leaf } = tree(); + const order: string[] = []; + top.addEventListener('ping', () => order.push('top-capture'), { capture: true }); + mid.addEventListener('ping', () => order.push('mid-capture'), { capture: true }); + leaf.addEventListener('ping', () => order.push('leaf')); + mid.addEventListener('ping', () => order.push('mid-bubble')); + top.addEventListener('ping', () => order.push('top-bubble')); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual(['top-capture', 'mid-capture', 'leaf', 'mid-bubble', 'top-bubble']); + root.destroy(); + }); + + it('capture listeners see non-bubbling events (delegation trick)', () => { + const { root, top, leaf } = tree(); + const order: string[] = []; + top.addEventListener('focus', () => order.push('top-capture'), { capture: true }); + top.addEventListener('focus', () => order.push('top-bubble')); + leaf.dispatchEvent(new Event('focus')); + expect(order).toEqual(['top-capture']); + root.destroy(); + }); + + it('bubble listeners do not fire during the capture walk', () => { + const { root, mid, leaf } = tree(); + const phases: number[] = []; + mid.addEventListener('ping', (event) => phases.push(event.eventPhase)); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(phases).toEqual([Event.BUBBLING_PHASE]); + root.destroy(); + }); + + it('at the target, capture and bubble listeners both fire in add order', () => { + const { root, leaf } = tree(); + const order: string[] = []; + leaf.addEventListener('ping', () => order.push('bubble')); + leaf.addEventListener('ping', () => order.push('capture'), { capture: true }); + leaf.dispatchEvent(new Event('ping')); + expect(order).toEqual(['bubble', 'capture']); + root.destroy(); + }); + + it('stopPropagation during capture prevents the target from seeing it', () => { + const { root, top, leaf } = tree(); + const order: string[] = []; + top.addEventListener( + 'ping', + (event) => { + order.push('top-capture'); + event.stopPropagation(); + }, + { capture: true }, + ); + leaf.addEventListener('ping', () => order.push('leaf')); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(order).toEqual(['top-capture']); + root.destroy(); + }); + + it('composedPath lists target -> ancestors during dispatch', () => { + const { root, top, mid, leaf } = tree(); + let path: EventTarget[] = []; + top.addEventListener('ping', (event) => { + path = event.composedPath(); + }); + const event = new Event('ping', { bubbles: true }); + leaf.dispatchEvent(event); + expect(path).toEqual([leaf, mid, top]); + expect(event.composedPath()).toEqual([]); + root.destroy(); + }); +}); + +describe('listener registration semantics', () => { + it('dedupes by (type, callback, capture)', () => { + const { root, leaf } = tree(); + let count = 0; + const listener = (): void => { + count++; + }; + leaf.addEventListener('ping', listener); + leaf.addEventListener('ping', listener); + leaf.addEventListener('ping', listener, { capture: true }); + leaf.dispatchEvent(new Event('ping')); + // bubble registration once + capture registration once, both at target + expect(count).toEqual(2); + root.destroy(); + }); + + it('removeEventListener respects the capture flag', () => { + const { root, leaf } = tree(); + let count = 0; + const listener = (): void => { + count++; + }; + leaf.addEventListener('ping', listener); + leaf.removeEventListener('ping', listener, { capture: true }); // wrong flag + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(1); + leaf.removeEventListener('ping', listener); + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(1); + root.destroy(); + }); + + it('once consumes the listener after one matching dispatch', () => { + const { root, leaf } = tree(); + let count = 0; + leaf.addEventListener('ping', () => count++, { once: true }); + leaf.dispatchEvent(new Event('ping')); + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(1); + root.destroy(); + }); + + it('once is not consumed by a phase that does not match', () => { + const { root, mid, leaf } = tree(); + let count = 0; + // A bubble-once listener on mid; a non-bubbling dispatch at leaf walks mid + // during capture only, which must not consume it. + mid.addEventListener('ping', () => count++, { once: true }); + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(0); + leaf.dispatchEvent(new Event('ping', { bubbles: true })); + expect(count).toEqual(1); + root.destroy(); + }); + + it('an aborted signal removes the listener', () => { + const { root, leaf } = tree(); + let count = 0; + const controller = new AbortController(); + leaf.addEventListener('ping', () => count++, { signal: controller.signal }); + leaf.dispatchEvent(new Event('ping')); + controller.abort(); + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(1); + root.destroy(); + }); + + it('supports handleEvent objects', () => { + const { root, leaf } = tree(); + let count = 0; + leaf.addEventListener('ping', { + handleEvent: () => { + count++; + }, + }); + leaf.dispatchEvent(new Event('ping')); + expect(count).toEqual(1); + root.destroy(); + }); +}); + +describe('event reuse', () => { + it('an unstopped event can be dispatched again', () => { + const { root, leaf } = tree(); + let count = 0; + leaf.addEventListener('ping', () => count++); + const event = new Event('ping', { bubbles: true }); + leaf.dispatchEvent(event); + leaf.dispatchEvent(event); + expect(count).toEqual(2); + root.destroy(); + }); + + it('a stopped event cannot be re-dispatched (native flag is sticky)', () => { + const { root, leaf } = tree(); + leaf.addEventListener('ping', (event) => event.stopPropagation()); + const event = new Event('ping', { bubbles: true }); + leaf.dispatchEvent(event); + expect(() => leaf.dispatchEvent(event)).toThrow(/fresh event/); + root.destroy(); + }); + + it('re-dispatching an in-flight event throws', () => { + const { root, top, leaf } = tree(); + const event = new Event('ping', { bubbles: true }); + let threw = false; + top.addEventListener('ping', () => { + try { + leaf.dispatchEvent(event); + } catch { + threw = true; + } + }); + leaf.dispatchEvent(event); + expect(threw).toBe(true); + root.destroy(); + }); +}); + +describe('lifecycle events', () => { + it('remove bubbles to ancestors before the node detaches', () => { + const { root, top, mid, leaf } = tree(); + let sawTarget: unknown; + let stillAttached = false; + top.addEventListener('remove', (event) => { + sawTarget = event.target; + stillAttached = [...mid.children].includes(leaf); + }); + leaf.remove(); + expect(sawTarget).toBe(leaf); + expect(stillAttached).toBe(true); + expect([...mid.children]).toEqual([]); + root.destroy(); + }); +}); diff --git a/packages/dom/test/focus.test.ts b/packages/dom/test/focus.test.ts new file mode 100644 index 0000000..90ce773 --- /dev/null +++ b/packages/dom/test/focus.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from '../test/suite.ts'; +import { createRoot, FocusEvent, FocusManager, type Node, type Root } from '../src/index.ts'; + +function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { + const node = root.createElement(localName); + if (tabindex !== undefined) { + node.setAttribute('tabindex', tabindex); + } + parent.append(node); + return node; +} + +describe('FocusManager construction', () => { + it('seeds the first focusable descendant', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); + expect(focus.activeElement).toBe(a); + expect(a.getAttribute('focused')).toBe(true); + root.destroy(); + }); + + it('focuses nothing on an empty container; activeElement falls back to root', () => { + const root = createRoot(); + const focus = new FocusManager(root.documentElement); + expect(focus.activeElement).toBe(root.documentElement); + expect(root.documentElement.hasAttribute('focused')).toBe(false); + root.destroy(); + }); + + it('does not enroll root in the ring', () => { + const root = createRoot(); + addChild(root, root.documentElement, 'A', 0); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + const names: string[] = []; + for (let i = 0; i < 3; i++) { + names.push(focus.activeElement.localName); + focus.next(); + } + expect(names).toEqual(['A', 'B', 'A']); // wraps A->B->A; root never appears + root.destroy(); + }); + + it('escape hatch: a tabindex on root keeps it in the ring', () => { + const root = createRoot(); + root.documentElement.setAttribute('tabindex', 0); // explicit enrollment + const a = addChild(root, root.documentElement, 'A', 0); + const focus = new FocusManager(root.documentElement); // seeds A, skipping root + expect(focus.activeElement).toBe(a); + focus.next(); // A -> root (wrap now includes root) + expect(focus.activeElement).toBe(root.documentElement); + root.destroy(); + }); +}); + +describe('tabindex', () => { + it('a node without tabindex is skipped by the ring', () => { + const root = createRoot(); + addChild(root, root.documentElement, 'skip'); // no tabindex + addChild(root, root.documentElement, 'here', 0); + const focus = new FocusManager(root.documentElement); // seeds "here" + expect(focus.activeElement.localName).toEqual('here'); + root.destroy(); + }); + + it('tabindex -1 is programmatically focusable but not sequentially', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const hidden = addChild(root, root.documentElement, 'hidden', -1); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + focus.next(); + expect(focus.activeElement.localName).toEqual('B'); // ring skips hidden + focus.focus(hidden); // but programmatic focus works + expect(focus.activeElement).toBe(hidden); + expect(a.hasAttribute('focused')).toBe(false); + root.destroy(); + }); +}); + +describe('sequential traversal', () => { + it('depth-first order, flat children', () => { + const root = createRoot(); + for (const name of ['A', 'B', 'C']) { + addChild(root, root.documentElement, name, 0); + } + const focus = new FocusManager(root.documentElement); // seeds A + const names: string[] = []; + for (let i = 0; i < 4; i++) { + names.push(focus.activeElement.localName); + focus.next(); + } + expect(names).toEqual(['A', 'B', 'C', 'A']); + root.destroy(); + }); + + it('depth-first order, nested children', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + addChild(root, a, 'A1', 0); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + const names: string[] = []; + for (let i = 0; i < 4; i++) { + names.push(focus.activeElement.localName); + focus.next(); + } + expect(names).toEqual(['A', 'A1', 'B', 'A']); + root.destroy(); + }); + + it('next moves forward, previous moves back, both wrap', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + focus.next(); + expect(focus.activeElement).toBe(b); + focus.next(); // wrap + expect(focus.activeElement).toBe(a); + focus.previous(); // wrap back + expect(focus.activeElement).toBe(b); + root.destroy(); + }); + + it('single focusable node is a no-op', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const focus = new FocusManager(root.documentElement); // seeds A + focus.next(); + expect(focus.activeElement).toBe(a); + root.destroy(); + }); + + it('no stops at all is a no-op', () => { + const root = createRoot(); + const focus = new FocusManager(root.documentElement); + focus.next(); + expect(focus.activeElement).toBe(root.documentElement); + root.destroy(); + }); + + it('focusables added after construction: next() enters the ring', () => { + const root = createRoot(); + const focus = new FocusManager(root.documentElement); // nothing to seed + const late = addChild(root, root.documentElement, 'late', 0); + focus.next(); + expect(focus.activeElement).toBe(late); + root.destroy(); + }); +}); + +describe('focus()', () => { + it('explicitly focuses a node', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + focus.focus(b); + expect(focus.activeElement).toBe(b); + expect(a.hasAttribute('focused')).toBe(false); + expect(b.getAttribute('focused')).toBe(true); + root.destroy(); + }); + + it('throws on a node without a tabindex', () => { + const root = createRoot(); + const child = addChild(root, root.documentElement, 'nope'); + const focus = new FocusManager(root.documentElement); + expect(() => focus.focus(child)).toThrow(); + root.destroy(); + }); + + it('is a no-op when already focused', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const focus = new FocusManager(root.documentElement); // seeds A + let events = 0; + a.addEventListener('focus', () => events++); + focus.focus(a); // already focused -> no-op + expect(focus.activeElement).toBe(a); + expect(events).toEqual(0); + root.destroy(); + }); +}); + +describe('focus events', () => { + it('fires blur/focusout at the old node, then focus/focusin at the new', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + const order: string[] = []; + for (const type of ['blur', 'focusout'] as const) { + a.addEventListener(type, () => order.push(`${type}:A`)); + } + for (const type of ['focus', 'focusin'] as const) { + b.addEventListener(type, () => order.push(`${type}:B`)); + } + focus.focus(b); + expect(order).toEqual(['blur:A', 'focusout:A', 'focus:B', 'focusin:B']); + root.destroy(); + }); + + it('relatedTarget points at the other side of the transition', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + let blurRelated: Node | undefined; + let focusRelated: Node | undefined; + a.addEventListener('blur', (event) => { + blurRelated = (event as FocusEvent).relatedTarget; + }); + b.addEventListener('focus', (event) => { + focusRelated = (event as FocusEvent).relatedTarget; + }); + focus.focus(b); + expect(blurRelated).toBe(b); + expect(focusRelated).toBe(a); + root.destroy(); + }); + + it('focus does not bubble; focusin bubbles; capture sees both', () => { + const root = createRoot(); + addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + const seen: string[] = []; + root.documentElement.addEventListener('focus', () => seen.push('focus-bubble')); + root.documentElement.addEventListener('focus', () => seen.push('focus-capture'), { + capture: true, + }); + root.documentElement.addEventListener('focusin', () => seen.push('focusin-bubble')); + focus.focus(b); + expect(seen).toEqual(['focus-capture', 'focusin-bubble']); + root.destroy(); + }); +}); + +describe('focused node removal', () => { + it('removing the focused node advances focus', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + expect(a.getAttribute('focused')).toBe(true); + a.remove(); + expect(focus.activeElement.localName).toEqual('B'); + root.destroy(); + }); + + it('removing a non-focused node does not move focus', () => { + const root = createRoot(); + addChild(root, root.documentElement, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + b.remove(); + expect(focus.activeElement.localName).toEqual('A'); + root.destroy(); + }); + + it('removing an ancestor of the focused node moves focus out of the subtree', () => { + const root = createRoot(); + const panel = addChild(root, root.documentElement, 'panel'); + const inner = addChild(root, panel, 'inner', 0); + addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds inner + expect(focus.activeElement).toBe(inner); + panel.remove(); + expect(focus.activeElement.localName).toEqual('B'); + root.destroy(); + }); + + it('removing the last focusable clears the pointer', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + const focus = new FocusManager(root.documentElement); // seeds A + a.remove(); + expect(focus.activeElement).toBe(root.documentElement); + root.destroy(); + }); +}); diff --git a/packages/dom/test/focusgroup.test.ts b/packages/dom/test/focusgroup.test.ts new file mode 100644 index 0000000..5cc3f5b --- /dev/null +++ b/packages/dom/test/focusgroup.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from '../test/suite.ts'; +import { + createRoot, + FocusGroupManager, + FocusManager, + type Node, + parseFocusgroup, + type Root, +} from '../src/index.ts'; + +function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { + const node = root.createElement(localName); + if (tabindex !== undefined) { + node.setAttribute('tabindex', tabindex); + } + parent.append(node); + return node; +} + +// before | group(one, two) | after — the canonical toolbar-between-stops tree. +function fixture(): { + root: Root; + before: Node; + group: Node; + one: Node; + two: Node; + after: Node; +} { + const root = createRoot(); + const before = addChild(root, root.documentElement, 'before', 0); + const group = addChild(root, root.documentElement, 'group'); + const one = addChild(root, group, 'one', 0); + const two = addChild(root, group, 'two', 0); + const after = addChild(root, root.documentElement, 'after', 0); + return { root, before, group, one, two, after }; +} + +describe('parseFocusgroup', () => { + it('applies behavior token defaults', () => { + expect(parseFocusgroup('toolbar')).toEqual({ + behavior: 'toolbar', + axis: 'inline', + wrap: false, + memory: true, + }); + expect(parseFocusgroup('tablist')).toEqual({ + behavior: 'tablist', + axis: 'inline', + wrap: true, + memory: true, + }); + expect(parseFocusgroup('listbox')).toEqual({ + behavior: 'listbox', + axis: 'block', + wrap: false, + memory: true, + }); + expect(parseFocusgroup('radiogroup')).toEqual({ + behavior: 'radiogroup', + axis: 'both', + wrap: true, + memory: true, + }); + }); + + it('modifier tokens override behavior defaults', () => { + expect(parseFocusgroup('toolbar wrap').wrap).toBe(true); + expect(parseFocusgroup('tablist nowrap').wrap).toBe(false); + expect(parseFocusgroup('toolbar block').axis).toBe('block'); + expect(parseFocusgroup('menu nomemory').memory).toBe(false); + }); + + it('bare value falls back to both axes, nowrap, memory', () => { + expect(parseFocusgroup('')).toEqual({ + behavior: undefined, + axis: 'both', + wrap: false, + memory: true, + }); + }); +}); + +describe('declarative focusgroup (attribute only, no manager)', () => { + it('collapses the group to a single tab stop entering at the first item', () => { + const { root, group, before } = fixture(); + group.setAttribute('focusgroup', 'toolbar'); + const focus = new FocusManager(root.documentElement); // seeds before + expect(focus.activeElement).toBe(before); + const names: string[] = []; + for (let i = 0; i < 4; i++) { + focus.next(); + names.push(focus.activeElement.localName); + } + // one enters the group; two is only reachable by arrows, never by Tab + expect(names).toEqual(['one', 'after', 'before', 'one']); + root.destroy(); + }); + + it('previous() enters the group from the other side at the same entry', () => { + const { root, group } = fixture(); + group.setAttribute('focusgroup', 'toolbar'); + const focus = new FocusManager(root.documentElement); // seeds before + focus.previous(); // wraps to after + focus.previous(); // group entry (first item — no memory recorded yet) + expect(focus.activeElement.localName).toEqual('one'); + root.destroy(); + }); +}); + +describe('FocusGroupManager traversal', () => { + it('next/previous move between items; nowrap stops at the ends', () => { + const { root, group, one, two } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar'); // nowrap + focus.focus(one); + g.next(); + expect(focus.activeElement).toBe(two); + g.next(); // end, no wrap + expect(focus.activeElement).toBe(two); + g.previous(); + expect(focus.activeElement).toBe(one); + g.previous(); // start, no wrap + expect(focus.activeElement).toBe(one); + root.destroy(); + }); + + it('wrap cycles at the ends', () => { + const { root, group, one, two } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'tablist'); // wrap by default + focus.focus(two); + g.next(); + expect(focus.activeElement).toBe(one); + g.previous(); + expect(focus.activeElement).toBe(two); + root.destroy(); + }); + + it('no-ops while focus is outside the group (safe to bind globally)', () => { + const { root, group, before } = fixture(); + const focus = new FocusManager(root.documentElement); // seeds before + const g = new FocusGroupManager(focus, group, 'toolbar'); + g.next(); + g.first(); + g.last(); + expect(focus.activeElement).toBe(before); + root.destroy(); + }); + + it('first/last jump within the group (Home/End)', () => { + const { root, group, one, two } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar'); + focus.focus(two); + g.first(); + expect(focus.activeElement).toBe(one); + g.last(); + expect(focus.activeElement).toBe(two); + root.destroy(); + }); + + it('exposes the parsed axis for key binding', () => { + const { root, group } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'listbox'); + expect(g.axis).toEqual('block'); + root.destroy(); + }); +}); + +describe('focus memory (roving tab stop)', () => { + it('re-entering the group returns to the last-focused item', () => { + const { root, group, one, two, after } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar'); + focus.focus(one); + g.next(); // two — recorded as memory via focusin + focus.focus(after); // leave the group + focus.previous(); // Tab back in + expect(focus.activeElement).toBe(two); + root.destroy(); + }); + + it('nomemory always enters at the first item', () => { + const { root, group, one, two, after } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar nomemory'); + focus.focus(two); + g.previous(); + g.next(); // back on two, but nothing recorded + focus.focus(after); + focus.previous(); + expect(focus.activeElement).toBe(one); + root.destroy(); + }); + + it('memory pointing at a removed item falls back to the first item', () => { + const { root, group, one, two, after } = fixture(); + const focus = new FocusManager(root.documentElement); + new FocusGroupManager(focus, group, 'toolbar'); + focus.focus(two); // memory = two + focus.focus(after); + two.remove(); // memory is now a dead node (signal aborted) + focus.previous(); + expect(focus.activeElement).toBe(one); + root.destroy(); + }); + + it('memory pointing at an item moved out of the group falls back', () => { + const { root, group, one, two, after } = fixture(); + const focus = new FocusManager(root.documentElement); + new FocusGroupManager(focus, group, 'toolbar'); + focus.focus(two); // memory = two + focus.focus(after); + root.documentElement.append(two); // move two out of the group (still alive) + focus.previous(); // group entry falls back: memory is no longer inside + expect(focus.activeElement).toBe(one); + root.destroy(); + }); +}); + +describe('opting out and nesting', () => { + it("focusgroup='none' items leave the group and become their own tab stop", () => { + const { root, group, one, two } = fixture(); + const opt = addChild(root, group, 'opt', 0); + opt.setAttribute('focusgroup', 'none'); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar'); + expect(g.items).toEqual([one, two]); + const names: string[] = []; + for (let i = 0; i < 4; i++) { + focus.next(); + names.push(focus.activeElement.localName); + } + expect(names).toEqual(['one', 'opt', 'after', 'before']); + root.destroy(); + }); + + it('a nested focusgroup is an independent segment with its own stop', () => { + const { root, group, one, two } = fixture(); + const inner = addChild(root, group, 'inner'); + const innerItem = addChild(root, inner, 'inner-item', 0); + const focus = new FocusManager(root.documentElement); + const outer = new FocusGroupManager(focus, group, 'toolbar'); + const nested = new FocusGroupManager(focus, inner, 'menu'); + expect(outer.items).toEqual([one, two]); + expect(nested.items).toEqual([innerItem]); + const names: string[] = []; + for (let i = 0; i < 4; i++) { + focus.next(); + names.push(focus.activeElement.localName); + } + expect(names).toEqual(['one', 'inner-item', 'after', 'before']); + root.destroy(); + }); +}); + +describe('dispose()', () => { + it('dissolves the group back into individual tab stops', () => { + const { root, group, one } = fixture(); + const focus = new FocusManager(root.documentElement); + const g = new FocusGroupManager(focus, group, 'toolbar'); + focus.focus(one); + g.dispose(); + expect(group.hasAttribute('focusgroup')).toBe(false); + const names: string[] = []; + for (let i = 0; i < 4; i++) { + focus.next(); + names.push(focus.activeElement.localName); + } + expect(names).toEqual(['two', 'after', 'before', 'one']); + root.destroy(); + }); +}); diff --git a/packages/dom/test/root.test.ts b/packages/dom/test/root.test.ts new file mode 100644 index 0000000..7757e4b --- /dev/null +++ b/packages/dom/test/root.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from '../test/suite.ts'; +import { createRoot } from '../src/index.ts'; + +function nextMicrotask(): Promise { + return Promise.resolve(); +} + +describe('createRoot', () => { + it('returns a root with a parentless, connected document element', () => { + const root = createRoot(); + expect(root.documentElement).toBeTruthy(); + expect(root.documentElement.parent).toBeUndefined(); + expect(root.documentElement.isConnected).toBe(true); + expect(root.documentElement.id).toBeTruthy(); + root.destroy(); + }); + + it('createElement returns a detached node with a unique id', () => { + const root = createRoot(); + const a = root.createElement('a'); + const b = root.createElement('b'); + expect(a.localName).toEqual('a'); + expect(a.parent).toBeUndefined(); + expect(a.isConnected).toBe(false); + expect(a.id).not.toEqual(b.id); + expect(a.id).not.toEqual(root.documentElement.id); + root.destroy(); + }); + + it('append attaches and connects, in order', () => { + const root = createRoot(); + const a = root.createElement('a'); + const b = root.createElement('b'); + root.documentElement.append(a, b); + expect(a.parent).toBe(root.documentElement); + expect(a.isConnected).toBe(true); + expect([...root.documentElement.children]).toEqual([a, b]); + root.destroy(); + }); + + it('insertBefore inserts at the reference position', () => { + const root = createRoot(); + const a = root.createElement('a'); + const c = root.createElement('c'); + root.documentElement.append(a, c); + const b = root.createElement('b'); + root.documentElement.insertBefore(b, c); + expect([...root.documentElement.children]).toEqual([a, b, c]); + root.destroy(); + }); + + it('insertBefore throws when the reference is not a child', () => { + const root = createRoot(); + const a = root.createElement('a'); + const inner = root.createElement('inner'); + a.append(inner); + root.documentElement.append(a); + const b = root.createElement('b'); + expect(() => root.documentElement.insertBefore(b, inner)).toThrow(); + root.destroy(); + }); + + it('append throws on cycles and cross-tree nodes', () => { + const root = createRoot(); + const a = root.createElement('a'); + const inner = root.createElement('inner'); + a.append(inner); + expect(() => inner.append(a)).toThrow(); // ancestor into descendant + expect(() => a.append(a)).toThrow(); // self + const other = createRoot(); + expect(() => root.documentElement.append(other.createElement('x'))).toThrow(); + other.destroy(); + root.destroy(); + }); + + it('a subtree can be built detached and connected in one append', () => { + const root = createRoot(); + const panel = root.createElement('panel'); + const item = root.createElement('item'); + panel.append(item); + expect(item.isConnected).toBe(false); + root.documentElement.append(panel); + expect(item.isConnected).toBe(true); + root.destroy(); + }); +}); + +describe('moves', () => { + it('appending an attached node relocates it (reorder)', () => { + const root = createRoot(); + const a = root.createElement('a'); + const b = root.createElement('b'); + const c = root.createElement('c'); + root.documentElement.append(a, b, c); + root.documentElement.append(a); // move a to the end + expect([...root.documentElement.children].map((n) => n.localName)).toEqual(['b', 'c', 'a']); + root.documentElement.insertBefore(c, b); // move c before b + expect([...root.documentElement.children].map((n) => n.localName)).toEqual(['c', 'b', 'a']); + root.destroy(); + }); + + it('moves are state-preserving: signal live, listeners intact, no remove event', () => { + const root = createRoot(); + const a = root.createElement('a'); + const b = root.createElement('b'); + root.documentElement.append(a, b); + let pings = 0; + let removes = 0; + a.addEventListener('ping', () => pings++); + root.documentElement.addEventListener('remove', () => removes++); + root.documentElement.append(a); // move + expect(a.signal.aborted).toBe(false); + expect(removes).toEqual(0); + a.dispatchEvent(new Event('ping')); + expect(pings).toEqual(1); + root.destroy(); + }); + + it('a move across parents keeps the subtree connected', () => { + const root = createRoot(); + const left = root.createElement('left'); + const right = root.createElement('right'); + root.documentElement.append(left, right); + const item = root.createElement('item'); + left.append(item); + right.append(item); // move between containers + expect(item.parent).toBe(right); + expect(item.isConnected).toBe(true); + expect([...left.children]).toEqual([]); + root.destroy(); + }); + + it('a removed node cannot be re-inserted', () => { + const root = createRoot(); + const a = root.createElement('a'); + root.documentElement.append(a); + a.remove(); + expect(() => root.documentElement.append(a)).toThrow(); + root.destroy(); + }); +}); + +describe('attributes', () => { + it('set/get/has/remove round-trip; snapshot is frozen', () => { + const root = createRoot(); + const node = root.documentElement; + node.setAttribute('n', 5); + expect(node.getAttribute('n')).toEqual(5); + expect(node.hasAttribute('n')).toBe(true); + expect(node.attributes['n']).toEqual(5); + expect(Object.isFrozen(node.attributes)).toBe(true); + node.removeAttribute('n'); + expect(node.hasAttribute('n')).toBe(false); + expect(() => node.removeAttribute('n')).not.toThrow(); + root.destroy(); + }); + + it('rejects invalid JsonValues', () => { + const root = createRoot(); + const node = root.documentElement; + expect(() => node.setAttribute('bad', undefined as never)).toThrow(); + expect(() => node.setAttribute('bad', Number.NaN)).toThrow(); + expect(() => node.setAttribute('bad', (() => {}) as never)).toThrow(); + expect(node.hasAttribute('bad')).toBe(false); + root.destroy(); + }); +}); + +describe('getElementById', () => { + it('resolves connected nodes only, like the DOM', () => { + const root = createRoot(); + const a = root.createElement('a'); + expect(root.getElementById(a.id)).toBeUndefined(); // detached + root.documentElement.append(a); + expect(root.getElementById(a.id)).toBe(a); + expect(root.getElementById(root.documentElement.id)).toBe(root.documentElement); + expect(root.getElementById('nope')).toBeUndefined(); + root.destroy(); + }); + + it('forgets removed subtrees', () => { + const root = createRoot(); + const a = root.createElement('a'); + const inner = root.createElement('inner'); + a.append(inner); + root.documentElement.append(a); + a.remove(); + expect(root.getElementById(a.id)).toBeUndefined(); + expect(root.getElementById(inner.id)).toBeUndefined(); + root.destroy(); + }); +}); + +describe('remove', () => { + it('detaches and destroys the subtree', () => { + const root = createRoot(); + const a = root.createElement('a'); + root.documentElement.append(a); + a.remove(); + expect([...root.documentElement.children]).toEqual([]); + expect(a.parent).toBeUndefined(); + expect(a.isConnected).toBe(false); + root.destroy(); + }); + + it('throws on the document element', () => { + const root = createRoot(); + expect(() => root.documentElement.remove()).toThrow(); + root.destroy(); + }); + + it('destroys a detached node without an event', () => { + const root = createRoot(); + const a = root.createElement('a'); + let removes = 0; + a.addEventListener('remove', () => removes++); + a.remove(); + expect(a.signal.aborted).toBe(true); + expect(removes).toEqual(0); + root.destroy(); + }); +}); + +describe('contains', () => { + it('is inclusive of self and descendants', () => { + const root = createRoot(); + const a = root.createElement('a'); + const inner = root.createElement('inner'); + a.append(inner); + root.documentElement.append(a); + expect(a.contains(a)).toBe(true); + expect(a.contains(inner)).toBe(true); + expect(root.documentElement.contains(inner)).toBe(true); + expect(inner.contains(a)).toBe(false); + root.destroy(); + }); +}); + +describe('change notification', () => { + it('emits one coalesced change per microtask burst', async () => { + const root = createRoot(); + let changes = 0; + root.addEventListener('change', () => changes++); + root.documentElement.setAttribute('a', 1); + root.documentElement.setAttribute('b', 2); + root.documentElement.append(root.createElement('c')); + await nextMicrotask(); + expect(changes).toEqual(1); + root.destroy(); + }); + + it('emits again for a later burst', async () => { + const root = createRoot(); + let changes = 0; + root.addEventListener('change', () => changes++); + root.documentElement.setAttribute('a', 1); + await nextMicrotask(); + root.documentElement.setAttribute('a', 2); + await nextMicrotask(); + expect(changes).toEqual(2); + root.destroy(); + }); + + it('mutations from event listeners coalesce into one change', async () => { + const root = createRoot(); + const input = root.createElement('input'); + root.documentElement.append(input); + input.addEventListener('keydown', () => { + input.setAttribute('value', 'x'); + input.setAttribute('cursor', 1); + }); + let changes = 0; + root.addEventListener('change', () => changes++); + input.dispatchEvent(new Event('keydown', { bubbles: true })); + await nextMicrotask(); + expect(changes).toEqual(1); + root.destroy(); + }); + + it('does not emit after destroy', async () => { + const root = createRoot(); + let changes = 0; + root.addEventListener('change', () => changes++); + root.documentElement.setAttribute('a', 1); + root.destroy(); + await nextMicrotask(); + expect(changes).toEqual(0); + }); +}); diff --git a/packages/dom/test/signal.test.ts b/packages/dom/test/signal.test.ts new file mode 100644 index 0000000..ed7bb5a --- /dev/null +++ b/packages/dom/test/signal.test.ts @@ -0,0 +1,138 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { describe, expect, it } from '../test/suite.ts'; +import { createRoot, FocusManager, type Node, type Root } from '../src/index.ts'; + +function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { + const node = root.createElement(localName); + if (tabindex !== undefined) { + node.setAttribute('tabindex', tabindex); + } + parent.append(node); + return node; +} + +describe('node.signal', () => { + it('is a live AbortSignal while the node is alive', () => { + const root = createRoot(); + const child = addChild(root, root.documentElement, 'child'); + expect(child.signal).toBeInstanceOf(AbortSignal); + expect(child.signal.aborted).toBe(false); + root.destroy(); + }); + + it('aborts when the node is removed; siblings are untouched', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'a'); + const b = addChild(root, root.documentElement, 'b'); + a.remove(); + expect(a.signal.aborted).toBe(true); + expect(b.signal.aborted).toBe(false); + expect(root.documentElement.signal.aborted).toBe(false); + root.destroy(); + }); + + it('stays live across moves — relocation is not removal', () => { + const root = createRoot(); + const left = addChild(root, root.documentElement, 'left'); + const right = addChild(root, root.documentElement, 'right'); + const item = addChild(root, left, 'item'); + right.append(item); // move + expect(item.signal.aborted).toBe(false); + root.documentElement.insertBefore(item, left); // move again + expect(item.signal.aborted).toBe(false); + root.destroy(); + }); + + it('aborts descendants depth-first, children before parents', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'a'); + const inner = addChild(root, a, 'inner'); + const innermost = addChild(root, inner, 'innermost'); + const order: string[] = []; + for (const [name, node] of [ + ['a', a], + ['inner', inner], + ['innermost', innermost], + ] as const) { + node.signal.addEventListener('abort', () => order.push(name)); + } + a.remove(); + expect(order).toEqual(['innermost', 'inner', 'a']); + root.destroy(); + }); + + it('root.destroy aborts the whole tree', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'a'); + const inner = addChild(root, a, 'inner'); + root.destroy(); + expect(root.documentElement.signal.aborted).toBe(true); + expect(a.signal.aborted).toBe(true); + expect(inner.signal.aborted).toBe(true); + }); + + it('cleans up ancestor listeners registered with { signal } (delegation)', () => { + const root = createRoot(); + const child = addChild(root, root.documentElement, 'child'); + let count = 0; + // A listener the child installs on the root, scoped to the child's life. + root.documentElement.addEventListener('ping', () => count++, { + signal: child.signal, + }); + child.dispatchEvent(new Event('ping', { bubbles: true })); + expect(count).toEqual(1); + child.remove(); + root.documentElement.dispatchEvent(new Event('ping', { bubbles: true })); + expect(count).toEqual(1); + root.destroy(); + }); + + it('cancels node-scoped timers on removal', async () => { + const root = createRoot(); + const spinner = addChild(root, root.documentElement, 'spinner'); + const outcome = delay(1_000, 'completed', { signal: spinner.signal }).catch( + (error: Error) => error.name, + ); + spinner.remove(); + expect(await outcome).toEqual('AbortError'); + root.destroy(); + }); + + it('stops a node-scoped async loop (the spinner pattern)', async () => { + const root = createRoot(); + const spinner = addChild(root, root.documentElement, 'spinner'); + let frames = 0; + const loop = (async () => { + try { + while (true) { + spinner.setAttribute('frame', frames++); + await delay(1, undefined, { signal: spinner.signal }); + } + } catch { + // aborted — the node was removed + } + })(); + await delay(10); + spinner.remove(); + await loop; + const after = frames; + await delay(10); + expect(frames).toEqual(after); // no ticks after removal + expect(frames).toBeGreaterThan(0); + root.destroy(); + }); + + it('a FocusManager scoped to a container dies with it', () => { + const root = createRoot(); + const panel = addChild(root, root.documentElement, 'panel'); + const a = addChild(root, panel, 'A', 0); + addChild(root, panel, 'B', 0); + const focus = new FocusManager(panel); // seeds A, manages removals within panel + expect(focus.activeElement).toBe(a); + panel.remove(); + expect(panel.signal.aborted).toBe(true); + // pointer cleared, listener dead via panel.signal; falls back to its root + expect(focus.activeElement).toBe(panel); + root.destroy(); + }); +}); diff --git a/packages/dom/test/suite.ts b/packages/dom/test/suite.ts new file mode 100644 index 0000000..6d1305e --- /dev/null +++ b/packages/dom/test/suite.ts @@ -0,0 +1 @@ +export { afterEach, beforeEach, describe, it, expect } from 'vitest'; diff --git a/packages/dom/tsconfig.json b/packages/dom/tsconfig.json new file mode 100644 index 0000000..7e6e876 --- /dev/null +++ b/packages/dom/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": ["@bomb.sh/tools/tsconfig.json"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40406cc..19e40fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,34 @@ importers: specifier: 'catalog:' version: 0.8.0 + packages/demo: + dependencies: + '@bomb.sh/dom': + specifier: workspace:* + version: link:../dom + '@bomb.sh/tty': + specifier: latest + version: 0.8.0 + '@types/node': + specifier: ^26.0.0 + version: 26.1.1 + devDependencies: + '@bomb.sh/tools': + specifier: latest + version: 0.5.4(@types/node@26.1.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + + packages/dom: + devDependencies: + '@bomb.sh/tools': + specifier: latest + version: 0.5.4(@types/node@26.1.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + '@types/node': + specifier: ^26.0.0 + version: 26.1.1 + vitest: + specifier: ^4.1.2 + version: 4.1.9(@types/node@26.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + packages: '@bomb.sh/args@0.3.1': @@ -162,48 +190,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.137.0': resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.137.0': resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.137.0': resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.137.0': resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.137.0': resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.137.0': resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==} @@ -282,41 +318,49 @@ packages: resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.21.3': resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.21.3': resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.21.3': resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.21.3': resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==} @@ -385,48 +429,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.47.0': resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.47.0': resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.47.0': resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.47.0': resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.47.0': resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.47.0': resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.47.0': resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.47.0': resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} @@ -499,48 +551,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -638,72 +698,84 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.1.5': resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-arm64-musl@1.1.5': resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-ppc64-gnu@1.1.5': resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.5': resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.5': resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-musl@1.1.5': resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -775,6 +847,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': resolution: {integrity: sha512-8AX9NwC+G6Sbh5hNLnx8YgxoRV/8BH8FQRtZ86OTtUQfESMRvwszOGTtfcC32g86O5jsEQPDHXKVSnsIzWh6lg==} engines: {node: '>=16.20.0'} @@ -870,31 +945,37 @@ packages: resolution: {integrity: sha512-fbxg3cBPdJ++36DXtdzcoKw2xzFov91Wxvmn1khX9MXQbDqJQLJmITZhtokcZsj4uGJe32sUmxAZnKbUtZLjmA==} cpu: [arm] os: [linux] + libc: [glibc] '@yuku-codegen/binding-linux-arm-musl@0.6.3': resolution: {integrity: sha512-Jk4P7kocGEisSvUFIm1VuHO3hC01LvS3sYAAmVVu1/ve5TuZ0iXyl9kIGtd1ZrgUvchgvZWNOaB+/Kq/RO63FA==} cpu: [arm] os: [linux] + libc: [musl] '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': resolution: {integrity: sha512-i1xE8Bx1YZLheWtBZHD0Mq3nAIDrhgiH7o8VB4GiCbHufKb4XKj4CqSDMWSC0RYPmn//E+UEd1NsE2NbOux1tQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@yuku-codegen/binding-linux-arm64-musl@0.6.3': resolution: {integrity: sha512-ZeLkC6xZrlDoIJTadHfqTABTmsj2f6wCCtYBYx/RPGgdmQcGLA0NALRl7m0tnK2CT45eNRuOYzsfEZyF0XWM/A==} cpu: [arm64] os: [linux] + libc: [musl] '@yuku-codegen/binding-linux-x64-gnu@0.6.3': resolution: {integrity: sha512-HNYt7zjIChPcnjZRG42CZq3Zn5mqaRo4UcFLr4mIbmGqdhX5hDDE8O8US8YgYyOUosfbBTBFdsbvFwVAx1TOkQ==} cpu: [x64] os: [linux] + libc: [glibc] '@yuku-codegen/binding-linux-x64-musl@0.6.3': resolution: {integrity: sha512-/1ttT31dAQc7hGtXWSEYEgzGtakAyO2C+/GqAzIuKXlGLpNPZgXdR8LZ0iDHDalbEr3AuHIPRV43sWLUrHWdsA==} cpu: [x64] os: [linux] + libc: [musl] '@yuku-codegen/binding-win32-arm64@0.6.3': resolution: {integrity: sha512-oAArRDU1lkKg+xFEtiQ7C+/wghpqrkBrFWM05W73S+3sZz8JIHH79Q+Qh5gWls3i8vccitUgCnvln5V7xKn3XQ==} @@ -925,31 +1006,37 @@ packages: resolution: {integrity: sha512-/4LzmPXPaCWqIpY1j3+XVb8rbXIratqCte3A4sGEjua6aVhvVxEVAqeKlBsoGkORWLeqbpcxhgxKwOGM17eexA==} cpu: [arm] os: [linux] + libc: [glibc] '@yuku-parser/binding-linux-arm-musl@0.6.3': resolution: {integrity: sha512-Df4jk0M/eNKKQfYzXBBKhKkmJBpB+XoX2LkMxmlK3GN+fxUdeb8EM78wX+1+eLVl5dZNo6f7gOd6oDV0gChevw==} cpu: [arm] os: [linux] + libc: [musl] '@yuku-parser/binding-linux-arm64-gnu@0.6.3': resolution: {integrity: sha512-sRCtDktUgIbbV78SYX3wdGVVm1Hz/nSUS24JgXB4MzUeGNwBNB+eQAWMtBxCGriyJNXK3zwfj+SSgvTmUdPf/A==} cpu: [arm64] os: [linux] + libc: [glibc] '@yuku-parser/binding-linux-arm64-musl@0.6.3': resolution: {integrity: sha512-3J/jV3ROSqlhLyB/6i5EUHxjkom5i59iPvrtiAsnAjHzMsZJJPEke9LSaOsB0rf4MJFH9AmjvsK8gDahTjZy+A==} cpu: [arm64] os: [linux] + libc: [musl] '@yuku-parser/binding-linux-x64-gnu@0.6.3': resolution: {integrity: sha512-a5mPn/OMSq2Aa2i7eJXcc37Jtw0b89gDO2mDpXN769b06IirEiqOzLNNJd6R7R8DxoadHzY0nLFhYNChE+jyAg==} cpu: [x64] os: [linux] + libc: [glibc] '@yuku-parser/binding-linux-x64-musl@0.6.3': resolution: {integrity: sha512-kQSjfa6zdvotuXEGNKQ9vZZxE+lcEEbTUMSuvY/5+0crlwBCjEqrf9/W9ViFDoQAEt8WJiu/mtVNYkKAOkjLmA==} cpu: [x64] os: [linux] + libc: [musl] '@yuku-parser/binding-win32-arm64@0.6.3': resolution: {integrity: sha512-ZawdN3R0YKr48BeXCpbax+WDWbgEG6nWyDosVeZasrT5TjgfF4XMP5SfuyMNvRJ4gbTitrcYHZkENSXZ9ncqcg==} @@ -1102,24 +1189,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1187,10 +1278,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -1345,6 +1432,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unrun@0.2.39: resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} engines: {node: '>=20.19.0'} @@ -1517,6 +1607,49 @@ snapshots: - vite-plus - vue-tsc + '@bomb.sh/tools@0.5.4(@types/node@26.1.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@bomb.sh/args': 0.3.1 + '@humanfs/node': 0.16.8 + '@humanfs/types': 0.15.0 + '@typescript/native-preview': 7.0.0-dev.20260623.1 + knip: 6.18.0 + oxfmt: 0.47.0 + oxlint: 1.71.0 + publint: 0.3.21 + tinyexec: 1.2.4 + tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39) + ultramatter: 0.0.4 + vitest: 4.1.9(@types/node@26.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@ts-macro/tsc' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-preview' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - happy-dom + - jsdom + - msw + - oxc-resolver + - oxlint-tsgolint + - tsx + - typescript + - unplugin-unused + - unrun + - vite + - vite-plus + - vue-tsc + '@bomb.sh/tty@0.8.0': {} '@clack/core@1.4.3': @@ -1995,6 +2128,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': optional: true @@ -2043,6 +2180,14 @@ snapshots: optionalDependencies: vite: 8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 @@ -2177,9 +2322,9 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 formatly@0.3.0: dependencies: @@ -2204,13 +2349,13 @@ snapshots: knip@6.18.0: dependencies: - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 oxc-parser: 0.137.0 oxc-resolver: 11.21.3 - picomatch: 4.0.4 + picomatch: 4.0.5 smol-toml: 1.7.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 @@ -2376,8 +2521,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} postcss@8.5.15: @@ -2480,8 +2623,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -2529,6 +2672,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@8.3.0: {} + unrun@0.2.39: dependencies: rolldown: 1.0.0-rc.17 @@ -2547,6 +2692,19 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 + vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.15 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + vitest-ansi-serializer@0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))): dependencies: vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -2565,7 +2723,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -2578,6 +2736,33 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.9(@types/node@26.1.1)(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + transitivePeerDependencies: + - msw + walk-up-path@4.0.0: {} why-is-node-running@2.3.0: From 6ff3b6a53ab2a5b7e6a93af455279448fc58d42f Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 16 Jul 2026 02:00:10 -0400 Subject: [PATCH 2/2] ref(dom): expose states under `states` --- examples/focus/package.json | 2 +- examples/focus/src/index.ts | 2 +- packages/dom/README.md | 12 ++- packages/dom/src/lib/focus.ts | 23 +++++- packages/dom/src/lib/node.ts | 37 +++++++++ packages/dom/src/lib/types.ts | 6 ++ packages/dom/test/focus.test.ts | 12 +-- packages/dom/test/states.test.ts | 133 +++++++++++++++++++++++++++++++ pnpm-lock.yaml | 9 +-- 9 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 packages/dom/test/states.test.ts diff --git a/examples/focus/package.json b/examples/focus/package.json index 966750c..cfc27b5 100644 --- a/examples/focus/package.json +++ b/examples/focus/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@bomb.sh/dom": "workspace:*", - "@bomb.sh/tty": "catalog:*" + "@bomb.sh/tty": "catalog:" }, "devDependencies": { "@bomb.sh/tools": "latest" diff --git a/examples/focus/src/index.ts b/examples/focus/src/index.ts index f7a8a95..371cf53 100644 --- a/examples/focus/src/index.ts +++ b/examples/focus/src/index.ts @@ -53,7 +53,7 @@ function makeTextInput(root: Root, parent: Node, name: string): void { node.setAttribute('tabindex', 0); node.setAttribute('value', ''); layout(node, () => { - const color = node.getAttribute('focused') ? rgba(255, 255, 255) : GRAY; + const color = node.states.has('focus') ? rgba(255, 255, 255) : GRAY; const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; return [ open(node.id, { diff --git a/packages/dom/README.md b/packages/dom/README.md index 5c03fb8..6546e28 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -51,7 +51,7 @@ diffing, no ops in this package. // compose with an op-based renderer (e.g. @bomb.sh/tty): read state, return ops function textInput(node: Node): Op[] { return [ - open(node.id, { border: node.getAttribute('focused') ? focusedBorder : border }), + open(node.id, { border: node.states.has('focus') ? focusedBorder : border }), text(String(node.getAttribute('value') ?? '')), close(), ]; @@ -74,6 +74,9 @@ reordering: - State is **attributes**: `getAttribute`/`setAttribute`/`hasAttribute`/ `removeAttribute`, with a frozen `node.attributes` snapshot for renderers. Focusability is literally `setAttribute('tabindex', 0)`. +- Derived pseudo-class flags live in **`node.states`** (`'focus'`, + `'focus-within'`) — the `ElementInternals.states` analog. Managers write + them, renderers read them; the attribute namespace stays author-owned. Deliberate divergences, documented rather than hidden: attribute values are JsonValue (renderers need structure; the DOM's string-only rule buys nothing @@ -124,9 +127,10 @@ Focus is structured the way the DOM structures it — authoritative state held by an owner, not a property scanned for: - **Focusability is `tabindex`**, like the DOM: `setAttribute('tabindex', 0)` - joins sequential traversal; `-1` is focusable only via `focus()`. The - `focused` attribute is the derived projection renderers read (`:focus`), - written only on transitions. + joins sequential traversal; `-1` is focusable only via `focus()`. Transitions + project into `node.states`: `'focus'` on the active node, `'focus-within'` + up its ancestor chain — the pseudo-classes renderers match on, minus the + colon. - **`FocusManager`** is the `document.activeElement` analog: an O(1) pointer, `focus()`, and sequential `next()`/`previous()` (Tab) traversal. Focus changes fire `blur`/`focusout` at the old node and `focus`/`focusin` at the diff --git a/packages/dom/src/lib/focus.ts b/packages/dom/src/lib/focus.ts index e76e7a8..f6b1968 100644 --- a/packages/dom/src/lib/focus.ts +++ b/packages/dom/src/lib/focus.ts @@ -6,8 +6,9 @@ // // - Focusability is the `tabindex` attribute, like the DOM: `0` (or any // non-negative number) joins sequential traversal; `-1` is focusable only -// programmatically. The `focused` attribute is a derived projection for -// renderers (`:focus`), written only on transitions. +// programmatically. The manager projects transitions into `node.states` for +// renderers: `'focus'` on the active node, `'focus-within'` on its ancestor +// chain — the pseudo-classes, minus the colon. Attributes stay author-owned. // - `FocusManager` is `document`'s focus machinery: an `activeElement` pointer, // `focus()`, and sequential (Tab) traversal. // - `focusgroup` is an attribute on a container using the Open UI @@ -225,16 +226,26 @@ export class FocusManager { } const old = this.#active; if (old) { - old.removeAttribute('focused'); + this.#clearStates(old); old.dispatchEvent(new FocusEvent('blur', node)); old.dispatchEvent(new FocusEvent('focusout', node)); } this.#active = node; - node.setAttribute('focused', true); + node.states.add('focus'); + for (let n: Node | undefined = node; n; n = n.parent) { + n.states.add('focus-within'); + } node.dispatchEvent(new FocusEvent('focus', old)); node.dispatchEvent(new FocusEvent('focusin', old)); } + #clearStates(node: Node): void { + node.states.delete('focus'); + for (let n: Node | undefined = node; n; n = n.parent) { + n.states.delete('focus-within'); + } + } + next(): void { this.#move(1); } @@ -327,6 +338,10 @@ export class FocusManager { return; } } + // No successor: clear states while the removed chain is still attached + // (this listener runs before detach), so surviving ancestors drop + // `focus-within`. + this.#clearStates(active); this.#active = undefined; } } diff --git a/packages/dom/src/lib/node.ts b/packages/dom/src/lib/node.ts index 4fb7415..0798781 100644 --- a/packages/dom/src/lib/node.ts +++ b/packages/dom/src/lib/node.ts @@ -27,6 +27,41 @@ class NodeDataImpl implements NodeData { } } +// CustomStateSet analog: a Set of pseudo-class flags that invalidates +// rendering on real mutations only (adding a present state or deleting an +// absent one is a no-op, no `change`). +class StateSetImpl extends Set { + #tree: TreeState; + + constructor(tree: TreeState) { + super(); + this.#tree = tree; + } + + override add(state: string): this { + if (!super.has(state)) { + super.add(state); + this.#tree.markDirty(); + } + return this; + } + + override delete(state: string): boolean { + const deleted = super.delete(state); + if (deleted) { + this.#tree.markDirty(); + } + return deleted; + } + + override clear(): void { + if (this.size > 0) { + super.clear(); + this.#tree.markDirty(); + } + } +} + // Shared per-tree bookkeeping; owned by the root, threaded to every node. // `registry` holds CONNECTED nodes only, so getElementById behaves like the // DOM's (detached nodes are not resolvable by id). @@ -42,6 +77,7 @@ export class NodeImpl extends PropagationTarget implements Node { _children: NodeImpl[] = []; _parent: NodeImpl | undefined; readonly data: NodeData = new NodeDataImpl(); + readonly states: Set; readonly #tree: TreeState; readonly #controller = new AbortController(); @@ -52,6 +88,7 @@ export class NodeImpl extends PropagationTarget implements Node { ) { super(); this.#tree = tree; + this.states = new StateSetImpl(tree); } protected override getParentTarget(): PropagationTarget | undefined { diff --git a/packages/dom/src/lib/types.ts b/packages/dom/src/lib/types.ts index ac6e343..a8ec73b 100644 --- a/packages/dom/src/lib/types.ts +++ b/packages/dom/src/lib/types.ts @@ -39,6 +39,12 @@ export interface Node extends EventTarget { readonly parent: Node | undefined; readonly isConnected: boolean; readonly data: NodeData; + // Pseudo-class flags (`'focus'`, `'focus-within'`, ...) — the + // ElementInternals.states analog, kept out of the author-owned attribute + // namespace. Convention: authors write attributes; managers (FocusManager, + // ...) write states; renderers read both. Mutations coalesce into the + // root's `change` event, like attributes. + readonly states: Set; // Aborts when this node is removed (or the root destroyed). Hand it to // anything whose lifetime should match the node's: listeners on ancestors // (`{ signal }`), timers, fetch, streams. Cancellation is cooperative. diff --git a/packages/dom/test/focus.test.ts b/packages/dom/test/focus.test.ts index 90ce773..6fd5996 100644 --- a/packages/dom/test/focus.test.ts +++ b/packages/dom/test/focus.test.ts @@ -17,7 +17,7 @@ describe('FocusManager construction', () => { addChild(root, root.documentElement, 'B', 0); const focus = new FocusManager(root.documentElement); expect(focus.activeElement).toBe(a); - expect(a.getAttribute('focused')).toBe(true); + expect(a.states.has('focus')).toBe(true); root.destroy(); }); @@ -25,7 +25,7 @@ describe('FocusManager construction', () => { const root = createRoot(); const focus = new FocusManager(root.documentElement); expect(focus.activeElement).toBe(root.documentElement); - expect(root.documentElement.hasAttribute('focused')).toBe(false); + expect(root.documentElement.states.has('focus')).toBe(false); root.destroy(); }); @@ -75,7 +75,7 @@ describe('tabindex', () => { expect(focus.activeElement.localName).toEqual('B'); // ring skips hidden focus.focus(hidden); // but programmatic focus works expect(focus.activeElement).toBe(hidden); - expect(a.hasAttribute('focused')).toBe(false); + expect(a.states.has('focus')).toBe(false); root.destroy(); }); }); @@ -160,8 +160,8 @@ describe('focus()', () => { const focus = new FocusManager(root.documentElement); // seeds A focus.focus(b); expect(focus.activeElement).toBe(b); - expect(a.hasAttribute('focused')).toBe(false); - expect(b.getAttribute('focused')).toBe(true); + expect(a.states.has('focus')).toBe(false); + expect(b.states.has('focus')).toBe(true); root.destroy(); }); @@ -246,7 +246,7 @@ describe('focused node removal', () => { const a = addChild(root, root.documentElement, 'A', 0); addChild(root, root.documentElement, 'B', 0); const focus = new FocusManager(root.documentElement); // seeds A - expect(a.getAttribute('focused')).toBe(true); + expect(a.states.has('focus')).toBe(true); a.remove(); expect(focus.activeElement.localName).toEqual('B'); root.destroy(); diff --git a/packages/dom/test/states.test.ts b/packages/dom/test/states.test.ts new file mode 100644 index 0000000..a87d247 --- /dev/null +++ b/packages/dom/test/states.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from '../test/suite.ts'; +import { createRoot, FocusManager, type Node, type Root } from '../src/index.ts'; + +function nextMicrotask(): Promise { + return Promise.resolve(); +} + +function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { + const node = root.createElement(localName); + if (tabindex !== undefined) { + node.setAttribute('tabindex', tabindex); + } + parent.append(node); + return node; +} + +describe('node.states', () => { + it('starts empty and round-trips add/has/delete', () => { + const root = createRoot(); + const node = root.createElement('a'); + expect(node.states.size).toEqual(0); + node.states.add('focus'); + expect(node.states.has('focus')).toBe(true); + expect(node.states.delete('focus')).toBe(true); + expect(node.states.has('focus')).toBe(false); + root.destroy(); + }); + + it('is separate from attributes', () => { + const root = createRoot(); + const node = root.documentElement; + node.states.add('focus'); + expect(node.hasAttribute('focus')).toBe(false); + expect(Object.keys(node.attributes)).toEqual([]); + root.destroy(); + }); + + it('mutations coalesce into one change event', async () => { + const root = createRoot(); + const a = root.createElement('a'); + root.documentElement.append(a); + await nextMicrotask(); // settle the append burst + let changes = 0; + root.addEventListener('change', () => changes++); + a.states.add('focus'); + a.states.add('focus-within'); + await nextMicrotask(); + expect(changes).toEqual(1); + root.destroy(); + }); + + it('redundant mutations do not emit a change', async () => { + const root = createRoot(); + const a = root.createElement('a'); + root.documentElement.append(a); + a.states.add('focus'); + await nextMicrotask(); + let changes = 0; + root.addEventListener('change', () => changes++); + a.states.add('focus'); // already present + a.states.delete('absent'); + await nextMicrotask(); + expect(changes).toEqual(0); + root.destroy(); + }); +}); + +describe('FocusManager states', () => { + it('seeding sets focus on the node and focus-within up the chain', () => { + const root = createRoot(); + const panel = addChild(root, root.documentElement, 'panel'); + const a = addChild(root, panel, 'A', 0); + new FocusManager(root.documentElement); + expect(a.states.has('focus')).toBe(true); + expect(a.states.has('focus-within')).toBe(true); + expect(panel.states.has('focus-within')).toBe(true); + expect(root.documentElement.states.has('focus-within')).toBe(true); + expect(panel.states.has('focus')).toBe(false); + root.destroy(); + }); + + it('does not write a focused attribute', () => { + const root = createRoot(); + const a = addChild(root, root.documentElement, 'A', 0); + new FocusManager(root.documentElement); + expect(a.hasAttribute('focused')).toBe(false); + root.destroy(); + }); + + it('a transition moves focus and focus-within to the new chain', () => { + const root = createRoot(); + const left = addChild(root, root.documentElement, 'left'); + const a = addChild(root, left, 'A', 0); + const right = addChild(root, root.documentElement, 'right'); + const b = addChild(root, right, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + focus.focus(b); + expect(a.states.has('focus')).toBe(false); + expect(a.states.has('focus-within')).toBe(false); + expect(left.states.has('focus-within')).toBe(false); + expect(b.states.has('focus')).toBe(true); + expect(right.states.has('focus-within')).toBe(true); + // the shared ancestor keeps focus-within across the transition + expect(root.documentElement.states.has('focus-within')).toBe(true); + root.destroy(); + }); + + it('removing the focused node moves the states to the successor', () => { + const root = createRoot(); + const left = addChild(root, root.documentElement, 'left'); + const a = addChild(root, left, 'A', 0); + const b = addChild(root, root.documentElement, 'B', 0); + const focus = new FocusManager(root.documentElement); // seeds A + a.remove(); + expect(focus.activeElement).toBe(b); + expect(b.states.has('focus')).toBe(true); + // the surviving ancestor of the removed node is no longer in the chain + expect(left.states.has('focus-within')).toBe(false); + root.destroy(); + }); + + it('removing the last focusable clears focus-within from survivors', () => { + const root = createRoot(); + const panel = addChild(root, root.documentElement, 'panel'); + const a = addChild(root, panel, 'A', 0); + const focus = new FocusManager(root.documentElement); // seeds A + a.remove(); + expect(focus.activeElement).toBe(root.documentElement); + expect(panel.states.has('focus-within')).toBe(false); + expect(root.documentElement.states.has('focus-within')).toBe(false); + root.destroy(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19e40fa..158922b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,17 +39,14 @@ importers: specifier: 'catalog:' version: 0.8.0 - packages/demo: + examples/focus: dependencies: '@bomb.sh/dom': specifier: workspace:* - version: link:../dom + version: link:../../packages/dom '@bomb.sh/tty': - specifier: latest + specifier: 'catalog:' version: 0.8.0 - '@types/node': - specifier: ^26.0.0 - version: 26.1.1 devDependencies: '@bomb.sh/tools': specifier: latest