[READ-ONLY] Mirror of https://github.com/bombshell-dev/playground.
0

Configure Feed

Select the types of activity you want to include in your feed.

spike(dom): implement minimal dom on EventTarget

Nate Moore (Jul 15, 2026, 6:10 PM EDT) 70dd1ea1 97136a6e

+3072 -13
+49
examples/focus/package.json
··· 1 + { 2 + "name": "focus", 3 + "version": "0.0.0", 4 + "private": true, 5 + "type": "module", 6 + "license": "MIT", 7 + "author": { 8 + "name": "Bombshell", 9 + "email": "oss@bomb.sh", 10 + "url": "https://bomb.sh" 11 + }, 12 + "exports": { 13 + ".": { 14 + "import": "./dist/index.js" 15 + }, 16 + "./package.json": "./package.json" 17 + }, 18 + "scripts": { 19 + "dev": "bsh dev", 20 + "build": "bsh build", 21 + "format": "bsh format", 22 + "lint": "bsh lint", 23 + "test": "bsh test" 24 + }, 25 + "dependencies": { 26 + "@bomb.sh/dom": "workspace:*", 27 + "@bomb.sh/tty": "catalog:*" 28 + }, 29 + "devDependencies": { 30 + "@bomb.sh/tools": "latest" 31 + }, 32 + "repository": { 33 + "type": "git", 34 + "url": "git+https://github.com/bombshell-dev/playground.git", 35 + "directory": "examples/focus" 36 + }, 37 + "devEngines": { 38 + "runtime": { 39 + "name": "node", 40 + "version": "22.14.0", 41 + "onFail": "error" 42 + }, 43 + "packageManager": { 44 + "name": "pnpm", 45 + "version": "10.7.0", 46 + "onFail": "error" 47 + } 48 + } 49 + }
+227
examples/focus/src/index.ts
··· 1 + // oxlint-disable bombshell-dev/no-generic-error 2 + import { 3 + createNodeData, 4 + createRoot, 5 + FocusGroupManager, 6 + FocusManager, 7 + type Node, 8 + type Root, 9 + } from '@bomb.sh/dom'; 10 + import { 11 + alternateBuffer, 12 + close, 13 + createInput, 14 + createTerm, 15 + cursor, 16 + fit, 17 + grow, 18 + type KeyEvent, 19 + type Op, 20 + open, 21 + percent, 22 + rgba, 23 + settings, 24 + text, 25 + } from '@bomb.sh/tty'; 26 + import { stdin, stdout } from 'node:process'; 27 + 28 + const GRAY = rgba(100, 100, 100); 29 + 30 + // Terminal key events, carried on the standard Event vocabulary. Bubbling 31 + // does the routing: an input consumes a key with stopPropagation(); anything 32 + // it ignores reaches the root handler. 33 + class KeyboardEvent extends Event { 34 + constructor(readonly detail: KeyEvent) { 35 + super(detail.type, { bubbles: true, cancelable: true }); 36 + } 37 + } 38 + 39 + interface LayoutOptions { 40 + node: Node; 41 + children: Iterable<Op>; 42 + } 43 + 44 + const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>('demo:layout', () => []); 45 + 46 + function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { 47 + node.data.set(layoutKey, body); 48 + } 49 + 50 + function makeTextInput(root: Root, parent: Node, name: string): void { 51 + const node = root.createElement(name); 52 + parent.append(node); 53 + node.setAttribute('tabindex', 0); 54 + node.setAttribute('value', ''); 55 + layout(node, () => { 56 + const color = node.getAttribute('focused') ? rgba(255, 255, 255) : GRAY; 57 + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; 58 + return [ 59 + open(node.id, { 60 + border, 61 + layout: { 62 + height: fit(3), 63 + width: percent(0.3), 64 + padding: { top: 1, right: 1, bottom: 1, left: 1 }, 65 + }, 66 + }), 67 + text(String(node.getAttribute('value') ?? '')), 68 + close(), 69 + ]; 70 + }); 71 + node.addEventListener('keydown', (event) => { 72 + const { key, code } = (event as KeyboardEvent).detail; 73 + const value = String(node.getAttribute('value') ?? ''); 74 + if (key.length === 1) { 75 + node.setAttribute('value', `${value}${key}`); 76 + event.stopPropagation(); 77 + } else if (code === 'Backspace') { 78 + node.setAttribute('value', value.slice(0, -1)); 79 + event.stopPropagation(); 80 + } 81 + // anything else bubbles up to the root Tab/Backtab/arrow handler 82 + }); 83 + } 84 + 85 + function screenBody({ node, children }: LayoutOptions): Op[] { 86 + return [ 87 + open(node.id, { 88 + layout: { 89 + height: grow(), 90 + width: grow(), 91 + direction: 'ttb', 92 + padding: { top: 1, right: 1, bottom: 1, left: 1 }, 93 + }, 94 + border: { 95 + color: rgba(255, 255, 255), 96 + top: 1, 97 + right: 1, 98 + bottom: 1, 99 + left: 1, 100 + }, 101 + }), 102 + ...children, 103 + close(), 104 + ]; 105 + } 106 + 107 + function containerBody({ node, children }: LayoutOptions): Op[] { 108 + return [ 109 + open(node.id, { 110 + border: { color: 0xfff, top: 1, right: 1, bottom: 1, left: 1 }, 111 + layout: { 112 + height: fit(), 113 + width: grow(), 114 + direction: 'ttb', 115 + padding: { top: 1, right: 1, bottom: 1, left: 1 }, 116 + }, 117 + }), 118 + ...children, 119 + close(), 120 + ]; 121 + } 122 + 123 + function walk(node: Node): Op[] { 124 + const children: Op[] = []; 125 + for (const child of node.children) { 126 + children.push(...walk(child)); 127 + } 128 + const body = node.data.get(layoutKey); 129 + return body ? body({ node, children }) : children; 130 + } 131 + 132 + if (!stdin.isTTY) { 133 + throw new Error('dom demo requires an interactive TTY'); 134 + } 135 + 136 + const root = createRoot(); 137 + 138 + layout(root.documentElement, screenBody); 139 + 140 + const container = root.createElement('input-1'); 141 + root.documentElement.append(container); 142 + layout(container, containerBody); 143 + 144 + makeTextInput(root, container, 'input-1-1'); 145 + makeTextInput(root, container, 'input-1-2'); 146 + makeTextInput(root, root.documentElement, 'input-2'); 147 + 148 + // document.activeElement analog; seeds focus now that inputs exist (input-1-1). 149 + const focus = new FocusManager(root.documentElement); 150 + 151 + // The two grouped inputs collapse into a single Tab stop, entered at the 152 + // last-focused input (memory). ArrowUp/ArrowDown move within the group — 153 + // listbox defaults: block axis, no wrap. 154 + const group = new FocusGroupManager(focus, container, 'listbox'); 155 + 156 + // Tab/Backtab/arrow navigation lives at the root; it only sees keys the 157 + // focused input let bubble. The group methods no-op while focus is outside 158 + // the group, so binding them here is safe. 159 + const navigate = (event: Event): void => { 160 + const { code } = (event as KeyboardEvent).detail; 161 + if (code === 'Tab') { 162 + focus.next(); 163 + } else if (code === 'Backtab') { 164 + focus.previous(); 165 + } else if (code === 'ArrowDown') { 166 + group.next(); 167 + } else if (code === 'ArrowUp') { 168 + group.previous(); 169 + } 170 + }; 171 + root.documentElement.addEventListener('keydown', navigate); 172 + root.documentElement.addEventListener('keyrepeat', navigate); 173 + 174 + const { columns, rows } = stdout.isTTY 175 + ? { columns: stdout.columns, rows: stdout.rows } 176 + : { columns: 80, rows: 24 }; 177 + 178 + let term = await createTerm({ height: rows, width: columns }); 179 + 180 + function render(): void { 181 + const ops = walk(root.documentElement); 182 + const { output } = term.render(ops); 183 + stdout.write(output); 184 + } 185 + 186 + root.addEventListener('change', render); 187 + 188 + const tty = settings(cursor(false), alternateBuffer()); 189 + stdin.setRawMode(true); 190 + stdout.write(tty.apply); 191 + 192 + function shutdown(): void { 193 + stdout.write(tty.revert); 194 + stdin.setRawMode(false); 195 + stdin.off('data', feed); 196 + stdin.pause(); 197 + } 198 + 199 + const input = await createInput(); 200 + let rescan: ReturnType<typeof setTimeout> | undefined; 201 + 202 + function feed(bytes: Uint8Array): void { 203 + clearTimeout(rescan); 204 + const result = input.scan(bytes); 205 + if (result.pending) { 206 + rescan = setTimeout(() => feed(new Uint8Array()), result.pending.delay); 207 + } 208 + for (const event of result.events) { 209 + if (event.type === 'keydown' && event.ctrl && event.code === 'c') { 210 + shutdown(); 211 + return; 212 + } 213 + if (event.type === 'resize') { 214 + void createTerm({ height: event.height, width: event.width }).then((next) => { 215 + term = next; 216 + render(); 217 + }); 218 + continue; 219 + } 220 + // All the input routing: dispatch at the focused node; capture, target, 221 + // and bubble listeners do the rest. 222 + focus.activeElement.dispatchEvent(new KeyboardEvent(event as KeyEvent)); 223 + } 224 + } 225 + stdin.on('data', feed); 226 + 227 + render();
+197
packages/dom/README.md
··· 1 + # @bomb.sh/dom 2 + 3 + Headless component tree with DOM-style event propagation. Zero dependencies. 4 + 5 + `@bomb.sh/dom` is a headless interaction tree for TUIs, stated entirely in the 6 + platform's vocabulary. Nodes **are** `EventTarget`s; the tree API is 7 + document-shaped (`createElement`/`append`/`insertBefore`, attributes, 8 + `getElementById`, `tabindex`); events route by capture/target/bubble 9 + propagation; a `change` event on the root signals "re-render". If you know the 10 + DOM, you already know this package. 11 + 12 + ## The question this package answers 13 + 14 + > Can we just leverage `EventTarget` on the nodes to manage bubbling? 15 + 16 + **Yes.** Node's native `EventTarget` provides listener storage, error 17 + isolation, and the full `Event` flag machinery (`stopPropagation`, 18 + `stopImmediatePropagation`, `preventDefault`, `once`, `signal`). What it lacks 19 + is a tree — `dispatchEvent` invokes every listener on a single target as if 20 + `AT_TARGET`. `PropagationTarget` (see `src/lib/events.ts`) adds the tree walk 21 + on top, and empirically that needs exactly two shims: 22 + 23 + 1. **Identity.** Each per-node native dispatch resets `event.target` to that 24 + node, so the real target, phase, and path are pinned as own properties on 25 + the event instance, shadowing the prototype getters. `currentTarget` needs 26 + no shim — native dispatch sets it correctly per node, and nulls it after. 27 + 2. **Phase filtering.** A bare `EventTarget` fires `{capture: true}` listeners 28 + on every dispatch, so listeners register through a thin wrapper that 29 + consults the pinned phase. The wrapper also owns `once`/`signal`, because a 30 + native `once` would consume a listener whose phase never matched. 31 + 32 + Between per-node dispatches, `event.cancelBubble` (the legacy readable alias 33 + for the stop-propagation flag) tells the walk when to halt. One divergence 34 + from the DOM: the flag can't be cleared from the outside, so a stopped event 35 + can't be re-dispatched — `dispatchEvent` throws and asks for a fresh event. 36 + 37 + ## The IR question 38 + 39 + > Can we avoid an IR / VDOM that we normalize to ops? 40 + 41 + **Yes — retained interaction tree, immediate-mode render.** This package's tree is 42 + _not_ a render tree and never normalizes to ops. It holds interaction state 43 + (focus, values, event listeners); rendering stays a pure function that walks 44 + the tree and produces `Op[]` fresh each frame — components are functions 45 + returning ops. The bridge between the two worlds is one string: `node.id` 46 + becomes the renderer's element key, so element identity in the renderer 47 + follows node identity in the interaction tree for free. No reconciliation, no 48 + diffing, no ops in this package. 49 + 50 + ```ts 51 + // compose with an op-based renderer (e.g. @bomb.sh/tty): read state, return ops 52 + function textInput(node: Node): Op[] { 53 + return [ 54 + open(node.id, { border: node.getAttribute('focused') ? focusedBorder : border }), 55 + text(String(node.getAttribute('value') ?? '')), 56 + close(), 57 + ]; 58 + } 59 + root.addEventListener('change', () => render(textInput(input))); 60 + ``` 61 + 62 + ## The tree: document-shaped 63 + 64 + Creation is separate from insertion, like the DOM — and insertion doubles as 65 + reordering: 66 + 67 + - `root.createElement(localName)` returns a **detached** node; `parent.append(...nodes)` 68 + and `parent.insertBefore(node, reference)` attach it. Detached nodes are not 69 + resolvable via `root.getElementById` (connected-only, like the DOM). 70 + - Inserting an **already-attached** node moves it, state-preservingly: no 71 + signal abort, no lifecycle events, listeners intact. This is the DOM's new 72 + `moveBefore()` semantics applied to all insertions — reordering is 73 + re-insertion, not a separate API. 74 + - State is **attributes**: `getAttribute`/`setAttribute`/`hasAttribute`/ 75 + `removeAttribute`, with a frozen `node.attributes` snapshot for renderers. 76 + Focusability is literally `setAttribute('tabindex', 0)`. 77 + 78 + Deliberate divergences, documented rather than hidden: attribute values are 79 + JsonValue (renderers need structure; the DOM's string-only rule buys nothing 80 + headless), `getAttribute` returns `undefined` for missing attributes (`null` 81 + is a legal value here), and `remove()` is **terminal** — it destroys the 82 + subtree and aborts `node.signal`. The DOM's detached-but-alive limbo exists 83 + for GC and adoption; a TUI tree doesn't need it, and the entire `node.signal` 84 + lifetime story depends on removal being final. Moves cover the legitimate 85 + reason to detach-and-reattach. 86 + 87 + ## Node lifetime: `node.signal` 88 + 89 + Every node owns an `AbortSignal` that aborts when the node is removed (or the 90 + root destroyed), descendants first, in reverse creation order. Hand it to 91 + anything whose lifetime should match the node's: 92 + 93 + ```ts 94 + // a listener installed on an ancestor, cleaned up when the node dies 95 + root.documentElement.addEventListener('keydown', onKey, { signal: node.signal }); 96 + 97 + // a spinner that stops when its node is removed — no manual cleanup 98 + import { setTimeout as delay } from 'node:timers/promises'; 99 + 100 + async function spin(node: Node): Promise<void> { 101 + const frames = ['⠋', '⠙', '⠹', '⠸']; 102 + try { 103 + for (let i = 0; ; i++) { 104 + node.setAttribute('frame', frames[i % frames.length]!); 105 + await delay(80, undefined, { signal: node.signal }); 106 + } 107 + } catch { 108 + // aborted — the node was removed 109 + } 110 + } 111 + ``` 112 + 113 + The same signal flows into `fetch`, `node:timers/promises`, streams, and 114 + this package's own `addEventListener` — one primitive, already understood by 115 + every web developer. One caveat: cancellation is **cooperative** — an async 116 + function keeps running past its awaits unless the awaited thing honors the 117 + signal (platform APIs do; arbitrary user code may not). `FocusManager` 118 + dogfoods the signal: its bookkeeping is registered with 119 + `{ signal: root.signal }` and disappears with its container. 120 + 121 + ## Focus: `FocusManager` and `focusgroup` 122 + 123 + Focus is structured the way the DOM structures it — authoritative state held 124 + by an owner, not a property scanned for: 125 + 126 + - **Focusability is `tabindex`**, like the DOM: `setAttribute('tabindex', 0)` 127 + joins sequential traversal; `-1` is focusable only via `focus()`. The 128 + `focused` attribute is the derived projection renderers read (`:focus`), 129 + written only on transitions. 130 + - **`FocusManager`** is the `document.activeElement` analog: an O(1) pointer, 131 + `focus()`, and sequential `next()`/`previous()` (Tab) traversal. Focus 132 + changes fire `blur`/`focusout` at the old node and `focus`/`focusin` at the 133 + new — `focus`/`blur` don't bubble, `focusin`/`focusout` do, and all four 134 + carry `relatedTarget`, matching the browser. 135 + - **`focusgroup`** implements the 136 + [Open UI scoped focusgroup explainer](https://open-ui.org/components/scoped-focusgroup.explainer/) 137 + (shipped in Chrome 150) as an attribute with the same token grammar: 138 + `'toolbar'`, `'tablist'`, `'listbox nomemory'`, `'menu wrap'`, … 139 + The attribute alone is honored declaratively by `FocusManager`: a group 140 + collapses to a single tab stop, entered at the last-focused item (memory) or 141 + its first item — the explainer's guaranteed tab stop algorithm. `'none'` 142 + opts a subtree out; nested groups are independent segments. 143 + - **`FocusGroupManager`** is the imperative half — arrow-key traversal 144 + (`next`/`previous`/`first`/`last`) within one group, axis metadata for key 145 + binding, and memory tracking via the bubbling `focusin` event. Its methods 146 + no-op while focus is outside the group, so arrows can be bound globally. 147 + 148 + ```ts 149 + const focus = new FocusManager(root.documentElement); 150 + const tabs = new FocusGroupManager(focus, tabBar, 'tablist'); // inline, wrap, memory 151 + 152 + // Tab collapses the group to one stop; arrows move within it 153 + if (code === 'Tab') focus.next(); 154 + if (code === 'ArrowRight') tabs.next(); 155 + if (code === 'ArrowLeft') tabs.previous(); 156 + ``` 157 + 158 + Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid 159 + tokens, and an opted-out element doesn't split the group into separate 160 + tab-stop segments — it just becomes its own stop. 161 + 162 + ## Sketch 163 + 164 + ```ts 165 + import { createRoot, FocusManager } from '@bomb.sh/dom'; 166 + 167 + const root = createRoot(); 168 + const input = root.createElement('input'); 169 + root.documentElement.append(input); 170 + input.setAttribute('tabindex', 0); 171 + input.setAttribute('value', ''); 172 + 173 + input.addEventListener('keydown', (event) => { 174 + const { key } = (event as KeyEvent).detail; 175 + if (key.length === 1) { 176 + input.setAttribute('value', `${input.getAttribute('value')}${key}`); 177 + event.stopPropagation(); // consumed — root never sees it 178 + } 179 + }); 180 + 181 + const focus = new FocusManager(root.documentElement); 182 + 183 + root.documentElement.addEventListener('keydown', (event) => { 184 + if ((event as KeyEvent).detail.code === 'Tab') focus.next(); // bubbled here 185 + }); 186 + 187 + root.addEventListener('change', render); 188 + 189 + // all the input routing there is: dispatch at the focused node 190 + focus.activeElement.dispatchEvent(new KeyEvent(raw)); 191 + ``` 192 + 193 + Run the demo: 194 + 195 + ```sh 196 + pnpm playground -e focus 197 + ```
+48
packages/dom/package.json
··· 1 + { 2 + "name": "@bomb.sh/dom", 3 + "version": "0.0.0", 4 + "private": true, 5 + "license": "ISC", 6 + "author": { 7 + "name": "Bombshell", 8 + "email": "oss@bomb.sh", 9 + "url": "https://bomb.sh" 10 + }, 11 + "repository": { 12 + "type": "git", 13 + "url": "git+https://github.com/bombshell-dev/playground.git", 14 + "directory": "packages/dom" 15 + }, 16 + "type": "module", 17 + "exports": { 18 + ".": { 19 + "types": "./dist/index.d.mts", 20 + "import": "./dist/index.mjs" 21 + }, 22 + "./package.json": "./package.json" 23 + }, 24 + "scripts": { 25 + "dev": "bsh dev --dts", 26 + "build": "bsh build --dts", 27 + "format": "bsh format", 28 + "lint": "bsh lint", 29 + "test": "bsh test" 30 + }, 31 + "devDependencies": { 32 + "@bomb.sh/tools": "latest", 33 + "@types/node": "^26.0.0", 34 + "vitest": "^4.1.2" 35 + }, 36 + "devEngines": { 37 + "packageManager": { 38 + "name": "pnpm", 39 + "version": "10.7.0", 40 + "onFail": "error" 41 + }, 42 + "runtime": { 43 + "name": "node", 44 + "version": "22.14.0", 45 + "onFail": "error" 46 + } 47 + } 48 + }
+1
packages/dom/src/index.ts
··· 1 + export * from './lib/mod.ts';
+180
packages/dom/src/lib/events.ts
··· 1 + // oxlint-disable bombshell-dev/no-generic-error 2 + // oxlint-disable max-params 3 + 4 + // The EventTarget experiment: can native EventTarget manage capture/bubble 5 + // propagation over a parent-linked tree, without reimplementing the DOM? 6 + // 7 + // Native EventTarget provides listener storage, error isolation, and the Event 8 + // flag machinery (stopPropagation, stopImmediatePropagation, preventDefault). 9 + // What it lacks is a tree — dispatchEvent invokes every listener on a single 10 + // target as if AT_TARGET. PropagationTarget adds the tree walk, which needs 11 + // exactly two shims: 12 + // 13 + // 1. Identity. Each per-node native dispatch resets `event.target` to that 14 + // node, so the real target, phase, and path are pinned as own properties on 15 + // the event instance, shadowing the prototype getters. `currentTarget` 16 + // needs no shim — native dispatch sets it correctly per node. 17 + // 18 + // 2. Phase filtering. A bare EventTarget fires `{capture: true}` listeners on 19 + // every dispatch, so listeners are registered through a thin wrapper that 20 + // consults the pinned phase. The wrapper also owns `once` and `signal`, 21 + // because native `once` would consume a listener whose phase never matched. 22 + // 23 + // Between per-node dispatches, `event.cancelBubble` (the legacy readable alias 24 + // for the stop-propagation flag) tells the walk when to halt. 25 + 26 + type Listener = EventListener | EventListenerObject; 27 + 28 + // Wrappers keyed by capture flag, per (type, callback) — mirroring the DOM's 29 + // (type, callback, capture) listener identity for dedupe and removal. 30 + interface WrapperPair { 31 + bubble?: EventListener; 32 + capture?: EventListener; 33 + } 34 + 35 + function normalizeOptions( 36 + options: AddEventListenerOptions | boolean | undefined, 37 + ): AddEventListenerOptions { 38 + return typeof options === 'boolean' ? { capture: options } : (options ?? {}); 39 + } 40 + 41 + function pin(event: Event, key: string, value: unknown): void { 42 + Object.defineProperty(event, key, { value, configurable: true }); 43 + } 44 + 45 + const inFlight = new WeakSet<Event>(); 46 + 47 + export class PropagationTarget extends EventTarget { 48 + #listeners = new Map<string, Map<Listener, WrapperPair>>(); 49 + 50 + // The DOM spec's "get the parent" hook. Subclasses with a tree override this. 51 + protected getParentTarget(): PropagationTarget | undefined { 52 + return undefined; 53 + } 54 + 55 + override addEventListener( 56 + type: string, 57 + callback: Listener | null, 58 + options?: AddEventListenerOptions | boolean, 59 + ): void { 60 + if (!callback) { 61 + return; 62 + } 63 + const opts = normalizeOptions(options); 64 + const capture = opts.capture === true; 65 + if (opts.signal?.aborted) { 66 + return; 67 + } 68 + let byCallback = this.#listeners.get(type); 69 + if (!byCallback) { 70 + byCallback = new Map(); 71 + this.#listeners.set(type, byCallback); 72 + } 73 + let pair = byCallback.get(callback); 74 + if (!pair) { 75 + pair = {}; 76 + byCallback.set(callback, pair); 77 + } 78 + const slot = capture ? 'capture' : 'bubble'; 79 + if (pair[slot]) { 80 + return; 81 + } 82 + const wrapper = (event: Event): void => { 83 + const phase = event.eventPhase; 84 + if (phase === Event.CAPTURING_PHASE && !capture) { 85 + return; 86 + } 87 + if (phase === Event.BUBBLING_PHASE && capture) { 88 + return; 89 + } 90 + // `once` is consumed here, after the phase check, so a capture-once 91 + // listener survives bubble walks that never match it. 92 + if (opts.once) { 93 + this.removeEventListener(type, callback, { capture }); 94 + } 95 + if (typeof callback === 'function') { 96 + callback.call(this, event); 97 + } else { 98 + callback.handleEvent(event); 99 + } 100 + }; 101 + pair[slot] = wrapper; 102 + opts.signal?.addEventListener( 103 + 'abort', 104 + () => this.removeEventListener(type, callback, { capture }), 105 + { once: true }, 106 + ); 107 + super.addEventListener(type, wrapper); 108 + } 109 + 110 + override removeEventListener( 111 + type: string, 112 + callback: Listener | null, 113 + options?: EventListenerOptions | boolean, 114 + ): void { 115 + if (!callback) { 116 + return; 117 + } 118 + const capture = normalizeOptions(options).capture === true; 119 + const byCallback = this.#listeners.get(type); 120 + const pair = byCallback?.get(callback); 121 + const slot = capture ? 'capture' : 'bubble'; 122 + const wrapper = pair?.[slot]; 123 + if (!byCallback || !pair || !wrapper) { 124 + return; 125 + } 126 + delete pair[slot]; 127 + if (!pair.bubble && !pair.capture) { 128 + byCallback.delete(callback); 129 + } 130 + if (byCallback.size === 0) { 131 + this.#listeners.delete(type); 132 + } 133 + super.removeEventListener(type, wrapper); 134 + } 135 + 136 + override dispatchEvent(event: Event): boolean { 137 + if (inFlight.has(event)) { 138 + throw new Error('This event is already being dispatched'); 139 + } 140 + if (event.cancelBubble) { 141 + // A previous dispatch stopped this event, and native EventTarget offers 142 + // no way to clear the flag (the DOM resets it on re-dispatch; the legacy 143 + // cancelBubble setter ignores `false`). Fail loud instead of silently 144 + // dispatching an event no walk will carry. 145 + throw new Error('This event was stopped by a previous dispatch; create a fresh event'); 146 + } 147 + const ancestors: PropagationTarget[] = []; 148 + for (let t = this.getParentTarget(); t; t = t.getParentTarget()) { 149 + ancestors.push(t); 150 + } 151 + inFlight.add(event); 152 + pin(event, 'target', this); 153 + pin(event, 'composedPath', () => [this, ...ancestors]); 154 + try { 155 + pin(event, 'eventPhase', Event.CAPTURING_PHASE); 156 + for (let i = ancestors.length - 1; i >= 0; i--) { 157 + EventTarget.prototype.dispatchEvent.call(ancestors[i], event); 158 + if (event.cancelBubble) { 159 + return !event.defaultPrevented; 160 + } 161 + } 162 + pin(event, 'eventPhase', Event.AT_TARGET); 163 + EventTarget.prototype.dispatchEvent.call(this, event); 164 + if (event.bubbles && !event.cancelBubble) { 165 + pin(event, 'eventPhase', Event.BUBBLING_PHASE); 166 + for (const ancestor of ancestors) { 167 + EventTarget.prototype.dispatchEvent.call(ancestor, event); 168 + if (event.cancelBubble) { 169 + break; 170 + } 171 + } 172 + } 173 + return !event.defaultPrevented; 174 + } finally { 175 + inFlight.delete(event); 176 + pin(event, 'eventPhase', Event.NONE); 177 + pin(event, 'composedPath', () => []); 178 + } 179 + } 180 + }
+420
packages/dom/src/lib/focus.ts
··· 1 + // oxlint-disable bombshell-dev/no-generic-error 2 + // oxlint-disable max-params 3 + 4 + // DOM-shaped focus: authoritative state held by an owner, not derived by 5 + // scanning the tree on every query — the way the platform does it: 6 + // 7 + // - Focusability is the `tabindex` attribute, like the DOM: `0` (or any 8 + // non-negative number) joins sequential traversal; `-1` is focusable only 9 + // programmatically. The `focused` attribute is a derived projection for 10 + // renderers (`:focus`), written only on transitions. 11 + // - `FocusManager` is `document`'s focus machinery: an `activeElement` pointer, 12 + // `focus()`, and sequential (Tab) traversal. 13 + // - `focusgroup` is an attribute on a container using the Open UI 14 + // scoped-focusgroup token grammar (`'toolbar'`, `'tablist wrap'`, 15 + // `'listbox nomemory'`, ...) — "the explainer" below: 16 + // https://open-ui.org/components/scoped-focusgroup.explainer/ 17 + // FocusManager honors it declaratively: 18 + // a group collapses to a single tab stop (entry = last-focused memory, else 19 + // first item), exactly like the explainer's guaranteed tab stop algorithm. 20 + // - `FocusGroupManager` is the imperative side of the attribute: arrow-key 21 + // traversal (`next`/`previous`/`first`/`last`) within the group, plus memory 22 + // tracking via a bubbling `focusin` listener. 23 + // 24 + // Simplifications vs the explainer, on purpose: no `focusgroupstart`, no grid 25 + // tokens, and an opted-out (`'none'`) element does not split the group into 26 + // separate tab-stop segments — it just becomes its own stop. 27 + import type { Node } from './types.ts'; 28 + import { createNodeData } from './types.ts'; 29 + 30 + export type FocusEventType = 'focus' | 'blur' | 'focusin' | 'focusout'; 31 + 32 + // DOM FocusEvent: `relatedTarget` is the other side of the transition — where 33 + // focus is going (blur/focusout) or where it came from (focus/focusin). 34 + // Like the DOM, focus/blur do not bubble; focusin/focusout do. 35 + export class FocusEvent extends Event { 36 + constructor( 37 + type: FocusEventType, 38 + readonly relatedTarget: Node | undefined, 39 + ) { 40 + super(type, { bubbles: type === 'focusin' || type === 'focusout' }); 41 + } 42 + } 43 + 44 + // In sequential (Tab/arrow) traversal, like tabindex="0" in the DOM. 45 + function isSequentiallyFocusable(node: Node): boolean { 46 + const tabindex = node.getAttribute('tabindex'); 47 + return typeof tabindex === 'number' && tabindex >= 0; 48 + } 49 + 50 + // Focusable at all — includes tabindex="-1" (programmatic focus only). 51 + function isFocusable(node: Node): boolean { 52 + return typeof node.getAttribute('tabindex') === 'number'; 53 + } 54 + 55 + // --------------------------------------------------------------------------- 56 + // focusgroup token grammar (Open UI scoped focusgroup) 57 + 58 + export interface FocusGroupConfig { 59 + behavior?: 'toolbar' | 'tablist' | 'radiogroup' | 'listbox' | 'menu' | 'menubar'; 60 + axis: 'inline' | 'block' | 'both'; 61 + wrap: boolean; 62 + memory: boolean; 63 + } 64 + 65 + const BEHAVIORS: Record<string, Pick<FocusGroupConfig, 'axis' | 'wrap'>> = { 66 + toolbar: { axis: 'inline', wrap: false }, 67 + tablist: { axis: 'inline', wrap: true }, 68 + radiogroup: { axis: 'both', wrap: true }, 69 + listbox: { axis: 'block', wrap: false }, 70 + menu: { axis: 'block', wrap: true }, 71 + menubar: { axis: 'inline', wrap: true }, 72 + }; 73 + 74 + // Token strings are parsed on every stop computation (i.e., every Tab), so 75 + // results are memoized by the exact attribute string. The set of distinct 76 + // focusgroup values in an app is tiny; the map is effectively bounded. 77 + const parsed = new Map<string, FocusGroupConfig>(); 78 + 79 + export function parseFocusgroup(value: string): FocusGroupConfig { 80 + const cached = parsed.get(value); 81 + if (cached) { 82 + return cached; 83 + } 84 + const tokens = value.split(/\s+/).filter(Boolean); 85 + const behavior = tokens.find((t) => t in BEHAVIORS) as FocusGroupConfig['behavior']; 86 + const base = behavior ? BEHAVIORS[behavior as string]! : { axis: 'both' as const, wrap: false }; 87 + const axis = tokens.includes('inline') 88 + ? 'inline' 89 + : tokens.includes('block') 90 + ? 'block' 91 + : base.axis; 92 + const wrap = tokens.includes('wrap') ? true : tokens.includes('nowrap') ? false : base.wrap; 93 + const config: FocusGroupConfig = Object.freeze({ 94 + behavior, 95 + axis, 96 + wrap, 97 + memory: !tokens.includes('nomemory'), 98 + }); 99 + parsed.set(value, config); 100 + return config; 101 + } 102 + 103 + const noneCache = new Map<string, boolean>(); 104 + 105 + function hasNoneToken(value: string): boolean { 106 + let none = noneCache.get(value); 107 + if (none === undefined) { 108 + none = value.split(/\s+/).includes('none'); 109 + noneCache.set(value, none); 110 + } 111 + return none; 112 + } 113 + 114 + // A node declares a group when its `focusgroup` attribute is a non-`none` 115 + // string. 116 + function groupValue(node: Node): string | undefined { 117 + const value = node.getAttribute('focusgroup'); 118 + if (typeof value !== 'string') { 119 + return undefined; 120 + } 121 + return hasNoneToken(value) ? undefined : value; 122 + } 123 + 124 + function optsOut(node: Node): boolean { 125 + const value = node.getAttribute('focusgroup'); 126 + return typeof value === 'string' && hasNoneToken(value); 127 + } 128 + 129 + // The segment a node's tab stop collapses into: the nearest ancestor-or-self 130 + // declaring a group, stopping at opt-outs (a `none` subtree is independent of 131 + // every enclosing group) and at the traversal root. Nearest wins, so an item 132 + // in a nested group belongs to the nested segment, not the outer one. 133 + function segmentOf(node: Node, root: Node): Node | undefined { 134 + for (let n: Node | undefined = node; n && n !== root; n = n.parent) { 135 + if (groupValue(n) !== undefined) { 136 + return n; 137 + } 138 + if (optsOut(n)) { 139 + return undefined; 140 + } 141 + } 142 + return undefined; 143 + } 144 + 145 + // Last-focused item per group container — the explainer's focus memory. 146 + // Private to this module; validity is checked at read time via `node.signal`. 147 + const memoryKey = createNodeData<Node | undefined>('focus:memory'); 148 + 149 + // Items of a group segment: the container (if focusable) and its focusable 150 + // descendants, excluding subtrees that opt out (`none`) or declare their own 151 + // nested group (independent segments per the explainer). 152 + function segmentItems(group: Node): Node[] { 153 + const items: Node[] = []; 154 + const walk = (node: Node): void => { 155 + if (node !== group && typeof node.getAttribute('focusgroup') === 'string') { 156 + return; 157 + } 158 + if (isSequentiallyFocusable(node)) { 159 + items.push(node); 160 + } 161 + for (const child of node.children) { 162 + walk(child); 163 + } 164 + }; 165 + walk(group); 166 + return items; 167 + } 168 + 169 + // Every sequentially focusable node in tree order, groups flattened — used for 170 + // removal successors, where "next focusable thing" matters more than tab stops. 171 + function flatFocusables(root: Node): Node[] { 172 + const result: Node[] = []; 173 + const walk = (node: Node): void => { 174 + if (isSequentiallyFocusable(node)) { 175 + result.push(node); 176 + } 177 + for (const child of node.children) { 178 + walk(child); 179 + } 180 + }; 181 + walk(root); 182 + return result; 183 + } 184 + 185 + // --------------------------------------------------------------------------- 186 + 187 + interface TabStop { 188 + stop: Node; 189 + // Set when this stop is the collapsed entry of a focusgroup segment. 190 + segment: Node | undefined; 191 + } 192 + 193 + // The `document.activeElement` analog: authoritative focus state for a subtree, 194 + // with sequential (Tab) traversal that honors declarative `focusgroup` props. 195 + // Construct one per root (or per container for a nested focus scope); its 196 + // bookkeeping is registered with `{ signal: root.signal }` and dies with it. 197 + export class FocusManager { 198 + #active: Node | undefined; 199 + 200 + constructor(readonly root: Node) { 201 + const seed = this.#stops().find(({ stop }) => stop !== root); 202 + if (seed) { 203 + this.focus(seed.stop); 204 + } 205 + // Dispatched before detach, so successor computation sees the full tree. 206 + root.addEventListener('remove', (event) => this.#onRemove(event.target as Node), { 207 + signal: root.signal, 208 + }); 209 + } 210 + 211 + // Never null: falls back to the root, like document.activeElement's body 212 + // fallback. 213 + get activeElement(): Node { 214 + return this.#active ?? this.root; 215 + } 216 + 217 + // Accepts any node with a tabindex, including -1 (programmatic focus, like 218 + // the DOM). Sequential traversal only visits tabindex >= 0. 219 + focus(node: Node): void { 220 + if (!isFocusable(node)) { 221 + throw new Error('Cannot focus a node without a tabindex attribute'); 222 + } 223 + if (node === this.#active) { 224 + return; 225 + } 226 + const old = this.#active; 227 + if (old) { 228 + old.removeAttribute('focused'); 229 + old.dispatchEvent(new FocusEvent('blur', node)); 230 + old.dispatchEvent(new FocusEvent('focusout', node)); 231 + } 232 + this.#active = node; 233 + node.setAttribute('focused', true); 234 + node.dispatchEvent(new FocusEvent('focus', old)); 235 + node.dispatchEvent(new FocusEvent('focusin', old)); 236 + } 237 + 238 + next(): void { 239 + this.#move(1); 240 + } 241 + 242 + previous(): void { 243 + this.#move(-1); 244 + } 245 + 246 + // Tab stops in tree order: plain focusables, plus one collapsed stop per 247 + // focusgroup segment (the explainer's guaranteed tab stop algorithm). 248 + #stops(): TabStop[] { 249 + const result: TabStop[] = []; 250 + const walk = (node: Node, group: Node | undefined): void => { 251 + let next = group; 252 + const value = groupValue(node); 253 + if (value !== undefined && node !== this.root) { 254 + const entry = this.#entryOf(node, value); 255 + if (entry) { 256 + result.push({ stop: entry, segment: node }); 257 + } 258 + next = node; 259 + } else if (optsOut(node)) { 260 + // Opted out of the enclosing group: independently tabbable. 261 + next = undefined; 262 + if (isSequentiallyFocusable(node)) { 263 + result.push({ stop: node, segment: undefined }); 264 + } 265 + } else if (!group && isSequentiallyFocusable(node)) { 266 + result.push({ stop: node, segment: undefined }); 267 + } 268 + for (const child of node.children) { 269 + walk(child, next); 270 + } 271 + }; 272 + walk(this.root, undefined); 273 + return result; 274 + } 275 + 276 + // Where Tab lands when entering a group: memory if alive and valid, else 277 + // the segment's first item. 278 + #entryOf(group: Node, value: string): Node | undefined { 279 + if (parseFocusgroup(value).memory) { 280 + const memory = group.data.get(memoryKey); 281 + if ( 282 + memory && 283 + !memory.signal.aborted && 284 + isSequentiallyFocusable(memory) && 285 + group.contains(memory) 286 + ) { 287 + return memory; 288 + } 289 + } 290 + return segmentItems(group)[0]; 291 + } 292 + 293 + #move(delta: number): void { 294 + const stops = this.#stops(); 295 + if (stops.length === 0) { 296 + return; 297 + } 298 + const active = this.#active; 299 + let idx = -1; 300 + if (active) { 301 + const segment = segmentOf(active, this.root); 302 + idx = segment 303 + ? stops.findIndex((s) => s.segment === segment) 304 + : stops.findIndex((s) => s.stop === active); 305 + } 306 + if (idx === -1) { 307 + this.focus(delta > 0 ? stops[0]!.stop : stops[stops.length - 1]!.stop); 308 + return; 309 + } 310 + if (stops.length === 1) { 311 + return; 312 + } 313 + this.focus(stops[(idx + delta + stops.length) % stops.length]!.stop); 314 + } 315 + 316 + #onRemove(removed: Node): void { 317 + const active = this.#active; 318 + if (!active || !removed.contains(active)) { 319 + return; 320 + } 321 + const chain = flatFocusables(this.root); 322 + const start = chain.indexOf(active); 323 + for (let i = 1; i < chain.length; i++) { 324 + const candidate = chain[(start + i + chain.length) % chain.length]!; 325 + if (!removed.contains(candidate)) { 326 + this.focus(candidate); 327 + return; 328 + } 329 + } 330 + this.#active = undefined; 331 + } 332 + } 333 + 334 + // The imperative half of the `focusgroup` attribute: arrow-key traversal within 335 + // one group. Declares the group by writing the token string to the container's 336 + // `focusgroup` attribute (renderer-visible, exactly like the DOM) and tracks 337 + // focus memory via the bubbling `focusin` event. 338 + export class FocusGroupManager { 339 + readonly config: FocusGroupConfig; 340 + #controller = new AbortController(); 341 + 342 + constructor( 343 + readonly focus: FocusManager, 344 + readonly container: Node, 345 + tokens = '', 346 + ) { 347 + this.config = parseFocusgroup(tokens); 348 + container.setAttribute('focusgroup', tokens); 349 + container.addEventListener( 350 + 'focusin', 351 + (event) => { 352 + const target = event.target as Node; 353 + if (this.config.memory && this.items.includes(target)) { 354 + container.data.set(memoryKey, target); 355 + } 356 + }, 357 + { signal: AbortSignal.any([container.signal, this.#controller.signal]) }, 358 + ); 359 + } 360 + 361 + // Advisory for key binding: which arrow keys the app should route here 362 + // (headless code has no keyboard; the explainer's axis is a key concern). 363 + get axis(): FocusGroupConfig['axis'] { 364 + return this.config.axis; 365 + } 366 + 367 + get items(): Node[] { 368 + return segmentItems(this.container); 369 + } 370 + 371 + // Arrow-key traversal only acts while focus is inside the group, like the 372 + // DOM behavior — so apps can bind arrows globally and let the group no-op. 373 + next(): void { 374 + this.#move(1); 375 + } 376 + 377 + previous(): void { 378 + this.#move(-1); 379 + } 380 + 381 + first(): void { 382 + const items = this.items; 383 + if (items.length > 0 && this.#index(items) !== -1) { 384 + this.focus.focus(items[0]!); 385 + } 386 + } 387 + 388 + last(): void { 389 + const items = this.items; 390 + if (items.length > 0 && this.#index(items) !== -1) { 391 + this.focus.focus(items[items.length - 1]!); 392 + } 393 + } 394 + 395 + // Removes the group declaration: items dissolve back into individual tab 396 + // stops. Listener bookkeeping is aborted; the container itself lives on. 397 + dispose(): void { 398 + this.#controller.abort(); 399 + this.container.removeAttribute('focusgroup'); 400 + this.container.data.set(memoryKey, undefined); 401 + } 402 + 403 + #index(items: Node[]): number { 404 + return items.indexOf(this.focus.activeElement); 405 + } 406 + 407 + #move(delta: number): void { 408 + const items = this.items; 409 + const idx = this.#index(items); 410 + if (idx === -1) { 411 + return; 412 + } 413 + const target = this.config.wrap 414 + ? items[(idx + delta + items.length) % items.length] 415 + : items[idx + delta]; 416 + if (target && target !== items[idx]) { 417 + this.focus.focus(target); 418 + } 419 + } 420 + }
+16
packages/dom/src/lib/mod.ts
··· 1 + export type { JsonValue, Node, NodeData, NodeDataKey, Root } from './types.ts'; 2 + 3 + export { createNodeData } from './types.ts'; 4 + 5 + export { createRoot } from './root.ts'; 6 + 7 + export { PropagationTarget } from './events.ts'; 8 + 9 + export { 10 + FocusEvent, 11 + type FocusEventType, 12 + type FocusGroupConfig, 13 + FocusGroupManager, 14 + FocusManager, 15 + parseFocusgroup, 16 + } from './focus.ts';
+209
packages/dom/src/lib/node.ts
··· 1 + // oxlint-disable bombshell-dev/no-generic-error 2 + // oxlint-disable max-params 3 + import { PropagationTarget } from './events.ts'; 4 + import type { JsonValue, Node, NodeData, NodeDataKey } from './types.ts'; 5 + import { validateJsonValue } from './validate.ts'; 6 + 7 + class NodeDataImpl implements NodeData { 8 + #map: Map<symbol, unknown> = new Map(); 9 + 10 + get<T>(key: NodeDataKey<T>): T | undefined { 11 + return this.#map.get(key.symbol) as T | undefined; 12 + } 13 + 14 + set<T>(key: NodeDataKey<T>, value: T): void { 15 + this.#map.set(key.symbol, value); 16 + } 17 + 18 + expect<T>(key: NodeDataKey<T>): T { 19 + const val = this.#map.get(key.symbol); 20 + if (val !== undefined) { 21 + return val as T; 22 + } else if (key.defaultValue !== undefined) { 23 + return key.defaultValue; 24 + } else { 25 + throw new Error(`NodeData '${key.symbol.description}' not found`); 26 + } 27 + } 28 + } 29 + 30 + // Shared per-tree bookkeeping; owned by the root, threaded to every node. 31 + // `registry` holds CONNECTED nodes only, so getElementById behaves like the 32 + // DOM's (detached nodes are not resolvable by id). 33 + export interface TreeState { 34 + registry: Map<string, NodeImpl>; 35 + documentElement: NodeImpl | undefined; 36 + nextId(): string; 37 + markDirty(): void; 38 + } 39 + 40 + export class NodeImpl extends PropagationTarget implements Node { 41 + _attributes: Record<string, JsonValue> = {}; 42 + _children: NodeImpl[] = []; 43 + _parent: NodeImpl | undefined; 44 + readonly data: NodeData = new NodeDataImpl(); 45 + readonly #tree: TreeState; 46 + readonly #controller = new AbortController(); 47 + 48 + constructor( 49 + readonly id: string, 50 + readonly localName: string, 51 + tree: TreeState, 52 + ) { 53 + super(); 54 + this.#tree = tree; 55 + } 56 + 57 + protected override getParentTarget(): PropagationTarget | undefined { 58 + return this._parent; 59 + } 60 + 61 + get attributes(): Record<string, JsonValue> { 62 + return Object.freeze({ ...this._attributes }); 63 + } 64 + 65 + get children(): Iterable<Node> { 66 + return this._children.values(); 67 + } 68 + 69 + get parent(): Node | undefined { 70 + return this._parent; 71 + } 72 + 73 + get isConnected(): boolean { 74 + if (this === this.#tree.documentElement) { 75 + return true; 76 + } 77 + return this._parent ? this._parent.isConnected : false; 78 + } 79 + 80 + get signal(): AbortSignal { 81 + return this.#controller.signal; 82 + } 83 + 84 + getAttribute(name: string): JsonValue | undefined { 85 + return this._attributes[name]; 86 + } 87 + 88 + setAttribute(name: string, value: JsonValue): void { 89 + validateJsonValue(value); 90 + this._attributes[name] = value; 91 + this.#tree.markDirty(); 92 + } 93 + 94 + hasAttribute(name: string): boolean { 95 + return name in this._attributes; 96 + } 97 + 98 + removeAttribute(name: string): void { 99 + if (name in this._attributes) { 100 + delete this._attributes[name]; 101 + this.#tree.markDirty(); 102 + } 103 + } 104 + 105 + contains(other: Node): boolean { 106 + for (let n: Node | undefined = other; n; n = n.parent) { 107 + if (n === this) { 108 + return true; 109 + } 110 + } 111 + return false; 112 + } 113 + 114 + append(...nodes: Node[]): void { 115 + for (const node of nodes) { 116 + this.#insert(node as NodeImpl, this._children.length); 117 + } 118 + } 119 + 120 + insertBefore(node: Node, reference: Node): void { 121 + const index = this._children.indexOf(reference as NodeImpl); 122 + if (index === -1) { 123 + throw new Error('insertBefore: `reference` is not a child of this node'); 124 + } 125 + this.#insert(node as NodeImpl, index); 126 + } 127 + 128 + // Attach (or move) `child` at `index`. An already-attached child relocates 129 + // state-preservingly — no signal abort, no lifecycle events — matching the 130 + // DOM's `moveBefore()` semantics rather than remove-and-reinsert. 131 + #insert(child: NodeImpl, index: number): void { 132 + if (child.#tree !== this.#tree) { 133 + throw new Error('Cannot insert a node from another tree'); 134 + } 135 + if (child === this.#tree.documentElement) { 136 + throw new Error('Cannot insert the document element'); 137 + } 138 + if (child.contains(this)) { 139 + throw new Error('Cannot insert a node into its own subtree'); 140 + } 141 + if (child.signal.aborted) { 142 + throw new Error('Cannot insert a removed node'); 143 + } 144 + const wasConnected = child.isConnected; 145 + let at = index; 146 + if (child._parent) { 147 + const from = child._parent._children.indexOf(child); 148 + child._parent._children.splice(from, 1); 149 + // Moving forward under the same parent: account for the vacated slot. 150 + if (child._parent === this && from < at) { 151 + at -= 1; 152 + } 153 + } 154 + this._children.splice(at, 0, child); 155 + child._parent = this; 156 + const isConnected = child.isConnected; 157 + if (isConnected && !wasConnected) { 158 + child.#register(); 159 + } else if (!isConnected && wasConnected) { 160 + child.#unregister(); 161 + } 162 + this.#tree.markDirty(); 163 + } 164 + 165 + #register(): void { 166 + this.#tree.registry.set(this.id, this); 167 + for (const child of this._children) { 168 + child.#register(); 169 + } 170 + } 171 + 172 + #unregister(): void { 173 + this.#tree.registry.delete(this.id); 174 + for (const child of this._children) { 175 + child.#unregister(); 176 + } 177 + } 178 + 179 + // Internal teardown — not on the public `Node` interface. Aborts each 180 + // node's signal depth-first, children before parents in reverse creation 181 + // order. Used by `remove` and by `root.destroy()`. 182 + destroy(): void { 183 + for (const child of [...this._children].reverse()) { 184 + child.destroy(); 185 + } 186 + this.#controller.abort(); 187 + } 188 + 189 + remove(): void { 190 + if (this === this.#tree.documentElement) { 191 + throw new Error('Cannot remove the document element'); 192 + } 193 + if (this._parent) { 194 + // Announce before detaching, so `remove` bubbles through the 195 + // still-attached ancestor path — extensions (e.g. focus) react by 196 + // listening on an ancestor. Descendants get no event of their own — 197 + // their teardown notification is their aborting signal. 198 + this.dispatchEvent(new Event('remove', { bubbles: true })); 199 + if (this.isConnected) { 200 + this.#unregister(); 201 + } 202 + const index = this._parent._children.indexOf(this); 203 + this._parent._children.splice(index, 1); 204 + this._parent = undefined; 205 + } 206 + this.destroy(); 207 + this.#tree.markDirty(); 208 + } 209 + }
+66
packages/dom/src/lib/root.ts
··· 1 + import { NodeImpl, type TreeState } from './node.ts'; 2 + import type { Node, Root } from './types.ts'; 3 + 4 + class RootImpl extends EventTarget implements Root { 5 + readonly documentElement: NodeImpl; 6 + #tree: TreeState; 7 + #destroyed = false; 8 + #scheduled = false; 9 + 10 + constructor() { 11 + super(); 12 + let counter = 0; 13 + const tree: TreeState = { 14 + registry: new Map(), 15 + documentElement: undefined, 16 + nextId: () => `node-${++counter}`, 17 + markDirty: () => this.#invalidate(), 18 + }; 19 + this.#tree = tree; 20 + const node = new NodeImpl(tree.nextId(), '', tree); 21 + tree.documentElement = node; 22 + tree.registry.set(node.id, node); 23 + this.documentElement = node; 24 + } 25 + 26 + createElement(localName = ''): Node { 27 + // Detached until appended, like document.createElement. Not resolvable 28 + // via getElementById until connected. 29 + return new NodeImpl(this.#tree.nextId(), localName, this.#tree); 30 + } 31 + 32 + getElementById(id: string): Node | undefined { 33 + return this.#tree.registry.get(id); 34 + } 35 + 36 + // Coalesce change notifications per microtask: a burst of synchronous 37 + // mutations — one dispatched input event's worth of listener work, or 38 + // imperative tree building — produces a single `change`. Renderers see final 39 + // state only. 40 + #invalidate(): void { 41 + if (this.#destroyed || this.#scheduled) { 42 + return; 43 + } 44 + this.#scheduled = true; 45 + queueMicrotask(() => { 46 + this.#scheduled = false; 47 + if (!this.#destroyed) { 48 + this.dispatchEvent(new Event('change')); 49 + } 50 + }); 51 + } 52 + 53 + destroy(): void { 54 + if (this.#destroyed) { 55 + return; 56 + } 57 + this.#destroyed = true; 58 + // Aborts every node's signal depth-first and forgets the tree. 59 + this.documentElement.destroy(); 60 + this.#tree.registry.clear(); 61 + } 62 + } 63 + 64 + export function createRoot(): Root { 65 + return new RootImpl(); 66 + }
+64
packages/dom/src/lib/types.ts
··· 1 + export type JsonValue = 2 + | string 3 + | number 4 + | boolean 5 + | null 6 + | JsonValue[] 7 + | { [key: string]: JsonValue }; 8 + 9 + export interface NodeDataKey<T> { 10 + readonly symbol: symbol; 11 + readonly defaultValue?: T; 12 + } 13 + 14 + export function createNodeData<T>(name: string, defaultValue?: T): NodeDataKey<T> { 15 + return { symbol: Symbol(name), defaultValue }; 16 + } 17 + 18 + export interface NodeData { 19 + get<T>(key: NodeDataKey<T>): T | undefined; 20 + set<T>(key: NodeDataKey<T>, value: T): void; 21 + expect<T>(key: NodeDataKey<T>): T; 22 + } 23 + 24 + // A DOM-shaped element: an EventTarget in a tree that carries state as 25 + // attributes and separates creation from insertion, like the document API. 26 + // Deliberate divergences from the platform, documented in the README: 27 + // - attributes are JsonValue-valued, not strings (they feed renderers) 28 + // - `getAttribute` returns `undefined` for a missing attribute, not `null` 29 + // (null is a legal attribute VALUE here) 30 + // - `remove()` is terminal — it destroys the subtree and aborts `signal`; 31 + // there is no detached-but-alive limbo. Reordering uses moves instead: 32 + // `append`/`insertBefore` relocate an attached node state-preservingly 33 + // (the DOM's `moveBefore()` semantics). 34 + export interface Node extends EventTarget { 35 + readonly id: string; 36 + readonly localName: string; 37 + readonly attributes: Record<string, JsonValue>; 38 + readonly children: Iterable<Node>; 39 + readonly parent: Node | undefined; 40 + readonly isConnected: boolean; 41 + readonly data: NodeData; 42 + // Aborts when this node is removed (or the root destroyed). Hand it to 43 + // anything whose lifetime should match the node's: listeners on ancestors 44 + // (`{ signal }`), timers, fetch, streams. Cancellation is cooperative. 45 + readonly signal: AbortSignal; 46 + getAttribute(name: string): JsonValue | undefined; 47 + setAttribute(name: string, value: JsonValue): void; 48 + hasAttribute(name: string): boolean; 49 + removeAttribute(name: string): void; 50 + contains(other: Node): boolean; 51 + append(...nodes: Node[]): void; 52 + insertBefore(node: Node, reference: Node): void; 53 + remove(): void; 54 + } 55 + 56 + // The document analog: owns the tree, creates (detached) nodes, resolves 57 + // connected nodes by id, and emits a `change` Event (coalesced per microtask) 58 + // whenever the tree may have changed. 59 + export interface Root extends EventTarget { 60 + readonly documentElement: Node; 61 + createElement(localName?: string): Node; 62 + getElementById(id: string): Node | undefined; 63 + destroy(): void; 64 + }
+54
packages/dom/src/lib/validate.ts
··· 1 + // oxlint-disable bombshell-dev/no-generic-error 2 + import type { JsonValue } from './types.ts'; 3 + 4 + export function validateJsonValue(value: unknown): asserts value is JsonValue { 5 + if (value === undefined) { 6 + throw new Error('undefined is not a valid JsonValue'); 7 + } 8 + if (typeof value === 'number') { 9 + if (Number.isNaN(value)) { 10 + throw new Error('NaN is not a valid JsonValue'); 11 + } 12 + if (!Number.isFinite(value)) { 13 + throw new Error(`${value} is not a valid JsonValue`); 14 + } 15 + return; 16 + } 17 + if (typeof value === 'string' || typeof value === 'boolean' || value === null) { 18 + return; 19 + } 20 + if (typeof value === 'function') { 21 + throw new Error('functions are not valid JsonValues'); 22 + } 23 + if (typeof value === 'symbol') { 24 + throw new Error('symbols are not valid JsonValues'); 25 + } 26 + if (typeof value === 'bigint') { 27 + throw new Error('bigints are not valid JsonValues'); 28 + } 29 + if (value instanceof Date) { 30 + throw new Error('Date instances are not valid JsonValues'); 31 + } 32 + if (value instanceof Map) { 33 + throw new Error('Map instances are not valid JsonValues'); 34 + } 35 + if (value instanceof Set) { 36 + throw new Error('Set instances are not valid JsonValues'); 37 + } 38 + if (value instanceof RegExp) { 39 + throw new Error('RegExp instances are not valid JsonValues'); 40 + } 41 + if (Array.isArray(value)) { 42 + for (const item of value) { 43 + validateJsonValue(item); 44 + } 45 + return; 46 + } 47 + if (typeof value === 'object' && value !== null) { 48 + for (const key of Object.keys(value)) { 49 + validateJsonValue((value as Record<string, unknown>)[key]); 50 + } 51 + return; 52 + } 53 + throw new Error(`${String(value)} is not a valid JsonValue`); 54 + }
+350
packages/dom/test/events.test.ts
··· 1 + import { describe, expect, it } from '../test/suite.ts'; 2 + import { createRoot, type Node, type Root } from '../src/index.ts'; 3 + 4 + // A three-deep tree: documentElement -> mid -> leaf. 5 + function tree(): { root: Root; top: Node; mid: Node; leaf: Node } { 6 + const root = createRoot(); 7 + const top = root.documentElement; 8 + const mid = root.createElement('mid'); 9 + const leaf = root.createElement('leaf'); 10 + mid.append(leaf); 11 + top.append(mid); 12 + return { root, top, mid, leaf }; 13 + } 14 + 15 + describe('dispatch at a target', () => { 16 + it('invokes listeners on the target with correct identity', () => { 17 + const { root, leaf } = tree(); 18 + const seen: { target: unknown; currentTarget: unknown; phase: number }[] = []; 19 + leaf.addEventListener('ping', (event) => { 20 + seen.push({ 21 + target: event.target, 22 + currentTarget: event.currentTarget, 23 + phase: event.eventPhase, 24 + }); 25 + }); 26 + const handled = leaf.dispatchEvent(new Event('ping')); 27 + expect(handled).toBe(true); 28 + expect(seen).toEqual([{ target: leaf, currentTarget: leaf, phase: Event.AT_TARGET }]); 29 + root.destroy(); 30 + }); 31 + 32 + it('returns false when preventDefault is called on a cancelable event', () => { 33 + const { root, leaf } = tree(); 34 + leaf.addEventListener('ping', (event) => event.preventDefault()); 35 + expect(leaf.dispatchEvent(new Event('ping', { cancelable: true }))).toBe(false); 36 + root.destroy(); 37 + }); 38 + 39 + it('custom Event subclasses pass through untouched', () => { 40 + class KeyEvent extends Event { 41 + constructor(readonly key: string) { 42 + super('keydown', { bubbles: true }); 43 + } 44 + } 45 + const { root, top, leaf } = tree(); 46 + let key = ''; 47 + top.addEventListener('keydown', (event) => { 48 + key = (event as KeyEvent).key; 49 + }); 50 + leaf.dispatchEvent(new KeyEvent('a')); 51 + expect(key).toEqual('a'); 52 + root.destroy(); 53 + }); 54 + }); 55 + 56 + describe('bubbling', () => { 57 + it('bubbles target -> mid -> top in order', () => { 58 + const { root, top, mid, leaf } = tree(); 59 + const order: string[] = []; 60 + for (const [name, node] of [ 61 + ['top', top], 62 + ['mid', mid], 63 + ['leaf', leaf], 64 + ] as const) { 65 + node.addEventListener('ping', (event) => { 66 + order.push(`${name}:${event.eventPhase}`); 67 + expect(event.target).toBe(leaf); 68 + expect(event.currentTarget).toBe(node); 69 + }); 70 + } 71 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 72 + expect(order).toEqual([ 73 + `leaf:${Event.AT_TARGET}`, 74 + `mid:${Event.BUBBLING_PHASE}`, 75 + `top:${Event.BUBBLING_PHASE}`, 76 + ]); 77 + root.destroy(); 78 + }); 79 + 80 + it("non-bubbling events do not reach ancestors' bubble listeners", () => { 81 + const { root, top, leaf } = tree(); 82 + let topSaw = false; 83 + top.addEventListener('ping', () => { 84 + topSaw = true; 85 + }); 86 + leaf.dispatchEvent(new Event('ping')); 87 + expect(topSaw).toBe(false); 88 + root.destroy(); 89 + }); 90 + 91 + it('stopPropagation halts ancestors but finishes the current node', () => { 92 + const { root, top, mid, leaf } = tree(); 93 + const order: string[] = []; 94 + leaf.addEventListener('ping', (event) => { 95 + order.push('leaf-1'); 96 + event.stopPropagation(); 97 + }); 98 + leaf.addEventListener('ping', () => order.push('leaf-2')); 99 + mid.addEventListener('ping', () => order.push('mid')); 100 + top.addEventListener('ping', () => order.push('top')); 101 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 102 + expect(order).toEqual(['leaf-1', 'leaf-2']); 103 + root.destroy(); 104 + }); 105 + 106 + it('stopImmediatePropagation halts remaining listeners on the same node too', () => { 107 + const { root, top, leaf } = tree(); 108 + const order: string[] = []; 109 + leaf.addEventListener('ping', (event) => { 110 + order.push('leaf-1'); 111 + event.stopImmediatePropagation(); 112 + }); 113 + leaf.addEventListener('ping', () => order.push('leaf-2')); 114 + top.addEventListener('ping', () => order.push('top')); 115 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 116 + expect(order).toEqual(['leaf-1']); 117 + root.destroy(); 118 + }); 119 + 120 + it('stopPropagation in a bubble listener halts remaining ancestors', () => { 121 + const { root, top, mid, leaf } = tree(); 122 + const order: string[] = []; 123 + mid.addEventListener('ping', (event) => { 124 + order.push('mid'); 125 + event.stopPropagation(); 126 + }); 127 + top.addEventListener('ping', () => order.push('top')); 128 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 129 + expect(order).toEqual(['mid']); 130 + root.destroy(); 131 + }); 132 + 133 + it('preventDefault anywhere on the path makes dispatchEvent return false', () => { 134 + const { root, top, leaf } = tree(); 135 + top.addEventListener('ping', (event) => event.preventDefault()); 136 + const handled = leaf.dispatchEvent(new Event('ping', { bubbles: true, cancelable: true })); 137 + expect(handled).toBe(false); 138 + root.destroy(); 139 + }); 140 + }); 141 + 142 + describe('capture phase', () => { 143 + it('capture runs top -> mid before target, bubble runs after', () => { 144 + const { root, top, mid, leaf } = tree(); 145 + const order: string[] = []; 146 + top.addEventListener('ping', () => order.push('top-capture'), { capture: true }); 147 + mid.addEventListener('ping', () => order.push('mid-capture'), { capture: true }); 148 + leaf.addEventListener('ping', () => order.push('leaf')); 149 + mid.addEventListener('ping', () => order.push('mid-bubble')); 150 + top.addEventListener('ping', () => order.push('top-bubble')); 151 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 152 + expect(order).toEqual(['top-capture', 'mid-capture', 'leaf', 'mid-bubble', 'top-bubble']); 153 + root.destroy(); 154 + }); 155 + 156 + it('capture listeners see non-bubbling events (delegation trick)', () => { 157 + const { root, top, leaf } = tree(); 158 + const order: string[] = []; 159 + top.addEventListener('focus', () => order.push('top-capture'), { capture: true }); 160 + top.addEventListener('focus', () => order.push('top-bubble')); 161 + leaf.dispatchEvent(new Event('focus')); 162 + expect(order).toEqual(['top-capture']); 163 + root.destroy(); 164 + }); 165 + 166 + it('bubble listeners do not fire during the capture walk', () => { 167 + const { root, mid, leaf } = tree(); 168 + const phases: number[] = []; 169 + mid.addEventListener('ping', (event) => phases.push(event.eventPhase)); 170 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 171 + expect(phases).toEqual([Event.BUBBLING_PHASE]); 172 + root.destroy(); 173 + }); 174 + 175 + it('at the target, capture and bubble listeners both fire in add order', () => { 176 + const { root, leaf } = tree(); 177 + const order: string[] = []; 178 + leaf.addEventListener('ping', () => order.push('bubble')); 179 + leaf.addEventListener('ping', () => order.push('capture'), { capture: true }); 180 + leaf.dispatchEvent(new Event('ping')); 181 + expect(order).toEqual(['bubble', 'capture']); 182 + root.destroy(); 183 + }); 184 + 185 + it('stopPropagation during capture prevents the target from seeing it', () => { 186 + const { root, top, leaf } = tree(); 187 + const order: string[] = []; 188 + top.addEventListener( 189 + 'ping', 190 + (event) => { 191 + order.push('top-capture'); 192 + event.stopPropagation(); 193 + }, 194 + { capture: true }, 195 + ); 196 + leaf.addEventListener('ping', () => order.push('leaf')); 197 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 198 + expect(order).toEqual(['top-capture']); 199 + root.destroy(); 200 + }); 201 + 202 + it('composedPath lists target -> ancestors during dispatch', () => { 203 + const { root, top, mid, leaf } = tree(); 204 + let path: EventTarget[] = []; 205 + top.addEventListener('ping', (event) => { 206 + path = event.composedPath(); 207 + }); 208 + const event = new Event('ping', { bubbles: true }); 209 + leaf.dispatchEvent(event); 210 + expect(path).toEqual([leaf, mid, top]); 211 + expect(event.composedPath()).toEqual([]); 212 + root.destroy(); 213 + }); 214 + }); 215 + 216 + describe('listener registration semantics', () => { 217 + it('dedupes by (type, callback, capture)', () => { 218 + const { root, leaf } = tree(); 219 + let count = 0; 220 + const listener = (): void => { 221 + count++; 222 + }; 223 + leaf.addEventListener('ping', listener); 224 + leaf.addEventListener('ping', listener); 225 + leaf.addEventListener('ping', listener, { capture: true }); 226 + leaf.dispatchEvent(new Event('ping')); 227 + // bubble registration once + capture registration once, both at target 228 + expect(count).toEqual(2); 229 + root.destroy(); 230 + }); 231 + 232 + it('removeEventListener respects the capture flag', () => { 233 + const { root, leaf } = tree(); 234 + let count = 0; 235 + const listener = (): void => { 236 + count++; 237 + }; 238 + leaf.addEventListener('ping', listener); 239 + leaf.removeEventListener('ping', listener, { capture: true }); // wrong flag 240 + leaf.dispatchEvent(new Event('ping')); 241 + expect(count).toEqual(1); 242 + leaf.removeEventListener('ping', listener); 243 + leaf.dispatchEvent(new Event('ping')); 244 + expect(count).toEqual(1); 245 + root.destroy(); 246 + }); 247 + 248 + it('once consumes the listener after one matching dispatch', () => { 249 + const { root, leaf } = tree(); 250 + let count = 0; 251 + leaf.addEventListener('ping', () => count++, { once: true }); 252 + leaf.dispatchEvent(new Event('ping')); 253 + leaf.dispatchEvent(new Event('ping')); 254 + expect(count).toEqual(1); 255 + root.destroy(); 256 + }); 257 + 258 + it('once is not consumed by a phase that does not match', () => { 259 + const { root, mid, leaf } = tree(); 260 + let count = 0; 261 + // A bubble-once listener on mid; a non-bubbling dispatch at leaf walks mid 262 + // during capture only, which must not consume it. 263 + mid.addEventListener('ping', () => count++, { once: true }); 264 + leaf.dispatchEvent(new Event('ping')); 265 + expect(count).toEqual(0); 266 + leaf.dispatchEvent(new Event('ping', { bubbles: true })); 267 + expect(count).toEqual(1); 268 + root.destroy(); 269 + }); 270 + 271 + it('an aborted signal removes the listener', () => { 272 + const { root, leaf } = tree(); 273 + let count = 0; 274 + const controller = new AbortController(); 275 + leaf.addEventListener('ping', () => count++, { signal: controller.signal }); 276 + leaf.dispatchEvent(new Event('ping')); 277 + controller.abort(); 278 + leaf.dispatchEvent(new Event('ping')); 279 + expect(count).toEqual(1); 280 + root.destroy(); 281 + }); 282 + 283 + it('supports handleEvent objects', () => { 284 + const { root, leaf } = tree(); 285 + let count = 0; 286 + leaf.addEventListener('ping', { 287 + handleEvent: () => { 288 + count++; 289 + }, 290 + }); 291 + leaf.dispatchEvent(new Event('ping')); 292 + expect(count).toEqual(1); 293 + root.destroy(); 294 + }); 295 + }); 296 + 297 + describe('event reuse', () => { 298 + it('an unstopped event can be dispatched again', () => { 299 + const { root, leaf } = tree(); 300 + let count = 0; 301 + leaf.addEventListener('ping', () => count++); 302 + const event = new Event('ping', { bubbles: true }); 303 + leaf.dispatchEvent(event); 304 + leaf.dispatchEvent(event); 305 + expect(count).toEqual(2); 306 + root.destroy(); 307 + }); 308 + 309 + it('a stopped event cannot be re-dispatched (native flag is sticky)', () => { 310 + const { root, leaf } = tree(); 311 + leaf.addEventListener('ping', (event) => event.stopPropagation()); 312 + const event = new Event('ping', { bubbles: true }); 313 + leaf.dispatchEvent(event); 314 + expect(() => leaf.dispatchEvent(event)).toThrow(/fresh event/); 315 + root.destroy(); 316 + }); 317 + 318 + it('re-dispatching an in-flight event throws', () => { 319 + const { root, top, leaf } = tree(); 320 + const event = new Event('ping', { bubbles: true }); 321 + let threw = false; 322 + top.addEventListener('ping', () => { 323 + try { 324 + leaf.dispatchEvent(event); 325 + } catch { 326 + threw = true; 327 + } 328 + }); 329 + leaf.dispatchEvent(event); 330 + expect(threw).toBe(true); 331 + root.destroy(); 332 + }); 333 + }); 334 + 335 + describe('lifecycle events', () => { 336 + it('remove bubbles to ancestors before the node detaches', () => { 337 + const { root, top, mid, leaf } = tree(); 338 + let sawTarget: unknown; 339 + let stillAttached = false; 340 + top.addEventListener('remove', (event) => { 341 + sawTarget = event.target; 342 + stillAttached = [...mid.children].includes(leaf); 343 + }); 344 + leaf.remove(); 345 + expect(sawTarget).toBe(leaf); 346 + expect(stillAttached).toBe(true); 347 + expect([...mid.children]).toEqual([]); 348 + root.destroy(); 349 + }); 350 + });
+285
packages/dom/test/focus.test.ts
··· 1 + import { describe, expect, it } from '../test/suite.ts'; 2 + import { createRoot, FocusEvent, FocusManager, type Node, type Root } from '../src/index.ts'; 3 + 4 + function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { 5 + const node = root.createElement(localName); 6 + if (tabindex !== undefined) { 7 + node.setAttribute('tabindex', tabindex); 8 + } 9 + parent.append(node); 10 + return node; 11 + } 12 + 13 + describe('FocusManager construction', () => { 14 + it('seeds the first focusable descendant', () => { 15 + const root = createRoot(); 16 + const a = addChild(root, root.documentElement, 'A', 0); 17 + addChild(root, root.documentElement, 'B', 0); 18 + const focus = new FocusManager(root.documentElement); 19 + expect(focus.activeElement).toBe(a); 20 + expect(a.getAttribute('focused')).toBe(true); 21 + root.destroy(); 22 + }); 23 + 24 + it('focuses nothing on an empty container; activeElement falls back to root', () => { 25 + const root = createRoot(); 26 + const focus = new FocusManager(root.documentElement); 27 + expect(focus.activeElement).toBe(root.documentElement); 28 + expect(root.documentElement.hasAttribute('focused')).toBe(false); 29 + root.destroy(); 30 + }); 31 + 32 + it('does not enroll root in the ring', () => { 33 + const root = createRoot(); 34 + addChild(root, root.documentElement, 'A', 0); 35 + addChild(root, root.documentElement, 'B', 0); 36 + const focus = new FocusManager(root.documentElement); // seeds A 37 + const names: string[] = []; 38 + for (let i = 0; i < 3; i++) { 39 + names.push(focus.activeElement.localName); 40 + focus.next(); 41 + } 42 + expect(names).toEqual(['A', 'B', 'A']); // wraps A->B->A; root never appears 43 + root.destroy(); 44 + }); 45 + 46 + it('escape hatch: a tabindex on root keeps it in the ring', () => { 47 + const root = createRoot(); 48 + root.documentElement.setAttribute('tabindex', 0); // explicit enrollment 49 + const a = addChild(root, root.documentElement, 'A', 0); 50 + const focus = new FocusManager(root.documentElement); // seeds A, skipping root 51 + expect(focus.activeElement).toBe(a); 52 + focus.next(); // A -> root (wrap now includes root) 53 + expect(focus.activeElement).toBe(root.documentElement); 54 + root.destroy(); 55 + }); 56 + }); 57 + 58 + describe('tabindex', () => { 59 + it('a node without tabindex is skipped by the ring', () => { 60 + const root = createRoot(); 61 + addChild(root, root.documentElement, 'skip'); // no tabindex 62 + addChild(root, root.documentElement, 'here', 0); 63 + const focus = new FocusManager(root.documentElement); // seeds "here" 64 + expect(focus.activeElement.localName).toEqual('here'); 65 + root.destroy(); 66 + }); 67 + 68 + it('tabindex -1 is programmatically focusable but not sequentially', () => { 69 + const root = createRoot(); 70 + const a = addChild(root, root.documentElement, 'A', 0); 71 + const hidden = addChild(root, root.documentElement, 'hidden', -1); 72 + addChild(root, root.documentElement, 'B', 0); 73 + const focus = new FocusManager(root.documentElement); // seeds A 74 + focus.next(); 75 + expect(focus.activeElement.localName).toEqual('B'); // ring skips hidden 76 + focus.focus(hidden); // but programmatic focus works 77 + expect(focus.activeElement).toBe(hidden); 78 + expect(a.hasAttribute('focused')).toBe(false); 79 + root.destroy(); 80 + }); 81 + }); 82 + 83 + describe('sequential traversal', () => { 84 + it('depth-first order, flat children', () => { 85 + const root = createRoot(); 86 + for (const name of ['A', 'B', 'C']) { 87 + addChild(root, root.documentElement, name, 0); 88 + } 89 + const focus = new FocusManager(root.documentElement); // seeds A 90 + const names: string[] = []; 91 + for (let i = 0; i < 4; i++) { 92 + names.push(focus.activeElement.localName); 93 + focus.next(); 94 + } 95 + expect(names).toEqual(['A', 'B', 'C', 'A']); 96 + root.destroy(); 97 + }); 98 + 99 + it('depth-first order, nested children', () => { 100 + const root = createRoot(); 101 + const a = addChild(root, root.documentElement, 'A', 0); 102 + addChild(root, a, 'A1', 0); 103 + addChild(root, root.documentElement, 'B', 0); 104 + const focus = new FocusManager(root.documentElement); // seeds A 105 + const names: string[] = []; 106 + for (let i = 0; i < 4; i++) { 107 + names.push(focus.activeElement.localName); 108 + focus.next(); 109 + } 110 + expect(names).toEqual(['A', 'A1', 'B', 'A']); 111 + root.destroy(); 112 + }); 113 + 114 + it('next moves forward, previous moves back, both wrap', () => { 115 + const root = createRoot(); 116 + const a = addChild(root, root.documentElement, 'A', 0); 117 + const b = addChild(root, root.documentElement, 'B', 0); 118 + const focus = new FocusManager(root.documentElement); // seeds A 119 + focus.next(); 120 + expect(focus.activeElement).toBe(b); 121 + focus.next(); // wrap 122 + expect(focus.activeElement).toBe(a); 123 + focus.previous(); // wrap back 124 + expect(focus.activeElement).toBe(b); 125 + root.destroy(); 126 + }); 127 + 128 + it('single focusable node is a no-op', () => { 129 + const root = createRoot(); 130 + const a = addChild(root, root.documentElement, 'A', 0); 131 + const focus = new FocusManager(root.documentElement); // seeds A 132 + focus.next(); 133 + expect(focus.activeElement).toBe(a); 134 + root.destroy(); 135 + }); 136 + 137 + it('no stops at all is a no-op', () => { 138 + const root = createRoot(); 139 + const focus = new FocusManager(root.documentElement); 140 + focus.next(); 141 + expect(focus.activeElement).toBe(root.documentElement); 142 + root.destroy(); 143 + }); 144 + 145 + it('focusables added after construction: next() enters the ring', () => { 146 + const root = createRoot(); 147 + const focus = new FocusManager(root.documentElement); // nothing to seed 148 + const late = addChild(root, root.documentElement, 'late', 0); 149 + focus.next(); 150 + expect(focus.activeElement).toBe(late); 151 + root.destroy(); 152 + }); 153 + }); 154 + 155 + describe('focus()', () => { 156 + it('explicitly focuses a node', () => { 157 + const root = createRoot(); 158 + const a = addChild(root, root.documentElement, 'A', 0); 159 + const b = addChild(root, root.documentElement, 'B', 0); 160 + const focus = new FocusManager(root.documentElement); // seeds A 161 + focus.focus(b); 162 + expect(focus.activeElement).toBe(b); 163 + expect(a.hasAttribute('focused')).toBe(false); 164 + expect(b.getAttribute('focused')).toBe(true); 165 + root.destroy(); 166 + }); 167 + 168 + it('throws on a node without a tabindex', () => { 169 + const root = createRoot(); 170 + const child = addChild(root, root.documentElement, 'nope'); 171 + const focus = new FocusManager(root.documentElement); 172 + expect(() => focus.focus(child)).toThrow(); 173 + root.destroy(); 174 + }); 175 + 176 + it('is a no-op when already focused', () => { 177 + const root = createRoot(); 178 + const a = addChild(root, root.documentElement, 'A', 0); 179 + const focus = new FocusManager(root.documentElement); // seeds A 180 + let events = 0; 181 + a.addEventListener('focus', () => events++); 182 + focus.focus(a); // already focused -> no-op 183 + expect(focus.activeElement).toBe(a); 184 + expect(events).toEqual(0); 185 + root.destroy(); 186 + }); 187 + }); 188 + 189 + describe('focus events', () => { 190 + it('fires blur/focusout at the old node, then focus/focusin at the new', () => { 191 + const root = createRoot(); 192 + const a = addChild(root, root.documentElement, 'A', 0); 193 + const b = addChild(root, root.documentElement, 'B', 0); 194 + const focus = new FocusManager(root.documentElement); // seeds A 195 + const order: string[] = []; 196 + for (const type of ['blur', 'focusout'] as const) { 197 + a.addEventListener(type, () => order.push(`${type}:A`)); 198 + } 199 + for (const type of ['focus', 'focusin'] as const) { 200 + b.addEventListener(type, () => order.push(`${type}:B`)); 201 + } 202 + focus.focus(b); 203 + expect(order).toEqual(['blur:A', 'focusout:A', 'focus:B', 'focusin:B']); 204 + root.destroy(); 205 + }); 206 + 207 + it('relatedTarget points at the other side of the transition', () => { 208 + const root = createRoot(); 209 + const a = addChild(root, root.documentElement, 'A', 0); 210 + const b = addChild(root, root.documentElement, 'B', 0); 211 + const focus = new FocusManager(root.documentElement); // seeds A 212 + let blurRelated: Node | undefined; 213 + let focusRelated: Node | undefined; 214 + a.addEventListener('blur', (event) => { 215 + blurRelated = (event as FocusEvent).relatedTarget; 216 + }); 217 + b.addEventListener('focus', (event) => { 218 + focusRelated = (event as FocusEvent).relatedTarget; 219 + }); 220 + focus.focus(b); 221 + expect(blurRelated).toBe(b); 222 + expect(focusRelated).toBe(a); 223 + root.destroy(); 224 + }); 225 + 226 + it('focus does not bubble; focusin bubbles; capture sees both', () => { 227 + const root = createRoot(); 228 + addChild(root, root.documentElement, 'A', 0); 229 + const b = addChild(root, root.documentElement, 'B', 0); 230 + const focus = new FocusManager(root.documentElement); // seeds A 231 + const seen: string[] = []; 232 + root.documentElement.addEventListener('focus', () => seen.push('focus-bubble')); 233 + root.documentElement.addEventListener('focus', () => seen.push('focus-capture'), { 234 + capture: true, 235 + }); 236 + root.documentElement.addEventListener('focusin', () => seen.push('focusin-bubble')); 237 + focus.focus(b); 238 + expect(seen).toEqual(['focus-capture', 'focusin-bubble']); 239 + root.destroy(); 240 + }); 241 + }); 242 + 243 + describe('focused node removal', () => { 244 + it('removing the focused node advances focus', () => { 245 + const root = createRoot(); 246 + const a = addChild(root, root.documentElement, 'A', 0); 247 + addChild(root, root.documentElement, 'B', 0); 248 + const focus = new FocusManager(root.documentElement); // seeds A 249 + expect(a.getAttribute('focused')).toBe(true); 250 + a.remove(); 251 + expect(focus.activeElement.localName).toEqual('B'); 252 + root.destroy(); 253 + }); 254 + 255 + it('removing a non-focused node does not move focus', () => { 256 + const root = createRoot(); 257 + addChild(root, root.documentElement, 'A', 0); 258 + const b = addChild(root, root.documentElement, 'B', 0); 259 + const focus = new FocusManager(root.documentElement); // seeds A 260 + b.remove(); 261 + expect(focus.activeElement.localName).toEqual('A'); 262 + root.destroy(); 263 + }); 264 + 265 + it('removing an ancestor of the focused node moves focus out of the subtree', () => { 266 + const root = createRoot(); 267 + const panel = addChild(root, root.documentElement, 'panel'); 268 + const inner = addChild(root, panel, 'inner', 0); 269 + addChild(root, root.documentElement, 'B', 0); 270 + const focus = new FocusManager(root.documentElement); // seeds inner 271 + expect(focus.activeElement).toBe(inner); 272 + panel.remove(); 273 + expect(focus.activeElement.localName).toEqual('B'); 274 + root.destroy(); 275 + }); 276 + 277 + it('removing the last focusable clears the pointer', () => { 278 + const root = createRoot(); 279 + const a = addChild(root, root.documentElement, 'A', 0); 280 + const focus = new FocusManager(root.documentElement); // seeds A 281 + a.remove(); 282 + expect(focus.activeElement).toBe(root.documentElement); 283 + root.destroy(); 284 + }); 285 + });
+274
packages/dom/test/focusgroup.test.ts
··· 1 + import { describe, expect, it } from '../test/suite.ts'; 2 + import { 3 + createRoot, 4 + FocusGroupManager, 5 + FocusManager, 6 + type Node, 7 + parseFocusgroup, 8 + type Root, 9 + } from '../src/index.ts'; 10 + 11 + function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { 12 + const node = root.createElement(localName); 13 + if (tabindex !== undefined) { 14 + node.setAttribute('tabindex', tabindex); 15 + } 16 + parent.append(node); 17 + return node; 18 + } 19 + 20 + // before | group(one, two) | after — the canonical toolbar-between-stops tree. 21 + function fixture(): { 22 + root: Root; 23 + before: Node; 24 + group: Node; 25 + one: Node; 26 + two: Node; 27 + after: Node; 28 + } { 29 + const root = createRoot(); 30 + const before = addChild(root, root.documentElement, 'before', 0); 31 + const group = addChild(root, root.documentElement, 'group'); 32 + const one = addChild(root, group, 'one', 0); 33 + const two = addChild(root, group, 'two', 0); 34 + const after = addChild(root, root.documentElement, 'after', 0); 35 + return { root, before, group, one, two, after }; 36 + } 37 + 38 + describe('parseFocusgroup', () => { 39 + it('applies behavior token defaults', () => { 40 + expect(parseFocusgroup('toolbar')).toEqual({ 41 + behavior: 'toolbar', 42 + axis: 'inline', 43 + wrap: false, 44 + memory: true, 45 + }); 46 + expect(parseFocusgroup('tablist')).toEqual({ 47 + behavior: 'tablist', 48 + axis: 'inline', 49 + wrap: true, 50 + memory: true, 51 + }); 52 + expect(parseFocusgroup('listbox')).toEqual({ 53 + behavior: 'listbox', 54 + axis: 'block', 55 + wrap: false, 56 + memory: true, 57 + }); 58 + expect(parseFocusgroup('radiogroup')).toEqual({ 59 + behavior: 'radiogroup', 60 + axis: 'both', 61 + wrap: true, 62 + memory: true, 63 + }); 64 + }); 65 + 66 + it('modifier tokens override behavior defaults', () => { 67 + expect(parseFocusgroup('toolbar wrap').wrap).toBe(true); 68 + expect(parseFocusgroup('tablist nowrap').wrap).toBe(false); 69 + expect(parseFocusgroup('toolbar block').axis).toBe('block'); 70 + expect(parseFocusgroup('menu nomemory').memory).toBe(false); 71 + }); 72 + 73 + it('bare value falls back to both axes, nowrap, memory', () => { 74 + expect(parseFocusgroup('')).toEqual({ 75 + behavior: undefined, 76 + axis: 'both', 77 + wrap: false, 78 + memory: true, 79 + }); 80 + }); 81 + }); 82 + 83 + describe('declarative focusgroup (attribute only, no manager)', () => { 84 + it('collapses the group to a single tab stop entering at the first item', () => { 85 + const { root, group, before } = fixture(); 86 + group.setAttribute('focusgroup', 'toolbar'); 87 + const focus = new FocusManager(root.documentElement); // seeds before 88 + expect(focus.activeElement).toBe(before); 89 + const names: string[] = []; 90 + for (let i = 0; i < 4; i++) { 91 + focus.next(); 92 + names.push(focus.activeElement.localName); 93 + } 94 + // one enters the group; two is only reachable by arrows, never by Tab 95 + expect(names).toEqual(['one', 'after', 'before', 'one']); 96 + root.destroy(); 97 + }); 98 + 99 + it('previous() enters the group from the other side at the same entry', () => { 100 + const { root, group } = fixture(); 101 + group.setAttribute('focusgroup', 'toolbar'); 102 + const focus = new FocusManager(root.documentElement); // seeds before 103 + focus.previous(); // wraps to after 104 + focus.previous(); // group entry (first item — no memory recorded yet) 105 + expect(focus.activeElement.localName).toEqual('one'); 106 + root.destroy(); 107 + }); 108 + }); 109 + 110 + describe('FocusGroupManager traversal', () => { 111 + it('next/previous move between items; nowrap stops at the ends', () => { 112 + const { root, group, one, two } = fixture(); 113 + const focus = new FocusManager(root.documentElement); 114 + const g = new FocusGroupManager(focus, group, 'toolbar'); // nowrap 115 + focus.focus(one); 116 + g.next(); 117 + expect(focus.activeElement).toBe(two); 118 + g.next(); // end, no wrap 119 + expect(focus.activeElement).toBe(two); 120 + g.previous(); 121 + expect(focus.activeElement).toBe(one); 122 + g.previous(); // start, no wrap 123 + expect(focus.activeElement).toBe(one); 124 + root.destroy(); 125 + }); 126 + 127 + it('wrap cycles at the ends', () => { 128 + const { root, group, one, two } = fixture(); 129 + const focus = new FocusManager(root.documentElement); 130 + const g = new FocusGroupManager(focus, group, 'tablist'); // wrap by default 131 + focus.focus(two); 132 + g.next(); 133 + expect(focus.activeElement).toBe(one); 134 + g.previous(); 135 + expect(focus.activeElement).toBe(two); 136 + root.destroy(); 137 + }); 138 + 139 + it('no-ops while focus is outside the group (safe to bind globally)', () => { 140 + const { root, group, before } = fixture(); 141 + const focus = new FocusManager(root.documentElement); // seeds before 142 + const g = new FocusGroupManager(focus, group, 'toolbar'); 143 + g.next(); 144 + g.first(); 145 + g.last(); 146 + expect(focus.activeElement).toBe(before); 147 + root.destroy(); 148 + }); 149 + 150 + it('first/last jump within the group (Home/End)', () => { 151 + const { root, group, one, two } = fixture(); 152 + const focus = new FocusManager(root.documentElement); 153 + const g = new FocusGroupManager(focus, group, 'toolbar'); 154 + focus.focus(two); 155 + g.first(); 156 + expect(focus.activeElement).toBe(one); 157 + g.last(); 158 + expect(focus.activeElement).toBe(two); 159 + root.destroy(); 160 + }); 161 + 162 + it('exposes the parsed axis for key binding', () => { 163 + const { root, group } = fixture(); 164 + const focus = new FocusManager(root.documentElement); 165 + const g = new FocusGroupManager(focus, group, 'listbox'); 166 + expect(g.axis).toEqual('block'); 167 + root.destroy(); 168 + }); 169 + }); 170 + 171 + describe('focus memory (roving tab stop)', () => { 172 + it('re-entering the group returns to the last-focused item', () => { 173 + const { root, group, one, two, after } = fixture(); 174 + const focus = new FocusManager(root.documentElement); 175 + const g = new FocusGroupManager(focus, group, 'toolbar'); 176 + focus.focus(one); 177 + g.next(); // two — recorded as memory via focusin 178 + focus.focus(after); // leave the group 179 + focus.previous(); // Tab back in 180 + expect(focus.activeElement).toBe(two); 181 + root.destroy(); 182 + }); 183 + 184 + it('nomemory always enters at the first item', () => { 185 + const { root, group, one, two, after } = fixture(); 186 + const focus = new FocusManager(root.documentElement); 187 + const g = new FocusGroupManager(focus, group, 'toolbar nomemory'); 188 + focus.focus(two); 189 + g.previous(); 190 + g.next(); // back on two, but nothing recorded 191 + focus.focus(after); 192 + focus.previous(); 193 + expect(focus.activeElement).toBe(one); 194 + root.destroy(); 195 + }); 196 + 197 + it('memory pointing at a removed item falls back to the first item', () => { 198 + const { root, group, one, two, after } = fixture(); 199 + const focus = new FocusManager(root.documentElement); 200 + new FocusGroupManager(focus, group, 'toolbar'); 201 + focus.focus(two); // memory = two 202 + focus.focus(after); 203 + two.remove(); // memory is now a dead node (signal aborted) 204 + focus.previous(); 205 + expect(focus.activeElement).toBe(one); 206 + root.destroy(); 207 + }); 208 + 209 + it('memory pointing at an item moved out of the group falls back', () => { 210 + const { root, group, one, two, after } = fixture(); 211 + const focus = new FocusManager(root.documentElement); 212 + new FocusGroupManager(focus, group, 'toolbar'); 213 + focus.focus(two); // memory = two 214 + focus.focus(after); 215 + root.documentElement.append(two); // move two out of the group (still alive) 216 + focus.previous(); // group entry falls back: memory is no longer inside 217 + expect(focus.activeElement).toBe(one); 218 + root.destroy(); 219 + }); 220 + }); 221 + 222 + describe('opting out and nesting', () => { 223 + it("focusgroup='none' items leave the group and become their own tab stop", () => { 224 + const { root, group, one, two } = fixture(); 225 + const opt = addChild(root, group, 'opt', 0); 226 + opt.setAttribute('focusgroup', 'none'); 227 + const focus = new FocusManager(root.documentElement); 228 + const g = new FocusGroupManager(focus, group, 'toolbar'); 229 + expect(g.items).toEqual([one, two]); 230 + const names: string[] = []; 231 + for (let i = 0; i < 4; i++) { 232 + focus.next(); 233 + names.push(focus.activeElement.localName); 234 + } 235 + expect(names).toEqual(['one', 'opt', 'after', 'before']); 236 + root.destroy(); 237 + }); 238 + 239 + it('a nested focusgroup is an independent segment with its own stop', () => { 240 + const { root, group, one, two } = fixture(); 241 + const inner = addChild(root, group, 'inner'); 242 + const innerItem = addChild(root, inner, 'inner-item', 0); 243 + const focus = new FocusManager(root.documentElement); 244 + const outer = new FocusGroupManager(focus, group, 'toolbar'); 245 + const nested = new FocusGroupManager(focus, inner, 'menu'); 246 + expect(outer.items).toEqual([one, two]); 247 + expect(nested.items).toEqual([innerItem]); 248 + const names: string[] = []; 249 + for (let i = 0; i < 4; i++) { 250 + focus.next(); 251 + names.push(focus.activeElement.localName); 252 + } 253 + expect(names).toEqual(['one', 'inner-item', 'after', 'before']); 254 + root.destroy(); 255 + }); 256 + }); 257 + 258 + describe('dispose()', () => { 259 + it('dissolves the group back into individual tab stops', () => { 260 + const { root, group, one } = fixture(); 261 + const focus = new FocusManager(root.documentElement); 262 + const g = new FocusGroupManager(focus, group, 'toolbar'); 263 + focus.focus(one); 264 + g.dispose(); 265 + expect(group.hasAttribute('focusgroup')).toBe(false); 266 + const names: string[] = []; 267 + for (let i = 0; i < 4; i++) { 268 + focus.next(); 269 + names.push(focus.activeElement.localName); 270 + } 271 + expect(names).toEqual(['two', 'after', 'before', 'one']); 272 + root.destroy(); 273 + }); 274 + });
+289
packages/dom/test/root.test.ts
··· 1 + import { describe, expect, it } from '../test/suite.ts'; 2 + import { createRoot } from '../src/index.ts'; 3 + 4 + function nextMicrotask(): Promise<void> { 5 + return Promise.resolve(); 6 + } 7 + 8 + describe('createRoot', () => { 9 + it('returns a root with a parentless, connected document element', () => { 10 + const root = createRoot(); 11 + expect(root.documentElement).toBeTruthy(); 12 + expect(root.documentElement.parent).toBeUndefined(); 13 + expect(root.documentElement.isConnected).toBe(true); 14 + expect(root.documentElement.id).toBeTruthy(); 15 + root.destroy(); 16 + }); 17 + 18 + it('createElement returns a detached node with a unique id', () => { 19 + const root = createRoot(); 20 + const a = root.createElement('a'); 21 + const b = root.createElement('b'); 22 + expect(a.localName).toEqual('a'); 23 + expect(a.parent).toBeUndefined(); 24 + expect(a.isConnected).toBe(false); 25 + expect(a.id).not.toEqual(b.id); 26 + expect(a.id).not.toEqual(root.documentElement.id); 27 + root.destroy(); 28 + }); 29 + 30 + it('append attaches and connects, in order', () => { 31 + const root = createRoot(); 32 + const a = root.createElement('a'); 33 + const b = root.createElement('b'); 34 + root.documentElement.append(a, b); 35 + expect(a.parent).toBe(root.documentElement); 36 + expect(a.isConnected).toBe(true); 37 + expect([...root.documentElement.children]).toEqual([a, b]); 38 + root.destroy(); 39 + }); 40 + 41 + it('insertBefore inserts at the reference position', () => { 42 + const root = createRoot(); 43 + const a = root.createElement('a'); 44 + const c = root.createElement('c'); 45 + root.documentElement.append(a, c); 46 + const b = root.createElement('b'); 47 + root.documentElement.insertBefore(b, c); 48 + expect([...root.documentElement.children]).toEqual([a, b, c]); 49 + root.destroy(); 50 + }); 51 + 52 + it('insertBefore throws when the reference is not a child', () => { 53 + const root = createRoot(); 54 + const a = root.createElement('a'); 55 + const inner = root.createElement('inner'); 56 + a.append(inner); 57 + root.documentElement.append(a); 58 + const b = root.createElement('b'); 59 + expect(() => root.documentElement.insertBefore(b, inner)).toThrow(); 60 + root.destroy(); 61 + }); 62 + 63 + it('append throws on cycles and cross-tree nodes', () => { 64 + const root = createRoot(); 65 + const a = root.createElement('a'); 66 + const inner = root.createElement('inner'); 67 + a.append(inner); 68 + expect(() => inner.append(a)).toThrow(); // ancestor into descendant 69 + expect(() => a.append(a)).toThrow(); // self 70 + const other = createRoot(); 71 + expect(() => root.documentElement.append(other.createElement('x'))).toThrow(); 72 + other.destroy(); 73 + root.destroy(); 74 + }); 75 + 76 + it('a subtree can be built detached and connected in one append', () => { 77 + const root = createRoot(); 78 + const panel = root.createElement('panel'); 79 + const item = root.createElement('item'); 80 + panel.append(item); 81 + expect(item.isConnected).toBe(false); 82 + root.documentElement.append(panel); 83 + expect(item.isConnected).toBe(true); 84 + root.destroy(); 85 + }); 86 + }); 87 + 88 + describe('moves', () => { 89 + it('appending an attached node relocates it (reorder)', () => { 90 + const root = createRoot(); 91 + const a = root.createElement('a'); 92 + const b = root.createElement('b'); 93 + const c = root.createElement('c'); 94 + root.documentElement.append(a, b, c); 95 + root.documentElement.append(a); // move a to the end 96 + expect([...root.documentElement.children].map((n) => n.localName)).toEqual(['b', 'c', 'a']); 97 + root.documentElement.insertBefore(c, b); // move c before b 98 + expect([...root.documentElement.children].map((n) => n.localName)).toEqual(['c', 'b', 'a']); 99 + root.destroy(); 100 + }); 101 + 102 + it('moves are state-preserving: signal live, listeners intact, no remove event', () => { 103 + const root = createRoot(); 104 + const a = root.createElement('a'); 105 + const b = root.createElement('b'); 106 + root.documentElement.append(a, b); 107 + let pings = 0; 108 + let removes = 0; 109 + a.addEventListener('ping', () => pings++); 110 + root.documentElement.addEventListener('remove', () => removes++); 111 + root.documentElement.append(a); // move 112 + expect(a.signal.aborted).toBe(false); 113 + expect(removes).toEqual(0); 114 + a.dispatchEvent(new Event('ping')); 115 + expect(pings).toEqual(1); 116 + root.destroy(); 117 + }); 118 + 119 + it('a move across parents keeps the subtree connected', () => { 120 + const root = createRoot(); 121 + const left = root.createElement('left'); 122 + const right = root.createElement('right'); 123 + root.documentElement.append(left, right); 124 + const item = root.createElement('item'); 125 + left.append(item); 126 + right.append(item); // move between containers 127 + expect(item.parent).toBe(right); 128 + expect(item.isConnected).toBe(true); 129 + expect([...left.children]).toEqual([]); 130 + root.destroy(); 131 + }); 132 + 133 + it('a removed node cannot be re-inserted', () => { 134 + const root = createRoot(); 135 + const a = root.createElement('a'); 136 + root.documentElement.append(a); 137 + a.remove(); 138 + expect(() => root.documentElement.append(a)).toThrow(); 139 + root.destroy(); 140 + }); 141 + }); 142 + 143 + describe('attributes', () => { 144 + it('set/get/has/remove round-trip; snapshot is frozen', () => { 145 + const root = createRoot(); 146 + const node = root.documentElement; 147 + node.setAttribute('n', 5); 148 + expect(node.getAttribute('n')).toEqual(5); 149 + expect(node.hasAttribute('n')).toBe(true); 150 + expect(node.attributes['n']).toEqual(5); 151 + expect(Object.isFrozen(node.attributes)).toBe(true); 152 + node.removeAttribute('n'); 153 + expect(node.hasAttribute('n')).toBe(false); 154 + expect(() => node.removeAttribute('n')).not.toThrow(); 155 + root.destroy(); 156 + }); 157 + 158 + it('rejects invalid JsonValues', () => { 159 + const root = createRoot(); 160 + const node = root.documentElement; 161 + expect(() => node.setAttribute('bad', undefined as never)).toThrow(); 162 + expect(() => node.setAttribute('bad', Number.NaN)).toThrow(); 163 + expect(() => node.setAttribute('bad', (() => {}) as never)).toThrow(); 164 + expect(node.hasAttribute('bad')).toBe(false); 165 + root.destroy(); 166 + }); 167 + }); 168 + 169 + describe('getElementById', () => { 170 + it('resolves connected nodes only, like the DOM', () => { 171 + const root = createRoot(); 172 + const a = root.createElement('a'); 173 + expect(root.getElementById(a.id)).toBeUndefined(); // detached 174 + root.documentElement.append(a); 175 + expect(root.getElementById(a.id)).toBe(a); 176 + expect(root.getElementById(root.documentElement.id)).toBe(root.documentElement); 177 + expect(root.getElementById('nope')).toBeUndefined(); 178 + root.destroy(); 179 + }); 180 + 181 + it('forgets removed subtrees', () => { 182 + const root = createRoot(); 183 + const a = root.createElement('a'); 184 + const inner = root.createElement('inner'); 185 + a.append(inner); 186 + root.documentElement.append(a); 187 + a.remove(); 188 + expect(root.getElementById(a.id)).toBeUndefined(); 189 + expect(root.getElementById(inner.id)).toBeUndefined(); 190 + root.destroy(); 191 + }); 192 + }); 193 + 194 + describe('remove', () => { 195 + it('detaches and destroys the subtree', () => { 196 + const root = createRoot(); 197 + const a = root.createElement('a'); 198 + root.documentElement.append(a); 199 + a.remove(); 200 + expect([...root.documentElement.children]).toEqual([]); 201 + expect(a.parent).toBeUndefined(); 202 + expect(a.isConnected).toBe(false); 203 + root.destroy(); 204 + }); 205 + 206 + it('throws on the document element', () => { 207 + const root = createRoot(); 208 + expect(() => root.documentElement.remove()).toThrow(); 209 + root.destroy(); 210 + }); 211 + 212 + it('destroys a detached node without an event', () => { 213 + const root = createRoot(); 214 + const a = root.createElement('a'); 215 + let removes = 0; 216 + a.addEventListener('remove', () => removes++); 217 + a.remove(); 218 + expect(a.signal.aborted).toBe(true); 219 + expect(removes).toEqual(0); 220 + root.destroy(); 221 + }); 222 + }); 223 + 224 + describe('contains', () => { 225 + it('is inclusive of self and descendants', () => { 226 + const root = createRoot(); 227 + const a = root.createElement('a'); 228 + const inner = root.createElement('inner'); 229 + a.append(inner); 230 + root.documentElement.append(a); 231 + expect(a.contains(a)).toBe(true); 232 + expect(a.contains(inner)).toBe(true); 233 + expect(root.documentElement.contains(inner)).toBe(true); 234 + expect(inner.contains(a)).toBe(false); 235 + root.destroy(); 236 + }); 237 + }); 238 + 239 + describe('change notification', () => { 240 + it('emits one coalesced change per microtask burst', async () => { 241 + const root = createRoot(); 242 + let changes = 0; 243 + root.addEventListener('change', () => changes++); 244 + root.documentElement.setAttribute('a', 1); 245 + root.documentElement.setAttribute('b', 2); 246 + root.documentElement.append(root.createElement('c')); 247 + await nextMicrotask(); 248 + expect(changes).toEqual(1); 249 + root.destroy(); 250 + }); 251 + 252 + it('emits again for a later burst', async () => { 253 + const root = createRoot(); 254 + let changes = 0; 255 + root.addEventListener('change', () => changes++); 256 + root.documentElement.setAttribute('a', 1); 257 + await nextMicrotask(); 258 + root.documentElement.setAttribute('a', 2); 259 + await nextMicrotask(); 260 + expect(changes).toEqual(2); 261 + root.destroy(); 262 + }); 263 + 264 + it('mutations from event listeners coalesce into one change', async () => { 265 + const root = createRoot(); 266 + const input = root.createElement('input'); 267 + root.documentElement.append(input); 268 + input.addEventListener('keydown', () => { 269 + input.setAttribute('value', 'x'); 270 + input.setAttribute('cursor', 1); 271 + }); 272 + let changes = 0; 273 + root.addEventListener('change', () => changes++); 274 + input.dispatchEvent(new Event('keydown', { bubbles: true })); 275 + await nextMicrotask(); 276 + expect(changes).toEqual(1); 277 + root.destroy(); 278 + }); 279 + 280 + it('does not emit after destroy', async () => { 281 + const root = createRoot(); 282 + let changes = 0; 283 + root.addEventListener('change', () => changes++); 284 + root.documentElement.setAttribute('a', 1); 285 + root.destroy(); 286 + await nextMicrotask(); 287 + expect(changes).toEqual(0); 288 + }); 289 + });
+138
packages/dom/test/signal.test.ts
··· 1 + import { setTimeout as delay } from 'node:timers/promises'; 2 + import { describe, expect, it } from '../test/suite.ts'; 3 + import { createRoot, FocusManager, type Node, type Root } from '../src/index.ts'; 4 + 5 + function addChild(root: Root, parent: Node, localName: string, tabindex?: number): Node { 6 + const node = root.createElement(localName); 7 + if (tabindex !== undefined) { 8 + node.setAttribute('tabindex', tabindex); 9 + } 10 + parent.append(node); 11 + return node; 12 + } 13 + 14 + describe('node.signal', () => { 15 + it('is a live AbortSignal while the node is alive', () => { 16 + const root = createRoot(); 17 + const child = addChild(root, root.documentElement, 'child'); 18 + expect(child.signal).toBeInstanceOf(AbortSignal); 19 + expect(child.signal.aborted).toBe(false); 20 + root.destroy(); 21 + }); 22 + 23 + it('aborts when the node is removed; siblings are untouched', () => { 24 + const root = createRoot(); 25 + const a = addChild(root, root.documentElement, 'a'); 26 + const b = addChild(root, root.documentElement, 'b'); 27 + a.remove(); 28 + expect(a.signal.aborted).toBe(true); 29 + expect(b.signal.aborted).toBe(false); 30 + expect(root.documentElement.signal.aborted).toBe(false); 31 + root.destroy(); 32 + }); 33 + 34 + it('stays live across moves — relocation is not removal', () => { 35 + const root = createRoot(); 36 + const left = addChild(root, root.documentElement, 'left'); 37 + const right = addChild(root, root.documentElement, 'right'); 38 + const item = addChild(root, left, 'item'); 39 + right.append(item); // move 40 + expect(item.signal.aborted).toBe(false); 41 + root.documentElement.insertBefore(item, left); // move again 42 + expect(item.signal.aborted).toBe(false); 43 + root.destroy(); 44 + }); 45 + 46 + it('aborts descendants depth-first, children before parents', () => { 47 + const root = createRoot(); 48 + const a = addChild(root, root.documentElement, 'a'); 49 + const inner = addChild(root, a, 'inner'); 50 + const innermost = addChild(root, inner, 'innermost'); 51 + const order: string[] = []; 52 + for (const [name, node] of [ 53 + ['a', a], 54 + ['inner', inner], 55 + ['innermost', innermost], 56 + ] as const) { 57 + node.signal.addEventListener('abort', () => order.push(name)); 58 + } 59 + a.remove(); 60 + expect(order).toEqual(['innermost', 'inner', 'a']); 61 + root.destroy(); 62 + }); 63 + 64 + it('root.destroy aborts the whole tree', () => { 65 + const root = createRoot(); 66 + const a = addChild(root, root.documentElement, 'a'); 67 + const inner = addChild(root, a, 'inner'); 68 + root.destroy(); 69 + expect(root.documentElement.signal.aborted).toBe(true); 70 + expect(a.signal.aborted).toBe(true); 71 + expect(inner.signal.aborted).toBe(true); 72 + }); 73 + 74 + it('cleans up ancestor listeners registered with { signal } (delegation)', () => { 75 + const root = createRoot(); 76 + const child = addChild(root, root.documentElement, 'child'); 77 + let count = 0; 78 + // A listener the child installs on the root, scoped to the child's life. 79 + root.documentElement.addEventListener('ping', () => count++, { 80 + signal: child.signal, 81 + }); 82 + child.dispatchEvent(new Event('ping', { bubbles: true })); 83 + expect(count).toEqual(1); 84 + child.remove(); 85 + root.documentElement.dispatchEvent(new Event('ping', { bubbles: true })); 86 + expect(count).toEqual(1); 87 + root.destroy(); 88 + }); 89 + 90 + it('cancels node-scoped timers on removal', async () => { 91 + const root = createRoot(); 92 + const spinner = addChild(root, root.documentElement, 'spinner'); 93 + const outcome = delay(1_000, 'completed', { signal: spinner.signal }).catch( 94 + (error: Error) => error.name, 95 + ); 96 + spinner.remove(); 97 + expect(await outcome).toEqual('AbortError'); 98 + root.destroy(); 99 + }); 100 + 101 + it('stops a node-scoped async loop (the spinner pattern)', async () => { 102 + const root = createRoot(); 103 + const spinner = addChild(root, root.documentElement, 'spinner'); 104 + let frames = 0; 105 + const loop = (async () => { 106 + try { 107 + while (true) { 108 + spinner.setAttribute('frame', frames++); 109 + await delay(1, undefined, { signal: spinner.signal }); 110 + } 111 + } catch { 112 + // aborted — the node was removed 113 + } 114 + })(); 115 + await delay(10); 116 + spinner.remove(); 117 + await loop; 118 + const after = frames; 119 + await delay(10); 120 + expect(frames).toEqual(after); // no ticks after removal 121 + expect(frames).toBeGreaterThan(0); 122 + root.destroy(); 123 + }); 124 + 125 + it('a FocusManager scoped to a container dies with it', () => { 126 + const root = createRoot(); 127 + const panel = addChild(root, root.documentElement, 'panel'); 128 + const a = addChild(root, panel, 'A', 0); 129 + addChild(root, panel, 'B', 0); 130 + const focus = new FocusManager(panel); // seeds A, manages removals within panel 131 + expect(focus.activeElement).toBe(a); 132 + panel.remove(); 133 + expect(panel.signal.aborted).toBe(true); 134 + // pointer cleared, listener dead via panel.signal; falls back to its root 135 + expect(focus.activeElement).toBe(panel); 136 + root.destroy(); 137 + }); 138 + });
+1
packages/dom/test/suite.ts
··· 1 + export { afterEach, beforeEach, describe, it, expect } from 'vitest';
+6
packages/dom/tsconfig.json
··· 1 + { 2 + "extends": ["@bomb.sh/tools/tsconfig.json"], 3 + "compilerOptions": { 4 + "types": ["node"] 5 + } 6 + }
+198 -13
pnpm-lock.yaml
··· 39 39 specifier: 'catalog:' 40 40 version: 0.8.0 41 41 42 + packages/demo: 43 + dependencies: 44 + '@bomb.sh/dom': 45 + specifier: workspace:* 46 + version: link:../dom 47 + '@bomb.sh/tty': 48 + specifier: latest 49 + version: 0.8.0 50 + '@types/node': 51 + specifier: ^26.0.0 52 + version: 26.1.1 53 + devDependencies: 54 + '@bomb.sh/tools': 55 + specifier: latest 56 + 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)) 57 + 58 + packages/dom: 59 + devDependencies: 60 + '@bomb.sh/tools': 61 + specifier: latest 62 + 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)) 63 + '@types/node': 64 + specifier: ^26.0.0 65 + version: 26.1.1 66 + vitest: 67 + specifier: ^4.1.2 68 + 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)) 69 + 42 70 packages: 43 71 44 72 '@bomb.sh/args@0.3.1': ··· 162 190 engines: {node: ^20.19.0 || >=22.12.0} 163 191 cpu: [arm64] 164 192 os: [linux] 193 + libc: [glibc] 165 194 166 195 '@oxc-parser/binding-linux-arm64-musl@0.137.0': 167 196 resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==} 168 197 engines: {node: ^20.19.0 || >=22.12.0} 169 198 cpu: [arm64] 170 199 os: [linux] 200 + libc: [musl] 171 201 172 202 '@oxc-parser/binding-linux-ppc64-gnu@0.137.0': 173 203 resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==} 174 204 engines: {node: ^20.19.0 || >=22.12.0} 175 205 cpu: [ppc64] 176 206 os: [linux] 207 + libc: [glibc] 177 208 178 209 '@oxc-parser/binding-linux-riscv64-gnu@0.137.0': 179 210 resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==} 180 211 engines: {node: ^20.19.0 || >=22.12.0} 181 212 cpu: [riscv64] 182 213 os: [linux] 214 + libc: [glibc] 183 215 184 216 '@oxc-parser/binding-linux-riscv64-musl@0.137.0': 185 217 resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==} 186 218 engines: {node: ^20.19.0 || >=22.12.0} 187 219 cpu: [riscv64] 188 220 os: [linux] 221 + libc: [musl] 189 222 190 223 '@oxc-parser/binding-linux-s390x-gnu@0.137.0': 191 224 resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==} 192 225 engines: {node: ^20.19.0 || >=22.12.0} 193 226 cpu: [s390x] 194 227 os: [linux] 228 + libc: [glibc] 195 229 196 230 '@oxc-parser/binding-linux-x64-gnu@0.137.0': 197 231 resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==} 198 232 engines: {node: ^20.19.0 || >=22.12.0} 199 233 cpu: [x64] 200 234 os: [linux] 235 + libc: [glibc] 201 236 202 237 '@oxc-parser/binding-linux-x64-musl@0.137.0': 203 238 resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==} 204 239 engines: {node: ^20.19.0 || >=22.12.0} 205 240 cpu: [x64] 206 241 os: [linux] 242 + libc: [musl] 207 243 208 244 '@oxc-parser/binding-openharmony-arm64@0.137.0': 209 245 resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==} ··· 282 318 resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==} 283 319 cpu: [arm64] 284 320 os: [linux] 321 + libc: [glibc] 285 322 286 323 '@oxc-resolver/binding-linux-arm64-musl@11.21.3': 287 324 resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==} 288 325 cpu: [arm64] 289 326 os: [linux] 327 + libc: [musl] 290 328 291 329 '@oxc-resolver/binding-linux-ppc64-gnu@11.21.3': 292 330 resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==} 293 331 cpu: [ppc64] 294 332 os: [linux] 333 + libc: [glibc] 295 334 296 335 '@oxc-resolver/binding-linux-riscv64-gnu@11.21.3': 297 336 resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==} 298 337 cpu: [riscv64] 299 338 os: [linux] 339 + libc: [glibc] 300 340 301 341 '@oxc-resolver/binding-linux-riscv64-musl@11.21.3': 302 342 resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==} 303 343 cpu: [riscv64] 304 344 os: [linux] 345 + libc: [musl] 305 346 306 347 '@oxc-resolver/binding-linux-s390x-gnu@11.21.3': 307 348 resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==} 308 349 cpu: [s390x] 309 350 os: [linux] 351 + libc: [glibc] 310 352 311 353 '@oxc-resolver/binding-linux-x64-gnu@11.21.3': 312 354 resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==} 313 355 cpu: [x64] 314 356 os: [linux] 357 + libc: [glibc] 315 358 316 359 '@oxc-resolver/binding-linux-x64-musl@11.21.3': 317 360 resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==} 318 361 cpu: [x64] 319 362 os: [linux] 363 + libc: [musl] 320 364 321 365 '@oxc-resolver/binding-openharmony-arm64@11.21.3': 322 366 resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==} ··· 385 429 engines: {node: ^20.19.0 || >=22.12.0} 386 430 cpu: [arm64] 387 431 os: [linux] 432 + libc: [glibc] 388 433 389 434 '@oxfmt/binding-linux-arm64-musl@0.47.0': 390 435 resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} 391 436 engines: {node: ^20.19.0 || >=22.12.0} 392 437 cpu: [arm64] 393 438 os: [linux] 439 + libc: [musl] 394 440 395 441 '@oxfmt/binding-linux-ppc64-gnu@0.47.0': 396 442 resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} 397 443 engines: {node: ^20.19.0 || >=22.12.0} 398 444 cpu: [ppc64] 399 445 os: [linux] 446 + libc: [glibc] 400 447 401 448 '@oxfmt/binding-linux-riscv64-gnu@0.47.0': 402 449 resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} 403 450 engines: {node: ^20.19.0 || >=22.12.0} 404 451 cpu: [riscv64] 405 452 os: [linux] 453 + libc: [glibc] 406 454 407 455 '@oxfmt/binding-linux-riscv64-musl@0.47.0': 408 456 resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} 409 457 engines: {node: ^20.19.0 || >=22.12.0} 410 458 cpu: [riscv64] 411 459 os: [linux] 460 + libc: [musl] 412 461 413 462 '@oxfmt/binding-linux-s390x-gnu@0.47.0': 414 463 resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} 415 464 engines: {node: ^20.19.0 || >=22.12.0} 416 465 cpu: [s390x] 417 466 os: [linux] 467 + libc: [glibc] 418 468 419 469 '@oxfmt/binding-linux-x64-gnu@0.47.0': 420 470 resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} 421 471 engines: {node: ^20.19.0 || >=22.12.0} 422 472 cpu: [x64] 423 473 os: [linux] 474 + libc: [glibc] 424 475 425 476 '@oxfmt/binding-linux-x64-musl@0.47.0': 426 477 resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} 427 478 engines: {node: ^20.19.0 || >=22.12.0} 428 479 cpu: [x64] 429 480 os: [linux] 481 + libc: [musl] 430 482 431 483 '@oxfmt/binding-openharmony-arm64@0.47.0': 432 484 resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} ··· 499 551 engines: {node: ^20.19.0 || >=22.12.0} 500 552 cpu: [arm64] 501 553 os: [linux] 554 + libc: [glibc] 502 555 503 556 '@oxlint/binding-linux-arm64-musl@1.71.0': 504 557 resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} 505 558 engines: {node: ^20.19.0 || >=22.12.0} 506 559 cpu: [arm64] 507 560 os: [linux] 561 + libc: [musl] 508 562 509 563 '@oxlint/binding-linux-ppc64-gnu@1.71.0': 510 564 resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} 511 565 engines: {node: ^20.19.0 || >=22.12.0} 512 566 cpu: [ppc64] 513 567 os: [linux] 568 + libc: [glibc] 514 569 515 570 '@oxlint/binding-linux-riscv64-gnu@1.71.0': 516 571 resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} 517 572 engines: {node: ^20.19.0 || >=22.12.0} 518 573 cpu: [riscv64] 519 574 os: [linux] 575 + libc: [glibc] 520 576 521 577 '@oxlint/binding-linux-riscv64-musl@1.71.0': 522 578 resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} 523 579 engines: {node: ^20.19.0 || >=22.12.0} 524 580 cpu: [riscv64] 525 581 os: [linux] 582 + libc: [musl] 526 583 527 584 '@oxlint/binding-linux-s390x-gnu@1.71.0': 528 585 resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} 529 586 engines: {node: ^20.19.0 || >=22.12.0} 530 587 cpu: [s390x] 531 588 os: [linux] 589 + libc: [glibc] 532 590 533 591 '@oxlint/binding-linux-x64-gnu@1.71.0': 534 592 resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} 535 593 engines: {node: ^20.19.0 || >=22.12.0} 536 594 cpu: [x64] 537 595 os: [linux] 596 + libc: [glibc] 538 597 539 598 '@oxlint/binding-linux-x64-musl@1.71.0': 540 599 resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} 541 600 engines: {node: ^20.19.0 || >=22.12.0} 542 601 cpu: [x64] 543 602 os: [linux] 603 + libc: [musl] 544 604 545 605 '@oxlint/binding-openharmony-arm64@1.71.0': 546 606 resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} ··· 638 698 engines: {node: ^20.19.0 || >=22.12.0} 639 699 cpu: [arm64] 640 700 os: [linux] 701 + libc: [glibc] 641 702 642 703 '@rolldown/binding-linux-arm64-gnu@1.1.5': 643 704 resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} 644 705 engines: {node: ^20.19.0 || >=22.12.0} 645 706 cpu: [arm64] 646 707 os: [linux] 708 + libc: [glibc] 647 709 648 710 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': 649 711 resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} 650 712 engines: {node: ^20.19.0 || >=22.12.0} 651 713 cpu: [arm64] 652 714 os: [linux] 715 + libc: [musl] 653 716 654 717 '@rolldown/binding-linux-arm64-musl@1.1.5': 655 718 resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} 656 719 engines: {node: ^20.19.0 || >=22.12.0} 657 720 cpu: [arm64] 658 721 os: [linux] 722 + libc: [musl] 659 723 660 724 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': 661 725 resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} 662 726 engines: {node: ^20.19.0 || >=22.12.0} 663 727 cpu: [ppc64] 664 728 os: [linux] 729 + libc: [glibc] 665 730 666 731 '@rolldown/binding-linux-ppc64-gnu@1.1.5': 667 732 resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} 668 733 engines: {node: ^20.19.0 || >=22.12.0} 669 734 cpu: [ppc64] 670 735 os: [linux] 736 + libc: [glibc] 671 737 672 738 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': 673 739 resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} 674 740 engines: {node: ^20.19.0 || >=22.12.0} 675 741 cpu: [s390x] 676 742 os: [linux] 743 + libc: [glibc] 677 744 678 745 '@rolldown/binding-linux-s390x-gnu@1.1.5': 679 746 resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} 680 747 engines: {node: ^20.19.0 || >=22.12.0} 681 748 cpu: [s390x] 682 749 os: [linux] 750 + libc: [glibc] 683 751 684 752 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': 685 753 resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} 686 754 engines: {node: ^20.19.0 || >=22.12.0} 687 755 cpu: [x64] 688 756 os: [linux] 757 + libc: [glibc] 689 758 690 759 '@rolldown/binding-linux-x64-gnu@1.1.5': 691 760 resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} 692 761 engines: {node: ^20.19.0 || >=22.12.0} 693 762 cpu: [x64] 694 763 os: [linux] 764 + libc: [glibc] 695 765 696 766 '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': 697 767 resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} 698 768 engines: {node: ^20.19.0 || >=22.12.0} 699 769 cpu: [x64] 700 770 os: [linux] 771 + libc: [musl] 701 772 702 773 '@rolldown/binding-linux-x64-musl@1.1.5': 703 774 resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} 704 775 engines: {node: ^20.19.0 || >=22.12.0} 705 776 cpu: [x64] 706 777 os: [linux] 778 + libc: [musl] 707 779 708 780 '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': 709 781 resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} ··· 775 847 '@types/node@22.20.1': 776 848 resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} 777 849 850 + '@types/node@26.1.1': 851 + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} 852 + 778 853 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': 779 854 resolution: {integrity: sha512-8AX9NwC+G6Sbh5hNLnx8YgxoRV/8BH8FQRtZ86OTtUQfESMRvwszOGTtfcC32g86O5jsEQPDHXKVSnsIzWh6lg==} 780 855 engines: {node: '>=16.20.0'} ··· 870 945 resolution: {integrity: sha512-fbxg3cBPdJ++36DXtdzcoKw2xzFov91Wxvmn1khX9MXQbDqJQLJmITZhtokcZsj4uGJe32sUmxAZnKbUtZLjmA==} 871 946 cpu: [arm] 872 947 os: [linux] 948 + libc: [glibc] 873 949 874 950 '@yuku-codegen/binding-linux-arm-musl@0.6.3': 875 951 resolution: {integrity: sha512-Jk4P7kocGEisSvUFIm1VuHO3hC01LvS3sYAAmVVu1/ve5TuZ0iXyl9kIGtd1ZrgUvchgvZWNOaB+/Kq/RO63FA==} 876 952 cpu: [arm] 877 953 os: [linux] 954 + libc: [musl] 878 955 879 956 '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': 880 957 resolution: {integrity: sha512-i1xE8Bx1YZLheWtBZHD0Mq3nAIDrhgiH7o8VB4GiCbHufKb4XKj4CqSDMWSC0RYPmn//E+UEd1NsE2NbOux1tQ==} 881 958 cpu: [arm64] 882 959 os: [linux] 960 + libc: [glibc] 883 961 884 962 '@yuku-codegen/binding-linux-arm64-musl@0.6.3': 885 963 resolution: {integrity: sha512-ZeLkC6xZrlDoIJTadHfqTABTmsj2f6wCCtYBYx/RPGgdmQcGLA0NALRl7m0tnK2CT45eNRuOYzsfEZyF0XWM/A==} 886 964 cpu: [arm64] 887 965 os: [linux] 966 + libc: [musl] 888 967 889 968 '@yuku-codegen/binding-linux-x64-gnu@0.6.3': 890 969 resolution: {integrity: sha512-HNYt7zjIChPcnjZRG42CZq3Zn5mqaRo4UcFLr4mIbmGqdhX5hDDE8O8US8YgYyOUosfbBTBFdsbvFwVAx1TOkQ==} 891 970 cpu: [x64] 892 971 os: [linux] 972 + libc: [glibc] 893 973 894 974 '@yuku-codegen/binding-linux-x64-musl@0.6.3': 895 975 resolution: {integrity: sha512-/1ttT31dAQc7hGtXWSEYEgzGtakAyO2C+/GqAzIuKXlGLpNPZgXdR8LZ0iDHDalbEr3AuHIPRV43sWLUrHWdsA==} 896 976 cpu: [x64] 897 977 os: [linux] 978 + libc: [musl] 898 979 899 980 '@yuku-codegen/binding-win32-arm64@0.6.3': 900 981 resolution: {integrity: sha512-oAArRDU1lkKg+xFEtiQ7C+/wghpqrkBrFWM05W73S+3sZz8JIHH79Q+Qh5gWls3i8vccitUgCnvln5V7xKn3XQ==} ··· 925 1006 resolution: {integrity: sha512-/4LzmPXPaCWqIpY1j3+XVb8rbXIratqCte3A4sGEjua6aVhvVxEVAqeKlBsoGkORWLeqbpcxhgxKwOGM17eexA==} 926 1007 cpu: [arm] 927 1008 os: [linux] 1009 + libc: [glibc] 928 1010 929 1011 '@yuku-parser/binding-linux-arm-musl@0.6.3': 930 1012 resolution: {integrity: sha512-Df4jk0M/eNKKQfYzXBBKhKkmJBpB+XoX2LkMxmlK3GN+fxUdeb8EM78wX+1+eLVl5dZNo6f7gOd6oDV0gChevw==} 931 1013 cpu: [arm] 932 1014 os: [linux] 1015 + libc: [musl] 933 1016 934 1017 '@yuku-parser/binding-linux-arm64-gnu@0.6.3': 935 1018 resolution: {integrity: sha512-sRCtDktUgIbbV78SYX3wdGVVm1Hz/nSUS24JgXB4MzUeGNwBNB+eQAWMtBxCGriyJNXK3zwfj+SSgvTmUdPf/A==} 936 1019 cpu: [arm64] 937 1020 os: [linux] 1021 + libc: [glibc] 938 1022 939 1023 '@yuku-parser/binding-linux-arm64-musl@0.6.3': 940 1024 resolution: {integrity: sha512-3J/jV3ROSqlhLyB/6i5EUHxjkom5i59iPvrtiAsnAjHzMsZJJPEke9LSaOsB0rf4MJFH9AmjvsK8gDahTjZy+A==} 941 1025 cpu: [arm64] 942 1026 os: [linux] 1027 + libc: [musl] 943 1028 944 1029 '@yuku-parser/binding-linux-x64-gnu@0.6.3': 945 1030 resolution: {integrity: sha512-a5mPn/OMSq2Aa2i7eJXcc37Jtw0b89gDO2mDpXN769b06IirEiqOzLNNJd6R7R8DxoadHzY0nLFhYNChE+jyAg==} 946 1031 cpu: [x64] 947 1032 os: [linux] 1033 + libc: [glibc] 948 1034 949 1035 '@yuku-parser/binding-linux-x64-musl@0.6.3': 950 1036 resolution: {integrity: sha512-kQSjfa6zdvotuXEGNKQ9vZZxE+lcEEbTUMSuvY/5+0crlwBCjEqrf9/W9ViFDoQAEt8WJiu/mtVNYkKAOkjLmA==} 951 1037 cpu: [x64] 952 1038 os: [linux] 1039 + libc: [musl] 953 1040 954 1041 '@yuku-parser/binding-win32-arm64@0.6.3': 955 1042 resolution: {integrity: sha512-ZawdN3R0YKr48BeXCpbax+WDWbgEG6nWyDosVeZasrT5TjgfF4XMP5SfuyMNvRJ4gbTitrcYHZkENSXZ9ncqcg==} ··· 1102 1189 engines: {node: '>= 12.0.0'} 1103 1190 cpu: [arm64] 1104 1191 os: [linux] 1192 + libc: [glibc] 1105 1193 1106 1194 lightningcss-linux-arm64-musl@1.32.0: 1107 1195 resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 1108 1196 engines: {node: '>= 12.0.0'} 1109 1197 cpu: [arm64] 1110 1198 os: [linux] 1199 + libc: [musl] 1111 1200 1112 1201 lightningcss-linux-x64-gnu@1.32.0: 1113 1202 resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 1114 1203 engines: {node: '>= 12.0.0'} 1115 1204 cpu: [x64] 1116 1205 os: [linux] 1206 + libc: [glibc] 1117 1207 1118 1208 lightningcss-linux-x64-musl@1.32.0: 1119 1209 resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} 1120 1210 engines: {node: '>= 12.0.0'} 1121 1211 cpu: [x64] 1122 1212 os: [linux] 1213 + libc: [musl] 1123 1214 1124 1215 lightningcss-win32-arm64-msvc@1.32.0: 1125 1216 resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} ··· 1187 1278 picocolors@1.1.1: 1188 1279 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 1189 1280 1190 - picomatch@4.0.4: 1191 - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} 1192 - engines: {node: '>=12'} 1193 - 1194 1281 picomatch@4.0.5: 1195 1282 resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} 1196 1283 engines: {node: '>=12'} ··· 1344 1431 1345 1432 undici-types@6.21.0: 1346 1433 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 1434 + 1435 + undici-types@8.3.0: 1436 + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} 1347 1437 1348 1438 unrun@0.2.39: 1349 1439 resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} ··· 1517 1607 - vite-plus 1518 1608 - vue-tsc 1519 1609 1610 + '@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))': 1611 + dependencies: 1612 + '@bomb.sh/args': 0.3.1 1613 + '@humanfs/node': 0.16.8 1614 + '@humanfs/types': 0.15.0 1615 + '@typescript/native-preview': 7.0.0-dev.20260623.1 1616 + knip: 6.18.0 1617 + oxfmt: 0.47.0 1618 + oxlint: 1.71.0 1619 + publint: 0.3.21 1620 + tinyexec: 1.2.4 1621 + 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) 1622 + ultramatter: 0.0.4 1623 + 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)) 1624 + 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))) 1625 + transitivePeerDependencies: 1626 + - '@arethetypeswrong/core' 1627 + - '@edge-runtime/vm' 1628 + - '@opentelemetry/api' 1629 + - '@ts-macro/tsc' 1630 + - '@tsdown/css' 1631 + - '@tsdown/exe' 1632 + - '@types/node' 1633 + - '@vitejs/devtools' 1634 + - '@vitest/browser-playwright' 1635 + - '@vitest/browser-preview' 1636 + - '@vitest/browser-webdriverio' 1637 + - '@vitest/coverage-istanbul' 1638 + - '@vitest/coverage-v8' 1639 + - '@vitest/ui' 1640 + - happy-dom 1641 + - jsdom 1642 + - msw 1643 + - oxc-resolver 1644 + - oxlint-tsgolint 1645 + - tsx 1646 + - typescript 1647 + - unplugin-unused 1648 + - unrun 1649 + - vite 1650 + - vite-plus 1651 + - vue-tsc 1652 + 1520 1653 '@bomb.sh/tty@0.8.0': {} 1521 1654 1522 1655 '@clack/core@1.4.3': ··· 1995 2128 dependencies: 1996 2129 undici-types: 6.21.0 1997 2130 2131 + '@types/node@26.1.1': 2132 + dependencies: 2133 + undici-types: 8.3.0 2134 + 1998 2135 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': 1999 2136 optional: true 2000 2137 ··· 2042 2179 magic-string: 0.30.21 2043 2180 optionalDependencies: 2044 2181 vite: 8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) 2182 + 2183 + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0))': 2184 + dependencies: 2185 + '@vitest/spy': 4.1.9 2186 + estree-walker: 3.0.3 2187 + magic-string: 0.30.21 2188 + optionalDependencies: 2189 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) 2045 2190 2046 2191 '@vitest/pretty-format@4.1.9': 2047 2192 dependencies: ··· 2177 2322 dependencies: 2178 2323 walk-up-path: 4.0.0 2179 2324 2180 - fdir@6.5.0(picomatch@4.0.4): 2325 + fdir@6.5.0(picomatch@4.0.5): 2181 2326 optionalDependencies: 2182 - picomatch: 4.0.4 2327 + picomatch: 4.0.5 2183 2328 2184 2329 formatly@0.3.0: 2185 2330 dependencies: ··· 2204 2349 2205 2350 knip@6.18.0: 2206 2351 dependencies: 2207 - fdir: 6.5.0(picomatch@4.0.4) 2352 + fdir: 6.5.0(picomatch@4.0.5) 2208 2353 formatly: 0.3.0 2209 2354 get-tsconfig: 4.14.0 2210 2355 jiti: 2.7.0 2211 2356 oxc-parser: 0.137.0 2212 2357 oxc-resolver: 11.21.3 2213 - picomatch: 4.0.4 2358 + picomatch: 4.0.5 2214 2359 smol-toml: 1.7.0 2215 2360 strip-json-comments: 5.0.3 2216 2361 tinyglobby: 0.2.17 ··· 2376 2521 2377 2522 picocolors@1.1.1: {} 2378 2523 2379 - picomatch@4.0.4: {} 2380 - 2381 2524 picomatch@4.0.5: {} 2382 2525 2383 2526 postcss@8.5.15: ··· 2480 2623 2481 2624 tinyglobby@0.2.17: 2482 2625 dependencies: 2483 - fdir: 6.5.0(picomatch@4.0.4) 2484 - picomatch: 4.0.4 2626 + fdir: 6.5.0(picomatch@4.0.5) 2627 + picomatch: 4.0.5 2485 2628 2486 2629 tinypool@2.1.0: {} 2487 2630 ··· 2529 2672 2530 2673 undici-types@6.21.0: {} 2531 2674 2675 + undici-types@8.3.0: {} 2676 + 2532 2677 unrun@0.2.39: 2533 2678 dependencies: 2534 2679 rolldown: 1.0.0-rc.17 ··· 2547 2692 jiti: 2.7.0 2548 2693 yaml: 2.9.0 2549 2694 2695 + vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0): 2696 + dependencies: 2697 + lightningcss: 1.32.0 2698 + picomatch: 4.0.5 2699 + postcss: 8.5.15 2700 + rolldown: 1.1.5 2701 + tinyglobby: 0.2.17 2702 + optionalDependencies: 2703 + '@types/node': 26.1.1 2704 + fsevents: 2.3.3 2705 + jiti: 2.7.0 2706 + yaml: 2.9.0 2707 + 2550 2708 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))): 2551 2709 dependencies: 2552 2710 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 2723 magic-string: 0.30.21 2566 2724 obug: 2.1.3 2567 2725 pathe: 2.0.3 2568 - picomatch: 4.0.4 2726 + picomatch: 4.0.5 2569 2727 std-env: 4.1.0 2570 2728 tinybench: 2.9.0 2571 2729 tinyexec: 1.2.4 ··· 2575 2733 why-is-node-running: 2.3.0 2576 2734 optionalDependencies: 2577 2735 '@types/node': 22.20.1 2736 + transitivePeerDependencies: 2737 + - msw 2738 + 2739 + 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)): 2740 + dependencies: 2741 + '@vitest/expect': 4.1.9 2742 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0)) 2743 + '@vitest/pretty-format': 4.1.9 2744 + '@vitest/runner': 4.1.9 2745 + '@vitest/snapshot': 4.1.9 2746 + '@vitest/spy': 4.1.9 2747 + '@vitest/utils': 4.1.9 2748 + es-module-lexer: 2.1.0 2749 + expect-type: 1.3.0 2750 + magic-string: 0.30.21 2751 + obug: 2.1.3 2752 + pathe: 2.0.3 2753 + picomatch: 4.0.5 2754 + std-env: 4.1.0 2755 + tinybench: 2.9.0 2756 + tinyexec: 1.2.4 2757 + tinyglobby: 0.2.17 2758 + tinyrainbow: 3.1.0 2759 + vite: 8.1.0(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.0) 2760 + why-is-node-running: 2.3.0 2761 + optionalDependencies: 2762 + '@types/node': 26.1.1 2578 2763 transitivePeerDependencies: 2579 2764 - msw 2580 2765