···11+// oxlint-disable bombshell-dev/no-generic-error
22+import {
33+ createNodeData,
44+ createRoot,
55+ FocusGroupManager,
66+ FocusManager,
77+ type Node,
88+ type Root,
99+} from '@bomb.sh/dom';
1010+import {
1111+ alternateBuffer,
1212+ close,
1313+ createInput,
1414+ createTerm,
1515+ cursor,
1616+ fit,
1717+ grow,
1818+ type KeyEvent,
1919+ type Op,
2020+ open,
2121+ percent,
2222+ rgba,
2323+ settings,
2424+ text,
2525+} from '@bomb.sh/tty';
2626+import { stdin, stdout } from 'node:process';
2727+2828+const GRAY = rgba(100, 100, 100);
2929+3030+// Terminal key events, carried on the standard Event vocabulary. Bubbling
3131+// does the routing: an input consumes a key with stopPropagation(); anything
3232+// it ignores reaches the root handler.
3333+class KeyboardEvent extends Event {
3434+ constructor(readonly detail: KeyEvent) {
3535+ super(detail.type, { bubbles: true, cancelable: true });
3636+ }
3737+}
3838+3939+interface LayoutOptions {
4040+ node: Node;
4141+ children: Iterable<Op>;
4242+}
4343+4444+const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>('demo:layout', () => []);
4545+4646+function layout(node: Node, body: (options: LayoutOptions) => Op[]): void {
4747+ node.data.set(layoutKey, body);
4848+}
4949+5050+function makeTextInput(root: Root, parent: Node, name: string): void {
5151+ const node = root.createElement(name);
5252+ parent.append(node);
5353+ node.setAttribute('tabindex', 0);
5454+ node.setAttribute('value', '');
5555+ layout(node, () => {
5656+ const color = node.getAttribute('focused') ? rgba(255, 255, 255) : GRAY;
5757+ const border = { color, top: 1, right: 1, bottom: 1, left: 1 };
5858+ return [
5959+ open(node.id, {
6060+ border,
6161+ layout: {
6262+ height: fit(3),
6363+ width: percent(0.3),
6464+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
6565+ },
6666+ }),
6767+ text(String(node.getAttribute('value') ?? '')),
6868+ close(),
6969+ ];
7070+ });
7171+ node.addEventListener('keydown', (event) => {
7272+ const { key, code } = (event as KeyboardEvent).detail;
7373+ const value = String(node.getAttribute('value') ?? '');
7474+ if (key.length === 1) {
7575+ node.setAttribute('value', `${value}${key}`);
7676+ event.stopPropagation();
7777+ } else if (code === 'Backspace') {
7878+ node.setAttribute('value', value.slice(0, -1));
7979+ event.stopPropagation();
8080+ }
8181+ // anything else bubbles up to the root Tab/Backtab/arrow handler
8282+ });
8383+}
8484+8585+function screenBody({ node, children }: LayoutOptions): Op[] {
8686+ return [
8787+ open(node.id, {
8888+ layout: {
8989+ height: grow(),
9090+ width: grow(),
9191+ direction: 'ttb',
9292+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
9393+ },
9494+ border: {
9595+ color: rgba(255, 255, 255),
9696+ top: 1,
9797+ right: 1,
9898+ bottom: 1,
9999+ left: 1,
100100+ },
101101+ }),
102102+ ...children,
103103+ close(),
104104+ ];
105105+}
106106+107107+function containerBody({ node, children }: LayoutOptions): Op[] {
108108+ return [
109109+ open(node.id, {
110110+ border: { color: 0xfff, top: 1, right: 1, bottom: 1, left: 1 },
111111+ layout: {
112112+ height: fit(),
113113+ width: grow(),
114114+ direction: 'ttb',
115115+ padding: { top: 1, right: 1, bottom: 1, left: 1 },
116116+ },
117117+ }),
118118+ ...children,
119119+ close(),
120120+ ];
121121+}
122122+123123+function walk(node: Node): Op[] {
124124+ const children: Op[] = [];
125125+ for (const child of node.children) {
126126+ children.push(...walk(child));
127127+ }
128128+ const body = node.data.get(layoutKey);
129129+ return body ? body({ node, children }) : children;
130130+}
131131+132132+if (!stdin.isTTY) {
133133+ throw new Error('dom demo requires an interactive TTY');
134134+}
135135+136136+const root = createRoot();
137137+138138+layout(root.documentElement, screenBody);
139139+140140+const container = root.createElement('input-1');
141141+root.documentElement.append(container);
142142+layout(container, containerBody);
143143+144144+makeTextInput(root, container, 'input-1-1');
145145+makeTextInput(root, container, 'input-1-2');
146146+makeTextInput(root, root.documentElement, 'input-2');
147147+148148+// document.activeElement analog; seeds focus now that inputs exist (input-1-1).
149149+const focus = new FocusManager(root.documentElement);
150150+151151+// The two grouped inputs collapse into a single Tab stop, entered at the
152152+// last-focused input (memory). ArrowUp/ArrowDown move within the group —
153153+// listbox defaults: block axis, no wrap.
154154+const group = new FocusGroupManager(focus, container, 'listbox');
155155+156156+// Tab/Backtab/arrow navigation lives at the root; it only sees keys the
157157+// focused input let bubble. The group methods no-op while focus is outside
158158+// the group, so binding them here is safe.
159159+const navigate = (event: Event): void => {
160160+ const { code } = (event as KeyboardEvent).detail;
161161+ if (code === 'Tab') {
162162+ focus.next();
163163+ } else if (code === 'Backtab') {
164164+ focus.previous();
165165+ } else if (code === 'ArrowDown') {
166166+ group.next();
167167+ } else if (code === 'ArrowUp') {
168168+ group.previous();
169169+ }
170170+};
171171+root.documentElement.addEventListener('keydown', navigate);
172172+root.documentElement.addEventListener('keyrepeat', navigate);
173173+174174+const { columns, rows } = stdout.isTTY
175175+ ? { columns: stdout.columns, rows: stdout.rows }
176176+ : { columns: 80, rows: 24 };
177177+178178+let term = await createTerm({ height: rows, width: columns });
179179+180180+function render(): void {
181181+ const ops = walk(root.documentElement);
182182+ const { output } = term.render(ops);
183183+ stdout.write(output);
184184+}
185185+186186+root.addEventListener('change', render);
187187+188188+const tty = settings(cursor(false), alternateBuffer());
189189+stdin.setRawMode(true);
190190+stdout.write(tty.apply);
191191+192192+function shutdown(): void {
193193+ stdout.write(tty.revert);
194194+ stdin.setRawMode(false);
195195+ stdin.off('data', feed);
196196+ stdin.pause();
197197+}
198198+199199+const input = await createInput();
200200+let rescan: ReturnType<typeof setTimeout> | undefined;
201201+202202+function feed(bytes: Uint8Array): void {
203203+ clearTimeout(rescan);
204204+ const result = input.scan(bytes);
205205+ if (result.pending) {
206206+ rescan = setTimeout(() => feed(new Uint8Array()), result.pending.delay);
207207+ }
208208+ for (const event of result.events) {
209209+ if (event.type === 'keydown' && event.ctrl && event.code === 'c') {
210210+ shutdown();
211211+ return;
212212+ }
213213+ if (event.type === 'resize') {
214214+ void createTerm({ height: event.height, width: event.width }).then((next) => {
215215+ term = next;
216216+ render();
217217+ });
218218+ continue;
219219+ }
220220+ // All the input routing: dispatch at the focused node; capture, target,
221221+ // and bubble listeners do the rest.
222222+ focus.activeElement.dispatchEvent(new KeyboardEvent(event as KeyEvent));
223223+ }
224224+}
225225+stdin.on('data', feed);
226226+227227+render();
+197
packages/dom/README.md
···11+# @bomb.sh/dom
22+33+Headless component tree with DOM-style event propagation. Zero dependencies.
44+55+`@bomb.sh/dom` is a headless interaction tree for TUIs, stated entirely in the
66+platform's vocabulary. Nodes **are** `EventTarget`s; the tree API is
77+document-shaped (`createElement`/`append`/`insertBefore`, attributes,
88+`getElementById`, `tabindex`); events route by capture/target/bubble
99+propagation; a `change` event on the root signals "re-render". If you know the
1010+DOM, you already know this package.
1111+1212+## The question this package answers
1313+1414+> Can we just leverage `EventTarget` on the nodes to manage bubbling?
1515+1616+**Yes.** Node's native `EventTarget` provides listener storage, error
1717+isolation, and the full `Event` flag machinery (`stopPropagation`,
1818+`stopImmediatePropagation`, `preventDefault`, `once`, `signal`). What it lacks
1919+is a tree — `dispatchEvent` invokes every listener on a single target as if
2020+`AT_TARGET`. `PropagationTarget` (see `src/lib/events.ts`) adds the tree walk
2121+on top, and empirically that needs exactly two shims:
2222+2323+1. **Identity.** Each per-node native dispatch resets `event.target` to that
2424+ node, so the real target, phase, and path are pinned as own properties on
2525+ the event instance, shadowing the prototype getters. `currentTarget` needs
2626+ no shim — native dispatch sets it correctly per node, and nulls it after.
2727+2. **Phase filtering.** A bare `EventTarget` fires `{capture: true}` listeners
2828+ on every dispatch, so listeners register through a thin wrapper that
2929+ consults the pinned phase. The wrapper also owns `once`/`signal`, because a
3030+ native `once` would consume a listener whose phase never matched.
3131+3232+Between per-node dispatches, `event.cancelBubble` (the legacy readable alias
3333+for the stop-propagation flag) tells the walk when to halt. One divergence
3434+from the DOM: the flag can't be cleared from the outside, so a stopped event
3535+can't be re-dispatched — `dispatchEvent` throws and asks for a fresh event.
3636+3737+## The IR question
3838+3939+> Can we avoid an IR / VDOM that we normalize to ops?
4040+4141+**Yes — retained interaction tree, immediate-mode render.** This package's tree is
4242+_not_ a render tree and never normalizes to ops. It holds interaction state
4343+(focus, values, event listeners); rendering stays a pure function that walks
4444+the tree and produces `Op[]` fresh each frame — components are functions
4545+returning ops. The bridge between the two worlds is one string: `node.id`
4646+becomes the renderer's element key, so element identity in the renderer
4747+follows node identity in the interaction tree for free. No reconciliation, no
4848+diffing, no ops in this package.
4949+5050+```ts
5151+// compose with an op-based renderer (e.g. @bomb.sh/tty): read state, return ops
5252+function textInput(node: Node): Op[] {
5353+ return [
5454+ open(node.id, { border: node.getAttribute('focused') ? focusedBorder : border }),
5555+ text(String(node.getAttribute('value') ?? '')),
5656+ close(),
5757+ ];
5858+}
5959+root.addEventListener('change', () => render(textInput(input)));
6060+```
6161+6262+## The tree: document-shaped
6363+6464+Creation is separate from insertion, like the DOM — and insertion doubles as
6565+reordering:
6666+6767+- `root.createElement(localName)` returns a **detached** node; `parent.append(...nodes)`
6868+ and `parent.insertBefore(node, reference)` attach it. Detached nodes are not
6969+ resolvable via `root.getElementById` (connected-only, like the DOM).
7070+- Inserting an **already-attached** node moves it, state-preservingly: no
7171+ signal abort, no lifecycle events, listeners intact. This is the DOM's new
7272+ `moveBefore()` semantics applied to all insertions — reordering is
7373+ re-insertion, not a separate API.
7474+- State is **attributes**: `getAttribute`/`setAttribute`/`hasAttribute`/
7575+ `removeAttribute`, with a frozen `node.attributes` snapshot for renderers.
7676+ Focusability is literally `setAttribute('tabindex', 0)`.
7777+7878+Deliberate divergences, documented rather than hidden: attribute values are
7979+JsonValue (renderers need structure; the DOM's string-only rule buys nothing
8080+headless), `getAttribute` returns `undefined` for missing attributes (`null`
8181+is a legal value here), and `remove()` is **terminal** — it destroys the
8282+subtree and aborts `node.signal`. The DOM's detached-but-alive limbo exists
8383+for GC and adoption; a TUI tree doesn't need it, and the entire `node.signal`
8484+lifetime story depends on removal being final. Moves cover the legitimate
8585+reason to detach-and-reattach.
8686+8787+## Node lifetime: `node.signal`
8888+8989+Every node owns an `AbortSignal` that aborts when the node is removed (or the
9090+root destroyed), descendants first, in reverse creation order. Hand it to
9191+anything whose lifetime should match the node's:
9292+9393+```ts
9494+// a listener installed on an ancestor, cleaned up when the node dies
9595+root.documentElement.addEventListener('keydown', onKey, { signal: node.signal });
9696+9797+// a spinner that stops when its node is removed — no manual cleanup
9898+import { setTimeout as delay } from 'node:timers/promises';
9999+100100+async function spin(node: Node): Promise<void> {
101101+ const frames = ['⠋', '⠙', '⠹', '⠸'];
102102+ try {
103103+ for (let i = 0; ; i++) {
104104+ node.setAttribute('frame', frames[i % frames.length]!);
105105+ await delay(80, undefined, { signal: node.signal });
106106+ }
107107+ } catch {
108108+ // aborted — the node was removed
109109+ }
110110+}
111111+```
112112+113113+The same signal flows into `fetch`, `node:timers/promises`, streams, and
114114+this package's own `addEventListener` — one primitive, already understood by
115115+every web developer. One caveat: cancellation is **cooperative** — an async
116116+function keeps running past its awaits unless the awaited thing honors the
117117+signal (platform APIs do; arbitrary user code may not). `FocusManager`
118118+dogfoods the signal: its bookkeeping is registered with
119119+`{ signal: root.signal }` and disappears with its container.
120120+121121+## Focus: `FocusManager` and `focusgroup`
122122+123123+Focus is structured the way the DOM structures it — authoritative state held
124124+by an owner, not a property scanned for:
125125+126126+- **Focusability is `tabindex`**, like the DOM: `setAttribute('tabindex', 0)`
127127+ joins sequential traversal; `-1` is focusable only via `focus()`. The
128128+ `focused` attribute is the derived projection renderers read (`:focus`),
129129+ written only on transitions.
130130+- **`FocusManager`** is the `document.activeElement` analog: an O(1) pointer,
131131+ `focus()`, and sequential `next()`/`previous()` (Tab) traversal. Focus
132132+ changes fire `blur`/`focusout` at the old node and `focus`/`focusin` at the
133133+ new — `focus`/`blur` don't bubble, `focusin`/`focusout` do, and all four
134134+ carry `relatedTarget`, matching the browser.
135135+- **`focusgroup`** implements the
136136+ [Open UI scoped focusgroup explainer](https://open-ui.org/components/scoped-focusgroup.explainer/)
137137+ (shipped in Chrome 150) as an attribute with the same token grammar:
138138+ `'toolbar'`, `'tablist'`, `'listbox nomemory'`, `'menu wrap'`, …
139139+ The attribute alone is honored declaratively by `FocusManager`: a group
140140+ collapses to a single tab stop, entered at the last-focused item (memory) or
141141+ its first item — the explainer's guaranteed tab stop algorithm. `'none'`
142142+ opts a subtree out; nested groups are independent segments.
143143+- **`FocusGroupManager`** is the imperative half — arrow-key traversal
144144+ (`next`/`previous`/`first`/`last`) within one group, axis metadata for key
145145+ binding, and memory tracking via the bubbling `focusin` event. Its methods
146146+ no-op while focus is outside the group, so arrows can be bound globally.
147147+148148+```ts
149149+const focus = new FocusManager(root.documentElement);
150150+const tabs = new FocusGroupManager(focus, tabBar, 'tablist'); // inline, wrap, memory
151151+152152+// Tab collapses the group to one stop; arrows move within it
153153+if (code === 'Tab') focus.next();
154154+if (code === 'ArrowRight') tabs.next();
155155+if (code === 'ArrowLeft') tabs.previous();
156156+```
157157+158158+Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid
159159+tokens, and an opted-out element doesn't split the group into separate
160160+tab-stop segments — it just becomes its own stop.
161161+162162+## Sketch
163163+164164+```ts
165165+import { createRoot, FocusManager } from '@bomb.sh/dom';
166166+167167+const root = createRoot();
168168+const input = root.createElement('input');
169169+root.documentElement.append(input);
170170+input.setAttribute('tabindex', 0);
171171+input.setAttribute('value', '');
172172+173173+input.addEventListener('keydown', (event) => {
174174+ const { key } = (event as KeyEvent).detail;
175175+ if (key.length === 1) {
176176+ input.setAttribute('value', `${input.getAttribute('value')}${key}`);
177177+ event.stopPropagation(); // consumed — root never sees it
178178+ }
179179+});
180180+181181+const focus = new FocusManager(root.documentElement);
182182+183183+root.documentElement.addEventListener('keydown', (event) => {
184184+ if ((event as KeyEvent).detail.code === 'Tab') focus.next(); // bubbled here
185185+});
186186+187187+root.addEventListener('change', render);
188188+189189+// all the input routing there is: dispatch at the focused node
190190+focus.activeElement.dispatchEvent(new KeyEvent(raw));
191191+```
192192+193193+Run the demo:
194194+195195+```sh
196196+pnpm playground -e focus
197197+```
···11+// oxlint-disable bombshell-dev/no-generic-error
22+// oxlint-disable max-params
33+44+// The EventTarget experiment: can native EventTarget manage capture/bubble
55+// propagation over a parent-linked tree, without reimplementing the DOM?
66+//
77+// Native EventTarget provides listener storage, error isolation, and the Event
88+// flag machinery (stopPropagation, stopImmediatePropagation, preventDefault).
99+// What it lacks is a tree — dispatchEvent invokes every listener on a single
1010+// target as if AT_TARGET. PropagationTarget adds the tree walk, which needs
1111+// exactly two shims:
1212+//
1313+// 1. Identity. Each per-node native dispatch resets `event.target` to that
1414+// node, so the real target, phase, and path are pinned as own properties on
1515+// the event instance, shadowing the prototype getters. `currentTarget`
1616+// needs no shim — native dispatch sets it correctly per node.
1717+//
1818+// 2. Phase filtering. A bare EventTarget fires `{capture: true}` listeners on
1919+// every dispatch, so listeners are registered through a thin wrapper that
2020+// consults the pinned phase. The wrapper also owns `once` and `signal`,
2121+// because native `once` would consume a listener whose phase never matched.
2222+//
2323+// Between per-node dispatches, `event.cancelBubble` (the legacy readable alias
2424+// for the stop-propagation flag) tells the walk when to halt.
2525+2626+type Listener = EventListener | EventListenerObject;
2727+2828+// Wrappers keyed by capture flag, per (type, callback) — mirroring the DOM's
2929+// (type, callback, capture) listener identity for dedupe and removal.
3030+interface WrapperPair {
3131+ bubble?: EventListener;
3232+ capture?: EventListener;
3333+}
3434+3535+function normalizeOptions(
3636+ options: AddEventListenerOptions | boolean | undefined,
3737+): AddEventListenerOptions {
3838+ return typeof options === 'boolean' ? { capture: options } : (options ?? {});
3939+}
4040+4141+function pin(event: Event, key: string, value: unknown): void {
4242+ Object.defineProperty(event, key, { value, configurable: true });
4343+}
4444+4545+const inFlight = new WeakSet<Event>();
4646+4747+export class PropagationTarget extends EventTarget {
4848+ #listeners = new Map<string, Map<Listener, WrapperPair>>();
4949+5050+ // The DOM spec's "get the parent" hook. Subclasses with a tree override this.
5151+ protected getParentTarget(): PropagationTarget | undefined {
5252+ return undefined;
5353+ }
5454+5555+ override addEventListener(
5656+ type: string,
5757+ callback: Listener | null,
5858+ options?: AddEventListenerOptions | boolean,
5959+ ): void {
6060+ if (!callback) {
6161+ return;
6262+ }
6363+ const opts = normalizeOptions(options);
6464+ const capture = opts.capture === true;
6565+ if (opts.signal?.aborted) {
6666+ return;
6767+ }
6868+ let byCallback = this.#listeners.get(type);
6969+ if (!byCallback) {
7070+ byCallback = new Map();
7171+ this.#listeners.set(type, byCallback);
7272+ }
7373+ let pair = byCallback.get(callback);
7474+ if (!pair) {
7575+ pair = {};
7676+ byCallback.set(callback, pair);
7777+ }
7878+ const slot = capture ? 'capture' : 'bubble';
7979+ if (pair[slot]) {
8080+ return;
8181+ }
8282+ const wrapper = (event: Event): void => {
8383+ const phase = event.eventPhase;
8484+ if (phase === Event.CAPTURING_PHASE && !capture) {
8585+ return;
8686+ }
8787+ if (phase === Event.BUBBLING_PHASE && capture) {
8888+ return;
8989+ }
9090+ // `once` is consumed here, after the phase check, so a capture-once
9191+ // listener survives bubble walks that never match it.
9292+ if (opts.once) {
9393+ this.removeEventListener(type, callback, { capture });
9494+ }
9595+ if (typeof callback === 'function') {
9696+ callback.call(this, event);
9797+ } else {
9898+ callback.handleEvent(event);
9999+ }
100100+ };
101101+ pair[slot] = wrapper;
102102+ opts.signal?.addEventListener(
103103+ 'abort',
104104+ () => this.removeEventListener(type, callback, { capture }),
105105+ { once: true },
106106+ );
107107+ super.addEventListener(type, wrapper);
108108+ }
109109+110110+ override removeEventListener(
111111+ type: string,
112112+ callback: Listener | null,
113113+ options?: EventListenerOptions | boolean,
114114+ ): void {
115115+ if (!callback) {
116116+ return;
117117+ }
118118+ const capture = normalizeOptions(options).capture === true;
119119+ const byCallback = this.#listeners.get(type);
120120+ const pair = byCallback?.get(callback);
121121+ const slot = capture ? 'capture' : 'bubble';
122122+ const wrapper = pair?.[slot];
123123+ if (!byCallback || !pair || !wrapper) {
124124+ return;
125125+ }
126126+ delete pair[slot];
127127+ if (!pair.bubble && !pair.capture) {
128128+ byCallback.delete(callback);
129129+ }
130130+ if (byCallback.size === 0) {
131131+ this.#listeners.delete(type);
132132+ }
133133+ super.removeEventListener(type, wrapper);
134134+ }
135135+136136+ override dispatchEvent(event: Event): boolean {
137137+ if (inFlight.has(event)) {
138138+ throw new Error('This event is already being dispatched');
139139+ }
140140+ if (event.cancelBubble) {
141141+ // A previous dispatch stopped this event, and native EventTarget offers
142142+ // no way to clear the flag (the DOM resets it on re-dispatch; the legacy
143143+ // cancelBubble setter ignores `false`). Fail loud instead of silently
144144+ // dispatching an event no walk will carry.
145145+ throw new Error('This event was stopped by a previous dispatch; create a fresh event');
146146+ }
147147+ const ancestors: PropagationTarget[] = [];
148148+ for (let t = this.getParentTarget(); t; t = t.getParentTarget()) {
149149+ ancestors.push(t);
150150+ }
151151+ inFlight.add(event);
152152+ pin(event, 'target', this);
153153+ pin(event, 'composedPath', () => [this, ...ancestors]);
154154+ try {
155155+ pin(event, 'eventPhase', Event.CAPTURING_PHASE);
156156+ for (let i = ancestors.length - 1; i >= 0; i--) {
157157+ EventTarget.prototype.dispatchEvent.call(ancestors[i], event);
158158+ if (event.cancelBubble) {
159159+ return !event.defaultPrevented;
160160+ }
161161+ }
162162+ pin(event, 'eventPhase', Event.AT_TARGET);
163163+ EventTarget.prototype.dispatchEvent.call(this, event);
164164+ if (event.bubbles && !event.cancelBubble) {
165165+ pin(event, 'eventPhase', Event.BUBBLING_PHASE);
166166+ for (const ancestor of ancestors) {
167167+ EventTarget.prototype.dispatchEvent.call(ancestor, event);
168168+ if (event.cancelBubble) {
169169+ break;
170170+ }
171171+ }
172172+ }
173173+ return !event.defaultPrevented;
174174+ } finally {
175175+ inFlight.delete(event);
176176+ pin(event, 'eventPhase', Event.NONE);
177177+ pin(event, 'composedPath', () => []);
178178+ }
179179+ }
180180+}
+420
packages/dom/src/lib/focus.ts
···11+// oxlint-disable bombshell-dev/no-generic-error
22+// oxlint-disable max-params
33+44+// DOM-shaped focus: authoritative state held by an owner, not derived by
55+// scanning the tree on every query — the way the platform does it:
66+//
77+// - Focusability is the `tabindex` attribute, like the DOM: `0` (or any
88+// non-negative number) joins sequential traversal; `-1` is focusable only
99+// programmatically. The `focused` attribute is a derived projection for
1010+// renderers (`:focus`), written only on transitions.
1111+// - `FocusManager` is `document`'s focus machinery: an `activeElement` pointer,
1212+// `focus()`, and sequential (Tab) traversal.
1313+// - `focusgroup` is an attribute on a container using the Open UI
1414+// scoped-focusgroup token grammar (`'toolbar'`, `'tablist wrap'`,
1515+// `'listbox nomemory'`, ...) — "the explainer" below:
1616+// https://open-ui.org/components/scoped-focusgroup.explainer/
1717+// FocusManager honors it declaratively:
1818+// a group collapses to a single tab stop (entry = last-focused memory, else
1919+// first item), exactly like the explainer's guaranteed tab stop algorithm.
2020+// - `FocusGroupManager` is the imperative side of the attribute: arrow-key
2121+// traversal (`next`/`previous`/`first`/`last`) within the group, plus memory
2222+// tracking via a bubbling `focusin` listener.
2323+//
2424+// Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid
2525+// tokens, and an opted-out (`'none'`) element does not split the group into
2626+// separate tab-stop segments — it just becomes its own stop.
2727+import type { Node } from './types.ts';
2828+import { createNodeData } from './types.ts';
2929+3030+export type FocusEventType = 'focus' | 'blur' | 'focusin' | 'focusout';
3131+3232+// DOM FocusEvent: `relatedTarget` is the other side of the transition — where
3333+// focus is going (blur/focusout) or where it came from (focus/focusin).
3434+// Like the DOM, focus/blur do not bubble; focusin/focusout do.
3535+export class FocusEvent extends Event {
3636+ constructor(
3737+ type: FocusEventType,
3838+ readonly relatedTarget: Node | undefined,
3939+ ) {
4040+ super(type, { bubbles: type === 'focusin' || type === 'focusout' });
4141+ }
4242+}
4343+4444+// In sequential (Tab/arrow) traversal, like tabindex="0" in the DOM.
4545+function isSequentiallyFocusable(node: Node): boolean {
4646+ const tabindex = node.getAttribute('tabindex');
4747+ return typeof tabindex === 'number' && tabindex >= 0;
4848+}
4949+5050+// Focusable at all — includes tabindex="-1" (programmatic focus only).
5151+function isFocusable(node: Node): boolean {
5252+ return typeof node.getAttribute('tabindex') === 'number';
5353+}
5454+5555+// ---------------------------------------------------------------------------
5656+// focusgroup token grammar (Open UI scoped focusgroup)
5757+5858+export interface FocusGroupConfig {
5959+ behavior?: 'toolbar' | 'tablist' | 'radiogroup' | 'listbox' | 'menu' | 'menubar';
6060+ axis: 'inline' | 'block' | 'both';
6161+ wrap: boolean;
6262+ memory: boolean;
6363+}
6464+6565+const BEHAVIORS: Record<string, Pick<FocusGroupConfig, 'axis' | 'wrap'>> = {
6666+ toolbar: { axis: 'inline', wrap: false },
6767+ tablist: { axis: 'inline', wrap: true },
6868+ radiogroup: { axis: 'both', wrap: true },
6969+ listbox: { axis: 'block', wrap: false },
7070+ menu: { axis: 'block', wrap: true },
7171+ menubar: { axis: 'inline', wrap: true },
7272+};
7373+7474+// Token strings are parsed on every stop computation (i.e., every Tab), so
7575+// results are memoized by the exact attribute string. The set of distinct
7676+// focusgroup values in an app is tiny; the map is effectively bounded.
7777+const parsed = new Map<string, FocusGroupConfig>();
7878+7979+export function parseFocusgroup(value: string): FocusGroupConfig {
8080+ const cached = parsed.get(value);
8181+ if (cached) {
8282+ return cached;
8383+ }
8484+ const tokens = value.split(/\s+/).filter(Boolean);
8585+ const behavior = tokens.find((t) => t in BEHAVIORS) as FocusGroupConfig['behavior'];
8686+ const base = behavior ? BEHAVIORS[behavior as string]! : { axis: 'both' as const, wrap: false };
8787+ const axis = tokens.includes('inline')
8888+ ? 'inline'
8989+ : tokens.includes('block')
9090+ ? 'block'
9191+ : base.axis;
9292+ const wrap = tokens.includes('wrap') ? true : tokens.includes('nowrap') ? false : base.wrap;
9393+ const config: FocusGroupConfig = Object.freeze({
9494+ behavior,
9595+ axis,
9696+ wrap,
9797+ memory: !tokens.includes('nomemory'),
9898+ });
9999+ parsed.set(value, config);
100100+ return config;
101101+}
102102+103103+const noneCache = new Map<string, boolean>();
104104+105105+function hasNoneToken(value: string): boolean {
106106+ let none = noneCache.get(value);
107107+ if (none === undefined) {
108108+ none = value.split(/\s+/).includes('none');
109109+ noneCache.set(value, none);
110110+ }
111111+ return none;
112112+}
113113+114114+// A node declares a group when its `focusgroup` attribute is a non-`none`
115115+// string.
116116+function groupValue(node: Node): string | undefined {
117117+ const value = node.getAttribute('focusgroup');
118118+ if (typeof value !== 'string') {
119119+ return undefined;
120120+ }
121121+ return hasNoneToken(value) ? undefined : value;
122122+}
123123+124124+function optsOut(node: Node): boolean {
125125+ const value = node.getAttribute('focusgroup');
126126+ return typeof value === 'string' && hasNoneToken(value);
127127+}
128128+129129+// The segment a node's tab stop collapses into: the nearest ancestor-or-self
130130+// declaring a group, stopping at opt-outs (a `none` subtree is independent of
131131+// every enclosing group) and at the traversal root. Nearest wins, so an item
132132+// in a nested group belongs to the nested segment, not the outer one.
133133+function segmentOf(node: Node, root: Node): Node | undefined {
134134+ for (let n: Node | undefined = node; n && n !== root; n = n.parent) {
135135+ if (groupValue(n) !== undefined) {
136136+ return n;
137137+ }
138138+ if (optsOut(n)) {
139139+ return undefined;
140140+ }
141141+ }
142142+ return undefined;
143143+}
144144+145145+// Last-focused item per group container — the explainer's focus memory.
146146+// Private to this module; validity is checked at read time via `node.signal`.
147147+const memoryKey = createNodeData<Node | undefined>('focus:memory');
148148+149149+// Items of a group segment: the container (if focusable) and its focusable
150150+// descendants, excluding subtrees that opt out (`none`) or declare their own
151151+// nested group (independent segments per the explainer).
152152+function segmentItems(group: Node): Node[] {
153153+ const items: Node[] = [];
154154+ const walk = (node: Node): void => {
155155+ if (node !== group && typeof node.getAttribute('focusgroup') === 'string') {
156156+ return;
157157+ }
158158+ if (isSequentiallyFocusable(node)) {
159159+ items.push(node);
160160+ }
161161+ for (const child of node.children) {
162162+ walk(child);
163163+ }
164164+ };
165165+ walk(group);
166166+ return items;
167167+}
168168+169169+// Every sequentially focusable node in tree order, groups flattened — used for
170170+// removal successors, where "next focusable thing" matters more than tab stops.
171171+function flatFocusables(root: Node): Node[] {
172172+ const result: Node[] = [];
173173+ const walk = (node: Node): void => {
174174+ if (isSequentiallyFocusable(node)) {
175175+ result.push(node);
176176+ }
177177+ for (const child of node.children) {
178178+ walk(child);
179179+ }
180180+ };
181181+ walk(root);
182182+ return result;
183183+}
184184+185185+// ---------------------------------------------------------------------------
186186+187187+interface TabStop {
188188+ stop: Node;
189189+ // Set when this stop is the collapsed entry of a focusgroup segment.
190190+ segment: Node | undefined;
191191+}
192192+193193+// The `document.activeElement` analog: authoritative focus state for a subtree,
194194+// with sequential (Tab) traversal that honors declarative `focusgroup` props.
195195+// Construct one per root (or per container for a nested focus scope); its
196196+// bookkeeping is registered with `{ signal: root.signal }` and dies with it.
197197+export class FocusManager {
198198+ #active: Node | undefined;
199199+200200+ constructor(readonly root: Node) {
201201+ const seed = this.#stops().find(({ stop }) => stop !== root);
202202+ if (seed) {
203203+ this.focus(seed.stop);
204204+ }
205205+ // Dispatched before detach, so successor computation sees the full tree.
206206+ root.addEventListener('remove', (event) => this.#onRemove(event.target as Node), {
207207+ signal: root.signal,
208208+ });
209209+ }
210210+211211+ // Never null: falls back to the root, like document.activeElement's body
212212+ // fallback.
213213+ get activeElement(): Node {
214214+ return this.#active ?? this.root;
215215+ }
216216+217217+ // Accepts any node with a tabindex, including -1 (programmatic focus, like
218218+ // the DOM). Sequential traversal only visits tabindex >= 0.
219219+ focus(node: Node): void {
220220+ if (!isFocusable(node)) {
221221+ throw new Error('Cannot focus a node without a tabindex attribute');
222222+ }
223223+ if (node === this.#active) {
224224+ return;
225225+ }
226226+ const old = this.#active;
227227+ if (old) {
228228+ old.removeAttribute('focused');
229229+ old.dispatchEvent(new FocusEvent('blur', node));
230230+ old.dispatchEvent(new FocusEvent('focusout', node));
231231+ }
232232+ this.#active = node;
233233+ node.setAttribute('focused', true);
234234+ node.dispatchEvent(new FocusEvent('focus', old));
235235+ node.dispatchEvent(new FocusEvent('focusin', old));
236236+ }
237237+238238+ next(): void {
239239+ this.#move(1);
240240+ }
241241+242242+ previous(): void {
243243+ this.#move(-1);
244244+ }
245245+246246+ // Tab stops in tree order: plain focusables, plus one collapsed stop per
247247+ // focusgroup segment (the explainer's guaranteed tab stop algorithm).
248248+ #stops(): TabStop[] {
249249+ const result: TabStop[] = [];
250250+ const walk = (node: Node, group: Node | undefined): void => {
251251+ let next = group;
252252+ const value = groupValue(node);
253253+ if (value !== undefined && node !== this.root) {
254254+ const entry = this.#entryOf(node, value);
255255+ if (entry) {
256256+ result.push({ stop: entry, segment: node });
257257+ }
258258+ next = node;
259259+ } else if (optsOut(node)) {
260260+ // Opted out of the enclosing group: independently tabbable.
261261+ next = undefined;
262262+ if (isSequentiallyFocusable(node)) {
263263+ result.push({ stop: node, segment: undefined });
264264+ }
265265+ } else if (!group && isSequentiallyFocusable(node)) {
266266+ result.push({ stop: node, segment: undefined });
267267+ }
268268+ for (const child of node.children) {
269269+ walk(child, next);
270270+ }
271271+ };
272272+ walk(this.root, undefined);
273273+ return result;
274274+ }
275275+276276+ // Where Tab lands when entering a group: memory if alive and valid, else
277277+ // the segment's first item.
278278+ #entryOf(group: Node, value: string): Node | undefined {
279279+ if (parseFocusgroup(value).memory) {
280280+ const memory = group.data.get(memoryKey);
281281+ if (
282282+ memory &&
283283+ !memory.signal.aborted &&
284284+ isSequentiallyFocusable(memory) &&
285285+ group.contains(memory)
286286+ ) {
287287+ return memory;
288288+ }
289289+ }
290290+ return segmentItems(group)[0];
291291+ }
292292+293293+ #move(delta: number): void {
294294+ const stops = this.#stops();
295295+ if (stops.length === 0) {
296296+ return;
297297+ }
298298+ const active = this.#active;
299299+ let idx = -1;
300300+ if (active) {
301301+ const segment = segmentOf(active, this.root);
302302+ idx = segment
303303+ ? stops.findIndex((s) => s.segment === segment)
304304+ : stops.findIndex((s) => s.stop === active);
305305+ }
306306+ if (idx === -1) {
307307+ this.focus(delta > 0 ? stops[0]!.stop : stops[stops.length - 1]!.stop);
308308+ return;
309309+ }
310310+ if (stops.length === 1) {
311311+ return;
312312+ }
313313+ this.focus(stops[(idx + delta + stops.length) % stops.length]!.stop);
314314+ }
315315+316316+ #onRemove(removed: Node): void {
317317+ const active = this.#active;
318318+ if (!active || !removed.contains(active)) {
319319+ return;
320320+ }
321321+ const chain = flatFocusables(this.root);
322322+ const start = chain.indexOf(active);
323323+ for (let i = 1; i < chain.length; i++) {
324324+ const candidate = chain[(start + i + chain.length) % chain.length]!;
325325+ if (!removed.contains(candidate)) {
326326+ this.focus(candidate);
327327+ return;
328328+ }
329329+ }
330330+ this.#active = undefined;
331331+ }
332332+}
333333+334334+// The imperative half of the `focusgroup` attribute: arrow-key traversal within
335335+// one group. Declares the group by writing the token string to the container's
336336+// `focusgroup` attribute (renderer-visible, exactly like the DOM) and tracks
337337+// focus memory via the bubbling `focusin` event.
338338+export class FocusGroupManager {
339339+ readonly config: FocusGroupConfig;
340340+ #controller = new AbortController();
341341+342342+ constructor(
343343+ readonly focus: FocusManager,
344344+ readonly container: Node,
345345+ tokens = '',
346346+ ) {
347347+ this.config = parseFocusgroup(tokens);
348348+ container.setAttribute('focusgroup', tokens);
349349+ container.addEventListener(
350350+ 'focusin',
351351+ (event) => {
352352+ const target = event.target as Node;
353353+ if (this.config.memory && this.items.includes(target)) {
354354+ container.data.set(memoryKey, target);
355355+ }
356356+ },
357357+ { signal: AbortSignal.any([container.signal, this.#controller.signal]) },
358358+ );
359359+ }
360360+361361+ // Advisory for key binding: which arrow keys the app should route here
362362+ // (headless code has no keyboard; the explainer's axis is a key concern).
363363+ get axis(): FocusGroupConfig['axis'] {
364364+ return this.config.axis;
365365+ }
366366+367367+ get items(): Node[] {
368368+ return segmentItems(this.container);
369369+ }
370370+371371+ // Arrow-key traversal only acts while focus is inside the group, like the
372372+ // DOM behavior — so apps can bind arrows globally and let the group no-op.
373373+ next(): void {
374374+ this.#move(1);
375375+ }
376376+377377+ previous(): void {
378378+ this.#move(-1);
379379+ }
380380+381381+ first(): void {
382382+ const items = this.items;
383383+ if (items.length > 0 && this.#index(items) !== -1) {
384384+ this.focus.focus(items[0]!);
385385+ }
386386+ }
387387+388388+ last(): void {
389389+ const items = this.items;
390390+ if (items.length > 0 && this.#index(items) !== -1) {
391391+ this.focus.focus(items[items.length - 1]!);
392392+ }
393393+ }
394394+395395+ // Removes the group declaration: items dissolve back into individual tab
396396+ // stops. Listener bookkeeping is aborted; the container itself lives on.
397397+ dispose(): void {
398398+ this.#controller.abort();
399399+ this.container.removeAttribute('focusgroup');
400400+ this.container.data.set(memoryKey, undefined);
401401+ }
402402+403403+ #index(items: Node[]): number {
404404+ return items.indexOf(this.focus.activeElement);
405405+ }
406406+407407+ #move(delta: number): void {
408408+ const items = this.items;
409409+ const idx = this.#index(items);
410410+ if (idx === -1) {
411411+ return;
412412+ }
413413+ const target = this.config.wrap
414414+ ? items[(idx + delta + items.length) % items.length]
415415+ : items[idx + delta];
416416+ if (target && target !== items[idx]) {
417417+ this.focus.focus(target);
418418+ }
419419+ }
420420+}
+16
packages/dom/src/lib/mod.ts
···11+export type { JsonValue, Node, NodeData, NodeDataKey, Root } from './types.ts';
22+33+export { createNodeData } from './types.ts';
44+55+export { createRoot } from './root.ts';
66+77+export { PropagationTarget } from './events.ts';
88+99+export {
1010+ FocusEvent,
1111+ type FocusEventType,
1212+ type FocusGroupConfig,
1313+ FocusGroupManager,
1414+ FocusManager,
1515+ parseFocusgroup,
1616+} from './focus.ts';
+209
packages/dom/src/lib/node.ts
···11+// oxlint-disable bombshell-dev/no-generic-error
22+// oxlint-disable max-params
33+import { PropagationTarget } from './events.ts';
44+import type { JsonValue, Node, NodeData, NodeDataKey } from './types.ts';
55+import { validateJsonValue } from './validate.ts';
66+77+class NodeDataImpl implements NodeData {
88+ #map: Map<symbol, unknown> = new Map();
99+1010+ get<T>(key: NodeDataKey<T>): T | undefined {
1111+ return this.#map.get(key.symbol) as T | undefined;
1212+ }
1313+1414+ set<T>(key: NodeDataKey<T>, value: T): void {
1515+ this.#map.set(key.symbol, value);
1616+ }
1717+1818+ expect<T>(key: NodeDataKey<T>): T {
1919+ const val = this.#map.get(key.symbol);
2020+ if (val !== undefined) {
2121+ return val as T;
2222+ } else if (key.defaultValue !== undefined) {
2323+ return key.defaultValue;
2424+ } else {
2525+ throw new Error(`NodeData '${key.symbol.description}' not found`);
2626+ }
2727+ }
2828+}
2929+3030+// Shared per-tree bookkeeping; owned by the root, threaded to every node.
3131+// `registry` holds CONNECTED nodes only, so getElementById behaves like the
3232+// DOM's (detached nodes are not resolvable by id).
3333+export interface TreeState {
3434+ registry: Map<string, NodeImpl>;
3535+ documentElement: NodeImpl | undefined;
3636+ nextId(): string;
3737+ markDirty(): void;
3838+}
3939+4040+export class NodeImpl extends PropagationTarget implements Node {
4141+ _attributes: Record<string, JsonValue> = {};
4242+ _children: NodeImpl[] = [];
4343+ _parent: NodeImpl | undefined;
4444+ readonly data: NodeData = new NodeDataImpl();
4545+ readonly #tree: TreeState;
4646+ readonly #controller = new AbortController();
4747+4848+ constructor(
4949+ readonly id: string,
5050+ readonly localName: string,
5151+ tree: TreeState,
5252+ ) {
5353+ super();
5454+ this.#tree = tree;
5555+ }
5656+5757+ protected override getParentTarget(): PropagationTarget | undefined {
5858+ return this._parent;
5959+ }
6060+6161+ get attributes(): Record<string, JsonValue> {
6262+ return Object.freeze({ ...this._attributes });
6363+ }
6464+6565+ get children(): Iterable<Node> {
6666+ return this._children.values();
6767+ }
6868+6969+ get parent(): Node | undefined {
7070+ return this._parent;
7171+ }
7272+7373+ get isConnected(): boolean {
7474+ if (this === this.#tree.documentElement) {
7575+ return true;
7676+ }
7777+ return this._parent ? this._parent.isConnected : false;
7878+ }
7979+8080+ get signal(): AbortSignal {
8181+ return this.#controller.signal;
8282+ }
8383+8484+ getAttribute(name: string): JsonValue | undefined {
8585+ return this._attributes[name];
8686+ }
8787+8888+ setAttribute(name: string, value: JsonValue): void {
8989+ validateJsonValue(value);
9090+ this._attributes[name] = value;
9191+ this.#tree.markDirty();
9292+ }
9393+9494+ hasAttribute(name: string): boolean {
9595+ return name in this._attributes;
9696+ }
9797+9898+ removeAttribute(name: string): void {
9999+ if (name in this._attributes) {
100100+ delete this._attributes[name];
101101+ this.#tree.markDirty();
102102+ }
103103+ }
104104+105105+ contains(other: Node): boolean {
106106+ for (let n: Node | undefined = other; n; n = n.parent) {
107107+ if (n === this) {
108108+ return true;
109109+ }
110110+ }
111111+ return false;
112112+ }
113113+114114+ append(...nodes: Node[]): void {
115115+ for (const node of nodes) {
116116+ this.#insert(node as NodeImpl, this._children.length);
117117+ }
118118+ }
119119+120120+ insertBefore(node: Node, reference: Node): void {
121121+ const index = this._children.indexOf(reference as NodeImpl);
122122+ if (index === -1) {
123123+ throw new Error('insertBefore: `reference` is not a child of this node');
124124+ }
125125+ this.#insert(node as NodeImpl, index);
126126+ }
127127+128128+ // Attach (or move) `child` at `index`. An already-attached child relocates
129129+ // state-preservingly — no signal abort, no lifecycle events — matching the
130130+ // DOM's `moveBefore()` semantics rather than remove-and-reinsert.
131131+ #insert(child: NodeImpl, index: number): void {
132132+ if (child.#tree !== this.#tree) {
133133+ throw new Error('Cannot insert a node from another tree');
134134+ }
135135+ if (child === this.#tree.documentElement) {
136136+ throw new Error('Cannot insert the document element');
137137+ }
138138+ if (child.contains(this)) {
139139+ throw new Error('Cannot insert a node into its own subtree');
140140+ }
141141+ if (child.signal.aborted) {
142142+ throw new Error('Cannot insert a removed node');
143143+ }
144144+ const wasConnected = child.isConnected;
145145+ let at = index;
146146+ if (child._parent) {
147147+ const from = child._parent._children.indexOf(child);
148148+ child._parent._children.splice(from, 1);
149149+ // Moving forward under the same parent: account for the vacated slot.
150150+ if (child._parent === this && from < at) {
151151+ at -= 1;
152152+ }
153153+ }
154154+ this._children.splice(at, 0, child);
155155+ child._parent = this;
156156+ const isConnected = child.isConnected;
157157+ if (isConnected && !wasConnected) {
158158+ child.#register();
159159+ } else if (!isConnected && wasConnected) {
160160+ child.#unregister();
161161+ }
162162+ this.#tree.markDirty();
163163+ }
164164+165165+ #register(): void {
166166+ this.#tree.registry.set(this.id, this);
167167+ for (const child of this._children) {
168168+ child.#register();
169169+ }
170170+ }
171171+172172+ #unregister(): void {
173173+ this.#tree.registry.delete(this.id);
174174+ for (const child of this._children) {
175175+ child.#unregister();
176176+ }
177177+ }
178178+179179+ // Internal teardown — not on the public `Node` interface. Aborts each
180180+ // node's signal depth-first, children before parents in reverse creation
181181+ // order. Used by `remove` and by `root.destroy()`.
182182+ destroy(): void {
183183+ for (const child of [...this._children].reverse()) {
184184+ child.destroy();
185185+ }
186186+ this.#controller.abort();
187187+ }
188188+189189+ remove(): void {
190190+ if (this === this.#tree.documentElement) {
191191+ throw new Error('Cannot remove the document element');
192192+ }
193193+ if (this._parent) {
194194+ // Announce before detaching, so `remove` bubbles through the
195195+ // still-attached ancestor path — extensions (e.g. focus) react by
196196+ // listening on an ancestor. Descendants get no event of their own —
197197+ // their teardown notification is their aborting signal.
198198+ this.dispatchEvent(new Event('remove', { bubbles: true }));
199199+ if (this.isConnected) {
200200+ this.#unregister();
201201+ }
202202+ const index = this._parent._children.indexOf(this);
203203+ this._parent._children.splice(index, 1);
204204+ this._parent = undefined;
205205+ }
206206+ this.destroy();
207207+ this.#tree.markDirty();
208208+ }
209209+}
+66
packages/dom/src/lib/root.ts
···11+import { NodeImpl, type TreeState } from './node.ts';
22+import type { Node, Root } from './types.ts';
33+44+class RootImpl extends EventTarget implements Root {
55+ readonly documentElement: NodeImpl;
66+ #tree: TreeState;
77+ #destroyed = false;
88+ #scheduled = false;
99+1010+ constructor() {
1111+ super();
1212+ let counter = 0;
1313+ const tree: TreeState = {
1414+ registry: new Map(),
1515+ documentElement: undefined,
1616+ nextId: () => `node-${++counter}`,
1717+ markDirty: () => this.#invalidate(),
1818+ };
1919+ this.#tree = tree;
2020+ const node = new NodeImpl(tree.nextId(), '', tree);
2121+ tree.documentElement = node;
2222+ tree.registry.set(node.id, node);
2323+ this.documentElement = node;
2424+ }
2525+2626+ createElement(localName = ''): Node {
2727+ // Detached until appended, like document.createElement. Not resolvable
2828+ // via getElementById until connected.
2929+ return new NodeImpl(this.#tree.nextId(), localName, this.#tree);
3030+ }
3131+3232+ getElementById(id: string): Node | undefined {
3333+ return this.#tree.registry.get(id);
3434+ }
3535+3636+ // Coalesce change notifications per microtask: a burst of synchronous
3737+ // mutations — one dispatched input event's worth of listener work, or
3838+ // imperative tree building — produces a single `change`. Renderers see final
3939+ // state only.
4040+ #invalidate(): void {
4141+ if (this.#destroyed || this.#scheduled) {
4242+ return;
4343+ }
4444+ this.#scheduled = true;
4545+ queueMicrotask(() => {
4646+ this.#scheduled = false;
4747+ if (!this.#destroyed) {
4848+ this.dispatchEvent(new Event('change'));
4949+ }
5050+ });
5151+ }
5252+5353+ destroy(): void {
5454+ if (this.#destroyed) {
5555+ return;
5656+ }
5757+ this.#destroyed = true;
5858+ // Aborts every node's signal depth-first and forgets the tree.
5959+ this.documentElement.destroy();
6060+ this.#tree.registry.clear();
6161+ }
6262+}
6363+6464+export function createRoot(): Root {
6565+ return new RootImpl();
6666+}
+64
packages/dom/src/lib/types.ts
···11+export type JsonValue =
22+ | string
33+ | number
44+ | boolean
55+ | null
66+ | JsonValue[]
77+ | { [key: string]: JsonValue };
88+99+export interface NodeDataKey<T> {
1010+ readonly symbol: symbol;
1111+ readonly defaultValue?: T;
1212+}
1313+1414+export function createNodeData<T>(name: string, defaultValue?: T): NodeDataKey<T> {
1515+ return { symbol: Symbol(name), defaultValue };
1616+}
1717+1818+export interface NodeData {
1919+ get<T>(key: NodeDataKey<T>): T | undefined;
2020+ set<T>(key: NodeDataKey<T>, value: T): void;
2121+ expect<T>(key: NodeDataKey<T>): T;
2222+}
2323+2424+// A DOM-shaped element: an EventTarget in a tree that carries state as
2525+// attributes and separates creation from insertion, like the document API.
2626+// Deliberate divergences from the platform, documented in the README:
2727+// - attributes are JsonValue-valued, not strings (they feed renderers)
2828+// - `getAttribute` returns `undefined` for a missing attribute, not `null`
2929+// (null is a legal attribute VALUE here)
3030+// - `remove()` is terminal — it destroys the subtree and aborts `signal`;
3131+// there is no detached-but-alive limbo. Reordering uses moves instead:
3232+// `append`/`insertBefore` relocate an attached node state-preservingly
3333+// (the DOM's `moveBefore()` semantics).
3434+export interface Node extends EventTarget {
3535+ readonly id: string;
3636+ readonly localName: string;
3737+ readonly attributes: Record<string, JsonValue>;
3838+ readonly children: Iterable<Node>;
3939+ readonly parent: Node | undefined;
4040+ readonly isConnected: boolean;
4141+ readonly data: NodeData;
4242+ // Aborts when this node is removed (or the root destroyed). Hand it to
4343+ // anything whose lifetime should match the node's: listeners on ancestors
4444+ // (`{ signal }`), timers, fetch, streams. Cancellation is cooperative.
4545+ readonly signal: AbortSignal;
4646+ getAttribute(name: string): JsonValue | undefined;
4747+ setAttribute(name: string, value: JsonValue): void;
4848+ hasAttribute(name: string): boolean;
4949+ removeAttribute(name: string): void;
5050+ contains(other: Node): boolean;
5151+ append(...nodes: Node[]): void;
5252+ insertBefore(node: Node, reference: Node): void;
5353+ remove(): void;
5454+}
5555+5656+// The document analog: owns the tree, creates (detached) nodes, resolves
5757+// connected nodes by id, and emits a `change` Event (coalesced per microtask)
5858+// whenever the tree may have changed.
5959+export interface Root extends EventTarget {
6060+ readonly documentElement: Node;
6161+ createElement(localName?: string): Node;
6262+ getElementById(id: string): Node | undefined;
6363+ destroy(): void;
6464+}
+54
packages/dom/src/lib/validate.ts
···11+// oxlint-disable bombshell-dev/no-generic-error
22+import type { JsonValue } from './types.ts';
33+44+export function validateJsonValue(value: unknown): asserts value is JsonValue {
55+ if (value === undefined) {
66+ throw new Error('undefined is not a valid JsonValue');
77+ }
88+ if (typeof value === 'number') {
99+ if (Number.isNaN(value)) {
1010+ throw new Error('NaN is not a valid JsonValue');
1111+ }
1212+ if (!Number.isFinite(value)) {
1313+ throw new Error(`${value} is not a valid JsonValue`);
1414+ }
1515+ return;
1616+ }
1717+ if (typeof value === 'string' || typeof value === 'boolean' || value === null) {
1818+ return;
1919+ }
2020+ if (typeof value === 'function') {
2121+ throw new Error('functions are not valid JsonValues');
2222+ }
2323+ if (typeof value === 'symbol') {
2424+ throw new Error('symbols are not valid JsonValues');
2525+ }
2626+ if (typeof value === 'bigint') {
2727+ throw new Error('bigints are not valid JsonValues');
2828+ }
2929+ if (value instanceof Date) {
3030+ throw new Error('Date instances are not valid JsonValues');
3131+ }
3232+ if (value instanceof Map) {
3333+ throw new Error('Map instances are not valid JsonValues');
3434+ }
3535+ if (value instanceof Set) {
3636+ throw new Error('Set instances are not valid JsonValues');
3737+ }
3838+ if (value instanceof RegExp) {
3939+ throw new Error('RegExp instances are not valid JsonValues');
4040+ }
4141+ if (Array.isArray(value)) {
4242+ for (const item of value) {
4343+ validateJsonValue(item);
4444+ }
4545+ return;
4646+ }
4747+ if (typeof value === 'object' && value !== null) {
4848+ for (const key of Object.keys(value)) {
4949+ validateJsonValue((value as Record<string, unknown>)[key]);
5050+ }
5151+ return;
5252+ }
5353+ throw new Error(`${String(value)} is not a valid JsonValue`);
5454+}