diff --git a/README.md b/README.md
index 437d647..1280466 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,10 @@ pnpm install
pnpm playground # returns an example selector
```
+## Projects
+
+[`@bomb.sh/dom`](packages/dom/README.md) is a headless interactive DOM.
+
## Support
Having trouble? Get help in the official [Bombshell Discord][discord].
diff --git a/package.json b/package.json
index ddb03e6..6ad1388 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint .",
- "test": "bsh test --exclude 'experiments/ghostwright/**'"
+ "test": "pnpm --filter @bomb.sh/dom build && bsh test --exclude 'experiments/ghostwright/**' --exclude '**/node_modules/**' --exclude '**/dist/**'"
},
"devDependencies": {
"@bomb.sh/args": "catalog:",
diff --git a/packages/dom/LICENSE b/packages/dom/LICENSE
new file mode 100644
index 0000000..8272839
--- /dev/null
+++ b/packages/dom/LICENSE
@@ -0,0 +1,21 @@
+MIT License Copyright (c) 2025-Present [Bombshell contributors](https://bomb.sh/team)
+
+Permission is hereby granted, free of
+charge, to any person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice
+(including the next paragraph) shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/packages/dom/README.md b/packages/dom/README.md
new file mode 100644
index 0000000..75432c2
--- /dev/null
+++ b/packages/dom/README.md
@@ -0,0 +1,121 @@
+# @bomb.sh/dom
+
+A lightweight, headless, in-memory DOM for interactive applications. Documents own their trees, focus, selection, and custom-element registries. Importing the package installs no globals. Terminal rendering, layout, input decoding, frame scheduling, and output backpressure belong to consumers.
+
+This is an independently maintained implementation with a tested subset of DOM and HTML behavior. See the [capability matrix](docs/capabilities.md) for supported scope and unfinished work, and [provenance](docs/provenance.md) for the pinned references, licenses, and baseline differences.
+
+## Construct and observe a document
+
+```ts
+import { Document, MutationObserver } from '@bomb.sh/dom';
+
+const document = new Document({
+ onInvalidate(change) {
+ // Ask your renderer for a frame; coalesce and handle backpressure there.
+ console.info(change.kind); // mutation, focus, or selection
+ },
+ reportError(error) {
+ console.error(error); // Listener / custom-element callback exceptions
+ },
+});
+const root = document.createElement('box');
+const label = document.createElement('text');
+label.textContent = 'Ready';
+root.append(label); // Detached preparation requests no frame.
+document.append(root); // Explicit shape: use "document" here for a Clack adapter.
+
+const observer = new MutationObserver((records) => {
+ console.info(records.map((record) => record.type));
+});
+observer.observe(label, { childList: true, characterData: true, subtree: true });
+label.firstChild!.textContent = 'Running';
+// Observer records are delivered in a microtask, independently of repainting.
+// Explicitly observing a detached node works too.
+```
+
+Documents start empty. `documentElement` is nullable. Append one root element; document fragments allow detached preparation. Nodes move on insertion and are adopted when moved across documents. Child, element, and attribute collections are live read-only array views; mutation goes through node/attribute methods.
+
+## Events and host input
+
+```ts
+import { Event, Dispatch } from '@bomb.sh/dom';
+
+root.addEventListener(
+ 'activate',
+ (event) => {
+ event.preventDefault();
+ },
+ { capture: true },
+);
+const accepted = label.dispatchEvent(
+ new Event('activate', {
+ bubbles: true,
+ cancelable: true,
+ }),
+); // false
+
+const input = document.createElement('input');
+const next = document.createElement('button');
+root.append(input, next);
+input.focus();
+const dispatch = new Dispatch(document); // document.dispatch is also available.
+dispatch.keydown({ key: 'Tab' }); // Route to focus, then navigate if uncanceled.
+dispatch.keyup({ key: 'Tab' });
+```
+
+Event names preserve case. Capture, target, bubbling, cancellation, listener snapshots, once/passive/signal options, listener objects, and shadow retargeting are exercised by unit tests and selected upstream WPT cases. Listener exceptions are reported without escaping dispatch. The default error reporter queues an uncaught exception; hosts can provide `reportError`.
+
+`element.dispatchEvent()` delivers a synthetic event. It does not move focus, edit text, or perform browser default actions. `Dispatch` is the explicit host-input boundary and implements logical Tab navigation; its dispatched events are still untrusted. See [focus and controls](docs/focus-and-controls.md).
+
+## Two independent selection systems
+
+```ts
+input.value = 'a😀b';
+input.setSelectionRange(1, 3, 'forward'); // UTF-16 code units
+input.setRangeText('x', 1, 3, 'select');
+
+const range = document.createRange();
+range.selectNodeContents(label);
+document.getSelection().addRange(range);
+console.info(document.getSelection().toString());
+```
+
+Ranges adjust when text or the tree changes. Element offsets count child nodes. These APIs do not measure graphemes, terminal columns, or coordinates. See [selection and observation](docs/selection-observation.md) for the exact API subset.
+
+## Custom elements and opt-in parsing
+
+```ts
+import { installParsing } from '@bomb.sh/dom/parsing';
+installParsing(document);
+
+class PromptLabel extends document.HTMLElement {
+ connectedCallback(): void {
+ if (!this.shadowRoot) {
+ this.attachShadow({ mode: 'open' }).innerHTML = '';
+ }
+ }
+}
+document.customElements.define('prompt-label', PromptLabel);
+const prompt = document.createElement('prompt-label');
+prompt.append('Continue?');
+root.append(prompt);
+```
+
+Each environment has an explicit `document.HTMLElement` base and registry. Autonomous custom elements support direct construction and identity-preserving upgrades, observed attributes, connection/disconnection/adoption callbacks, and reentrancy. Parsing is opt-in; scripts remain inert. Template contents are separate, inert trees. Shadow roots and slots compose events and focus without a rendering engine.
+
+`parse5` is the only production dependency. The separate `@bomb.sh/dom/parsing` entry uses its HTML tokenizer/tree builder, then creates nodes in this implementation. The core entry does not load it. No LinkeDOM runtime, second DOM hierarchy, framework, browser process, or worker is required.
+
+## Development
+
+From the playground root:
+
+```sh
+pnpm install
+pnpm --filter @bomb.sh/dom build
+pnpm --filter @bomb.sh/dom typecheck
+pnpm --filter @bomb.sh/dom exec vitest run
+pnpm --filter @bomb.sh/dom format:check
+pnpm --filter @bomb.sh/dom lint
+```
+
+The package emits ESM JavaScript, declaration files, and maps through the playground's TypeScript compiler. Core checking uses strict TypeScript without the browser DOM library. JSX and upstream browser declaration requirements stay local to examples. No publishing is performed by these commands.
diff --git a/packages/dom/docs/capabilities.md b/packages/dom/docs/capabilities.md
new file mode 100644
index 0000000..7574a58
--- /dev/null
+++ b/packages/dom/docs/capabilities.md
@@ -0,0 +1,35 @@
+# Capability matrix
+
+Statuses describe this implementation and its checked-in tests. “Implemented and tested” means the scope in that row, not complete conformance to an entire specification. The full DOM/HTML/Web Components WPT suites have not been run.
+
+| Area | Status | Supported scope / remaining boundary |
+| ---------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Packaging | Implemented and tested | Strict TypeScript, ESM-only exports and declarations; independent documents; no process globals, browser or worker dependency. |
+| Tree and mutation | Implemented and tested | Element, Text, Comment, Attr, Document, DocumentFragment; arbitrary element names; moves, prevalidation, siblings, ownership, connectivity, cloning/import/adoption, textContent, HTML/SVG/MathML namespaces. Live read-only array views protect collections. |
+| Typed element names | Implemented and tested | `Elements` declaration merging; input/textarea/template/slot return actual specialized classes. Application declarations must match registered classes. |
+| Host invalidation | Implemented and tested | Connected changes only; detach invalidates the old document; focus and selection changes observable; listeners/dispatch alone do not invalidate; host owns scheduling. |
+| MutationObserver | Implemented and tested | Target/subtree options, detached targets, old values, filters, transient removed-subtree observation, microtask delivery, takeRecords/disconnect, aggregate fragment/replacement records. |
+| Events | Implemented and tested | Capture/target/bubble, stable paths, stop boundaries, cancellation, dedup/removal/snapshots, reuse/nesting, once/passive/AbortSignal, listener objects/error reporting, case preservation, extensible objects. 20 unchanged pinned event WPT cases pass without skips. |
+| Focus and host dispatch | Implemented and tested | Eligibility, focus/blur transitions and related targets, removal/reentrancy, logical tabindex navigation, shadow focus/delegation, separate host keydown/keyup entry. |
+| Text controls | Implemented and tested | Input/textarea values and UTF-16 selection, directions/clamping, select/setSelectionRange/setRangeText, dirty values, value cloning, and observable selection. |
+| Document ranges/selection | Implemented and tested | Live boundaries, comparison, collapse/extend, direction, traversal/stringification, clone/extract/delete contents; insertion/removal/text/split updates. |
+| Autonomous custom elements | Implemented and tested | Per-document base/registry, construction, define/get/whenDefined/upgrade, preserved existing identity/listeners/user state, lifecycle and observed attributes, adoption, callback errors/reentrancy. |
+| Templates and parsing | Implemented and tested | Opt-in parse5 HTML fragments, inert nested templates, namespaces/comments, cloning/importing, basic serialization, TreeWalker nextNode/parentNode and filtering. |
+| Shadow trees and slots | Implemented and tested | Open/closed roots, named/default/fallback slot distribution, slotchange, shadow-including connectivity/adoption, event retargeting/closed composed paths, focus interactions. |
+| Remaining Range/Selection APIs | Planned but unfinished | Range.insertNode/surroundContents/createContextualFragment, StaticRange, normalize-related updates, composed/shadow document selection and additional full-suite coverage. APIs absent from declarations until implemented. |
+| Further custom-element/shadow APIs | Planned but unfinished | Unicode custom-element names, customized built-ins, scoped registries, form-associated elements/ElementInternals, declarative shadow DOM, manual slot assignment, cloneable/serializable roots, complete specification edge-case coverage. |
+| Further DOM facilities | Planned but unfinished | CSS selector matching, complete traversal API, NodeList/NamedNodeMap legacy methods, DOMTokenList/style declaration model, DocumentType/processing instructions/XML documents and XML parsing, complete HTML serialization (including raw-text script/style serialization). |
+| Further interaction | Planned but unfinished | Full browser focus-navigation/radio-group behavior, editing/activation defaults, broader form values/validation and input sanitization. See focus documentation. |
+| Rendering and terminal I/O | Intentionally unsupported | CSS layout, glyph/caret painting, ANSI, keyboard-byte decoding, hit testing, coordinates, frame coalescing/output backpressure. Consumer responsibilities. |
+| Browser subsystems | Intentionally unsupported | Canvas, media, navigation, networking, browser script execution, trusted user input, browser windows or rendering processes. |
+| Legacy distribution/global setup | Intentionally unsupported | CommonJS builds, worker bundles/infrastructure, implicit global document/window/constructor installation, decorators as a requirement. |
+
+## Milestone record
+
+1. The pinned DOM/event baseline established the initial tree and event regression coverage.
+2. Independent host invalidation, MutationObserver, focus/Dispatch, and text controls followed.
+3. Live Range/Selection tests cover both ordinary edits and detached-tree mutations.
+4. Custom elements, inert templates, parsing, shadow roots, and slots have focused unit tests and custom-element browser differential cases.
+5. Framework integrations and their acceptance tests are maintained separately from the core package.
+
+Further work should expand this matrix only after adding meaningful tests and accurate declarations. No unsupported browser API is advertised through structural casts or global interface replacement.
diff --git a/packages/dom/docs/events-provenance.md b/packages/dom/docs/events-provenance.md
new file mode 100644
index 0000000..6fb2cf5
--- /dev/null
+++ b/packages/dom/docs/events-provenance.md
@@ -0,0 +1,58 @@
+# Events: references and boundaries
+
+The event implementation is independently authored. No LinkeDOM implementation is
+transplanted. Its regression requirements were checked against the read-only
+[`@clack/ui` baseline](https://github.com/bombshell-dev/ui/blob/606675f16131c6ae7c4553a67ab477bdf661f772/packages/ui/src/dom.ts)
+at `606675f16131c6ae7c4553a67ab477bdf661f772`, including `dom.test.ts`. The pinned
+source was inspected through `git show` in the existing local checkout.
+
+The [WHATWG DOM event dispatch and invoke algorithms](https://dom.spec.whatwg.org/#concept-event-dispatch)
+govern phase order, callback receivers, cancellation, registration removal,
+per-invocation snapshots, cleanup, and listener exception handling. In particular,
+target capture and target non-capture are separate invocations: a propagation
+stop during target capture prevents the non-capture invocation. Additions can
+join a later invocation but cannot join the current snapshot. Cancellation
+persists on reuse; stop flags do not.
+
+Relevant WPT cases inspected:
+
+- [`Event-dispatch-order.html`](https://github.com/web-platform-tests/wpt/blob/master/dom/events/Event-dispatch-order.html): ancestor capture precedes target delivery and ancestor bubbling.
+- [`EventTarget-dispatchEvent.html`](https://github.com/web-platform-tests/wpt/blob/master/dom/events/EventTarget-dispatchEvent.html): active events reject redispatch; listener exceptions are reported while delivery continues.
+- [`event-composed-path.html`](https://github.com/web-platform-tests/wpt/blob/master/shadow-dom/event-composed-path.html), [`event-composed.html`](https://github.com/web-platform-tests/wpt/blob/master/shadow-dom/event-composed.html), and [`event-with-related-target.html`](https://github.com/web-platform-tests/wpt/blob/master/shadow-dom/event-with-related-target.html): open/closed visibility, shadow boundaries, and related-target retargeting.
+
+The local tests independently exercise these cases and additional registration,
+abort, passive, object-listener, mutation-during-dispatch, and reuse scenarios.
+
+An additional [Vitest adapter](../test/wpt-events.test.ts) executes all 20 cases
+from three unchanged upstream WPT files: `AddEventListenerOptions-once.any.js`,
+`AddEventListenerOptions-passive.any.js`, and
+`AddEventListenerOptions-signal.any.js`. They are pinned to commit
+`b4b303c8dca22989c43e2d76fce199c36d4b77a7` with the upstream BSD license and
+[provenance](../test/wpt/README.md). Every case passes; none are skipped.
+These tests found and corrected options-dictionary conversion occurring too
+late for null listeners and acceptance of invalid null abort signals. This is
+a selected upstream run, not a run of the complete WPT browser harness.
+
+## Deliberate corrections to the baseline
+
+The baseline allows listener exceptions to escape `dispatchEvent`, terminating
+delivery. This package reports them through the document/target error reporter,
+continues delivery, and returns the cancellation result. The standalone default
+reports an uncaught exception in a microtask; hosts can supply `reportError`.
+`stopImmediatePropagation()` sets both stop flags. Event dispatch has its own
+active flag, including before listener invocation, rather than inferring it from
+`eventPhase`. Neither listener registration nor dispatch requests a host frame.
+
+## Current scope
+
+`Event`, `CustomEvent`, `FocusEvent`, and `KeyboardEvent` are synthetic event
+classes; `isTrusted` is always false. Keyboard events expose key/code, location,
+the four primary modifiers, repeat, and composition status. They do not execute
+browser default actions. Legacy `initEvent`, UIEvent view/detail, legacy keyCode,
+mouse/pointer events, activation behavior, and window-global `event` are not
+implemented. Tree hooks capture retargeting and closed-root information with
+the event path. Targeted tests cover open/closed and nested closed roots,
+non-bubbling composed host delivery, related-target trimming, slotted light-tree
+events, and mutation-stable retargeting. This is not a claim that all shadow WPT
+cases pass. Native `AbortSignal.any()` supplies a private dependent signal so
+listener removal survives propagation stops on the source signal's abort event.
diff --git a/packages/dom/docs/focus-and-controls.md b/packages/dom/docs/focus-and-controls.md
new file mode 100644
index 0000000..3430142
--- /dev/null
+++ b/packages/dom/docs/focus-and-controls.md
@@ -0,0 +1,79 @@
+# Focus and text controls
+
+Each `Document` owns its own `FocusManager` and `Dispatch`. `element.focus()`
+moves logical focus; `blur()` releases it. `document.activeElement` retargets
+shadow focus to the host, while `shadowRoot.activeElement` exposes the focused
+element in that root. `document.focusManager.activeElement` is the deepest
+focused element, useful to input drivers. With no explicit focus, a document's
+`activeElement` falls back to its body or root element.
+
+Focus transitions deliver `blur`, `focusout`, `focus`, and `focusin`, carrying
+the opposite element as `relatedTarget`. Repeated focus is a no-op. Removal
+clears focus without generating blur events. Nested transitions supersede stale
+outer transitions. Focus changes invalidate the connected document.
+
+The implementation follows the
+[HTML focus processing model](https://html.spec.whatwg.org/multipage/interaction.html#focus-update-steps)
+where applicable to a headless environment. Eligibility includes native controls,
+links with `href`, valid `tabindex`, and editable elements. Disconnected,
+hidden, inert, disabled, and hidden-type input elements cannot receive focus.
+Disabled fieldsets honor the first-legend exception. There is no CSS visibility,
+geometry, platform focus preference, browsing-context, or modal-dialog model.
+
+Logical Tab order sorts positive `tabindex` values within each document,
+shadow-host, and slot scope, then flattens those scopes. Zero/default values keep
+tree order within their scope. Negative scope owners omit their subtree from
+sequential navigation while allowing programmatic focus. Navigation wraps within
+the document. Shadow delegation preserves an existing delegate and respects
+nested delegation boundaries. Browser chrome, popover/navigation-context scopes,
+radio-group behavior, and platform focus preferences remain unfinished.
+
+```ts
+import { Document, Dispatch } from '@bomb.sh/dom';
+
+const document = new Document();
+const box = document.createElement('box');
+const input = document.createElement('input');
+const button = document.createElement('button');
+document.append(box);
+box.append(input, button);
+
+input.focus();
+const dispatch = new Dispatch(document);
+dispatch.keydown({ key: 'Tab' }); // Routes keydown to input, then focuses button.
+dispatch.keyup({ key: 'Tab' }); // Routes keyup to the newly focused button.
+```
+
+`Dispatch.keydown()` and `keyup()` accept host data and construct synthetic
+`KeyboardEvent` objects. Uncanceled Tab performs explicit logical navigation.
+The DOM event remains untrusted. Dispatching a keyboard or focus event directly
+never performs focus navigation, text editing, activation, or other default
+actions. Byte decoding belongs to the host.
+
+Input and textarea value selection is separate from document `Selection`.
+The [HTML text selection algorithms](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection)
+define UTF-16 offsets, clamping, direction, and replacement modes. The package
+implements `selectionStart`, `selectionEnd`, `selectionDirection`, `select()`,
+`setSelectionRange()`, and `setRangeText()`. Input types without text selection
+return null selection properties and reject selection setters. Textareas
+normalize CRLF; text inputs remove newlines. Dirty values stay separate from
+default values. Selection events are queued as tasks and host invalidation is
+synchronous. Programmatic value changes do not fire input/change events.
+
+```ts
+input.value = 'a😀b';
+input.setSelectionRange(1, 3, 'forward'); // The emoji occupies two UTF-16 units.
+input.setRangeText('x', 1, 3, 'select');
+```
+
+Cloning and importing copy text-control values and dirty-value flags, following
+the HTML [input cloning](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
+and [textarea cloning](https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element)
+steps. Clones have their own selection and focus state; caret positions and
+listeners are not copied. A shallow pristine textarea clone retains the current
+value despite having no default-value children. Moving/adopting the existing
+element preserves its selection and value.
+
+Form submission, validity, input-type-specific value sanitization beyond newline
+handling, native editing actions, and select/option value models are unfinished.
+No caret geometry, painting, or terminal width calculation is included.
diff --git a/packages/dom/docs/provenance.md b/packages/dom/docs/provenance.md
new file mode 100644
index 0000000..f5541ef
--- /dev/null
+++ b/packages/dom/docs/provenance.md
@@ -0,0 +1,50 @@
+# Source provenance
+
+This package is independently maintained. It does not depend on LinkeDOM or wrap a second DOM tree.
+
+## Bombshell baseline
+
+Read-only reference: [bombshell-dev/ui at `606675f16131c6ae7c4553a67ab477bdf661f772`](https://github.com/bombshell-dev/ui/tree/606675f16131c6ae7c4553a67ab477bdf661f772).
+
+Inspected files: `packages/ui/src/dom.ts`, `dom.test.ts`, `ui.ts`, `packages/ui-preact/src/root.ts`, `root.test.tsx`, `elements.ts`, `jsx-runtime.ts`, and `jsx-dev-runtime.ts`.
+
+The core DOM/event regression tests preserve useful baseline behavior while covering the standards differences below. The upstream license is MIT, copyright (c) 2025-Present Bombshell contributors; its permission notice is retained in this package's LICENSE.
+
+The existing terminal renderer and its dependencies remain consumer concerns. `ui.ts` demonstrates why connected-document invalidation is separate from output scheduling: its consumer requests frames, coalesces them, and awaits writes independently of tree mutation.
+
+## LinkeDOM evaluation
+
+Read-only reference: [WebReflection/linkedom at `e2ee20813e4b3ae946d7ba41621ddf3698431989`](https://github.com/WebReflection/linkedom/tree/e2ee20813e4b3ae946d7ba41621ddf3698431989), inspected 2026-10-21. Upstream is ISC licensed, copyright (c) 2021 Andrea Giammarchi, @WebReflection.
+
+The registry (`esm/interface/custom-element-registry.js`), HTML element constructor (`esm/html/element.js`), template element (`esm/html/template-element.js`), shadow root (`esm/interface/shadow-root.js`), and shared symbols were evaluated as a coherent tree-dependent slice. The registry traverses the linked tree through NEXT/END sentinels and coordinates construction through UPGRADE. It also has module-level constructor and reaction state. Templates coordinate CONTENT/PRIVATE symbols with parsing and cloning. The registry and templates cannot safely be transplanted independently into this package's tree.
+
+No LinkeDOM source has been copied into this package. It supplies design references, not runtime objects. In particular, upstream's upgrade strategy of removing attributes and enumerable properties before construction is not adopted: upgrades must preserve existing identity and user state and queue lifecycle reactions explicitly.
+
+## Standards and test references
+
+- [WHATWG DOM: mutation algorithms](https://dom.spec.whatwg.org/#mutation-algorithms), [event dispatch](https://dom.spec.whatwg.org/#concept-event-dispatch), [mutation observers](https://dom.spec.whatwg.org/#mutation-observers), and [ranges](https://dom.spec.whatwg.org/#ranges).
+- [WHATWG HTML: focus](https://html.spec.whatwg.org/multipage/interaction.html#focus), [text-control selection](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection), and [custom elements](https://html.spec.whatwg.org/multipage/custom-elements.html).
+- [Web Platform Tests: DOM events](https://github.com/web-platform-tests/wpt/tree/master/dom/events), [nodes](https://github.com/web-platform-tests/wpt/tree/master/dom/nodes), [ranges](https://github.com/web-platform-tests/wpt/tree/master/dom/ranges), and [custom elements](https://github.com/web-platform-tests/wpt/tree/master/custom-elements).
+
+The test suite contains focused adaptations and independently written regression tests. It is not the complete WPT suite. Passing it is not a claim of complete DOM or Web Components conformance.
+
+The independently written [custom-element browser differential](../test/browser/custom-elements.html) matches five expectations in `test/components.test.ts`: construction after fragment attributes and descendants exist, construction after connected insertion, inert template parsing, pre-upgrade attribute snapshots, and reentrant per-element reaction queues. All five passed in installed headless Chrome 155 on 2026-10-21. The fixture writes its actual and expected values into the page and sets `#results[data-passed="true"]` when they match. Open it in a browser, or reproduce from the workspace root with an installed Chrome executable:
+
+```sh
+DOM_BROWSER='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
+"$DOM_BROWSER" --headless --disable-gpu --no-first-run \
+ --user-data-dir=/tmp/bomb-dom-browser-validation --dump-dom \
+ "file://$PWD/packages/dom/test/browser/custom-elements.html"
+```
+
+This is a development check. Neither Chrome nor a browser automation library is a package runtime dependency. The comparison exposed two parser bugs that the regression tests now cover: eager construction before parsed attributes existed and construction inside inert template contents.
+
+## Baseline differences
+
+The baseline automatically creates a `` element. Here document shape is explicit: append a root of the application's choice, including ``. This permits HTML-like, terminal-specific, and detached framework containers without special cases.
+
+Baseline text and attribute nodes inherited unrestricted child insertion; the DOM hierarchy rules reject it. Public collections must not expose the private mutation array. Baseline direct `Attr.value` assignment did not invalidate an attached document; the attribute owner must participate in mutation recording and invalidation. Namespaces cannot be represented by returning the HTML namespace for every node. HTML `tagName` and `nodeName` are uppercase; `localName` remains separate, and `createElementNS` preserves qualified-name case. The baseline exposed a lowercase `tagName`.
+
+The baseline dispatch implementation established capture/target/bubble order, event-path snapshots, registration identity, and the target-capture stop boundary. DOM listener exceptions are reported rather than synchronously escaping `dispatchEvent`; `stopImmediatePropagation` sets both stop flags. Registration options, abort handling, composed-path behavior, and interactive focus require additional implementation beyond that baseline.
+
+Setting character data to the same string may avoid a rendered frame, but standard mutation records remain distinct from frame invalidation. The same distinction applies to explicitly observed detached nodes.
diff --git a/packages/dom/docs/selection-observation.md b/packages/dom/docs/selection-observation.md
new file mode 100644
index 0000000..d5a2bf3
--- /dev/null
+++ b/packages/dom/docs/selection-observation.md
@@ -0,0 +1,13 @@
+# Observation and document selection
+
+`MutationObserver` delivers attribute, character-data, and child-list records in a microtask. Registrations belong to explicitly observed nodes, including detached trees. `subtree`, attribute filters, optional old values, overlapping registrations, `takeRecords()`, `disconnect()`, and updates to existing registrations are tested. Removed descendants remain observable through the next observer delivery, including when `childList` was not requested. Delivery snapshots are taken for each observer in notification order, so one observer's callback can add records to another observer that has not yet run.
+
+The host invalidation callback is separate. Suppressing frames for detached preparation never suppresses mutation records. A callback exception is reported through the document of its first record, using `DocumentOptions.reportError`; it does not abort later observer delivery. This explicitly chooses a host for an observer that has no ambient browser realm. Record collections are frozen arrays, rather than a browser NodeList implementation. Registered targets are weakly retained by an observer; live target nodes retain their own registrations.
+
+`document.createRange()` creates a live Range, initially collapsed at `(document, 0)`. Character-data offsets count UTF-16 code units; other node offsets count children. Ranges update when nodes are inserted, moved, removed, split, or edited. A move uses the standard remove/insert behavior: a boundary inside the moved subtree relocates to its former parent position. It does not follow the moved subtree. Existing nodes, listeners, and user state still retain their identities.
+
+Implemented Range operations are boundary getters/setters and before/after variants, common ancestor, collapse, node/content selection, comparisons and point/intersection queries, cloneRange, stringification, deleteContents, cloneContents, and extractContents. Extraction moves fully contained nodes by identity and clones partial ancestor shells. The legacy detach method is a standard no-op and does not stop boundary updates. `insertNode`, `surroundContents`, contextual fragment parsing, StaticRange, normalize-related boundary merging, and geometry APIs are not implemented.
+
+Each document owns one Selection backed by a live Range. Tested APIs are add/remove ranges, getRangeAt, collapse/setPosition, collapseToStart/End, setBaseAndExtent, extend, selectAllChildren, deleteFromDocument, and stringification. Explicit selection operations and live changes to its range invalidate the host's selection state. Selection is limited to the document tree; shadow selection and getComposedRanges are unfinished. Selection stringification returns the range's logical text because this package has no CSS rendering engine. Input/textarea value selection is a separate subsystem described in [focus-and-controls.md](focus-and-controls.md).
+
+The implementation and tests are original. Normative sources consulted: [WHATWG mutation observers](https://dom.spec.whatwg.org/#mutation-observers), [live ranges](https://dom.spec.whatwg.org/#interface-range), and [Selection API](https://w3c.github.io/selection-api/). Relevant WPT source inspected: [MutationObserver childList](https://github.com/web-platform-tests/wpt/blob/master/dom/nodes/MutationObserver-childList.html), [attributes](https://github.com/web-platform-tests/wpt/blob/master/dom/nodes/MutationObserver-attributes.html), and [Range setting](https://github.com/web-platform-tests/wpt/blob/master/dom/ranges/Range-set.html). These observations do not claim the full WPT observer/range suite has been executed.
diff --git a/packages/dom/package.json b/packages/dom/package.json
new file mode 100644
index 0000000..32afd67
--- /dev/null
+++ b/packages/dom/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@bomb.sh/dom",
+ "version": "0.0.0",
+ "description": "A headless, interactive in-memory DOM",
+ "license": "MIT",
+ "files": [
+ "dist",
+ "README.md",
+ "docs",
+ "LICENSE"
+ ],
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./parsing": {
+ "types": "./dist/parsing.d.ts",
+ "import": "./dist/parsing.js"
+ }
+ },
+ "scripts": {
+ "build": "tsgo -p tsconfig.build.json",
+ "typecheck": "tsgo -p tsconfig.json",
+ "test": "vitest run",
+ "format": "bsh format src test docs README.md package.json tsconfig.json tsconfig.build.json",
+ "format:check": "bsh format --check src test docs README.md package.json tsconfig.json tsconfig.build.json",
+ "lint": "bsh lint"
+ },
+ "dependencies": {
+ "parse5": "^8.0.0"
+ },
+ "devDependencies": {
+ "@bomb.sh/tools": "^0.6.1",
+ "@types/node": "^22",
+ "@typescript/native-preview": "7.0.0-dev.20260623.1",
+ "vitest": "^4.1.2"
+ }
+}
diff --git a/packages/dom/src/controls.ts b/packages/dom/src/controls.ts
new file mode 100644
index 0000000..ddb9b38
--- /dev/null
+++ b/packages/dom/src/controls.ts
@@ -0,0 +1,220 @@
+import type { Element, Node } from './dom.ts';
+import { Event } from './events.ts';
+import { subscribeMutations } from './hooks.ts';
+
+export type SelectionDirection = 'forward' | 'backward' | 'none';
+export type SelectionMode = 'select' | 'start' | 'end' | 'preserve';
+
+export interface TextControlOptions {
+ element: Element;
+ multiline: boolean;
+ defaultValue: () => string;
+ supportsSelection?: () => boolean;
+}
+
+const controls = new WeakMap();
+
+/** Shared value and UTF-16 selection algorithms for input and textarea elements. */
+export class TextControlState {
+ readonly #options: TextControlOptions;
+ #value = '';
+ #dirty = false;
+ #defaultSnapshot = '';
+ #start = 0;
+ #end = 0;
+ #direction: SelectionDirection = 'none';
+ #selectionChangeQueued = false;
+
+ constructor(options: TextControlOptions) {
+ this.#options = options;
+ controls.set(options.element, this);
+ }
+
+ get value(): string {
+ const currentDefault = this.#normalize(this.#options.defaultValue());
+ if (!this.#dirty && currentDefault !== this.#defaultSnapshot) this.#value = currentDefault;
+ this.#defaultSnapshot = currentDefault;
+ return this.#value;
+ }
+
+ /** @internal HTML cloning copies value and dirtiness, without copying selection. */
+ copyValueFrom(source: TextControlState): void {
+ this.#value = source.value;
+ this.#dirty = source.#dirty;
+ this.#defaultSnapshot = this.#normalize(this.#options.defaultValue());
+ }
+
+ /** @internal Attribute/children changes update only a pristine control value. */
+ defaultValueChanged(): void {
+ this.#defaultSnapshot = this.#normalize(this.#options.defaultValue());
+ if (this.#dirty) return;
+ this.#value = this.#defaultSnapshot;
+ this.#setSelection({
+ start: Math.min(this.#start, this.#value.length),
+ end: Math.min(this.#end, this.#value.length),
+ direction: this.#direction,
+ });
+ }
+
+ set value(value: string) {
+ const normalized = this.#normalize(value == null ? '' : String(value));
+ const previous = this.value;
+ this.#dirty = true;
+ this.#value = normalized;
+ if (normalized === previous) return;
+ this.#setSelection({ start: normalized.length, end: normalized.length, direction: 'none' });
+ this.#invalidate('mutation');
+ }
+
+ get selectionStart(): number | null {
+ return this.#supported ? Math.min(this.#start, this.value.length) : null;
+ }
+
+ set selectionStart(value: number | null) {
+ this.#assertSupported();
+ const start = unsigned(value);
+ this.setSelectionRange(start, Math.max(start, this.selectionEnd ?? 0), this.#direction);
+ }
+
+ get selectionEnd(): number | null {
+ return this.#supported ? Math.min(this.#end, this.value.length) : null;
+ }
+
+ set selectionEnd(value: number | null) {
+ this.#assertSupported();
+ this.setSelectionRange(this.selectionStart ?? 0, unsigned(value), this.#direction);
+ }
+
+ get selectionDirection(): SelectionDirection | null {
+ return this.#supported ? this.#direction : null;
+ }
+
+ set selectionDirection(value: SelectionDirection | null) {
+ this.setSelectionRange(this.selectionStart ?? 0, this.selectionEnd ?? 0, value ?? 'none');
+ }
+
+ select(): void {
+ if (this.#supported) this.setSelectionRange(0, this.value.length);
+ }
+
+ // oxlint-disable-next-line bombshell-dev/max-params -- HTML text selection API.
+ setSelectionRange(start: number, end: number, direction: SelectionDirection = 'none'): void {
+ this.#assertSupported();
+ const endOffset = Math.min(unsigned(end), this.value.length);
+ const startOffset = Math.min(unsigned(start), endOffset);
+ const normalizedDirection =
+ direction === 'forward' || direction === 'backward' ? direction : 'none';
+ this.#setSelection({ start: startOffset, end: endOffset, direction: normalizedDirection });
+ }
+
+ setRangeText(
+ replacement: string,
+ options: { start?: number; end?: number; selectionMode?: SelectionMode } = {},
+ ): void {
+ this.#assertSupported();
+ const mode = options.selectionMode ?? 'preserve';
+ if (!['select', 'start', 'end', 'preserve'].includes(mode)) {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL enum conversion requires TypeError.
+ throw new TypeError('Invalid selection mode');
+ }
+ const current = this.value;
+ const start = unsigned(options.start === undefined ? this.selectionStart : options.start);
+ const end = unsigned(options.end === undefined ? this.selectionEnd : options.end);
+ if (start > end) throw new DOMException('The start is after the end', 'IndexSizeError');
+ const from = Math.min(start, current.length);
+ const to = Math.min(end, current.length);
+ const added = this.#normalize(String(replacement));
+ let selectedStart = this.selectionStart ?? 0;
+ let selectedEnd = this.selectionEnd ?? 0;
+ this.#dirty = true;
+ this.#value = current.slice(0, from) + added + current.slice(to);
+ const insertedEnd = from + added.length;
+ switch (mode) {
+ case 'select':
+ selectedStart = from;
+ selectedEnd = insertedEnd;
+ break;
+ case 'start':
+ selectedStart = selectedEnd = from;
+ break;
+ case 'end':
+ selectedStart = selectedEnd = insertedEnd;
+ break;
+ case 'preserve': {
+ const delta = added.length - (to - from);
+ if (selectedStart > to) selectedStart += delta;
+ else if (selectedStart > from) selectedStart = from;
+ if (selectedEnd > to) selectedEnd += delta;
+ else if (selectedEnd > from) selectedEnd = insertedEnd;
+ }
+ }
+ this.#setSelection({ start: selectedStart, end: selectedEnd, direction: 'none' });
+ if (this.#value !== current) this.#invalidate('mutation');
+ }
+
+ get #supported(): boolean {
+ return this.#options.supportsSelection?.() ?? true;
+ }
+
+ #assertSupported(): void {
+ if (!this.#supported)
+ throw new DOMException(
+ 'This input type does not support text selection',
+ 'InvalidStateError',
+ );
+ }
+
+ #normalize(value: string): string {
+ return this.#options.multiline ? value.replace(/\r\n?/g, '\n') : value.replace(/[\r\n]/g, '');
+ }
+
+ #invalidate(kind: 'mutation' | 'selection'): void {
+ const element = this.#options.element;
+ if (element.isConnected) element.ownerDocument.invalidate({ kind, target: element });
+ }
+
+ #setSelection(selection: { start: number; end: number; direction: SelectionDirection }): void {
+ const { start, end, direction } = selection;
+ if (start === this.#start && end === this.#end && direction === this.#direction) return;
+ this.#start = start;
+ this.#end = end;
+ this.#direction = direction;
+ this.#invalidate('selection');
+ const element = this.#options.element;
+ setTimeout(() => element.dispatchEvent(new Event('select', { bubbles: true })), 0);
+ if (!this.#selectionChangeQueued) {
+ this.#selectionChangeQueued = true;
+ setTimeout(() => {
+ this.#selectionChangeQueued = false;
+ element.dispatchEvent(new Event('selectionchange', { bubbles: true }));
+ }, 0);
+ }
+ }
+}
+
+function unsigned(value: number | null): number {
+ return Number(value) >>> 0;
+}
+
+// oxlint-disable bombshell-dev/exported-function-async -- Internal synchronous DOM cloning algorithm.
+/** @internal Preserve control value state during cloneNode/importNode. */
+export function cloneTextControlState(source: Element, copy: Element): void {
+ const original = controls.get(source);
+ const target = controls.get(copy);
+ if (original && target) target.copyValueFrom(original);
+}
+
+/** @internal Run textarea's children-changed steps even for an empty default assignment. */
+export function syncTextControlDefault(element: Element): void {
+ controls.get(element)?.defaultValueChanged();
+}
+
+subscribeMutations((mutation) => {
+ if (mutation.type === 'attributes') {
+ if (mutation.attributeName === 'value') controls.get(mutation.target)?.defaultValueChanged();
+ return;
+ }
+ for (let node: Node | null = mutation.target; node; node = node.parentNode) {
+ controls.get(node)?.defaultValueChanged();
+ }
+});
diff --git a/packages/dom/src/custom-elements.ts b/packages/dom/src/custom-elements.ts
new file mode 100644
index 0000000..addc065
--- /dev/null
+++ b/packages/dom/src/custom-elements.ts
@@ -0,0 +1,326 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- DOM custom-element registration and reaction algorithms are synchronous. */
+/* oxlint-disable bombshell-dev/no-generic-error -- The HTML construction/definition algorithms require TypeError for invalid constructors and callbacks. */
+import { Element, getShadowRoot, type Document, type Node } from './dom.ts';
+
+/** A constructor bound to a document's HTMLElement base. */
+export interface CustomElementConstructor {
+ new (): Element;
+ readonly observedAttributes?: Iterable;
+}
+
+type CallbackName =
+ | 'connectedCallback'
+ | 'disconnectedCallback'
+ | 'adoptedCallback'
+ | 'attributeChangedCallback';
+type LifecycleCallback = (this: Element, ...arguments_: unknown[]) => void;
+interface Definition {
+ name: string;
+ constructor: CustomElementConstructor;
+ observed: readonly string[];
+ callbacks: Record;
+}
+interface CustomState {
+ definition: Definition;
+ status: 'custom' | 'failed' | 'constructing';
+}
+type Reaction = () => void;
+const customStates = new WeakMap();
+const reactionQueues = new WeakMap();
+const scopes: Element[][] = [];
+let pending: Element[] = [];
+
+function enqueue(element: Element, options: { reaction: Reaction; schedule?: boolean }): void {
+ let queue = reactionQueues.get(element);
+ if (!queue) reactionQueues.set(element, (queue = []));
+ queue.push(options.reaction);
+ if (options.schedule !== false) (scopes.at(-1) ?? pending).push(element);
+}
+function invoke(elements: readonly Element[]): void {
+ for (const element of elements) {
+ const queue = reactionQueues.get(element);
+ // oxlint-disable-next-line no-unmodified-loop-condition -- Each iteration shifts the reaction queue, including reentrant additions.
+ while (queue?.length) {
+ const reaction = queue.shift();
+ try {
+ reaction?.();
+ } catch (error) {
+ element.ownerDocument.reportError(error);
+ }
+ }
+ }
+}
+/** @internal Complete one DOM operation before invoking its custom-element reactions. */
+export function withReactions(operation: () => T): T {
+ const queue: Element[] = [];
+ scopes.push(queue);
+ try {
+ return operation();
+ } finally {
+ scopes.pop();
+ invoke(queue);
+ }
+}
+/** @internal Drain pending reactions; nested DOM operations get independent queues. */
+export function flushReactions(): void {
+ if (scopes.length) return;
+ const queue = pending;
+ pending = [];
+ invoke(queue);
+}
+/** @internal Defer construction until the enclosing mutation has finished. */
+export function enqueueUpgrade(element: Element): void {
+ enqueue(element, {
+ reaction: () => element.ownerDocument.customElements.upgradeElement(element),
+ });
+}
+function validName(name: string): boolean {
+ return (
+ /^[a-z][.0-9_a-z-]*-[.0-9_a-z-]*$/.test(name) &&
+ ![
+ 'annotation-xml',
+ 'color-profile',
+ 'font-face',
+ 'font-face-src',
+ 'font-face-uri',
+ 'font-face-format',
+ 'font-face-name',
+ 'missing-glyph',
+ ].includes(name)
+ );
+}
+function getCallback(prototype: object, name: CallbackName): LifecycleCallback | null {
+ const value: unknown = Reflect.get(prototype, name);
+ if (value === undefined) return null;
+ if (typeof value !== 'function') throw new TypeError(`${name} must be callable`);
+ return function (...arguments_: unknown[]): void {
+ Reflect.apply(value, this, arguments_);
+ };
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Internal lifecycle dispatch needs element, captured callback definition, arguments, and scheduling mode.
+function enqueueCallback(
+ element: Element,
+ definition: Definition,
+ name: CallbackName,
+ arguments_: unknown[],
+ schedule = true,
+): void {
+ const callback = definition.callbacks[name];
+ if (callback) enqueue(element, { reaction: () => callback.apply(element, arguments_), schedule });
+}
+/** @internal Traverse shadow-including descendants for lifecycle delivery. */
+export function customConnection(node: Node, connected: boolean): void {
+ if (node instanceof Element) {
+ const state = customStates.get(node);
+ if (connected && !state) enqueueUpgrade(node);
+ if (state?.status === 'custom')
+ enqueueCallback(
+ node,
+ state.definition,
+ connected ? 'connectedCallback' : 'disconnectedCallback',
+ [],
+ );
+ const shadow = getShadowRoot(node);
+ if (shadow) for (const child of shadow.childNodes) customConnection(child, connected);
+ }
+ for (const child of node.childNodes) customConnection(child, connected);
+}
+/* oxlint-disable bombshell-dev/max-params -- Preserve the DOM attribute-change and HTML adoption algorithm inputs. */
+/** @internal Queue observed-attribute reactions independently of rendering invalidation. */
+export function customAttribute(
+ element: Element,
+ name: string,
+ oldValue: string | null,
+ value: string | null,
+ namespace: string | null,
+): void {
+ const state = customStates.get(element);
+ if (state?.status !== 'custom' || !state.definition.observed.includes(name)) return;
+ enqueueCallback(element, state.definition, 'attributeChangedCallback', [
+ name,
+ oldValue,
+ value,
+ namespace,
+ ]);
+}
+/** @internal Preserve the original definition when adopting across documents. */
+export function customAdopted(
+ element: Element,
+ oldDocument: Document,
+ newDocument: Document,
+): void {
+ const state = customStates.get(element);
+ if (state?.status === 'custom')
+ enqueueCallback(element, state.definition, 'adoptedCallback', [oldDocument, newDocument]);
+}
+/* oxlint-enable bombshell-dev/max-params */
+
+/** Autonomous custom elements with identity-preserving, document-local upgrades. */
+export class CustomElementRegistry {
+ readonly #document: Document;
+ readonly #definitions = new Map();
+ readonly #constructors = new Map();
+ readonly #waiting = new Map<
+ string,
+ {
+ promise: Promise;
+ resolve: (constructor: CustomElementConstructor) => void;
+ }
+ >();
+ readonly #stack: { definition: Definition; element: Element; consumed: boolean }[] = [];
+ #defining = false;
+ readonly HTMLElement: CustomElementConstructor;
+ constructor(document: Document) {
+ this.#document = document;
+ // oxlint-disable-next-line typescript/no-this-alias -- The document-bound constructor closes over its owning registry.
+ const registry = this;
+ this.HTMLElement = class HTMLElement extends Element {
+ constructor() {
+ const definition = registry.#constructors.get(new.target);
+ if (!definition)
+ throw new TypeError('Custom element constructor is not registered in this document');
+ super(definition.name, document);
+ const entry = registry.#stack.at(-1);
+ if (entry?.definition === definition) {
+ if (entry.consumed)
+ throw new DOMException('Element already constructed', 'InvalidStateError');
+ entry.consumed = true;
+ return entry.element;
+ }
+ customStates.set(this, { definition, status: 'custom' });
+ }
+ };
+ }
+ define(name: string, constructor: CustomElementConstructor): void {
+ withReactions(() => {
+ if (!validName(name)) throw new DOMException('Invalid custom element name', 'SyntaxError');
+ if (this.#definitions.has(name) || this.#constructors.has(constructor) || this.#defining)
+ throw new DOMException('Definition already registered or running', 'NotSupportedError');
+ if (!(constructor.prototype instanceof this.HTMLElement))
+ throw new TypeError('Extend this document’s HTMLElement constructor');
+ let definition: Definition;
+ this.#defining = true;
+ try {
+ const callbacks: Definition['callbacks'] = {
+ connectedCallback: getCallback(constructor.prototype, 'connectedCallback'),
+ disconnectedCallback: getCallback(constructor.prototype, 'disconnectedCallback'),
+ adoptedCallback: getCallback(constructor.prototype, 'adoptedCallback'),
+ attributeChangedCallback: getCallback(constructor.prototype, 'attributeChangedCallback'),
+ };
+ const attributes = callbacks.attributeChangedCallback
+ ? constructor.observedAttributes
+ : undefined;
+ definition = {
+ name,
+ constructor,
+ callbacks,
+ observed: attributes === undefined ? [] : [...attributes].map(String),
+ };
+ } finally {
+ this.#defining = false;
+ }
+ this.#definitions.set(name, definition);
+ this.#constructors.set(constructor, definition);
+ this.#enqueueUpgrades(this.#document);
+ this.#waiting.get(name)?.resolve(constructor);
+ this.#waiting.delete(name);
+ });
+ }
+ get(name: string): CustomElementConstructor | undefined {
+ return this.#definitions.get(name)?.constructor;
+ }
+ getName(constructor: CustomElementConstructor): string | null {
+ return this.#constructors.get(constructor)?.name ?? null;
+ }
+ whenDefined(name: string): Promise {
+ if (!validName(name))
+ return Promise.reject(new DOMException('Invalid custom element name', 'SyntaxError'));
+ const constructor = this.get(name);
+ if (constructor) return Promise.resolve(constructor);
+ let waiting = this.#waiting.get(name);
+ if (!waiting) {
+ waiting = Promise.withResolvers();
+ this.#waiting.set(name, waiting);
+ }
+ return waiting.promise;
+ }
+ /** @internal Construct an already-registered element. */
+ create(name: string): Element | null {
+ const definition = this.#definitions.get(name);
+ if (!definition) return null;
+ try {
+ const element = new definition.constructor();
+ if (
+ !(element instanceof Element) ||
+ customStates.get(element)?.definition !== definition ||
+ element.ownerDocument !== this.#document ||
+ element.localName !== name ||
+ element.parentNode ||
+ element.childNodes.length ||
+ element.attributes.length
+ )
+ throw new DOMException('Invalid custom element construction result', 'NotSupportedError');
+ return element;
+ } catch (error) {
+ this.#document.reportError(error);
+ const failed = new Element(name, this.#document);
+ customStates.set(failed, { definition, status: 'failed' });
+ return failed;
+ }
+ }
+ upgrade(root: Node): void {
+ withReactions(() => this.#enqueueUpgrades(root));
+ }
+ #enqueueUpgrades(root: Node): void {
+ const visit = (node: Node): void => {
+ if (node instanceof Element) {
+ if (node.ownerDocument === this.#document)
+ enqueue(node, { reaction: () => this.upgradeElement(node) });
+ const shadow = getShadowRoot(node);
+ if (shadow) visit(shadow);
+ }
+ for (const child of node.childNodes) visit(child);
+ };
+ visit(root);
+ }
+ /** @internal Upgrade one queued candidate; its existing reactions follow construction. */
+ upgradeElement(element: Element): void {
+ if (
+ customStates.has(element) ||
+ element.namespaceURI !== 'http://www.w3.org/1999/xhtml' ||
+ element.ownerDocument !== this.#document
+ )
+ return;
+ const definition = this.#definitions.get(element.localName);
+ if (!definition) return;
+ const state: CustomState = { definition, status: 'constructing' };
+ customStates.set(element, state);
+ for (const attr of element.attributes) {
+ if (definition.observed.includes(attr.localName))
+ enqueueCallback(
+ element,
+ definition,
+ 'attributeChangedCallback',
+ [attr.localName, null, attr.value, attr.namespaceURI],
+ false,
+ );
+ }
+ if (element.isConnected) enqueueCallback(element, definition, 'connectedCallback', [], false);
+ const entry = { definition, element, consumed: false };
+ this.#stack.push(entry);
+ try {
+ Object.setPrototypeOf(element, definition.constructor.prototype);
+ const result = new definition.constructor();
+ if (result !== element || !entry.consumed)
+ throw new TypeError('Upgrade constructor did not return the existing element');
+ state.status = 'custom';
+ } catch (error) {
+ state.status = 'failed';
+ const queue = reactionQueues.get(element);
+ if (queue) queue.length = 0;
+ this.#document.reportError(error);
+ } finally {
+ this.#stack.pop();
+ }
+ }
+}
diff --git a/packages/dom/src/dom.ts b/packages/dom/src/dom.ts
new file mode 100644
index 0000000..0918d09
--- /dev/null
+++ b/packages/dom/src/dom.ts
@@ -0,0 +1,1389 @@
+import { EventTarget, Event, type Event as DOMEvent } from './events.ts';
+import {
+ notifyBeforeRemove,
+ notifyInsert,
+ notifyMutation,
+ notifyTextChange,
+ notifyTextSplit,
+} from './hooks.ts';
+
+import {
+ CustomElementRegistry,
+ customConnection,
+ customAttribute,
+ customAdopted,
+ flushReactions,
+ withReactions,
+ type CustomElementConstructor,
+} from './custom-elements.ts';
+import { TreeWalker, type NodeFilterCallback } from './traversal.ts';
+import { Range, Selection } from './selection.ts';
+import { FocusManager, Dispatch, defaultTabIndex } from './focus.ts';
+import {
+ cloneTextControlState,
+ syncTextControlDefault,
+ TextControlState,
+ type SelectionDirection,
+ type SelectionMode,
+} from './controls.ts';
+
+export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+export const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
+export const ELEMENT_NODE = 1;
+export const ATTRIBUTE_NODE = 2;
+export const TEXT_NODE = 3;
+export const COMMENT_NODE = 8;
+export const DOCUMENT_NODE = 9;
+export const DOCUMENT_FRAGMENT_NODE = 11;
+
+interface State {
+ parent: Node | null;
+ owner: Document | null;
+ children: Node[];
+ view: readonly Node[];
+}
+const states = new WeakMap();
+function state(node: Node): State {
+ const value = states.get(node);
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- WebIDL brand checks use TypeError.
+ if (!value) throw new TypeError('Expected a DOM node');
+ return value;
+}
+
+/** A host-visible change. Hosts choose how to coalesce these into frames. */
+export interface Invalidation {
+ readonly kind: 'mutation' | 'focus' | 'selection';
+ readonly target: Node;
+}
+/** Options for a document with no implicit root or process globals. */
+export interface MarkupParser {
+ parse(context: Element | ShadowRoot, markup: string): DocumentFragment;
+ serialize(node: Node): string;
+}
+export interface DocumentOptions {
+ onInvalidate?: (change: Invalidation) => void;
+ reportError?: (error: unknown) => void;
+}
+/** Extend this interface with application element names and real subclasses. */
+export interface Elements {
+ input: HTMLInputElement;
+ textarea: HTMLTextAreaElement;
+ template: HTMLTemplateElement;
+ slot: HTMLSlotElement;
+}
+
+/** Base of the single in-memory node hierarchy. Collections are live read-only array views. */
+export abstract class Node extends EventTarget {
+ static readonly ELEMENT_NODE = ELEMENT_NODE;
+ static readonly ATTRIBUTE_NODE = ATTRIBUTE_NODE;
+ static readonly TEXT_NODE = TEXT_NODE;
+ static readonly COMMENT_NODE = COMMENT_NODE;
+ static readonly DOCUMENT_NODE = DOCUMENT_NODE;
+ static readonly DOCUMENT_FRAGMENT_NODE = DOCUMENT_FRAGMENT_NODE;
+ abstract readonly nodeType: number;
+ abstract readonly nodeName: string;
+
+ constructor(owner: Document | null) {
+ super();
+ const children: Node[] = [];
+ states.set(this, { parent: null, owner, children, view: readonlyCollection(() => children) });
+ }
+ get ownerDocument(): Document | null {
+ return state(this).owner;
+ }
+ get parentNode(): Node | null {
+ return state(this).parent;
+ }
+ get parentElement(): Element | null {
+ return this.parentNode instanceof Element ? this.parentNode : null;
+ }
+ get childNodes(): readonly Node[] {
+ return state(this).view;
+ }
+ get firstChild(): Node | null {
+ return state(this).children[0] ?? null;
+ }
+ get lastChild(): Node | null {
+ return state(this).children.at(-1) ?? null;
+ }
+ get previousSibling(): Node | null {
+ const siblings = this.parentNode ? state(this.parentNode).children : [];
+ return siblings[siblings.indexOf(this) - 1] ?? null;
+ }
+ get nextSibling(): Node | null {
+ const siblings = this.parentNode ? state(this.parentNode).children : [];
+ return siblings[siblings.indexOf(this) + 1] ?? null;
+ }
+ get isConnected(): boolean {
+ return this.getRootNode({ composed: true }) instanceof Document;
+ }
+ get nodeValue(): string | null {
+ return null;
+ }
+ set nodeValue(_value: string | null) {}
+ get textContent(): string | null {
+ if (this instanceof Document) return null;
+ return state(this)
+ .children.map((child) => (child instanceof Comment ? '' : (child.textContent ?? '')))
+ .join('');
+ }
+ set textContent(value: string | null) {
+ if (this instanceof Document) return;
+ const text = value == null ? '' : String(value);
+ this.replaceChildren(...(text ? [this.document.createTextNode(text)] : []));
+ }
+ protected get document(): Document {
+ if (this instanceof Document) return this;
+ const owner = state(this).owner;
+ if (!owner) throw new DOMException('Node has no owner document', 'InvalidStateError');
+ return owner;
+ }
+ getRootNode(options: { composed?: boolean } = {}): Node {
+ // oxlint-disable-next-line typescript/no-this-alias -- Mutable tree traversal cursor.
+ let node: Node = this;
+ while (node.parentNode) node = node.parentNode;
+ if (options.composed && node instanceof ShadowRoot) return node.host.getRootNode(options);
+ return node;
+ }
+ hasChildNodes(): boolean {
+ return state(this).children.length > 0;
+ }
+ contains(other: Node | null): boolean {
+ for (let node = other; node; node = node.parentNode) if (node === this) return true;
+ return false;
+ }
+ isSameNode(other: Node | null): boolean {
+ return this === other;
+ }
+ appendChild(node: T): T {
+ return this.insertBefore(node, null);
+ }
+ insertBefore(node: T, anchor: Node | null): T {
+ return withReactions(() => this.#insertBefore(node, anchor));
+ }
+ #insertBefore(node: T, anchor: Node | null, suppressObservers = false): T {
+ validateInsertion(this, node, anchor);
+ if (node === anchor) return node;
+ const nodes = node instanceof DocumentFragment ? [...node.childNodes] : [node];
+ if (node instanceof DocumentFragment) {
+ for (const child of nodes) {
+ notifyBeforeRemove(child, node, 0);
+ state(node).children.shift();
+ state(child).parent = null;
+ }
+ if (nodes.length)
+ notifyMutation({
+ type: 'childList',
+ target: node,
+ removedNodes: nodes,
+ previousSibling: null,
+ nextSibling: null,
+ });
+ } else if (node.parentNode) node.parentNode.#removeChild(node);
+ const children = state(this).children;
+ const index = anchor ? children.indexOf(anchor) : children.length;
+ const previousSibling = children[index - 1] ?? null;
+ notifyInsert(this, index, nodes.length);
+ for (const child of nodes) {
+ adopt(child, this.document);
+ state(child).parent = this;
+ }
+ children.splice(index, 0, ...nodes);
+ if (nodes.length) {
+ if (!suppressObservers)
+ notifyMutation({
+ type: 'childList',
+ target: this,
+ addedNodes: nodes,
+ previousSibling,
+ nextSibling: anchor,
+ });
+ this.notifyHost();
+ updateSlots(this);
+ for (const child of nodes) connectionChanged(child, true);
+ }
+ return node;
+ }
+ removeChild(node: T): T {
+ return withReactions(() => this.#removeChild(node));
+ }
+ #removeChild(node: T, suppressObservers = false): T {
+ const children = state(this).children;
+ const index = children.indexOf(node);
+ if (index < 0) throw new DOMException('Node is not a child', 'NotFoundError');
+ const previousSibling = children[index - 1] ?? null;
+ const nextSibling = children[index + 1] ?? null;
+ const connected = node.isConnected;
+ notifyBeforeRemove(node, this, index);
+ this.document.nodeRemoving(node);
+ children.splice(index, 1);
+ state(node).parent = null;
+ if (!suppressObservers)
+ notifyMutation({
+ type: 'childList',
+ target: this,
+ removedNodes: [node],
+ previousSibling,
+ nextSibling,
+ });
+ this.notifyHost();
+ updateSlots(this);
+ updateSlots(node);
+ if (connected) connectionChanged(node, false);
+ return node;
+ }
+ replaceChild(node: Node, child: T): T {
+ return withReactions(() => this.#replaceChild(node, child));
+ }
+ #replaceChild(node: Node, child: T): T {
+ if (child.parentNode !== this) throw new DOMException('Node is not a child', 'NotFoundError');
+ if (node === child) return child;
+ validateInsertion(this, node, child, child);
+ // Removing the old child first also permits replacing a document element.
+ let anchor = child.nextSibling;
+ if (anchor === node) anchor = node.nextSibling;
+ const addedNodes = node instanceof DocumentFragment ? [...node.childNodes] : [node];
+ if (node.parentNode) node.parentNode.#removeChild(node);
+ const previousSibling = child.previousSibling;
+ this.#removeChild(child, true);
+ this.#insertBefore(node, anchor, true);
+ notifyMutation({
+ type: 'childList',
+ target: this,
+ addedNodes,
+ removedNodes: [child],
+ previousSibling,
+ nextSibling: anchor,
+ });
+ return child;
+ }
+ remove(): void {
+ this.parentNode?.removeChild(this);
+ }
+ protected detachNode(node: Node): void {
+ if (node.parentNode) node.parentNode.#removeChild(node);
+ }
+ append(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#append(nodes));
+ }
+ #append(nodes: readonly (Node | string)[]): void {
+ validateSequence(this, nodes);
+ const converted = nodes.map((node) =>
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ );
+ for (const node of converted) validateInsertion(this, node, null);
+ if (converted.length === 1) this.#insertBefore(converted[0]!, null);
+ else if (converted.length) {
+ const fragment = this.document.createDocumentFragment();
+ for (const node of converted) fragment.#insertBefore(node, null);
+ this.#insertBefore(fragment, null);
+ }
+ }
+ prepend(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#prepend(nodes));
+ }
+ #prepend(nodes: readonly (Node | string)[]): void {
+ validateSequence(this, nodes);
+ for (const node of nodes) if (typeof node !== 'string') validateInsertion(this, node, null);
+ const fragment = this.document.createDocumentFragment();
+ for (const node of nodes)
+ fragment.#insertBefore(
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ null,
+ );
+ this.#insertBefore(fragment, this.firstChild);
+ }
+ replaceChildren(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#replaceChildren(nodes));
+ }
+ #replaceChildren(nodes: readonly (Node | string)[]): void {
+ const converted = nodes.map((node) =>
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ );
+ for (const node of converted) validateInsertion(this, node, null, undefined, true);
+ if (this instanceof Document) {
+ const flattened = converted.flatMap((node) =>
+ node instanceof DocumentFragment ? [...node.childNodes] : [node],
+ );
+ if (flattened.filter((node) => node instanceof Element).length > 1)
+ throw new DOMException('Multiple document elements', 'HierarchyRequestError');
+ }
+ const fragment = this.document.createDocumentFragment();
+ for (const node of converted) fragment.#insertBefore(node, null);
+ const removedNodes = [...this.childNodes];
+ const addedNodes = [...fragment.childNodes];
+ for (const node of removedNodes) this.#removeChild(node, true);
+ this.#insertBefore(fragment, null, true);
+ if (removedNodes.length || addedNodes.length)
+ notifyMutation({
+ type: 'childList',
+ target: this,
+ addedNodes,
+ removedNodes,
+ previousSibling: null,
+ nextSibling: null,
+ });
+ }
+ before(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#before(nodes));
+ }
+ #before(nodes: readonly (Node | string)[]): void {
+ const parent = this.parentNode;
+ if (!parent || !nodes.length) return;
+ validateSequence(parent, nodes);
+ for (const node of nodes) if (typeof node !== 'string') validateInsertion(parent, node, this);
+ let previous = this.previousSibling;
+ while (previous && nodes.includes(previous)) previous = previous.previousSibling;
+ const fragment = this.document.createDocumentFragment();
+ for (const node of nodes)
+ fragment.#insertBefore(
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ null,
+ );
+ parent.#insertBefore(fragment, previous ? previous.nextSibling : parent.firstChild);
+ }
+ after(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#after(nodes));
+ }
+ #after(nodes: readonly (Node | string)[]): void {
+ const parent = this.parentNode;
+ if (!parent || !nodes.length) return;
+ validateSequence(parent, nodes);
+ for (const node of nodes) if (typeof node !== 'string') validateInsertion(parent, node, this);
+ let anchor = this.nextSibling;
+ while (anchor && nodes.includes(anchor)) anchor = anchor.nextSibling;
+ const fragment = this.document.createDocumentFragment();
+ for (const node of nodes)
+ fragment.#insertBefore(
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ null,
+ );
+ parent.#insertBefore(fragment, anchor);
+ }
+ replaceWith(...nodes: (Node | string)[]): void {
+ withReactions(() => this.#replaceWith(nodes));
+ }
+ #replaceWith(nodes: readonly (Node | string)[]): void {
+ const parent = this.parentNode;
+ if (!parent) return;
+ if (nodes.length === 1 && nodes[0] === this) return;
+ validateSequence(parent, nodes, { replaced: this });
+ for (const node of nodes)
+ if (typeof node !== 'string') validateInsertion(parent, node, this, this);
+ let anchor = this.nextSibling;
+ while (anchor && nodes.includes(anchor)) anchor = anchor.nextSibling;
+ const fragment = this.document.createDocumentFragment();
+ for (const node of nodes)
+ fragment.#insertBefore(
+ typeof node === 'string' ? this.document.createTextNode(node) : node,
+ null,
+ );
+ if (this.parentNode === parent) parent.#replaceChild(fragment, this);
+ else parent.#insertBefore(fragment, anchor);
+ }
+ cloneNode(deep = false): Node {
+ return clone(this, this.document, deep);
+ }
+ protected override getEventParent(_event: DOMEvent): EventTarget | null {
+ return getAssignedSlot(this) ?? this.parentNode;
+ }
+ protected override retargetEventTarget(target: EventTarget | null): EventTarget | null {
+ let candidate = target;
+ while (candidate instanceof Node) {
+ const root = candidate.getRootNode();
+ if (!(root instanceof ShadowRoot)) return candidate;
+ // Retargeting is based on shadow-including ancestry, not the event path.
+ for (
+ // oxlint-disable-next-line typescript/no-this-alias -- Mutable ancestor traversal cursor.
+ let current: Node | null = this;
+ current;
+ current = current.parentNode ?? (current instanceof ShadowRoot ? current.host : null)
+ ) {
+ if (current === root) return candidate;
+ }
+ candidate = root.host;
+ }
+ return candidate;
+ }
+ protected override getEventClosedRoots(): readonly EventTarget[] {
+ const roots: ShadowRoot[] = [];
+ let root = this.getRootNode();
+ while (root instanceof ShadowRoot) {
+ if (root.mode === 'closed') roots.push(root);
+ root = root.host.getRootNode();
+ }
+ return roots;
+ }
+ protected override isEventTargetInShadowTree(): boolean {
+ return this.getRootNode() instanceof ShadowRoot;
+ }
+ protected override reportEventError(error: unknown): void {
+ this.document.reportError(error);
+ }
+ protected notifyHost(kind: Invalidation['kind'] = 'mutation'): void {
+ if (this.isConnected) this.document.invalidate({ kind, target: this });
+ }
+}
+
+// oxlint-disable-next-line bombshell-dev/max-params -- Internal DOM pre-insertion algorithm arguments.
+function validateInsertion(
+ parent: Node,
+ node: Node,
+ anchor: Node | null,
+ replaced?: Node,
+ replacingAll = false,
+): void {
+ if (
+ !(parent instanceof Element || parent instanceof Document || parent instanceof DocumentFragment)
+ )
+ throw new DOMException('Node cannot have children', 'HierarchyRequestError');
+ if (anchor !== null && anchor.parentNode !== parent)
+ throw new DOMException('Anchor is not a child', 'NotFoundError');
+ // Host-including ancestry also prevents a host being inserted into its shadow tree.
+ for (
+ let ancestor: Node | null = parent;
+ ancestor;
+ ancestor = ancestor.parentNode ?? (ancestor instanceof ShadowRoot ? ancestor.host : null)
+ ) {
+ if (ancestor === node)
+ throw new DOMException('Insertion would create a cycle', 'HierarchyRequestError');
+ }
+ if (node instanceof Document || node instanceof Attr || node instanceof ShadowRoot)
+ throw new DOMException('Invalid child node type', 'HierarchyRequestError');
+ const nodes = node instanceof DocumentFragment ? node.childNodes : [node];
+ if (parent instanceof Document) {
+ if (nodes.some((child) => child instanceof Text))
+ throw new DOMException('Document cannot contain text', 'HierarchyRequestError');
+ const existing = replacingAll
+ ? []
+ : parent.childNodes.filter((child) => child !== replaced && child !== node);
+ if ([...existing, ...nodes].filter((child) => child instanceof Element).length > 1)
+ throw new DOMException('Multiple document elements', 'HierarchyRequestError');
+ }
+}
+
+/** An independent document; construction neither installs globals nor creates a root. */
+export class Document extends Node {
+ readonly nodeType = DOCUMENT_NODE;
+ readonly nodeName = '#document';
+ readonly #options: DocumentOptions;
+ readonly customElements: CustomElementRegistry;
+ readonly HTMLElement: CustomElementConstructor;
+ readonly focusManager: FocusManager;
+ readonly dispatch: Dispatch;
+ readonly #selection: Selection;
+ #selectionQueued = false;
+ #parser: MarkupParser | null = null;
+ #templateOwner: Document | null = null;
+ constructor(options: DocumentOptions | (() => void) = {}) {
+ super(null);
+ this.#options = typeof options === 'function' ? { onInvalidate: options } : options;
+ this.customElements = new CustomElementRegistry(this);
+ this.HTMLElement = this.customElements.HTMLElement;
+ this.focusManager = new FocusManager(this);
+ this.dispatch = new Dispatch(this);
+ this.#selection = new Selection(this);
+ }
+ override get ownerDocument(): null {
+ return null;
+ }
+ get documentElement(): Element | null {
+ return this.children[0] ?? null;
+ }
+ get children(): readonly Element[] {
+ return elementChildren(this);
+ }
+ get body(): Element | null {
+ return this.documentElement?.children.find((node) => node.localName === 'body') ?? null;
+ }
+ createElement(name: K): Elements[K];
+ createElement(name: string): Element;
+ createElement(name: string): Element {
+ const localName = String(name).toLowerCase();
+ validateName(localName);
+ const custom = this.customElements.create(localName);
+ if (custom) return custom;
+ if (localName === 'template') return new HTMLTemplateElement(this);
+ if (localName === 'slot') return new HTMLSlotElement(this);
+ if (localName === 'input') return new HTMLInputElement(this);
+ if (localName === 'textarea') return new HTMLTextAreaElement(this);
+ return new Element(localName, this);
+ }
+ createElementNS(namespace: string | null, name: string): Element {
+ const parts = qualifiedName(namespace, name);
+ if (
+ parts.namespaceURI === HTML_NAMESPACE &&
+ !parts.prefix &&
+ parts.localName === parts.localName.toLowerCase()
+ )
+ return this.createElement(parts.localName);
+ return new Element(parts.localName, this, parts);
+ }
+ createTextNode(data: string): Text {
+ return new Text(String(data), this);
+ }
+ createComment(data: string): Comment {
+ return new Comment(String(data), this);
+ }
+ createDocumentFragment(): DocumentFragment {
+ return new DocumentFragment(this);
+ }
+ createAttribute(name: string): Attr {
+ const normalized = String(name).toLowerCase();
+ validateName(normalized);
+ return new Attr(normalized, this);
+ }
+ createAttributeNS(namespace: string | null, name: string): Attr {
+ const parts = qualifiedName(namespace, name);
+ return new Attr(parts.localName, this, parts);
+ }
+ adoptNode(node: T): T {
+ return withReactions(() => this.#adoptNode(node));
+ }
+ #adoptNode(node: T): T {
+ if (node instanceof Document || node instanceof ShadowRoot)
+ throw new DOMException('Cannot adopt this node', 'NotSupportedError');
+ if (node instanceof Attr) node.ownerElement?.removeAttributeNode(node);
+ this.detachNode(node);
+ adopt(node, this);
+ flushReactions();
+ return node;
+ }
+ importNode(node: T, deep = false): Node {
+ if (node instanceof Document || node instanceof ShadowRoot)
+ throw new DOMException('Cannot import this node', 'NotSupportedError');
+ return clone(node, this, deep);
+ }
+ getElementById(id: string): Element | null {
+ return descendants(this).find((node) => node.id === id) ?? null;
+ }
+ /** Notify the host synchronously; coalescing is the host's responsibility. */
+ invalidate(change: Invalidation): void {
+ this.#options.onInvalidate?.(change);
+ }
+ /** Report listener and lifecycle exceptions without stopping dispatch. */
+ reportError(error: unknown): void {
+ if (this.#options.reportError) this.#options.reportError(error);
+ else
+ queueMicrotask(() => {
+ throw error;
+ });
+ }
+ /** Internal focus-removal hook, completed by the focus subsystem. */
+ nodeRemoving(node: Node): void {
+ this.focusManager.removing(node);
+ }
+ get activeElement(): Element | null {
+ return this.focusManager.activeElementFor(this);
+ }
+ createTreeWalker(
+ root: Node,
+ whatToShow?: number,
+ filter?: NodeFilterCallback | null,
+ ): TreeWalker {
+ return new TreeWalker(root, { whatToShow, filter });
+ }
+ /** Parsing is opt-in through @bomb.sh/dom/parsing. */
+ setMarkupParser(parser: MarkupParser): void {
+ this.#parser = parser;
+ }
+ get markupParser(): MarkupParser {
+ if (!this.#parser)
+ throw new DOMException('Install @bomb.sh/dom/parsing for markup APIs', 'NotSupportedError');
+ return this.#parser;
+ }
+ /** @internal Inert owner document for template contents. */
+ get templateOwnerDocument(): Document {
+ return (this.#templateOwner ??= new Document());
+ }
+ createRange(): Range {
+ return new Range(this);
+ }
+ getSelection(): Selection {
+ return this.#selection;
+ }
+ _selectionChanged(): void {
+ this.invalidate({ kind: 'selection', target: this });
+ if (!this.#selectionQueued) {
+ this.#selectionQueued = true;
+ setTimeout(() => {
+ this.#selectionQueued = false;
+ this.dispatchEvent(new Event('selectionchange'));
+ }, 0);
+ }
+ }
+}
+
+interface NamespaceName {
+ namespaceURI: string | null;
+ prefix: string | null;
+ localName: string;
+}
+function validateName(name: string): void {
+ if (!/^[\p{L}_:][\p{L}\p{N}_.:\-\u00B7]*$/u.test(name))
+ throw new DOMException('Invalid name', 'InvalidCharacterError');
+}
+function qualifiedName(namespace: string | null, name: string): NamespaceName {
+ const namespaceURI = namespace === '' ? null : namespace;
+ validateName(String(name));
+ const parts = String(name).split(':');
+ if (parts.length > 2 || parts.some((part) => !part))
+ throw new DOMException('Invalid qualified name', 'NamespaceError');
+ const prefix = parts.length === 2 ? parts[0]! : null;
+ const localName = parts.at(-1)!;
+ if (
+ (prefix && !namespaceURI) ||
+ (prefix === 'xml' && namespaceURI !== 'http://www.w3.org/XML/1998/namespace') ||
+ ((prefix === 'xmlns' || name === 'xmlns') &&
+ namespaceURI !== 'http://www.w3.org/2000/xmlns/') ||
+ (namespaceURI === 'http://www.w3.org/2000/xmlns/' && prefix !== 'xmlns' && name !== 'xmlns')
+ )
+ throw new DOMException('Invalid namespace', 'NamespaceError');
+ return { namespaceURI, prefix, localName };
+}
+
+/** An element with namespace-aware attributes and protected tree ownership. */
+export class Element extends Node {
+ readonly nodeType = ELEMENT_NODE;
+ readonly localName: string;
+ readonly namespaceURI: string | null;
+ readonly prefix: string | null;
+ readonly #attributes: Attr[] = [];
+ readonly #attributeView = readonlyCollection(() => this.#attributes);
+
+ constructor(name: string, document: Document, options: Partial = {}) {
+ super(document);
+ this.localName = name;
+ this.namespaceURI = options.namespaceURI === undefined ? HTML_NAMESPACE : options.namespaceURI;
+ this.prefix = options.prefix ?? null;
+ }
+ override get ownerDocument(): Document {
+ return this.document;
+ }
+ get tagName(): string {
+ const name = this.prefix ? `${this.prefix}:${this.localName}` : this.localName;
+ return this.namespaceURI === HTML_NAMESPACE ? name.toUpperCase() : name;
+ }
+ get nodeName(): string {
+ return this.tagName;
+ }
+ get children(): readonly Element[] {
+ return elementChildren(this);
+ }
+ get firstElementChild(): Element | null {
+ return this.children[0] ?? null;
+ }
+ get lastElementChild(): Element | null {
+ return this.children.at(-1) ?? null;
+ }
+ get childElementCount(): number {
+ return this.children.length;
+ }
+ get attributes(): readonly Attr[] {
+ return this.#attributeView;
+ }
+ get innerHTML(): string {
+ return this.ownerDocument.markupParser.serialize(
+ this instanceof HTMLTemplateElement ? this.content : this,
+ );
+ }
+ set innerHTML(value: string) {
+ withReactions(() => {
+ const fragment = this.ownerDocument.markupParser.parse(this, String(value));
+ (this instanceof HTMLTemplateElement ? this.content : this).replaceChildren(fragment);
+ });
+ }
+ get slot(): string {
+ return this.getAttribute('slot') ?? '';
+ }
+ set slot(value: string) {
+ this.setAttribute('slot', value);
+ }
+ get assignedSlot(): HTMLSlotElement | null {
+ return visibleAssignedSlot(this);
+ }
+ get tabIndex(): number {
+ return defaultTabIndex(this);
+ }
+ set tabIndex(value: number) {
+ this.setAttribute('tabindex', String(Number(value) | 0));
+ }
+ focus(): void {
+ this.ownerDocument.focusManager.focus(this);
+ }
+ blur(): void {
+ this.ownerDocument.focusManager.blur(this);
+ }
+ get id(): string {
+ return this.getAttribute('id') ?? '';
+ }
+ set id(value: string) {
+ this.setAttribute('id', value);
+ }
+ get className(): string {
+ return this.getAttribute('class') ?? '';
+ }
+ set className(value: string) {
+ this.setAttribute('class', value);
+ }
+ hasAttributes(): boolean {
+ return this.#attributes.length > 0;
+ }
+ getAttributeNames(): string[] {
+ return this.#attributes.map((attr) => attr.name);
+ }
+ getAttributeNode(name: string): Attr | null {
+ const normalized =
+ this.namespaceURI === HTML_NAMESPACE ? String(name).toLowerCase() : String(name);
+ return this.#attributes.find((attr) => attr.name === normalized) ?? null;
+ }
+ getAttributeNodeNS(namespace: string | null, localName: string): Attr | null {
+ return (
+ this.#attributes.find(
+ (attr) => attr.namespaceURI === (namespace || null) && attr.localName === localName,
+ ) ?? null
+ );
+ }
+ getAttribute(name: string): string | null {
+ return this.getAttributeNode(name)?.value ?? null;
+ }
+ getAttributeNS(namespace: string | null, localName: string): string | null {
+ return this.getAttributeNodeNS(namespace, localName)?.value ?? null;
+ }
+ hasAttribute(name: string): boolean {
+ return this.getAttributeNode(name) !== null;
+ }
+ hasAttributeNS(namespace: string | null, localName: string): boolean {
+ return this.getAttributeNodeNS(namespace, localName) !== null;
+ }
+ setAttribute(name: string, value: string): void {
+ const normalized =
+ this.namespaceURI === HTML_NAMESPACE ? String(name).toLowerCase() : String(name);
+ validateName(normalized);
+ const current = this.getAttributeNode(normalized);
+ if (current) current.value = String(value);
+ else {
+ const attr = new Attr(normalized, this.ownerDocument);
+ attr.value = String(value);
+ this.setAttributeNode(attr);
+ }
+ }
+ setAttributeNS(namespace: string | null, name: string, value: string): void {
+ const parts = qualifiedName(namespace, name);
+ const current = this.getAttributeNodeNS(parts.namespaceURI, parts.localName);
+ if (current) current.value = String(value);
+ else {
+ const attr = new Attr(parts.localName, this.ownerDocument, parts);
+ attr.value = String(value);
+ this.setAttributeNodeNS(attr);
+ }
+ }
+ setAttributeNode(attr: Attr): Attr | null {
+ return this.#setAttributeNode(attr);
+ }
+ setAttributeNodeNS(attr: Attr): Attr | null {
+ return this.#setAttributeNode(attr);
+ }
+ #setAttributeNode(attr: Attr): Attr | null {
+ if (attr.ownerElement && attr.ownerElement !== this)
+ throw new DOMException('Attribute is already in use', 'InUseAttributeError');
+ const previous = this.getAttributeNodeNS(attr.namespaceURI, attr.localName);
+ if (previous === attr) return attr;
+ if (previous) {
+ this.#attributes.splice(this.#attributes.indexOf(previous), 1, attr);
+ attributeOwners.delete(previous);
+ } else this.#attributes.push(attr);
+ attributeOwners.set(attr, this);
+ state(attr).owner = this.ownerDocument;
+ this.attributeChanged(attr, previous?.value ?? null);
+ return previous;
+ }
+ removeAttribute(name: string): void {
+ const attr = this.getAttributeNode(name);
+ if (attr) this.removeAttributeNode(attr);
+ }
+ removeAttributeNS(namespace: string | null, name: string): void {
+ const attr = this.getAttributeNodeNS(namespace, name);
+ if (attr) this.removeAttributeNode(attr);
+ }
+ removeAttributeNode(attr: Attr): Attr {
+ const index = this.#attributes.indexOf(attr);
+ if (index < 0)
+ throw new DOMException('Attribute is not owned by this element', 'NotFoundError');
+ this.#attributes.splice(index, 1);
+ attributeOwners.delete(attr);
+ this.attributeChanged(attr, attr.value);
+ return attr;
+ }
+ toggleAttribute(name: string, force?: boolean): boolean {
+ if (this.hasAttribute(name)) {
+ if (force !== true) this.removeAttribute(name);
+ } else if (force !== false) this.setAttribute(name, '');
+ return this.hasAttribute(name);
+ }
+ /** Internal notification shared by attribute setters. */
+ attributeChanged(attr: Attr, oldValue: string | null): void {
+ notifyMutation({
+ type: 'attributes',
+ target: this,
+ attributeName: attr.localName,
+ attributeNamespace: attr.namespaceURI,
+ oldValue,
+ });
+ this.notifyHost();
+ updateSlots(this);
+ this.ownerDocument.focusManager.revalidate();
+ customAttribute(
+ this,
+ attr.localName,
+ oldValue,
+ attr.ownerElement === this ? attr.value : null,
+ attr.namespaceURI,
+ );
+ flushReactions();
+ }
+ attachShadow(options: { mode: 'open' | 'closed'; delegatesFocus?: boolean }): ShadowRoot {
+ if (shadowRoots.get(this))
+ throw new DOMException('Shadow root already attached', 'NotSupportedError');
+ const root = new ShadowRoot(this, options);
+ shadowRoots.set(this, root);
+ return root;
+ }
+ get shadowRoot(): ShadowRoot | null {
+ const root = shadowRoots.get(this);
+ return root?.mode === 'open' ? root : null;
+ }
+ /** Internal access includes closed roots without exposing them on shadowRoot. */
+ protected get internalShadowRoot(): ShadowRoot | null {
+ return shadowRoots.get(this) ?? null;
+ }
+}
+
+/** An owned attribute; attributes are never children. */
+export class Attr extends Node {
+ readonly nodeType = ATTRIBUTE_NODE;
+ readonly localName: string;
+ readonly namespaceURI: string | null;
+ readonly prefix: string | null;
+ #value = '';
+ constructor(name: string, document: Document, options: Partial = {}) {
+ super(document);
+ this.localName = name;
+ this.namespaceURI = options.namespaceURI ?? null;
+ this.prefix = options.prefix ?? null;
+ }
+ get name(): string {
+ return this.prefix ? `${this.prefix}:${this.localName}` : this.localName;
+ }
+ get nodeName(): string {
+ return this.name;
+ }
+ get ownerElement(): Element | null {
+ return attributeOwners.get(this) ?? null;
+ }
+ get value(): string {
+ return this.#value;
+ }
+ set value(value: string) {
+ const old = this.#value;
+ this.#value = String(value);
+ this.ownerElement?.attributeChanged(this, old);
+ }
+ override get nodeValue(): string {
+ return this.value;
+ }
+ override set nodeValue(value: string | null) {
+ this.value = value ?? '';
+ }
+ override get textContent(): string {
+ return this.value;
+ }
+ override set textContent(value: string | null) {
+ this.value = value ?? '';
+ }
+}
+const attributeOwners = new WeakMap();
+
+/** UTF-16 character data shared by Text and Comment. */
+export abstract class CharacterData extends Node {
+ #data: string;
+ constructor(data: string, document: Document) {
+ super(document);
+ this.#data = String(data);
+ }
+ get data(): string {
+ return this.#data;
+ }
+ set data(value: string) {
+ this.replaceData(0, this.length, String(value));
+ }
+ get length(): number {
+ return this.#data.length;
+ }
+ override get nodeValue(): string {
+ return this.data;
+ }
+ override set nodeValue(value: string | null) {
+ this.data = value ?? '';
+ }
+ override get textContent(): string {
+ return this.data;
+ }
+ override set textContent(value: string | null) {
+ this.data = value ?? '';
+ }
+ substringData(offset: number, count: number): string {
+ offset = checkOffset(this, offset);
+ return this.data.slice(offset, offset + (Number(count) >>> 0));
+ }
+ appendData(data: string): void {
+ this.replaceData(this.length, 0, data);
+ }
+ insertData(offset: number, data: string): void {
+ this.replaceData(offset, 0, data);
+ }
+ deleteData(offset: number, count: number): void {
+ this.replaceData(offset, count, '');
+ }
+ replaceData(offset: number, count: number, data: string): void {
+ offset = checkOffset(this, offset);
+ const removed = Math.min(Number(count) >>> 0, this.length - offset);
+ const added = String(data);
+ const oldValue = this.#data;
+ notifyTextChange(this, offset, removed, added.length);
+ this.#data = oldValue.slice(0, offset) + added + oldValue.slice(offset + removed);
+ notifyMutation({ type: 'characterData', target: this, oldValue });
+ if (oldValue !== this.#data) this.notifyHost();
+ }
+}
+function checkOffset(node: CharacterData, offset: number): number {
+ const converted = Number(offset) >>> 0;
+ if (converted > node.length)
+ throw new DOMException('Offset is outside character data', 'IndexSizeError');
+ return converted;
+}
+/** A mutable text node. */
+export class Text extends CharacterData {
+ readonly nodeType = TEXT_NODE;
+ readonly nodeName = '#text';
+ get assignedSlot(): HTMLSlotElement | null {
+ return visibleAssignedSlot(this);
+ }
+ splitText(offset: number): Text {
+ offset = checkOffset(this, offset);
+ const result = this.document.createTextNode(this.data.slice(offset));
+ this.parentNode?.insertBefore(result, this.nextSibling);
+ notifyTextSplit(this, result, offset);
+ this.deleteData(offset, this.length - offset);
+ return result;
+ }
+}
+/** A comment, excluded from ancestor textContent. */
+export class Comment extends CharacterData {
+ readonly nodeType = COMMENT_NODE;
+ readonly nodeName = '#comment';
+}
+/** A detached container whose children are moved on insertion. */
+export class DocumentFragment extends Node {
+ readonly nodeType = DOCUMENT_FRAGMENT_NODE;
+ readonly nodeName = '#document-fragment';
+ get children(): readonly Element[] {
+ return elementChildren(this);
+ }
+ get firstElementChild(): Element | null {
+ return this.children[0] ?? null;
+ }
+ getElementById(id: string): Element | null {
+ return descendants(this).find((node) => node.id === id) ?? null;
+ }
+}
+
+/** A shadow tree owned by its host document. */
+export class ShadowRoot extends DocumentFragment {
+ readonly host: Element;
+ readonly mode: 'open' | 'closed';
+ readonly delegatesFocus: boolean;
+ constructor(host: Element, options: { mode: 'open' | 'closed'; delegatesFocus?: boolean }) {
+ super(host.ownerDocument);
+ this.host = host;
+ this.mode = options.mode;
+ this.delegatesFocus = options.delegatesFocus ?? false;
+ }
+ get innerHTML(): string {
+ return this.host.ownerDocument.markupParser.serialize(this);
+ }
+ set innerHTML(value: string) {
+ withReactions(() =>
+ this.replaceChildren(this.host.ownerDocument.markupParser.parse(this, String(value))),
+ );
+ }
+ get activeElement(): Element | null {
+ return this.host.ownerDocument.focusManager.activeElementFor(this);
+ }
+ protected override getEventParent(event: DOMEvent): EventTarget | null {
+ return !event.composed && event.target instanceof Node && event.target.getRootNode() === this
+ ? null
+ : this.host;
+ }
+ override cloneNode(): never {
+ throw new DOMException('Cannot clone a shadow root', 'NotSupportedError');
+ }
+}
+
+function adopt(node: Node, owner: Document): void {
+ const oldOwner = state(node).owner;
+ state(node).owner = owner;
+ if (node instanceof Element) {
+ for (const attr of node.attributes) state(attr).owner = owner;
+ const shadow = getShadowRoot(node);
+ if (shadow) adopt(shadow, owner);
+ if (node instanceof HTMLTemplateElement) adopt(node.content, owner.templateOwnerDocument);
+ if (oldOwner && oldOwner !== owner) customAdopted(node, oldOwner, owner);
+ }
+ for (const child of node.childNodes) adopt(child, owner);
+}
+function connectionChanged(node: Node, connected: boolean): void {
+ if (!connected || node.isConnected) customConnection(node, connected);
+ flushReactions();
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Internal DOM cloning algorithm arguments.
+function clone(node: Node, owner: Document, deep: boolean): Node {
+ let copy: Node;
+ if (node instanceof Document) copy = new Document();
+ else if (node instanceof ShadowRoot)
+ throw new DOMException('Cannot clone a shadow root', 'NotSupportedError');
+ else if (node instanceof Element) {
+ const element = owner.createElementNS(
+ node.namespaceURI,
+ node.prefix ? `${node.prefix}:${node.localName}` : node.localName,
+ );
+ for (const attr of node.attributes) element.setAttributeNodeNS(cloneAttribute(attr, owner));
+ if (deep && node instanceof HTMLTemplateElement && element instanceof HTMLTemplateElement)
+ for (const child of node.content.childNodes)
+ element.content.appendChild(clone(child, element.content.ownerDocument ?? owner, true));
+ copy = element;
+ } else if (node instanceof Text) copy = owner.createTextNode(node.data);
+ else if (node instanceof Comment) copy = owner.createComment(node.data);
+ else if (node instanceof Attr) copy = cloneAttribute(node, owner);
+ else copy = owner.createDocumentFragment();
+ if (deep)
+ for (const child of node.childNodes)
+ copy.appendChild(clone(child, copy instanceof Document ? copy : owner, true));
+ if (node instanceof Element && copy instanceof Element) cloneTextControlState(node, copy);
+ return copy;
+}
+function cloneAttribute(attr: Attr, owner: Document): Attr {
+ const result = new Attr(attr.localName, owner, {
+ namespaceURI: attr.namespaceURI,
+ prefix: attr.prefix,
+ });
+ result.value = attr.value;
+ return result;
+}
+/** Elements in light-tree order, excluding the root. */
+export function descendants(root: Node): Element[] {
+ const result: Element[] = [];
+ for (const child of root.childNodes) {
+ if (child instanceof Element) result.push(child);
+ result.push(...descendants(child));
+ }
+ return result;
+}
+
+const shadowRoots = new WeakMap();
+/** @internal Shared shadow access for DOM subsystems, including closed roots. */
+export function getShadowRoot(element: Element): ShadowRoot | null {
+ return shadowRoots.get(element) ?? null;
+}
+
+/** Editable text control algorithms shared by supported input/textarea elements. */
+export abstract class TextControlElement extends Element {
+ protected abstract readonly control: TextControlState;
+ get value(): string {
+ return this.control.value;
+ }
+ set value(value: string) {
+ this.control.value = value;
+ }
+ get selectionStart(): number | null {
+ return this.control.selectionStart;
+ }
+ set selectionStart(value: number | null) {
+ this.control.selectionStart = value;
+ }
+ get selectionEnd(): number | null {
+ return this.control.selectionEnd;
+ }
+ set selectionEnd(value: number | null) {
+ this.control.selectionEnd = value;
+ }
+ get selectionDirection(): SelectionDirection | null {
+ return this.control.selectionDirection;
+ }
+ set selectionDirection(value: SelectionDirection | null) {
+ this.control.selectionDirection = value;
+ }
+ select(): void {
+ this.control.select();
+ }
+ setSelectionRange(start: number, end: number, direction?: SelectionDirection): void {
+ this.control.setSelectionRange(start, end, direction);
+ }
+ setRangeText(replacement: string): void;
+ setRangeText(
+ replacement: string,
+ start: number,
+ end: number,
+ selectionMode?: SelectionMode,
+ ): void;
+ setRangeText(
+ replacement: string,
+ start?: number,
+ end?: number,
+ selectionMode?: SelectionMode,
+ ): void {
+ this.control.setRangeText(replacement, { start, end, selectionMode });
+ }
+}
+/** Input text values and selection, without browser form submission or validation. */
+export class HTMLInputElement extends TextControlElement {
+ protected readonly control: TextControlState;
+ constructor(document: Document) {
+ super('input', document);
+ this.control = new TextControlState({
+ element: this,
+ multiline: false,
+ defaultValue: () => this.defaultValue,
+ supportsSelection: () => ['text', 'search', 'url', 'tel', 'password'].includes(this.type),
+ });
+ }
+ get type(): string {
+ const value = this.getAttribute('type')?.toLowerCase() ?? 'text';
+ return [
+ 'hidden',
+ 'text',
+ 'search',
+ 'tel',
+ 'url',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ 'range',
+ 'color',
+ 'checkbox',
+ 'radio',
+ 'file',
+ 'submit',
+ 'image',
+ 'reset',
+ 'button',
+ ].includes(value)
+ ? value
+ : 'text';
+ }
+ set type(value: string) {
+ this.setAttribute('type', value);
+ }
+ get defaultValue(): string {
+ return this.getAttribute('value') ?? '';
+ }
+ set defaultValue(value: string) {
+ this.setAttribute('value', value);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+}
+/** Multiline text values and selection. */
+export class HTMLTextAreaElement extends TextControlElement {
+ protected readonly control: TextControlState;
+ constructor(document: Document) {
+ super('textarea', document);
+ this.control = new TextControlState({
+ element: this,
+ multiline: true,
+ defaultValue: () => this.defaultValue,
+ });
+ }
+ get defaultValue(): string {
+ return this.textContent ?? '';
+ }
+ set defaultValue(value: string) {
+ this.textContent = value;
+ syncTextControlDefault(this);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+}
+
+/** Inert template contents are separate from the template's light-tree children. */
+export class HTMLTemplateElement extends Element {
+ readonly content: DocumentFragment;
+ constructor(document: Document) {
+ super('template', document);
+ this.content = document.templateOwnerDocument.createDocumentFragment();
+ }
+}
+/** Named slot distribution without rendering or hit testing. */
+export class HTMLSlotElement extends Element {
+ constructor(document: Document) {
+ super('slot', document);
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ assignedNodes(options: { flatten?: boolean } = {}): readonly Node[] {
+ const root = this.getRootNode();
+ let nodes: readonly Node[] =
+ root instanceof ShadowRoot
+ ? root.host.childNodes.filter((node) => getAssignedSlot(node) === this)
+ : [];
+ if (options.flatten) {
+ if (!nodes.length)
+ nodes = this.childNodes.filter((node) => node instanceof Element || node instanceof Text);
+ nodes = nodes.flatMap((node) =>
+ node instanceof HTMLSlotElement ? [...node.assignedNodes({ flatten: true })] : [node],
+ );
+ }
+ return Object.freeze([...nodes]);
+ }
+ assignedElements(options: { flatten?: boolean } = {}): readonly Element[] {
+ return Object.freeze(
+ this.assignedNodes(options).filter((node): node is Element => node instanceof Element),
+ );
+ }
+}
+/** @internal Resolve slot assignment including closed roots. */
+export function getAssignedSlot(node: Node): HTMLSlotElement | null {
+ if (!(node instanceof Element || node instanceof Text) || !(node.parentNode instanceof Element))
+ return null;
+ const root = getShadowRoot(node.parentNode);
+ if (!root) return null;
+ const name = node instanceof Element ? node.slot : '';
+ return (
+ descendants(root).find(
+ (element): element is HTMLSlotElement =>
+ element instanceof HTMLSlotElement && element.name === name,
+ ) ?? null
+ );
+}
+const slotAssignments = new WeakMap();
+const pendingSlots = new Set();
+function updateSlots(node: Node): void {
+ const visit = (root: Node): void => {
+ if (root instanceof HTMLSlotElement) {
+ const previous = slotAssignments.get(root) ?? [];
+ const assigned = root.assignedNodes();
+ const next = assigned.length
+ ? assigned
+ : root.childNodes.filter((child) => child instanceof Element || child instanceof Text);
+ slotAssignments.set(root, next);
+ if (
+ previous.length !== next.length ||
+ previous.some((value, index) => value !== next[index])
+ ) {
+ if (!pendingSlots.size)
+ queueMicrotask(() => {
+ const slots = [...pendingSlots];
+ pendingSlots.clear();
+ for (const slot of slots)
+ slot.dispatchEvent(new Event('slotchange', { bubbles: true }));
+ });
+ pendingSlots.add(root);
+ }
+ }
+ if (root instanceof Element) {
+ const shadow = getShadowRoot(root);
+ if (shadow) visit(shadow);
+ }
+ for (const child of root.childNodes) visit(child);
+ };
+ visit(node.getRootNode({ composed: true }));
+}
+
+function visibleAssignedSlot(node: Node): HTMLSlotElement | null {
+ const slot = getAssignedSlot(node);
+ const root = slot?.getRootNode();
+ return root instanceof ShadowRoot && root.mode === 'closed' ? null : slot;
+}
+
+function readonlyCollection(read: () => readonly T[]): readonly T[] {
+ const snapshot = (): readonly T[] => Object.freeze([...read()]);
+ const reject = (): false => false;
+ return new Proxy([], {
+ get(_target, key) {
+ const values = snapshot();
+ const value: unknown = Reflect.get(values, key);
+ return typeof value === 'function' && key !== 'constructor' ? value.bind(values) : value;
+ },
+ has(_target, key) {
+ return Reflect.has(snapshot(), key);
+ },
+ ownKeys() {
+ return Reflect.ownKeys(snapshot());
+ },
+ getOwnPropertyDescriptor(_target, key) {
+ const descriptor = Reflect.getOwnPropertyDescriptor([...read()], key);
+ return descriptor;
+ },
+ set: reject,
+ deleteProperty: reject,
+ defineProperty: reject,
+ preventExtensions: reject,
+ setPrototypeOf: reject,
+ });
+}
+
+const elementCollections = new WeakMap();
+function elementChildren(node: Node): readonly Element[] {
+ let collection = elementCollections.get(node);
+ if (!collection) {
+ collection = readonlyCollection(() =>
+ state(node).children.filter((child): child is Element => child instanceof Element),
+ );
+ elementCollections.set(node, collection);
+ }
+ return collection;
+}
+
+// oxlint-disable-next-line bombshell-dev/max-params -- Validate the complete variadic mutation before transferring any existing node.
+function validateSequence(
+ parent: Node,
+ nodes: readonly (Node | string)[],
+ options: { replaced?: Node } = {},
+): void {
+ for (const node of nodes)
+ if (typeof node !== 'string') validateInsertion(parent, node, null, options.replaced);
+ if (!(parent instanceof Document)) return;
+ if (nodes.some((node) => typeof node === 'string'))
+ throw new DOMException('Document cannot contain text', 'HierarchyRequestError');
+ const incoming = nodes.flatMap((node) =>
+ typeof node === 'string'
+ ? []
+ : node instanceof DocumentFragment
+ ? [...node.childNodes]
+ : [node],
+ );
+ const final = new Set([
+ ...parent.childNodes.filter((node) => node !== options.replaced && !incoming.includes(node)),
+ ...incoming,
+ ]);
+ if ([...final].filter((node) => node instanceof Element).length > 1)
+ throw new DOMException('Multiple document elements', 'HierarchyRequestError');
+}
diff --git a/packages/dom/src/events.ts b/packages/dom/src/events.ts
new file mode 100644
index 0000000..19457a4
--- /dev/null
+++ b/packages/dom/src/events.ts
@@ -0,0 +1,476 @@
+export interface EventInit {
+ bubbles?: boolean;
+ cancelable?: boolean;
+ composed?: boolean;
+}
+
+export type EventPhase = 0 | 1 | 2 | 3;
+
+interface PathEntry {
+ invocationTarget: EventTarget;
+ target: EventTarget | null;
+ relatedTarget: EventTarget | null;
+ closedRoots: readonly EventTarget[];
+ atTarget: boolean;
+}
+
+interface EventState {
+ target: EventTarget | null;
+ currentTarget: EventTarget | null;
+ relatedTarget: EventTarget | null;
+ path: PathEntry[];
+ phase: EventPhase;
+ dispatching: boolean;
+ stopped: boolean;
+ immediateStopped: boolean;
+ passive: boolean;
+ canceled: boolean;
+}
+
+const states = new WeakMap();
+
+function stateOf(event: Event): EventState {
+ const state = states.get(event);
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL rejects incompatible event objects with TypeError.
+ if (!state) throw new TypeError('Expected an @bomb.sh/dom Event');
+ return state;
+}
+
+/** Synthetic events are extensible, so frameworks can attach their bookkeeping. */
+export class Event {
+ static readonly NONE = 0;
+ static readonly CAPTURING_PHASE = 1;
+ static readonly AT_TARGET = 2;
+ static readonly BUBBLING_PHASE = 3;
+ readonly NONE = 0;
+ readonly CAPTURING_PHASE = 1;
+ readonly AT_TARGET = 2;
+ readonly BUBBLING_PHASE = 3;
+ readonly type: string;
+ readonly bubbles: boolean;
+ readonly cancelable: boolean;
+ readonly composed: boolean;
+ readonly timeStamp = performance.now();
+
+ constructor(type: string, options: EventInit = {}) {
+ this.type = String(type);
+ this.bubbles = Boolean(options.bubbles);
+ this.cancelable = Boolean(options.cancelable);
+ this.composed = Boolean(options.composed);
+ states.set(this, {
+ target: null,
+ currentTarget: null,
+ relatedTarget: null,
+ path: [],
+ phase: Event.NONE,
+ dispatching: false,
+ stopped: false,
+ immediateStopped: false,
+ passive: false,
+ canceled: false,
+ });
+ }
+
+ get target(): EventTarget | null {
+ return stateOf(this).target;
+ }
+
+ get srcElement(): EventTarget | null {
+ return this.target;
+ }
+
+ get currentTarget(): EventTarget | null {
+ return stateOf(this).currentTarget;
+ }
+
+ get eventPhase(): EventPhase {
+ return stateOf(this).phase;
+ }
+
+ get isTrusted(): false {
+ return false;
+ }
+
+ get defaultPrevented(): boolean {
+ return stateOf(this).canceled;
+ }
+
+ get cancelBubble(): boolean {
+ return stateOf(this).stopped;
+ }
+
+ set cancelBubble(value: boolean) {
+ if (value) this.stopPropagation();
+ }
+
+ get returnValue(): boolean {
+ return !this.defaultPrevented;
+ }
+
+ set returnValue(value: boolean) {
+ if (!value) this.preventDefault();
+ }
+
+ composedPath(): EventTarget[] {
+ const state = stateOf(this);
+ const current = state.path.find((entry) => entry.invocationTarget === state.currentTarget);
+ if (!current) return [];
+ return state.path
+ .filter((entry) => entry.closedRoots.every((root) => current.closedRoots.includes(root)))
+ .map((entry) => entry.invocationTarget);
+ }
+
+ stopPropagation(): void {
+ stateOf(this).stopped = true;
+ }
+
+ stopImmediatePropagation(): void {
+ const state = stateOf(this);
+ state.stopped = true;
+ state.immediateStopped = true;
+ }
+
+ preventDefault(): void {
+ const state = stateOf(this);
+ if (this.cancelable && !state.passive) state.canceled = true;
+ }
+}
+
+export interface CustomEventInit extends EventInit {
+ detail?: T;
+}
+
+/** An event carrying application-defined detail. */
+export class CustomEvent extends Event {
+ readonly detail: T | null;
+
+ constructor(type: string, options: CustomEventInit = {}) {
+ super(type, options);
+ this.detail = options.detail === undefined ? null : options.detail;
+ }
+}
+
+export interface FocusEventInit extends EventInit {
+ relatedTarget?: EventTarget | null;
+}
+
+/** A focus transition event with a retargetable related target. */
+export class FocusEvent extends Event {
+ constructor(type: string, options: FocusEventInit = {}) {
+ super(type, options);
+ stateOf(this).relatedTarget = options.relatedTarget ?? null;
+ }
+
+ get relatedTarget(): EventTarget | null {
+ return stateOf(this).relatedTarget;
+ }
+}
+
+export interface KeyboardEventInit extends EventInit {
+ key?: string;
+ code?: string;
+ location?: number;
+ ctrlKey?: boolean;
+ shiftKey?: boolean;
+ altKey?: boolean;
+ metaKey?: boolean;
+ repeat?: boolean;
+ isComposing?: boolean;
+}
+
+/** Synthetic keyboard data without browser default actions. */
+export class KeyboardEvent extends Event {
+ static readonly DOM_KEY_LOCATION_STANDARD = 0;
+ static readonly DOM_KEY_LOCATION_LEFT = 1;
+ static readonly DOM_KEY_LOCATION_RIGHT = 2;
+ static readonly DOM_KEY_LOCATION_NUMPAD = 3;
+ readonly DOM_KEY_LOCATION_STANDARD = 0;
+ readonly DOM_KEY_LOCATION_LEFT = 1;
+ readonly DOM_KEY_LOCATION_RIGHT = 2;
+ readonly DOM_KEY_LOCATION_NUMPAD = 3;
+ readonly key: string;
+ readonly code: string;
+ readonly location: number;
+ readonly ctrlKey: boolean;
+ readonly shiftKey: boolean;
+ readonly altKey: boolean;
+ readonly metaKey: boolean;
+ readonly repeat: boolean;
+ readonly isComposing: boolean;
+
+ constructor(type: string, options: KeyboardEventInit = {}) {
+ super(type, options);
+ this.key = String(options.key ?? '');
+ this.code = String(options.code ?? '');
+ this.location = Number(options.location ?? 0) >>> 0;
+ this.ctrlKey = Boolean(options.ctrlKey);
+ this.shiftKey = Boolean(options.shiftKey);
+ this.altKey = Boolean(options.altKey);
+ this.metaKey = Boolean(options.metaKey);
+ this.repeat = Boolean(options.repeat);
+ this.isComposing = Boolean(options.isComposing);
+ }
+
+ getModifierState(key: string): boolean {
+ switch (key) {
+ case 'Alt':
+ return this.altKey;
+ case 'Control':
+ return this.ctrlKey;
+ case 'Meta':
+ return this.metaKey;
+ case 'Shift':
+ return this.shiftKey;
+ default:
+ return false;
+ }
+ }
+}
+
+export interface EventListener {
+ (this: Target, event: Event): void;
+}
+
+export interface EventListenerObject {
+ handleEvent(event: Event): void;
+}
+
+export type EventListenerOrEventListenerObject =
+ | EventListener
+ | EventListenerObject;
+
+export interface EventListenerOptions {
+ capture?: boolean;
+}
+
+export interface AddEventListenerOptions extends EventListenerOptions {
+ once?: boolean;
+ passive?: boolean;
+ signal?: AbortSignal;
+}
+
+interface Registration {
+ type: string;
+ callback: object;
+ invoke: (event: Event) => void;
+ capture: boolean;
+ once: boolean;
+ passive: boolean;
+ removed: boolean;
+ signal?: AbortSignal;
+ abort?: () => void;
+}
+
+export interface EventTargetOptions {
+ /** Listener exceptions are reported here without escaping dispatchEvent. */
+ reportError?: (error: unknown) => void;
+}
+
+function reportUnhandledError(error: unknown): void {
+ queueMicrotask(() => {
+ throw error;
+ });
+}
+
+/** Listener registration and capture/target/bubble event dispatch. */
+export class EventTarget {
+ readonly #listeners: Registration[] = [];
+ readonly #reportError: (error: unknown) => void;
+
+ constructor(options: EventTargetOptions = {}) {
+ this.#reportError = options.reportError ?? reportUnhandledError;
+ }
+
+ // oxlint-disable-next-line bombshell-dev/max-params -- Standard EventTarget signature.
+ addEventListener(
+ type: string,
+ callback: EventListenerOrEventListenerObject | null,
+ options: boolean | AddEventListenerOptions = {},
+ ): void {
+ const name = String(type);
+ const normalized = typeof options === 'boolean' ? { capture: options } : (options ?? {});
+ const capture = Boolean(normalized.capture);
+ const once = Boolean(normalized.once);
+ const passive = Boolean(normalized.passive);
+ const providedSignal = normalized.signal;
+ // Dictionary conversion precedes the null-listener and duplicate checks.
+ // A private dependent signal follows the source's abort algorithm even
+ // when a user listener stops propagation of its public abort event.
+ const signal = providedSignal === undefined ? undefined : AbortSignal.any([providedSignal]);
+ if (callback == null || signal?.aborted) return;
+ if (
+ this.#listeners.some(
+ (entry) =>
+ entry.type === name &&
+ entry.callback === callback &&
+ entry.capture === capture &&
+ !entry.signal?.aborted,
+ )
+ )
+ return;
+ const registration: Registration = {
+ type: name,
+ callback,
+ invoke: (event) => {
+ if (typeof callback === 'function') callback.call(this, event);
+ else callback.handleEvent(event);
+ },
+ capture,
+ once,
+ passive,
+ removed: false,
+ signal,
+ };
+ if (registration.signal) {
+ registration.abort = () => this.#removeRegistration(registration);
+ registration.signal.addEventListener('abort', registration.abort, { once: true });
+ }
+ this.#listeners.push(registration);
+ }
+
+ // oxlint-disable-next-line bombshell-dev/max-params -- Standard EventTarget signature.
+ removeEventListener(
+ type: string,
+ callback: EventListenerOrEventListenerObject | null,
+ options: boolean | EventListenerOptions = {},
+ ): void {
+ const capture = typeof options === 'boolean' ? options : Boolean(options?.capture);
+ const name = String(type);
+ const registration = this.#listeners.find(
+ (entry) => entry.type === name && entry.callback === callback && entry.capture === capture,
+ );
+ if (registration) this.#removeRegistration(registration);
+ }
+
+ dispatchEvent(event: Event): boolean {
+ const state = stateOf(event);
+ if (state.dispatching)
+ throw new DOMException('The event is already being dispatched', 'InvalidStateError');
+ state.dispatching = true;
+ state.target = this;
+ const originalRelatedTarget = state.relatedTarget;
+ let clearTargets = false;
+ try {
+ const visited = new Set();
+ for (
+ // oxlint-disable-next-line typescript/no-this-alias -- Mutable cursor for the event ancestor path.
+ let current: EventTarget | null = this;
+ current;
+ current = current.getEventParent(event)
+ ) {
+ if (visited.has(current))
+ throw new DOMException('The event parent path contains a cycle', 'HierarchyRequestError');
+ visited.add(current);
+ const target = current.retargetEventTarget(this);
+ const relatedTarget = current.retargetEventTarget(originalRelatedTarget);
+ if (target === relatedTarget && originalRelatedTarget !== this) break;
+ state.path.push({
+ invocationTarget: current,
+ target,
+ relatedTarget,
+ closedRoots: [...current.getEventClosedRoots()],
+ atTarget: target === current,
+ });
+ }
+ const terminal = state.path.at(-1);
+ clearTargets = Boolean(
+ terminal?.target?.isEventTargetInShadowTree() ||
+ terminal?.relatedTarget?.isEventTargetInShadowTree(),
+ );
+ for (const entry of state.path.toReversed()) this.#invoke({ event, entry, capture: true });
+ for (const entry of state.path) {
+ if (entry.atTarget || event.bubbles) this.#invoke({ event, entry, capture: false });
+ }
+ return !state.canceled;
+ } finally {
+ state.phase = Event.NONE;
+ state.currentTarget = null;
+ state.path = [];
+ state.dispatching = false;
+ state.stopped = false;
+ state.immediateStopped = false;
+ state.passive = false;
+ if (clearTargets) {
+ state.target = null;
+ state.relatedTarget = null;
+ }
+ }
+ }
+
+ /** Tree subclasses supply their event parent; plain targets have no parent. */
+ protected getEventParent(_event: Event): EventTarget | null {
+ return null;
+ }
+
+ /** Retarget against this invocation target before any listeners run. */
+ protected retargetEventTarget(target: EventTarget | null): EventTarget | null {
+ return target;
+ }
+
+ /** Closed shadow roots containing this target, captured with the event path. */
+ protected getEventClosedRoots(): readonly EventTarget[] {
+ return [];
+ }
+
+ /** Used to clear target references after dispatch confined to a shadow tree. */
+ protected isEventTargetInShadowTree(): boolean {
+ return false;
+ }
+
+ protected reportEventError(error: unknown): void {
+ this.#reportError(error);
+ }
+
+ #removeRegistration(registration: Registration): void {
+ registration.removed = true;
+ const index = this.#listeners.indexOf(registration);
+ if (index !== -1) this.#listeners.splice(index, 1);
+ if (registration.signal && registration.abort) {
+ registration.signal.removeEventListener('abort', registration.abort);
+ }
+ }
+
+ #invoke(options: { event: Event; entry: PathEntry; capture: boolean }): void {
+ const { event, entry, capture } = options;
+ const state = stateOf(event);
+ state.target = entry.target;
+ state.relatedTarget = entry.relatedTarget;
+ state.phase = entry.atTarget
+ ? Event.AT_TARGET
+ : capture
+ ? Event.CAPTURING_PHASE
+ : Event.BUBBLING_PHASE;
+ if (state.stopped) return;
+ const target = entry.invocationTarget;
+ state.currentTarget = target;
+ // oxlint-disable-next-line unicorn/no-useless-spread -- DOM requires a snapshot per invocation.
+ for (const registration of [...target.#listeners]) {
+ // Native dependent signals become aborted before the source's public
+ // abort listeners run, even if our removal callback has not run yet.
+ if (registration.signal?.aborted) {
+ target.#removeRegistration(registration);
+ continue;
+ }
+ if (
+ registration.removed ||
+ registration.type !== event.type ||
+ registration.capture !== capture
+ )
+ continue;
+ if (registration.once) target.#removeRegistration(registration);
+ state.passive = registration.passive;
+ try {
+ registration.invoke(event);
+ } catch (error) {
+ try {
+ target.reportEventError(error);
+ } catch (reportingError) {
+ reportUnhandledError(reportingError);
+ }
+ } finally {
+ state.passive = false;
+ }
+ if (state.immediateStopped) break;
+ }
+ }
+}
diff --git a/packages/dom/src/focus.ts b/packages/dom/src/focus.ts
new file mode 100644
index 0000000..a374fed
--- /dev/null
+++ b/packages/dom/src/focus.ts
@@ -0,0 +1,324 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- DOM focus eligibility and reflected properties are synchronous. */
+import {
+ Document,
+ Element,
+ HTMLSlotElement,
+ type Node,
+ ShadowRoot,
+ getAssignedSlot,
+ getShadowRoot,
+} from './dom.ts';
+import { FocusEvent, KeyboardEvent, type KeyboardEventInit } from './events.ts';
+
+function parentAcrossShadow(node: Node): Node | null {
+ return (
+ getAssignedSlot(node) ?? node.parentNode ?? (node instanceof ShadowRoot ? node.host : null)
+ );
+}
+
+function shadowContains(ancestor: Node, node: Node): boolean {
+ for (let current: Node | null = node; current; current = parentAcrossShadow(current)) {
+ if (current === ancestor) return true;
+ }
+ return false;
+}
+
+function shadowIncludingContains(ancestor: Node, node: Node): boolean {
+ for (
+ let current: Node | null = node;
+ current;
+ current = current.parentNode ?? (current instanceof ShadowRoot ? current.host : null)
+ ) {
+ if (current === ancestor) return true;
+ }
+ return false;
+}
+
+/** Logical headless eligibility, without CSS visibility or geometry checks. */
+export function isFocusable(element: Element): boolean {
+ if (!element.isConnected) return false;
+ if (getShadowRoot(element)?.delegatesFocus) return false;
+ const nativeControl = ['button', 'input', 'select', 'textarea', 'option', 'optgroup'].includes(
+ element.localName,
+ );
+ for (let current: Node | null = element; current; current = parentAcrossShadow(current)) {
+ if (!(current instanceof Element)) continue;
+ if (
+ current.parentNode instanceof Element &&
+ getShadowRoot(current.parentNode) &&
+ !getAssignedSlot(current)
+ )
+ return false;
+ if (current.hasAttribute('hidden') || current.hasAttribute('inert')) return false;
+ if (
+ nativeControl &&
+ current.localName === 'fieldset' &&
+ current.hasAttribute('disabled') &&
+ current.contains(element)
+ ) {
+ const legend = current.children.find((child) => child.localName === 'legend');
+ if (!legend?.contains(element)) return false;
+ }
+ }
+ if (nativeControl && element.hasAttribute('disabled')) return false;
+ if (element.localName === 'input' && element.getAttribute('type')?.toLowerCase() === 'hidden')
+ return false;
+ if (explicitTabIndex(element) !== null) return true;
+ if (['input', 'button', 'select', 'textarea'].includes(element.localName)) return true;
+ if (['a', 'area'].includes(element.localName) && element.hasAttribute('href')) return true;
+ const editable = element.getAttribute('contenteditable')?.toLowerCase();
+ return editable === '' || editable === 'true' || editable === 'plaintext-only';
+}
+
+/** Reflected tabindex default for the supported headless focusable elements. */
+export function defaultTabIndex(element: Element): number {
+ const explicit = explicitTabIndex(element);
+ if (explicit !== null && explicit >= -2147483648 && explicit <= 2147483647) return explicit;
+ return [
+ 'a',
+ 'area',
+ 'input',
+ 'button',
+ 'select',
+ 'textarea',
+ 'frame',
+ 'iframe',
+ 'object',
+ ].includes(element.localName)
+ ? 0
+ : -1;
+}
+
+function explicitTabIndex(element: Element): number | null {
+ const value = element.getAttribute('tabindex');
+ if (value === null || !/^[\t\n\f\r ]*[+-]?\d+/.test(value)) return null;
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+/** Independent focus state and logical navigation for one document. */
+export class FocusManager {
+ readonly #document: Document;
+ #active: Element | null = null;
+ #transition = 0;
+
+ constructor(document: Document) {
+ this.#document = document;
+ }
+
+ /** The focused element inside shadow trees, without retargeting to a host. */
+ get activeElement(): Element | null {
+ return this.#active;
+ }
+
+ activeElementFor(root: Document | ShadowRoot): Element | null {
+ let active = this.#active;
+ if (!active) return root instanceof Document ? (root.body ?? root.documentElement) : null;
+ while (active.getRootNode() !== root) {
+ const activeRoot = active.getRootNode();
+ if (!(activeRoot instanceof ShadowRoot)) return null;
+ active = activeRoot.host;
+ }
+ return active;
+ }
+
+ focus(element: Element | null): void {
+ if (element && element.ownerDocument !== this.#document) return;
+ if (element) {
+ const shadow = getShadowRoot(element);
+ if (shadow?.delegatesFocus) {
+ element =
+ this.#active && shadowIncludingContains(element, this.#active)
+ ? this.#active
+ : (this.#delegate(shadow) ?? element);
+ }
+ if (!isFocusable(element)) return;
+ }
+ if (element === this.#active) return;
+ const previous = this.#active;
+ const transition = ++this.#transition;
+ this.#active = null;
+ if (previous) {
+ this.#invalidate(previous);
+ previous.dispatchEvent(new FocusEvent('blur', { relatedTarget: element, composed: true }));
+ if (transition !== this.#transition) return;
+ previous.dispatchEvent(
+ new FocusEvent('focusout', { relatedTarget: element, bubbles: true, composed: true }),
+ );
+ if (transition !== this.#transition) return;
+ }
+ if (!element || !isFocusable(element)) return;
+ this.#active = element;
+ this.#invalidate(element);
+ element.dispatchEvent(new FocusEvent('focus', { relatedTarget: previous, composed: true }));
+ if (transition !== this.#transition || this.#active !== element) return;
+ element.dispatchEvent(
+ new FocusEvent('focusin', { relatedTarget: previous, bubbles: true, composed: true }),
+ );
+ }
+
+ blur(element: Element): void {
+ const shadow = getShadowRoot(element);
+ if (
+ this.#active === element ||
+ (shadow?.delegatesFocus && this.#active && shadowIncludingContains(shadow, this.#active))
+ )
+ this.focus(null);
+ }
+
+ #delegate(root: ShadowRoot): Element | null {
+ const elements: Element[] = [];
+ const visit = (node: Node): void => {
+ for (const child of node.childNodes) {
+ if (child instanceof Element) elements.push(child);
+ visit(child);
+ }
+ };
+ visit(root);
+ const resolve = (element: Element): Element | null => {
+ if (isFocusable(element)) return element;
+ const shadow = getShadowRoot(element);
+ return shadow?.delegatesFocus ? this.#delegate(shadow) : null;
+ };
+ for (const element of elements) {
+ if (!element.hasAttribute('autofocus')) continue;
+ const result = resolve(element);
+ if (result) return result;
+ }
+ for (const element of elements) {
+ const result = resolve(element);
+ if (result) return result;
+ }
+ return null;
+ }
+
+ /** Removing focused content fixes focus without manufacturing blur events. */
+ removing(node: Node): void {
+ if (this.#active && shadowContains(node, this.#active)) {
+ const previous = this.#active;
+ this.#active = null;
+ this.#transition++;
+ this.#invalidate(previous);
+ }
+ }
+
+ /** Called after eligibility-affecting attributes change. */
+ revalidate(): void {
+ if (this.#active && !isFocusable(this.#active)) this.focus(null);
+ }
+
+ focusableElements(): readonly Element[] {
+ return Object.freeze(this.#scope(this.#document));
+ }
+
+ /** Moves through the logical order, wrapping at either end of the document. */
+ moveFocus(backward = false): Element | null {
+ const candidates = this.focusableElements();
+ if (!candidates.length) return null;
+ const current = this.#active ? candidates.indexOf(this.#active) : -1;
+ if (current < 0 && this.#active) {
+ const tree = this.#collect(this.#document);
+ const position = tree.indexOf(this.#active);
+ const search = backward ? tree.slice(0, position).toReversed() : tree.slice(position + 1);
+ const next = search.find((element) => candidates.includes(element));
+ if (next) {
+ this.focus(next);
+ return this.#active;
+ }
+ }
+ const next =
+ current < 0
+ ? backward
+ ? candidates.length - 1
+ : 0
+ : (current + (backward ? -1 : 1) + candidates.length) % candidates.length;
+ const element = candidates[next] ?? null;
+ this.focus(element);
+ return this.#active;
+ }
+
+ #scope(root: Document | ShadowRoot | HTMLSlotElement): Element[] {
+ const scope: Element[] = [];
+ const visit = (children: readonly Node[]): void => {
+ for (const child of children) {
+ if (!(child instanceof Element)) continue;
+ const isOwner = getShadowRoot(child) !== null || child instanceof HTMLSlotElement;
+ if (isOwner || isFocusable(child)) {
+ if ((explicitTabIndex(child) ?? 0) >= 0) scope.push(child);
+ }
+ if (!isOwner) visit(child.childNodes);
+ }
+ };
+ if (root instanceof HTMLSlotElement) {
+ const assigned = root.assignedNodes();
+ visit(assigned.length ? assigned : root.childNodes);
+ } else visit(root.childNodes);
+ const ordered = scope.toSorted(
+ (a, b) => (explicitTabIndex(a) || Infinity) - (explicitTabIndex(b) || Infinity),
+ );
+ return ordered.flatMap((element) => {
+ const shadow = getShadowRoot(element);
+ const inside = shadow
+ ? this.#scope(shadow)
+ : element instanceof HTMLSlotElement
+ ? this.#scope(element)
+ : [];
+ return isFocusable(element) ? [element, ...inside] : inside;
+ });
+ }
+
+ #collect(root: Node): Element[] {
+ const elements: Element[] = [];
+ const children =
+ root instanceof HTMLSlotElement ? root.assignedNodes({ flatten: true }) : root.childNodes;
+ for (const child of children) {
+ if (!(child instanceof Element)) continue;
+ elements.push(child);
+ const shadow = getShadowRoot(child);
+ elements.push(...this.#collect(shadow ?? child));
+ }
+ return elements;
+ }
+
+ #invalidate(element: Element): void {
+ if (element.isConnected) this.#document.invalidate({ kind: 'focus', target: element });
+ }
+}
+
+/** Input from a terminal/other host, distinct from a dispatched DOM event. */
+export interface KeyInput extends Omit {
+ key: string;
+}
+
+/** Host input boundary. It routes keys and performs explicit logical Tab navigation. */
+export class Dispatch {
+ readonly #document: Document;
+
+ constructor(document: Document) {
+ this.#document = document;
+ }
+
+ keydown(input: KeyInput): boolean {
+ const accepted = this.#key('keydown', input);
+ if (accepted && input.key === 'Tab' && !input.altKey && !input.ctrlKey && !input.metaKey) {
+ this.#document.focusManager.moveFocus(Boolean(input.shiftKey));
+ }
+ return accepted;
+ }
+
+ keyup(input: KeyInput): boolean {
+ return this.#key('keyup', input);
+ }
+
+ #key(type: 'keydown' | 'keyup', input: KeyInput): boolean {
+ this.#document.focusManager.revalidate();
+ const target =
+ this.#document.focusManager.activeElement ??
+ this.#document.body ??
+ this.#document.documentElement ??
+ this.#document;
+ return target.dispatchEvent(
+ new KeyboardEvent(type, { ...input, bubbles: true, cancelable: true, composed: true }),
+ );
+ }
+}
diff --git a/packages/dom/src/hooks.ts b/packages/dom/src/hooks.ts
new file mode 100644
index 0000000..6696ecc
--- /dev/null
+++ b/packages/dom/src/hooks.ts
@@ -0,0 +1,77 @@
+/* oxlint-disable bombshell-dev/exported-function-async, bombshell-dev/max-params -- Internal synchronous mutation hooks mirror the DOM algorithm arguments. */
+import type { Node } from './dom.ts';
+
+export interface MutationRecordData {
+ type: 'attributes' | 'characterData' | 'childList';
+ target: Node;
+ addedNodes?: readonly Node[];
+ removedNodes?: readonly Node[];
+ previousSibling?: Node | null;
+ nextSibling?: Node | null;
+ attributeName?: string | null;
+ attributeNamespace?: string | null;
+ oldValue?: string | null;
+}
+
+export type BoundaryMutation =
+ | { type: 'remove'; node: Node; parent: Node; index: number }
+ | { type: 'insert'; parent: Node; index: number; count: number }
+ | { type: 'text'; node: Node; offset: number; count: number; insertedLength: number }
+ | { type: 'split'; node: Node; newNode: Node; offset: number };
+
+const mutationSubscribers = new Set<(record: MutationRecordData) => void>();
+const boundarySubscribers = new Set<(mutation: BoundaryMutation) => void>();
+const afterMutationSubscribers = new Set<() => void>();
+
+/** @internal Subscribe to committed mutations, independently of host invalidation. */
+export function subscribeMutations(callback: (record: MutationRecordData) => void): () => void {
+ mutationSubscribers.add(callback);
+ return () => mutationSubscribers.delete(callback);
+}
+
+/** @internal Subscribe to mutation details needed for live boundary updates. */
+export function subscribeBoundaries(callback: (mutation: BoundaryMutation) => void): () => void {
+ boundarySubscribers.add(callback);
+ return () => boundarySubscribers.delete(callback);
+}
+
+/** @internal Publish user-observable boundary changes after mutation records are queued. */
+export function subscribeAfterMutation(callback: () => void): () => void {
+ afterMutationSubscribers.add(callback);
+ return () => afterMutationSubscribers.delete(callback);
+}
+
+/** @internal Publish a completed node mutation. */
+export function notifyMutation(record: MutationRecordData): void {
+ for (const subscriber of mutationSubscribers) subscriber(record);
+ for (const subscriber of afterMutationSubscribers) subscriber();
+}
+
+function notifyBoundary(mutation: BoundaryMutation): void {
+ for (const subscriber of boundarySubscribers) subscriber(mutation);
+}
+
+/** @internal Capture old ancestry and adjust boundaries before unlinking a child. */
+export function notifyBeforeRemove(node: Node, parent: Node, index: number): void {
+ notifyBoundary({ type: 'remove', node, parent, index });
+}
+
+/** @internal Adjust existing boundaries before linking inserted children. */
+export function notifyInsert(parent: Node, index: number, count: number): void {
+ notifyBoundary({ type: 'insert', parent, index, count });
+}
+
+/** @internal Adjust UTF-16 boundaries before replacing character data. */
+export function notifyTextChange(
+ node: Node,
+ offset: number,
+ count: number,
+ insertedLength: number,
+): void {
+ notifyBoundary({ type: 'text', node, offset, count, insertedLength });
+}
+
+/** Call after inserting the new text node, before shortening the original. */
+export function notifyTextSplit(node: Node, newNode: Node, offset: number): void {
+ notifyBoundary({ type: 'split', node, newNode, offset });
+}
diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts
new file mode 100644
index 0000000..55355d1
--- /dev/null
+++ b/packages/dom/src/index.ts
@@ -0,0 +1,36 @@
+export {
+ Node,
+ Document,
+ Element,
+ Attr,
+ CharacterData,
+ Text,
+ Comment,
+ DocumentFragment,
+ ShadowRoot,
+ HTMLInputElement,
+ HTMLTextAreaElement,
+ HTMLTemplateElement,
+ HTMLSlotElement,
+ TextControlElement,
+ HTML_NAMESPACE,
+ SVG_NAMESPACE,
+ MATHML_NAMESPACE,
+ ELEMENT_NODE,
+ ATTRIBUTE_NODE,
+ TEXT_NODE,
+ COMMENT_NODE,
+ DOCUMENT_NODE,
+ DOCUMENT_FRAGMENT_NODE,
+} from './dom.ts';
+export type { Elements, DocumentOptions, Invalidation, MarkupParser } from './dom.ts';
+export * from './events.ts';
+export * from './observation.ts';
+export * from './selection.ts';
+export { Dispatch, FocusManager, isFocusable } from './focus.ts';
+export type { KeyInput } from './focus.ts';
+export type { SelectionDirection, SelectionMode } from './controls.ts';
+export { CustomElementRegistry } from './custom-elements.ts';
+export type { CustomElementConstructor } from './custom-elements.ts';
+export { TreeWalker, NodeFilter } from './traversal.ts';
+export type { NodeFilterCallback } from './traversal.ts';
diff --git a/packages/dom/src/observation.ts b/packages/dom/src/observation.ts
new file mode 100644
index 0000000..8ed68e6
--- /dev/null
+++ b/packages/dom/src/observation.ts
@@ -0,0 +1,225 @@
+/* oxlint-disable bombshell-dev/no-generic-error -- WebIDL and the DOM Standard require TypeError for invalid receivers, callbacks, and observer options. */
+import { Document, Node } from './dom.ts';
+import { subscribeBoundaries, subscribeMutations, type MutationRecordData } from './hooks.ts';
+
+export interface MutationObserverInit {
+ childList?: boolean;
+ attributes?: boolean;
+ characterData?: boolean;
+ subtree?: boolean;
+ attributeOldValue?: boolean;
+ characterDataOldValue?: boolean;
+ attributeFilter?: readonly string[];
+}
+
+export interface MutationRecord {
+ readonly type: 'attributes' | 'characterData' | 'childList';
+ readonly target: Node;
+ readonly addedNodes: readonly Node[];
+ readonly removedNodes: readonly Node[];
+ readonly previousSibling: Node | null;
+ readonly nextSibling: Node | null;
+ readonly attributeName: string | null;
+ readonly attributeNamespace: string | null;
+ readonly oldValue: string | null;
+}
+
+export type MutationCallback = (
+ this: MutationObserver,
+ records: MutationRecord[],
+ observer: MutationObserver,
+) => void;
+
+interface Registration {
+ observer: MutationObserver;
+ options: MutationObserverInit;
+ source?: Registration;
+}
+
+// A live target retains its observers; an observer only weakly retains its targets.
+const registrations = new WeakMap>();
+const pendingObservers = new Set();
+const observerState = new WeakMap();
+let microtaskPending = false;
+
+interface ObserverState {
+ callback: MutationCallback;
+ nodes: Set>;
+ transientNodes: Set>;
+ records: MutationRecord[];
+}
+
+function state(observer: MutationObserver): ObserverState {
+ const value = observerState.get(observer);
+ if (!value) throw new TypeError('Invalid MutationObserver receiver');
+ return value;
+}
+
+function schedule(pendingObserver: MutationObserver): void {
+ pendingObservers.add(pendingObserver);
+ if (microtaskPending) return;
+ microtaskPending = true;
+ queueMicrotask(() => {
+ microtaskPending = false;
+ const pending = [...pendingObservers];
+ pendingObservers.clear();
+ for (const observer of pending) {
+ const info = state(observer);
+ const records = observer.takeRecords();
+ clearTransients(observer);
+ if (!records.length) continue;
+ try {
+ info.callback.call(observer, records, observer);
+ } catch (error) {
+ // In this explicit-document environment, report through the first record's host.
+ const target = records[0]?.target;
+ const document = target instanceof Document ? target : target?.ownerDocument;
+ try {
+ if (document) document.reportError(error);
+ else
+ queueMicrotask(() => {
+ throw error;
+ });
+ } catch (reportingError) {
+ queueMicrotask(() => {
+ throw reportingError;
+ });
+ }
+ }
+ }
+ });
+}
+
+function clearTransients(observer: MutationObserver, source?: Registration): void {
+ const info = state(observer);
+ for (const reference of info.transientNodes) {
+ const target = reference.deref();
+ if (!target) continue;
+ const entries = registrations.get(target);
+ if (!entries) continue;
+ for (const entry of entries) {
+ if (entry.observer === observer && entry.source && (!source || entry.source === source))
+ entries.delete(entry);
+ }
+ }
+ if (!source) info.transientNodes.clear();
+}
+
+/** Microtask-delivered records for explicitly observed nodes, including detached nodes. */
+export class MutationObserver {
+ constructor(callback: MutationCallback) {
+ if (typeof callback !== 'function') throw new TypeError('MutationObserver requires a callback');
+ observerState.set(this, { callback, nodes: new Set(), transientNodes: new Set(), records: [] });
+ }
+
+ observe(target: Node, options: MutationObserverInit = {}): void {
+ if (!(target instanceof Node)) throw new TypeError('MutationObserver target must be a Node');
+ const normalized = { ...options };
+ if (options.attributeFilter)
+ normalized.attributeFilter = [...options.attributeFilter].map(String);
+ if (
+ options.attributes === undefined &&
+ (options.attributeOldValue !== undefined || options.attributeFilter !== undefined)
+ )
+ normalized.attributes = true;
+ if (options.characterData === undefined && options.characterDataOldValue !== undefined)
+ normalized.characterData = true;
+ if (!normalized.childList && !normalized.attributes && !normalized.characterData)
+ throw new TypeError('At least one mutation type must be observed');
+ if (
+ !normalized.attributes &&
+ (normalized.attributeOldValue || normalized.attributeFilter !== undefined)
+ )
+ throw new TypeError('Attribute options require attributes');
+ if (!normalized.characterData && normalized.characterDataOldValue)
+ throw new TypeError('characterDataOldValue requires characterData');
+ let entries = registrations.get(target);
+ if (!entries) registrations.set(target, (entries = new Set()));
+ const existing = [...entries].find((entry) => entry.observer === this && !entry.source);
+ if (existing) {
+ clearTransients(this, existing);
+ existing.options = normalized;
+ } else {
+ entries.add({ observer: this, options: normalized });
+ state(this).nodes.add(new WeakRef(target));
+ }
+ }
+
+ disconnect(): void {
+ const info = state(this);
+ for (const reference of info.nodes) {
+ const target = reference.deref();
+ if (!target) continue;
+ const entries = registrations.get(target);
+ if (!entries) continue;
+ for (const entry of entries) if (entry.observer === this) entries.delete(entry);
+ }
+ clearTransients(this);
+ info.nodes.clear();
+ info.records = [];
+ }
+
+ takeRecords(): MutationRecord[] {
+ const info = state(this);
+ const records = info.records;
+ info.records = [];
+ return records;
+ }
+}
+
+subscribeBoundaries((mutation) => {
+ if (mutation.type !== 'remove') return;
+ for (let ancestor: Node | null = mutation.parent; ancestor; ancestor = ancestor.parentNode) {
+ for (const entry of registrations.get(ancestor) ?? []) {
+ if (!entry.options.subtree) continue;
+ const source = entry.source ?? entry;
+ let entries = registrations.get(mutation.node);
+ if (!entries) registrations.set(mutation.node, (entries = new Set()));
+ if ([...entries].some((candidate) => candidate.source === source)) continue;
+ entries.add({ observer: entry.observer, options: source.options, source });
+ state(entry.observer).transientNodes.add(new WeakRef(mutation.node));
+ schedule(entry.observer);
+ }
+ }
+});
+
+function matches(record: MutationRecordData, options: MutationObserverInit): boolean {
+ if (record.type === 'childList') return Boolean(options.childList);
+ if (record.type === 'characterData') return Boolean(options.characterData);
+ if (!options.attributes) return false;
+ return (
+ options.attributeFilter === undefined ||
+ (!record.attributeNamespace && options.attributeFilter.includes(record.attributeName ?? ''))
+ );
+}
+
+subscribeMutations((record) => {
+ const interested = new Map();
+ for (let ancestor: Node | null = record.target; ancestor; ancestor = ancestor.parentNode) {
+ for (const { observer, options } of registrations.get(ancestor) ?? []) {
+ if (ancestor !== record.target && !options.subtree) continue;
+ if (!matches(record, options)) continue;
+ const oldValue =
+ record.type === 'attributes'
+ ? options.attributeOldValue
+ : record.type === 'characterData' && options.characterDataOldValue;
+ interested.set(observer, Boolean(interested.get(observer) || oldValue));
+ }
+ }
+ for (const [observer, includeOldValue] of interested) {
+ state(observer).records.push(
+ Object.freeze({
+ type: record.type,
+ target: record.target,
+ addedNodes: Object.freeze([...(record.addedNodes ?? [])]),
+ removedNodes: Object.freeze([...(record.removedNodes ?? [])]),
+ previousSibling: record.previousSibling ?? null,
+ nextSibling: record.nextSibling ?? null,
+ attributeName: record.attributeName ?? null,
+ attributeNamespace: record.attributeNamespace ?? null,
+ oldValue: includeOldValue ? (record.oldValue ?? null) : null,
+ }),
+ );
+ schedule(observer);
+ }
+});
diff --git a/packages/dom/src/parsing.ts b/packages/dom/src/parsing.ts
new file mode 100644
index 0000000..a6ea1ba
--- /dev/null
+++ b/packages/dom/src/parsing.ts
@@ -0,0 +1,108 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Parser installation is deliberately synchronous. */
+import { parseFragment, defaultTreeAdapter, html, type DefaultTreeAdapterMap } from 'parse5';
+import { enqueueUpgrade, flushReactions } from './custom-elements.ts';
+import {
+ type Document,
+ Element,
+ Attr,
+ Text,
+ Comment,
+ HTMLTemplateElement,
+ type Node,
+ type ShadowRoot,
+} from './dom.ts';
+
+type ParsedNode = DefaultTreeAdapterMap['childNode'];
+function convert(node: ParsedNode, document: Document): Node | null {
+ if (defaultTreeAdapter.isTextNode(node)) return document.createTextNode(node.value);
+ if (defaultTreeAdapter.isCommentNode(node)) return document.createComment(node.data);
+ if (!defaultTreeAdapter.isElementNode(node)) return null;
+ const customCandidate = node.namespaceURI === html.NS.HTML && node.tagName.includes('-');
+ const element = customCandidate
+ ? new Element(node.tagName, document)
+ : document.createElementNS(node.namespaceURI, node.tagName);
+ if (customCandidate && document.customElements.get(node.tagName)) enqueueUpgrade(element);
+ for (const parsed of node.attrs) {
+ const attr = new Attr(parsed.name, document, {
+ namespaceURI: parsed.namespace ?? null,
+ prefix: parsed.prefix ?? null,
+ });
+ attr.value = parsed.value;
+ element.setAttributeNodeNS(attr);
+ }
+ const destination = element instanceof HTMLTemplateElement ? element.content : element;
+ const source =
+ node.tagName === 'template' && 'content' in node ? node.content.childNodes : node.childNodes;
+ for (const child of source) {
+ const result = convert(child, destination.ownerDocument ?? document);
+ if (result) destination.appendChild(result);
+ }
+ return element;
+}
+function escapeText(value: string): string {
+ return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
+}
+const voidElements = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+]);
+function serializeNode(node: Node): string {
+ if (node instanceof Text) return escapeText(node.data);
+ if (node instanceof Comment) return ``;
+ if (!(node instanceof Element)) return serializeChildren(node);
+ const name = node.prefix ? `${node.prefix}:${node.localName}` : node.localName;
+ const attrs = node.attributes
+ .map(
+ (attr) => ` ${attr.name}="${attr.value.replaceAll('&', '&').replaceAll('"', '"')}"`,
+ )
+ .join('');
+ const opening = `<${name}${attrs}>`;
+ if (node.namespaceURI === 'http://www.w3.org/1999/xhtml' && voidElements.has(name))
+ return opening;
+ return `${opening}${serializeChildren(node instanceof HTMLTemplateElement ? node.content : node)}${name}>`;
+}
+function serializeChildren(node: Node): string {
+ return node.childNodes.map(serializeNode).join('');
+}
+/** Install opt-in HTML parsing backed by parse5. Scripts remain inert. */
+export function installParsing(document: Document): void {
+ document.setMarkupParser({
+ parse(context: Element | ShadowRoot, markup: string) {
+ const element = context instanceof Element ? context : context.host;
+ const astContext = defaultTreeAdapter.createElement(
+ element.localName,
+ element.namespaceURI === 'http://www.w3.org/2000/svg'
+ ? html.NS.SVG
+ : element.namespaceURI === 'http://www.w3.org/1998/Math/MathML'
+ ? html.NS.MATHML
+ : html.NS.HTML,
+ [],
+ );
+ const parsed = parseFragment(astContext, markup, {});
+ const owner =
+ context instanceof HTMLTemplateElement
+ ? (context.content.ownerDocument ?? document)
+ : document;
+ const fragment = owner.createDocumentFragment();
+ for (const child of parsed.childNodes) {
+ const node = convert(child, owner);
+ if (node) fragment.appendChild(node);
+ }
+ flushReactions();
+ return fragment;
+ },
+ serialize: serializeChildren,
+ });
+}
diff --git a/packages/dom/src/selection.ts b/packages/dom/src/selection.ts
new file mode 100644
index 0000000..faccb55
--- /dev/null
+++ b/packages/dom/src/selection.ts
@@ -0,0 +1,519 @@
+import { CharacterData, Document, type DocumentFragment, type Node } from './dom.ts';
+import { subscribeAfterMutation, subscribeBoundaries, type BoundaryMutation } from './hooks.ts';
+
+type Boundary = readonly [node: Node, offset: number];
+const liveRanges = new Set>();
+const changedRanges = new Set();
+
+function nodeLength(node: Node): number {
+ return node instanceof CharacterData ? node.length : node.childNodes.length;
+}
+
+function boundary(node: Node, offset: number): Boundary {
+ if (node.nodeType === 10)
+ throw new DOMException('A doctype cannot be a boundary container', 'InvalidNodeTypeError');
+ const converted = Number(offset) >>> 0;
+ if (converted > nodeLength(node))
+ throw new DOMException('Offset exceeds the node length', 'IndexSizeError');
+ return [node, converted];
+}
+
+function parentBoundary(node: Node, after: boolean): Boundary {
+ const parent = node.parentNode;
+ if (!parent) throw new DOMException('Node has no parent', 'InvalidNodeTypeError');
+ return [parent, parent.childNodes.indexOf(node) + Number(after)];
+}
+
+function sameBoundary(left: Boundary, right: Boundary): boolean {
+ return left[0] === right[0] && left[1] === right[1];
+}
+
+/** The DOM boundary-point order, including ancestor/descendant boundaries. */
+function compare(left: Boundary, right: Boundary): -1 | 0 | 1 {
+ const [leftNode, leftOffset] = left;
+ const [rightNode, rightOffset] = right;
+ if (leftNode.getRootNode() !== rightNode.getRootNode())
+ throw new DOMException('Boundary points have different roots', 'WrongDocumentError');
+ if (leftNode === rightNode)
+ return leftOffset === rightOffset ? 0 : leftOffset < rightOffset ? -1 : 1;
+ if (leftNode.contains(rightNode)) {
+ let child = rightNode;
+ while (child.parentNode && child.parentNode !== leftNode) child = child.parentNode;
+ return leftNode.childNodes.indexOf(child) < leftOffset ? 1 : -1;
+ }
+ if (rightNode.contains(leftNode)) return compare(right, left) === -1 ? 1 : -1;
+ const ancestors = new Set();
+ for (let ancestor: Node | null = leftNode; ancestor; ancestor = ancestor.parentNode)
+ ancestors.add(ancestor);
+ let rightChild = rightNode;
+ while (rightChild.parentNode && !ancestors.has(rightChild.parentNode))
+ rightChild = rightChild.parentNode;
+ const common = rightChild.parentNode;
+ if (!common) throw new DOMException('Boundary points have different roots', 'WrongDocumentError');
+ let leftChild = leftNode;
+ while (leftChild.parentNode && leftChild.parentNode !== common) leftChild = leftChild.parentNode;
+ return common.childNodes.indexOf(leftChild) < common.childNodes.indexOf(rightChild) ? -1 : 1;
+}
+
+function adjust(point: Boundary, mutation: BoundaryMutation): Boundary {
+ const [node, offset] = point;
+ if (mutation.type === 'remove') {
+ if (mutation.node.contains(node)) return [mutation.parent, mutation.index];
+ if (node === mutation.parent && offset > mutation.index) return [node, offset - 1];
+ } else if (mutation.type === 'insert') {
+ if (node === mutation.parent && offset > mutation.index) return [node, offset + mutation.count];
+ } else if (mutation.type === 'text') {
+ if (node === mutation.node && offset > mutation.offset) {
+ return [
+ node,
+ offset <= mutation.offset + mutation.count
+ ? mutation.offset
+ : offset + mutation.insertedLength - mutation.count,
+ ];
+ }
+ } else if (mutation.node.parentNode) {
+ if (node === mutation.node && offset > mutation.offset)
+ return [mutation.newNode, offset - mutation.offset];
+ const parent = mutation.node.parentNode;
+ if (node === parent && offset === parent.childNodes.indexOf(mutation.node) + 1)
+ return [node, offset + 1];
+ }
+ return point;
+}
+
+interface ContentPlan {
+ node: Node;
+ full: boolean;
+ text?: { offset: number; count: number };
+ children?: ContentPlan[];
+}
+
+/** A live range. Construct via Document.createRange(), or pass an explicit document. */
+export class Range {
+ static readonly START_TO_START = 0;
+ static readonly START_TO_END = 1;
+ static readonly END_TO_END = 2;
+ static readonly END_TO_START = 3;
+ readonly START_TO_START = 0;
+ readonly START_TO_END = 1;
+ readonly END_TO_END = 2;
+ readonly END_TO_START = 3;
+ #start: Boundary;
+ #end: Boundary;
+ #document: Document;
+ #subscribers = new Set<() => void>();
+
+ constructor(document: Document) {
+ this.#document = document;
+ this.#start = this.#end = [document, 0];
+ liveRanges.add(new WeakRef(this));
+ }
+
+ get startContainer(): Node {
+ return this.#start[0];
+ }
+ get startOffset(): number {
+ return this.#start[1];
+ }
+ get endContainer(): Node {
+ return this.#end[0];
+ }
+ get endOffset(): number {
+ return this.#end[1];
+ }
+ get collapsed(): boolean {
+ return sameBoundary(this.#start, this.#end);
+ }
+ get commonAncestorContainer(): Node {
+ let node = this.startContainer;
+ while (!node.contains(this.endContainer) && node.parentNode) node = node.parentNode;
+ return node;
+ }
+
+ // oxlint-disable-next-line bombshell-dev/max-params -- Atomically replace both endpoints, optionally deferring host notification until mutation completes.
+ #update(start: Boundary, end: Boundary, notify = true): void {
+ const changed = !sameBoundary(start, this.#start) || !sameBoundary(end, this.#end);
+ this.#start = start;
+ this.#end = end;
+ if (changed) {
+ if (notify) this._notifyChanged();
+ else if (this.#subscribers.size) changedRanges.add(this);
+ }
+ }
+
+ /** @internal */
+ _subscribeChanges(callback: () => void): () => void {
+ this.#subscribers.add(callback);
+ return () => this.#subscribers.delete(callback);
+ }
+
+ /** @internal */
+ _adjust(mutation: BoundaryMutation): void {
+ this.#update(adjust(this.#start, mutation), adjust(this.#end, mutation), false);
+ }
+
+ /** @internal */
+ _notifyChanged(): void {
+ for (const subscriber of this.#subscribers) subscriber();
+ }
+
+ setStart(node: Node, offset: number): void {
+ const start = boundary(node, offset);
+ const end =
+ node.getRootNode() !== this.endContainer.getRootNode() || compare(start, this.#end) > 0
+ ? start
+ : this.#end;
+ this.#update(start, end);
+ }
+
+ setEnd(node: Node, offset: number): void {
+ const end = boundary(node, offset);
+ const start =
+ node.getRootNode() !== this.startContainer.getRootNode() || compare(end, this.#start) < 0
+ ? end
+ : this.#start;
+ this.#update(start, end);
+ }
+
+ setStartBefore(node: Node): void {
+ this.setStart(...parentBoundary(node, false));
+ }
+ setStartAfter(node: Node): void {
+ this.setStart(...parentBoundary(node, true));
+ }
+ setEndBefore(node: Node): void {
+ this.setEnd(...parentBoundary(node, false));
+ }
+ setEndAfter(node: Node): void {
+ this.setEnd(...parentBoundary(node, true));
+ }
+
+ collapse(toStart = false): void {
+ const point = toStart ? this.#start : this.#end;
+ this.#update(point, point);
+ }
+
+ selectNode(node: Node): void {
+ this.#update(parentBoundary(node, false), parentBoundary(node, true));
+ }
+
+ selectNodeContents(node: Node): void {
+ this.#update(boundary(node, 0), boundary(node, nodeLength(node)));
+ }
+
+ compareBoundaryPoints(how: number, sourceRange: Range): -1 | 0 | 1 {
+ switch (how) {
+ case 0:
+ return compare(this.#start, sourceRange.#start);
+ case 1:
+ return compare(this.#end, sourceRange.#start);
+ case 2:
+ return compare(this.#end, sourceRange.#end);
+ case 3:
+ return compare(this.#start, sourceRange.#end);
+ default:
+ throw new DOMException('Unknown boundary comparison', 'NotSupportedError');
+ }
+ }
+
+ comparePoint(node: Node, offset: number): -1 | 0 | 1 {
+ if (node.getRootNode() !== this.startContainer.getRootNode())
+ throw new DOMException('Node has a different root', 'WrongDocumentError');
+ const point = boundary(node, offset);
+ return compare(point, this.#start) < 0 ? -1 : compare(point, this.#end) > 0 ? 1 : 0;
+ }
+
+ isPointInRange(node: Node, offset: number): boolean {
+ if (node.getRootNode() !== this.startContainer.getRootNode()) return false;
+ return this.comparePoint(node, offset) === 0;
+ }
+
+ intersectsNode(node: Node): boolean {
+ if (node.getRootNode() !== this.startContainer.getRootNode()) return false;
+ if (!node.parentNode) return true;
+ return (
+ compare(parentBoundary(node, false), this.#end) < 0 &&
+ compare(parentBoundary(node, true), this.#start) > 0
+ );
+ }
+
+ cloneRange(): Range {
+ const clone = new Range(this.#document);
+ clone.#start = this.#start;
+ clone.#end = this.#end;
+ return clone;
+ }
+
+ /** The standard legacy method is intentionally a no-op: ranges remain live. */
+ detach(): void {}
+
+ toString(): string {
+ if (this.collapsed) return '';
+ let result = '';
+ const visit = (node: Node): void => {
+ if (node.nodeType === 3 && node instanceof CharacterData) {
+ if (compare([node, 0], this.#end) < 0 && compare([node, node.length], this.#start) > 0) {
+ result += node.data.slice(
+ node === this.startContainer ? this.startOffset : 0,
+ node === this.endContainer ? this.endOffset : node.length,
+ );
+ }
+ } else for (const child of node.childNodes) visit(child);
+ };
+ visit(this.commonAncestorContainer);
+ return result;
+ }
+
+ #plans(): ContentPlan[] {
+ if (this.collapsed) return [];
+ const plan = (node: Node): ContentPlan | undefined => {
+ const full =
+ compare(this.#start, [node, 0]) < 0 && compare([node, nodeLength(node)], this.#end) < 0;
+ if (full) return { node, full };
+ if (!node.contains(this.startContainer) && !node.contains(this.endContainer)) return;
+ if (node instanceof CharacterData) {
+ const offset = node === this.startContainer ? this.startOffset : 0;
+ const end = node === this.endContainer ? this.endOffset : node.length;
+ return { node, full: false, text: { offset, count: end - offset } };
+ }
+ const children: ContentPlan[] = [];
+ for (const child of node.childNodes) {
+ const childPlan = plan(child);
+ if (childPlan) children.push(childPlan);
+ }
+ return { node, full: false, children };
+ };
+ const common = this.commonAncestorContainer;
+ const result: ContentPlan[] = [];
+ if (common instanceof CharacterData) {
+ const item = plan(common);
+ if (item) result.push(item);
+ } else
+ for (const child of common.childNodes) {
+ const item = plan(child);
+ if (item) result.push(item);
+ }
+ return result;
+ }
+
+ #collapseForRemoval(): void {
+ if (this.startContainer.contains(this.endContainer)) this.#update(this.#start, this.#start);
+ else {
+ let reference = this.startContainer;
+ while (reference.parentNode && !reference.parentNode.contains(this.endContainer))
+ reference = reference.parentNode;
+ const point = parentBoundary(reference, true);
+ this.#update(point, point);
+ }
+ }
+
+ deleteContents(): void {
+ const plans = this.#plans();
+ this.#collapseForRemoval();
+ const remove = (plan: ContentPlan): void => {
+ if (plan.full) plan.node.parentNode?.removeChild(plan.node);
+ else if (plan.text && plan.node instanceof CharacterData)
+ plan.node.replaceData(plan.text.offset, plan.text.count, '');
+ else for (const child of plan.children ?? []) remove(child);
+ };
+ for (const plan of plans) remove(plan);
+ }
+
+ #copyContents(extract: boolean): DocumentFragment {
+ const plans = this.#plans();
+ const document =
+ this.startContainer instanceof Document
+ ? this.startContainer
+ : (this.startContainer.ownerDocument ?? this.#document);
+ const fragment = document.createDocumentFragment();
+ const check = (plan: ContentPlan): void => {
+ if (plan.node.nodeType === 10)
+ throw new DOMException('A fragment cannot contain a doctype', 'HierarchyRequestError');
+ for (const child of plan.children ?? []) check(child);
+ };
+ for (const plan of plans) check(plan);
+ if (extract) this.#collapseForRemoval();
+ const copy = (plan: ContentPlan): Node => {
+ if (plan.full) return extract ? plan.node : plan.node.cloneNode(true);
+ const clone = plan.node.cloneNode(false);
+ if (plan.text && plan.node instanceof CharacterData && clone instanceof CharacterData) {
+ clone.data = plan.node.data.slice(plan.text.offset, plan.text.offset + plan.text.count);
+ if (extract) plan.node.replaceData(plan.text.offset, plan.text.count, '');
+ } else for (const child of plan.children ?? []) clone.appendChild(copy(child));
+ return clone;
+ };
+ for (const plan of plans) fragment.appendChild(copy(plan));
+ return fragment;
+ }
+
+ cloneContents(): DocumentFragment {
+ return this.#copyContents(false);
+ }
+ extractContents(): DocumentFragment {
+ return this.#copyContents(true);
+ }
+}
+
+subscribeBoundaries((mutation) => {
+ for (const reference of liveRanges) {
+ const range = reference.deref();
+ if (range) range._adjust(mutation);
+ else liveRanges.delete(reference);
+ }
+});
+
+subscribeAfterMutation(() => {
+ const changed = [...changedRanges];
+ changedRanges.clear();
+ for (const range of changed) range._notifyChanged();
+});
+
+/** One document's selection, backed by one live Range. */
+export class Selection {
+ #document: Document;
+ #range: Range | null = null;
+ #direction: 'forward' | 'backward' | 'none' = 'none';
+ #unsubscribe: (() => void) | undefined;
+
+ constructor(document: Document) {
+ this.#document = document;
+ }
+
+ get rangeCount(): number {
+ return this.#visibleRange ? 1 : 0;
+ }
+ get #visibleRange(): Range | null {
+ return this.#range?.startContainer.getRootNode() === this.#document ? this.#range : null;
+ }
+ get anchorNode(): Node | null {
+ return this.#direction === 'backward'
+ ? (this.#visibleRange?.endContainer ?? null)
+ : (this.#visibleRange?.startContainer ?? null);
+ }
+ get anchorOffset(): number {
+ return this.#direction === 'backward'
+ ? (this.#visibleRange?.endOffset ?? 0)
+ : (this.#visibleRange?.startOffset ?? 0);
+ }
+ get focusNode(): Node | null {
+ return this.#direction === 'backward'
+ ? (this.#visibleRange?.startContainer ?? null)
+ : (this.#visibleRange?.endContainer ?? null);
+ }
+ get focusOffset(): number {
+ return this.#direction === 'backward'
+ ? (this.#visibleRange?.startOffset ?? 0)
+ : (this.#visibleRange?.endOffset ?? 0);
+ }
+ get isCollapsed(): boolean {
+ return !this.#visibleRange || this.#visibleRange.collapsed;
+ }
+ get direction(): 'forward' | 'backward' | 'none' {
+ return this.#range ? this.#direction : 'none';
+ }
+ get type(): 'None' | 'Caret' | 'Range' {
+ return !this.#visibleRange ? 'None' : this.isCollapsed ? 'Caret' : 'Range';
+ }
+
+ #replace(range: Range | null, direction: 'forward' | 'backward' | 'none'): void {
+ if (range === this.#range && direction === this.#direction) return;
+ this.#unsubscribe?.();
+ this.#range = range;
+ this.#direction = direction;
+ this.#unsubscribe = range?._subscribeChanges(() => this.#document._selectionChanged());
+ this.#document._selectionChanged();
+ }
+
+ getRangeAt(index: number): Range {
+ if (Number(index) >>> 0 !== 0 || !this.#visibleRange)
+ throw new DOMException('Selection has no range at this index', 'IndexSizeError');
+ return this.#visibleRange;
+ }
+
+ addRange(range: Range): void {
+ if (this.rangeCount || range.startContainer.getRootNode() !== this.#document) return;
+ this.#replace(range, 'forward');
+ }
+
+ removeRange(range: Range): void {
+ if (this.#range !== range) throw new DOMException('Range is not selected', 'NotFoundError');
+ this.removeAllRanges();
+ }
+
+ removeAllRanges(): void {
+ this.#replace(null, 'none');
+ }
+ empty(): void {
+ this.removeAllRanges();
+ }
+
+ collapse(node: Node | null, offset = 0): void {
+ if (node === null) {
+ this.removeAllRanges();
+ return;
+ }
+ const point = boundary(node, offset);
+ if (node.getRootNode() !== this.#document) return;
+ const range = new Range(this.#document);
+ range.setStart(...point);
+ range.collapse(true);
+ this.#replace(range, 'none');
+ }
+
+ setPosition(node: Node | null, offset = 0): void {
+ this.collapse(node, offset);
+ }
+
+ collapseToStart(): void {
+ if (!this.#range) throw new DOMException('Selection is empty', 'InvalidStateError');
+ this.collapse(this.#range.startContainer, this.#range.startOffset);
+ }
+
+ collapseToEnd(): void {
+ if (!this.#range) throw new DOMException('Selection is empty', 'InvalidStateError');
+ this.collapse(this.#range.endContainer, this.#range.endOffset);
+ }
+
+ // oxlint-disable-next-line bombshell-dev/max-params -- This is the standard Selection.setBaseAndExtent signature.
+ setBaseAndExtent(
+ anchorNode: Node,
+ anchorOffset: number,
+ focusNode: Node,
+ focusOffset: number,
+ ): void {
+ const anchor = boundary(anchorNode, anchorOffset);
+ const focus = boundary(focusNode, focusOffset);
+ if (anchorNode.getRootNode() !== this.#document || focusNode.getRootNode() !== this.#document)
+ return;
+ const ordering = compare(anchor, focus);
+ const range = new Range(this.#document);
+ range.setStart(...(ordering > 0 ? focus : anchor));
+ range.setEnd(...(ordering > 0 ? anchor : focus));
+ this.#replace(range, ordering > 0 ? 'backward' : 'forward');
+ }
+
+ extend(node: Node, offset = 0): void {
+ if (node.getRootNode() !== this.#document) return;
+ if (!this.#range) throw new DOMException('Selection is empty', 'InvalidStateError');
+ const anchor =
+ this.#direction === 'backward'
+ ? ([this.#range.endContainer, this.#range.endOffset] as const)
+ : ([this.#range.startContainer, this.#range.startOffset] as const);
+ this.setBaseAndExtent(anchor[0], anchor[1], node, offset);
+ }
+
+ selectAllChildren(node: Node): void {
+ boundary(node, 0);
+ if (node.getRootNode() !== this.#document) return;
+ const range = new Range(this.#document);
+ range.selectNodeContents(node);
+ this.#replace(range, 'forward');
+ }
+
+ deleteFromDocument(): void {
+ this.#range?.deleteContents();
+ }
+ toString(): string {
+ return this.#range?.toString() ?? '';
+ }
+}
diff --git a/packages/dom/src/traversal.ts b/packages/dom/src/traversal.ts
new file mode 100644
index 0000000..887c440
--- /dev/null
+++ b/packages/dom/src/traversal.ts
@@ -0,0 +1,73 @@
+import type { Node } from './dom.ts';
+
+/** Standard traversal masks and filter results for the supported node kinds. */
+export const NodeFilter = Object.freeze({
+ FILTER_ACCEPT: 1,
+ FILTER_REJECT: 2,
+ FILTER_SKIP: 3,
+ SHOW_ALL: 0xffffffff,
+ SHOW_ELEMENT: 1,
+ SHOW_ATTRIBUTE: 2,
+ SHOW_TEXT: 4,
+ SHOW_COMMENT: 128,
+ SHOW_DOCUMENT: 256,
+ SHOW_DOCUMENT_FRAGMENT: 1024,
+});
+export type NodeFilterCallback = ((node: Node) => number) | { acceptNode(node: Node): number };
+/** Live depth-first traversal; rejected subtrees are pruned. */
+export class TreeWalker {
+ readonly root: Node;
+ readonly whatToShow: number;
+ readonly filter: NodeFilterCallback | null;
+ currentNode: Node;
+ constructor(
+ root: Node,
+ options: { whatToShow?: number; filter?: NodeFilterCallback | null } = {},
+ ) {
+ this.root = root;
+ this.currentNode = root;
+ this.whatToShow = options.whatToShow ?? NodeFilter.SHOW_ALL;
+ this.filter = options.filter ?? null;
+ }
+ #accept(node: Node): number {
+ if (!(this.whatToShow & (1 << (node.nodeType - 1)))) return NodeFilter.FILTER_SKIP;
+ return !this.filter
+ ? NodeFilter.FILTER_ACCEPT
+ : typeof this.filter === 'function'
+ ? this.filter(node)
+ : this.filter.acceptNode(node);
+ }
+ nextNode(): Node | null {
+ let node = this.currentNode;
+ let descend = true;
+ while (true) {
+ if (descend && node.firstChild) node = node.firstChild;
+ else {
+ while (node !== this.root && !node.nextSibling) {
+ if (!node.parentNode) return null;
+ node = node.parentNode;
+ }
+ if (node === this.root) return null;
+ if (!node.nextSibling) return null;
+ node = node.nextSibling;
+ }
+ const result = this.#accept(node);
+ if (result === NodeFilter.FILTER_ACCEPT) {
+ this.currentNode = node;
+ return node;
+ }
+ descend = result !== NodeFilter.FILTER_REJECT;
+ }
+ }
+ parentNode(): Node | null {
+ let node = this.currentNode;
+ while (node !== this.root && node.parentNode) {
+ node = node.parentNode;
+ if (this.#accept(node) === NodeFilter.FILTER_ACCEPT) {
+ this.currentNode = node;
+ return node;
+ }
+ }
+ return null;
+ }
+}
diff --git a/packages/dom/test/baseline.test.ts b/packages/dom/test/baseline.test.ts
new file mode 100644
index 0000000..0d76891
--- /dev/null
+++ b/packages/dom/test/baseline.test.ts
@@ -0,0 +1,1188 @@
+// Adapted from @clack/ui baseline 606675f16131c6ae7c4553a67ab477bdf661f772.
+// Copyright (c) 2025-Present Bombshell contributors. MIT; see LICENSE.
+import { describe, expect, it, vi } from 'vitest';
+import { Document, type Element, type Node, type Text } from '../src/dom.ts';
+import { Event } from '../src/events.ts';
+
+function documentWithRoot(notify: () => void): Document & { root: Element } {
+ const document = new Document();
+ const root = document.createElement('document');
+ document.appendChild(root);
+ document.invalidate = () => notify();
+ return Object.assign(document, { root });
+}
+
+describe('DOM connectivity', () => {
+ it('preserves a subtree through connection, disconnection, and reconnection', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const root = document.root;
+ const branch = document.createElement('div');
+ const span = document.createElement('span');
+ const text = document.createTextNode('initial');
+
+ expect(root.isConnected).toBe(true);
+ expect(branch.isConnected).toBe(false);
+ expect(span.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(branch.parentNode).toBeNull();
+
+ branch.appendChild(span);
+ span.appendChild(text);
+ text.data = 'prepared';
+ branch.setAttribute('data-state', 'prepared');
+
+ expect(span.parentNode).toBe(branch);
+ expect(text.parentNode).toBe(span);
+ expect(branch.isConnected).toBe(false);
+ expect(span.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(text.data).toBe('prepared');
+ expect(branch.attributes[0]).toMatchObject({ name: 'data-state', value: 'prepared' });
+ expect(notify).not.toHaveBeenCalled();
+
+ root.appendChild(branch);
+
+ expect(root.childNodes).toHaveLength(1);
+ expect(root.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(root);
+ expect(branch.isConnected).toBe(true);
+ expect(span.isConnected).toBe(true);
+ expect(text.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'connected';
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ branch.setAttribute('data-state', 'connected');
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ const extra = document.createTextNode('!');
+ span.appendChild(extra);
+ expect(extra.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ root.removeChild(branch);
+
+ expect(root.childNodes).toHaveLength(0);
+ expect(branch.parentNode).toBeNull();
+ expect(span.parentNode).toBe(branch);
+ expect(text.parentNode).toBe(span);
+ expect(branch.isConnected).toBe(false);
+ expect(span.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(extra.isConnected).toBe(false);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'detached';
+ branch.removeAttribute('data-state');
+ span.removeChild(extra);
+
+ expect(extra.parentNode).toBeNull();
+ expect(span.childNodes).toHaveLength(1);
+ expect(span.childNodes[0]).toBe(text);
+ expect(text.data).toBe('detached');
+ expect(branch.attributes).toHaveLength(0);
+ expect(notify).not.toHaveBeenCalled();
+
+ root.appendChild(branch);
+
+ expect(root.childNodes).toHaveLength(1);
+ expect(root.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(root);
+ expect(branch.isConnected).toBe(true);
+ expect(span.isConnected).toBe(true);
+ expect(text.isConnected).toBe(true);
+ expect(extra.isConnected).toBe(false);
+ expect(text.data).toBe('detached');
+ expect(branch.attributes).toHaveLength(0);
+ expect(notify).toHaveBeenCalled();
+ });
+
+ it('moves a subtree between detached parents without notifying', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const source = document.createElement('div');
+ const destination = document.createElement('div');
+ const branch = document.createElement('span');
+ const text = document.createTextNode('before');
+ branch.appendChild(text);
+ source.appendChild(branch);
+ notify.mockClear();
+
+ destination.appendChild(branch);
+
+ expect(source.childNodes).toHaveLength(0);
+ expect(destination.childNodes).toHaveLength(1);
+ expect(destination.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(destination);
+ expect(branch.childNodes).toHaveLength(1);
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(source.isConnected).toBe(false);
+ expect(destination.isConnected).toBe(false);
+ expect(branch.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(notify).not.toHaveBeenCalled();
+
+ text.data = 'after';
+ expect(notify).not.toHaveBeenCalled();
+
+ branch.setAttribute('data-state', 'after');
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ it('connects a detached subtree and starts notifying when moved into a connected parent', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const source = document.createElement('div');
+ const destination = document.createElement('div');
+ const branch = document.createElement('span');
+ const text = document.createTextNode('before');
+ branch.appendChild(text);
+ source.appendChild(branch);
+ document.root.appendChild(destination);
+ notify.mockClear();
+
+ destination.appendChild(branch);
+
+ expect(source.childNodes).toHaveLength(0);
+ expect(destination.childNodes).toHaveLength(1);
+ expect(destination.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(destination);
+ expect(branch.childNodes).toHaveLength(1);
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(source.isConnected).toBe(false);
+ expect(destination.isConnected).toBe(true);
+ expect(branch.isConnected).toBe(true);
+ expect(text.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'after';
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ branch.setAttribute('data-state', 'after');
+ expect(notify).toHaveBeenCalled();
+ });
+
+ it('notifies removal and stops notifying when a subtree moves into a detached parent', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const source = document.createElement('div');
+ const destination = document.createElement('div');
+ const branch = document.createElement('span');
+ const text = document.createTextNode('before');
+ branch.appendChild(text);
+ source.appendChild(branch);
+ document.root.appendChild(source);
+ notify.mockClear();
+
+ destination.appendChild(branch);
+
+ expect(source.childNodes).toHaveLength(0);
+ expect(destination.childNodes).toHaveLength(1);
+ expect(destination.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(destination);
+ expect(branch.childNodes).toHaveLength(1);
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(source.isConnected).toBe(true);
+ expect(destination.isConnected).toBe(false);
+ expect(branch.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'after';
+ expect(notify).not.toHaveBeenCalled();
+
+ branch.setAttribute('data-state', 'after');
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ it('keeps a subtree connected and notifying when moved between connected parents', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const source = document.createElement('div');
+ const destination = document.createElement('div');
+ const branch = document.createElement('span');
+ const text = document.createTextNode('before');
+ branch.appendChild(text);
+ source.appendChild(branch);
+ document.root.appendChild(source);
+ document.root.appendChild(destination);
+ notify.mockClear();
+
+ destination.appendChild(branch);
+
+ expect(source.childNodes).toHaveLength(0);
+ expect(destination.childNodes).toHaveLength(1);
+ expect(destination.childNodes[0]).toBe(branch);
+ expect(branch.parentNode).toBe(destination);
+ expect(branch.childNodes).toHaveLength(1);
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(source.isConnected).toBe(true);
+ expect(destination.isConnected).toBe(true);
+ expect(branch.isConnected).toBe(true);
+ expect(text.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'after';
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ branch.setAttribute('data-state', 'after');
+ expect(notify).toHaveBeenCalled();
+ });
+
+ it('moves an existing child to the end without duplicating it', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const root = document.root;
+ const first = document.createTextNode('first');
+ const second = document.createTextNode('second');
+ root.appendChild(first);
+ root.appendChild(second);
+ notify.mockClear();
+
+ root.appendChild(first);
+
+ expect(root.childNodes).toHaveLength(2);
+ expect(root.childNodes[0]).toBe(second);
+ expect(root.childNodes[1]).toBe(first);
+ expect(first.parentNode).toBe(root);
+ expect(second.parentNode).toBe(root);
+ expect(first.isConnected).toBe(true);
+ expect(second.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+ });
+
+ it('rejects cycles and removal from the wrong parent without changing the tree', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const root = document.root;
+ const parent = document.createElement('div');
+ const other = document.createElement('div');
+ const child = document.createElement('span');
+ parent.appendChild(child);
+ root.appendChild(parent);
+ root.appendChild(other);
+ notify.mockClear();
+
+ for (const mutate of [
+ () => child.appendChild(parent),
+ () => parent.appendChild(parent),
+ () => other.removeChild(child),
+ ]) {
+ expect(mutate).toThrow();
+
+ expect(root.childNodes).toHaveLength(2);
+ expect(root.childNodes[0]).toBe(parent);
+ expect(root.childNodes[1]).toBe(other);
+ expect(parent.parentNode).toBe(root);
+ expect(other.parentNode).toBe(root);
+ expect(parent.childNodes).toHaveLength(1);
+ expect(parent.childNodes[0]).toBe(child);
+ expect(child.parentNode).toBe(parent);
+ expect(child.childNodes).toHaveLength(0);
+ expect(other.childNodes).toHaveLength(0);
+ expect(notify).not.toHaveBeenCalled();
+ }
+ });
+});
+
+describe('createElementNS', () => {
+ it('creates HTML elements and text with the metadata used to match existing nodes', () => {
+ const document = documentWithRoot(vi.fn());
+ const namespace = 'http://www.w3.org/1999/xhtml';
+ const div = document.createElementNS(namespace, 'div');
+ const span = document.createElement('span');
+ const text = document.createTextNode('hello');
+
+ expect(document.root.ownerDocument).toBe(document);
+ expect(document.root.namespaceURI).toBe(namespace);
+ expect(document.root.nodeType).toBe(1);
+ expect(div.ownerDocument).toBe(document);
+ expect(div.namespaceURI).toBe(namespace);
+ expect(div.localName).toBe('div');
+ expect(div.nodeName).toBe('DIV');
+ expect(div.nodeType).toBe(1);
+ expect(div.parentNode).toBeNull();
+ expect(div.isConnected).toBe(false);
+ expect(span.namespaceURI).toBe(namespace);
+ expect(span.localName).toBe('span');
+ expect(text.ownerDocument).toBe(document);
+ expect(text.nodeName).toBe('#text');
+ expect(text.nodeType).toBe(3);
+ expect(text.firstChild).toBeNull();
+ expect(text.nextSibling).toBeNull();
+ });
+});
+
+describe('createTextNode / data', () => {
+ it('coerces numeric text to strings and updates or clears it without replacing the node', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ // Preact passes numbers directly from JavaScript, despite the DOM's string signature.
+ const text: Text = Reflect.apply(document.createTextNode, document, [42]);
+
+ expect(text.data).toBe('42');
+ expect(notify).not.toHaveBeenCalled();
+ document.root.appendChild(text);
+ notify.mockClear();
+
+ Reflect.set(text, 'data', 43n);
+
+ expect(text.data).toBe('43');
+ expect(document.root.childNodes[0]).toBe(text);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = '43';
+ expect(notify).not.toHaveBeenCalled();
+
+ text.data = '';
+ expect(text.data).toBe('');
+ expect(document.root.childNodes).toHaveLength(1);
+ expect(document.root.childNodes[0]).toBe(text);
+ expect(notify).toHaveBeenCalled();
+ });
+});
+
+describe('insertBefore', () => {
+ it('inserts before an anchor and appends with a null anchor', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const parent = document.createElement('div');
+ const first = document.createTextNode('first');
+ const middle = document.createElement('span');
+ const last = document.createTextNode('last');
+
+ expect(parent.firstChild).toBeNull();
+ expect(parent.insertBefore(last, null)).toBe(last);
+ expect(parent.insertBefore(first, last)).toBe(first);
+ expect(parent.insertBefore(middle, last)).toBe(middle);
+
+ expect(parent.childNodes).toHaveLength(3);
+ expect(parent.childNodes[0]).toBe(first);
+ expect(parent.childNodes[1]).toBe(middle);
+ expect(parent.childNodes[2]).toBe(last);
+ expect(parent.firstChild).toBe(first);
+ expect(first.nextSibling).toBe(middle);
+ expect(middle.nextSibling).toBe(last);
+ expect(last.nextSibling).toBeNull();
+ expect(first.parentNode).toBe(parent);
+ expect(middle.parentNode).toBe(parent);
+ expect(last.parentNode).toBe(parent);
+ expect(parent.children).toHaveLength(1);
+ expect(parent.children[0]).toBe(middle);
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ it('reorders existing children in both directions without duplicating them', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const parent = document.root;
+ const first = document.createElement('span');
+ const middle = document.createTextNode('middle');
+ const last = document.createElement('span');
+ parent.appendChild(first);
+ parent.appendChild(middle);
+ parent.appendChild(last);
+ notify.mockClear();
+
+ parent.insertBefore(last, first);
+
+ expect(parent.childNodes).toHaveLength(3);
+ expect(parent.firstChild).toBe(last);
+ expect(last.nextSibling).toBe(first);
+ expect(first.nextSibling).toBe(middle);
+ expect(middle.nextSibling).toBeNull();
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ parent.insertBefore(last, middle);
+
+ expect(parent.childNodes).toHaveLength(3);
+ expect(parent.firstChild).toBe(first);
+ expect(first.nextSibling).toBe(last);
+ expect(last.nextSibling).toBe(middle);
+ expect(middle.nextSibling).toBeNull();
+ expect(notify).toHaveBeenCalled();
+
+ parent.insertBefore(last, null);
+ parent.insertBefore(first, first);
+
+ expect(parent.childNodes).toHaveLength(3);
+ expect(parent.childNodes[0]).toBe(first);
+ expect(parent.childNodes[1]).toBe(middle);
+ expect(parent.childNodes[2]).toBe(last);
+ expect(parent.firstChild).toBe(first);
+ expect(first.nextSibling).toBe(middle);
+ expect(middle.nextSibling).toBe(last);
+ expect(last.nextSibling).toBeNull();
+ expect(first.parentNode).toBe(parent);
+ expect(middle.parentNode).toBe(parent);
+ expect(last.parentNode).toBe(parent);
+ });
+
+ it('connects and disconnects a subtree when inserting it into another parent', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const detached = document.createElement('div');
+ const root = document.root;
+ const branch = document.createElement('span');
+ const text = document.createTextNode('before');
+ const anchor = document.createTextNode('anchor');
+ branch.appendChild(text);
+ detached.appendChild(branch);
+ root.appendChild(anchor);
+ notify.mockClear();
+
+ root.insertBefore(branch, anchor);
+
+ expect(detached.childNodes).toHaveLength(0);
+ expect(detached.firstChild).toBeNull();
+ expect(root.childNodes).toHaveLength(2);
+ expect(root.firstChild).toBe(branch);
+ expect(branch.nextSibling).toBe(anchor);
+ expect(branch.parentNode).toBe(root);
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(branch.isConnected).toBe(true);
+ expect(text.isConnected).toBe(true);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'connected';
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ detached.insertBefore(branch, null);
+
+ expect(root.childNodes).toHaveLength(1);
+ expect(root.firstChild).toBe(anchor);
+ expect(detached.childNodes).toHaveLength(1);
+ expect(detached.firstChild).toBe(branch);
+ expect(branch.parentNode).toBe(detached);
+ expect(branch.nextSibling).toBeNull();
+ expect(branch.childNodes[0]).toBe(text);
+ expect(text.parentNode).toBe(branch);
+ expect(branch.isConnected).toBe(false);
+ expect(text.isConnected).toBe(false);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ text.data = 'detached';
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid insertion anchors and cycles before detaching any nodes', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const root = document.root;
+ const parent = document.createElement('div');
+ const other = document.createElement('div');
+ const child = document.createElement('span');
+ parent.appendChild(child);
+ root.appendChild(parent);
+ root.appendChild(other);
+ notify.mockClear();
+
+ expect(parent.insertBefore).toBeTypeOf('function');
+ expect(() => other.insertBefore(child, parent)).toThrow();
+ expect(parent.childNodes).toHaveLength(1);
+ expect(parent.childNodes[0]).toBe(child);
+ expect(child.parentNode).toBe(parent);
+ expect(other.childNodes).toHaveLength(0);
+ expect(notify).not.toHaveBeenCalled();
+
+ expect(() => child.insertBefore(parent, null)).toThrow();
+ expect(root.childNodes).toHaveLength(2);
+ expect(root.childNodes[0]).toBe(parent);
+ expect(root.childNodes[1]).toBe(other);
+ expect(parent.parentNode).toBe(root);
+ expect(parent.childNodes[0]).toBe(child);
+ expect(child.parentNode).toBe(parent);
+ expect(child.childNodes).toHaveLength(0);
+ expect(notify).not.toHaveBeenCalled();
+
+ expect(() => parent.insertBefore(parent, child)).toThrow();
+ expect(root.childNodes[0]).toBe(parent);
+ expect(parent.parentNode).toBe(root);
+ expect(parent.childNodes).toHaveLength(1);
+ expect(parent.childNodes[0]).toBe(child);
+ expect(child.parentNode).toBe(parent);
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('remove', () => {
+ it('removes elements and text, updates sibling links, and ignores repeated removal', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const root = document.root;
+ const branch = document.createElement('span');
+ const nested = document.createTextNode('nested');
+ const sibling = document.createTextNode('sibling');
+ branch.appendChild(nested);
+ root.appendChild(branch);
+ root.appendChild(sibling);
+ notify.mockClear();
+
+ branch.remove();
+
+ expect(root.childNodes).toHaveLength(1);
+ expect(root.firstChild).toBe(sibling);
+ expect(branch.parentNode).toBeNull();
+ expect(branch.nextSibling).toBeNull();
+ expect(branch.firstChild).toBe(nested);
+ expect(nested.parentNode).toBe(branch);
+ expect(branch.isConnected).toBe(false);
+ expect(nested.isConnected).toBe(false);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ branch.remove();
+ expect(notify).not.toHaveBeenCalled();
+
+ sibling.remove();
+
+ expect(root.childNodes).toHaveLength(0);
+ expect(root.firstChild).toBeNull();
+ expect(sibling.parentNode).toBeNull();
+ expect(sibling.nextSibling).toBeNull();
+ expect(sibling.isConnected).toBe(false);
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ sibling.remove();
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('setAttribute / removeAttribute', () => {
+ it('stores string attributes for existing-node matching and updates them without duplicates', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const element = document.createElement('span');
+
+ expect(element.attributes).toHaveLength(0);
+ element.setAttribute('title', 'before');
+ // Attribute values arrive from JavaScript as numbers and booleans too.
+ Reflect.apply(element.setAttribute, element, ['data-count', 42]);
+ Reflect.apply(element.setAttribute, element, ['aria-hidden', false]);
+
+ expect(element.attributes).toHaveLength(3);
+ expect(element.attributes[0]).toMatchObject({ name: 'title', value: 'before' });
+ expect(element.attributes[1]).toMatchObject({ name: 'data-count', value: '42' });
+ expect(element.attributes[2]).toMatchObject({ name: 'aria-hidden', value: 'false' });
+ expect(notify).not.toHaveBeenCalled();
+
+ document.root.appendChild(element);
+ notify.mockClear();
+ element.setAttribute('title', 'after');
+
+ expect(element.attributes).toHaveLength(3);
+ expect(element.attributes[0]).toMatchObject({ name: 'title', value: 'after' });
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ element.removeAttribute('data-count');
+
+ expect(element.attributes).toHaveLength(2);
+ expect(element.attributes[0]).toMatchObject({ name: 'title', value: 'after' });
+ expect(element.attributes[1]).toMatchObject({ name: 'aria-hidden', value: 'false' });
+ expect(notify).toHaveBeenCalled();
+
+ notify.mockClear();
+ element.removeAttribute('data-count');
+ expect(element.attributes).toHaveLength(2);
+ expect(notify).not.toHaveBeenCalled();
+
+ document.root.removeChild(element);
+ notify.mockClear();
+ element.removeAttribute('title');
+ element.removeAttribute('aria-hidden');
+ expect(element.attributes).toHaveLength(0);
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
+
+describe('events', () => {
+ describe('Event', () => {
+ it('starts with no dispatch state and allows framework bookkeeping', () => {
+ expect(Event).toBeTypeOf('function');
+ const event = new Event('click');
+
+ expect(event.type).toBe('click');
+ expect(event.bubbles).toBe(false);
+ expect(event.cancelable).toBe(false);
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.target).toBeNull();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ expect(Event.NONE).toBe(0);
+ expect(Event.CAPTURING_PHASE).toBe(1);
+ expect(Event.AT_TARGET).toBe(2);
+ expect(Event.BUBBLING_PHASE).toBe(3);
+
+ const dispatched = Symbol('dispatched');
+ expect(Reflect.set(event, dispatched, 1)).toBe(true);
+ expect(Reflect.get(event, dispatched)).toBe(1);
+ });
+ });
+
+ describe('addEventListener / removeEventListener', () => {
+ it('calls a stable listener with the element as this as its handler changes', () => {
+ const document = documentWithRoot(vi.fn());
+ const first = vi.fn();
+ const second = vi.fn();
+ const element = Object.assign(document.createElement('span'), { handler: first });
+ const listener = vi.fn(function (this: typeof element, event: Event): void {
+ this.handler(event);
+ });
+ element.addEventListener('click', listener, false);
+ element.addEventListener('click', listener, false);
+ const initial = new Event('click');
+ element.dispatchEvent(initial);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener.mock.contexts[0]).toBe(element);
+ expect(first).toHaveBeenCalledExactlyOnceWith(initial);
+
+ element.handler = second;
+ const updated = new Event('click');
+ element.dispatchEvent(updated);
+
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledExactlyOnceWith(updated);
+
+ element.removeEventListener('click', listener, false);
+ element.dispatchEvent(new Event('click'));
+
+ expect(listener).toHaveBeenCalledTimes(2);
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps capture and non-capture registrations separate when removing listeners', () => {
+ const document = documentWithRoot(vi.fn());
+ const element = document.createElement('span');
+ const listener = vi.fn();
+ element.addEventListener('click', listener, true);
+ element.addEventListener('click', listener, false);
+ element.dispatchEvent(new Event('click'));
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ listener.mockClear();
+ element.removeEventListener('click', listener, true);
+ element.dispatchEvent(new Event('click'));
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ listener.mockClear();
+ element.removeEventListener('click', listener, false);
+ element.dispatchEvent(new Event('click'));
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('scopes listeners by node and event type without requesting a render', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const first = document.createElement('span');
+ const second = document.createElement('span');
+ document.root.appendChild(first);
+ document.root.appendChild(second);
+ notify.mockClear();
+ const listener = vi.fn();
+ first.addEventListener('click', listener);
+ first.addEventListener('keydown', listener);
+ second.addEventListener('click', listener);
+
+ first.removeEventListener('click', listener);
+ first.removeEventListener('click', listener);
+ first.removeEventListener('missing', listener);
+ first.dispatchEvent(new Event('click'));
+ expect(listener).not.toHaveBeenCalled();
+
+ const keydown = new Event('keydown');
+ first.dispatchEvent(keydown);
+ expect(listener).toHaveBeenCalledExactlyOnceWith(keydown);
+
+ listener.mockClear();
+ const click = new Event('click');
+ second.dispatchEvent(click);
+ expect(listener).toHaveBeenCalledExactlyOnceWith(click);
+ expect(notify).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('dispatchEvent', () => {
+ it('sets the target and clears transient state even when there are no listeners', () => {
+ const document = documentWithRoot(vi.fn());
+ const target = document.createElement('span');
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(event.bubbles).toBe(true);
+ expect(event.cancelable).toBe(true);
+ expect(event.target).toBe(target);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('captures through the document, invokes the target, and bubbles back in order', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const parent = document.createElement('div');
+ const target = document.createElement('span');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const event = new Event('click', { bubbles: true });
+ const calls: string[] = [];
+ const phases: number[] = [];
+ const contexts: boolean[] = [];
+ const targets: boolean[] = [];
+ const events: boolean[] = [];
+ function record(label: string, currentTarget: Node): (this: Node, received: Event) => void {
+ return function (this: Node, received: Event): void {
+ calls.push(label);
+ phases.push(received.eventPhase);
+ contexts.push(this === currentTarget && received.currentTarget === currentTarget);
+ targets.push(received.target === target);
+ events.push(received === event);
+ };
+ }
+ document.addEventListener('click', record('document capture', document), true);
+ root.addEventListener('click', record('root capture', root), true);
+ parent.addEventListener('click', record('parent capture', parent), true);
+ // Register non-capture first to verify that phase order takes precedence.
+ target.addEventListener('click', record('target first', target));
+ target.addEventListener('click', record('target capture', target), true);
+ target.addEventListener('click', record('target second', target));
+ parent.addEventListener('click', record('parent bubble', parent));
+ root.addEventListener('click', record('root bubble', root));
+ document.addEventListener('click', record('document bubble', document));
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(calls).toEqual([
+ 'document capture',
+ 'root capture',
+ 'parent capture',
+ 'target capture',
+ 'target first',
+ 'target second',
+ 'parent bubble',
+ 'root bubble',
+ 'document bubble',
+ ]);
+ expect(phases).toEqual([
+ Event.CAPTURING_PHASE,
+ Event.CAPTURING_PHASE,
+ Event.CAPTURING_PHASE,
+ Event.AT_TARGET,
+ Event.AT_TARGET,
+ Event.AT_TARGET,
+ Event.BUBBLING_PHASE,
+ Event.BUBBLING_PHASE,
+ Event.BUBBLING_PHASE,
+ ]);
+ expect(contexts.every(Boolean)).toBe(true);
+ expect(targets.every(Boolean)).toBe(true);
+ expect(events.every(Boolean)).toBe(true);
+ expect(event.target).toBe(target);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ });
+
+ it('captures non-bubbling events and invokes both groups of target listeners', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const calls: string[] = [];
+ root.addEventListener('click', () => calls.push('root capture'), true);
+ root.addEventListener('click', () => calls.push('root bubble'));
+ target.addEventListener('click', () => calls.push('target'));
+ target.addEventListener('click', () => calls.push('target capture'), true);
+
+ target.dispatchEvent(new Event('click'));
+
+ expect(calls).toEqual(['root capture', 'target capture', 'target']);
+ });
+
+ it('preserves listeners and dispatches within a detached subtree without rendering', () => {
+ const notify = vi.fn();
+ const document = documentWithRoot(notify);
+ const parent = document.createElement('div');
+ const target = document.createElement('span');
+ parent.appendChild(target);
+ document.root.appendChild(parent);
+ const calls: string[] = [];
+ document.addEventListener('click', () => calls.push('document'));
+ parent.addEventListener('click', () => calls.push('parent capture'), true);
+ parent.addEventListener('click', () => calls.push('parent bubble'));
+ target.addEventListener('click', () => calls.push('target'));
+ parent.remove();
+ notify.mockClear();
+
+ target.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(target.isConnected).toBe(false);
+ expect(calls).toEqual(['parent capture', 'target', 'parent bubble']);
+ expect(notify).not.toHaveBeenCalled();
+ });
+
+ it('keeps the original ancestor path when a listener reparents the target', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const original = document.createElement('div');
+ const destination = document.createElement('div');
+ const target = document.createElement('span');
+ root.appendChild(original);
+ root.appendChild(destination);
+ original.appendChild(target);
+ const calls: string[] = [];
+ original.addEventListener(
+ 'click',
+ () => {
+ calls.push('original capture');
+ destination.appendChild(target);
+ },
+ true,
+ );
+ original.addEventListener('click', () => calls.push('original bubble'));
+ destination.addEventListener('click', () => calls.push('destination capture'), true);
+ destination.addEventListener('click', () => calls.push('destination bubble'));
+ target.addEventListener('click', () => calls.push('target'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+
+ target.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(target.parentNode).toBe(destination);
+ expect(calls).toEqual(['original capture', 'target', 'original bubble', 'root bubble']);
+
+ calls.length = 0;
+ target.dispatchEvent(new Event('click', { bubbles: true }));
+ expect(calls).toEqual(['destination capture', 'target', 'destination bubble', 'root bubble']);
+ });
+
+ it('skips a removed listener even if the same callback is registered again', () => {
+ const document = documentWithRoot(vi.fn());
+ const target = document.createElement('span');
+ const calls: string[] = [];
+ const later = (): number => calls.push('later');
+ const replace = (): void => {
+ calls.push('replace');
+ target.removeEventListener('click', later);
+ target.addEventListener('click', later);
+ };
+ target.addEventListener('click', replace);
+ target.addEventListener('click', later);
+
+ target.dispatchEvent(new Event('click'));
+ expect(calls).toEqual(['replace']);
+
+ target.removeEventListener('click', replace);
+ calls.length = 0;
+ target.dispatchEvent(new Event('click'));
+ expect(calls).toEqual(['later']);
+ });
+
+ it('allows additions in a later phase but defers additions to the current listener list', () => {
+ const document = documentWithRoot(vi.fn());
+ const target = document.createElement('span');
+ const calls: string[] = [];
+ const later = (): number => calls.push('later');
+ const added = (): void => {
+ calls.push('added');
+ target.addEventListener('click', later);
+ };
+ target.addEventListener(
+ 'click',
+ () => {
+ calls.push('capture');
+ target.addEventListener('click', added);
+ },
+ true,
+ );
+
+ target.dispatchEvent(new Event('click'));
+ expect(calls).toEqual(['capture', 'added']);
+
+ calls.length = 0;
+ target.dispatchEvent(new Event('click'));
+ expect(calls).toEqual(['capture', 'added', 'later']);
+ });
+
+ it('supports nested events, rejects active-event redispatch, and permits later reuse', () => {
+ const document = documentWithRoot(vi.fn());
+ const target = document.createElement('span');
+ const outer = new Event('outer');
+ const inner = new Event('inner');
+ const calls: string[] = [];
+ const states: boolean[] = [];
+ let redispatchError: unknown;
+ let redispatching = false;
+ target.addEventListener('inner', (event: Event) => {
+ calls.push('inner');
+ states.push(event.currentTarget === target && outer.currentTarget === target);
+ });
+ target.addEventListener('outer', (event: Event) => {
+ if (redispatching) {
+ calls.push('unexpected redispatch');
+ return;
+ }
+ calls.push('outer start');
+ redispatching = true;
+ try {
+ target.dispatchEvent(event);
+ } catch (error) {
+ redispatchError = error;
+ } finally {
+ redispatching = false;
+ }
+ target.dispatchEvent(inner);
+ states.push(event.currentTarget === target && event.eventPhase === Event.AT_TARGET);
+ states.push(inner.currentTarget === null && inner.eventPhase === Event.NONE);
+ calls.push('outer end');
+ });
+
+ target.dispatchEvent(outer);
+
+ expect(redispatchError).toBeInstanceOf(Error);
+ expect(calls).toEqual(['outer start', 'inner', 'outer end']);
+ expect(states).toEqual([true, true, true]);
+ expect(outer.currentTarget).toBeNull();
+ expect(outer.eventPhase).toBe(Event.NONE);
+
+ calls.length = 0;
+ states.length = 0;
+ redispatchError = undefined;
+ target.dispatchEvent(outer);
+ expect(redispatchError).toBeInstanceOf(Error);
+ expect(calls).toEqual(['outer start', 'inner', 'outer end']);
+ expect(states).toEqual([true, true, true]);
+ });
+ });
+
+ describe('stopPropagation', () => {
+ it('finishes the current ancestor capture listeners without reaching the target', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const calls: string[] = [];
+ root.addEventListener(
+ 'click',
+ (event: Event) => {
+ calls.push('first capture');
+ event.stopPropagation();
+ },
+ true,
+ );
+ root.addEventListener('click', () => calls.push('second capture'), true);
+ root.addEventListener('click', () => calls.push('root bubble'));
+ target.addEventListener('click', () => calls.push('target capture'), true);
+ target.addEventListener('click', () => calls.push('target'));
+
+ target.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(calls).toEqual(['first capture', 'second capture']);
+ });
+
+ it('stops capture before visiting another ancestor', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const parent = document.createElement('div');
+ const target = document.createElement('span');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const calls: string[] = [];
+ root.addEventListener(
+ 'click',
+ (event: Event) => {
+ calls.push('first root capture');
+ event.stopPropagation();
+ },
+ true,
+ );
+ root.addEventListener('click', () => calls.push('second root capture'), true);
+ parent.addEventListener('click', () => calls.push('parent capture'), true);
+ target.addEventListener('click', () => calls.push('target capture'), true);
+ target.addEventListener('click', () => calls.push('target'));
+ parent.addEventListener('click', () => calls.push('parent bubble'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+ document.addEventListener('click', () => calls.push('document bubble'));
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(calls).toEqual(['first root capture', 'second root capture']);
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ });
+
+ it('finishes target capture listeners without entering the non-capture phase', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener(
+ 'click',
+ (event: Event) => {
+ calls.push('first capture');
+ event.stopPropagation();
+ },
+ true,
+ );
+ target.addEventListener('click', () => calls.push('second capture'), true);
+ target.addEventListener('click', () => calls.push('target'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+
+ target.dispatchEvent(new Event('click', { bubbles: true }));
+
+ expect(calls).toEqual(['first capture', 'second capture']);
+ });
+
+ it('finishes target listeners without bubbling or canceling the event', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener('click', (event: Event) => {
+ calls.push('first');
+ event.stopPropagation();
+ });
+ target.addEventListener('click', () => calls.push('second'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(calls).toEqual(['first', 'second']);
+ expect(event.defaultPrevented).toBe(false);
+ });
+
+ it('finishes the current ancestor bubble listeners without visiting further ancestors', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const parent = document.createElement('div');
+ const target = document.createElement('span');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener('click', () => calls.push('target'));
+ parent.addEventListener('click', (event: Event) => {
+ calls.push('first parent bubble');
+ event.stopPropagation();
+ });
+ parent.addEventListener('click', () => calls.push('second parent bubble'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+ document.addEventListener('click', () => calls.push('document bubble'));
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(calls).toEqual(['target', 'first parent bubble', 'second parent bubble']);
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ });
+ });
+
+ describe('stopImmediatePropagation', () => {
+ it('stops remaining ancestor bubble listeners and further ancestors', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const parent = document.createElement('div');
+ const target = document.createElement('span');
+ root.appendChild(parent);
+ parent.appendChild(target);
+ const calls: string[] = [];
+ target.addEventListener('click', () => calls.push('target'));
+ parent.addEventListener('click', (event: Event) => {
+ calls.push('first parent bubble');
+ event.stopImmediatePropagation();
+ });
+ parent.addEventListener('click', () => calls.push('second parent bubble'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+ document.addEventListener('click', () => calls.push('document bubble'));
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+
+ expect(calls).toEqual(['target', 'first parent bubble']);
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ });
+
+ it('stops remaining listeners and ancestors, then clears the stop flags for reuse', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const calls: string[] = [];
+ const stop = (event: Event): void => {
+ calls.push('stop');
+ event.stopImmediatePropagation();
+ };
+ target.addEventListener('click', stop);
+ target.addEventListener('click', () => calls.push('target'));
+ root.addEventListener('click', () => calls.push('root bubble'));
+ const event = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(event)).toBe(true);
+ expect(calls).toEqual(['stop']);
+ expect(event.defaultPrevented).toBe(false);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+
+ target.removeEventListener('click', stop);
+ calls.length = 0;
+ expect(target.dispatchEvent(event)).toBe(true);
+ expect(calls).toEqual(['target', 'root bubble']);
+ });
+ });
+
+ describe('preventDefault', () => {
+ it('cancels only cancelable events without stopping propagation', () => {
+ const document = documentWithRoot(vi.fn());
+ const root = document.root;
+ const target = document.createElement('span');
+ root.appendChild(target);
+ const prevented: boolean[] = [];
+ const prevent = (event: Event): void => event.preventDefault();
+ target.addEventListener('click', prevent);
+ root.addEventListener('click', (event: Event) => prevented.push(event.defaultPrevented));
+ const ordinary = new Event('click', { bubbles: true });
+ const cancelable = new Event('click', { bubbles: true, cancelable: true });
+
+ expect(target.dispatchEvent(ordinary)).toBe(true);
+ expect(ordinary.defaultPrevented).toBe(false);
+ expect(target.dispatchEvent(cancelable)).toBe(false);
+ expect(cancelable.defaultPrevented).toBe(true);
+ expect(prevented).toEqual([false, true]);
+
+ target.removeEventListener('click', prevent);
+ expect(target.dispatchEvent(cancelable)).toBe(false);
+ expect(cancelable.defaultPrevented).toBe(true);
+ });
+ });
+});
diff --git a/packages/dom/test/browser/custom-elements.html b/packages/dom/test/browser/custom-elements.html
new file mode 100644
index 0000000..30f719b
--- /dev/null
+++ b/packages/dom/test/browser/custom-elements.html
@@ -0,0 +1,118 @@
+
+
+Custom element browser differential
+Running
+
diff --git a/packages/dom/test/components.test.ts b/packages/dom/test/components.test.ts
new file mode 100644
index 0000000..5510806
--- /dev/null
+++ b/packages/dom/test/components.test.ts
@@ -0,0 +1,603 @@
+import assert from 'node:assert/strict';
+import { describe, expect, it, vi } from 'vitest';
+import { Document, Element, HTMLTemplateElement, HTMLSlotElement } from '../src/dom.ts';
+import { Event } from '../src/events.ts';
+import { installParsing } from '../src/parsing.ts';
+
+function setup(): { document: Document; root: Element } {
+ const document = new Document();
+ const root = document.createElement('screen');
+ document.appendChild(root);
+ return { document, root };
+}
+
+describe('autonomous custom elements', () => {
+ it('runs moving lifecycle callbacks after adoption and insertion finish', () => {
+ const first = setup();
+ const second = setup();
+ const calls: string[] = [];
+ class Item extends first.document.HTMLElement {
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ disconnectedCallback(): void {
+ expect(this.parentNode).toBe(second.root);
+ expect(this.ownerDocument).toBe(second.document);
+ expect(this.isConnected).toBe(true);
+ calls.push('disconnected');
+ }
+ adoptedCallback(): void {
+ expect(this.parentNode).toBe(second.root);
+ calls.push('adopted');
+ }
+ }
+ first.document.customElements.define('x-item', Item);
+ const item = first.root.appendChild(new Item());
+ calls.length = 0;
+ second.root.appendChild(item);
+ expect(calls).toEqual(['disconnected', 'adopted', 'connected']);
+ });
+ it('finishes removing all children before a disconnected callback can reinsert a sibling', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ disconnectedCallback(): void {
+ calls.push(this.id);
+ if (this.id === 'first') {
+ expect(root.childNodes).toHaveLength(0);
+ root.appendChild(second);
+ }
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const first = new Item();
+ first.id = 'first';
+ const second = new Item();
+ second.id = 'second';
+ root.append(first, second);
+ root.replaceChildren();
+ expect(calls).toEqual(['first', 'second']);
+ expect(root.childNodes).toEqual([second]);
+ });
+ it('finishes inserting all before/after arguments before their connected callbacks', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ connectedCallback(): void {
+ calls.push(root.textContent ?? '');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const anchor = root.appendChild(document.createElement('anchor'));
+ anchor.textContent = 'anchor';
+ const before = new Item();
+ before.textContent = 'before';
+ anchor.before(before, ' one');
+ const after = new Item();
+ after.textContent = 'after';
+ anchor.after(after, ' two');
+ expect(calls).toEqual(['before oneanchor', 'before oneanchorafter two']);
+ });
+ it('clears reactions when an existing element cannot be upgraded', () => {
+ const errors: unknown[] = [];
+ const calls: string[] = [];
+ const document = new Document({ reportError: (error) => errors.push(error) });
+ const item = document.appendChild(document.createElement('x-item'));
+ item.setAttribute('value', 'one');
+ Object.preventExtensions(item);
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ attributeChangedCallback(): void {
+ calls.push('attribute');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ document.customElements.upgrade(item);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toBeInstanceOf(TypeError);
+ expect(calls).toEqual([]);
+ });
+ it('captures lifecycle callbacks at definition time', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ connectedCallback(): void {
+ calls.push('original connected');
+ }
+ attributeChangedCallback(): void {
+ calls.push('original attribute');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ Item.prototype.connectedCallback = () => {
+ calls.push('replacement connected');
+ };
+ Item.prototype.attributeChangedCallback = () => {
+ calls.push('replacement attribute');
+ };
+ const item = new Item();
+ root.appendChild(item);
+ item.setAttribute('value', 'next');
+ expect(calls).toEqual(['original connected', 'original attribute']);
+ });
+ it('only reads observedAttributes when an attribute callback was registered', () => {
+ const { document } = setup();
+ let reads = 0;
+ class Item extends document.HTMLElement {
+ static override get observedAttributes(): string[] {
+ reads++;
+ return ['value'];
+ }
+ }
+ document.customElements.define('x-item', Item);
+ expect(reads).toBe(0);
+ });
+ it('rejects non-callable lifecycle methods without registering a partial definition', () => {
+ const { document } = setup();
+ class Item extends document.HTMLElement {}
+ Reflect.set(Item.prototype, 'connectedCallback', 42);
+ expect(() => document.customElements.define('x-item', Item)).toThrow(TypeError);
+ expect(document.customElements.get('x-item')).toBeUndefined();
+ Reflect.deleteProperty(Item.prototype, 'connectedCallback');
+ document.customElements.define('x-item', Item);
+ expect(document.createElement('x-item')).toBeInstanceOf(Item);
+ });
+ it('rejects recursive definition getters and restores the registry after failure', () => {
+ const { document } = setup();
+ let recurse = true;
+ class Other extends document.HTMLElement {}
+ class Item extends document.HTMLElement {
+ static override get observedAttributes(): string[] {
+ if (recurse) document.customElements.define('x-other', Other);
+ return ['value'];
+ }
+ attributeChangedCallback(): void {}
+ }
+ expect(() => document.customElements.define('x-item', Item)).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ expect(document.customElements.get('x-item')).toBeUndefined();
+ expect(document.customElements.get('x-other')).toBeUndefined();
+ recurse = false;
+ document.customElements.define('x-item', Item);
+ document.customElements.define('x-other', Other);
+ expect(document.createElement('x-item')).toBeInstanceOf(Item);
+ });
+ it('snapshots pre-upgrade attribute values before a constructor changes attributes', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ const existing = document.createElement('x-item');
+ existing.setAttribute('value', 'before');
+ root.appendChild(existing);
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ constructor() {
+ super();
+ calls.push('construct start');
+ this.setAttribute('value', 'after');
+ calls.push('construct end');
+ }
+ attributeChangedCallback(_name: string, old: string | null, value: string): void {
+ calls.push(`${old}:${value}`);
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ expect(existing.getAttribute('value')).toBe('after');
+ expect(calls).toEqual(['construct start', 'construct end', 'null:before', 'connected']);
+ });
+ it('finishes each upgrade and its callbacks before constructing the next candidate', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ const first = document.createElement('x-item');
+ first.id = 'first';
+ const second = document.createElement('x-item');
+ second.id = 'second';
+ root.append(first, second);
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push(`${this.id}:construct`);
+ }
+ connectedCallback(): void {
+ calls.push(`${this.id}:connected`);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ expect(calls).toEqual([
+ 'first:construct',
+ 'first:connected',
+ 'second:construct',
+ 'second:connected',
+ ]);
+ });
+ it('delivers nested attribute callbacks before the modifying call returns', () => {
+ const { document } = setup();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['first', 'second'];
+ attributeChangedCallback(name: string): void {
+ calls.push(`${name}:start`);
+ if (name === 'first') this.setAttribute('second', 'value');
+ calls.push(`${name}:end`);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ new Item().setAttribute('first', 'value');
+ expect(calls).toEqual(['first:start', 'second:start', 'second:end', 'first:end']);
+ });
+ it('drains already queued reactions for an element before its reentrant new reaction', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ const item = document.createElement('x-item');
+ item.setAttribute('first', 'one');
+ item.setAttribute('second', 'two');
+ root.appendChild(item);
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['first', 'second'];
+ attributeChangedCallback(name: string, _old: string | null, value: string): void {
+ calls.push(`${name}:${value}`);
+ if (name === 'first') {
+ this.setAttribute('second', 'changed');
+ calls.push('first:return');
+ }
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ expect(calls).toEqual([
+ 'first:one',
+ 'second:two',
+ 'connected',
+ 'second:changed',
+ 'first:return',
+ ]);
+ });
+ it('cancels uninvoked reactions after a failed upgrade and never retries that element', () => {
+ const errors: unknown[] = [];
+ const document = new Document({ reportError: (error) => errors.push(error) });
+ const root = document.appendChild(document.createElement('root'));
+ const item = root.appendChild(document.createElement('x-item'));
+ item.setAttribute('value', 'before');
+ const failure = new RangeError('constructor failed');
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ constructor() {
+ super();
+ calls.push('construct');
+ throw failure;
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ attributeChangedCallback(): void {
+ calls.push('attribute');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ item.setAttribute('value', 'after');
+ item.remove();
+ root.appendChild(item);
+ document.customElements.upgrade(item);
+ expect(calls).toEqual(['construct']);
+ expect(errors).toEqual([failure]);
+ });
+ it('keeps a shared pending whenDefined promise and document ownership across async upgrades', async () => {
+ const first = setup();
+ const second = setup();
+ const calls: string[] = [];
+ const waiting = first.document.customElements.whenDefined('x-item');
+ expect(first.document.customElements.whenDefined('x-item')).toBe(waiting);
+ const firstItem = first.root.appendChild(first.document.createElement('x-item'));
+ const secondItem = second.root.appendChild(second.document.createElement('x-item'));
+ class First extends first.document.HTMLElement {
+ connectedCallback(): void {
+ calls.push('first');
+ }
+ }
+ class Second extends second.document.HTMLElement {
+ connectedCallback(): void {
+ calls.push('second');
+ }
+ }
+ await Promise.resolve();
+ first.document.customElements.define('x-item', First);
+ first.document.customElements.upgrade(secondItem);
+ expect(secondItem).not.toBeInstanceOf(First);
+ await Promise.resolve();
+ second.document.customElements.define('x-item', Second);
+ expect(await waiting).toBe(First);
+ expect(firstItem).toBeInstanceOf(First);
+ expect(secondItem).toBeInstanceOf(Second);
+ expect(firstItem.ownerDocument).toBe(first.document);
+ expect(secondItem.ownerDocument).toBe(second.document);
+ expect(calls).toEqual(['first', 'second']);
+ });
+ it('constructs registered classes without installing globals', () => {
+ const { document, root } = setup();
+ class Counter extends document.HTMLElement {
+ count = 1;
+ }
+ document.customElements.define('x-counter', Counter);
+ const counter = document.createElement('x-counter');
+ expect(counter).toBeInstanceOf(Counter);
+ expect(new Counter().ownerDocument).toBe(document);
+ root.appendChild(counter);
+ expect(document.customElements.getName(Counter)).toBe('x-counter');
+ expect(globalThis).not.toHaveProperty('document');
+ });
+ it('upgrades existing identity, descendants, user state and listeners', () => {
+ const { document, root } = setup();
+ const existing = document.createElement('x-counter');
+ const text = document.createTextNode('retained');
+ existing.appendChild(text);
+ const listener = vi.fn();
+ existing.addEventListener('change', listener);
+ const userState = { id: 42 };
+ Reflect.set(existing, 'userState', userState);
+ existing.setAttribute('value', 'before');
+ root.appendChild(existing);
+ const calls: string[] = [];
+ class Counter extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ initialized = true;
+ constructor() {
+ super();
+ calls.push('construct');
+ }
+ attributeChangedCallback(name: string, old: string | null, value: string): void {
+ calls.push(`${name}:${old}:${value}`);
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ document.customElements.define('x-counter', Counter);
+ expect(root.firstChild).toBe(existing);
+ expect(existing).toBeInstanceOf(Counter);
+ expect(existing.firstChild).toBe(text);
+ expect(Reflect.get(existing, 'userState')).toBe(userState);
+ existing.dispatchEvent(new Event('change'));
+ expect(listener).toHaveBeenCalledOnce();
+ expect(calls).toEqual(['construct', 'value:null:before', 'connected']);
+ });
+ it('orders disconnect, adoption, connect and attributes across documents', () => {
+ const { document, root } = setup();
+ const second = setup();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['state'];
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ disconnectedCallback(): void {
+ calls.push('disconnected');
+ }
+ adoptedCallback(old: Document, next: Document): void {
+ expect(old).toBe(document);
+ expect(next).toBe(second.document);
+ calls.push('adopted');
+ }
+ attributeChangedCallback(name: string, old: string | null, value: string | null): void {
+ calls.push(`${name}:${old}:${value}`);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const item = new Item();
+ root.appendChild(item);
+ item.setAttribute('state', 'one');
+ second.root.appendChild(item);
+ item.removeAttribute('state');
+ expect(calls).toEqual([
+ 'connected',
+ 'state:null:one',
+ 'disconnected',
+ 'adopted',
+ 'connected',
+ 'state:one:null',
+ ]);
+ expect(item.ownerDocument).toBe(second.document);
+ });
+ it('handles reentrant lifecycle mutations and reports callback exceptions', () => {
+ const errors: unknown[] = [];
+ const document = new Document({ reportError: (error) => errors.push(error) });
+ const root = document.createElement('screen');
+ document.appendChild(root);
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ connectedCallback(): void {
+ calls.push('connected');
+ this.remove();
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Deliberately throw a user callback exception to verify reporting.
+ throw new TypeError('callback');
+ }
+ disconnectedCallback(): void {
+ calls.push('disconnected');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const item = new Item();
+ root.appendChild(item);
+ expect(calls).toEqual(['connected', 'disconnected']);
+ expect(errors).toHaveLength(1);
+ expect(item.parentNode).toBeNull();
+ });
+ it('upgrades each element in a subtree exactly once on connection', () => {
+ const { document, root } = setup();
+ const calls: string[] = [];
+ const parent = document.createElement('x-item');
+ const child = document.createElement('x-item');
+ parent.appendChild(child);
+ class Item extends document.HTMLElement {
+ connectedCallback(): void {
+ calls.push(this === parent ? 'parent' : 'child');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ root.appendChild(parent);
+ expect(calls).toEqual(['parent', 'child']);
+ });
+ it('keeps registries independent and supports explicit detached upgrades', async () => {
+ const first = setup();
+ const second = setup();
+ class First extends first.document.HTMLElement {}
+ class Second extends second.document.HTMLElement {}
+ const waiting = first.document.customElements.whenDefined('x-item');
+ const item = first.document.createElement('x-item');
+ first.document.customElements.define('x-item', First);
+ second.document.customElements.define('x-item', Second);
+ expect(await waiting).toBe(First);
+ expect(item).not.toBeInstanceOf(First);
+ first.document.customElements.upgrade(item);
+ expect(item).toBeInstanceOf(First);
+ expect(second.document.createElement('x-item')).toBeInstanceOf(Second);
+ expect(() => first.document.customElements.define('x-item', First)).toThrow();
+ expect(() => first.document.customElements.define('bad', First)).toThrow();
+ });
+});
+
+describe('templates, parsing and slots', () => {
+ it('constructs parsed custom elements after their attributes and descendants exist', () => {
+ const { document, root } = setup();
+ installParsing(document);
+ const calls: unknown[][] = [];
+ class Item extends document.HTMLElement {
+ static override observedAttributes = ['value'];
+ constructor() {
+ super();
+ calls.push(['construct', this.getAttribute('value'), this.textContent, this.isConnected]);
+ }
+ attributeChangedCallback(_name: string, old: string | null, value: string): void {
+ calls.push(['attribute', old, value]);
+ }
+ connectedCallback(): void {
+ calls.push(['connected', this.textContent]);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const detached = document.createElement('box');
+ detached.innerHTML = 'detached';
+ expect(calls).toEqual([
+ ['construct', 'one', 'detached', false],
+ ['attribute', null, 'one'],
+ ]);
+ calls.length = 0;
+ root.innerHTML = 'connected';
+ expect(calls).toEqual([
+ ['construct', 'two', 'connected', true],
+ ['attribute', null, 'two'],
+ ['connected', 'connected'],
+ ]);
+ });
+ it('upgrades parsed parents before descendants and exposes the complete parsed tree', () => {
+ const { document, root } = setup();
+ installParsing(document);
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push(`${this.id}:${this.textContent}`);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ root.innerHTML = 'text';
+ expect(calls).toEqual(['parent:text', 'child:text']);
+ });
+ it('keeps custom elements inert when parsing directly into a template', () => {
+ const { document, root } = setup();
+ installParsing(document);
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push('constructed');
+ }
+ connectedCallback(): void {
+ calls.push('connected');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ const template = document.createElement('template');
+ template.innerHTML = 'value';
+ expect(calls).toEqual([]);
+ expect(template.content.firstChild).not.toBeInstanceOf(Item);
+ root.appendChild(template.content);
+ expect(calls).toEqual(['constructed', 'connected']);
+ expect(root.firstChild).toBeInstanceOf(Item);
+ });
+ it('opts parsing in without executing scripts and uses inert template contents', () => {
+ const { document, root } = setup();
+ installParsing(document);
+ root.innerHTML =
+ 'hello';
+ const template = root.firstChild;
+ expect(template).toBeInstanceOf(HTMLTemplateElement);
+ assert(template instanceof HTMLTemplateElement);
+ expect(template.childNodes).toHaveLength(0);
+ expect(template.content.textContent).toBe('hello');
+ expect(template.content.ownerDocument).not.toBe(document);
+ expect(template.content.firstChild?.isConnected).toBe(false);
+ const imported = document.importNode(template.content, true);
+ root.appendChild(imported);
+ expect(root.lastChild?.ownerDocument).toBe(document);
+ expect(root.lastChild?.textContent).toBe('hello');
+ expect(template.innerHTML).toBe('hello');
+ });
+ it('deep clones nested templates and preserves comments, namespaces and traversal', () => {
+ const { document } = setup();
+ installParsing(document);
+ const template = document.createElement('template');
+ template.innerHTML =
+ 'nested';
+ const clone = document.importNode(template, true);
+ expect(clone).toBeInstanceOf(HTMLTemplateElement);
+ assert(clone instanceof HTMLTemplateElement);
+ expect(clone.content.firstChild).not.toBe(template.content.firstChild);
+ const svg = clone.content.firstChild;
+ expect(svg instanceof Element && svg.namespaceURI).toBe('http://www.w3.org/2000/svg');
+ const walker = document.createTreeWalker(clone.content, 129);
+ const names: string[] = [];
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) names.push(node.nodeName);
+ expect(names).toEqual(['svg', 'circle', '#comment', 'TEMPLATE']);
+ const nested = clone.content.lastChild;
+ expect(nested instanceof HTMLTemplateElement && nested.content.textContent).toBe('nested');
+ });
+ it('assigns named/default slots, fallback and coalesced slotchange', async () => {
+ const { document, root } = setup();
+ const host = document.createElement('x-host');
+ root.appendChild(host);
+ const shadow = host.attachShadow({ mode: 'open' });
+ const named = document.createElement('slot');
+ named.name = 'label';
+ const fallback = document.createTextNode('fallback');
+ named.appendChild(fallback);
+ const main = document.createElement('slot');
+ shadow.append(named, main);
+ expect(named).toBeInstanceOf(HTMLSlotElement);
+ expect(named.assignedNodes({ flatten: true })).toEqual([fallback]);
+ const events = vi.fn();
+ named.addEventListener('slotchange', events);
+ const label = document.createElement('text');
+ label.slot = 'label';
+ const body = document.createTextNode('body');
+ host.append(label, body);
+ expect(label.assignedSlot).toBe(named);
+ expect(named.assignedElements()).toEqual([label]);
+ expect(main.assignedNodes()).toEqual([body]);
+ label.slot = '';
+ label.slot = 'label';
+ await Promise.resolve();
+ expect(events).toHaveBeenCalledOnce();
+ expect(label.parentNode).toBe(host);
+ expect(shadow.isConnected).toBe(true);
+ });
+});
diff --git a/packages/dom/test/controls.test.ts b/packages/dom/test/controls.test.ts
new file mode 100644
index 0000000..3c6407e
--- /dev/null
+++ b/packages/dom/test/controls.test.ts
@@ -0,0 +1,231 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Document, HTMLInputElement, HTMLTextAreaElement } from '../src/dom.ts';
+import { Event } from '../src/events.ts';
+
+afterEach(() => vi.useRealTimers());
+
+describe('text controls', () => {
+ it.each(['input', 'textarea'] as const)(
+ 'clones dirty %s values without copying caret or listeners',
+ (tag) => {
+ const document = new Document();
+ const source = document.createElement(tag);
+ source.defaultValue = 'default';
+ source.value = 'edited 😀';
+ source.setSelectionRange(1, 4, 'backward');
+ document.append(source);
+ source.focus();
+ const listener = vi.fn();
+ source.addEventListener('custom', listener);
+ const copy = source.cloneNode(true);
+ if (!(copy instanceof HTMLInputElement || copy instanceof HTMLTextAreaElement))
+ expect.unreachable();
+ expect(copy.value).toBe('edited 😀');
+ expect(copy.defaultValue).toBe('default');
+ expect(copy.selectionStart).toBe(0);
+ expect(copy.selectionEnd).toBe(0);
+ expect(copy.selectionDirection).toBe('none');
+ copy.dispatchEvent(new Event('custom'));
+ expect(listener).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(source);
+ copy.defaultValue = 'different default';
+ expect(copy.value).toBe('edited 😀');
+ copy.value = 'independent';
+ expect(source.value).toBe('edited 😀');
+ },
+ );
+
+ it('preserves a pristine shallow textarea value separately from its empty default', () => {
+ const document = new Document();
+ const source = document.createElement('textarea');
+ source.defaultValue = 'original';
+ const copy = source.cloneNode(false);
+ if (!(copy instanceof HTMLTextAreaElement)) expect.unreachable();
+ expect(copy.childNodes).toHaveLength(0);
+ expect(copy.defaultValue).toBe('');
+ expect(copy.value).toBe('original');
+ copy.defaultValue = '';
+ expect(copy.value).toBe('');
+ copy.defaultValue = 'replacement';
+ expect(copy.value).toBe('replacement');
+ expect(source.value).toBe('original');
+ });
+
+ it.each(['input', 'textarea'] as const)(
+ 'preserves the pristine %s flag across deep clone and import',
+ (tag) => {
+ const document = new Document();
+ const destination = new Document();
+ const source = document.createElement(tag);
+ source.defaultValue = 'default';
+ const copy = destination.importNode(source, true);
+ if (!(copy instanceof HTMLInputElement || copy instanceof HTMLTextAreaElement))
+ expect.unreachable();
+ expect(copy.ownerDocument).toBe(destination);
+ expect(copy.value).toBe('default');
+ copy.defaultValue = 'updated';
+ expect(copy.value).toBe('updated');
+ expect(source.value).toBe('default');
+ },
+ );
+
+ it('retains value and UTF-16 selection when an existing control moves between documents', () => {
+ const source = new Document();
+ const destination = new Document();
+ const input = source.createElement('input');
+ input.value = 'a😀b';
+ input.setSelectionRange(1, 3, 'forward');
+ source.append(input);
+ destination.append(input);
+ expect(input.ownerDocument).toBe(destination);
+ expect(input.value).toBe('a😀b');
+ expect(input.selectionStart).toBe(1);
+ expect(input.selectionEnd).toBe(3);
+ expect(input.selectionDirection).toBe('forward');
+ });
+
+ it('creates typed input/textarea subclasses with independent value selection', () => {
+ const document = new Document();
+ const input = document.createElement('input');
+ const textarea = document.createElement('textarea');
+ expect(input).toBeInstanceOf(HTMLInputElement);
+ expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
+ input.value = 'hello';
+ textarea.value = 'world';
+ input.setSelectionRange(1, 4, 'backward');
+ expect(input.selectionStart).toBe(1);
+ expect(input.selectionEnd).toBe(4);
+ expect(input.selectionDirection).toBe('backward');
+ expect(textarea.selectionStart).toBe(5);
+ });
+
+ it('uses UTF-16 units, clamps offsets, and collapses backward ranges to their end', () => {
+ const input = new Document().createElement('input');
+ input.value = 'a😀b';
+ input.setSelectionRange(1, 3);
+ expect(input.value.slice(input.selectionStart ?? 0, input.selectionEnd ?? 0)).toBe('😀');
+ input.setSelectionRange(99, 99);
+ expect(input.selectionStart).toBe(4);
+ input.setSelectionRange(3, 1);
+ expect(input.selectionStart).toBe(1);
+ expect(input.selectionEnd).toBe(1);
+ input.selectionStart = 3;
+ expect(input.selectionEnd).toBe(3);
+ input.selectionEnd = 0;
+ expect(input.selectionStart).toBe(0);
+ });
+
+ it('selects all without changing focus or firing input/change events', () => {
+ const document = new Document();
+ const input = document.createElement('input');
+ input.value = 'text';
+ const listener = vi.fn();
+ input.addEventListener('input', listener);
+ input.addEventListener('change', listener);
+ input.select();
+ expect(input.selectionStart).toBe(0);
+ expect(input.selectionEnd).toBe(4);
+ expect(document.focusManager.activeElement).toBeNull();
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['select', 2, 5],
+ ['start', 2, 2],
+ ['end', 5, 5],
+ ['preserve', 1, 7],
+ ] as const)('setRangeText applies %s selection mode', (mode, start, end) => {
+ const input = new Document().createElement('input');
+ input.value = 'abcdefgh';
+ input.setSelectionRange(1, 6, 'backward');
+ input.setRangeText('XYZ', 2, 4, mode);
+ expect(input.value).toBe('abXYZefgh');
+ expect(input.selectionStart).toBe(start);
+ expect(input.selectionEnd).toBe(end);
+ expect(input.selectionDirection).toBe('none');
+ });
+
+ it('setRangeText defaults to the selection and validates before modifying value', () => {
+ const input = new Document().createElement('input');
+ input.value = 'abcdef';
+ input.setSelectionRange(2, 4);
+ input.setRangeText('X');
+ expect(input.value).toBe('abXef');
+ expect(input.selectionStart).toBe(2);
+ expect(input.selectionEnd).toBe(3);
+ expect(() => input.setRangeText('broken', 5, 1)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(input.value).toBe('abXef');
+ });
+
+ it('returns null and throws InvalidStateError for selection on non-text input types', () => {
+ const input = new Document().createElement('input');
+ input.type = 'number';
+ expect(input.selectionStart).toBeNull();
+ expect(input.selectionEnd).toBeNull();
+ expect(input.selectionDirection).toBeNull();
+ expect(() => input.setSelectionRange(0, 1)).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ expect(() => input.setRangeText('1')).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ input.select();
+ });
+
+ it('normalizes input newlines and textarea CRLF while retaining dirty values', () => {
+ const document = new Document();
+ const input = document.createElement('input');
+ input.defaultValue = 'initial';
+ expect(input.value).toBe('initial');
+ input.value = 'a\r\nb';
+ input.defaultValue = 'new default';
+ expect(input.value).toBe('ab');
+ const textarea = document.createElement('textarea');
+ textarea.defaultValue = 'first\r\nsecond';
+ expect(textarea.value).toBe('first\nsecond');
+ textarea.value = 'new\rline';
+ expect(textarea.value).toBe('new\nline');
+ expect(textarea.defaultValue).toBe('first\r\nsecond');
+ });
+
+ it('keeps the caret on same-value writes and moves it to the end on changed values', () => {
+ const input = new Document().createElement('input');
+ input.value = 'hello';
+ input.setSelectionRange(1, 3);
+ input.value = 'hello';
+ expect(input.selectionStart).toBe(1);
+ expect(input.selectionEnd).toBe(3);
+ input.value = 'x';
+ expect(input.selectionStart).toBe(1);
+ expect(input.selectionEnd).toBe(1);
+ });
+
+ it('invalidates connected control state, with queued selection events', async () => {
+ vi.useFakeTimers();
+ const changes = vi.fn();
+ const document = new Document({ onInvalidate: changes });
+ const input = document.createElement('input');
+ input.value = 'abcd';
+ await vi.runAllTimersAsync();
+ expect(changes).not.toHaveBeenCalled();
+ document.append(input);
+ changes.mockClear();
+ const select = vi.fn();
+ const selectionchange = vi.fn();
+ input.addEventListener('select', select);
+ input.addEventListener('selectionchange', selectionchange);
+ input.setSelectionRange(0, 1);
+ input.setSelectionRange(1, 2);
+ expect(changes).toHaveBeenCalledWith({ kind: 'selection', target: input });
+ expect(select).not.toHaveBeenCalled();
+ await vi.runAllTimersAsync();
+ expect(select).toHaveBeenCalledTimes(2);
+ expect(selectionchange).toHaveBeenCalledOnce();
+ input.remove();
+ changes.mockClear();
+ input.setSelectionRange(2, 3);
+ expect(changes).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/dom/test/events.test.ts b/packages/dom/test/events.test.ts
new file mode 100644
index 0000000..bb1eab7
--- /dev/null
+++ b/packages/dom/test/events.test.ts
@@ -0,0 +1,434 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ CustomEvent,
+ Event,
+ EventTarget,
+ FocusEvent,
+ KeyboardEvent,
+ type EventListenerObject,
+} from '../src/events.ts';
+
+class TreeTarget extends EventTarget {
+ parent: TreeTarget | null = null;
+
+ override getEventParent(): TreeTarget | null {
+ return this.parent;
+ }
+}
+
+function tree(): { root: TreeTarget; parent: TreeTarget; target: TreeTarget } {
+ const root = new TreeTarget();
+ const parent = new TreeTarget();
+ const target = new TreeTarget();
+ parent.parent = root;
+ target.parent = parent;
+ return { root, parent, target };
+}
+
+describe('event dispatch', () => {
+ it('captures, invokes both target groups, then bubbles with correct state and receiver', () => {
+ const { root, parent, target } = tree();
+ const calls: string[] = [];
+ const event = new Event('Change', { bubbles: true });
+ // oxlint-disable-next-line bombshell-dev/max-params -- Test helper mirrors the three asserted callback fields.
+ const observe = (name: string, expected: EventTarget, phase: number) =>
+ function (this: EventTarget, observed: Event) {
+ calls.push(name);
+ expect(this).toBe(expected);
+ expect(observed).toBe(event);
+ expect(observed.target).toBe(target);
+ expect(observed.currentTarget).toBe(expected);
+ expect(observed.eventPhase).toBe(phase);
+ expect(observed.composedPath()).toEqual([target, parent, root]);
+ };
+ root.addEventListener('Change', observe('root capture', root, Event.CAPTURING_PHASE), true);
+ parent.addEventListener('Change', observe('parent capture', parent, Event.CAPTURING_PHASE), {
+ capture: true,
+ });
+ target.addEventListener('Change', observe('target bubble', target, Event.AT_TARGET));
+ target.addEventListener('Change', observe('target capture', target, Event.AT_TARGET), true);
+ parent.addEventListener('Change', observe('parent bubble', parent, Event.BUBBLING_PHASE));
+ root.addEventListener('Change', observe('root bubble', root, Event.BUBBLING_PHASE));
+ expect(target.dispatchEvent(event)).toBe(true);
+ expect(calls).toEqual([
+ 'root capture',
+ 'parent capture',
+ 'target capture',
+ 'target bubble',
+ 'parent bubble',
+ 'root bubble',
+ ]);
+ expect(event.target).toBe(target);
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ expect(event.composedPath()).toEqual([]);
+ });
+
+ it('delivers non-bubbling events to ancestor capture and both target groups', () => {
+ const { parent, target } = tree();
+ const calls: string[] = [];
+ parent.addEventListener('x', () => calls.push('capture'), true);
+ parent.addEventListener('x', () => calls.push('bubble'));
+ target.addEventListener('x', () => calls.push('target capture'), true);
+ target.addEventListener('x', () => calls.push('target bubble'));
+ target.dispatchEvent(new Event('x'));
+ expect(calls).toEqual(['capture', 'target capture', 'target bubble']);
+ });
+
+ it.each(['root', 'parent', 'target'] as const)(
+ 'checks stopPropagation after %s capture',
+ (stopAt) => {
+ const nodes = tree();
+ const calls: string[] = [];
+ for (const name of ['root', 'parent', 'target'] as const) {
+ nodes[name].addEventListener(
+ 'x',
+ (event) => {
+ calls.push(name);
+ if (name === stopAt) event.stopPropagation();
+ },
+ true,
+ );
+ nodes[name].addEventListener('x', () => calls.push(`${name} second`), true);
+ nodes[name].addEventListener('x', () => calls.push(`${name} bubble`));
+ }
+ nodes.target.dispatchEvent(new Event('x', { bubbles: true }));
+ const names = ['root', 'parent', 'target'];
+ expect(calls).toEqual(
+ names.slice(0, names.indexOf(stopAt) + 1).flatMap((name) => [name, `${name} second`]),
+ );
+ },
+ );
+
+ it.each(['target', 'parent'] as const)(
+ 'finishes the current %s bubble invocation when stopped',
+ (stopAt) => {
+ const nodes = tree();
+ const calls: string[] = [];
+ for (const name of ['target', 'parent', 'root'] as const) {
+ nodes[name].addEventListener('x', (event) => {
+ calls.push(name);
+ if (name === stopAt) event.stopPropagation();
+ });
+ nodes[name].addEventListener('x', () => calls.push(`${name} second`));
+ }
+ nodes.target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(calls).toEqual(
+ stopAt === 'target'
+ ? ['target', 'target second']
+ : ['target', 'target second', 'parent', 'parent second'],
+ );
+ },
+ );
+
+ it('stops immediate delivery and clears both stop flags before later reuse', () => {
+ const { parent, target } = tree();
+ const later = vi.fn();
+ const stop = (event: Event): void => event.stopImmediatePropagation();
+ target.addEventListener('x', stop);
+ target.addEventListener('x', later);
+ parent.addEventListener('x', later);
+ const event = new Event('x', { bubbles: true });
+ target.dispatchEvent(event);
+ expect(later).not.toHaveBeenCalled();
+ expect(event.cancelBubble).toBe(false);
+ target.removeEventListener('x', stop);
+ target.dispatchEvent(event);
+ expect(later).toHaveBeenCalledTimes(2);
+ });
+
+ it('honors a stop flag set before dispatch then clears it', () => {
+ const target = new EventTarget();
+ const listener = vi.fn();
+ const event = new Event('x');
+ target.addEventListener('x', listener);
+ event.stopPropagation();
+ target.dispatchEvent(event);
+ expect(listener).not.toHaveBeenCalled();
+ target.dispatchEvent(event);
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('does not let cancelBubble=false undo a propagation stop', () => {
+ const { parent, target } = tree();
+ const listener = vi.fn();
+ target.addEventListener('x', (event) => {
+ event.cancelBubble = true;
+ event.cancelBubble = false;
+ });
+ parent.addEventListener('x', listener);
+ target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('cancels only cancelable events and preserves cancellation on reuse', () => {
+ const target = new EventTarget();
+ const listener = (event: Event): void => event.preventDefault();
+ target.addEventListener('x', listener);
+ expect(target.dispatchEvent(new Event('x'))).toBe(true);
+ const event = new Event('x', { cancelable: true });
+ expect(target.dispatchEvent(event)).toBe(false);
+ expect(event.defaultPrevented).toBe(true);
+ expect(event.returnValue).toBe(false);
+ event.returnValue = true;
+ target.removeEventListener('x', listener);
+ expect(target.dispatchEvent(event)).toBe(false);
+ });
+
+ it('keeps its entire path stable after listeners reparent the target', () => {
+ const { root, parent, target } = tree();
+ const replacement = new TreeTarget();
+ const calls: string[] = [];
+ root.addEventListener(
+ 'x',
+ () => {
+ target.parent = replacement;
+ },
+ true,
+ );
+ parent.addEventListener('x', () => calls.push('original'));
+ replacement.addEventListener('x', () => calls.push('replacement'));
+ target.addEventListener(
+ 'x',
+ (event) => expect(event.composedPath()).toEqual([target, parent, root]),
+ { once: true },
+ );
+ target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(calls).toEqual(['original']);
+ target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(calls).toEqual(['original', 'replacement']);
+ });
+
+ it('allows nested events but rejects active-event redispatch on every target', () => {
+ const target = new EventTarget();
+ const other = new EventTarget();
+ const outer = new Event('outer');
+ const inner = new Event('inner');
+ const calls: string[] = [];
+ target.addEventListener('outer', (event) => {
+ expect(() => target.dispatchEvent(event)).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ expect(() => other.dispatchEvent(event)).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ other.dispatchEvent(inner);
+ expect(outer.currentTarget).toBe(target);
+ expect(inner.currentTarget).toBeNull();
+ calls.push('outer');
+ });
+ other.addEventListener('inner', () => calls.push('inner'));
+ target.dispatchEvent(outer);
+ target.dispatchEvent(outer);
+ expect(calls).toEqual(['inner', 'outer', 'inner', 'outer']);
+ });
+
+ it('reports listener exceptions, continues invocation, and cleans transient state', () => {
+ const errors = vi.fn();
+ const target = new EventTarget({ reportError: errors });
+ const failure = new Error('listener failed');
+ const later = vi.fn();
+ target.addEventListener('x', () => {
+ throw failure;
+ });
+ target.addEventListener('x', later);
+ const event = new Event('x');
+ expect(target.dispatchEvent(event)).toBe(true);
+ expect(errors).toHaveBeenCalledWith(failure);
+ expect(later).toHaveBeenCalledOnce();
+ expect(event.currentTarget).toBeNull();
+ expect(event.eventPhase).toBe(Event.NONE);
+ });
+});
+
+describe('event listeners', () => {
+ it('deduplicates by type, callback and capture; removal is capture-specific', () => {
+ const target = new EventTarget();
+ const listener = vi.fn();
+ target.addEventListener('Change', listener);
+ target.addEventListener('Change', listener, { once: true });
+ target.addEventListener('Change', listener, true);
+ target.removeEventListener('Change', listener, { capture: true });
+ target.dispatchEvent(new Event('Change'));
+ target.dispatchEvent(new Event('Change'));
+ target.dispatchEvent(new Event('change'));
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it('removal takes effect immediately and remove/re-add cannot revive an old registration', () => {
+ const target = new EventTarget();
+ const calls: string[] = [];
+ const later = (): number => calls.push('later');
+ const first = (): void => {
+ calls.push('first');
+ target.removeEventListener('x', later);
+ target.addEventListener('x', later);
+ };
+ target.addEventListener('x', first, { once: true });
+ target.addEventListener('x', later);
+ target.dispatchEvent(new Event('x'));
+ expect(calls).toEqual(['first']);
+ target.dispatchEvent(new Event('x'));
+ expect(calls).toEqual(['first', 'later']);
+ });
+
+ it('takes a fresh listener snapshot for each phase and each ancestor invocation', () => {
+ const { parent, target } = tree();
+ const calls: string[] = [];
+ const sameInvocation = (): number => calls.push('same invocation');
+ target.addEventListener(
+ 'x',
+ () => {
+ calls.push('target capture');
+ target.addEventListener('x', () => {
+ calls.push('target bubble');
+ target.addEventListener('x', sameInvocation);
+ parent.addEventListener('x', () => calls.push('parent bubble'));
+ });
+ },
+ true,
+ );
+ target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(calls).toEqual(['target capture', 'target bubble', 'parent bubble']);
+ });
+
+ it('removes once listeners before invoking them, including nested dispatch', () => {
+ const target = new EventTarget();
+ const listener = vi.fn(() => target.dispatchEvent(new Event('x')));
+ target.addEventListener('x', listener, { once: true });
+ target.dispatchEvent(new Event('x'));
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('passive listeners cannot cancel and the flag is reset before the next listener', () => {
+ const target = new EventTarget();
+ const prevented: boolean[] = [];
+ target.addEventListener(
+ 'x',
+ (event) => {
+ event.preventDefault();
+ event.returnValue = false;
+ prevented.push(event.defaultPrevented);
+ },
+ { passive: true },
+ );
+ target.addEventListener('x', (event) => {
+ event.preventDefault();
+ prevented.push(event.defaultPrevented);
+ });
+ expect(target.dispatchEvent(new Event('x', { cancelable: true }))).toBe(false);
+ expect(prevented).toEqual([false, true]);
+ });
+
+ it('supports abort before registration and during listener delivery', () => {
+ const target = new EventTarget();
+ const alreadyAborted = new AbortController();
+ alreadyAborted.abort();
+ const pending = new AbortController();
+ const listener = vi.fn();
+ target.addEventListener('x', listener, { signal: alreadyAborted.signal });
+ target.addEventListener('x', () => pending.abort());
+ target.addEventListener('x', listener, { signal: pending.signal });
+ target.dispatchEvent(new Event('x'));
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('an abort on a duplicate registration does not remove the existing listener', () => {
+ const target = new EventTarget();
+ const controller = new AbortController();
+ const listener = vi.fn();
+ target.addEventListener('x', listener);
+ target.addEventListener('x', listener, { signal: controller.signal });
+ controller.abort();
+ target.dispatchEvent(new Event('x'));
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('abort removal cannot be canceled by stopping the source signal abort event', () => {
+ const target = new EventTarget();
+ const controller = new AbortController();
+ controller.signal.addEventListener('abort', (event) => event.stopImmediatePropagation());
+ const listener = vi.fn();
+ target.addEventListener('x', listener, { signal: controller.signal });
+ controller.abort();
+ target.dispatchEvent(new Event('x'));
+ expect(listener).not.toHaveBeenCalled();
+ target.addEventListener('x', listener);
+ target.dispatchEvent(new Event('x'));
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('removes signal listeners before a source abort listener dispatches or re-registers', () => {
+ const target = new EventTarget();
+ const controller = new AbortController();
+ const listener = vi.fn();
+ controller.signal.addEventListener('abort', () => {
+ target.dispatchEvent(new Event('x'));
+ expect(listener).not.toHaveBeenCalled();
+ target.addEventListener('x', listener);
+ });
+ target.addEventListener('x', listener, { signal: controller.signal });
+ controller.abort();
+ target.dispatchEvent(new Event('x'));
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('invokes listener objects with the object receiver and reads handleEvent at invocation', () => {
+ const target = new EventTarget();
+ let receiver: unknown;
+ const old = vi.fn();
+ const object: EventListenerObject = { handleEvent: old };
+ target.addEventListener('x', object);
+ object.handleEvent = function () {
+ // oxlint-disable-next-line typescript/no-this-alias -- Capture the actual callback receiver for this assertion.
+ receiver = this;
+ };
+ target.dispatchEvent(new Event('x'));
+ expect(receiver).toBe(object);
+ expect(old).not.toHaveBeenCalled();
+ target.removeEventListener('x', object);
+ receiver = null;
+ target.dispatchEvent(new Event('x'));
+ expect(receiver).toBeNull();
+ });
+
+ it('ignores null callbacks', () => {
+ const target = new EventTarget();
+ target.addEventListener('x', null);
+ target.removeEventListener('x', null);
+ expect(target.dispatchEvent(new Event('x'))).toBe(true);
+ });
+});
+
+describe('event data', () => {
+ it('preserves event-name case and permits framework bookkeeping', () => {
+ const event = Object.assign(new Event('ValueChange'), { _dispatched: 42 });
+ expect(event.type).toBe('ValueChange');
+ expect(event._dispatched).toBe(42);
+ expect(event.isTrusted).toBe(false);
+ expect(event.timeStamp).toBeGreaterThanOrEqual(0);
+ });
+
+ it('retains typed custom detail and initializes absent detail to null', () => {
+ const detail = { value: 1 };
+ expect(new CustomEvent('x', { detail }).detail).toBe(detail);
+ expect(new CustomEvent('x').detail).toBeNull();
+ });
+
+ it('retains focus relatedTarget and exposes initialized keyboard data', () => {
+ const target = new EventTarget();
+ expect(new FocusEvent('focus', { relatedTarget: target }).relatedTarget).toBe(target);
+ const event = new KeyboardEvent('keydown', {
+ key: 'A',
+ code: 'KeyA',
+ shiftKey: true,
+ repeat: true,
+ });
+ expect(event.key).toBe('A');
+ expect(event.code).toBe('KeyA');
+ expect(event.repeat).toBe(true);
+ expect(event.getModifierState('Shift')).toBe(true);
+ expect(event.getModifierState('Control')).toBe(false);
+ expect(event.getModifierState('unknown')).toBe(false);
+ });
+});
diff --git a/packages/dom/test/focus.test.ts b/packages/dom/test/focus.test.ts
new file mode 100644
index 0000000..039277c
--- /dev/null
+++ b/packages/dom/test/focus.test.ts
@@ -0,0 +1,363 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Document, type Element, type HTMLInputElement } from '../src/dom.ts';
+import { Event, FocusEvent, KeyboardEvent } from '../src/events.ts';
+import { Dispatch } from '../src/focus.ts';
+
+function fixture(): {
+ document: Document;
+ root: Element;
+ first: Element;
+ second: HTMLInputElement;
+ changes: ReturnType;
+} {
+ const changes = vi.fn();
+ const document = new Document({ onInvalidate: changes });
+ const root = document.createElement('box');
+ const first = document.createElement('button');
+ const second = document.createElement('input');
+ document.appendChild(root);
+ root.append(first, second);
+ changes.mockClear();
+ return { document, root, first, second, changes };
+}
+
+describe('focus', () => {
+ it('moves focus with ordered events, related targets, and rendering invalidation', () => {
+ const { document, root, first, second, changes } = fixture();
+ const calls: string[] = [];
+ first.focus();
+ for (const name of ['blur', 'focusout', 'focus', 'focusin']) {
+ root.addEventListener(
+ name,
+ (event) => {
+ expect(event).toBeInstanceOf(FocusEvent);
+ if (!(event instanceof FocusEvent)) return;
+ calls.push(`${event.target === first ? 'first' : 'second'} ${name}`);
+ expect(event.relatedTarget).toBe(event.target === first ? second : first);
+ },
+ true,
+ );
+ }
+ calls.length = 0;
+ changes.mockClear();
+ second.focus();
+ expect(document.activeElement).toBe(second);
+ expect(calls).toEqual(['first blur', 'first focusout', 'second focus', 'second focusin']);
+ expect(changes).toHaveBeenCalledWith({ kind: 'focus', target: first });
+ expect(changes).toHaveBeenCalledWith({ kind: 'focus', target: second });
+ });
+
+ it('does nothing on repeated focus or blur of an unfocused element', () => {
+ const { first, second, changes } = fixture();
+ first.focus();
+ const focus = vi.fn();
+ first.addEventListener('focus', focus);
+ changes.mockClear();
+ first.focus();
+ second.blur();
+ expect(focus).not.toHaveBeenCalled();
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it('distinguishes dispatching a focus event from changing focus', () => {
+ const { document, root, first, changes } = fixture();
+ first.dispatchEvent(new FocusEvent('focus'));
+ expect(document.activeElement).toBe(root);
+ expect(changes).not.toHaveBeenCalled();
+ });
+
+ it('rejects detached, disabled, hidden, inert, and ineligible nodes', () => {
+ const { document, root, first, second } = fixture();
+ const detached = document.createElement('button');
+ detached.focus();
+ root.focus();
+ first.setAttribute('disabled', '');
+ first.focus();
+ second.setAttribute('hidden', '');
+ second.focus();
+ expect(document.activeElement).toBe(root);
+ second.removeAttribute('hidden');
+ root.setAttribute('inert', '');
+ second.focus();
+ expect(document.activeElement).toBe(root);
+ });
+
+ it('permits tabindex=-1 programmatic focus but excludes it from navigation', () => {
+ const { document, root, first, second } = fixture();
+ const custom = document.createElement('text');
+ custom.tabIndex = -1;
+ root.append(custom);
+ custom.focus();
+ expect(document.activeElement).toBe(custom);
+ expect(document.focusManager.focusableElements()).toEqual([first, second]);
+ });
+
+ it('orders positive tabindex first with stable tree order then zero defaults', () => {
+ const { document, root, first, second } = fixture();
+ const a = document.createElement('box');
+ const b = document.createElement('box');
+ const c = document.createElement('box');
+ a.tabIndex = 2;
+ b.tabIndex = c.tabIndex = 1;
+ root.append(a, b, c);
+ expect(document.focusManager.focusableElements()).toEqual([b, c, a, first, second]);
+ });
+
+ it('orders tabindex within shadow and slot scopes before flattening them', () => {
+ const { document, root, first, second } = fixture();
+ first.tabIndex = 3;
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const beforeSlot = document.createElement('input');
+ const slot = document.createElement('slot');
+ const assigned = document.createElement('input');
+ assigned.tabIndex = 1;
+ shadow.append(beforeSlot, slot);
+ host.append(assigned);
+ root.append(host);
+ expect(document.focusManager.focusableElements()).toEqual([
+ first,
+ second,
+ beforeSlot,
+ assigned,
+ ]);
+ host.tabIndex = 2;
+ expect(document.focusManager.focusableElements()).toEqual([
+ host,
+ beforeSlot,
+ assigned,
+ first,
+ second,
+ ]);
+ });
+
+ it('omits negative-tabindex shadow/slot scopes from sequential focus', () => {
+ const { document, root, first, second } = fixture();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const slot = document.createElement('slot');
+ const assigned = document.createElement('input');
+ shadow.append(slot);
+ host.append(assigned);
+ root.append(host);
+ slot.tabIndex = -1;
+ expect(document.focusManager.focusableElements()).toEqual([first, second]);
+ slot.removeAttribute('tabindex');
+ host.tabIndex = -1;
+ expect(document.focusManager.focusableElements()).toEqual([first, second]);
+ assigned.focus();
+ expect(document.activeElement).toBe(assigned);
+ });
+
+ it('continues in tree order from a programmatically focused negative-tabindex element', () => {
+ const { document, root, first, second } = fixture();
+ const programmatic = document.createElement('box');
+ programmatic.tabIndex = -1;
+ root.insertBefore(programmatic, second);
+ programmatic.focus();
+ document.focusManager.moveFocus();
+ expect(document.activeElement).toBe(second);
+ programmatic.focus();
+ document.focusManager.moveFocus(true);
+ expect(document.activeElement).toBe(first);
+ });
+
+ it('reflects tabindex independently of focus eligibility', () => {
+ const { document, root } = fixture();
+ const anchor = document.createElement('a');
+ const custom = document.createElement('box');
+ root.append(anchor, custom);
+ expect(anchor.tabIndex).toBe(0);
+ anchor.focus();
+ expect(document.activeElement).toBe(root);
+ custom.setAttribute('tabindex', '2147483648');
+ expect(custom.tabIndex).toBe(-1);
+ custom.focus();
+ expect(document.activeElement).toBe(custom);
+ });
+
+ it('silently clears focus when a focused subtree is removed', () => {
+ const { document, root, first, changes } = fixture();
+ first.focus();
+ const blur = vi.fn();
+ first.addEventListener('blur', blur);
+ changes.mockClear();
+ first.remove();
+ expect(document.activeElement).toBe(root);
+ expect(document.focusManager.activeElement).toBeNull();
+ expect(blur).not.toHaveBeenCalled();
+ expect(changes).toHaveBeenCalledWith({ kind: 'focus', target: first });
+ root.append(first);
+ expect(document.activeElement).toBe(root);
+ });
+
+ it('allows a nested focus transition to supersede an outer transition', () => {
+ const { document, root, first, second } = fixture();
+ const third = document.createElement('button');
+ root.append(third);
+ first.focus();
+ first.addEventListener('blur', () => third.focus());
+ const stale = vi.fn();
+ second.addEventListener('focus', stale);
+ second.focus();
+ expect(document.activeElement).toBe(third);
+ expect(stale).not.toHaveBeenCalled();
+ });
+
+ it('handles disabled fieldsets with the first-legend exception', () => {
+ const { document, root } = fixture();
+ const fieldset = document.createElement('fieldset');
+ const legend = document.createElement('legend');
+ const allowed = document.createElement('button');
+ const blocked = document.createElement('button');
+ fieldset.setAttribute('disabled', '');
+ legend.append(allowed);
+ fieldset.append(legend, blocked);
+ root.append(fieldset);
+ allowed.focus();
+ expect(document.activeElement).toBe(allowed);
+ blocked.focus();
+ expect(document.activeElement).toBe(allowed);
+ });
+
+ it('keeps focus independent across documents', () => {
+ const first = fixture();
+ const second = fixture();
+ first.first.focus();
+ second.second.focus();
+ expect(first.document.activeElement).toBe(first.first);
+ expect(second.document.activeElement).toBe(second.second);
+ });
+
+ it('retargets shadow focus to hosts and supports delegated focus', () => {
+ const { document, root } = fixture();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'closed', delegatesFocus: true });
+ const input = document.createElement('input');
+ shadow.append(input);
+ root.append(host);
+ host.focus();
+ expect(document.activeElement).toBe(host);
+ expect(shadow.activeElement).toBe(input);
+ expect(document.focusManager.activeElement).toBe(input);
+ host.remove();
+ expect(shadow.activeElement).toBeNull();
+ });
+
+ it('preserves an existing delegate and forwards host.blur to its focused shadow descendant', () => {
+ const { document, root } = fixture();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open', delegatesFocus: true });
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ shadow.append(first, second);
+ root.append(host);
+ second.focus();
+ const focus = vi.fn();
+ second.addEventListener('focus', focus);
+ host.focus();
+ expect(shadow.activeElement).toBe(second);
+ expect(focus).not.toHaveBeenCalled();
+ host.blur();
+ expect(document.focusManager.activeElement).toBeNull();
+ });
+
+ it('delegates through delegating roots and honors autofocus without crossing other roots', () => {
+ const { document, root } = fixture();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open', delegatesFocus: true });
+ const nestedHost = document.createElement('box');
+ const nested = nestedHost.attachShadow({ mode: 'open' });
+ const inaccessible = document.createElement('input');
+ nested.append(inaccessible);
+ const first = document.createElement('input');
+ const preferred = document.createElement('input');
+ preferred.setAttribute('autofocus', '');
+ shadow.append(nestedHost, first, preferred);
+ root.append(host);
+ host.focus();
+ expect(document.focusManager.activeElement).toBe(preferred);
+ preferred.removeAttribute('autofocus');
+ host.blur();
+ host.focus();
+ expect(document.focusManager.activeElement).toBe(first);
+ });
+
+ it('navigates assigned slot content in composition order and skips unassigned light children', () => {
+ const { document, root, first, second } = fixture();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'closed' });
+ const slot = document.createElement('slot');
+ slot.name = 'controls';
+ const inner = document.createElement('input');
+ const assigned = document.createElement('input');
+ const unassigned = document.createElement('input');
+ assigned.slot = 'controls';
+ shadow.append(inner, slot);
+ host.append(assigned, unassigned);
+ root.append(host);
+ expect(document.focusManager.focusableElements()).toEqual([first, second, inner, assigned]);
+ assigned.focus();
+ expect(document.activeElement).toBe(assigned);
+ unassigned.focus();
+ expect(document.activeElement).toBe(assigned);
+ host.remove();
+ expect(document.focusManager.activeElement).toBeNull();
+ });
+});
+
+describe('host input dispatch', () => {
+ it('routes keydown/keyup to focus and Tab navigation only through Dispatch', () => {
+ const { document, first, second } = fixture();
+ const dispatch = new Dispatch(document);
+ const calls: string[] = [];
+ first.addEventListener('keydown', (event) => {
+ expect(event).toBeInstanceOf(KeyboardEvent);
+ expect(event.isTrusted).toBe(false);
+ calls.push('first down');
+ });
+ second.addEventListener('keyup', () => calls.push('second up'));
+ first.focus();
+ first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
+ expect(document.activeElement).toBe(first);
+ expect(dispatch.keydown({ key: 'Tab' })).toBe(true);
+ expect(document.activeElement).toBe(second);
+ expect(dispatch.keyup({ key: 'Tab' })).toBe(true);
+ expect(calls).toEqual(['first down', 'first down', 'second up']);
+ dispatch.keydown({ key: 'Tab', shiftKey: true });
+ expect(document.activeElement).toBe(first);
+ });
+
+ it('respects canceled Tab and leaves other key default actions to consumers', () => {
+ const { document, first } = fixture();
+ const dispatch = new Dispatch(document);
+ first.focus();
+ first.addEventListener('keydown', (event) => event.preventDefault(), { once: true });
+ expect(dispatch.keydown({ key: 'Tab' })).toBe(false);
+ expect(document.activeElement).toBe(first);
+ dispatch.keydown({ key: 'Tab', ctrlKey: true });
+ dispatch.keydown({ key: 'Enter' });
+ expect(document.activeElement).toBe(first);
+ });
+
+ it('wraps logical navigation and chooses a candidate when nothing is focused', () => {
+ const { document, first, second } = fixture();
+ const dispatch = new Dispatch(document);
+ dispatch.keydown({ key: 'Tab', shiftKey: true });
+ expect(document.activeElement).toBe(second);
+ dispatch.keydown({ key: 'Tab' });
+ expect(document.activeElement).toBe(first);
+ first.blur();
+ dispatch.keydown({ key: 'Tab' });
+ expect(document.activeElement).toBe(first);
+ });
+
+ it('does not invalidate rendering merely by dispatching a keyboard event', () => {
+ const { document, first, changes } = fixture();
+ first.focus();
+ changes.mockClear();
+ new Dispatch(document).keydown({ key: 'ArrowRight' });
+ first.dispatchEvent(new Event('arbitrary'));
+ expect(changes).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/dom/test/observation.test.ts b/packages/dom/test/observation.test.ts
new file mode 100644
index 0000000..55d1ef1
--- /dev/null
+++ b/packages/dom/test/observation.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Document, MutationObserver, type MutationRecord } from '../src/index.ts';
+
+describe('MutationObserver', () => {
+ it('rejects a non-node observation target at runtime', () => {
+ const observer = new MutationObserver(() => {});
+ expect(() => Reflect.apply(observer.observe, observer, [{}, { childList: true }])).toThrow(
+ TypeError,
+ );
+ });
+
+ it('reports callback errors through the document host without aborting other deliveries', async () => {
+ const reportError = vi.fn();
+ const document = new Document({ reportError });
+ const element = document.createElement('box');
+ const failure = new TypeError('observer failure');
+ const failing = new MutationObserver(() => {
+ throw failure;
+ });
+ const following = vi.fn();
+ const successful = new MutationObserver(following);
+ failing.observe(element, { attributes: true });
+ successful.observe(element, { attributes: true });
+ element.setAttribute('id', 'updated');
+ await Promise.resolve();
+ expect(reportError).toHaveBeenCalledWith(failure);
+ expect(following).toHaveBeenCalledOnce();
+ failing.disconnect();
+ successful.disconnect();
+ });
+
+ it('observes explicitly targeted detached trees without invalidating the host', async () => {
+ const invalidate = vi.fn();
+ const document = new Document({ onInvalidate: invalidate });
+ const container = document.createElement('box');
+ const child = document.createTextNode('before');
+ container.appendChild(child);
+ const callback = vi.fn();
+ const observer = new MutationObserver(callback);
+ observer.observe(container, {
+ subtree: true,
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ });
+ child.data = 'after';
+ container.appendChild(document.createComment('comment'));
+ expect(callback).not.toHaveBeenCalled();
+ expect(invalidate).not.toHaveBeenCalled();
+ await Promise.resolve();
+ expect(callback).toHaveBeenCalledOnce();
+ expect(callback.mock.calls[0]?.[0]).toMatchObject([
+ { type: 'characterData', target: child, oldValue: 'before' },
+ { type: 'childList', target: container, previousSibling: child, nextSibling: null },
+ ]);
+ observer.disconnect();
+ });
+
+ it('preserves child-list snapshots and sibling positions', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const first = document.createElement('first');
+ const second = document.createElement('second');
+ const inserted = document.createElement('inserted');
+ parent.append(first, second);
+ const observer = new MutationObserver(() => {});
+ observer.observe(parent, { childList: true });
+ parent.insertBefore(inserted, second);
+ inserted.remove();
+ const records = observer.takeRecords();
+ expect(records[0]).toMatchObject({
+ target: parent,
+ addedNodes: [inserted],
+ removedNodes: [],
+ previousSibling: first,
+ nextSibling: second,
+ });
+ expect(records[1]).toMatchObject({
+ target: parent,
+ addedNodes: [],
+ removedNodes: [inserted],
+ previousSibling: first,
+ nextSibling: second,
+ });
+ expect(Object.isFrozen(records[0]?.addedNodes)).toBe(true);
+ observer.disconnect();
+ });
+
+ it('filters attribute names and namespaces, preserves old values, and copies options', () => {
+ const document = new Document();
+ const element = document.createElement('box');
+ element.setAttribute('value', 'initial');
+ const names = ['value'];
+ const observer = new MutationObserver(() => {});
+ observer.observe(element, { attributeFilter: names, attributeOldValue: true });
+ names.push('ignored');
+ element.setAttribute('value', 'next');
+ element.setAttribute('ignored', 'no');
+ element.setAttributeNS('urn:test', 'test:value', 'namespaced');
+ element.removeAttribute('value');
+ expect(observer.takeRecords()).toMatchObject([
+ {
+ type: 'attributes',
+ target: element,
+ attributeName: 'value',
+ attributeNamespace: null,
+ oldValue: 'initial',
+ },
+ { type: 'attributes', attributeName: 'value', oldValue: 'next' },
+ ]);
+ observer.disconnect();
+ });
+
+ it('defaults attributes/characterData from old-value options, including false', () => {
+ const document = new Document();
+ const element = document.createElement('box');
+ const text = document.createTextNode('before');
+ const observer = new MutationObserver(() => {});
+ observer.observe(element, { attributeOldValue: false });
+ observer.observe(text, { characterDataOldValue: false });
+ element.setAttribute('id', 'a');
+ text.data = 'after';
+ expect(observer.takeRecords().map((record) => [record.type, record.oldValue])).toEqual([
+ ['attributes', null],
+ ['characterData', null],
+ ]);
+ observer.disconnect();
+ });
+
+ it.each([
+ {},
+ { attributes: false, attributeOldValue: true },
+ { attributes: false, attributeFilter: [], childList: true },
+ { characterData: false, characterDataOldValue: true },
+ ])('rejects contradictory or empty options %j', (options) => {
+ const observer = new MutationObserver(() => {});
+ expect(() => observer.observe(new Document(), options)).toThrow(TypeError);
+ });
+
+ it('deduplicates overlapping registrations and includes oldValue requested by either', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = document.createElement('text');
+ parent.append(child);
+ child.setAttribute('id', 'old');
+ const observer = new MutationObserver(() => {});
+ observer.observe(parent, { attributes: true, subtree: true });
+ observer.observe(child, { attributes: true, attributeOldValue: true });
+ child.setAttribute('id', 'new');
+ expect(observer.takeRecords()).toMatchObject([{ oldValue: 'old' }]);
+ observer.disconnect();
+ });
+
+ it('updates a registration and disconnect discards queued records', async () => {
+ const document = new Document();
+ const element = document.createElement('box');
+ const callback = vi.fn();
+ const observer = new MutationObserver(callback);
+ observer.observe(element, { attributes: true });
+ observer.observe(element, { childList: true });
+ element.setAttribute('id', 'a');
+ expect(observer.takeRecords()).toEqual([]);
+ element.append('child');
+ observer.disconnect();
+ await Promise.resolve();
+ expect(callback).not.toHaveBeenCalled();
+ element.append('another');
+ expect(observer.takeRecords()).toEqual([]);
+ });
+
+ it('retains removed subtree observation through delivery, then stops', async () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = document.createElement('text');
+ parent.append(child);
+ const deliveries: MutationRecord[][] = [];
+ const observer = new MutationObserver((records) => deliveries.push(records));
+ observer.observe(parent, { childList: true, subtree: true, attributes: true });
+ child.remove();
+ child.setAttribute('id', 'observed');
+ await Promise.resolve();
+ expect(deliveries[0]?.map((record) => record.type)).toEqual(['childList', 'attributes']);
+ child.setAttribute('id', 'unobserved');
+ await Promise.resolve();
+ expect(deliveries).toHaveLength(1);
+ observer.disconnect();
+ });
+
+ it('clears transient registrations even if childList was not observed', async () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = document.createElement('text');
+ parent.append(child);
+ const callback = vi.fn();
+ const observer = new MutationObserver(callback);
+ observer.observe(parent, { subtree: true, attributes: true });
+ child.remove();
+ await Promise.resolve();
+ child.setAttribute('id', 'unobserved');
+ await Promise.resolve();
+ expect(callback).not.toHaveBeenCalled();
+ observer.disconnect();
+ });
+
+ it('takeRecords drains records without clearing transient observations', async () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = document.createElement('text');
+ parent.append(child);
+ const callback = vi.fn();
+ const observer = new MutationObserver(callback);
+ observer.observe(parent, { subtree: true, childList: true, attributes: true });
+ child.remove();
+ expect(observer.takeRecords()).toHaveLength(1);
+ child.setAttribute('id', 'retained');
+ await Promise.resolve();
+ expect(callback.mock.calls[0]?.[0]).toMatchObject([{ type: 'attributes' }]);
+ observer.disconnect();
+ });
+
+ it('uses per-observer delivery snapshots under reentrant mutation', async () => {
+ const document = new Document();
+ const element = document.createElement('box');
+ const delivered: string[] = [];
+ const first = new MutationObserver(function (records, observer) {
+ expect(this).toBe(observer);
+ delivered.push(`first:${records.map((record) => record.attributeName).join(',')}`);
+ if (records[0]?.attributeName === 'a') element.setAttribute('b', '2');
+ });
+ const second = new MutationObserver((records) =>
+ delivered.push(`second:${records.map((record) => record.attributeName).join(',')}`),
+ );
+ first.observe(element, { attributes: true });
+ second.observe(element, { attributes: true });
+ element.setAttribute('a', '1');
+ await Promise.resolve();
+ expect(delivered).toEqual(['first:a', 'second:a,b']);
+ await Promise.resolve();
+ expect(delivered).toEqual(['first:a', 'second:a,b', 'first:b']);
+ first.disconnect();
+ second.disconnect();
+ });
+});
diff --git a/packages/dom/test/selection.test.ts b/packages/dom/test/selection.test.ts
new file mode 100644
index 0000000..8c3af62
--- /dev/null
+++ b/packages/dom/test/selection.test.ts
@@ -0,0 +1,356 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ Document,
+ MutationObserver,
+ Range,
+ type Element,
+ type Node,
+ type Text,
+} from '../src/index.ts';
+
+function fixture(): {
+ document: Document;
+ root: Element;
+ left: Element;
+ middle: Element;
+ right: Element;
+ first: Text;
+ last: Text;
+} {
+ const document = new Document();
+ const root = document.createElement('box');
+ const left = document.createElement('left');
+ const middle = document.createElement('middle');
+ const right = document.createElement('right');
+ const first = document.createTextNode('A😀B');
+ const last = document.createTextNode('last');
+ left.append(first);
+ middle.append('middle');
+ right.append(last);
+ root.append(left, middle, right);
+ document.append(root);
+ return { document, root, left, middle, right, first, last };
+}
+
+function endpoints(range: Range): readonly [Node, number, Node, number] {
+ return [range.startContainer, range.startOffset, range.endContainer, range.endOffset];
+}
+
+describe('live Range', () => {
+ it('starts collapsed at the document and uses UTF-16 offsets', () => {
+ const { document, first } = fixture();
+ const range = document.createRange();
+ expect(endpoints(range)).toEqual([document, 0, document, 0]);
+ range.setStart(first, 1);
+ range.setEnd(first, 3);
+ expect(range.toString()).toBe('😀');
+ expect(range.collapsed).toBe(false);
+ expect(range.commonAncestorContainer).toBe(first);
+ });
+
+ it('counts child boundaries, excludes comment text, and traverses descendants', () => {
+ const { document, root, first, last } = fixture();
+ root.insertBefore(document.createComment('ignored'), root.firstChild);
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ expect(endpoints(range)).toEqual([root, 0, root, 4]);
+ expect(range.toString()).toBe('A😀Bmiddlelast');
+ range.setStart(first, 3);
+ range.setEnd(last, 2);
+ expect(range.toString()).toBe('Bmiddlela');
+ expect(range.commonAncestorContainer).toBe(root);
+ });
+
+ it('collapses across reversed boundary points and different roots', () => {
+ const { document, root, first, last } = fixture();
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ range.setStart(last, 2);
+ range.setEnd(first, 1);
+ expect(endpoints(range)).toEqual([first, 1, first, 1]);
+ const detached = document.createTextNode('detached');
+ range.setEnd(detached, 3);
+ expect(endpoints(range)).toEqual([detached, 3, detached, 3]);
+ });
+
+ it('validates offsets before modifying the range', () => {
+ const { document, first } = fixture();
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ for (const offset of [-1, 5]) {
+ expect(() => range.setStart(first, offset)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ expect(endpoints(range)).toEqual([first, 0, first, 4]);
+ }
+ expect(() => range.setEndBefore(document)).toThrow(
+ expect.objectContaining({ name: 'InvalidNodeTypeError' }),
+ );
+ });
+
+ it('implements before/after, boundary comparison, and point queries', () => {
+ const { document, root, left, middle, right, first } = fixture();
+ const range = document.createRange();
+ range.setStartAfter(left);
+ range.setEndBefore(right);
+ expect(endpoints(range)).toEqual([root, 1, root, 2]);
+ expect(range.intersectsNode(middle)).toBe(true);
+ expect(range.intersectsNode(left)).toBe(false);
+ expect(range.comparePoint(first, 1)).toBe(-1);
+ expect(range.isPointInRange(root, 1)).toBe(true);
+ const clone = range.cloneRange();
+ expect(clone.compareBoundaryPoints(Range.START_TO_START, range)).toBe(0);
+ expect(clone.compareBoundaryPoints(Range.START_TO_END, range)).toBe(1);
+ expect(clone.compareBoundaryPoints(Range.END_TO_START, range)).toBe(-1);
+ expect(() => clone.compareBoundaryPoints(9, range)).toThrow(
+ expect.objectContaining({ name: 'NotSupportedError' }),
+ );
+ const detached = document.createTextNode('outside');
+ expect(range.isPointInRange(detached, 0)).toBe(false);
+ expect(() => range.comparePoint(detached, 0)).toThrow(
+ expect.objectContaining({ name: 'WrongDocumentError' }),
+ );
+ });
+
+ it('updates child offsets only when insertion is before the boundary', () => {
+ const { document, root, middle } = fixture();
+ const range = document.createRange();
+ range.setStart(root, 1);
+ range.setEnd(root, 3);
+ root.insertBefore(document.createElement('new'), middle);
+ expect(endpoints(range)).toEqual([root, 1, root, 4]);
+ root.append(document.createElement('end'));
+ expect(range.endOffset).toBe(4);
+ });
+
+ it('moves boundaries in removed descendants to the former parent position', () => {
+ const { document, root, left, first, last } = fixture();
+ const range = document.createRange();
+ range.setStart(first, 2);
+ range.setEnd(last, 2);
+ left.remove();
+ expect(endpoints(range)).toEqual([root, 0, last, 2]);
+ root.remove();
+ expect(endpoints(range)).toEqual([document, 0, document, 0]);
+ });
+
+ it('does not transport range boundaries when an existing subtree moves', () => {
+ const { document, root, left, first } = fixture();
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ root.append(left);
+ expect(endpoints(range)).toEqual([root, 0, root, 0]);
+ });
+
+ it.each([
+ [0, 1, 0, 1],
+ [1, 2, 1, 1],
+ [2, 3, 1, 1],
+ [3, 4, 1, 3],
+ ])('updates text replacement boundaries %i..%i', (start, end, expectedStart, expectedEnd) => {
+ const { document, first } = fixture();
+ const range = document.createRange();
+ range.setStart(first, start);
+ range.setEnd(first, end);
+ first.replaceData(1, 2, 'X');
+ expect(endpoints(range)).toEqual([first, expectedStart, first, expectedEnd]);
+ });
+
+ it('migrates ranges during splitText, preserving an offset exactly at the split', () => {
+ const { document, first, left } = fixture();
+ const range = document.createRange();
+ range.setStart(first, 1);
+ range.setEnd(first, 4);
+ const after = document.createRange();
+ after.setStart(left, 1);
+ const split = first.splitText(1);
+ expect(endpoints(range)).toEqual([first, 1, split, 3]);
+ expect(endpoints(after)).toEqual([left, 2, left, 2]);
+ });
+
+ it('clones partial ancestor shells without mutating the original', () => {
+ const { document, root, first, last } = fixture();
+ const range = document.createRange();
+ range.setStart(first, 3);
+ range.setEnd(last, 2);
+ const fragment = range.cloneContents();
+ expect(fragment.textContent).toBe('Bmiddlela');
+ expect(fragment.children.map((child) => child.localName)).toEqual(['left', 'middle', 'right']);
+ expect(root.textContent).toBe('A😀Bmiddlelast');
+ expect(fragment.firstChild).not.toBe(root.firstChild);
+ });
+
+ it('extracts fully contained nodes by identity and clones partial ancestors', () => {
+ const { document, root, left, middle, first, last } = fixture();
+ const range = document.createRange();
+ range.setStart(first, 3);
+ range.setEnd(last, 2);
+ const fragment = range.extractContents();
+ expect(fragment.textContent).toBe('Bmiddlela');
+ expect(fragment.childNodes[1]).toBe(middle);
+ expect(fragment.firstChild).not.toBe(left);
+ expect(root.textContent).toBe('A😀st');
+ expect(endpoints(range)).toEqual([root, 1, root, 1]);
+ });
+
+ it('deletes partial text and contained nodes, retaining boundary ancestors', () => {
+ const { document, root, left, right, first, last } = fixture();
+ const range = document.createRange();
+ range.setStart(first, 3);
+ range.setEnd(last, 2);
+ range.deleteContents();
+ expect(root.childNodes).toEqual([left, right]);
+ expect(root.textContent).toBe('A😀st');
+ expect(range.collapsed).toBe(true);
+ });
+
+ it('clones/extracts comments as character data without stringifying them', () => {
+ const document = new Document();
+ const comment = document.createComment('abcdef');
+ const range = document.createRange();
+ range.setStart(comment, 2);
+ range.setEnd(comment, 4);
+ expect(range.toString()).toBe('');
+ expect(range.extractContents().firstChild?.nodeValue).toBe('cd');
+ expect(comment.data).toBe('abef');
+ expect(endpoints(range)).toEqual([comment, 2, comment, 2]);
+ });
+
+ it('keeps cloned and detached ranges live', () => {
+ const { document, first } = fixture();
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ const clone = range.cloneRange();
+ range.detach();
+ first.deleteData(1, 2);
+ expect(range.endOffset).toBe(2);
+ expect(clone.endOffset).toBe(2);
+ });
+});
+
+describe('document Selection', () => {
+ it('notifies hosts after live boundary edits commit and observer records are queued', () => {
+ const seen: string[] = [];
+ let armed = false;
+ const document = new Document({
+ onInvalidate(change) {
+ if (change.kind !== 'selection' || !armed) return;
+ seen.push(document.documentElement?.textContent ?? '');
+ document.documentElement?.setAttribute('selection', 'changed');
+ },
+ });
+ const root = document.createElement('box');
+ const text = document.createTextNode('abc');
+ root.append(text);
+ document.append(root);
+ document.getSelection().collapse(text, 2);
+ const observer = new MutationObserver(() => {});
+ observer.observe(root, { subtree: true, characterData: true, attributes: true });
+ armed = true;
+ text.deleteData(0, 1);
+ expect(seen).toEqual(['bc']);
+ expect(observer.takeRecords().map((record) => record.type)).toEqual([
+ 'characterData',
+ 'attributes',
+ ]);
+ observer.disconnect();
+ });
+
+ it('hides a selected range moved by script to a detached root', () => {
+ const { document, first } = fixture();
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ const selection = document.getSelection();
+ selection.addRange(range);
+ range.selectNodeContents(document.createTextNode('detached'));
+ expect(selection.anchorNode).toBeNull();
+ expect(selection.focusNode).toBeNull();
+ expect(selection.rangeCount).toBe(0);
+ expect(selection.isCollapsed).toBe(true);
+ });
+
+ it('owns one document-local selection with range identity', () => {
+ const { document, first, last } = fixture();
+ const selection = document.getSelection();
+ expect(document.getSelection()).toBe(selection);
+ expect(selection.type).toBe('None');
+ expect(selection.rangeCount).toBe(0);
+ const range = document.createRange();
+ range.selectNodeContents(first);
+ selection.addRange(range);
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect(selection.toString()).toBe('A😀B');
+ range.selectNodeContents(last);
+ expect(selection.toString()).toBe('last');
+ selection.addRange(document.createRange());
+ expect(selection.getRangeAt(0)).toBe(range);
+ expect(new Document().getSelection().rangeCount).toBe(0);
+ });
+
+ it('preserves backward anchor/focus through live range mutation', () => {
+ const { document, first, last } = fixture();
+ const selection = document.getSelection();
+ selection.setBaseAndExtent(last, 3, first, 1);
+ expect(selection.direction).toBe('backward');
+ expect(selection.anchorNode).toBe(last);
+ expect(selection.focusNode).toBe(first);
+ first.insertData(0, '_');
+ expect(selection.focusOffset).toBe(2);
+ expect(selection.anchorOffset).toBe(3);
+ selection.extend(last, 4);
+ expect(selection.direction).toBe('forward');
+ expect(selection.toString()).toBe('t');
+ });
+
+ it('collapse replaces the range while deletion mutates the selected range', () => {
+ const { document, first } = fixture();
+ const selection = document.getSelection();
+ selection.setBaseAndExtent(first, 1, first, 3);
+ const selected = selection.getRangeAt(0);
+ selection.deleteFromDocument();
+ expect(selection.getRangeAt(0)).toBe(selected);
+ expect(first.data).toBe('AB');
+ expect(selection.type).toBe('Caret');
+ selection.collapseToEnd();
+ expect(selection.getRangeAt(0)).not.toBe(selected);
+ selection.removeAllRanges();
+ expect(() => selection.collapseToStart()).toThrow(
+ expect.objectContaining({ name: 'InvalidStateError' }),
+ );
+ expect(() => selection.getRangeAt(0)).toThrow(
+ expect.objectContaining({ name: 'IndexSizeError' }),
+ );
+ });
+
+ it('ignores detached and foreign-document ranges', () => {
+ const { document } = fixture();
+ const detached = document.createTextNode('detached');
+ const range = document.createRange();
+ range.selectNodeContents(detached);
+ const selection = document.getSelection();
+ selection.addRange(range);
+ expect(selection.rangeCount).toBe(0);
+ selection.addRange(new Document().createRange());
+ expect(selection.rangeCount).toBe(0);
+ });
+
+ it('invalidates selection changes and selected live-range adjustments', () => {
+ const invalidate = vi.fn();
+ const document = new Document({ onInvalidate: invalidate });
+ const root = document.createElement('box');
+ const text = document.createTextNode('text');
+ root.append(text);
+ document.append(root);
+ invalidate.mockClear();
+ const selection = document.getSelection();
+ selection.collapse(text, 2);
+ expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ kind: 'selection' }));
+ invalidate.mockClear();
+ text.deleteData(0, 1);
+ expect(selection.anchorOffset).toBe(1);
+ expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ kind: 'selection' }));
+ invalidate.mockClear();
+ selection.removeAllRanges();
+ expect(invalidate).toHaveBeenCalledWith(expect.objectContaining({ kind: 'selection' }));
+ });
+});
diff --git a/packages/dom/test/shadow-events.test.ts b/packages/dom/test/shadow-events.test.ts
new file mode 100644
index 0000000..ba2c303
--- /dev/null
+++ b/packages/dom/test/shadow-events.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Document } from '../src/dom.ts';
+import { Event, FocusEvent, type EventTarget } from '../src/events.ts';
+
+describe('shadow event dispatch', () => {
+ it.each(['open', 'closed'] as const)('retargets composed events across a %s root', (mode) => {
+ const document = new Document();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode });
+ const target = document.createElement('button');
+ document.append(host);
+ shadow.append(target);
+ const calls: string[] = [];
+ const inside: EventTarget[][] = [];
+ const outside: EventTarget[][] = [];
+ target.addEventListener('x', (event) => {
+ calls.push('target');
+ expect(event.target).toBe(target);
+ inside.push(event.composedPath());
+ });
+ shadow.addEventListener('x', (event) => expect(event.target).toBe(target));
+ host.addEventListener('x', (event) => {
+ calls.push('host');
+ expect(event.target).toBe(host);
+ expect(event.eventPhase).toBe(Event.AT_TARGET);
+ outside.push(event.composedPath());
+ });
+ document.addEventListener('x', (event) => {
+ calls.push('document');
+ expect(event.target).toBe(host);
+ expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
+ });
+ const event = new Event('x', { bubbles: true, composed: true });
+ target.dispatchEvent(event);
+ expect(calls).toEqual(['target', 'host', 'document']);
+ expect(inside).toEqual([[target, shadow, host, document]]);
+ expect(outside).toEqual([
+ mode === 'open' ? [target, shadow, host, document] : [host, document],
+ ]);
+ expect(event.target).toBe(host);
+ expect(event.composedPath()).toEqual([]);
+ });
+
+ it('keeps non-composed events inside their original shadow root and clears references', () => {
+ const document = new Document();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const target = document.createElement('button');
+ const related = document.createElement('button');
+ document.append(host);
+ shadow.append(target, related);
+ const escaped = vi.fn();
+ host.addEventListener('focusout', escaped);
+ const event = new FocusEvent('focusout', { bubbles: true, relatedTarget: related });
+ shadow.addEventListener('focusout', (observed) =>
+ expect(observed.composedPath()).toEqual([target, shadow]),
+ );
+ target.dispatchEvent(event);
+ expect(escaped).not.toHaveBeenCalled();
+ expect(event.target).toBeNull();
+ expect(event.relatedTarget).toBeNull();
+ });
+
+ it('delivers non-bubbling composed events at hosts in both target phases', () => {
+ const document = new Document();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const target = document.createElement('button');
+ document.append(host);
+ shadow.append(target);
+ const calls: string[] = [];
+ document.addEventListener('x', () => calls.push('document capture'), true);
+ document.addEventListener('x', () => calls.push('document bubble'));
+ host.addEventListener(
+ 'x',
+ (event) => {
+ calls.push('host capture');
+ expect(event.eventPhase).toBe(Event.AT_TARGET);
+ },
+ true,
+ );
+ host.addEventListener('x', (event) => {
+ calls.push('host bubble');
+ expect(event.eventPhase).toBe(Event.AT_TARGET);
+ });
+ target.addEventListener('x', () => calls.push('target'));
+ target.dispatchEvent(new Event('x', { composed: true }));
+ expect(calls).toEqual(['document capture', 'host capture', 'target', 'host bubble']);
+ });
+
+ it('suppresses outer focus transitions when both related targets retarget to the same host', () => {
+ const document = new Document();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ document.append(host);
+ shadow.append(first, second);
+ first.focus();
+ const outer = vi.fn();
+ const inside: string[] = [];
+ host.addEventListener('focusout', outer);
+ host.addEventListener('focusin', outer);
+ shadow.addEventListener('focusout', () => inside.push('out'));
+ shadow.addEventListener('focusin', () => inside.push('in'));
+ second.focus();
+ expect(inside).toEqual(['out', 'in']);
+ expect(outer).not.toHaveBeenCalled();
+ });
+
+ it('retargets relatedTarget when focus enters or leaves a shadow tree', () => {
+ const document = new Document();
+ const root = document.createElement('box');
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const inner = document.createElement('input');
+ const outer = document.createElement('input');
+ document.append(root);
+ root.append(host, outer);
+ shadow.append(inner);
+ const observations: unknown[] = [];
+ root.addEventListener('focusin', (event) => {
+ if (event instanceof FocusEvent) observations.push([event.target, event.relatedTarget]);
+ });
+ outer.focus();
+ inner.focus();
+ outer.focus();
+ expect(observations).toEqual([
+ [outer, null],
+ [host, outer],
+ [outer, host],
+ ]);
+ });
+
+ it('captures visibility and retargeting before listeners move the target outside the root', () => {
+ const document = new Document();
+ const root = document.createElement('box');
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode: 'closed' });
+ const target = document.createElement('button');
+ document.append(root);
+ root.append(host);
+ shadow.append(target);
+ root.addEventListener('x', () => root.append(target), true);
+ host.addEventListener('x', (event) => {
+ expect(event.target).toBe(host);
+ expect(event.composedPath()).toEqual([host, root, document]);
+ });
+ target.addEventListener('x', (event) => {
+ expect(event.target).toBe(target);
+ expect(event.composedPath()).toEqual([target, shadow, host, root, document]);
+ });
+ target.dispatchEvent(new Event('x', { bubbles: true, composed: true }));
+ });
+
+ it.each(['open', 'closed'] as const)(
+ 'routes slotted light-tree events through a %s root even without composed',
+ (mode) => {
+ const document = new Document();
+ const host = document.createElement('box');
+ const shadow = host.attachShadow({ mode });
+ const slot = document.createElement('slot');
+ const target = document.createElement('button');
+ document.append(host);
+ shadow.append(slot);
+ host.append(target);
+ const calls: string[] = [];
+ slot.addEventListener('x', (event) => {
+ calls.push('slot');
+ expect(event.target).toBe(target);
+ expect(event.composedPath()).toEqual([target, slot, shadow, host, document]);
+ });
+ host.addEventListener('x', (event) => {
+ calls.push('host');
+ expect(event.target).toBe(target);
+ expect(event.composedPath()).toEqual(
+ mode === 'open' ? [target, slot, shadow, host, document] : [target, host, document],
+ );
+ });
+ target.dispatchEvent(new Event('x', { bubbles: true }));
+ expect(calls).toEqual(['slot', 'host']);
+ },
+ );
+
+ it('hides each nested closed root according to the current listener', () => {
+ const document = new Document();
+ const outerHost = document.createElement('box');
+ const outerRoot = outerHost.attachShadow({ mode: 'closed' });
+ const innerHost = document.createElement('box');
+ const innerRoot = innerHost.attachShadow({ mode: 'closed' });
+ const target = document.createElement('input');
+ document.append(outerHost);
+ outerRoot.append(innerHost);
+ innerRoot.append(target);
+ const paths: EventTarget[][] = [];
+ target.addEventListener('x', (event) => paths.push(event.composedPath()));
+ innerHost.addEventListener('x', (event) => paths.push(event.composedPath()));
+ outerHost.addEventListener('x', (event) => paths.push(event.composedPath()));
+ target.dispatchEvent(new Event('x', { composed: true, bubbles: true }));
+ expect(paths).toEqual([
+ [target, innerRoot, innerHost, outerRoot, outerHost, document],
+ [innerHost, outerRoot, outerHost, document],
+ [outerHost, document],
+ ]);
+ });
+});
diff --git a/packages/dom/test/tree.test.ts b/packages/dom/test/tree.test.ts
new file mode 100644
index 0000000..e0ca6cb
--- /dev/null
+++ b/packages/dom/test/tree.test.ts
@@ -0,0 +1,254 @@
+import assert from 'node:assert/strict';
+import { describe, expect, it, vi } from 'vitest';
+import { Document, Element, HTML_NAMESPACE, SVG_NAMESPACE } from '../src/dom.ts';
+import { Event } from '../src/events.ts';
+import { MutationObserver } from '../src/observation.ts';
+
+describe('tree and ownership invariants', () => {
+ it('rejects invalid document fragments before changing either tree or notifying', () => {
+ const notify = vi.fn();
+ const source = new Document();
+ const destination = new Document({ onInvalidate: notify });
+ const fragment = source.createDocumentFragment();
+ const first = source.createElement('first');
+ const second = source.createElement('second');
+ fragment.append(first, second);
+ const observer = new MutationObserver(() => {});
+ observer.observe(fragment, { childList: true });
+ expect(() => destination.appendChild(fragment)).toThrowError(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(fragment.childNodes).toEqual([first, second]);
+ expect(destination.childNodes).toHaveLength(0);
+ expect(first.ownerDocument).toBe(source);
+ expect(first.parentNode).toBe(fragment);
+ expect(observer.takeRecords()).toEqual([]);
+ expect(notify).not.toHaveBeenCalled();
+ observer.disconnect();
+ });
+
+ it('rejects cycles and wrong anchors before moving a connected subtree', () => {
+ const document = new Document();
+ const root = document.appendChild(document.createElement('box'));
+ const branch = root.appendChild(document.createElement('branch'));
+ const child = branch.appendChild(document.createElement('child'));
+ const detached = document.createElement('detached');
+ expect(() => detached.insertBefore(branch, child)).toThrowError(
+ expect.objectContaining({ name: 'NotFoundError' }),
+ );
+ expect(() => child.insertBefore(branch, null)).toThrowError(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(() => branch.appendChild(branch)).toThrowError(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(branch.parentNode).toBe(root);
+ expect(child.parentNode).toBe(branch);
+ expect(detached.childNodes).toHaveLength(0);
+ });
+
+ it('adopts descendants and attributes without losing identity, listeners, or user state', () => {
+ const source = new Document();
+ const destination = new Document();
+ const root = source.appendChild(source.createElement('root'));
+ const branch = root.appendChild(source.createElement('branch'));
+ branch.setAttribute('data-state', 'before');
+ const attr = branch.getAttributeNode('data-state');
+ const text = branch.appendChild(source.createTextNode('value'));
+ const listener = vi.fn();
+ branch.addEventListener('customName', listener);
+ const state = { selected: true };
+ Reflect.set(branch, 'userState', state);
+ destination.appendChild(branch);
+ expect(branch.ownerDocument).toBe(destination);
+ expect(text.ownerDocument).toBe(destination);
+ expect(attr?.ownerDocument).toBe(destination);
+ expect(attr?.ownerElement).toBe(branch);
+ expect(Reflect.get(branch, 'userState')).toBe(state);
+ branch.dispatchEvent(new Event('customName'));
+ expect(listener).toHaveBeenCalledOnce();
+ expect(root.childNodes).toHaveLength(0);
+ });
+
+ it('exposes collections which cannot mutate the tree behind its invariants', () => {
+ const document = new Document();
+ const root = document.createElement('box');
+ const child = root.appendChild(document.createElement('text'));
+ expect(Reflect.set(root.childNodes, '0', document.createTextNode('replacement'))).toBe(false);
+ expect(Reflect.set(root.children, 'length', 0)).toBe(false);
+ root.setAttribute('title', 'kept');
+ expect(Reflect.set(root.attributes, 'length', 0)).toBe(false);
+ expect(root.firstChild).toBe(child);
+ expect(child.parentNode).toBe(root);
+ expect(root.getAttribute('title')).toBe('kept');
+ });
+
+ it('treats attributes and character data as leaf nodes', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = parent.appendChild(document.createElement('text'));
+ for (const leaf of [
+ document.createAttribute('title'),
+ document.createTextNode('hello'),
+ document.createComment('comment'),
+ ]) {
+ expect(() => leaf.appendChild(child)).toThrowError(
+ expect.objectContaining({ name: 'HierarchyRequestError' }),
+ );
+ expect(child.parentNode).toBe(parent);
+ }
+ });
+
+ it('preserves case in createElementNS, including the HTML namespace', () => {
+ const document = new Document();
+ expect(document.createElement('MixedCase').localName).toBe('mixedcase');
+ expect(document.createElementNS(HTML_NAMESPACE, 'MixedCase').localName).toBe('MixedCase');
+ const svg = document.createElementNS(SVG_NAMESPACE, 'linearGradient');
+ expect(svg.localName).toBe('linearGradient');
+ expect(svg.nodeName).toBe('linearGradient');
+ svg.setAttribute('viewBox', '0 0 1 1');
+ expect(svg.getAttribute('viewbox')).toBeNull();
+ expect(svg.cloneNode()).toBeInstanceOf(Element);
+ expect(svg.cloneNode().nodeName).toBe('linearGradient');
+ });
+
+ it('replaces attribute nodes by namespace and local name for both setters', () => {
+ const document = new Document();
+ const element = document.createElement('box');
+ const first = document.createAttributeNS('urn:test', 'first:name');
+ const second = document.createAttributeNS('urn:test', 'second:name');
+ first.value = 'first';
+ second.value = 'second';
+ element.setAttributeNode(first);
+ expect(element.setAttributeNode(second)).toBe(first);
+ expect(first.ownerElement).toBeNull();
+ expect(second.ownerElement).toBe(element);
+ expect(element.attributes).toHaveLength(1);
+ expect(element.getAttributeNS('urn:test', 'name')).toBe('second');
+ });
+
+ it('rejects an attribute owned by another element without changing either owner', () => {
+ const document = new Document();
+ const first = document.createElement('first');
+ const second = document.createElement('second');
+ first.setAttribute('title', 'first');
+ second.setAttribute('title', 'second');
+ const attr = first.getAttributeNode('title');
+ assert.ok(attr);
+ expect(() => second.setAttributeNode(attr)).toThrowError(
+ expect.objectContaining({ name: 'InUseAttributeError' }),
+ );
+ expect(attr.ownerElement).toBe(first);
+ expect(second.getAttribute('title')).toBe('second');
+ });
+
+ it('records one fragment removal and one parent insertion for fragment transfer', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const fragment = document.createDocumentFragment();
+ const first = fragment.appendChild(document.createElement('first'));
+ const second = fragment.appendChild(document.createElement('second'));
+ const observer = new MutationObserver(() => {});
+ observer.observe(fragment, { childList: true });
+ observer.observe(parent, { childList: true });
+ parent.appendChild(fragment);
+ const records = observer.takeRecords();
+ expect(records).toHaveLength(2);
+ expect(records[0]?.target).toBe(fragment);
+ expect(records[0]?.removedNodes).toEqual([first, second]);
+ expect(records[1]?.target).toBe(parent);
+ expect(records[1]?.addedNodes).toEqual([first, second]);
+ observer.disconnect();
+ });
+
+ it('keeps an element in place when replaced with itself', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const child = parent.appendChild(document.createElement('text'));
+ child.replaceWith(child);
+ expect(child.parentNode).toBe(parent);
+ expect(parent.firstChild).toBe(child);
+ });
+
+ it('prepends an existing first child followed by another existing child in argument order', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const first = parent.appendChild(document.createElement('first'));
+ const middle = parent.appendChild(document.createElement('middle'));
+ const last = parent.appendChild(document.createElement('last'));
+ parent.prepend(first, last);
+ expect(parent.childNodes).toEqual([first, last, middle]);
+ });
+});
+
+it('aggregates replacement records while retaining transient subtree observers', () => {
+ const document = new Document();
+ const parent = document.createElement('box');
+ const a = document.createElement('a');
+ const b = document.createElement('b');
+ parent.append(a, b);
+ const observer = new MutationObserver(() => {});
+ observer.observe(parent, { childList: true, subtree: true, attributes: true });
+ parent.textContent = 'replacement';
+ a.setAttribute('detached', 'yes');
+ const records = observer.takeRecords();
+ expect(records).toHaveLength(2);
+ expect(records[0]?.removedNodes).toEqual([a, b]);
+ expect(records[0]?.addedNodes[0]?.textContent).toBe('replacement');
+ expect(records[1]?.target).toBe(a);
+ const replacement = document.createElement('next');
+ observer.takeRecords();
+ parent.replaceChild(replacement, parent.firstChild!);
+ const replaced = observer.takeRecords();
+ expect(replaced).toHaveLength(1);
+ expect(replaced[0]?.addedNodes).toEqual([replacement]);
+ expect(replaced[0]?.removedNodes).toHaveLength(1);
+});
+
+it('keeps held child and attribute collections live without exposing mutation arrays', () => {
+ const document = new Document();
+ const root = document.createElement('box');
+ const nodes = root.childNodes;
+ const attrs = root.attributes;
+ const text = document.createTextNode('a');
+ root.appendChild(text);
+ root.setAttribute('id', 'example');
+ expect(nodes).toBe(root.childNodes);
+ expect(nodes[0]).toBe(text);
+ expect(nodes.length).toBe(1);
+ expect(attrs[0]?.value).toBe('example');
+ expect(Reflect.set(nodes, '0', document.createTextNode('bad'))).toBe(false);
+ expect(Reflect.deleteProperty(nodes, '0')).toBe(false);
+ expect(Reflect.defineProperty(attrs, '0', { value: null })).toBe(false);
+ expect(() => Array.prototype.pop.call(nodes)).toThrow();
+ root.removeChild(text);
+ root.removeAttribute('id');
+ expect(nodes.length).toBe(0);
+ expect(attrs.length).toBe(0);
+});
+
+it('uses WebIDL unsigned offsets and counts for UTF-16 character data', () => {
+ const document = new Document();
+ const text = document.createTextNode('a😀bc');
+ expect(text.substringData(1.8, 2.9)).toBe('😀');
+ text.replaceData(Number.NaN, 0, 'x');
+ expect(text.data).toBe('xa😀bc');
+ text.deleteData(2, -1);
+ expect(text.data).toBe('xa');
+ expect(() => text.deleteData(-1, 1)).toThrow();
+ expect(text.splitText(1.9).data).toBe('a');
+ expect(text.data).toBe('x');
+});
+
+it('rejects a variadic invalid document shape before moving either source node', () => {
+ const document = new Document();
+ const source = document.createElement('box');
+ const a = document.createElement('a');
+ const b = document.createElement('b');
+ source.append(a, b);
+ expect(() => document.append(a, b)).toThrow();
+ expect(source.childNodes).toEqual([a, b]);
+ expect(document.childNodes.length).toBe(0);
+ expect(() => document.prepend(a, b)).toThrow();
+ expect(source.childNodes).toEqual([a, b]);
+});
diff --git a/packages/dom/test/types.test.ts b/packages/dom/test/types.test.ts
new file mode 100644
index 0000000..65d64a1
--- /dev/null
+++ b/packages/dom/test/types.test.ts
@@ -0,0 +1,24 @@
+import { expect, expectTypeOf, it } from 'vitest';
+import { Document, type Element } from '../src/index.ts';
+
+interface TerminalLabel extends Element {
+ readonly terminalValue: number;
+}
+declare module '../src/index.ts' {
+ interface Elements {
+ 'terminal-label': TerminalLabel;
+ }
+}
+
+it('keeps element-name augmentation tied to a real registered constructor', () => {
+ const document = new Document();
+ class Label extends document.HTMLElement {
+ readonly terminalValue = 42;
+ }
+ document.customElements.define('terminal-label', Label);
+ const label = document.createElement('terminal-label');
+ expectTypeOf(label).toEqualTypeOf();
+ expect(label).toBeInstanceOf(Label);
+ expect(label.terminalValue).toBe(42);
+ expectTypeOf(document.createElement('arbitrary-terminal-node')).toEqualTypeOf();
+});
diff --git a/packages/dom/test/wpt-events.test.ts b/packages/dom/test/wpt-events.test.ts
new file mode 100644
index 0000000..7c39d37
--- /dev/null
+++ b/packages/dom/test/wpt-events.test.ts
@@ -0,0 +1,87 @@
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+import { describe, expect, it } from 'vitest';
+import { Event, EventTarget } from '../src/events.ts';
+
+interface AsyncTest {
+ step_func(callback: (...args: Args) => void): (...args: Args) => void;
+ done(): void;
+}
+
+// These are unchanged upstream test files, pinned and licensed in wpt/README.md.
+// The sandbox exposes only the tested constructors and a minimal testharness API.
+// It does not install globals into the package or the surrounding Node process.
+for (const file of [
+ 'AddEventListenerOptions-once.any.js',
+ 'AddEventListenerOptions-passive.any.js',
+ 'AddEventListenerOptions-signal.any.js',
+]) {
+ describe(`upstream WPT: ${file}`, () => {
+ const listenerErrors: unknown[] = [];
+ class HarnessEventTarget extends EventTarget {
+ constructor() {
+ super({ reportError: (error) => listenerErrors.push(error) });
+ }
+ }
+ const verifyListenerErrors = (): void => {
+ expect(listenerErrors, 'WPT assertions thrown in event listeners').toEqual([]);
+ };
+ const context = {
+ Event,
+ EventTarget: HarnessEventTarget,
+ AbortController,
+ AbortSignal,
+ TypeError,
+ test(callback: () => void, name: string): void {
+ it(name, () => {
+ listenerErrors.length = 0;
+ callback();
+ verifyListenerErrors();
+ });
+ },
+ async_test(callback: (test: AsyncTest) => void, name: string): void {
+ it(name, async () => {
+ listenerErrors.length = 0;
+ await new Promise((resolve, reject) => {
+ callback({
+ step_func:
+ (step) =>
+ (...args) => {
+ try {
+ step(...args);
+ } catch (error) {
+ reject(error);
+ }
+ },
+ done: resolve,
+ });
+ });
+ verifyListenerErrors();
+ });
+ },
+ // oxlint-disable-next-line bombshell-dev/max-params -- WPT testharness API.
+ assert_equals(actual: unknown, expected: unknown, message?: string): void {
+ expect(actual, message).toBe(expected);
+ },
+ assert_true(actual: unknown, message?: string): void {
+ expect(actual, message).toBe(true);
+ },
+ assert_false(actual: unknown, message?: string): void {
+ expect(actual, message).toBe(false);
+ },
+ assert_unreached(message?: string): void {
+ expect.unreachable(message);
+ },
+ // oxlint-disable-next-line bombshell-dev/max-params -- WPT testharness API.
+ assert_throws_js(
+ constructor: new (...args: never[]) => Error,
+ callback: () => void,
+ message?: string,
+ ): void {
+ expect(callback, message).toThrow(constructor);
+ },
+ };
+ const source = readFileSync(new URL(`./wpt/${file}.txt`, import.meta.url), 'utf8');
+ runInNewContext(source, context, { filename: file });
+ });
+}
diff --git a/packages/dom/test/wpt/AddEventListenerOptions-once.any.js.txt b/packages/dom/test/wpt/AddEventListenerOptions-once.any.js.txt
new file mode 100644
index 0000000..b4edd43
--- /dev/null
+++ b/packages/dom/test/wpt/AddEventListenerOptions-once.any.js.txt
@@ -0,0 +1,96 @@
+// META: title=AddEventListenerOptions.once
+
+"use strict";
+
+test(function() {
+ var invoked_once = false;
+ var invoked_normal = false;
+ function handler_once() {
+ invoked_once = true;
+ }
+ function handler_normal() {
+ invoked_normal = true;
+ }
+
+ const et = new EventTarget();
+ et.addEventListener('test', handler_once, {once: true});
+ et.addEventListener('test', handler_normal);
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_once, true, "Once handler should be invoked");
+ assert_equals(invoked_normal, true, "Normal handler should be invoked");
+
+ invoked_once = false;
+ invoked_normal = false;
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_once, false, "Once handler shouldn't be invoked again");
+ assert_equals(invoked_normal, true, "Normal handler should be invoked again");
+ et.removeEventListener('test', handler_normal);
+}, "Once listener should be invoked only once");
+
+test(function() {
+ const et = new EventTarget();
+ var invoked_count = 0;
+ function handler() {
+ invoked_count++;
+ if (invoked_count == 1)
+ et.dispatchEvent(new Event('test'));
+ }
+ et.addEventListener('test', handler, {once: true});
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_count, 1, "Once handler should only be invoked once");
+
+ invoked_count = 0;
+ function handler2() {
+ invoked_count++;
+ if (invoked_count == 1)
+ et.addEventListener('test', handler2, {once: true});
+ if (invoked_count <= 2)
+ et.dispatchEvent(new Event('test'));
+ }
+ et.addEventListener('test', handler2, {once: true});
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_count, 2, "Once handler should only be invoked once after each adding");
+}, "Once listener should be invoked only once even if the event is nested");
+
+test(function() {
+ var invoked_count = 0;
+ function handler() {
+ invoked_count++;
+ }
+
+ const et = new EventTarget();
+
+ et.addEventListener('test', handler, {once: true});
+ et.addEventListener('test', handler);
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_count, 1, "The handler should only be added once");
+
+ invoked_count = 0;
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_count, 0, "The handler was added as a once listener");
+
+ invoked_count = 0;
+ et.addEventListener('test', handler, {once: true});
+ et.removeEventListener('test', handler);
+ et.dispatchEvent(new Event('test'));
+ assert_equals(invoked_count, 0, "The handler should have been removed");
+}, "Once listener should be added / removed like normal listeners");
+
+test(function() {
+ const et = new EventTarget();
+
+ var invoked_count = 0;
+
+ for (let n = 4; n > 0; n--) {
+ et.addEventListener('test', (e) => {
+ invoked_count++;
+ e.stopImmediatePropagation();
+ }, {once: true});
+ }
+
+ for (let n = 4; n > 0; n--) {
+ et.dispatchEvent(new Event('test'));
+ }
+
+ assert_equals(invoked_count, 4, "The listeners should be invoked");
+}, "Multiple once listeners should be invoked even if the stopImmediatePropagation is set");
diff --git a/packages/dom/test/wpt/AddEventListenerOptions-passive.any.js.txt b/packages/dom/test/wpt/AddEventListenerOptions-passive.any.js.txt
new file mode 100644
index 0000000..8e59cf5
--- /dev/null
+++ b/packages/dom/test/wpt/AddEventListenerOptions-passive.any.js.txt
@@ -0,0 +1,134 @@
+// META: title=AddEventListenerOptions.passive
+
+test(function() {
+ var supportsPassive = false;
+ var query_options = {
+ get passive() {
+ supportsPassive = true;
+ return false;
+ },
+ get dummy() {
+ assert_unreached("dummy value getter invoked");
+ return false;
+ }
+ };
+
+ const et = new EventTarget();
+ et.addEventListener('test_event', null, query_options);
+ assert_true(supportsPassive, "addEventListener doesn't support the passive option");
+
+ supportsPassive = false;
+ et.removeEventListener('test_event', null, query_options);
+ assert_false(supportsPassive, "removeEventListener supports the passive option when it should not");
+}, "Supports passive option on addEventListener only");
+
+function testPassiveValue(optionsValue, expectedDefaultPrevented, existingEventTarget) {
+ var defaultPrevented = undefined;
+ var handler = function handler(e) {
+ assert_false(e.defaultPrevented, "Event prematurely marked defaultPrevented");
+ e.preventDefault();
+ defaultPrevented = e.defaultPrevented;
+ }
+ const et = existingEventTarget || new EventTarget();
+ et.addEventListener('test', handler, optionsValue);
+ var uncanceled = et.dispatchEvent(new Event('test', {bubbles: true, cancelable: true}));
+
+ assert_equals(defaultPrevented, expectedDefaultPrevented, "Incorrect defaultPrevented for options: " + JSON.stringify(optionsValue));
+ assert_equals(uncanceled, !expectedDefaultPrevented, "Incorrect return value from dispatchEvent");
+
+ et.removeEventListener('test', handler, optionsValue);
+}
+
+test(function() {
+ testPassiveValue(undefined, true);
+ testPassiveValue({}, true);
+ testPassiveValue({passive: false}, true);
+ testPassiveValue({passive: true}, false);
+ testPassiveValue({passive: 0}, true);
+ testPassiveValue({passive: 1}, false);
+}, "preventDefault should be ignored if-and-only-if the passive option is true");
+
+function testPassiveValueOnReturnValue(test, optionsValue, expectedDefaultPrevented) {
+ var defaultPrevented = undefined;
+ var handler = test.step_func(e => {
+ assert_false(e.defaultPrevented, "Event prematurely marked defaultPrevented");
+ e.returnValue = false;
+ defaultPrevented = e.defaultPrevented;
+ });
+ const et = new EventTarget();
+ et.addEventListener('test', handler, optionsValue);
+ var uncanceled = et.dispatchEvent(new Event('test', {bubbles: true, cancelable: true}));
+
+ assert_equals(defaultPrevented, expectedDefaultPrevented, "Incorrect defaultPrevented for options: " + JSON.stringify(optionsValue));
+ assert_equals(uncanceled, !expectedDefaultPrevented, "Incorrect return value from dispatchEvent");
+
+ et.removeEventListener('test', handler, optionsValue);
+}
+
+async_test(t => {
+ testPassiveValueOnReturnValue(t, undefined, true);
+ testPassiveValueOnReturnValue(t, {}, true);
+ testPassiveValueOnReturnValue(t, {passive: false}, true);
+ testPassiveValueOnReturnValue(t, {passive: true}, false);
+ testPassiveValueOnReturnValue(t, {passive: 0}, true);
+ testPassiveValueOnReturnValue(t, {passive: 1}, false);
+ t.done();
+}, "returnValue should be ignored if-and-only-if the passive option is true");
+
+function testPassiveWithOtherHandlers(optionsValue, expectedDefaultPrevented) {
+ var handlerInvoked1 = false;
+ var dummyHandler1 = function() {
+ handlerInvoked1 = true;
+ };
+ var handlerInvoked2 = false;
+ var dummyHandler2 = function() {
+ handlerInvoked2 = true;
+ };
+
+ const et = new EventTarget();
+ et.addEventListener('test', dummyHandler1, {passive:true});
+ et.addEventListener('test', dummyHandler2);
+
+ testPassiveValue(optionsValue, expectedDefaultPrevented, et);
+
+ assert_true(handlerInvoked1, "Extra passive handler not invoked");
+ assert_true(handlerInvoked2, "Extra non-passive handler not invoked");
+
+ et.removeEventListener('test', dummyHandler1);
+ et.removeEventListener('test', dummyHandler2);
+}
+
+test(function() {
+ testPassiveWithOtherHandlers({}, true);
+ testPassiveWithOtherHandlers({passive: false}, true);
+ testPassiveWithOtherHandlers({passive: true}, false);
+}, "passive behavior of one listener should be unaffected by the presence of other listeners");
+
+function testOptionEquivalence(optionValue1, optionValue2, expectedEquality) {
+ var invocationCount = 0;
+ var handler = function handler(e) {
+ invocationCount++;
+ }
+ const et = new EventTarget();
+ et.addEventListener('test', handler, optionValue1);
+ et.addEventListener('test', handler, optionValue2);
+ et.dispatchEvent(new Event('test', {bubbles: true}));
+ assert_equals(invocationCount, expectedEquality ? 1 : 2, "equivalence of options " +
+ JSON.stringify(optionValue1) + " and " + JSON.stringify(optionValue2));
+ et.removeEventListener('test', handler, optionValue1);
+ et.removeEventListener('test', handler, optionValue2);
+}
+
+test(function() {
+ // Sanity check options that should be treated as distinct handlers
+ testOptionEquivalence({capture:true}, {capture:false, passive:false}, false);
+ testOptionEquivalence({capture:true}, {passive:true}, false);
+
+ // Option values that should be treated as equivalent
+ testOptionEquivalence({}, {passive:false}, true);
+ testOptionEquivalence({passive:true}, {passive:false}, true);
+ testOptionEquivalence(undefined, {passive:true}, true);
+ testOptionEquivalence({capture: true, passive: false}, {capture: true, passive: true}, true);
+
+}, "Equivalence of option values");
+
diff --git a/packages/dom/test/wpt/AddEventListenerOptions-signal.any.js.txt b/packages/dom/test/wpt/AddEventListenerOptions-signal.any.js.txt
new file mode 100644
index 0000000..e6a3426
--- /dev/null
+++ b/packages/dom/test/wpt/AddEventListenerOptions-signal.any.js.txt
@@ -0,0 +1,143 @@
+'use strict';
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', handler, { signal: controller.signal });
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 1, "Adding a signal still adds a listener");
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 2, "The listener was not added with the once flag");
+ controller.abort();
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 2, "Aborting on the controller removes the listener");
+ et.addEventListener('test', handler, { signal: controller.signal });
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 2, "Passing an aborted signal never adds the handler");
+}, "Passing an AbortSignal to addEventListener options should allow removing a listener");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', handler, { signal: controller.signal });
+ et.removeEventListener('test', handler);
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Passing an AbortSignal to addEventListener does not prevent removeEventListener");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', handler, { signal: controller.signal, once: true });
+ controller.abort();
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Passing an AbortSignal to addEventListener works with the once flag");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', handler, { signal: controller.signal, once: true });
+ et.removeEventListener('test', handler);
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Removing a once listener works with a passed signal");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('first', handler, { signal: controller.signal, once: true });
+ et.addEventListener('second', handler, { signal: controller.signal, once: true });
+ controller.abort();
+ et.dispatchEvent(new Event('first'));
+ et.dispatchEvent(new Event('second'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Passing an AbortSignal to multiple listeners");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', handler, { signal: controller.signal, capture: true });
+ controller.abort();
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Passing an AbortSignal to addEventListener works with the capture flag");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', () => {
+ controller.abort();
+ }, { signal: controller.signal });
+ et.addEventListener('test', handler, { signal: controller.signal });
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Aborting from a listener does not call future listeners");
+
+test(function() {
+ let count = 0;
+ function handler() {
+ count++;
+ }
+ const et = new EventTarget();
+ const controller = new AbortController();
+ et.addEventListener('test', () => {
+ et.addEventListener('test', handler, { signal: controller.signal });
+ controller.abort();
+ }, { signal: controller.signal });
+ et.dispatchEvent(new Event('test'));
+ assert_equals(count, 0, "The listener was still removed");
+}, "Adding then aborting a listener in another listener does not call it");
+
+test(function() {
+ const et = new EventTarget();
+ const ac = new AbortController();
+ let count = 0;
+ et.addEventListener('foo', () => {
+ et.addEventListener('foo', () => {
+ count++;
+ if (count > 5) ac.abort();
+ et.dispatchEvent(new Event('foo'));
+ }, { signal: ac.signal });
+ et.dispatchEvent(new Event('foo'));
+ }, { once: true });
+ et.dispatchEvent(new Event('foo'));
+}, "Aborting from a nested listener should remove it");
+
+test(function() {
+ const et = new EventTarget();
+ assert_throws_js(TypeError, () => { et.addEventListener("foo", () => {}, { signal: null }); });
+}, "Passing null as the signal should throw");
+
+test(function() {
+ const et = new EventTarget();
+ assert_throws_js(TypeError, () => { et.addEventListener("foo", null, { signal: null }); });
+}, "Passing null as the signal should throw (listener is also null)");
diff --git a/packages/dom/test/wpt/LICENSE.txt b/packages/dom/test/wpt/LICENSE.txt
new file mode 100644
index 0000000..39c46d0
--- /dev/null
+++ b/packages/dom/test/wpt/LICENSE.txt
@@ -0,0 +1,11 @@
+# The 3-Clause BSD License
+
+Copyright © web-platform-tests contributors
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/dom/test/wpt/README.md b/packages/dom/test/wpt/README.md
new file mode 100644
index 0000000..e2f2629
--- /dev/null
+++ b/packages/dom/test/wpt/README.md
@@ -0,0 +1,24 @@
+# Selected upstream Web Platform Tests
+
+These files are unmodified source copies from `web-platform-tests/wpt`, commit
+`b4b303c8dca22989c43e2d76fce199c36d4b77a7`:
+
+- [`dom/events/AddEventListenerOptions-once.any.js`](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/dom/events/AddEventListenerOptions-once.any.js)
+- [`dom/events/AddEventListenerOptions-passive.any.js`](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/dom/events/AddEventListenerOptions-passive.any.js)
+- [`dom/events/AddEventListenerOptions-signal.any.js`](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/dom/events/AddEventListenerOptions-signal.any.js)
+
+Copyright © web-platform-tests contributors. The upstream BSD 3-Clause license
+is retained in [LICENSE.txt](./LICENSE.txt). The `.txt` suffix keeps fixtures
+outside TypeScript compilation and formatting; it does not change their bytes.
+
+`../wpt-events.test.ts` executes every case in these three files inside a Node VM
+context. A minimal adapter maps `test`, `async_test`, and their assertions to
+Vitest. It supplies this package's Event/EventTarget and Node's native abort
+constructors. Errors reported by listeners are checked explicitly so swallowed
+assertions cannot create false passes. TypeError is supplied from the same realm
+as the implementation so constructor assertions test the intended exception.
+
+There are no skipped cases in these files. This is a selected upstream run,
+not a run of the complete WPT harness or browser suite. Browser document/window
+dispatch, trusted events, activation, and the complete shadow event test suite
+remain outside this adapter.
diff --git a/packages/dom/tsconfig.build.json b/packages/dom/tsconfig.build.json
new file mode 100644
index 0000000..da61aa0
--- /dev/null
+++ b/packages/dom/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist",
+ "sourceMap": true,
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/dom/tsconfig.json b/packages/dom/tsconfig.json
new file mode 100644
index 0000000..3c51da2
--- /dev/null
+++ b/packages/dom/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@bomb.sh/tools/tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "types": ["node"],
+ "lib": ["ESNext"],
+ "skipLibCheck": false
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 61f58ea..cd03e80 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,25 @@ importers:
specifier: ^4.0.2
version: 4.0.3
+ packages/dom:
+ dependencies:
+ parse5:
+ specifier: ^8.0.0
+ version: 8.0.1
+ devDependencies:
+ '@bomb.sh/tools':
+ specifier: ^0.6.1
+ version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))
+ '@types/node':
+ specifier: ^22
+ version: 22.20.1
+ '@typescript/native-preview':
+ specifier: 7.0.0-dev.20260623.1
+ version: 7.0.0-dev.20260623.1
+ vitest:
+ specifier: ^4.1.2
+ version: 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))
+
vendor/ui:
dependencies:
'@bomb.sh/tty':
@@ -1059,6 +1078,10 @@ packages:
resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==}
engines: {node: '>=14'}
+ entities@8.1.0:
+ resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
+ engines: {node: '>=20.19.0'}
+
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -1237,6 +1260,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -2254,6 +2280,8 @@ snapshots:
empathic@2.0.1: {}
+ entities@8.1.0: {}
+
es-module-lexer@2.1.0: {}
estree-walker@3.0.3:
@@ -2471,6 +2499,10 @@ snapshots:
package-manager-detector@1.6.0: {}
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.1.0
+
pathe@2.0.3: {}
picocolors@1.1.1: {}