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..249d4bb
--- /dev/null
+++ b/packages/dom/README.md
@@ -0,0 +1,155 @@
+# @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. Synthetic keyboard and focus events do not run host input actions. Following HTML activation semantics, an uncanceled `MouseEvent("click")` can activate controls; `element.click()` provides that operation. `Dispatch` is the explicit host-input boundary for logical navigation, text editing, keyboard activation and option selection; all generated events remain 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.
+
+## Interactive HTML and terminal elements
+
+Forms, buttons, labels, fieldsets, inputs, textareas, selects/options, output,
+progress/meter, details/summary and dialogs have specialized classes and tested
+state transitions. See [forms and element extensions](docs/forms-and-elements.md)
+for examples and limits, and [interactive standards](docs/interactive-standards.md)
+for browser comparisons and known differences.
+
+Arbitrary names still work without registration. To give a terminal element
+application behavior, register its factory explicitly on each document:
+
+```ts
+import { Document, Element } from '@bomb.sh/dom';
+
+class Box extends Element {
+ constructor(document: Document) {
+ super('box', document);
+ }
+ status = 'ready';
+}
+declare module '@bomb.sh/dom' {
+ interface Elements {
+ box: Box;
+ }
+}
+document.registerElement('box', (owner) => new Box(owner));
+document.createElement('box').status; // string, with a real Box instance
+```
+
+Declaration merging is a TypeScript contract: install the factory in every
+application document that relies on that declaration. Registration affects future
+creation, cloning and parsing; it does not upgrade existing nodes. Hyphenated
+custom elements use `document.customElements` and its lifecycle model.
+
+## 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..b899b21
--- /dev/null
+++ b/packages/dom/docs/capabilities.md
@@ -0,0 +1,40 @@
+# 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; specialized control, form, interactive, template and slot classes. Document-local `registerElement` factories support terminal names. Declarations must match each documentās registered factories. |
+| 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 transitions, logical tabindex/shadow/radio navigation, autofocus, modal isolation, host keydown/keyup/text/selection entry. Cancellable activation and plain-text editing are explicit host defaults; see focus documentation. |
+| Text controls | Implemented and tested | Values/defaults/dirty state, type-dependent sanitization, checkedness/radio groups, UTF-16 selection, select/setSelectionRange/setRangeText, numeric/date conversion/stepping, validity and observable state. Host editing supports selection-capable input types and textarea. |
+| 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 fragments, inert templates, namespaces/comments, cloning/importing, explicit declarative shadow parsing via setHTMLUnsafe, getHTML shadow serialization, TreeWalker nextNode/parentNode and filtering. |
+| Shadow trees and slots | Implemented and tested | Open/closed roots, named/manual/fallback distribution, slot.assign/slotchange, clonable/serializable roots, declarative hydration, connectivity/adoption, event retargeting/composed paths and focus. |
+| Forms and activation | Implemented and tested | Form/button/label/fieldset/legend associations, disabled first-legend exception, submit/reset/validation, FormData entry collection and events, cancelable mouse-click activation, label forwarding and implicit host submission. No network submission. |
+| Choice and value elements | Implemented and tested | Select/option/optgroup/datalist selection, live protected collections, selectedcontent snapshots, output reset/defaults, progress/meter numeric state and label associations; three unchanged selectedness WPT cases. |
+| Disclosure and dialog | Implemented and tested | Details/summary open state and named exclusivity; dialog show/showModal/requestClose/close, cancel/toggle/close events, return values, focus restoration and modal removal cleanup. |
+| Shared HTML state | Implemented and tested | Hidden/inert/autofocus, inherited contentEditable/plaintext editing, classList and output.htmlFor token methods with live attributes. Visibility has no CSS/layout component. |
+| 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 custom elements/ElementInternals, and complete specification edge-case coverage. |
+| Further DOM facilities | Planned but unfinished | CSS selector matching, complete traversal, NodeList/NamedNodeMap and DOMTokenList numeric/legacy properties, style declaration model, DocumentType/processing instructions/XML, complete serialization (including raw-text script/style). |
+| Further interaction | Planned but unfinished | IME/clipboard/rich-text editing, editing non-selection input types, shadow editable selection, browser-specific focus/CloseWatcher policy, popovers, file input values/files, localized validation UI and complete control algorithms. See focus and form 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. Interactive HTML adds forms, choice/value controls, details/dialog, host editing, manual slots, declarative shadows and document-local terminal factories. Browser differential observations and explicit divergences are recorded in [interactive standards](interactive-standards.md).
+6. 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..748d1d3
--- /dev/null
+++ b/packages/dom/docs/focus-and-controls.md
@@ -0,0 +1,145 @@
+# 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. Closed dialogs and collapsed
+details content are excluded; a details element's first summary remains eligible.
+An open modal dialog makes the rest of its document logically inert. There is no
+CSS visibility, geometry, platform focus preference, or browsing-context 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, restricted to the topmost modal dialog when one is present. Shadow
+delegation preserves an existing delegate and respects nested delegation boundaries.
+Autofocus runs in a queued task after connection, once per document, and preserves
+focus that was explicitly placed before that task. Dialog opening independently
+honors autofocus. Named radio groups contribute their checked member to sequential
+navigation, or their first eligible member when unchecked. Browser chrome,
+popover/navigation-context scopes 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 logical navigation. Enter activates
+buttons and summaries; Space activates buttons, summaries, checkboxes, and radios
+on an uncanceled matching keyup. Radio arrow navigation changes the focused and
+checked member. Select arrow/Home/End keys skip disabled options. Escape requests
+closure of the topmost eligible dialog. Text-input Enter commits a pending change
+and applies implicit form submission. The generated events remain untrusted.
+Dispatching a keyboard or focus event directly never performs these host actions.
+Byte decoding belongs to the host.
+
+Host typing and `dispatch.insertText(text)` edit selection-capable inputs and
+textareas. `beforeinput` can cancel the edit; a successful edit emits `input`.
+Readonly and disabled controls reject editing. Host insertion honors `maxlength`;
+script assignments remain unrestricted. Control edits emit `change` on blur or
+input Enter when the value has changed since the edit session began. Programmatic
+assignments do not independently start an edit session.
+
+Left/Right, Backspace, and Delete use grapheme boundaries while all exposed offsets
+remain UTF-16 code units. Shift extends selection, Home/End go to the start/end of
+the value, and Ctrl/Meta+A selects it. Textarea Enter inserts a line break. The host
+can call `dispatch.select(indices)` to commit an option selection from its own picker.
+IME composition orchestration, clipboard access, visual-line movement, and editing
+non-selection input types such as number/date/email remain unfinished.
+
+```ts
+input.focus();
+document.dispatch.insertText('hello');
+document.dispatch.keydown({ key: 'ArrowLeft', shiftKey: true });
+document.dispatch.insertText('!');
+```
+
+Connected `contenteditable` and `plaintext-only` editing hosts also accept host text
+insertion, range replacement, selection movement, deletion, and plaintext line
+breaks. Their caret is the document's live `Selection`; mixed text/element boundary
+points work without replacing the entire subtree. Input events target the editing
+host. As an explicit conservative editing policy, edits whose selected range
+intersects a `contenteditable=false` island are rejected. Rich-text formatting,
+atomic deletion of noneditable widgets, HTML paragraph creation, and editable
+selection inside shadow trees are unfinished. This is a defined plaintext editing
+subset, not a browser editing-engine compatibility claim.
+
+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.
+
+Details and dialogs follow the state/event algorithms in the
+[HTML interactive elements specification](https://html.spec.whatwg.org/multipage/interactive-elements.html).
+Details `open` changes queue a coalesced `ToggleEvent`; a nonempty `name` enforces
+one open member per tree. Canceling a summary's click prevents its toggle. Dialog
+`show()`/`showModal()` send cancelable opening `beforetoggle`, focus an autofocus
+candidate, the first eligible descendant, or the dialog itself, and queue `toggle`.
+`requestClose()` sends cancelable `cancel`; `close()` bypasses cancellation, updates
+`returnValue`, restores applicable prior focus, and queues `close`. Closing
+`beforetoggle` is not cancelable. Nested modal dialogs have independent focus
+restoration, and removing a dialog subtree releases modal state, including shadow
+descendants. State is owned by document-keyed weak maps.
+
+```ts
+const dialog = document.createElement('dialog');
+const accept = document.createElement('button');
+accept.textContent = 'Accept';
+accept.addEventListener('click', () => dialog.close('accepted'));
+dialog.append(accept);
+box.append(dialog);
+dialog.showModal();
+```
+
+One intentional standards surprise: removing a modal dialog's `open` attribute
+directly does not release its modal blocking. Use `close()` or remove the dialog.
+`closedBy` controls host Escape requests; an explicit `requestClose()` ignores it.
+Pointer light-dismiss, popovers, browser CloseWatcher activation-history policy,
+caret geometry, painting, and terminal width calculation are outside this tested
+scope.
diff --git a/packages/dom/docs/forms-and-elements.md b/packages/dom/docs/forms-and-elements.md
new file mode 100644
index 0000000..cd72dfa
--- /dev/null
+++ b/packages/dom/docs/forms-and-elements.md
@@ -0,0 +1,149 @@
+# Forms and element extensions
+
+`Document.createElement()` constructs actual specialized classes for `input`,
+`textarea`, `button`, `form`, `label`, `fieldset`, `legend`, `select`, `option`,
+`optgroup`, `datalist`, `selectedcontent`, `output`, `progress`, `meter`, `details`,
+`summary`, `dialog`, `template` and `slot`. Arbitrary names use the common Element
+base unless the document registers an application factory.
+
+## Forms without network submission
+
+```ts
+import { Document, FormDataEvent, SubmitEvent } from '@bomb.sh/dom';
+
+const document = new Document();
+const form = document.createElement('form');
+const name = document.createElement('input');
+name.name = 'name';
+name.required = true;
+const submit = document.createElement('button');
+submit.textContent = 'Continue';
+form.append(name, submit);
+document.append(form);
+
+form.addEventListener('submit', (event) => {
+ if (event instanceof SubmitEvent) console.info(event.submitter);
+});
+form.addEventListener('formdata', (event) => {
+ if (event instanceof FormDataEvent) console.info([...event.formData]);
+});
+name.value = 'Ada';
+form.requestSubmit(submit); // Validation ā submit ā formdata; no network request.
+```
+
+Controls resolve their form through ancestors or an explicit `form` ID in a
+connected document. Moving controls, changing IDs, and adoption update association.
+Labels resolve their control through `for` or a nested labelable element. Disabled
+fieldsets exclude descendants except those inside the first legend. Collections
+are live, protected arrays; they do not claim the browser's complete
+HTMLFormControlsCollection or HTMLOptionsCollection interface.
+
+`checkValidity()` and `reportValidity()` dispatch cancelable `invalid` events;
+reporting focuses an eligible uncanceled invalid control. There is no browser
+validation popup. Validity covers the implemented required/type/pattern/length/
+min/max/step constraints plus custom errors. Invalid values can still be assigned
+by application code. Minimum/maximum text length validity applies to user edits.
+
+`requestSubmit()` validates and sends a cancelable `SubmitEvent`. `submit()` bypasses
+validation and that event. Both construct an entry list and send `FormDataEvent`.
+`method="dialog"` closes the ancestor dialog after collecting entries. Canceling
+`submit` prevents those subsequent actions. Submitting a disconnected form does
+nothing. `reset()` sends a cancelable reset event and restores defaults without
+synthesizing input/change events. `new FormData(form, submitter)` can collect entries
+independently; it uses the host's actual FormData/Blob implementation (Node 22 in
+this workspace). Reentrant entry collection is rejected.
+
+Input/textarea dirty values and checkedness are distinct from attributes/defaults.
+Checkbox click activation happens before listeners and rolls back when canceled.
+Radio groups share a nonempty name, form owner and tree; cloned controls preserve
+current values, checkedness, dirty flags and input indeterminateness. Numeric/date
+input types support applicable `valueAsNumber`, `valueAsDate`, `stepUp` and `stepDown`.
+Programmatic changes invalidate connected state but do not synthesize input/change.
+See [focus and controls](focus-and-controls.md) for host editing and selection.
+
+File-input files/selection, picker UI, numeric editor display state (`badInput`),
+modern color alpha/color-space handling, form-associated custom elements,
+submission encoding/navigation, and complete HTML control properties are unfinished
+or outside the package's scope. No file-selection API or browser window is claimed.
+
+## Choices and calculated values
+
+```ts
+const select = document.createElement('select');
+select.name = 'mode';
+for (const value of ['quick', 'thorough']) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.text = value;
+ select.add(option);
+}
+form.append(select);
+select.value = 'thorough';
+select.focus();
+document.dispatch.select([0]); // Explicit host selection, with input/change events.
+
+const output = document.createElement('output');
+output.defaultValue = 'Ready';
+output.value = 'Running';
+form.append(output);
+form.reset(); // Restores output to Ready, alongside the other controls.
+```
+
+Single and multiple selects preserve option selectedness and dirtiness through
+mutation, cloning, reset and adoption. Disabled options/groups are excluded from
+host selection and form entries. `selectedcontent` mirrors selected option contents
+as snapshots; it is not a general live cloning facility. Datalist exposes options
+without a popup. Output exposes default/current values and `htmlFor` tokens.
+Progress supports determinate/indeterminate position; meter clamps its numeric
+range and thresholds. Painting is the renderer's responsibility.
+
+## Templates and shadow composition
+
+```ts
+import { installParsing } from '@bomb.sh/dom/parsing';
+installParsing(document);
+const container = document.createElement('div');
+container.setHTMLUnsafe(`
+
+`);
+console.info(container.getHTML({ serializableShadowRoots: true }));
+```
+
+`innerHTML` keeps shadow templates inert. `setHTMLUnsafe` explicitly enables
+declarative shadow parsing for hosts inside the parsed fragment; a top-level
+template does not attach a root to the existing context element. There is no
+sanitizer, and scripts remain inert. A same-mode `attachShadow` on a declarative
+root clears it and returns the same root for hydration. Clonable roots copy their
+shadow descendants even when the host clone is shallow. `getHTML` includes roots
+only when requested through `serializableShadowRoots` or an explicit `shadowRoots`
+list. Ordinary serialization omits them.
+
+```ts
+const host = document.createElement('box');
+const shadow = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
+const slot = document.createElement('slot');
+shadow.append(slot);
+const first = document.createElement('text');
+const second = document.createTextNode('second');
+host.append(first, second);
+slot.assign(second, first); // Assignment order; light-tree order stays unchanged.
+```
+
+Manual assignments use weak references and survive removal/reinsertion of the
+assigned node. Changing connected composition invalidates the host renderer and
+queues slotchange without inventing a tree mutation record. The package permits
+HTML terminal names as shadow hosts; this deliberately extends the browser's
+restricted valid-shadow-host-name list. SVG/MathML shadow hosts are rejected.
+Scoped registries, ElementInternals and the complete shadow/custom-element
+specification remain unfinished.
+
+Custom-element reactions belong to document-keyed weak state. Mutations share a
+bounded local queue across participating documents and drain it in `finally`;
+direct parser calls also own a scope, including failed parsing. Backup reactions
+receive their own microtask. There is no module-global strong pending-element
+queue that waits for an unrelated document mutation.
diff --git a/packages/dom/docs/interactive-standards.md b/packages/dom/docs/interactive-standards.md
new file mode 100644
index 0000000..9e23cb2
--- /dev/null
+++ b/packages/dom/docs/interactive-standards.md
@@ -0,0 +1,101 @@
+# Interactive element standards and differential checks
+
+The element algorithms were checked against the WHATWG HTML living standard
+dated 2026-10-26. This is a scoped implementation, not a claim of complete HTML
+element or Web Platform Test conformance.
+
+- [Select](https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element)
+ and [option](https://html.spec.whatwg.org/multipage/form-elements.html#the-option-element):
+ option lists, selectedness and dirtiness, default selection, reset, and disabled
+ options/groups. Collections use the package's live read-only array interface;
+ the select exposes `item`, `namedItem`, `add`, `remove`, and `length`.
+- [Selectedcontent](https://html.spec.whatwg.org/multipage/form-elements.html#the-selectedcontent-element):
+ cloning the selected option's contents. These are snapshots: editing an option's
+ text does not update the snapshot until another selection update. Removing a
+ selected option refreshes its snapshot in a microtask.
+- [Output](https://html.spec.whatwg.org/multipage/form-elements.html#the-output-element),
+ [progress](https://html.spec.whatwg.org/multipage/form-elements.html#the-progress-element),
+ and [meter](https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element):
+ default-value/reset modes and numeric reflection. Setting `progress.max` to a
+ nonpositive number leaves its attribute unchanged; setting the content
+ attribute directly still uses the getter's fallback.
+- [Input](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
+ and [DOM event dispatch](https://dom.spec.whatwg.org/#concept-event-dispatch):
+ checkedness, activation before listeners, cancellation rollback, and input/change
+ notifications. Plain `Event('click')` does not activate a checkbox;
+ `MouseEvent('click')` does. These synthetic events remain untrusted.
+
+The unchanged upstream
+[`option-selected.html`](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/html/semantics/forms/the-option-element/option-selected.html)
+contributes three passing tests for pristine and dirty options, alongside the
+existing event WPT subset. The pinned source and BSD license are in `test/wpt`.
+The upstream
+[`selected-index.html`](https://github.com/web-platform-tests/wpt/blob/b4b303c8dca22989c43e2d76fce199c36d4b77a7/html/semantics/forms/the-select-element/selected-index.html)
+was also consulted; that complete file is not claimed as passing because it
+requires the browser's `HTMLOptionsCollection` API.
+
+## Browser observations
+
+`test/browser/interactive-controls.html` is an independently written development
+fixture. Chrome **156.0.8078.73** on macOS matched all **15 recorded browser
+expectations** on 2026-10-28. Thirteen agree with the package; two cases explicitly
+record different package expectations. The fixture covers checkbox cancellation
+and cloning, click event classes, option dirtiness and attributes, output reset,
+numeric reflection and stepping, and selectedcontent timing. It neither runs nor
+requires a browser in package tests or at runtime.
+
+Run the fixture by opening it in a browser, or use the installed Chrome binary:
+
+```sh
+"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
+ --headless --disable-gpu --no-first-run \
+ --user-data-dir="$(mktemp -d /tmp/dom-controls.XXXXXX)" \
+ --dump-dom "file://$PWD/packages/dom/test/browser/interactive-controls.html"
+```
+
+Two observed Chrome differences deliberately follow the current standard:
+
+1. After `select.value` is set to a missing value, assigning `false` to an already
+ unselected option asks for a reset in the HTML algorithm and restores the first
+ eligible option. Chrome short-circuits that unchanged setter and keeps the
+ selection empty. A dedicated unit test preserves the standard reset behavior.
+2. When the previously selected radio changes groups during another radio's
+ canceled click, the HTML cancellation algorithm unchecks the activated radio.
+ Chrome leaves it checked. The package follows the specified rollback rule.
+
+Numeric stepping makes a separate interoperability choice. With `min="0.1"` and
+`step="0.2"`, starting at `0.2` and calling `stepUp(2)` yields `0.5`; from `0.5`,
+`stepUp(-1)` yields `0.3`. This agrees with the measured browser and honors the
+signed step count. The current
+[step algorithm](https://html.spec.whatwg.org/multipage/input.html#dom-input-stepup)
+literally omits `n` in its mismatch branch and checks direction by method name
+without accounting for negative `n`. Those branches are not claimed as literal
+specification matches.
+
+## Shadow parsing, cloning, and assignment
+
+`test/browser/shadow-extensions.html` adds **12 browser differential cases**, all
+matching Chrome 156.0.8078.73 on 2026-10-28. Run it using the same command above
+with `shadow-extensions.html` as the filename. These checks cover:
+
+- [DOM cloning](https://dom.spec.whatwg.org/#concept-node-clone): even a shallow
+ host clone deeply copies its clonable shadow tree. Custom-element constructors
+ run after the attributes and trees have been copied, so constructors can reuse
+ an existing shadow root.
+- [Declarative shadow parsing](https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead):
+ `innerHTML` preserves ordinary templates. `setHTMLUnsafe` recognizes declarative
+ templates beneath newly parsed hosts; a top-level template does not attach a
+ root to the fragment's context element.
+- [Shadow attachment](https://dom.spec.whatwg.org/#concept-attach-a-shadow-root):
+ same-mode hydration reuses and clears the declarative root, preserving its
+ flags. A mode mismatch or a second attachment is rejected.
+- [HTML serialization](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#html-serialization-methods):
+ closed roots are included only through serializable-root or explicit-root
+ options. The synthetic template attributes follow the standard's order.
+- [Slot assignment](https://html.spec.whatwg.org/multipage/scripting.html#dom-slot-assign):
+ manual ordering, transfer, reconnection, prevalidated slottables, removal
+ notifications, and the template slot-assignment property's named default.
+
+These are development checks on the supported algorithms. They do not imply
+support for scoped custom-element registries, browser layout, or the complete
+shadow-tree Web Platform Test suite.
diff --git a/packages/dom/docs/provenance.md b/packages/dom/docs/provenance.md
new file mode 100644
index 0000000..3bb796e
--- /dev/null
+++ b/packages/dom/docs/provenance.md
@@ -0,0 +1,65 @@
+# 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.
+
+## Interactive HTML milestone
+
+The new form, choice/value, disclosure/dialog, activation and editing algorithms
+are original implementations checked against the current WHATWG HTML standard.
+Three unchanged pinned option WPT cases supplement the event subset; their source
+and license are recorded in [test/wpt](../test/wpt/README.md). Development browser
+comparisons, standards conflicts and explicit interoperability choices are in
+[interactive standards](interactive-standards.md). They cover controls plus manual
+slots, declarative hydration, serialization and custom-element cloning. No browser
+or upstream DOM implementation is used at runtime.
+
+The package additionally permits arbitrary HTML terminal names as shadow hosts
+and document-local element factories. These extensions and the bounded reaction
+queue ownership model are described in [forms and elements](forms-and-elements.md).
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..935866a
--- /dev/null
+++ b/packages/dom/src/controls.ts
@@ -0,0 +1,722 @@
+import type { Element, HTMLInputElement, Node } from './dom.ts';
+import { formOwner, formTree, type ValidityFlags } from './forms.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;
+ sanitize?: (value: string) => string;
+}
+
+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;
+ #userEdited = false;
+
+ constructor(options: TextControlOptions) {
+ this.#options = options;
+ controls.set(options.element, this);
+ }
+
+ get userEdited(): boolean {
+ return this.#userEdited;
+ }
+ markUserEdited(): void {
+ if (this.#userEdited) return;
+ this.#userEdited = true;
+ this.#invalidate('mutation');
+ }
+ reset(): void {
+ const previous = this.value;
+ const wasUserEdited = this.#userEdited;
+ this.#dirty = false;
+ this.#userEdited = false;
+ this.defaultValueChanged();
+ if (previous !== this.value || wasUserEdited) this.#invalidate('mutation');
+ }
+ resanitize(): void {
+ const previous = this.#value;
+ this.#value = this.#normalize(previous);
+ if (previous !== this.#value) this.#invalidate('mutation');
+ }
+ 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.#userEdited = source.#userEdited;
+ 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;
+ const wasUserEdited = this.#userEdited;
+ this.#dirty = true;
+ this.#userEdited = false;
+ this.#value = normalized;
+ if (normalized === previous) {
+ if (wasUserEdited) this.#invalidate('mutation');
+ return;
+ }
+ if (this.#supported)
+ 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 wasUserEdited = this.#userEdited;
+ 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 rawReplacement = String(replacement);
+ const added = this.#options.multiline
+ ? rawReplacement.replace(/\r\n?/g, '\n')
+ : rawReplacement.replace(/[\r\n]/g, '');
+ let selectedStart = this.selectionStart ?? 0;
+ let selectedEnd = this.selectionEnd ?? 0;
+ this.#dirty = true;
+ this.#userEdited = false;
+ this.#value = this.#normalize(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 || wasUserEdited) 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 {
+ if (this.#options.sanitize) return this.#options.sanitize(value);
+ 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);
+ inputStates.get(copy)?.copyFrom(inputStates.get(source));
+}
+
+/** @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();
+ }
+});
+
+/** @internal User editing and script assignment have different length-validity rules. */
+export function markControlUserEdited(element: Element): void {
+ controls.get(element)?.markUserEdited();
+}
+
+const inputStates = new WeakMap();
+const defaultValueTypes = new Set(['hidden', 'submit', 'image', 'reset', 'button']);
+const checkableTypes = new Set(['checkbox', 'radio']);
+const readonlyTypes = new Set([
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+]);
+export function inputValueMode(type: string): 'value' | 'default' | 'default/on' | 'filename' {
+ return defaultValueTypes.has(type)
+ ? 'default'
+ : checkableTypes.has(type)
+ ? 'default/on'
+ : type === 'file'
+ ? 'filename'
+ : 'value';
+}
+export function supportsReadOnly(type: string): boolean {
+ return readonlyTypes.has(type);
+}
+
+/** Input checkedness has a separate dirty flag from its value. */
+export class InputControlState {
+ readonly element: HTMLInputElement;
+ readonly #text: TextControlState;
+ #checked = false;
+ #dirtyChecked = false;
+ #indeterminate = false;
+ #type = 'text';
+ readonly #activationChanges: boolean[] = [];
+ constructor(element: HTMLInputElement, text: TextControlState) {
+ this.element = element;
+ this.#text = text;
+ inputStates.set(element, this);
+ }
+ get checked(): boolean {
+ return this.#checked;
+ }
+ set checked(value: boolean) {
+ this.#dirtyChecked = true;
+ this.#setChecked(Boolean(value));
+ }
+ get indeterminate(): boolean {
+ return this.#indeterminate;
+ }
+ set indeterminate(value: boolean) {
+ if (this.#indeterminate === Boolean(value)) return;
+ this.#indeterminate = Boolean(value);
+ this.#invalidate();
+ }
+ reset(): void {
+ this.#text.reset();
+ this.#dirtyChecked = false;
+ this.#setChecked(this.element.defaultChecked);
+ }
+ copyFrom(source: InputControlState | undefined): void {
+ if (!source) return;
+ this.#checked = source.#checked;
+ this.#dirtyChecked = source.#dirtyChecked;
+ this.#indeterminate = source.#indeterminate;
+ }
+ attributeChanged(name: string): void {
+ if (name === 'checked' && !this.#dirtyChecked) this.#setChecked(this.element.defaultChecked);
+ if (name === 'type') {
+ const wasSelectable = ['text', 'search', 'url', 'tel', 'password'].includes(this.#type);
+ const mode = inputValueMode(this.element.type);
+ const oldMode = inputValueMode(this.#type);
+ if (oldMode === 'value' && (mode === 'default' || mode === 'default/on') && this.#text.value)
+ this.element.setAttribute('value', this.#text.value);
+ else if (mode === 'value' && oldMode !== 'value') this.#text.reset();
+ else if (mode === 'filename') this.#text.value = '';
+ this.#type = this.element.type;
+ this.#text.resanitize();
+ if (!wasSelectable && ['text', 'search', 'url', 'tel', 'password'].includes(this.#type))
+ this.#text.setSelectionRange(0, 0);
+ }
+ if (['min', 'max', 'step', 'multiple'].includes(name)) this.#text.resanitize();
+ if (['type', 'name', 'form'].includes(name) && this.checked) this.enforceRadioGroup();
+ }
+ enforceRadioGroup(): void {
+ if (!this.checked || this.element.type !== 'radio') return;
+ for (const peer of radioGroup(this.element)) {
+ const state = inputStates.get(peer);
+ if (state && state !== this) state.#setChecked(false);
+ }
+ }
+ completeActivation(): boolean {
+ return this.#activationChanges.pop() ?? false;
+ }
+ preActivate(): (() => void) | null {
+ this.#activationChanges.push(
+ this.element.type === 'checkbox' || (this.element.type === 'radio' && !this.checked),
+ );
+ if (!checkableTypes.has(this.element.type))
+ return () => {
+ this.#activationChanges.pop();
+ };
+ const checked = this.#checked;
+ const indeterminate = this.#indeterminate;
+ const previousRadio =
+ this.element.type === 'radio'
+ ? radioGroup(this.element).find((peer) => peer.checked)
+ : undefined;
+ this.checked = this.element.type === 'radio' || !this.checked;
+ if (this.element.type === 'checkbox') this.indeterminate = false;
+ return () => {
+ this.#activationChanges.pop();
+ if (this.element.type === 'checkbox') {
+ this.#setChecked(checked);
+ this.indeterminate = indeterminate;
+ } else if (this.element.type === 'radio') {
+ if (previousRadio && radioGroup(this.element).includes(previousRadio)) {
+ const previousState = inputStates.get(previousRadio);
+ if (previousState) previousState.#setChecked(true);
+ } else this.#setChecked(false);
+ }
+ };
+ }
+ #setChecked(value: boolean): void {
+ const changed = value !== this.#checked;
+ this.#checked = value;
+ if (value) this.enforceRadioGroup();
+ if (changed) this.#invalidate();
+ }
+ #invalidate(): void {
+ if (this.element.isConnected)
+ this.element.ownerDocument.invalidate({ kind: 'mutation', target: this.element });
+ }
+}
+
+export function radioGroup(element: HTMLInputElement): readonly HTMLInputElement[] {
+ if (element.type !== 'radio' || !element.name) return [element];
+ const owner = formOwner(element);
+ return formTree(element.getRootNode()).flatMap((candidate) => {
+ const peer = inputStates.get(candidate)?.element;
+ return peer && peer.type === 'radio' && peer.name === element.name && formOwner(peer) === owner
+ ? [peer]
+ : [];
+ });
+}
+
+subscribeMutations((record) => {
+ if (record.type === 'attributes') {
+ const state = inputStates.get(record.target);
+ state?.attributeChanged(record.attributeName ?? '');
+ if (!['id', 'form'].includes(record.attributeName ?? '')) return;
+ }
+ if (record.type !== 'childList' && record.type !== 'attributes') return;
+ const roots =
+ record.type === 'childList'
+ ? [...(record.addedNodes ?? []), record.target.getRootNode(), ...(record.removedNodes ?? [])]
+ : [record.target.getRootNode()];
+ for (const root of roots)
+ for (const element of formTree(root)) inputStates.get(element)?.enforceRadioGroup();
+});
+
+const floatingPoint = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;
+function numberValue(value: string): number {
+ return floatingPoint.test(value) && Number.isFinite(Number(value)) ? Number(value) : NaN;
+}
+const day = 86400000;
+// oxlint-disable-next-line bombshell-dev/max-params -- Date construction uses the standard year/month/day components.
+function utcDate(year: number, month: number, date: number): number {
+ const value = new Date(0);
+ value.setUTCFullYear(year, month, date);
+ return value.getTime();
+}
+function dateNumber(value: string): number {
+ const match = /^(\d{4,})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match) return NaN;
+ const year = Number(match[1]),
+ month = Number(match[2]),
+ date = Number(match[3]);
+ const number = utcDate(year, month - 1, date);
+ const parsed = new Date(number);
+ return year > 0 &&
+ parsed.getUTCFullYear() === year &&
+ parsed.getUTCMonth() === month - 1 &&
+ parsed.getUTCDate() === date
+ ? number
+ : NaN;
+}
+function timeNumber(value: string): number {
+ const match = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(value);
+ if (!match) return NaN;
+ const hours = Number(match[1]),
+ minutes = Number(match[2]),
+ seconds = Number(match[3] ?? 0);
+ return hours < 24 && minutes < 60 && seconds < 60
+ ? ((hours * 60 + minutes) * 60 + seconds) * 1000 + Number((match[4] ?? '').padEnd(3, '0'))
+ : NaN;
+}
+function weekStart(year: number): number {
+ const jan4 = utcDate(year, 0, 4);
+ return jan4 - ((new Date(jan4).getUTCDay() + 6) % 7) * day;
+}
+export function inputValueAsNumber(type: string, value: string): number {
+ if (type === 'number' || type === 'range') return numberValue(value);
+ if (type === 'date') return dateNumber(value);
+ if (type === 'time') return timeNumber(value);
+ if (type === 'datetime-local') {
+ const parts = value.split(/[T ]/);
+ return parts.length === 2 ? dateNumber(parts[0] ?? '') + timeNumber(parts[1] ?? '') : NaN;
+ }
+ if (type === 'month') {
+ const match = /^(\d{4,})-(\d{2})$/.exec(value);
+ if (!match || Number(match[1]) < 1 || Number(match[2]) < 1 || Number(match[2]) > 12) return NaN;
+ return (Number(match[1]) - 1970) * 12 + Number(match[2]) - 1;
+ }
+ if (type === 'week') {
+ const match = /^(\d{4,})-W(\d{2})$/.exec(value);
+ if (!match || Number(match[1]) < 1) return NaN;
+ const year = Number(match[1]),
+ week = Number(match[2]);
+ const time = weekStart(year) + (week - 1) * 7 * day;
+ return week >= 1 && time < weekStart(year + 1) ? time : NaN;
+ }
+ return NaN;
+}
+const numericTypes = new Set([
+ 'number',
+ 'range',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+]);
+export function supportsValueAsNumber(type: string): boolean {
+ return numericTypes.has(type);
+}
+function pad(value: number, length = 2): string {
+ return String(value).padStart(length, '0');
+}
+export function inputValueFromNumber(type: string, value: number): string {
+ if (!Number.isFinite(value) && !Number.isNaN(value)) {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- The HTML numeric setter rejects infinity before checking the type.
+ throw new TypeError('The numeric value must be finite');
+ }
+ if (!numericTypes.has(type))
+ throw new DOMException('This input type does not support numeric values', 'InvalidStateError');
+ if (Number.isNaN(value)) return '';
+ if (type === 'number' || type === 'range') return String(value);
+ if (type === 'month') {
+ const year = 1970 + Math.floor(value / 12);
+ return year > 0 ? `${pad(year, 4)}-${pad((((Math.trunc(value) % 12) + 12) % 12) + 1)}` : '';
+ }
+ const date = new Date(value);
+ if (!Number.isFinite(date.getTime()) || date.getUTCFullYear() < 1) return '';
+ const datePart = `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
+ if (type === 'date') return datePart;
+ if (type === 'week') {
+ const thursday = new Date(
+ utcDate(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +
+ (3 - ((date.getUTCDay() + 6) % 7)) * day,
+ );
+ const year = thursday.getUTCFullYear();
+ return `${pad(year, 4)}-W${pad(Math.floor((thursday.getTime() - weekStart(year)) / (7 * day)) + 1)}`;
+ }
+ const seconds = date.getUTCSeconds(),
+ millis = date.getUTCMilliseconds();
+ const time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}${seconds || millis ? `:${pad(seconds)}` : ''}${millis ? `.${pad(millis, 3).replace(/0+$/, '')}` : ''}`;
+ return type === 'time' ? time : `${datePart}T${time}`;
+}
+export function inputValueAsDate(type: string, value: string): Date | null {
+ if (!['date', 'month', 'week', 'time'].includes(type)) return null;
+ const number = inputValueAsNumber(type, value);
+ if (!Number.isFinite(number)) return null;
+ return new Date(
+ type === 'month'
+ ? utcDate(1970 + Math.floor(number / 12), ((number % 12) + 12) % 12, 1)
+ : number,
+ );
+}
+export function inputValueFromDate(type: string, value: Date | null): string {
+ if (!['date', 'month', 'week', 'time'].includes(type))
+ throw new DOMException('This input type does not support dates', 'InvalidStateError');
+ if (value === null) return '';
+ const date = new Date(Date.prototype.getTime.call(value));
+ if (!Number.isFinite(date.getTime())) return '';
+ return inputValueFromNumber(
+ type,
+ type === 'month' ? (date.getUTCFullYear() - 1970) * 12 + date.getUTCMonth() : date.getTime(),
+ );
+}
+
+function stepParameters(element: HTMLInputElement): {
+ step: number;
+ base: number;
+ min: number;
+ max: number;
+} {
+ const type = element.type;
+ const scale =
+ type === 'date'
+ ? day
+ : type === 'week'
+ ? 7 * day
+ : ['time', 'datetime-local'].includes(type)
+ ? 1000
+ : 1;
+ const fallback = ['time', 'datetime-local'].includes(type) ? 60 : 1;
+ const attribute = element.step.toLowerCase();
+ const rawStep = numberValue(attribute);
+ const step = attribute === 'any' ? NaN : (rawStep > 0 ? rawStep : fallback) * scale;
+ const min = inputValueAsNumber(type, element.min),
+ max = inputValueAsNumber(type, element.max);
+ const defaultNumber = inputValueAsNumber(type, element.defaultValue);
+ return {
+ step,
+ base: Number.isFinite(min)
+ ? min
+ : Number.isFinite(defaultNumber)
+ ? defaultNumber
+ : type === 'week'
+ ? -259200000
+ : 0,
+ min: Number.isFinite(min) ? min : type === 'range' ? 0 : -Infinity,
+ max: Number.isFinite(max) ? max : type === 'range' ? 100 : Infinity,
+ };
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Numeric step arithmetic has independent value, base, and quantum arguments.
+function stepMismatch(value: number, base: number, step: number): boolean {
+ if (!Number.isFinite(value) || !Number.isFinite(step)) return false;
+ const quotient = (value - base) / step;
+ return Math.abs(quotient - Math.round(quotient)) > 1e-7;
+}
+export function sanitizeInputValue(element: HTMLInputElement, value: string): string {
+ const type = element.type;
+ if (['text', 'search', 'tel', 'password', 'email', 'url'].includes(type))
+ value = value.replace(/[\r\n]/g, '');
+ if (type === 'url' || type === 'email') {
+ const normalized = value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '');
+ return type === 'email' && element.multiple
+ ? normalized
+ .split(',')
+ .map((part) => part.trim())
+ .join(',')
+ : normalized;
+ }
+ if (type === 'color') return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : '#000000';
+ if (!numericTypes.has(type)) return value;
+ let number = inputValueAsNumber(type, value);
+ if (type !== 'range') {
+ if (!Number.isFinite(number)) return '';
+ return type === 'datetime-local' ? inputValueFromNumber(type, number) : value;
+ }
+ const { min, max, base, step } = stepParameters(element);
+ if (!Number.isFinite(number)) number = max < min ? min : min + (max - min) / 2;
+ number = Math.max(min, Math.min(max, number));
+ if (Number.isFinite(step) && stepMismatch(number, base, step)) {
+ const lower = base + Math.floor((number - base) / step) * step;
+ const upper = lower + step;
+ number =
+ upper <= max && (lower < min || number - lower >= upper - number)
+ ? upper
+ : lower >= min
+ ? lower
+ : number;
+ }
+ return String(number);
+}
+export function stepInputValue(element: HTMLInputElement, amount: number): void {
+ if (!numericTypes.has(element.type))
+ throw new DOMException('This input type cannot step', 'InvalidStateError');
+ const { min, max, base, step } = stepParameters(element);
+ if (!Number.isFinite(step))
+ throw new DOMException('An input with step=any cannot step', 'InvalidStateError');
+ if (min > max) return;
+ const count = Number(amount) | 0;
+ let value = element.valueAsNumber;
+ if (!Number.isFinite(value)) value = 0;
+ const previous = value;
+ if (stepMismatch(value, base, step)) {
+ value =
+ base +
+ (count < 0 ? Math.floor((value - base) / step) : Math.ceil((value - base) / step)) * step;
+ value += (count < 0 ? Math.min(0, count + 1) : Math.max(0, count - 1)) * step;
+ } else value += count * step;
+ if (value < min) value = base + Math.ceil((min - base) / step) * step;
+ if (value > max) value = base + Math.floor((max - base) / step) * step;
+ if (
+ value < min ||
+ value > max ||
+ (count > 0 && value < previous) ||
+ (count < 0 && value > previous)
+ )
+ return;
+ element.valueAsNumber = Number(value.toPrecision(15));
+}
+
+export function textLengthValidity(element: Element, value: string): ValidityFlags {
+ if (!controls.get(element)?.userEdited) return {};
+ const lengthAttribute = (name: string): number => {
+ const raw = element.getAttribute(name) ?? '';
+ return /^\s*\+?\d+/.test(raw) ? Number.parseInt(raw, 10) : -1;
+ };
+ const min = lengthAttribute('minlength'),
+ max = lengthAttribute('maxlength');
+ return {
+ tooLong: max >= 0 && value.length > max,
+ tooShort: value.length > 0 && min >= 0 && value.length < min,
+ };
+}
+export function inputValidity(element: HTMLInputElement): ValidityFlags {
+ const type = element.type,
+ value = element.value;
+ const flags: ValidityFlags = {};
+ const required = element.required && !(element.readOnly && supportsReadOnly(type));
+ if (type === 'checkbox') flags.valueMissing = required && !element.checked;
+ else if (type === 'radio') {
+ const group = radioGroup(element);
+ flags.valueMissing = group.some((peer) => peer.required) && !group.some((peer) => peer.checked);
+ } else if (!['hidden', 'range', 'color', 'submit', 'image', 'reset', 'button'].includes(type))
+ flags.valueMissing = required && !value;
+ if (type === 'email' && value) {
+ const email =
+ /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
+ flags.typeMismatch = (element.multiple ? value.split(',') : [value]).some(
+ (part) => !email.test(part),
+ );
+ }
+ if (type === 'url' && value) {
+ flags.typeMismatch = !URL.canParse(value);
+ }
+ if (['text', 'search', 'tel', 'url', 'email', 'password'].includes(type)) {
+ Object.assign(flags, textLengthValidity(element, value));
+ const pattern = element.getAttribute('pattern');
+ if (pattern !== null && value) {
+ try {
+ flags.patternMismatch = !new RegExp(`^(?:${pattern})$`, 'v').test(value);
+ } catch {
+ /* An invalid author pattern imposes no constraint. */
+ }
+ }
+ }
+ if (numericTypes.has(type) && value) {
+ const number = element.valueAsNumber;
+ const { min, max, base, step } = stepParameters(element);
+ if (type === 'time' && min > max) {
+ flags.rangeUnderflow = flags.rangeOverflow = number > max && number < min;
+ } else {
+ flags.rangeUnderflow = number < min;
+ flags.rangeOverflow = number > max;
+ }
+ flags.stepMismatch = stepMismatch(number, base, step);
+ }
+ return flags;
+}
diff --git a/packages/dom/src/custom-elements.ts b/packages/dom/src/custom-elements.ts
new file mode 100644
index 0000000..ae8fecf
--- /dev/null
+++ b/packages/dom/src/custom-elements.ts
@@ -0,0 +1,365 @@
+/* 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();
+interface ReactionScope {
+ scopes: Element[][];
+ pending: Element[];
+ scheduled: boolean;
+}
+const environments = new WeakMap();
+function environment(document: Document): ReactionScope {
+ let state = environments.get(document);
+ if (!state) {
+ state = { scopes: [], pending: [], scheduled: false };
+ environments.set(document, state);
+ }
+ return state;
+}
+
+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) {
+ const document = element.ownerDocument;
+ const state = environment(document);
+ const scope = state.scopes.at(-1);
+ if (scope) scope.push(element);
+ else {
+ state.pending.push(element);
+ if (!state.scheduled) {
+ state.scheduled = true;
+ queueMicrotask(() => {
+ state.scheduled = false;
+ flushReactions(document);
+ });
+ }
+ }
+ }
+}
+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) {
+ try {
+ element.ownerDocument.reportError(error);
+ } catch (reportingError) {
+ queueMicrotask(() => {
+ throw reportingError;
+ });
+ }
+ }
+ }
+ }
+}
+/** @internal Complete one DOM operation before invoking its custom-element reactions. */
+export function withReactions(documents: Document | readonly Document[], operation: () => T): T {
+ const queue: Element[] = [];
+ const states = [...new Set(Array.isArray(documents) ? documents : [documents])].map(environment);
+ for (const state of states) state.scopes.push(queue);
+ try {
+ return operation();
+ } finally {
+ for (const state of states) state.scopes.pop();
+ invoke(queue);
+ }
+}
+/** @internal Parsing joins its caller's mutation scope, or owns one for direct parser calls. */
+export function withParsingReactions(document: Document, operation: () => T): T {
+ return environment(document).scopes.length ? operation() : withReactions(document, operation);
+}
+/** @internal Drain only this document's backup reactions. */
+export function flushReactions(document: Document): void {
+ const state = environment(document);
+ if (state.scopes.length) return;
+ const queue = state.pending;
+ state.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(this.#document, () => {
+ 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.#document, () => 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..acb65ef
--- /dev/null
+++ b/packages/dom/src/dom.ts
@@ -0,0 +1,2023 @@
+import { EventTarget, Event, MouseEvent, type Event as DOMEvent } from './events.ts';
+import { defineFormElements } from './form-elements.ts';
+import { DOMTokenList } from './tokens.ts';
+import { defineValueElements, cloneValueElementState } from './value-elements.ts';
+import { defineInteractiveElements, activateInteractive } from './interactive-elements.ts';
+import {
+ notifyBeforeRemove,
+ notifyInsert,
+ notifyMutation,
+ notifyTextChange,
+ notifyTextSplit,
+} from './hooks.ts';
+
+import {
+ CustomElementRegistry,
+ customConnection,
+ customAttribute,
+ customAdopted,
+ flushReactions,
+ enqueueUpgrade,
+ 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,
+ InputControlState,
+ sanitizeInputValue,
+ inputValidity,
+ inputValueMode,
+ inputValueAsNumber,
+ inputValueFromNumber,
+ inputValueAsDate,
+ inputValueFromDate,
+ stepInputValue,
+ supportsReadOnly,
+ textLengthValidity,
+ type SelectionDirection,
+ type SelectionMode,
+} from './controls.ts';
+import {
+ formOwner,
+ formTree,
+ labelsFor,
+ isDisabled,
+ inDatalist,
+ registerControl,
+ validityFor,
+ validationMessageFor,
+ setControlValidity,
+ validateControl,
+ type ValidityState,
+} from './forms.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,
+ options?: { declarativeShadowRoots?: boolean },
+ ): DocumentFragment;
+ serialize(node: Node, options?: HTMLSerializationOptions): string;
+}
+export interface HTMLSerializationOptions {
+ serializableShadowRoots?: boolean;
+ shadowRoots?: readonly ShadowRoot[];
+}
+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;
+ button: HTMLButtonElement;
+ form: HTMLFormElement;
+ label: HTMLLabelElement;
+ fieldset: HTMLFieldSetElement;
+ legend: HTMLLegendElement;
+ select: HTMLSelectElement;
+ option: HTMLOptionElement;
+ optgroup: HTMLOptGroupElement;
+ datalist: HTMLDataListElement;
+ selectedcontent: HTMLSelectedContentElement;
+ output: HTMLOutputElement;
+ progress: HTMLProgressElement;
+ meter: HTMLMeterElement;
+ details: HTMLDetailsElement;
+ summary: HTMLSummaryElement;
+ dialog: HTMLDialogElement;
+}
+
+/** 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(reactionDocuments(this.document, [node]), () =>
+ 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.document, () => 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(reactionDocuments(this.document, [node]), () =>
+ 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(reactionDocuments(this.document, nodes), () => 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(reactionDocuments(this.document, nodes), () => 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(reactionDocuments(this.document, nodes), () => 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(reactionDocuments(this.document, nodes), () => 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(reactionDocuments(this.document, nodes), () => 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(reactionDocuments(this.document, nodes), () => 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 withReactions(this.document, () => 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;
+ readonly #elementFactories = new Map Element>();
+ 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;
+ const factory = this.#elementFactories.get(localName);
+ if (factory) {
+ const element = factory(this);
+ if (
+ !(element instanceof Element) ||
+ element.ownerDocument !== this ||
+ element.localName !== localName ||
+ element.namespaceURI !== HTML_NAMESPACE ||
+ element.parentNode
+ )
+ throw new DOMException(
+ 'Element factory returned an incompatible node',
+ 'NotSupportedError',
+ );
+ return element;
+ }
+ 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);
+ const Constructor = Object.hasOwn(specializedElements, localName)
+ ? specializedElements[localName]
+ : undefined;
+ if (Constructor) return new Constructor(this);
+ return new Element(localName, this);
+ }
+ /** Register an explicit document-local factory for terminal element names. */
+ registerElement(name: string, factory: (document: Document) => Element): void {
+ const normalized = String(name).toLowerCase();
+ validateName(normalized);
+ if (
+ normalized.includes('-') ||
+ ['input', 'textarea', 'template', 'slot'].includes(normalized) ||
+ Object.hasOwn(specializedElements, normalized) ||
+ this.#elementFactories.has(normalized)
+ )
+ throw new DOMException('Element name is reserved or already registered', 'NotSupportedError');
+ this.#elementFactories.set(normalized, factory);
+ }
+ 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(reactionDocuments(this, [node]), () => 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(this);
+ return node;
+ }
+ importNode(node: T, deep = false): Node {
+ if (node instanceof Document || node instanceof ShadowRoot)
+ throw new DOMException('Cannot import this node', 'NotSupportedError');
+ return withReactions(this, () => 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);
+ #clickInProgress = false;
+ #classList: DOMTokenList | null = null;
+
+ 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,
+ );
+ }
+ getHTML(options: HTMLSerializationOptions = {}): string {
+ return this.ownerDocument.markupParser.serialize(
+ this instanceof HTMLTemplateElement ? this.content : this,
+ options,
+ );
+ }
+ set innerHTML(value: string) {
+ withReactions(this.ownerDocument, () => {
+ const fragment = this.ownerDocument.markupParser.parse(this, String(value));
+ (this instanceof HTMLTemplateElement ? this.content : this).replaceChildren(fragment);
+ });
+ }
+ /** Parse HTML including declarative shadow roots; scripts remain inert. */
+ setHTMLUnsafe(value: string): void {
+ withReactions(this.ownerDocument, () => {
+ const fragment = this.ownerDocument.markupParser.parse(this, String(value), {
+ declarativeShadowRoots: true,
+ });
+ (this instanceof HTMLTemplateElement ? this.content : this).replaceChildren(fragment);
+ });
+ }
+ get hidden(): boolean {
+ return this.hasAttribute('hidden');
+ }
+ set hidden(value: boolean) {
+ this.toggleAttribute('hidden', Boolean(value));
+ }
+ get inert(): boolean {
+ return this.hasAttribute('inert');
+ }
+ set inert(value: boolean) {
+ this.toggleAttribute('inert', Boolean(value));
+ }
+ get autofocus(): boolean {
+ return this.hasAttribute('autofocus');
+ }
+ set autofocus(value: boolean) {
+ this.toggleAttribute('autofocus', Boolean(value));
+ }
+ get contentEditable(): string {
+ const value = this.getAttribute('contenteditable')?.toLowerCase();
+ return value === '' || value === 'true'
+ ? 'true'
+ : value === 'false' || value === 'plaintext-only'
+ ? value
+ : 'inherit';
+ }
+ set contentEditable(value: string) {
+ const normalized = String(value).toLowerCase();
+ if (!['true', 'false', 'plaintext-only', 'inherit'].includes(normalized))
+ throw new DOMException('Invalid editable state', 'SyntaxError');
+ if (normalized === 'inherit') this.removeAttribute('contenteditable');
+ else this.setAttribute('contenteditable', normalized);
+ }
+ get isContentEditable(): boolean {
+ return this.contentEditable === 'inherit'
+ ? (this.parentElement?.isContentEditable ?? false)
+ : this.contentEditable !== 'false';
+ }
+ click(): void {
+ if (this.#clickInProgress || this.activationDisabled()) return;
+ this.#clickInProgress = true;
+ try {
+ this.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }),
+ );
+ } finally {
+ this.#clickInProgress = false;
+ }
+ }
+ protected override hasActivationBehavior(): boolean {
+ return (
+ this.namespaceURI === HTML_NAMESPACE &&
+ ['input', 'button', 'label', 'summary'].includes(this.localName)
+ );
+ }
+ protected activationDisabled(): boolean {
+ return false;
+ }
+ protected override activationBehavior(_event: Event, target: EventTarget | null): void {
+ activateInteractive(this, target);
+ }
+ 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') ?? '';
+ }
+ get classList(): DOMTokenList {
+ return (this.#classList ??= new DOMTokenList(this, '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(this.ownerDocument);
+ }
+ attachShadow(options: ShadowRootInit): ShadowRoot {
+ if (
+ !['open', 'closed'].includes(options.mode) ||
+ (options.slotAssignment !== undefined &&
+ !['named', 'manual'].includes(options.slotAssignment))
+ )
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL enum conversion throws TypeError.
+ throw new TypeError('Invalid shadow root options');
+ if (this.namespaceURI !== HTML_NAMESPACE)
+ throw new DOMException('Shadow hosts must be HTML elements', 'NotSupportedError');
+ const existing = shadowRoots.get(this);
+ if (existing) {
+ if (!declarativeRoots.has(existing) || existing.mode !== options.mode)
+ throw new DOMException('Shadow root already attached', 'NotSupportedError');
+ declarativeRoots.delete(existing);
+ existing.replaceChildren();
+ return existing;
+ }
+ const root = new ShadowRoot(this, options);
+ shadowRoots.set(this, root);
+ this.notifyHost();
+ this.ownerDocument.focusManager.revalidate();
+ 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 interface ShadowRootInit {
+ mode: 'open' | 'closed';
+ delegatesFocus?: boolean;
+ slotAssignment?: 'named' | 'manual';
+ clonable?: boolean;
+ serializable?: boolean;
+}
+const declarativeRoots = new WeakSet();
+/** @internal Preserve declarative-root hydration semantics without exposing mutable state. */
+export function markDeclarativeShadowRoot(root: ShadowRoot): void {
+ declarativeRoots.add(root);
+}
+export class ShadowRoot extends DocumentFragment {
+ readonly host: Element;
+ readonly mode: 'open' | 'closed';
+ readonly delegatesFocus: boolean;
+ readonly slotAssignment: 'named' | 'manual';
+ readonly clonable: boolean;
+ readonly serializable: boolean;
+ constructor(host: Element, options: ShadowRootInit) {
+ super(host.ownerDocument);
+ this.host = host;
+ this.mode = options.mode;
+ this.delegatesFocus = options.delegatesFocus ?? false;
+ this.slotAssignment = options.slotAssignment ?? 'named';
+ this.clonable = Boolean(options.clonable);
+ this.serializable = Boolean(options.serializable);
+ }
+ get innerHTML(): string {
+ return this.host.ownerDocument.markupParser.serialize(this);
+ }
+ getHTML(options: HTMLSerializationOptions = {}): string {
+ return this.host.ownerDocument.markupParser.serialize(this, options);
+ }
+ set innerHTML(value: string) {
+ withReactions(this.host.ownerDocument, () =>
+ this.replaceChildren(this.host.ownerDocument.markupParser.parse(this, String(value))),
+ );
+ }
+ setHTMLUnsafe(value: string): void {
+ withReactions(this.host.ownerDocument, () =>
+ this.replaceChildren(
+ this.host.ownerDocument.markupParser.parse(this, String(value), {
+ declarativeShadowRoots: true,
+ }),
+ ),
+ );
+ }
+ 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);
+ if (node.ownerDocument) flushReactions(node.ownerDocument);
+}
+// 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 customCandidate = node.namespaceURI === HTML_NAMESPACE && node.localName.includes('-');
+ const element = customCandidate
+ ? new Element(node.localName, owner, { namespaceURI: node.namespaceURI, prefix: node.prefix })
+ : owner.createElementNS(
+ node.namespaceURI,
+ node.prefix ? `${node.prefix}:${node.localName}` : node.localName,
+ );
+ if (customCandidate && owner.customElements.get(node.localName)) enqueueUpgrade(element);
+ 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));
+ const shadow = getShadowRoot(node);
+ if (shadow?.clonable) {
+ const copiedShadow = element.attachShadow({
+ mode: shadow.mode,
+ delegatesFocus: shadow.delegatesFocus,
+ slotAssignment: shadow.slotAssignment,
+ clonable: true,
+ serializable: shadow.serializable,
+ });
+ if (declarativeRoots.has(shadow)) declarativeRoots.add(copiedShadow);
+ for (const child of shadow.childNodes) copiedShadow.appendChild(clone(child, 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);
+ cloneValueElementState(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;
+}
+
+/** Shared text editing, form association, and constraint APIs. */
+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 name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get readOnly(): boolean {
+ return this.hasAttribute('readonly');
+ }
+ set readOnly(value: boolean) {
+ this.toggleAttribute('readonly', Boolean(value));
+ }
+ get required(): boolean {
+ return this.hasAttribute('required');
+ }
+ set required(value: boolean) {
+ this.toggleAttribute('required', Boolean(value));
+ }
+ get placeholder(): string {
+ return this.getAttribute('placeholder') ?? '';
+ }
+ set placeholder(value: string) {
+ this.setAttribute('placeholder', value);
+ }
+ get minLength(): number {
+ return this.#lengthAttribute('minlength');
+ }
+ set minLength(value: number) {
+ this.#setLengthAttribute('minlength', value);
+ }
+ get maxLength(): number {
+ return this.#lengthAttribute('maxlength');
+ }
+ set maxLength(value: number) {
+ this.#setLengthAttribute('maxlength', value);
+ }
+ get form(): HTMLFormElement | null {
+ return formOwner(this);
+ }
+ get labels(): readonly Element[] | null {
+ return labelsFor(this);
+ }
+ get validity(): ValidityState {
+ return validityFor(this);
+ }
+ get validationMessage(): string {
+ return validationMessageFor(this);
+ }
+ get willValidate(): boolean {
+ return !isDisabled(this) && !this.readOnly && !inDatalist(this);
+ }
+ checkValidity(): boolean {
+ return validateControl(this);
+ }
+ reportValidity(): boolean {
+ return validateControl(this, true);
+ }
+ setCustomValidity(message: string): void {
+ setControlValidity(this, message);
+ }
+ 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 });
+ }
+ #lengthAttribute(name: string): number {
+ const raw = this.getAttribute(name);
+ if (raw === null || !/^\s*\+?\d+/.test(raw)) return -1;
+ const parsed = Number.parseInt(raw, 10);
+ return parsed <= 2147483647 ? parsed : -1;
+ }
+ #setLengthAttribute(name: string, value: number): void {
+ const length = Number(value) | 0;
+ if (length < 0) throw new DOMException('A length cannot be negative', 'IndexSizeError');
+ this.setAttribute(name, String(length));
+ }
+}
+/** Input values, checkedness, constraints, selection, and activation. */
+export class HTMLInputElement extends TextControlElement {
+ protected readonly control: TextControlState;
+ readonly #input: InputControlState;
+ 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),
+ sanitize: (value) => sanitizeInputValue(this, value),
+ });
+ this.#input = new InputControlState(this, this.control);
+ registerControl(this, {
+ reset: () => this.#input.reset(),
+ validity: () => inputValidity(this),
+ willValidate: () => this.willValidate,
+ value: () => this.value,
+ entries: (submitter) => {
+ if (!this.name || isDisabled(this) || ['reset', 'button', 'file'].includes(this.type))
+ return [];
+ if (['checkbox', 'radio'].includes(this.type) && !this.checked) return [];
+ if (['submit', 'image'].includes(this.type) && submitter !== this) return [];
+ return this.type === 'image'
+ ? [
+ [`${this.name}.x`, '0'],
+ [`${this.name}.y`, '0'],
+ ]
+ : [[this.name, this.value]];
+ },
+ });
+ }
+ 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);
+ }
+ override get value(): string {
+ const mode = inputValueMode(this.type);
+ return mode === 'default/on'
+ ? (this.getAttribute('value') ?? 'on')
+ : mode === 'default'
+ ? this.defaultValue
+ : mode === 'filename'
+ ? ''
+ : this.control.value;
+ }
+ override set value(value: string) {
+ const mode = inputValueMode(this.type);
+ const string = value == null ? '' : String(value);
+ if (mode === 'filename') {
+ if (string)
+ throw new DOMException(
+ 'A file input can only be cleared programmatically',
+ 'InvalidStateError',
+ );
+ this.control.value = '';
+ } else if (mode === 'default' || mode === 'default/on') this.defaultValue = string;
+ else this.control.value = string;
+ }
+ get defaultValue(): string {
+ return this.getAttribute('value') ?? '';
+ }
+ set defaultValue(value: string) {
+ this.setAttribute('value', value);
+ }
+ get checked(): boolean {
+ return this.#input.checked;
+ }
+ set checked(value: boolean) {
+ this.#input.checked = value;
+ }
+ get defaultChecked(): boolean {
+ return this.hasAttribute('checked');
+ }
+ set defaultChecked(value: boolean) {
+ this.toggleAttribute('checked', Boolean(value));
+ }
+ get indeterminate(): boolean {
+ return this.#input.indeterminate;
+ }
+ set indeterminate(value: boolean) {
+ this.#input.indeterminate = value;
+ }
+ get multiple(): boolean {
+ return this.hasAttribute('multiple');
+ }
+ set multiple(value: boolean) {
+ this.toggleAttribute('multiple', Boolean(value));
+ }
+ get formNoValidate(): boolean {
+ return this.hasAttribute('formnovalidate');
+ }
+ set formNoValidate(value: boolean) {
+ this.toggleAttribute('formnovalidate', Boolean(value));
+ }
+ get formMethod(): string {
+ if (!this.hasAttribute('formmethod')) return '';
+ const value = this.getAttribute('formmethod')?.toLowerCase();
+ return value === 'post' || value === 'dialog' ? value : 'get';
+ }
+ set formMethod(value: string) {
+ this.setAttribute('formmethod', value);
+ }
+ get min(): string {
+ return this.getAttribute('min') ?? '';
+ }
+ set min(value: string) {
+ this.setAttribute('min', value);
+ }
+ get max(): string {
+ return this.getAttribute('max') ?? '';
+ }
+ set max(value: string) {
+ this.setAttribute('max', value);
+ }
+ get step(): string {
+ return this.getAttribute('step') ?? '';
+ }
+ set step(value: string) {
+ this.setAttribute('step', value);
+ }
+ get pattern(): string {
+ return this.getAttribute('pattern') ?? '';
+ }
+ set pattern(value: string) {
+ this.setAttribute('pattern', value);
+ }
+ get valueAsNumber(): number {
+ return inputValueAsNumber(this.type, this.value);
+ }
+ set valueAsNumber(value: number) {
+ this.value = inputValueFromNumber(this.type, Number(value));
+ }
+ get valueAsDate(): Date | null {
+ return inputValueAsDate(this.type, this.value);
+ }
+ set valueAsDate(value: Date | null) {
+ this.value = inputValueFromDate(this.type, value);
+ }
+ stepUp(amount = 1): void {
+ stepInputValue(this, amount);
+ }
+ stepDown(amount = 1): void {
+ stepInputValue(this, -amount);
+ }
+ get list(): HTMLDataListElement | null {
+ const id = this.getAttribute('list');
+ if (
+ !id ||
+ [
+ 'hidden',
+ 'password',
+ 'checkbox',
+ 'radio',
+ 'file',
+ 'submit',
+ 'image',
+ 'reset',
+ 'button',
+ ].includes(this.type)
+ )
+ return null;
+ const candidate = formTree(this.getRootNode()).find((element) => element.id === id);
+ return candidate instanceof HTMLDataListElement ? candidate : null;
+ }
+ override get labels(): readonly Element[] | null {
+ return this.type === 'hidden' ? null : labelsFor(this);
+ }
+ override get willValidate(): boolean {
+ return (
+ !isDisabled(this) &&
+ !inDatalist(this) &&
+ !['hidden', 'reset', 'button'].includes(this.type) &&
+ !(this.readOnly && supportsReadOnly(this.type))
+ );
+ }
+ protected override activationDisabled(): boolean {
+ return isDisabled(this);
+ }
+ protected override legacyPreActivationBehavior(): (() => void) | null {
+ return this.#input.preActivate();
+ }
+ protected override activationBehavior(_event: Event): void {
+ const changed = this.#input.completeActivation();
+ if (['checkbox', 'radio'].includes(this.type) && !this.isConnected) return;
+ if (!['checkbox', 'radio'].includes(this.type) && isDisabled(this)) return;
+ if (['checkbox', 'radio'].includes(this.type) && changed) {
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ this.dispatchEvent(new Event('change', { bubbles: true }));
+ } else if (['submit', 'image'].includes(this.type)) this.form?.requestSubmit(this);
+ else if (this.type === 'reset') this.form?.reset();
+ }
+}
+/** Multiline text values, selection, form association, and constraints. */
+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,
+ });
+ registerControl(this, {
+ reset: () => this.control.reset(),
+ validity: () => ({
+ valueMissing: this.required && !this.readOnly && !this.value,
+ ...textLengthValidity(this, this.value),
+ }),
+ willValidate: () => this.willValidate,
+ value: () => this.value,
+ entries: () => (this.name && !isDisabled(this) ? [[this.name, this.value]] : []),
+ });
+ }
+ get type(): 'textarea' {
+ return 'textarea';
+ }
+ get textLength(): number {
+ return this.value.length;
+ }
+ get defaultValue(): string {
+ return this.textContent ?? '';
+ }
+ set defaultValue(value: string) {
+ this.textContent = value;
+ syncTextControlDefault(this);
+ }
+ override get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+}
+
+/** 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();
+ }
+ get shadowRootMode(): string {
+ const mode = this.getAttribute('shadowrootmode')?.toLowerCase();
+ return mode === 'open' || mode === 'closed' ? mode : '';
+ }
+ set shadowRootMode(value: string) {
+ this.setAttribute('shadowrootmode', value);
+ }
+ get shadowRootSlotAssignment(): string {
+ return this.getAttribute('shadowrootslotassignment')?.toLowerCase() === 'manual'
+ ? 'manual'
+ : 'named';
+ }
+ set shadowRootSlotAssignment(value: string) {
+ this.setAttribute('shadowrootslotassignment', value);
+ }
+ get shadowRootDelegatesFocus(): boolean {
+ return this.hasAttribute('shadowrootdelegatesfocus');
+ }
+ set shadowRootDelegatesFocus(value: boolean) {
+ this.toggleAttribute('shadowrootdelegatesfocus', Boolean(value));
+ }
+ get shadowRootClonable(): boolean {
+ return this.hasAttribute('shadowrootclonable');
+ }
+ set shadowRootClonable(value: boolean) {
+ this.toggleAttribute('shadowrootclonable', Boolean(value));
+ }
+ get shadowRootSerializable(): boolean {
+ return this.hasAttribute('shadowrootserializable');
+ }
+ set shadowRootSerializable(value: boolean) {
+ this.toggleAttribute('shadowrootserializable', Boolean(value));
+ }
+}
+const manualSlotNodes = new WeakMap[]>();
+const manualSlots = new WeakMap>();
+/** 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.slotAssignment === 'manual'
+ ? (manualSlotNodes.get(this) ?? []).flatMap((ref) => {
+ const node = ref.deref();
+ return node && getAssignedSlot(node) === this ? [node] : [];
+ })
+ : 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),
+ );
+ }
+ assign(...nodes: (Element | Text)[]): void {
+ if (nodes.some((node) => !(node instanceof Element || node instanceof Text)))
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL rejects nodes outside the slottable union.
+ throw new TypeError('Only elements and text nodes are slottable');
+ const changed = new Set([this]);
+ for (const node of nodes) {
+ const previous = manualSlots.get(node)?.deref();
+ if (previous) changed.add(previous);
+ }
+ const before = new Map([...changed].map((slot) => [slot, slot.assignedNodes()]));
+ for (const ref of manualSlotNodes.get(this) ?? []) {
+ const node = ref.deref();
+ if (node && manualSlots.get(node)?.deref() === this) manualSlots.delete(node);
+ }
+ const unique = [...new Set(nodes)];
+ for (const node of unique) {
+ manualSlots.set(node, new WeakRef(this));
+ }
+ manualSlotNodes.set(
+ this,
+ unique.map((node) => new WeakRef(node)),
+ );
+ for (const slot of changed) {
+ updateSlots(slot);
+ const previous = before.get(slot) ?? [];
+ const next = slot.assignedNodes();
+ if (previous.length !== next.length || previous.some((node, index) => node !== next[index])) {
+ slot.notifyHost();
+ slot.ownerDocument.focusManager.revalidate();
+ }
+ }
+ }
+}
+/** @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;
+ if (root.slotAssignment === 'manual') {
+ const slot = manualSlots.get(node)?.deref();
+ return slot?.getRootNode() === root ? slot : 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;
+}
+
+export 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');
+}
+
+export const {
+ HTMLFormElement,
+ HTMLButtonElement,
+ HTMLLabelElement,
+ HTMLFieldSetElement,
+ HTMLLegendElement,
+} = defineFormElements(Element);
+export type HTMLFormElement = InstanceType;
+export type HTMLButtonElement = InstanceType;
+export type HTMLLabelElement = InstanceType;
+export type HTMLFieldSetElement = InstanceType;
+export type HTMLLegendElement = InstanceType;
+export const {
+ HTMLSelectElement,
+ HTMLOptionElement,
+ HTMLOptGroupElement,
+ HTMLDataListElement,
+ HTMLSelectedContentElement,
+ HTMLOutputElement,
+ HTMLProgressElement,
+ HTMLMeterElement,
+} = defineValueElements(Element);
+export type HTMLSelectElement = InstanceType;
+export type HTMLOptionElement = InstanceType;
+export type HTMLOptGroupElement = InstanceType;
+export type HTMLDataListElement = InstanceType;
+export type HTMLSelectedContentElement = InstanceType;
+export type HTMLOutputElement = InstanceType;
+export type HTMLProgressElement = InstanceType;
+export type HTMLMeterElement = InstanceType;
+export const { HTMLDetailsElement, HTMLSummaryElement, HTMLDialogElement } =
+ defineInteractiveElements(Element);
+export type HTMLDetailsElement = InstanceType;
+export type HTMLSummaryElement = InstanceType;
+export type HTMLDialogElement = InstanceType;
+const specializedElements: Readonly<
+ Record Element) | undefined>
+> = {
+ button: HTMLButtonElement,
+ form: HTMLFormElement,
+ label: HTMLLabelElement,
+ fieldset: HTMLFieldSetElement,
+ legend: HTMLLegendElement,
+ select: HTMLSelectElement,
+ option: HTMLOptionElement,
+ optgroup: HTMLOptGroupElement,
+ datalist: HTMLDataListElement,
+ selectedcontent: HTMLSelectedContentElement,
+ output: HTMLOutputElement,
+ progress: HTMLProgressElement,
+ meter: HTMLMeterElement,
+ details: HTMLDetailsElement,
+ summary: HTMLSummaryElement,
+ dialog: HTMLDialogElement,
+};
+
+function reactionDocuments(document: Document, nodes: readonly (Node | string)[]): Document[] {
+ return [
+ document,
+ ...nodes.flatMap((node) =>
+ typeof node === 'string' || !node.ownerDocument ? [] : [node.ownerDocument],
+ ),
+ ];
+}
diff --git a/packages/dom/src/events.ts b/packages/dom/src/events.ts
new file mode 100644
index 0000000..d80b3e4
--- /dev/null
+++ b/packages/dom/src/events.ts
@@ -0,0 +1,601 @@
+import type { Element } from './dom.ts';
+
+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 MouseEventInit extends EventInit {
+ button?: number;
+ buttons?: number;
+ clientX?: number;
+ clientY?: number;
+ screenX?: number;
+ screenY?: number;
+ ctrlKey?: boolean;
+ shiftKey?: boolean;
+ altKey?: boolean;
+ metaKey?: boolean;
+ relatedTarget?: EventTarget | null;
+}
+
+/** Pointer data supplied by a host; no hit testing or coordinate calculation. */
+export class MouseEvent extends Event {
+ readonly button: number;
+ readonly buttons: number;
+ readonly clientX: number;
+ readonly clientY: number;
+ readonly screenX: number;
+ readonly screenY: number;
+ readonly ctrlKey: boolean;
+ readonly shiftKey: boolean;
+ readonly altKey: boolean;
+ readonly metaKey: boolean;
+ constructor(type: string, options: MouseEventInit = {}) {
+ super(type, options);
+ this.button = Number(options.button ?? 0);
+ this.buttons = Number(options.buttons ?? 0);
+ this.clientX = Number(options.clientX ?? 0);
+ this.clientY = Number(options.clientY ?? 0);
+ this.screenX = Number(options.screenX ?? 0);
+ this.screenY = Number(options.screenY ?? 0);
+ this.ctrlKey = Boolean(options.ctrlKey);
+ this.shiftKey = Boolean(options.shiftKey);
+ this.altKey = Boolean(options.altKey);
+ this.metaKey = Boolean(options.metaKey);
+ stateOf(this).relatedTarget = options.relatedTarget ?? null;
+ }
+ get relatedTarget(): EventTarget | null {
+ return stateOf(this).relatedTarget;
+ }
+}
+
+export interface InputEventInit extends EventInit {
+ data?: string | null;
+ inputType?: string;
+ isComposing?: boolean;
+}
+export class InputEvent extends Event {
+ readonly data: string | null;
+ readonly inputType: string;
+ readonly isComposing: boolean;
+ constructor(type: string, options: InputEventInit = {}) {
+ super(type, options);
+ this.data = options.data == null ? null : String(options.data);
+ this.inputType = String(options.inputType ?? '');
+ this.isComposing = Boolean(options.isComposing);
+ }
+}
+
+export interface ToggleEventInit extends EventInit {
+ oldState?: string;
+ newState?: string;
+ source?: Element | null;
+}
+export class ToggleEvent extends Event {
+ readonly oldState: string;
+ readonly newState: string;
+ readonly source: Element | null;
+ constructor(type: string, options: ToggleEventInit = {}) {
+ super(type, options);
+ this.oldState = String(options.oldState ?? '');
+ this.newState = String(options.newState ?? '');
+ this.source = options.source ?? null;
+ }
+}
+
+export interface SubmitEventInit extends EventInit {
+ submitter?: Element | null;
+}
+export class SubmitEvent extends Event {
+ readonly submitter: Element | null;
+ constructor(type: string, options: SubmitEventInit = {}) {
+ super(type, options);
+ this.submitter = options.submitter ?? null;
+ }
+}
+export interface FormDataEventInit extends EventInit {
+ formData: globalThis.FormData;
+}
+export class FormDataEvent extends Event {
+ readonly formData: globalThis.FormData;
+ constructor(type: string, options: FormDataEventInit) {
+ super(type, options);
+ this.formData = options.formData;
+ }
+}
+
+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;
+ let activationTarget: EventTarget | null = null;
+ let activationEventTarget: EventTarget | null = null;
+ let rollback: (() => void) | null = null;
+ 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);
+ if (event instanceof MouseEvent && event.type === 'click') {
+ const activationEntry = state.path.find(
+ (entry, index) =>
+ (index === 0 || event.bubbles) && entry.invocationTarget.hasActivationBehavior(),
+ );
+ activationTarget = activationEntry?.invocationTarget ?? null;
+ activationEventTarget = activationEntry?.target ?? null;
+ rollback = activationTarget?.legacyPreActivationBehavior() ?? null;
+ }
+ 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 });
+ }
+ } 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;
+ }
+ }
+ if (state.canceled) rollback?.();
+ else activationTarget?.activationBehavior(event, activationEventTarget);
+ return !state.canceled;
+ }
+
+ /** HTML subclasses opt in to click activation; ordinary event targets have none. */
+ protected hasActivationBehavior(): boolean {
+ return false;
+ }
+ protected legacyPreActivationBehavior(): (() => void) | null {
+ return null;
+ }
+ protected activationBehavior(_event: Event, _target: EventTarget | null): void {}
+
+ /** 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..82167d4
--- /dev/null
+++ b/packages/dom/src/focus.ts
@@ -0,0 +1,765 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- DOM focus eligibility and reflected properties are synchronous. */
+import {
+ Document,
+ Element,
+ HTMLSlotElement,
+ HTMLInputElement,
+ HTMLTextAreaElement,
+ Text,
+ type Node,
+ ShadowRoot,
+ getAssignedSlot,
+ getShadowRoot,
+} from './dom.ts';
+import { Event, FocusEvent, InputEvent, KeyboardEvent, type KeyboardEventInit } from './events.ts';
+import {
+ isClosedWidgetContent,
+ isInteractionInert,
+ isSummary,
+ requestDialogClose,
+} from './interactive-elements.ts';
+import { markControlUserEdited, radioGroup } from './controls.ts';
+import { implicitSubmit, isDisabled } from './forms.ts';
+import { changeSelectSelection, handleSelectKey } from './value-elements.ts';
+import { subscribeMutations } from './hooks.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 (isInteractionInert(element) || isClosedWidgetContent(element)) 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')) 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 (isSummary(element) || element.localName === 'dialog') return true;
+ if (['a', 'area'].includes(element.localName) && element.hasAttribute('href')) return true;
+ return element.isContentEditable && !element.parentElement?.isContentEditable;
+}
+
+/** 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;
+ if (
+ isSummary(element) ||
+ (element.isContentEditable && !element.parentElement?.isContentEditable)
+ )
+ return 0;
+ 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;
+ #autofocusProcessed = false;
+
+ 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);
+ commitControl(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);
+ }
+
+ /** Process connected autofocus candidates once, after synchronous tree preparation. */
+ processAutofocus(): void {
+ if (this.#autofocusProcessed) return;
+ if (this.#active) {
+ this.#autofocusProcessed = true;
+ return;
+ }
+ const candidate = this.#collect(this.#document).find(
+ (element) => element.hasAttribute('autofocus') && isFocusable(element),
+ );
+ if (!candidate) return;
+ this.#autofocusProcessed = true;
+ this.focus(candidate);
+ }
+
+ focusableElements(): readonly Element[] {
+ return Object.freeze(
+ this.#scope(this.#document).filter((element) => {
+ if (!(element instanceof HTMLInputElement) || element.type !== 'radio') return true;
+ const group = radioGroup(element).filter(isFocusable);
+ return element === (group.find((member) => member.checked) ?? group[0]);
+ }),
+ );
+ }
+
+ /** 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 ((isOwner ? (explicitTabIndex(child) ?? 0) : child.tabIndex) >= 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 for logical navigation, activation, and control editing. */
+export class Dispatch {
+ readonly #document: Document;
+ #spaceTarget: Element | null = null;
+
+ constructor(document: Document) {
+ this.#document = document;
+ }
+
+ keydown(input: KeyInput): boolean {
+ const target = this.#document.focusManager.activeElement;
+ const accepted = this.#key('keydown', input);
+ if (!accepted || input.isComposing || this.#document.focusManager.activeElement !== target)
+ return accepted;
+ if (input.key === 'Tab' && !input.altKey && !input.ctrlKey && !input.metaKey) {
+ this.#document.focusManager.moveFocus(Boolean(input.shiftKey));
+ return accepted;
+ }
+ if (input.key === 'Escape' && !input.altKey && !input.ctrlKey && !input.metaKey) {
+ requestDialogClose(this.#document);
+ return accepted;
+ }
+ if (!target || !isFocusable(target)) return accepted;
+ if (editKey(target, input)) return accepted;
+ if (editContentKey(target, input)) return accepted;
+ if (input.altKey || input.ctrlKey || input.metaKey) return accepted;
+ if (handleSelectKey(target, input.key)) return accepted;
+ if (radioKey(target, input.key)) return accepted;
+ if (
+ input.key === 'Enter' &&
+ target instanceof HTMLInputElement &&
+ !activationKeyTarget(target)
+ ) {
+ commitControl(target);
+ implicitSubmit(target);
+ }
+ if (input.key === ' ' && activationKeyTarget(target)) {
+ if (!input.repeat) this.#spaceTarget = target;
+ }
+ if (
+ input.key === 'Enter' &&
+ activationKeyTarget(target) &&
+ !(target instanceof HTMLInputElement && ['checkbox', 'radio'].includes(target.type))
+ )
+ target.click();
+ return accepted;
+ }
+
+ keyup(input: KeyInput): boolean {
+ const accepted = this.#key('keyup', input);
+ if (input.key === ' ') {
+ const target = this.#spaceTarget;
+ this.#spaceTarget = null;
+ if (
+ accepted &&
+ target &&
+ target === this.#document.focusManager.activeElement &&
+ isFocusable(target)
+ )
+ target.click();
+ }
+ return accepted;
+ }
+
+ /** Insert host text (including paste), without pretending to dispatch a trusted key. */
+ insertText(text: string): boolean {
+ const target = this.#document.focusManager.activeElement;
+ if (!target || !isFocusable(target)) return false;
+ return isTextControl(target)
+ ? replaceSelection(target, String(text), 'insertText')
+ : replaceEditableSelection(target, { text: String(text), inputType: 'insertText' });
+ }
+
+ /** Commit host option selection, for example from a consumer's picker UI. */
+ select(indices: readonly number[]): boolean {
+ const target = this.#document.focusManager.activeElement;
+ return target && isFocusable(target) ? changeSelectSelection(target, indices) : false;
+ }
+
+ #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 }),
+ );
+ }
+}
+
+type TextControl = HTMLInputElement | HTMLTextAreaElement;
+const editedValues = new WeakMap();
+
+function commitControl(element: Element): void {
+ const initial = editedValues.get(element);
+ editedValues.delete(element);
+ if (initial !== undefined && isTextControl(element) && element.value !== initial)
+ element.dispatchEvent(new Event('change', { bubbles: true }));
+}
+
+function radioKey(element: Element, key: string): boolean {
+ if (
+ !(element instanceof HTMLInputElement) ||
+ element.type !== 'radio' ||
+ !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)
+ )
+ return false;
+ const group = radioGroup(element).filter(isFocusable);
+ if (!group.length) return true;
+ const step = key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1;
+ const next = group[(group.indexOf(element) + step + group.length) % group.length];
+ if (next) {
+ next.focus();
+ next.click();
+ }
+ return true;
+}
+
+function isTextControl(element: Element): element is TextControl {
+ return (
+ (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) &&
+ element.selectionStart !== null
+ );
+}
+
+function activationKeyTarget(element: Element): boolean {
+ return (
+ element.localName === 'button' ||
+ isSummary(element) ||
+ (element instanceof HTMLInputElement &&
+ ['button', 'submit', 'reset', 'checkbox', 'radio'].includes(element.type))
+ );
+}
+
+// oxlint-disable-next-line bombshell-dev/max-params -- Editing operation carries control, replacement, input kind, and optional explicit range.
+function replaceSelection(
+ control: TextControl,
+ text: string,
+ inputType: string,
+ range?: { start: number; end: number },
+): boolean {
+ if (control.hasAttribute('readonly') || isDisabled(control)) return false;
+ const start = range?.start ?? control.selectionStart ?? 0;
+ const end = range?.end ?? control.selectionEnd ?? 0;
+ const normalized =
+ control instanceof HTMLTextAreaElement
+ ? text.replace(/\r\n?/g, '\n')
+ : text.replace(/[\r\n]/g, '');
+ let replacement = normalized;
+ const maximum = control.getAttribute('maxlength');
+ if (maximum !== null && /^\d+$/.test(maximum)) {
+ const available = Math.max(0, Number(maximum) - (control.value.length - (end - start)));
+ replacement = normalized.slice(0, available);
+ if (replacement && /[\uD800-\uDBFF]$/.test(replacement)) replacement = replacement.slice(0, -1);
+ }
+ if (!replacement && start === end) return true;
+ const data = inputType.startsWith('delete') ? null : replacement;
+ const before = new InputEvent('beforeinput', {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ inputType,
+ data,
+ });
+ if (!control.dispatchEvent(before)) return false;
+ if (
+ !isFocusable(control) ||
+ control.hasAttribute('readonly') ||
+ isDisabled(control) ||
+ control.ownerDocument.focusManager.activeElement !== control
+ )
+ return false;
+ if (!editedValues.has(control)) editedValues.set(control, control.value);
+ control.setRangeText(replacement, start, end, 'end');
+ markControlUserEdited(control);
+ control.dispatchEvent(
+ new InputEvent('input', { bubbles: true, composed: true, inputType, data }),
+ );
+ return true;
+}
+
+function adjacentBoundary(value: string, position: { offset: number; backward: boolean }): number {
+ const boundaries = [
+ 0,
+ ...Array.from(
+ new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value),
+ (part) => part.index + part.segment.length,
+ ),
+ ];
+ return position.backward
+ ? (boundaries.findLast((boundary) => boundary < position.offset) ?? 0)
+ : (boundaries.find((boundary) => boundary > position.offset) ?? value.length);
+}
+
+function editKey(element: Element, input: KeyInput): boolean {
+ if (!isTextControl(element)) return false;
+ if ((input.ctrlKey || input.metaKey) && input.key.toLowerCase() === 'a' && !input.altKey) {
+ element.select();
+ return true;
+ }
+ if (input.altKey || input.ctrlKey || input.metaKey) return false;
+ const start = element.selectionStart ?? 0;
+ const end = element.selectionEnd ?? 0;
+ const backward = element.selectionDirection === 'backward';
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(input.key)) {
+ const anchor = backward ? end : start;
+ const current = backward ? start : end;
+ let next =
+ input.key === 'Home'
+ ? 0
+ : input.key === 'End'
+ ? element.value.length
+ : adjacentBoundary(element.value, {
+ offset: current,
+ backward: input.key === 'ArrowLeft',
+ });
+ if (!input.shiftKey && start !== end && ['ArrowLeft', 'ArrowRight'].includes(input.key))
+ next = input.key === 'ArrowLeft' ? start : end;
+ if (input.shiftKey)
+ element.setSelectionRange(
+ Math.min(anchor, next),
+ Math.max(anchor, next),
+ next < anchor ? 'backward' : 'forward',
+ );
+ else element.setSelectionRange(next, next);
+ return true;
+ }
+ if (input.key === 'Backspace' || input.key === 'Delete') {
+ const from =
+ start === end && input.key === 'Backspace'
+ ? adjacentBoundary(element.value, { offset: start, backward: true })
+ : start;
+ const to =
+ start === end && input.key === 'Delete'
+ ? adjacentBoundary(element.value, { offset: end, backward: false })
+ : end;
+ replaceSelection(
+ element,
+ '',
+ input.key === 'Backspace' ? 'deleteContentBackward' : 'deleteContentForward',
+ { start: from, end: to },
+ );
+ return true;
+ }
+ if (input.key === 'Enter' && element instanceof HTMLTextAreaElement) {
+ replaceSelection(element, '\n', 'insertLineBreak');
+ return true;
+ }
+ if ([...input.key].length === 1) {
+ replaceSelection(element, input.key, 'insertText');
+ return true;
+ }
+ return false;
+}
+
+function editingHost(element: Element): Element | null {
+ if (!element.isContentEditable) return null;
+ let host = element;
+ while (host.parentElement?.isContentEditable) host = host.parentElement;
+ return host.getRootNode() instanceof Document ? host : null;
+}
+
+function editableBoundary(host: Element, node: Node): boolean {
+ const element = node instanceof Element ? node : node.parentElement;
+ return Boolean(element?.isContentEditable && host.contains(node));
+}
+
+function hasProtectedContent(host: Element, range: ReturnType): boolean {
+ for (const child of host.children) {
+ if (!child.isContentEditable && range.intersectsNode(child)) return true;
+ if (hasProtectedContent(child, range)) return true;
+ }
+ return false;
+}
+
+function replaceEditableSelection(
+ element: Element,
+ edit: { text: string; inputType: string },
+): boolean {
+ const host = editingHost(element);
+ if (!host) return false;
+ const selection = host.ownerDocument.getSelection();
+ if (
+ !selection.rangeCount ||
+ !host.contains(selection.anchorNode) ||
+ !host.contains(selection.focusNode)
+ )
+ selection.collapse(host, host.childNodes.length);
+ if (!selection.rangeCount) return false;
+ const range = selection.getRangeAt(0);
+ if (
+ !editableBoundary(host, range.startContainer) ||
+ !editableBoundary(host, range.endContainer) ||
+ hasProtectedContent(host, range)
+ )
+ return false;
+ const text = edit.text.replace(/\r\n?/g, '\n');
+ if (!text && range.collapsed) return true;
+ const data = edit.inputType.startsWith('delete') ? null : text;
+ if (
+ !host.dispatchEvent(
+ new InputEvent('beforeinput', {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ inputType: edit.inputType,
+ data,
+ }),
+ )
+ )
+ return false;
+ const active = host.ownerDocument.focusManager.activeElement;
+ if (
+ !active ||
+ editingHost(active) !== host ||
+ !isFocusable(active) ||
+ !editableBoundary(host, range.startContainer) ||
+ !editableBoundary(host, range.endContainer) ||
+ hasProtectedContent(host, range)
+ )
+ return false;
+ range.deleteContents();
+ const container = range.startContainer;
+ const offset = range.startOffset;
+ if (text) {
+ if (container instanceof Text) {
+ container.insertData(offset, text);
+ selection.collapse(container, offset + text.length);
+ } else {
+ const added = host.ownerDocument.createTextNode(text);
+ container.insertBefore(added, container.childNodes[offset] ?? null);
+ selection.collapse(added, text.length);
+ }
+ } else selection.collapse(container, offset);
+ host.dispatchEvent(
+ new InputEvent('input', { bubbles: true, composed: true, inputType: edit.inputType, data }),
+ );
+ return true;
+}
+
+function textPoint(host: Element, offset: number): readonly [Node, number] {
+ let remaining = offset;
+ let last: Text | undefined;
+ const visit = (node: Node): readonly [Node, number] | undefined => {
+ if (node instanceof Text) {
+ last = node;
+ if (remaining <= node.length) return [node, remaining];
+ remaining -= node.length;
+ }
+ for (const child of node.childNodes) {
+ const found = visit(child);
+ if (found) return found;
+ }
+ return undefined;
+ };
+ return visit(host) ?? (last ? [last, last.length] : [host, 0]);
+}
+
+function editContentKey(element: Element, input: KeyInput): boolean {
+ const host = editingHost(element);
+ if (!host) return false;
+ const selection = host.ownerDocument.getSelection();
+ if ((input.ctrlKey || input.metaKey) && input.key.toLowerCase() === 'a' && !input.altKey) {
+ selection.selectAllChildren(host);
+ return true;
+ }
+ if (input.altKey || input.ctrlKey || input.metaKey) return false;
+ if (!selection.rangeCount || !host.contains(selection.focusNode))
+ selection.collapse(host, host.childNodes.length);
+ if (!selection.rangeCount) return false;
+ const range = selection.getRangeAt(0);
+ if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(input.key)) {
+ if (
+ !editableBoundary(host, range.startContainer) ||
+ !editableBoundary(host, range.endContainer)
+ )
+ return true;
+ const prefix = host.ownerDocument.createRange();
+ prefix.selectNodeContents(host);
+ const anchor = selection.focusNode;
+ if (!anchor) return true;
+ prefix.setEnd(anchor, selection.focusOffset);
+ const value = host.textContent ?? '';
+ const offset = prefix.toString().length;
+ const backward = input.key === 'Backspace' || input.key === 'ArrowLeft';
+ const next =
+ input.key === 'Home'
+ ? 0
+ : input.key === 'End'
+ ? value.length
+ : adjacentBoundary(value, { offset, backward });
+ const point = textPoint(host, next);
+ if (!editableBoundary(host, point[0])) return true;
+ if (input.key === 'Backspace' || input.key === 'Delete') {
+ if (range.collapsed) selection.extend(...point);
+ replaceEditableSelection(element, {
+ text: '',
+ inputType: input.key === 'Backspace' ? 'deleteContentBackward' : 'deleteContentForward',
+ });
+ } else if (input.shiftKey) selection.extend(...point);
+ else if (!range.collapsed && ['ArrowLeft', 'ArrowRight'].includes(input.key)) {
+ if (backward) selection.collapseToStart();
+ else selection.collapseToEnd();
+ } else selection.collapse(...point);
+ return true;
+ }
+ if (input.key === 'Enter') {
+ replaceEditableSelection(element, { text: '\n', inputType: 'insertLineBreak' });
+ return true;
+ }
+ if ([...input.key].length === 1) {
+ replaceEditableSelection(element, { text: input.key, inputType: 'insertText' });
+ return true;
+ }
+ return false;
+}
+
+const autofocusQueued = new WeakSet();
+subscribeMutations((record) => {
+ if (record.type !== 'childList' || !record.addedNodes?.length || !record.target.isConnected)
+ return;
+ const document = record.target instanceof Document ? record.target : record.target.ownerDocument;
+ if (!document || autofocusQueued.has(document)) return;
+ autofocusQueued.add(document);
+ setTimeout(() => {
+ autofocusQueued.delete(document);
+ document.focusManager.processAutofocus();
+ }, 0);
+});
diff --git a/packages/dom/src/form-elements.ts b/packages/dom/src/form-elements.ts
new file mode 100644
index 0000000..edda070
--- /dev/null
+++ b/packages/dom/src/form-elements.ts
@@ -0,0 +1,453 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- HTML form methods are synchronous. */
+import {
+ type Document,
+ Element,
+ Node,
+ HTML_NAMESPACE,
+ HTMLFormElement as FormClass,
+ type HTMLFormElement,
+ HTMLButtonElement as ButtonClass,
+ HTMLInputElement,
+ descendants,
+ readonlyCollection,
+} from './dom.ts';
+import { Event, SubmitEvent, FormDataEvent, type EventTarget } from './events.ts';
+import { isFocusable } from './focus.ts';
+import {
+ formOwner,
+ formControls,
+ controlState,
+ registerControl,
+ isDisabled,
+ labelControl,
+ labelsFor,
+ validityFor,
+ validationMessageFor,
+ setControlValidity,
+ validateControl,
+ type ValidityState,
+} from './forms.ts';
+
+export interface FormElement extends Element {
+ name: string;
+ method: string;
+ noValidate: boolean;
+ readonly elements: readonly Element[];
+ readonly length: number;
+ reset(): void;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+ requestSubmit(submitter?: Element | null): void;
+ submit(): void;
+}
+export interface ButtonElement extends Element {
+ name: string;
+ value: string;
+ type: string;
+ disabled: boolean;
+ formNoValidate: boolean;
+ formMethod: string;
+ readonly form: HTMLFormElement | null;
+ readonly labels: readonly Element[];
+ readonly willValidate: boolean;
+ readonly validity: ValidityState;
+ readonly validationMessage: string;
+ setCustomValidity(message: string): void;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+}
+export interface LabelElement extends Element {
+ htmlFor: string;
+ readonly control: Element | null;
+ readonly form: HTMLFormElement | null;
+}
+export interface FieldSetElement extends Element {
+ name: string;
+ disabled: boolean;
+ readonly form: HTMLFormElement | null;
+ readonly type: 'fieldset';
+ readonly elements: readonly Element[];
+ readonly willValidate: false;
+ readonly validity: ValidityState;
+ readonly validationMessage: string;
+ setCustomValidity(message: string): void;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+}
+export interface LegendElement extends Element {
+ readonly form: HTMLFormElement | null;
+}
+export interface FormElementConstructor {
+ new (document: Document): T;
+}
+interface FormElementClasses {
+ HTMLFormElement: FormElementConstructor;
+ HTMLButtonElement: FormElementConstructor;
+ HTMLLabelElement: FormElementConstructor;
+ HTMLFieldSetElement: FormElementConstructor;
+ HTMLLegendElement: FormElementConstructor;
+}
+
+/** Form entry collection uses the platform's actual FormData/Blob implementation. */
+const constructingEntryList = new WeakSet();
+export class FormData extends globalThis.FormData {
+ constructor(form?: HTMLFormElement, submitter?: Element | null) {
+ super();
+ if (form === undefined) return;
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL rejects non-form arguments.
+ if (!(form instanceof FormClass)) throw new TypeError('Expected an HTMLFormElement');
+ validateSubmitter(form, submitter ?? null);
+ if (constructingEntryList.has(form))
+ throw new DOMException(
+ 'The form entry list is already being constructed',
+ 'InvalidStateError',
+ );
+ constructingEntryList.add(form);
+ try {
+ const data = new FormData();
+ for (const control of formControls(form)) {
+ if (isDisabled(control) || insideDataList(control)) continue;
+ for (const [name, value] of controlState(control)?.entries(submitter ?? null) ?? [])
+ data.append(name, value);
+ }
+ form.dispatchEvent(new FormDataEvent('formdata', { bubbles: true, formData: data }));
+ // The constructor receives a clone after synchronous formdata customization.
+ for (const [name, value] of data) this.append(name, value);
+ } finally {
+ constructingEntryList.delete(form);
+ }
+ }
+}
+function insideDataList(element: Element): boolean {
+ for (let node = element.parentElement; node; node = node.parentElement)
+ if (node.namespaceURI === HTML_NAMESPACE && node.localName === 'datalist') return true;
+ return false;
+}
+function isSubmitButton(element: Element): boolean {
+ return element instanceof ButtonClass
+ ? element.type === 'submit'
+ : element instanceof HTMLInputElement && ['submit', 'image'].includes(element.type);
+}
+function validateSubmitter(form: Element, submitter: Element | null): void {
+ if (!submitter) return;
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- requestSubmit uses a Web IDL TypeError for non-submit buttons.
+ if (!isSubmitButton(submitter)) throw new TypeError('The submitter must be a submit button');
+ if (formOwner(submitter) !== form)
+ throw new DOMException('Submitter belongs to another form', 'NotFoundError');
+}
+function method(element: Element, name: string): string {
+ const value = element.getAttribute(name)?.toLowerCase();
+ return value === 'post' || value === 'dialog' ? value : 'get';
+}
+function interactiveDescendant(element: Element): boolean {
+ if (element.namespaceURI !== HTML_NAMESPACE) return false;
+ if (
+ ['button', 'details', 'embed', 'iframe', 'label', 'select', 'textarea'].includes(
+ element.localName,
+ )
+ )
+ return true;
+ if (element.localName === 'a') return element.hasAttribute('href');
+ if (element.localName === 'input')
+ return element.getAttribute('type')?.toLowerCase() !== 'hidden';
+ if (element.localName === 'audio' || element.localName === 'video')
+ return element.hasAttribute('controls');
+ return element.localName === 'img' && element.hasAttribute('usemap');
+}
+
+/** Evaluated after the common Element base has been defined. */
+export function defineFormElements(Base: typeof Element): FormElementClasses {
+ class HTMLFormElement extends Base implements FormElement {
+ #resetting = false;
+ #firingSubmissionEvents = false;
+ readonly #elements = readonlyCollection(() => formControls(this));
+ constructor(document: Document) {
+ super('form', document);
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get method(): string {
+ return method(this, 'method');
+ }
+ set method(value: string) {
+ this.setAttribute('method', value);
+ }
+ get noValidate(): boolean {
+ return this.hasAttribute('novalidate');
+ }
+ set noValidate(value: boolean) {
+ this.toggleAttribute('novalidate', Boolean(value));
+ }
+ get elements(): readonly Element[] {
+ return this.#elements;
+ }
+ get length(): number {
+ return this.elements.length;
+ }
+ reset(): void {
+ if (this.#resetting) return;
+ this.#resetting = true;
+ try {
+ if (!this.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true }))) return;
+ for (const element of formControls(this)) controlState(element)?.reset();
+ } finally {
+ this.#resetting = false;
+ }
+ }
+ checkValidity(): boolean {
+ let valid = true;
+ for (const element of formControls(this)) if (!validateControl(element)) valid = false;
+ return valid;
+ }
+ reportValidity(): boolean {
+ let valid = true;
+ const unhandled: Element[] = [];
+ for (const element of formControls(this)) {
+ if (!controlState(element)?.willValidate() || validityFor(element).valid) continue;
+ valid = false;
+ if (element.dispatchEvent(new Event('invalid', { cancelable: true })))
+ unhandled.push(element);
+ }
+ unhandled.find(isFocusable)?.focus();
+ return valid;
+ }
+ requestSubmit(submitter: Element | null = null): void {
+ validateSubmitter(this, submitter);
+ this.#submit(submitter, false);
+ }
+ submit(): void {
+ this.#submit(null, true);
+ }
+ #submit(submitter: Element | null, bypass: boolean): void {
+ if (constructingEntryList.has(this) || !this.isConnected) return;
+ if (!bypass) {
+ if (this.#firingSubmissionEvents) return;
+ this.#firingSubmissionEvents = true;
+ try {
+ if (
+ !this.noValidate &&
+ !submitter?.hasAttribute('formnovalidate') &&
+ !this.reportValidity()
+ )
+ return;
+ if (
+ !this.dispatchEvent(
+ new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter }),
+ )
+ )
+ return;
+ } finally {
+ this.#firingSubmissionEvents = false;
+ }
+ }
+ if (!this.isConnected) return;
+ // Entry construction and formdata happen before method=dialog handling too.
+ // oxlint-disable-next-line no-new -- Construction delivers the standard formdata event to the host.
+ new FormData(this, submitter);
+ if (!this.isConnected) return;
+ const submissionMethod = submitter?.hasAttribute('formmethod')
+ ? method(submitter, 'formmethod')
+ : this.method;
+ if (submissionMethod === 'dialog') {
+ for (let parent = this.parentElement; parent; parent = parent.parentElement) {
+ if (
+ parent.namespaceURI === HTML_NAMESPACE &&
+ parent.localName === 'dialog' &&
+ 'close' in parent &&
+ typeof parent.close === 'function'
+ ) {
+ parent.close(submitter && 'value' in submitter ? String(submitter.value) : undefined);
+ return;
+ }
+ }
+ return;
+ }
+ // The formdata event hands the entry list to the host. There is no network/navigation step.
+ }
+ }
+ class HTMLButtonElement extends Base implements ButtonElement {
+ constructor(document: Document) {
+ super('button', document);
+ registerControl(this, {
+ reset: () => {},
+ entries: (submitter) => (submitter === this && this.name ? [[this.name, this.value]] : []),
+ validity: () => ({}),
+ willValidate: () => this.type === 'submit' && !isDisabled(this) && !insideDataList(this),
+ });
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get value(): string {
+ return this.getAttribute('value') ?? '';
+ }
+ set value(value: string) {
+ this.setAttribute('value', value);
+ }
+ get type(): string {
+ const value = this.getAttribute('type')?.toLowerCase();
+ return value === 'button' || value === 'reset' ? value : 'submit';
+ }
+ set type(value: string) {
+ this.setAttribute('type', value);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get formNoValidate(): boolean {
+ return this.hasAttribute('formnovalidate');
+ }
+ set formNoValidate(value: boolean) {
+ this.toggleAttribute('formnovalidate', Boolean(value));
+ }
+ get formMethod(): string {
+ return method(this, 'formmethod');
+ }
+ set formMethod(value: string) {
+ this.setAttribute('formmethod', value);
+ }
+ get form(): FormElement | null {
+ return formOwner(this);
+ }
+ get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+ get willValidate(): boolean {
+ return controlState(this)?.willValidate() ?? false;
+ }
+ get validity(): ValidityState {
+ return validityFor(this);
+ }
+ get validationMessage(): string {
+ return validationMessageFor(this);
+ }
+ setCustomValidity(message: string): void {
+ setControlValidity(this, message);
+ }
+ checkValidity(): boolean {
+ return validateControl(this);
+ }
+ reportValidity(): boolean {
+ return validateControl(this, true);
+ }
+ protected override activationDisabled(): boolean {
+ return isDisabled(this);
+ }
+ protected override activationBehavior(_event: Event): void {
+ if (isDisabled(this)) return;
+ if (this.type === 'submit') this.form?.requestSubmit(this);
+ else if (this.type === 'reset') this.form?.reset();
+ }
+ }
+ class HTMLLabelElement extends Base implements LabelElement {
+ constructor(document: Document) {
+ super('label', document);
+ }
+ get htmlFor(): string {
+ return this.getAttribute('for') ?? '';
+ }
+ set htmlFor(value: string) {
+ this.setAttribute('for', value);
+ }
+ get control(): Element | null {
+ return labelControl(this);
+ }
+ get form(): FormElement | null {
+ return this.control ? formOwner(this.control) : null;
+ }
+ protected override activationBehavior(_event: Event, target: EventTarget | null): void {
+ const control = this.control;
+ if (!control || isDisabled(control)) return;
+ for (
+ let node =
+ target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
+ node && node !== this;
+ node = node.parentElement
+ )
+ if (node === control || interactiveDescendant(node)) return;
+ control.focus();
+ control.click();
+ }
+ }
+ class HTMLFieldSetElement extends Base implements FieldSetElement {
+ readonly #elements = readonlyCollection(() =>
+ descendants(this).filter((element) => controlState(element) !== undefined),
+ );
+ constructor(document: Document) {
+ super('fieldset', document);
+ registerControl(this, {
+ reset: () => {},
+ entries: () => [],
+ validity: () => ({}),
+ willValidate: () => false,
+ });
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get form(): FormElement | null {
+ return formOwner(this);
+ }
+ get type(): 'fieldset' {
+ return 'fieldset';
+ }
+ get elements(): readonly Element[] {
+ return this.#elements;
+ }
+ get willValidate(): false {
+ return false;
+ }
+ get validity(): ValidityState {
+ return validityFor(this);
+ }
+ get validationMessage(): string {
+ return '';
+ }
+ setCustomValidity(message: string): void {
+ setControlValidity(this, message);
+ }
+ checkValidity(): boolean {
+ return true;
+ }
+ reportValidity(): boolean {
+ return true;
+ }
+ }
+ class HTMLLegendElement extends Base implements LegendElement {
+ constructor(document: Document) {
+ super('legend', document);
+ }
+ get form(): FormElement | null {
+ return this.parentElement?.namespaceURI === HTML_NAMESPACE &&
+ this.parentElement.localName === 'fieldset'
+ ? formOwner(this.parentElement)
+ : null;
+ }
+ }
+ return {
+ HTMLFormElement,
+ HTMLButtonElement,
+ HTMLLabelElement,
+ HTMLFieldSetElement,
+ HTMLLegendElement,
+ };
+}
diff --git a/packages/dom/src/forms.ts b/packages/dom/src/forms.ts
new file mode 100644
index 0000000..cacc42a
--- /dev/null
+++ b/packages/dom/src/forms.ts
@@ -0,0 +1,263 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Synchronous HTML form algorithms. */
+import {
+ Element,
+ HTMLFormElement,
+ HTMLInputElement,
+ HTML_NAMESPACE,
+ readonlyCollection,
+ type Node,
+} from './dom.ts';
+import { Event } from './events.ts';
+
+export interface ValidityFlags {
+ valueMissing?: boolean;
+ typeMismatch?: boolean;
+ patternMismatch?: boolean;
+ tooLong?: boolean;
+ tooShort?: boolean;
+ rangeUnderflow?: boolean;
+ rangeOverflow?: boolean;
+ stepMismatch?: boolean;
+ badInput?: boolean;
+}
+
+/** Internal control algorithms, registered without retaining documents or detached controls. */
+export interface FormControl {
+ reset(): void;
+ entries(submitter?: Element | null): readonly (readonly [string, string])[];
+ validity(): ValidityFlags;
+ willValidate(): boolean;
+ value?(): string;
+}
+
+interface ControlRecord {
+ control: FormControl;
+ message: string;
+ validity: ValidityState;
+}
+const controls = new WeakMap();
+
+/** A live view of a control's current constraints. */
+export class ValidityState {
+ readonly #element: Element;
+ constructor(element: Element) {
+ this.#element = element;
+ }
+ get valueMissing(): boolean {
+ return this.#flags.valueMissing ?? false;
+ }
+ get typeMismatch(): boolean {
+ return this.#flags.typeMismatch ?? false;
+ }
+ get patternMismatch(): boolean {
+ return this.#flags.patternMismatch ?? false;
+ }
+ get tooLong(): boolean {
+ return this.#flags.tooLong ?? false;
+ }
+ get tooShort(): boolean {
+ return this.#flags.tooShort ?? false;
+ }
+ get rangeUnderflow(): boolean {
+ return this.#flags.rangeUnderflow ?? false;
+ }
+ get rangeOverflow(): boolean {
+ return this.#flags.rangeOverflow ?? false;
+ }
+ get stepMismatch(): boolean {
+ return this.#flags.stepMismatch ?? false;
+ }
+ get badInput(): boolean {
+ return this.#flags.badInput ?? false;
+ }
+ get customError(): boolean {
+ return Boolean(controls.get(this.#element)?.message);
+ }
+ get valid(): boolean {
+ return !this.customError && !Object.values(this.#flags).some(Boolean);
+ }
+ get #flags(): ValidityFlags {
+ return controls.get(this.#element)?.control.validity() ?? {};
+ }
+}
+
+export function registerControl(element: Element, control: FormControl): void {
+ controls.set(element, { control, message: '', validity: new ValidityState(element) });
+}
+export function controlState(element: Element): FormControl | undefined {
+ return controls.get(element)?.control;
+}
+export function resetFormControl(element: Element): void {
+ controlState(element)?.reset();
+}
+export function validityFor(element: Element): ValidityState {
+ return controls.get(element)?.validity ?? new ValidityState(element);
+}
+export function setControlValidity(element: Element, message: string): void {
+ const record = controls.get(element);
+ if (record && record.message !== String(message)) {
+ record.message = String(message);
+ if (element.isConnected)
+ element.ownerDocument.invalidate({ kind: 'mutation', target: element });
+ }
+}
+export function validationMessageFor(element: Element): string {
+ const record = controls.get(element);
+ if (!record?.control.willValidate() || record.validity.valid) return '';
+ if (record.message) return record.message;
+ if (record.validity.valueMissing) return 'Please fill out this field.';
+ if (record.validity.typeMismatch) return 'Please enter a value of the required type.';
+ if (record.validity.patternMismatch) return 'Please match the requested format.';
+ if (record.validity.tooLong) return 'Please shorten this text.';
+ if (record.validity.tooShort) return 'Please lengthen this text.';
+ if (record.validity.rangeUnderflow) return 'The value is below the allowed minimum.';
+ if (record.validity.rangeOverflow) return 'The value is above the allowed maximum.';
+ if (record.validity.stepMismatch) return 'Please enter a value on the allowed step.';
+ return 'Please enter a valid value.';
+}
+export function validateControl(element: Element, report = false): boolean {
+ const record = controls.get(element);
+ if (!record?.control.willValidate() || record.validity.valid) return true;
+ const uncanceled = element.dispatchEvent(new Event('invalid', { cancelable: true }));
+ if (report && uncanceled) element.focus();
+ return false;
+}
+
+/** Tree-order traversal stays inside one light-tree root, including detached roots. */
+export function formTree(root: Node): Element[] {
+ const result: Element[] = root instanceof Element ? [root] : [];
+ for (const child of root.childNodes) result.push(...formTree(child));
+ return result;
+}
+
+export function formOwner(element: Element): HTMLFormElement | null {
+ if (element.hasAttribute('form') && element.isConnected) {
+ const id = element.getAttribute('form');
+ if (!id) return null;
+ const candidate = formTree(element.getRootNode()).find((node) => node.id === id);
+ return isForm(candidate) ? candidate : null;
+ }
+ for (let parent = element.parentElement; parent; parent = parent.parentElement)
+ if (isForm(parent)) return parent;
+ return null;
+}
+function isForm(element: Element | undefined): element is HTMLFormElement {
+ return element instanceof HTMLFormElement;
+}
+export function formControls(form: Element): readonly Element[] {
+ return Object.freeze(
+ formTree(form.getRootNode()).filter(
+ (element) => controls.has(element) && formOwner(element) === form,
+ ),
+ );
+}
+
+export function isDisabled(element: Element): boolean {
+ if (element.namespaceURI !== HTML_NAMESPACE) return false;
+ if (
+ ['button', 'input', 'select', 'textarea', 'fieldset', 'optgroup', 'option'].includes(
+ element.localName,
+ ) &&
+ element.hasAttribute('disabled')
+ )
+ return true;
+ if (
+ element.localName === 'option' &&
+ element.parentElement?.namespaceURI === HTML_NAMESPACE &&
+ element.parentElement?.localName === 'optgroup' &&
+ element.parentElement.hasAttribute('disabled')
+ )
+ return true;
+ for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
+ if (
+ ancestor.namespaceURI !== HTML_NAMESPACE ||
+ ancestor.localName !== 'fieldset' ||
+ !ancestor.hasAttribute('disabled')
+ )
+ continue;
+ const legend = ancestor.children.find(
+ (child) => child.namespaceURI === HTML_NAMESPACE && child.localName === 'legend',
+ );
+ if (!legend?.contains(element)) return true;
+ }
+ return false;
+}
+
+export function isLabelable(element: Element): boolean {
+ return (
+ element.namespaceURI === HTML_NAMESPACE &&
+ ['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea'].includes(
+ element.localName,
+ ) &&
+ !(element.localName === 'input' && element.getAttribute('type')?.toLowerCase() === 'hidden')
+ );
+}
+export function labelControl(label: Element): Element | null {
+ if (label.hasAttribute('for')) {
+ const id = label.getAttribute('for');
+ if (!id) return null;
+ const candidate = formTree(label.getRootNode()).find((node) => node.id === id);
+ return candidate && isLabelable(candidate) ? candidate : null;
+ }
+ return formTree(label).find((node) => node !== label && isLabelable(node)) ?? null;
+}
+const labelCollections = new WeakMap();
+export function labelsFor(element: Element): readonly Element[] {
+ let collection = labelCollections.get(element);
+ if (!collection) {
+ collection = readonlyCollection(() =>
+ formTree(element.getRootNode()).filter(
+ (node) =>
+ node.namespaceURI === HTML_NAMESPACE &&
+ node.localName === 'label' &&
+ labelControl(node) === element,
+ ),
+ );
+ labelCollections.set(element, collection);
+ }
+ return collection;
+}
+
+/** Descendants of a datalist are barred from constraint validation. */
+export function inDatalist(element: Element): boolean {
+ for (let parent = element.parentElement; parent; parent = parent.parentElement)
+ if (parent.namespaceURI === HTML_NAMESPACE && parent.localName === 'datalist') return true;
+ return false;
+}
+
+/** Host-driver Enter behavior; synthetic keyboard dispatch does not call this. */
+export function implicitSubmit(element: Element): void {
+ const form = formOwner(element);
+ if (!form) return;
+ const listed = formControls(form);
+ const submitter = listed.find((control) => {
+ const type = control.getAttribute('type')?.toLowerCase() ?? '';
+ return control.localName === 'button'
+ ? !['button', 'reset'].includes(type)
+ : control.localName === 'input' && ['submit', 'image'].includes(type);
+ });
+ if (submitter) {
+ if (!isDisabled(submitter)) submitter.click();
+ return;
+ }
+ const blockingTypes = [
+ 'text',
+ 'search',
+ 'tel',
+ 'url',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ ];
+ if (
+ listed.filter(
+ (control) => control instanceof HTMLInputElement && blockingTypes.includes(control.type),
+ ).length < 2
+ )
+ form.requestSubmit();
+}
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..c5fcca1
--- /dev/null
+++ b/packages/dom/src/index.ts
@@ -0,0 +1,59 @@
+export {
+ Node,
+ Document,
+ Element,
+ Attr,
+ CharacterData,
+ Text,
+ Comment,
+ DocumentFragment,
+ ShadowRoot,
+ HTMLInputElement,
+ HTMLTextAreaElement,
+ HTMLTemplateElement,
+ HTMLSlotElement,
+ HTMLButtonElement,
+ HTMLFormElement,
+ HTMLLabelElement,
+ HTMLFieldSetElement,
+ HTMLLegendElement,
+ HTMLSelectElement,
+ HTMLOptionElement,
+ HTMLOptGroupElement,
+ HTMLDataListElement,
+ HTMLSelectedContentElement,
+ HTMLOutputElement,
+ HTMLProgressElement,
+ HTMLMeterElement,
+ HTMLDetailsElement,
+ HTMLSummaryElement,
+ HTMLDialogElement,
+ 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';
+
+export { FormData } from './form-elements.ts';
+export type { ValidityState } from './forms.ts';
+
+export { DOMTokenList } from './tokens.ts';
+
+export type { ShadowRootInit, HTMLSerializationOptions } from './dom.ts';
diff --git a/packages/dom/src/interactive-elements.ts b/packages/dom/src/interactive-elements.ts
new file mode 100644
index 0000000..cdfaf82
--- /dev/null
+++ b/packages/dom/src/interactive-elements.ts
@@ -0,0 +1,421 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Element algorithms and DOM accessors are synchronous. */
+import {
+ type Document,
+ Element,
+ HTML_NAMESPACE,
+ Node,
+ ShadowRoot,
+ getAssignedSlot,
+ getShadowRoot,
+} from './dom.ts';
+import { Event, ToggleEvent, type EventTarget } from './events.ts';
+import { isFocusable } from './focus.ts';
+import { subscribeMutations } from './hooks.ts';
+
+interface DialogState {
+ modal: boolean;
+ previous: Element | null;
+ returnValue: string;
+ closing: boolean;
+ requesting: boolean;
+}
+export interface DetailsElement extends Element {
+ open: boolean;
+ name: string;
+}
+export interface DialogElement extends Element {
+ open: boolean;
+ returnValue: string;
+ closedBy: string;
+ show(): void;
+ showModal(): void;
+ close(returnValue?: string): void;
+ requestClose(returnValue?: string): void;
+}
+interface InteractiveElementConstructors {
+ HTMLDetailsElement: new (document: Document) => DetailsElement;
+ HTMLSummaryElement: new (document: Document) => Element;
+ HTMLDialogElement: new (document: Document) => DialogElement;
+}
+const dialogs = new WeakMap();
+const modalStacks = new WeakMap();
+const openDialogs = new WeakMap();
+const toggles = new WeakMap<
+ Element,
+ { oldState: string; newState: string; timer: ReturnType }
+>();
+
+function parent(node: Node): Node | null {
+ return (
+ getAssignedSlot(node) ?? node.parentNode ?? (node instanceof ShadowRoot ? node.host : null)
+ );
+}
+
+function contains(ancestor: Node, node: Node): boolean {
+ for (let current: Node | null = node; current; current = parent(current)) {
+ if (current === ancestor) return true;
+ }
+ return false;
+}
+
+function descendants(root: Node): Element[] {
+ const result: Element[] = [];
+ for (const child of root.childNodes) {
+ if (!(child instanceof Element)) continue;
+ result.push(child, ...descendants(child));
+ }
+ return result;
+}
+
+function shadowDescendants(root: Node): Element[] {
+ const result: Element[] = [];
+ const shadow = root instanceof Element ? getShadowRoot(root) : null;
+ if (shadow) result.push(...shadowDescendants(shadow));
+ for (const child of root.childNodes) {
+ if (!(child instanceof Element)) continue;
+ result.push(child, ...shadowDescendants(child));
+ }
+ return result;
+}
+
+function queueToggle(element: Element, transition: { oldState: string; newState: string }): void {
+ const previous = toggles.get(element);
+ if (previous) clearTimeout(previous.timer);
+ const task = {
+ oldState: previous?.oldState ?? transition.oldState,
+ newState: transition.newState,
+ timer: setTimeout(() => {
+ toggles.delete(element);
+ element.dispatchEvent(
+ new ToggleEvent('toggle', { oldState: task.oldState, newState: task.newState }),
+ );
+ }, 0),
+ };
+ toggles.set(element, task);
+}
+
+function firstSummary(details: Element): Element | undefined {
+ return details.children.find(
+ (child) => child.namespaceURI === HTML_NAMESPACE && child.localName === 'summary',
+ );
+}
+
+/** @internal Only the first summary child supplies the disclosure control. */
+export function isSummary(element: Element): boolean {
+ return (
+ element.namespaceURI === HTML_NAMESPACE &&
+ element.localName === 'summary' &&
+ element.parentElement?.namespaceURI === HTML_NAMESPACE &&
+ element.parentElement?.localName === 'details' &&
+ firstSummary(element.parentElement) === element
+ );
+}
+
+/** @internal Logical inertness, including the document's topmost modal dialog. */
+export function isInteractionInert(element: Element): boolean {
+ const modal = modalStacks.get(element.ownerDocument)?.at(-1);
+ if (modal && !contains(modal, element)) return true;
+ for (let current: Node | null = element; current; current = parent(current)) {
+ if (current instanceof Element && current.hasAttribute('inert')) return true;
+ if (current === modal) break;
+ }
+ return false;
+}
+
+/** @internal Widget state contributes to logical visibility without a CSS engine. */
+export function isClosedWidgetContent(element: Element): boolean {
+ for (let current: Node | null = element; current; current = parent(current)) {
+ if (!(current instanceof Element) || current.namespaceURI !== HTML_NAMESPACE) continue;
+ if (current.localName === 'dialog' && !current.hasAttribute('open')) return true;
+ if (current !== element && current.localName === 'details' && !current.hasAttribute('open')) {
+ const summary = firstSummary(current);
+ if (!summary || !contains(summary, element)) return true;
+ }
+ }
+ return false;
+}
+
+function ensureExclusive(element: Element, opening: boolean): void {
+ const name = element.getAttribute('name');
+ if (!name || !element.hasAttribute('open')) return;
+ const root = element.getRootNode();
+ const peers = [...(root instanceof Element ? [root] : []), ...descendants(root)];
+ for (const peer of peers) {
+ if (
+ peer === element ||
+ peer.namespaceURI !== HTML_NAMESPACE ||
+ peer.localName !== 'details' ||
+ peer.getAttribute('name') !== name ||
+ !peer.hasAttribute('open')
+ )
+ continue;
+ if (opening) peer.removeAttribute('open');
+ else {
+ element.removeAttribute('open');
+ return;
+ }
+ }
+}
+
+/** @internal Called by click activation after cancellation has been checked. */
+export function activateInteractive(element: Element, target: EventTarget | null): boolean {
+ if (!isSummary(element)) return false;
+ for (
+ let current =
+ target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
+ current instanceof Element && current !== element;
+ current = current.parentElement
+ ) {
+ if (
+ current.namespaceURI === HTML_NAMESPACE &&
+ (['button', 'input', 'select', 'textarea', 'summary'].includes(current.localName) ||
+ (current.localName === 'a' && current.hasAttribute('href')))
+ )
+ return true;
+ }
+ element.parentElement?.toggleAttribute('open');
+ return true;
+}
+
+function dialogState(element: Element): DialogState {
+ const state = dialogs.get(element);
+ if (!state) throw new DOMException('Expected a dialog element', 'InvalidStateError');
+ return state;
+}
+
+function registerOpen(element: Element): void {
+ if (!element.isConnected || !element.hasAttribute('open')) return;
+ const document = element.ownerDocument;
+ let list = openDialogs.get(document);
+ if (!list) {
+ list = [];
+ openDialogs.set(document, list);
+ }
+ if (!list.includes(element)) list.push(element);
+}
+
+function removeFrom(list: Element[] | undefined, element: Element): void {
+ const index = list?.indexOf(element) ?? -1;
+ if (index >= 0) list?.splice(index, 1);
+}
+
+function closeDialog(element: Element, result?: string): void {
+ const state = dialogState(element);
+ if (!element.hasAttribute('open') || state.closing) return;
+ state.closing = true;
+ try {
+ element.dispatchEvent(
+ new ToggleEvent('beforetoggle', { oldState: 'open', newState: 'closed' }),
+ );
+ if (!element.hasAttribute('open')) return;
+ const document = element.ownerDocument;
+ const active = document.focusManager.activeElement;
+ const restore = state.modal || Boolean(active && contains(element, active));
+ queueToggle(element, { oldState: 'open', newState: 'closed' });
+ element.removeAttribute('open');
+ removeFrom(modalStacks.get(document), element);
+ state.modal = false;
+ if (result !== undefined) state.returnValue = String(result);
+ const previous = state.previous;
+ state.previous = null;
+ if (restore && previous) previous.focus();
+ document.focusManager.revalidate();
+ setTimeout(() => element.dispatchEvent(new Event('close')), 0);
+ } finally {
+ state.closing = false;
+ }
+}
+
+function requestClose(element: Element, result?: string): void {
+ const state = dialogState(element);
+ if (!element.isConnected || !element.hasAttribute('open') || state.requesting) return;
+ state.requesting = true;
+ try {
+ if (element.dispatchEvent(new Event('cancel', { cancelable: true })))
+ closeDialog(element, result);
+ } finally {
+ state.requesting = false;
+ }
+}
+
+/** @internal Escape closes the last eligible open dialog; requestClose ignores closedby. */
+export function requestDialogClose(document: Document): boolean {
+ const element = openDialogs.get(document)?.at(-1);
+ if (!element) return false;
+ const value = element.getAttribute('closedby')?.toLowerCase();
+ if (
+ value === 'none' ||
+ (!['any', 'closerequest'].includes(value ?? '') && !dialogState(element).modal)
+ )
+ return false;
+ requestClose(element);
+ return true;
+}
+
+function showDialog(element: Element, modal: boolean): void {
+ const state = dialogState(element);
+ if (element.hasAttribute('open')) {
+ if (state.modal !== modal)
+ throw new DOMException('The dialog is already open in a different mode', 'InvalidStateError');
+ return;
+ }
+ if (modal && !element.isConnected)
+ throw new DOMException('A modal dialog must be connected', 'InvalidStateError');
+ if (
+ !element.dispatchEvent(
+ new ToggleEvent('beforetoggle', { oldState: 'closed', newState: 'open', cancelable: true }),
+ )
+ )
+ return;
+ if (element.hasAttribute('open') || (modal && !element.isConnected)) return;
+ const document = element.ownerDocument;
+ state.previous = document.focusManager.activeElement;
+ queueToggle(element, { oldState: 'closed', newState: 'open' });
+ element.setAttribute('open', '');
+ state.modal = modal;
+ if (modal) {
+ let stack = modalStacks.get(document);
+ if (!stack) {
+ stack = [];
+ modalStacks.set(document, stack);
+ }
+ removeFrom(stack, element);
+ stack.push(element);
+ }
+ const candidates = shadowDescendants(element).filter(isFocusable);
+ const focus = element.hasAttribute('autofocus')
+ ? element
+ : (candidates.find((child) => child.hasAttribute('autofocus')) ?? candidates[0] ?? element);
+ focus.focus();
+ document.focusManager.revalidate();
+}
+
+/** @internal Define subclasses after the shared Element base has initialized. */
+export function defineInteractiveElements(
+ ElementBase: typeof Element,
+): InteractiveElementConstructors {
+ class HTMLDetailsElement extends ElementBase {
+ constructor(document: Document) {
+ super('details', document);
+ }
+ get open(): boolean {
+ return this.hasAttribute('open');
+ }
+ set open(value: boolean) {
+ this.toggleAttribute('open', Boolean(value));
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ }
+ class HTMLSummaryElement extends ElementBase {
+ constructor(document: Document) {
+ super('summary', document);
+ }
+ }
+ class HTMLDialogElement extends ElementBase {
+ constructor(document: Document) {
+ super('dialog', document);
+ dialogs.set(this, {
+ modal: false,
+ previous: null,
+ returnValue: '',
+ closing: false,
+ requesting: false,
+ });
+ }
+ get open(): boolean {
+ return this.hasAttribute('open');
+ }
+ set open(value: boolean) {
+ this.toggleAttribute('open', Boolean(value));
+ }
+ get returnValue(): string {
+ return dialogState(this).returnValue;
+ }
+ set returnValue(value: string) {
+ dialogState(this).returnValue = String(value);
+ }
+ get closedBy(): string {
+ const value = this.getAttribute('closedby')?.toLowerCase();
+ return value && ['any', 'closerequest', 'none'].includes(value)
+ ? value
+ : dialogState(this).modal
+ ? 'closerequest'
+ : 'none';
+ }
+ set closedBy(value: string) {
+ this.setAttribute('closedby', value);
+ }
+ show(): void {
+ showDialog(this, false);
+ }
+ showModal(): void {
+ showDialog(this, true);
+ }
+ close(returnValue?: string): void {
+ closeDialog(this, returnValue);
+ }
+ requestClose(returnValue?: string): void {
+ requestClose(this, returnValue);
+ }
+ }
+ return { HTMLDetailsElement, HTMLSummaryElement, HTMLDialogElement };
+}
+
+subscribeMutations((record) => {
+ if (
+ record.type === 'attributes' &&
+ record.target instanceof Element &&
+ !record.attributeNamespace
+ ) {
+ const element = record.target;
+ if (element.namespaceURI === HTML_NAMESPACE && element.localName === 'details') {
+ if (record.attributeName === 'name') ensureExclusive(element, false);
+ if (
+ record.attributeName === 'open' &&
+ (record.oldValue === null) !== !element.hasAttribute('open')
+ ) {
+ queueToggle(element, {
+ oldState: record.oldValue === null ? 'closed' : 'open',
+ newState: element.hasAttribute('open') ? 'open' : 'closed',
+ });
+ ensureExclusive(element, true);
+ element.ownerDocument.focusManager.revalidate();
+ }
+ }
+ if (dialogs.has(element) && record.attributeName === 'open') {
+ if (element.hasAttribute('open')) registerOpen(element);
+ else removeFrom(openDialogs.get(element.ownerDocument), element);
+ element.ownerDocument.focusManager.revalidate();
+ }
+ }
+ if (record.type !== 'childList') return;
+ for (const removed of record.removedNodes ?? []) {
+ const elements = [
+ ...(removed instanceof Element ? [removed] : []),
+ ...shadowDescendants(removed),
+ ];
+ for (const element of elements) {
+ const state = dialogs.get(element);
+ if (!state) continue;
+ removeFrom(modalStacks.get(element.ownerDocument), element);
+ removeFrom(openDialogs.get(element.ownerDocument), element);
+ state.modal = false;
+ state.previous = null;
+ }
+ }
+ for (const added of record.addedNodes ?? []) {
+ for (const element of [
+ ...(added instanceof Element ? [added] : []),
+ ...shadowDescendants(added),
+ ]) {
+ if (element.namespaceURI === HTML_NAMESPACE && element.localName === 'details')
+ ensureExclusive(element, false);
+ if (dialogs.has(element)) registerOpen(element);
+ }
+ }
+});
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..192a10a
--- /dev/null
+++ b/packages/dom/src/parsing.ts
@@ -0,0 +1,160 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Parser installation is deliberately synchronous. */
+import { parseFragment, defaultTreeAdapter, html, type DefaultTreeAdapterMap } from 'parse5';
+import { enqueueUpgrade, withParsingReactions } from './custom-elements.ts';
+import {
+ type Document,
+ Element,
+ Attr,
+ Text,
+ Comment,
+ HTMLTemplateElement,
+ type Node,
+ type ShadowRoot,
+ getShadowRoot,
+ markDeclarativeShadowRoot,
+ type HTMLSerializationOptions,
+} from './dom.ts';
+
+type ParsedNode = DefaultTreeAdapterMap['childNode'];
+// oxlint-disable-next-line bombshell-dev/max-params -- Conversion needs the parsed node, owner document, and declarative-shadow mode.
+function convert(node: ParsedNode, document: Document, declarative = false): 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, declarative);
+ if (result && !attachDeclarative(destination, result, declarative))
+ 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, options: HTMLSerializationOptions = {}): string {
+ if (node instanceof Text) return escapeText(node.data);
+ if (node instanceof Comment) return ``;
+ if (!(node instanceof Element)) return serializeChildren(node, options);
+ 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, options)}${name}>`;
+}
+function serializeChildren(node: Node, options: HTMLSerializationOptions = {}): string {
+ const shadow = node instanceof Element ? getShadowRoot(node) : null;
+ let prefix = '';
+ if (
+ shadow &&
+ ((options.serializableShadowRoots && shadow.serializable) ||
+ options.shadowRoots?.includes(shadow))
+ ) {
+ const flags = `${shadow.delegatesFocus ? ' shadowrootdelegatesfocus=""' : ''}${shadow.serializable ? ' shadowrootserializable=""' : ''}${shadow.slotAssignment === 'manual' ? ' shadowrootslotassignment="manual"' : ''}${shadow.clonable ? ' shadowrootclonable=""' : ''}`;
+ prefix = `${serializeChildren(shadow, options)}`;
+ }
+ return prefix + node.childNodes.map((child) => serializeNode(child, options)).join('');
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Declarative attachment needs the parent, candidate, and parser mode.
+function attachDeclarative(parent: Node, child: Node, enabled: boolean): boolean {
+ if (
+ !enabled ||
+ !(parent instanceof Element) ||
+ !(child instanceof HTMLTemplateElement) ||
+ getShadowRoot(parent)
+ )
+ return false;
+ const mode = child.shadowRootMode.toLowerCase();
+ if (mode !== 'open' && mode !== 'closed') return false;
+ if (parent.namespaceURI !== html.NS.HTML) return false;
+ const shadow = parent.attachShadow({
+ mode,
+ delegatesFocus: child.shadowRootDelegatesFocus,
+ clonable: child.shadowRootClonable,
+ serializable: child.shadowRootSerializable,
+ slotAssignment: child.shadowRootSlotAssignment === 'manual' ? 'manual' : 'named',
+ });
+ markDeclarativeShadowRoot(shadow);
+ shadow.appendChild(child.content);
+ enqueueTree(shadow);
+ return true;
+}
+function enqueueTree(node: Node): void {
+ if (node instanceof Element) {
+ if (node.localName.includes('-')) enqueueUpgrade(node);
+ const shadow = getShadowRoot(node);
+ if (shadow) enqueueTree(shadow);
+ }
+ for (const child of node.childNodes) enqueueTree(child);
+}
+/** 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,
+ options: { declarativeShadowRoots?: boolean } = {},
+ ) {
+ return withParsingReactions(document, () => {
+ 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, Boolean(options.declarativeShadowRoots));
+ if (node) fragment.appendChild(node);
+ }
+ 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/tokens.ts b/packages/dom/src/tokens.ts
new file mode 100644
index 0000000..7d03935
--- /dev/null
+++ b/packages/dom/src/tokens.ts
@@ -0,0 +1,96 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Token list access and mutation are synchronous. */
+import type { Element } from './dom.ts';
+
+/** A live ordered token set backed by an attribute. */
+export class DOMTokenList implements Iterable {
+ readonly #element: Element;
+ readonly #attribute: string;
+ constructor(element: Element, attribute: string) {
+ this.#element = element;
+ this.#attribute = attribute;
+ }
+ get value(): string {
+ return this.#element.getAttribute(this.#attribute) ?? '';
+ }
+ set value(value: string) {
+ this.#element.setAttribute(this.#attribute, String(value));
+ }
+ get length(): number {
+ return this.#tokens().length;
+ }
+ item(index: number): string | null {
+ return this.#tokens()[Number(index) >>> 0] ?? null;
+ }
+ contains(token: string): boolean {
+ return this.#tokens().includes(String(token));
+ }
+ add(...tokens: string[]): void {
+ const added = tokens.map(validateToken);
+ this.#write([...new Set([...this.#tokens(), ...added])]);
+ }
+ remove(...tokens: string[]): void {
+ const removed = tokens.map(validateToken);
+ this.#write(this.#tokens().filter((token) => !removed.includes(token)));
+ }
+ toggle(token: string, force?: boolean): boolean {
+ token = validateToken(token);
+ const exists = this.contains(token);
+ if (exists && force !== true) {
+ this.remove(token);
+ return false;
+ }
+ if (!exists && force !== false) {
+ this.add(token);
+ return true;
+ }
+ return exists;
+ }
+ replace(token: string, replacement: string): boolean {
+ token = validateToken(token);
+ replacement = validateToken(replacement);
+ const tokens = this.#tokens();
+ if (!tokens.includes(token)) return false;
+ this.#write([...new Set(tokens.map((entry) => (entry === token ? replacement : entry)))]);
+ return true;
+ }
+ supports(_token: string): never {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- class/for have no supported-token vocabulary in HTML.
+ throw new TypeError('This attribute has no supported token vocabulary');
+ }
+ [Symbol.iterator](): IterableIterator {
+ return this.values();
+ }
+ *values(): IterableIterator {
+ for (let index = 0; index < this.length; index++) yield this.item(index)!;
+ }
+ *keys(): IterableIterator {
+ for (let index = 0; index < this.length; index++) yield index;
+ }
+ *entries(): IterableIterator<[number, string]> {
+ for (let index = 0; index < this.length; index++) yield [index, this.item(index)!];
+ }
+ forEach(
+ callback: (value: string, key: number, list: DOMTokenList) => void,
+ thisArg?: unknown,
+ ): void {
+ for (let index = 0; index < this.length; index++)
+ callback.call(thisArg, this.item(index)!, index, this);
+ }
+ toString(): string {
+ return this.value;
+ }
+ #tokens(): string[] {
+ return [...new Set(this.value.split(/[\t\n\f\r ]+/).filter(Boolean))];
+ }
+ #write(tokens: readonly string[]): void {
+ if (!tokens.length && !this.#element.hasAttribute(this.#attribute)) return;
+ this.value = tokens.join(' ');
+ }
+}
+function validateToken(value: string): string {
+ const token = String(value);
+ if (!token) throw new DOMException('Token must not be empty', 'SyntaxError');
+ if (/[\t\n\f\r ]/.test(token))
+ throw new DOMException('Token contains whitespace', 'InvalidCharacterError');
+ return token;
+}
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/src/value-elements.ts b/packages/dom/src/value-elements.ts
new file mode 100644
index 0000000..c3b1ee9
--- /dev/null
+++ b/packages/dom/src/value-elements.ts
@@ -0,0 +1,828 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- Synchronous HTML element algorithms. */
+import {
+ Element,
+ Text,
+ descendants,
+ readonlyCollection,
+ type Document,
+ type HTMLFormElement,
+ type Node,
+} from './dom.ts';
+import { Event } from './events.ts';
+import { subscribeMutations } from './hooks.ts';
+import { DOMTokenList } from './tokens.ts';
+import {
+ formOwner,
+ isDisabled,
+ labelsFor,
+ registerControl,
+ setControlValidity,
+ validateControl,
+ validationMessageFor,
+ validityFor,
+ type ValidityState,
+} from './forms.ts';
+
+interface FormValueElement extends Element {
+ name: string;
+ readonly form: HTMLFormElement | null;
+ readonly labels: readonly Element[];
+ readonly willValidate: boolean;
+ readonly validity: ValidityState;
+ readonly validationMessage: string;
+ checkValidity(): boolean;
+ reportValidity(): boolean;
+ setCustomValidity(message: string): void;
+}
+
+export interface SelectElement extends FormValueElement {
+ disabled: boolean;
+ multiple: boolean;
+ required: boolean;
+ size: number;
+ readonly type: 'select-one' | 'select-multiple';
+ readonly options: readonly OptionElement[];
+ readonly selectedOptions: readonly OptionElement[];
+ length: number;
+ selectedIndex: number;
+ value: string;
+ item(index: number): OptionElement | null;
+ namedItem(name: string): OptionElement | null;
+ add(element: OptionElement | OptGroupElement, before?: Element | number | null): void;
+ remove(index?: number): void;
+}
+export interface OptionElement extends Element {
+ disabled: boolean;
+ defaultSelected: boolean;
+ selected: boolean;
+ value: string;
+ label: string;
+ text: string;
+ readonly index: number;
+ readonly form: HTMLFormElement | null;
+}
+export interface OptGroupElement extends Element {
+ disabled: boolean;
+ label: string;
+}
+export interface DataListElement extends Element {
+ readonly options: readonly OptionElement[];
+}
+export interface OutputElement extends FormValueElement {
+ readonly type: 'output';
+ get htmlFor(): DOMTokenList;
+ set htmlFor(value: string);
+ value: string;
+ defaultValue: string;
+}
+export interface ProgressElement extends Element {
+ value: number;
+ max: number;
+ readonly position: number;
+ readonly labels: readonly Element[];
+}
+export interface MeterElement extends Element {
+ value: number;
+ min: number;
+ max: number;
+ low: number;
+ high: number;
+ optimum: number;
+ readonly labels: readonly Element[];
+}
+interface ValueElementConstructors {
+ HTMLSelectElement: new (document: Document) => SelectElement;
+ HTMLOptionElement: new (document: Document) => OptionElement;
+ HTMLOptGroupElement: new (document: Document) => OptGroupElement;
+ HTMLDataListElement: new (document: Document) => DataListElement;
+ HTMLSelectedContentElement: new (document: Document) => Element;
+ HTMLOutputElement: new (document: Document) => OutputElement;
+ HTMLProgressElement: new (document: Document) => ProgressElement;
+ HTMLMeterElement: new (document: Document) => MeterElement;
+}
+interface OptionState {
+ selected: boolean;
+ dirty: boolean;
+}
+const optionStates = new WeakMap();
+const selectStates = new WeakMap();
+const updatingContent = new WeakSet();
+const queuedContentUpdates = new WeakSet();
+
+/** @internal Define subclasses after the common Element base has initialized. */
+export function defineValueElements(ElementBase: typeof Element): ValueElementConstructors {
+ class HTMLSelectElement extends ElementBase implements SelectElement {
+ readonly #options = readonlyCollection(() => optionsOf(this));
+ readonly #selectedOptions = readonlyCollection(() =>
+ optionsOf(this).filter((option) => option.selected),
+ );
+ constructor(document: Document) {
+ super('select', document);
+ selectStates.set(this, this);
+ registerControl(this, {
+ reset: () => {
+ for (const option of optionsOf(this)) {
+ const state = optionState(option);
+ state.dirty = false;
+ state.selected = option.defaultSelected;
+ }
+ normalizeSelection(this);
+ updateSelectedContent(this);
+ invalidate(this);
+ },
+ entries: () =>
+ this.name && !isDisabled(this)
+ ? optionsOf(this)
+ .filter((option) => option.selected && !optionDisabled(option))
+ .map((option) => [this.name, option.value] as const)
+ : [],
+ validity: () => {
+ const options = optionsOf(this);
+ const selected = options.filter((option) => option.selected);
+ const placeholder =
+ !this.multiple &&
+ displaySize(this) === 1 &&
+ options[0]?.parentNode === this &&
+ options[0].value === ''
+ ? options[0]
+ : null;
+ return {
+ valueMissing:
+ this.required &&
+ (selected.length === 0 || (selected.length === 1 && selected[0] === placeholder)),
+ };
+ },
+ willValidate: () => !isDisabled(this) && !hasAncestor(this, 'datalist'),
+ value: () => this.value,
+ });
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get multiple(): boolean {
+ return this.hasAttribute('multiple');
+ }
+ set multiple(value: boolean) {
+ this.toggleAttribute('multiple', Boolean(value));
+ }
+ get required(): boolean {
+ return this.hasAttribute('required');
+ }
+ set required(value: boolean) {
+ this.toggleAttribute('required', Boolean(value));
+ }
+ get size(): number {
+ const match = /^[\t\n\f\r ]*\+?(\d+)/.exec(this.getAttribute('size') ?? '');
+ const size = match ? Number(match[1]) : 0;
+ return size <= 2147483647 ? size : 0;
+ }
+ set size(value: number) {
+ this.setAttribute('size', String(Number(value) >>> 0));
+ }
+ get type(): 'select-one' | 'select-multiple' {
+ return this.multiple ? 'select-multiple' : 'select-one';
+ }
+ get form(): HTMLFormElement | null {
+ return formOwner(this);
+ }
+ get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+ get willValidate(): boolean {
+ return !isDisabled(this) && !hasAncestor(this, 'datalist');
+ }
+ get validity(): ValidityState {
+ return validityFor(this);
+ }
+ get validationMessage(): string {
+ return validationMessageFor(this);
+ }
+ checkValidity(): boolean {
+ return validateControl(this);
+ }
+ reportValidity(): boolean {
+ return validateControl(this, true);
+ }
+ setCustomValidity(message: string): void {
+ setControlValidity(this, message);
+ }
+ get options(): readonly OptionElement[] {
+ return this.#options;
+ }
+ get selectedOptions(): readonly OptionElement[] {
+ return this.#selectedOptions;
+ }
+ get length(): number {
+ return this.options.length;
+ }
+ set length(value: number) {
+ const length = Number(value) >>> 0;
+ while (this.length > length) this.options.at(-1)?.remove();
+ while (this.length < length) this.appendChild(new HTMLOptionElement(this.ownerDocument));
+ }
+ get selectedIndex(): number {
+ return optionsOf(this).findIndex((option) => option.selected);
+ }
+ set selectedIndex(value: number) {
+ const index = Number(value) | 0;
+ for (const [current, option] of optionsOf(this).entries()) {
+ const state = optionState(option);
+ state.selected = current === index;
+ if (state.selected) state.dirty = true;
+ }
+ updateSelectedContent(this);
+ invalidate(this);
+ }
+ get value(): string {
+ return this.options.find((option) => option.selected)?.value ?? '';
+ }
+ set value(value: string) {
+ const text = String(value);
+ this.selectedIndex = optionsOf(this).findIndex((option) => option.value === text);
+ }
+ item(index: number): OptionElement | null {
+ return this.options[Number(index) >>> 0] ?? null;
+ }
+ namedItem(name: string): OptionElement | null {
+ const key = String(name);
+ return key
+ ? (this.options.find(
+ (option) => option.id === key || option.getAttribute('name') === key,
+ ) ?? null)
+ : null;
+ }
+ add(element: OptionElement | OptGroupElement, before: Element | number | null = null): void {
+ if (!(element instanceof HTMLOptionElement || element instanceof HTMLOptGroupElement)) {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL interface conversion.
+ throw new TypeError('Expected an option or optgroup');
+ }
+ const anchor =
+ typeof before === 'number' ? (this.options[Number(before) | 0] ?? null) : before;
+ if (anchor && (anchor === this || !this.contains(anchor)))
+ throw new DOMException('The anchor is not in this select', 'NotFoundError');
+ (anchor?.parentNode ?? this).insertBefore(element, anchor);
+ }
+ override remove(index?: number): void {
+ if (arguments.length === 0) super.remove();
+ else this.options[Number(index) | 0]?.remove();
+ }
+ }
+ class HTMLOptionElement extends ElementBase implements OptionElement {
+ constructor(document: Document) {
+ super('option', document);
+ optionStates.set(this, { selected: false, dirty: false });
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get defaultSelected(): boolean {
+ return this.hasAttribute('selected');
+ }
+ set defaultSelected(value: boolean) {
+ this.toggleAttribute('selected', Boolean(value));
+ }
+ get selected(): boolean {
+ return optionState(this).selected;
+ }
+ set selected(value: boolean) {
+ const state = optionState(this);
+ state.dirty = true;
+ state.selected = Boolean(value);
+ const select = ownerSelect(this);
+ if (select) {
+ if (state.selected) deselectOthers(select, this);
+ normalizeSelection(select);
+ updateSelectedContent(select);
+ }
+ invalidate(this);
+ }
+ get value(): string {
+ return this.getAttribute('value') ?? optionText(this);
+ }
+ set value(value: string) {
+ this.setAttribute('value', value);
+ }
+ get label(): string {
+ return this.getAttribute('label') ?? optionText(this);
+ }
+ set label(value: string) {
+ this.setAttribute('label', value);
+ }
+ get text(): string {
+ return optionText(this);
+ }
+ set text(value: string) {
+ this.textContent = String(value);
+ }
+ get index(): number {
+ return ownerSelect(this)?.options.indexOf(this) ?? 0;
+ }
+ get form(): HTMLFormElement | null {
+ const select = ownerSelect(this);
+ return select ? formOwner(select) : null;
+ }
+ }
+ class HTMLOptGroupElement extends ElementBase implements OptGroupElement {
+ constructor(document: Document) {
+ super('optgroup', document);
+ }
+ get disabled(): boolean {
+ return this.hasAttribute('disabled');
+ }
+ set disabled(value: boolean) {
+ this.toggleAttribute('disabled', Boolean(value));
+ }
+ get label(): string {
+ return this.getAttribute('label') ?? '';
+ }
+ set label(value: string) {
+ this.setAttribute('label', value);
+ }
+ }
+ class HTMLDataListElement extends ElementBase implements DataListElement {
+ readonly #options = readonlyCollection(() =>
+ descendants(this).filter((element): element is OptionElement => optionStates.has(element)),
+ );
+ constructor(document: Document) {
+ super('datalist', document);
+ }
+ get options(): readonly OptionElement[] {
+ return this.#options;
+ }
+ }
+ class HTMLSelectedContentElement extends ElementBase {
+ constructor(document: Document) {
+ super('selectedcontent', document);
+ }
+ }
+ class HTMLOutputElement extends ElementBase implements OutputElement {
+ #defaultValue: string | null = null;
+ readonly #htmlFor = new DOMTokenList(this, 'for');
+ constructor(document: Document) {
+ super('output', document);
+ registerControl(this, {
+ reset: () => {
+ this.textContent = this.defaultValue;
+ this.#defaultValue = null;
+ },
+ entries: () => [],
+ validity: () => ({}),
+ willValidate: () => false,
+ value: () => this.value,
+ });
+ }
+ get name(): string {
+ return this.getAttribute('name') ?? '';
+ }
+ set name(value: string) {
+ this.setAttribute('name', value);
+ }
+ get type(): 'output' {
+ return 'output';
+ }
+ get htmlFor(): DOMTokenList {
+ return this.#htmlFor;
+ }
+ set htmlFor(value: string) {
+ this.#htmlFor.value = value;
+ }
+ get value(): string {
+ return this.textContent ?? '';
+ }
+ set value(value: string) {
+ this.#defaultValue = this.defaultValue;
+ this.textContent = String(value);
+ }
+ get defaultValue(): string {
+ return this.#defaultValue ?? this.textContent ?? '';
+ }
+ set defaultValue(value: string) {
+ if (this.#defaultValue === null) this.textContent = String(value);
+ else this.#defaultValue = String(value);
+ }
+ get form(): HTMLFormElement | null {
+ return formOwner(this);
+ }
+ get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+ get willValidate(): boolean {
+ return false;
+ }
+ get validity(): ValidityState {
+ return validityFor(this);
+ }
+ get validationMessage(): string {
+ return validationMessageFor(this);
+ }
+ checkValidity(): boolean {
+ return validateControl(this);
+ }
+ reportValidity(): boolean {
+ return validateControl(this, true);
+ }
+ setCustomValidity(message: string): void {
+ setControlValidity(this, message);
+ }
+ }
+ class HTMLProgressElement extends ElementBase implements ProgressElement {
+ constructor(document: Document) {
+ super('progress', document);
+ }
+ get value(): number {
+ return clamp(numberAttribute(this, 'value', 0), 0, this.max);
+ }
+ set value(value: number) {
+ setNumber(this, 'value', value);
+ }
+ get max(): number {
+ const value = numberAttribute(this, 'max', 1);
+ return value > 0 ? value : 1;
+ }
+ set max(value: number) {
+ const number = finiteNumber(value);
+ if (number > 0) this.setAttribute('max', String(number));
+ }
+ get position(): number {
+ return this.hasAttribute('value') ? this.value / this.max : -1;
+ }
+ get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+ }
+ class HTMLMeterElement extends ElementBase implements MeterElement {
+ constructor(document: Document) {
+ super('meter', document);
+ }
+ get value(): number {
+ return clamp(numberAttribute(this, 'value', 0), this.min, this.max);
+ }
+ set value(value: number) {
+ setNumber(this, 'value', value);
+ }
+ get min(): number {
+ return numberAttribute(this, 'min', 0);
+ }
+ set min(value: number) {
+ setNumber(this, 'min', value);
+ }
+ get max(): number {
+ return Math.max(this.min, numberAttribute(this, 'max', 1));
+ }
+ set max(value: number) {
+ setNumber(this, 'max', value);
+ }
+ get low(): number {
+ return clamp(numberAttribute(this, 'low', this.min), this.min, this.max);
+ }
+ set low(value: number) {
+ setNumber(this, 'low', value);
+ }
+ get high(): number {
+ return clamp(numberAttribute(this, 'high', this.max), this.low, this.max);
+ }
+ set high(value: number) {
+ setNumber(this, 'high', value);
+ }
+ get optimum(): number {
+ return clamp(numberAttribute(this, 'optimum', (this.min + this.max) / 2), this.min, this.max);
+ }
+ set optimum(value: number) {
+ setNumber(this, 'optimum', value);
+ }
+ get labels(): readonly Element[] {
+ return labelsFor(this);
+ }
+ }
+ return {
+ HTMLSelectElement,
+ HTMLOptionElement,
+ HTMLOptGroupElement,
+ HTMLDataListElement,
+ HTMLSelectedContentElement,
+ HTMLOutputElement,
+ HTMLProgressElement,
+ HTMLMeterElement,
+ };
+}
+
+function optionState(option: Element): OptionState {
+ const state = optionStates.get(option);
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Internal brand check.
+ if (!state) throw new TypeError('Expected an option');
+ return state;
+}
+function optionsOf(select: Element): OptionElement[] {
+ const result: OptionElement[] = [];
+ const visit = (parent: Element, inGroup: boolean): void => {
+ for (const child of parent.children) {
+ if (optionStates.has(child)) {
+ result.push(child as OptionElement);
+ continue;
+ }
+ if (['select', 'hr', 'datalist'].includes(child.localName)) continue;
+ if (child.localName === 'optgroup' && inGroup) continue;
+ visit(child, inGroup || child.localName === 'optgroup');
+ }
+ };
+ visit(select, false);
+ return result;
+}
+function ownerSelect(option: Element): SelectElement | null {
+ for (let ancestor = option.parentElement; ancestor; ancestor = ancestor.parentElement) {
+ const select = selectStates.get(ancestor);
+ if (select) return optionsOf(select).includes(option as OptionElement) ? select : null;
+ }
+ return null;
+}
+function hasAncestor(element: Element, name: string): boolean {
+ for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement)
+ if (ancestor.localName === name) return true;
+ return false;
+}
+function optionDisabled(option: OptionElement): boolean {
+ if (option.disabled) return true;
+ for (let ancestor = option.parentElement; ancestor; ancestor = ancestor.parentElement) {
+ if (['select', 'hr', 'datalist', 'option'].includes(ancestor.localName)) return false;
+ if (ancestor.localName === 'optgroup') return ancestor.hasAttribute('disabled');
+ }
+ return false;
+}
+function optionText(option: Element): string {
+ const visit = (parent: Node): string =>
+ parent.childNodes
+ .map((node) => {
+ if (node instanceof Text) return node.data;
+ if (!(node instanceof Element) || node.localName === 'script') return '';
+ if (node.localName === 'img') return '';
+ return visit(node);
+ })
+ .join('');
+ return visit(option)
+ .replace(/[\t\n\f\r ]+/g, ' ')
+ .replace(/^ | $/g, '');
+}
+function displaySize(select: SelectElement): number {
+ return select.size || (select.multiple ? 4 : 1);
+}
+function deselectOthers(select: SelectElement, selected: OptionElement): void {
+ if (!select.multiple)
+ for (const option of optionsOf(select))
+ if (option !== selected) optionState(option).selected = false;
+}
+function normalizeSelection(select: SelectElement): boolean {
+ if (select.multiple) return false;
+ const options = optionsOf(select);
+ const selectedOptions = options.filter((option) => option.selected);
+ const selected = selectedOptions.at(-1);
+ if (selected) {
+ deselectOthers(select, selected);
+ return selectedOptions.length > 1;
+ } else if (displaySize(select) === 1) {
+ const first = options.find((option) => !optionDisabled(option));
+ if (first) {
+ optionState(first).selected = true;
+ return true;
+ }
+ }
+ return false;
+}
+function invalidate(element: Element): void {
+ if (element.isConnected) element.ownerDocument.invalidate({ kind: 'mutation', target: element });
+}
+function updateSelectedContent(select: SelectElement, only?: Element): void {
+ if (select.multiple || updatingContent.has(select)) return;
+ updatingContent.add(select);
+ try {
+ const option = optionsOf(select).find((item) => item.selected);
+ for (const element of only ? [only] : descendants(select)) {
+ if (element.localName !== 'selectedcontent') continue;
+ let disabled = false;
+ let selectCount = 0;
+ for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
+ if (ancestor.localName === 'select') selectCount++;
+ if (['option', 'selectedcontent'].includes(ancestor.localName) || selectCount > 1) {
+ disabled = true;
+ break;
+ }
+ }
+ if (!disabled)
+ element.replaceChildren(
+ ...(option?.childNodes.map((child) => child.cloneNode(true)) ?? []),
+ );
+ }
+ } finally {
+ updatingContent.delete(select);
+ }
+}
+function queueSelectedContentUpdate(select: SelectElement): void {
+ if (queuedContentUpdates.has(select)) return;
+ queuedContentUpdates.add(select);
+ queueMicrotask(() => {
+ queuedContentUpdates.delete(select);
+ updateSelectedContent(select);
+ });
+}
+
+/** @internal Host selection acts on option indices, retaining duplicate-value distinctions. */
+export function changeSelectSelection(element: Element, indices: readonly number[]): boolean {
+ const select = selectStates.get(element);
+ if (!select || isDisabled(select)) return false;
+ const options = optionsOf(select);
+ const requested = new Set(
+ indices
+ .map((index) => Number(index) | 0)
+ .filter((index) => options[index] && !optionDisabled(options[index])),
+ );
+ if (indices.length && requested.size === 0) return false;
+ const first = requested.values().next().value;
+ let changed = false;
+ for (const [index, option] of options.entries()) {
+ const state = optionState(option);
+ const selected = select.multiple
+ ? optionDisabled(option)
+ ? state.selected
+ : requested.has(index)
+ : index === first;
+ if (selected !== state.selected) {
+ state.selected = selected;
+ state.dirty = true;
+ changed = true;
+ }
+ }
+ if (changed) {
+ updateSelectedContent(select);
+ invalidate(select);
+ select.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ return changed;
+}
+
+/** @internal Logical single-select navigation for the explicit input driver. */
+export function handleSelectKey(element: Element, key: string): boolean {
+ const select = selectStates.get(element);
+ if (!select || select.multiple || isDisabled(select)) return false;
+ const options = optionsOf(select);
+ const enabled = options
+ .map((option, index) => ({ option, index }))
+ .filter(({ option }) => !optionDisabled(option));
+ let target: number | undefined;
+ if (key === 'Home') target = enabled[0]?.index;
+ else if (key === 'End') target = enabled.at(-1)?.index;
+ else if (key === 'ArrowDown')
+ target = enabled.find(({ index }) => index > select.selectedIndex)?.index;
+ else if (key === 'ArrowUp')
+ target = enabled.findLast(({ index }) => index < select.selectedIndex)?.index;
+ return target === undefined ? false : changeSelectSelection(select, [target]);
+}
+
+/** @internal HTML option cloning copies selectedness and dirtiness. */
+export function cloneValueElementState(source: Element, copy: Element): void {
+ const original = optionStates.get(source);
+ const target = optionStates.get(copy);
+ if (original && target) {
+ target.selected = original.selected;
+ target.dirty = original.dirty;
+ }
+ const sourceSelect = selectStates.get(source);
+ const copySelect = selectStates.get(copy);
+ if (sourceSelect && copySelect) {
+ const originals = optionsOf(sourceSelect);
+ for (const [index, option] of optionsOf(copySelect).entries()) {
+ const sourceOption = originals[index];
+ if (sourceOption) Object.assign(optionState(option), optionState(sourceOption));
+ }
+ updateSelectedContent(copySelect);
+ }
+}
+
+subscribeMutations((mutation) => {
+ const target = mutation.target;
+ if (mutation.type === 'characterData') return;
+ if (target instanceof Element && updatingContent.has(target)) return;
+ const selects = new Set();
+ const update = new Set();
+ const insertedContent: Element[] = [];
+ let removal = false;
+ for (let node: Node | null = target; node; node = node.parentNode) {
+ if (!(node instanceof Element)) continue;
+ const select = selectStates.get(node);
+ if (select) {
+ selects.add(select);
+ break;
+ }
+ if (node.localName === 'selectedcontent') return;
+ }
+ if (target instanceof Element && mutation.type === 'attributes') {
+ const state = optionStates.get(target);
+ if (
+ mutation.attributeName === 'selected' &&
+ mutation.oldValue !== null &&
+ target.hasAttribute('selected')
+ )
+ return;
+ if (state && mutation.attributeName === 'selected' && !state.dirty) {
+ const changed = state.selected !== target.hasAttribute('selected');
+ state.selected = target.hasAttribute('selected');
+ const select = ownerSelect(target);
+ if (select && state.selected) deselectOthers(select, target as OptionElement);
+ if (select && changed) update.add(select);
+ }
+ if (!['selected', 'disabled', 'multiple', 'size'].includes(mutation.attributeName ?? ''))
+ return;
+ }
+ if (mutation.type === 'childList') {
+ // Option content is snapshotted when selection changes, not live mirrored.
+ for (let node: Node | null = target; node; node = node.parentNode) {
+ if (node instanceof Element && optionStates.has(node)) return;
+ }
+ const changedElements = [
+ ...(mutation.addedNodes ?? []),
+ ...(mutation.removedNodes ?? []),
+ ].flatMap((node) =>
+ node instanceof Element ? [node, ...descendants(node)] : descendants(node),
+ );
+ if (
+ !changedElements.some(
+ (element) =>
+ optionStates.has(element) ||
+ selectStates.has(element) ||
+ element.localName === 'selectedcontent',
+ )
+ )
+ return;
+ for (const removed of mutation.removedNodes ?? []) {
+ const elements =
+ removed instanceof Element ? [removed, ...descendants(removed)] : descendants(removed);
+ if (elements.some((element) => optionStates.get(element)?.selected)) {
+ removal = true;
+ for (const select of selects) update.add(select);
+ }
+ }
+ for (const added of mutation.addedNodes ?? []) {
+ const elements =
+ added instanceof Element ? [added, ...descendants(added)] : descendants(added);
+ for (const element of elements) {
+ const select = selectStates.get(element);
+ if (select) {
+ selects.add(select);
+ update.add(select);
+ }
+ if (element.localName === 'selectedcontent') insertedContent.push(element);
+ if (optionStates.get(element)?.selected) {
+ const owner = ownerSelect(element);
+ if (owner) {
+ deselectOthers(owner, element as OptionElement);
+ selects.add(owner);
+ update.add(owner);
+ }
+ }
+ }
+ }
+ }
+ for (const select of selects) {
+ if (normalizeSelection(select)) update.add(select);
+ if (update.has(select)) {
+ if (removal) queueSelectedContentUpdate(select);
+ else updateSelectedContent(select);
+ } else {
+ for (const content of insertedContent)
+ if (select.contains(content)) updateSelectedContent(select, content);
+ }
+ }
+});
+
+// oxlint-disable-next-line bombshell-dev/max-params -- Internal numeric reflection algorithm.
+function numberAttribute(element: Element, name: string, fallback: number): number {
+ const value = Number.parseFloat(element.getAttribute(name) ?? '');
+ return Number.isFinite(value) ? value : fallback;
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Internal numeric reflection algorithm.
+function setNumber(element: Element, name: string, value: number): void {
+ element.setAttribute(name, String(finiteNumber(value)));
+}
+function finiteNumber(value: number): number {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL double rejects non-finite values.
+ throw new TypeError('Expected a finite number');
+ }
+ return number;
+}
+// oxlint-disable-next-line bombshell-dev/max-params -- Conventional inclusive numeric range operation.
+function clamp(value: number, minimum: number, maximum: number): number {
+ return Math.min(Math.max(value, minimum), maximum);
+}
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/browser/interactive-controls.html b/packages/dom/test/browser/interactive-controls.html
new file mode 100644
index 0000000..f2af48f
--- /dev/null
+++ b/packages/dom/test/browser/interactive-controls.html
@@ -0,0 +1,226 @@
+
+
+Interactive control browser differential
+Running
+
diff --git a/packages/dom/test/browser/shadow-extensions.html b/packages/dom/test/browser/shadow-extensions.html
new file mode 100644
index 0000000..f7472df
--- /dev/null
+++ b/packages/dom/test/browser/shadow-extensions.html
@@ -0,0 +1,212 @@
+
+
+Shadow extension 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/element-extensions.test.ts b/packages/dom/test/element-extensions.test.ts
new file mode 100644
index 0000000..8d4128b
--- /dev/null
+++ b/packages/dom/test/element-extensions.test.ts
@@ -0,0 +1,291 @@
+import assert from 'node:assert/strict';
+import { describe, expect, it, vi } from 'vitest';
+import { Document, Element, HTMLSlotElement } from '../src/dom.ts';
+import { installParsing } from '../src/parsing.ts';
+import { enqueueUpgrade } from '../src/custom-elements.ts';
+import { MutationObserver } from '../src/observation.ts';
+
+class BoxElement extends Element {
+ constructor(document: Document) {
+ super('box', document);
+ }
+ value = 'ready';
+}
+declare module '../src/dom.ts' {
+ interface Elements {
+ box: BoxElement;
+ }
+}
+
+describe('document-local element extensions and token lists', () => {
+ it('constructs typed terminal elements without affecting other documents or namespaces', () => {
+ const document = new Document();
+ const other = new Document();
+ document.registerElement('box', (owner) => new BoxElement(owner));
+ const box = document.createElement('box');
+ expect(box.value).toBe('ready');
+ expect(box.ownerDocument).toBe(document);
+ expect(other.createElement('box')).not.toBeInstanceOf(BoxElement);
+ expect(document.createElementNS('http://www.w3.org/2000/svg', 'box')).not.toBeInstanceOf(
+ BoxElement,
+ );
+ expect(box.cloneNode()).toBeInstanceOf(BoxElement);
+ installParsing(document);
+ const container = document.createElement('div');
+ container.innerHTML = 'hello';
+ expect(container.firstChild).toBeInstanceOf(BoxElement);
+ expect(() => document.registerElement('box', (owner) => new BoxElement(owner))).toThrow();
+ expect(() => document.registerElement('input', (owner) => new BoxElement(owner))).toThrow();
+ expect(() => document.registerElement('x-box', (owner) => new BoxElement(owner))).toThrow();
+ });
+ it('rejects incompatible factories and preserves arbitrary names from Object.prototype', () => {
+ const document = new Document();
+ document.registerElement('bad', () => new Element('bad', new Document()));
+ expect(() => document.createElement('bad')).toThrow();
+ for (const name of ['constructor', '__proto__', 'toString']) {
+ const element = document.createElement(name);
+ expect(element).toBeInstanceOf(Element);
+ expect(element.localName).toBe(name.toLowerCase());
+ }
+ });
+ it('keeps token lists live and ordered, validating every token before mutation', () => {
+ const element = new Document().createElement('div');
+ const tokens = element.classList;
+ expect(element.classList).toBe(tokens);
+ element.setAttribute('class', 'a a\tb c');
+ expect([...tokens]).toEqual(['a', 'b', 'c']);
+ expect(tokens.replace('a', 'c')).toBe(true);
+ expect(tokens.value).toBe('c b');
+ expect(() => tokens.add('valid', 'two words')).toThrow();
+ expect(tokens.value).toBe('c b');
+ expect(() => tokens.remove('c', '')).toThrow();
+ expect(tokens.value).toBe('c b');
+ expect(tokens.toggle('c', false)).toBe(false);
+ expect(tokens.toggle('d')).toBe(true);
+ expect(tokens.item(2)).toBeNull();
+ expect([...tokens.entries()]).toEqual([
+ [0, 'b'],
+ [1, 'd'],
+ ]);
+ expect(() => tokens.supports('b')).toThrow(TypeError);
+ });
+});
+
+describe('manual slots and declarative shadow roots', () => {
+ it('invalidates connected manual distribution without recording a tree mutation', () => {
+ const invalidated = vi.fn();
+ const document = new Document({ onInvalidate: invalidated });
+ const host = document.createElement('div');
+ const shadow = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
+ const slot = shadow.appendChild(document.createElement('slot'));
+ const child = host.appendChild(document.createElement('span'));
+ slot.assign(child);
+ expect(invalidated).not.toHaveBeenCalled();
+ document.append(host);
+ const observer = new MutationObserver(() => {});
+ observer.observe(slot, { childList: true, subtree: true, attributes: true });
+ invalidated.mockClear();
+ slot.assign();
+ expect(invalidated).toHaveBeenCalledOnce();
+ expect(observer.takeRecords()).toEqual([]);
+ invalidated.mockClear();
+ slot.assign();
+ expect(invalidated).not.toHaveBeenCalled();
+ });
+ it('distributes manually in assignment order, transfers slots, and retains assignments on reconnect', async () => {
+ const document = new Document();
+ const host = document.appendChild(document.createElement('div'));
+ const root = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
+ const first = document.createElement('slot');
+ const second = document.createElement('slot');
+ root.append(first, second);
+ const a = document.createElement('span');
+ const b = document.createTextNode('b');
+ host.append(a, b);
+ expect(first.assignedNodes()).toEqual([]);
+ const changed = vi.fn();
+ first.addEventListener('slotchange', changed);
+ first.assign(b, a, b);
+ expect(first.assignedNodes()).toEqual([b, a]);
+ expect(first.assignedElements()).toEqual([a]);
+ expect(a.assignedSlot).toBe(first);
+ second.assign(a);
+ expect(first.assignedNodes()).toEqual([b]);
+ expect(second.assignedNodes()).toEqual([a]);
+ await Promise.resolve();
+ expect(changed).toHaveBeenCalledOnce();
+ a.remove();
+ expect(second.assignedNodes()).toEqual([]);
+ host.append(a);
+ expect(second.assignedNodes()).toEqual([a]);
+ second.assign();
+ expect(a.assignedSlot).toBeNull();
+ });
+ it('does not change named distribution through assign and prevalidates invalid slottables', () => {
+ const document = new Document();
+ const host = document.createElement('div');
+ const shadow = host.attachShadow({ mode: 'open' });
+ const slot = shadow.appendChild(document.createElement('slot'));
+ const child = host.appendChild(document.createElement('span'));
+ slot.assign();
+ expect(slot.assignedNodes()).toEqual([child]);
+ expect(() => Reflect.apply(slot.assign, slot, [child, document.createComment('bad')])).toThrow(
+ TypeError,
+ );
+ expect(slot.assignedNodes()).toEqual([child]);
+ });
+ it('parses declarative roots only through explicit parsing and preserves hydration identity', () => {
+ const document = new Document();
+ installParsing(document);
+ const outer = document.createElement('div');
+ const markup =
+ '';
+ outer.innerHTML = markup;
+ expect(outer.firstElementChild?.shadowRoot).toBeNull();
+ outer.setHTMLUnsafe(markup);
+ const host = outer.firstElementChild;
+ assert(host);
+ const shadow = host.shadowRoot;
+ assert(shadow);
+ expect(shadow.textContent).toBe('shadow');
+ expect(host.textContent).toBe('light');
+ expect(shadow.clonable).toBe(true);
+ expect(shadow.serializable).toBe(true);
+ expect(outer.innerHTML).toBe('');
+ expect(outer.getHTML({ serializableShadowRoots: true })).toContain(
+ ' host.attachShadow({ mode: 'closed' })).toThrow();
+ expect(host.shadowRoot).toBe(shadow);
+ expect(host.attachShadow({ mode: 'open' })).toBe(shadow);
+ expect(shadow.childNodes).toHaveLength(0);
+ expect(() => host.attachShadow({ mode: 'open' })).toThrow();
+ });
+ it('keeps a top-level shadow template inert when parsing a fragment into an existing host', () => {
+ const document = new Document();
+ installParsing(document);
+ const host = document.createElement('div');
+ host.setHTMLUnsafe('shadowlight');
+ expect(host.shadowRoot).toBeNull();
+ expect(host.firstElementChild?.localName).toBe('template');
+ });
+ it('copies clonable shadow descendants even on shallow host clones and adopts their owners', () => {
+ const document = new Document();
+ const host = document.createElement('div');
+ const shadow = host.attachShadow({
+ mode: 'open',
+ clonable: true,
+ serializable: true,
+ delegatesFocus: true,
+ });
+ shadow.append(document.createElement('input'));
+ host.append('light');
+ const copy = host.cloneNode();
+ assert(copy instanceof Element);
+ expect(copy.childNodes).toHaveLength(0);
+ expect(copy.shadowRoot?.firstChild).not.toBe(shadow.firstChild);
+ expect(copy.shadowRoot?.firstChild?.nodeName).toBe('INPUT');
+ expect(copy.shadowRoot?.delegatesFocus).toBe(true);
+ const other = new Document();
+ const imported = other.importNode(host, true);
+ assert(imported instanceof Element);
+ expect(imported.shadowRoot?.firstChild?.ownerDocument).toBe(other);
+ expect(imported.textContent).toBe('light');
+ });
+ it('upgrades nested declarative content after parsing and supports closed-root serialization', () => {
+ const document = new Document();
+ installParsing(document);
+ const host = document.appendChild(document.createElement('div'));
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push(`${this.id}:${this.textContent}:${this.isConnected}`);
+ }
+ }
+ document.customElements.define('x-item', Item);
+ host.setHTMLUnsafe(
+ '',
+ );
+ expect(calls).toEqual(['inner:value:true']);
+ expect(host.firstElementChild?.shadowRoot?.slotAssignment).toBe('manual');
+ expect(host.firstElementChild?.shadowRoot?.lastChild).toBeInstanceOf(HTMLSlotElement);
+ const hidden = document.createElement('section');
+ const closed = hidden.attachShadow({ mode: 'closed' });
+ closed.append('secret');
+ expect(hidden.getHTML()).toBe('');
+ expect(hidden.getHTML({ shadowRoots: [closed] })).toBe(
+ 'secret',
+ );
+ });
+ it('upgrades custom clones after their attributes, light tree and shadow tree exist', () => {
+ const document = new Document();
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push(`${this.id}:${this.textContent}:${this.shadowRoot?.textContent ?? 'none'}`);
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open', clonable: true }).append('shadow');
+ }
+ }
+ document.customElements.define('x-cloned', Item);
+ const original = new Item();
+ original.id = 'original';
+ original.append('light');
+ calls.length = 0;
+ const copy = original.cloneNode(true);
+ expect(copy).toBeInstanceOf(Item);
+ assert(copy instanceof Element);
+ expect(calls).toEqual(['original:light:shadow']);
+ expect(copy.shadowRoot?.firstChild).not.toBe(original.shadowRoot?.firstChild);
+ });
+});
+
+describe('bounded custom-element reaction scopes', () => {
+ it('drains direct parser reactions on failure without leaking work into another document', async () => {
+ const document = new Document();
+ installParsing(document);
+ const calls: string[] = [];
+ class Item extends document.HTMLElement {
+ constructor() {
+ super();
+ calls.push('constructed');
+ }
+ }
+ document.customElements.define('x-item', Item);
+ document.registerElement('broken', () => {
+ // oxlint-disable-next-line bombshell-dev/no-generic-error -- Deliberately fail a user factory to test reaction cleanup.
+ throw new RangeError('factory failed');
+ });
+ expect(() =>
+ document.markupParser.parse(
+ document.createElement('div'),
+ '',
+ ),
+ ).toThrow('factory failed');
+ expect(calls).toEqual(['constructed']);
+ const other = new Document();
+ other.append(other.createElement('div'));
+ await Promise.resolve();
+ expect(calls).toEqual(['constructed']);
+ });
+ it('keeps backup queues independent and automatically delivers them in a microtask', async () => {
+ const first = new Document();
+ const second = new Document();
+ const item = first.createElement('x-item');
+ const calls: string[] = [];
+ class Item extends first.HTMLElement {
+ constructor() {
+ super();
+ calls.push('constructed');
+ }
+ }
+ first.customElements.define('x-item', Item);
+ enqueueUpgrade(item);
+ second.append(second.createElement('div'));
+ expect(calls).toEqual([]);
+ await Promise.resolve();
+ expect(item).toBeInstanceOf(Item);
+ expect(calls).toEqual(['constructed']);
+ });
+});
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/form-elements.test.ts b/packages/dom/test/form-elements.test.ts
new file mode 100644
index 0000000..948192f
--- /dev/null
+++ b/packages/dom/test/form-elements.test.ts
@@ -0,0 +1,467 @@
+import { describe, expect, it } from 'vitest';
+import {
+ Document,
+ Event,
+ MouseEvent,
+ KeyboardEvent,
+ SubmitEvent,
+ FormData,
+ FormDataEvent,
+ HTMLFormElement,
+ HTMLButtonElement,
+ SVG_NAMESPACE,
+} from '../src/index.ts';
+
+function fixture(): { document: Document; root: ReturnType } {
+ const document = new Document();
+ const root = document.createElement('box');
+ document.append(root);
+ return { document, root };
+}
+
+describe('form ownership, activation, and local submission', () => {
+ it('excludes datalist buttons from validation and foreign input names from activation', () => {
+ const { document, root } = fixture();
+ const form = root.appendChild(document.createElement('form'));
+ const list = form.appendChild(document.createElement('datalist'));
+ const barred = list.appendChild(document.createElement('button'));
+ barred.setCustomValidity('barred');
+ expect(barred.willValidate).toBe(false);
+ expect(form.checkValidity()).toBe(true);
+ const button = form.appendChild(document.createElement('button'));
+ const foreign = button.appendChild(document.createElementNS(SVG_NAMESPACE, 'input'));
+ let submitted = false;
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ submitted = true;
+ });
+ foreign.click();
+ expect(submitted).toBe(true);
+ });
+ it('uses the activation target in its own shadow scope after event retargeting', () => {
+ const { document, root } = fixture();
+ const shadow = root.attachShadow({ mode: 'open' });
+ const label = shadow.appendChild(document.createElement('label'));
+ const input = label.appendChild(document.createElement('input'));
+ input.type = 'checkbox';
+ const anchor = label.appendChild(document.createElement('a'));
+ anchor.setAttribute('href', '#');
+ const text = anchor.appendChild(document.createTextNode('link'));
+ text.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
+ expect(input.checked).toBe(false);
+ const details = shadow.appendChild(document.createElement('details'));
+ const summary = details.appendChild(document.createElement('summary'));
+ summary.append(anchor);
+ text.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
+ expect(details.open).toBe(false);
+ summary.click();
+ expect(details.open).toBe(true);
+ });
+ it('constructs specialized form/button elements and submits a live entry list', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ const button = document.createElement('button');
+ input.name = 'answer';
+ input.value = '42';
+ button.name = 'action';
+ button.value = 'save';
+ form.append(input, button);
+ root.append(form);
+ expect(form).toBeInstanceOf(HTMLFormElement);
+ expect(button).toBeInstanceOf(HTMLButtonElement);
+ const calls: string[] = [];
+ form.addEventListener('submit', (event) => {
+ expect(event).toBeInstanceOf(SubmitEvent);
+ if (event instanceof SubmitEvent) expect(event.submitter).toBe(button);
+ input.value = 'updated';
+ calls.push('submit');
+ });
+ form.addEventListener('formdata', (event) => {
+ if (!(event instanceof FormDataEvent)) return;
+ expect([...event.formData]).toEqual([
+ ['answer', 'updated'],
+ ['action', 'save'],
+ ]);
+ calls.push('formdata');
+ });
+ button.click();
+ expect(calls).toEqual(['submit', 'formdata']);
+ });
+ it('supports external ownership and reassociation after id changes or moves', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('form');
+ first.id = 'a';
+ const second = document.createElement('form');
+ second.id = 'b';
+ const input = document.createElement('input');
+ input.setAttribute('form', 'a');
+ root.append(first, second, input);
+ const collection = first.elements;
+ expect(input.form).toBe(first);
+ expect([...collection]).toEqual([input]);
+ input.setAttribute('form', 'b');
+ expect(input.form).toBe(second);
+ expect(collection.length).toBe(0);
+ second.id = 'c';
+ expect(input.form).toBeNull();
+ input.removeAttribute('form');
+ first.append(input);
+ expect(input.form).toBe(first);
+ });
+ it('validates all controls, honors cancellation and novalidate, and exposes custom messages', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.name = 'name';
+ input.required = true;
+ form.append(input);
+ root.append(form);
+ let invalid = 0;
+ let submissions = 0;
+ let entries = 0;
+ input.addEventListener('invalid', () => invalid++);
+ form.addEventListener('submit', () => submissions++);
+ form.addEventListener('formdata', () => entries++);
+ form.requestSubmit();
+ expect(invalid).toBe(1);
+ expect(submissions).toBe(0);
+ input.value = 'ready';
+ input.setCustomValidity('Try again');
+ expect(input.validationMessage).toBe('Try again');
+ expect(form.checkValidity()).toBe(false);
+ form.noValidate = true;
+ form.requestSubmit();
+ expect(submissions).toBe(1);
+ form.addEventListener('submit', (event) => event.preventDefault());
+ form.requestSubmit();
+ expect(entries).toBe(1);
+ });
+ it('reset is cancelable and restores dirty values, checkedness, selections, and output defaults', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ input.defaultValue = 'original';
+ input.value = 'changed';
+ const check = document.createElement('input');
+ check.type = 'checkbox';
+ check.defaultChecked = true;
+ check.checked = false;
+ const select = document.createElement('select');
+ const a = document.createElement('option');
+ a.value = 'a';
+ const b = document.createElement('option');
+ b.value = 'b';
+ select.append(a, b);
+ select.value = 'b';
+ const output = document.createElement('output');
+ output.defaultValue = '0';
+ output.value = '99';
+ form.append(input, check, select, output);
+ root.append(form);
+ form.addEventListener('reset', (event) => event.preventDefault(), { once: true });
+ form.reset();
+ expect(input.value).toBe('changed');
+ form.reset();
+ expect(input.value).toBe('original');
+ expect(check.checked).toBe(true);
+ expect(select.value).toBe('a');
+ expect(output.value).toBe('0');
+ input.defaultValue = 'new';
+ expect(input.value).toBe('new');
+ });
+ it('omits disabled and unnamed controls, includes selected values and unchecked defaults correctly', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ root.append(form);
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ const legend = document.createElement('legend');
+ const included = document.createElement('input');
+ included.name = 'legend';
+ included.value = 'yes';
+ legend.append(included);
+ const excluded = document.createElement('input');
+ excluded.name = 'disabled';
+ fieldset.append(legend, excluded);
+ form.append(fieldset);
+ const check = document.createElement('input');
+ check.type = 'checkbox';
+ check.name = 'check';
+ form.append(check);
+ const select = document.createElement('select');
+ select.multiple = true;
+ select.name = 'items';
+ for (const value of ['a', 'b']) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.selected = true;
+ select.append(option);
+ }
+ form.append(select);
+ expect([...new FormData(form)]).toEqual([
+ ['legend', 'yes'],
+ ['items', 'a'],
+ ['items', 'b'],
+ ]);
+ check.checked = true;
+ expect(new FormData(form).get('check')).toBe('on');
+ });
+ it('requestSubmit rejects invalid submitters while submit bypasses validation/submit listeners', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ root.append(form);
+ const input = document.createElement('input');
+ input.required = true;
+ input.name = 'required';
+ form.append(input);
+ const other = document.createElement('button');
+ root.append(other);
+ expect(() => form.requestSubmit(input)).toThrow(TypeError);
+ expect(() => form.requestSubmit(other)).toThrow('Submitter belongs');
+ let submits = 0;
+ let data = 0;
+ form.addEventListener('submit', () => submits++);
+ form.addEventListener('formdata', () => data++);
+ form.submit();
+ expect(submits).toBe(0);
+ expect(data).toBe(1);
+ });
+ it('label activation resolves explicit and nested controls without double activation', () => {
+ const { document, root } = fixture();
+ const label = document.createElement('label');
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.id = 'agree';
+ label.htmlFor = 'agree';
+ root.append(label, input);
+ label.click();
+ expect(input.checked).toBe(true);
+ expect(document.activeElement).toBe(input);
+ label.removeAttribute('for');
+ label.append(input);
+ input.click();
+ expect(input.checked).toBe(false);
+ const span = document.createElement('span');
+ label.append(span);
+ span.click();
+ expect(input.checked).toBe(true);
+ });
+ it('captures an ancestor activation target and preactivation before listeners, and rolls back cancellation', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const button = document.createElement('button');
+ const child = document.createElement('span');
+ button.append(child);
+ form.append(button);
+ root.append(form);
+ let submitted = 0;
+ form.addEventListener('submit', () => submitted++);
+ child.click();
+ expect(submitted).toBe(1);
+ const check = document.createElement('input');
+ check.type = 'checkbox';
+ root.append(check);
+ check.indeterminate = true;
+ check.addEventListener('click', (event) => {
+ expect(check.checked).toBe(true);
+ expect(check.indeterminate).toBe(false);
+ event.preventDefault();
+ });
+ expect(check.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))).toBe(
+ false,
+ );
+ expect(check.checked).toBe(false);
+ expect(check.indeterminate).toBe(true);
+ });
+ it('plain events and synthetic keyboard dispatch do not perform activation', () => {
+ const { document, root } = fixture();
+ const check = document.createElement('input');
+ check.type = 'checkbox';
+ root.append(check);
+ check.dispatchEvent(new Event('click', { bubbles: true }));
+ check.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
+ check.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' }));
+ expect(check.checked).toBe(false);
+ });
+ it('method=dialog submits then closes its owning dialog with the submitter value', () => {
+ const { document, root } = fixture();
+ const dialog = document.createElement('dialog');
+ const form = document.createElement('form');
+ form.method = 'dialog';
+ const button = document.createElement('button');
+ button.value = 'accepted';
+ form.append(button);
+ dialog.append(form);
+ root.append(dialog);
+ dialog.showModal();
+ button.click();
+ expect(dialog.open).toBe(false);
+ expect(dialog.returnValue).toBe('accepted');
+ });
+
+ it('guards entry construction against recursive formdata and releases the guard afterward', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ root.append(form);
+ let calls = 0;
+ form.addEventListener('formdata', () => calls++);
+ form.addEventListener(
+ 'formdata',
+ () => {
+ expect(() => new FormData(form)).toThrow('already being constructed');
+ form.requestSubmit();
+ form.submit();
+ },
+ { once: true },
+ );
+ expect(new FormData(form).get('missing')).toBe(null);
+ expect(calls).toBe(1);
+ expect(new FormData(form).get('missing')).toBe(null);
+ expect(calls).toBe(2);
+ });
+
+ it('clones the customized entry list after formdata dispatch', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ root.append(form);
+ const retained: globalThis.FormData[] = [];
+ form.addEventListener('formdata', (event) => {
+ if (!(event instanceof FormDataEvent)) return;
+ event.formData.append('custom', 'during');
+ retained.push(event.formData);
+ });
+ const data = new FormData(form);
+ expect(data.get('custom')).toBe('during');
+ expect(retained[0]).toBeInstanceOf(FormData);
+ retained[0]?.set('custom', 'after');
+ expect(data.get('custom')).toBe('during');
+ });
+
+ it('constructs dialog submission data before closing and respects removal during formdata', () => {
+ const { document, root } = fixture();
+ const dialog = document.createElement('dialog');
+ const form = document.createElement('form');
+ const button = document.createElement('button');
+ form.method = 'dialog';
+ form.append(button);
+ dialog.append(form);
+ root.append(dialog);
+ dialog.showModal();
+ const calls: string[] = [];
+ form.addEventListener('submit', () => calls.push('submit'));
+ form.addEventListener('formdata', () => {
+ calls.push('formdata');
+ expect(dialog.open).toBe(true);
+ });
+ button.click();
+ expect(calls).toEqual(['submit', 'formdata']);
+ expect(dialog.open).toBe(false);
+ dialog.showModal();
+ form.addEventListener('formdata', () => form.remove(), { once: true });
+ button.click();
+ expect(dialog.open).toBe(true);
+ });
+
+ it('keeps live form and fieldset collection identities stable', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const fieldset = document.createElement('fieldset');
+ form.append(fieldset);
+ root.append(form);
+ const controls = form.elements;
+ const descendants = fieldset.elements;
+ expect(form.elements).toBe(controls);
+ expect(fieldset.elements).toBe(descendants);
+ const input = document.createElement('input');
+ fieldset.append(input);
+ expect([...controls]).toEqual([fieldset, input]);
+ expect([...descendants]).toEqual([input]);
+ expect(Reflect.set(controls, '0', input)).toBe(false);
+ });
+
+ it('keeps explicit label lookup consistent with labels, including empty IDs and detached roots', () => {
+ const document = new Document();
+ const input = document.createElement('input');
+ const label = document.createElement('label');
+ document.append(input);
+ input.append(label);
+ label.htmlFor = '';
+ expect(label.control).toBe(null);
+ expect(input.labels).toHaveLength(0);
+ input.id = 'root-control';
+ label.htmlFor = 'root-control';
+ input.remove();
+ expect(label.control).toBe(input);
+ expect(input.labels).toEqual([label]);
+ });
+
+ it('ignores interactive label descendants even when the event targets their text node', () => {
+ const { document, root } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.id = 'check';
+ const label = document.createElement('label');
+ label.htmlFor = 'check';
+ const anchor = document.createElement('a');
+ anchor.setAttribute('href', '/');
+ const text = document.createTextNode('link');
+ anchor.append(text);
+ label.append(anchor);
+ root.append(input, label);
+ text.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(input.checked).toBe(false);
+ anchor.removeAttribute('href');
+ text.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(input.checked).toBe(true);
+ });
+
+ it('rejects foreign-namespace submitters and excludes foreign labelable names', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const foreignButton = document.createElementNS(SVG_NAMESPACE, 'button');
+ foreignButton.id = 'foreign';
+ const label = document.createElement('label');
+ label.htmlFor = 'foreign';
+ form.append(foreignButton, label);
+ root.append(form);
+ expect(label.control).toBe(null);
+ expect(() => form.requestSubmit(foreignButton)).toThrow(TypeError);
+ });
+
+ it('reports every invalid control before focusing the first uncanceled candidate', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ first.required = second.required = true;
+ form.append(first, second);
+ root.append(form);
+ const events: string[] = [];
+ first.addEventListener('invalid', (event) => {
+ events.push('first invalid');
+ event.preventDefault();
+ });
+ second.addEventListener('invalid', () => events.push('second invalid'));
+ second.addEventListener('focus', () => events.push('focus'));
+ expect(form.reportValidity()).toBe(false);
+ expect(events).toEqual(['first invalid', 'second invalid', 'focus']);
+ expect(document.activeElement).toBe(second);
+ });
+
+ it('permits submit() during submit events while blocking recursive requestSubmit()', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ root.append(form);
+ const events: string[] = [];
+ form.addEventListener('submit', (event) => {
+ events.push('submit');
+ event.preventDefault();
+ form.requestSubmit();
+ form.submit();
+ });
+ form.addEventListener('formdata', () => events.push('formdata'));
+ form.requestSubmit();
+ expect(events).toEqual(['submit', 'formdata']);
+ });
+});
diff --git a/packages/dom/test/forms-controls.test.ts b/packages/dom/test/forms-controls.test.ts
new file mode 100644
index 0000000..b5bd7ea
--- /dev/null
+++ b/packages/dom/test/forms-controls.test.ts
@@ -0,0 +1,505 @@
+import { describe, expect, it, vi } from 'vitest';
+import { Document, type Element, HTMLInputElement } from '../src/dom.ts';
+import { Event, MouseEvent } from '../src/events.ts';
+import { controlState, isDisabled } from '../src/forms.ts';
+import { markControlUserEdited } from '../src/controls.ts';
+
+function fixture(): { document: Document; root: Element } {
+ const document = new Document();
+ const root = document.createElement('main');
+ document.append(root);
+ return { document, root };
+}
+
+describe('input checkedness and radio groups', () => {
+ it('keeps checkedness dirtiness independent of its reflected default', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ expect(input.checked).toBe(false);
+ input.defaultChecked = true;
+ expect(input.checked).toBe(true);
+ input.checked = false;
+ input.defaultChecked = false;
+ input.defaultChecked = true;
+ expect(input.checked).toBe(false);
+ controlState(input)?.reset();
+ expect(input.checked).toBe(true);
+ input.defaultChecked = false;
+ expect(input.checked).toBe(false);
+ });
+
+ it('uses before-listener checkbox activation and restores canceled state', () => {
+ const { document, root } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.indeterminate = true;
+ root.append(input);
+ const changes: string[] = [];
+ input.addEventListener('input', () => changes.push('input'));
+ input.addEventListener('change', () => changes.push('change'));
+ input.addEventListener(
+ 'click',
+ (event) => {
+ expect(input.checked).toBe(true);
+ expect(input.indeterminate).toBe(false);
+ event.preventDefault();
+ },
+ { once: true },
+ );
+ input.click();
+ expect(input.checked).toBe(false);
+ expect(input.indeterminate).toBe(true);
+ expect(changes).toEqual([]);
+ input.click();
+ expect(input.checked).toBe(true);
+ expect(input.indeterminate).toBe(false);
+ expect(changes).toEqual(['input', 'change']);
+ input.dispatchEvent(new Event('click', { cancelable: true }));
+ expect(input.checked).toBe(true);
+ input.dispatchEvent(new MouseEvent('click'));
+ expect(input.checked).toBe(false);
+ });
+
+ it('keeps nested canceled activation bookkeeping separate from the outer click', () => {
+ const { document, root } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ root.append(input);
+ const change = vi.fn();
+ input.addEventListener('change', change);
+ input.addEventListener(
+ 'click',
+ () => {
+ input.addEventListener('click', (event) => event.preventDefault(), { once: true });
+ input.dispatchEvent(new MouseEvent('click', { cancelable: true }));
+ },
+ { once: true },
+ );
+ input.click();
+ expect(input.checked).toBe(true);
+ expect(change).toHaveBeenCalledOnce();
+ });
+
+ it('retains dirty checkedness after cancellation and suppresses detached input/change events', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ const change = vi.fn();
+ input.addEventListener('change', change);
+ input.addEventListener('click', (event) => event.preventDefault(), { once: true });
+ input.click();
+ input.defaultChecked = true;
+ expect(input.checked).toBe(false);
+ input.click();
+ expect(input.checked).toBe(true);
+ expect(change).not.toHaveBeenCalled();
+ });
+
+ it('follows the current group when rolling back a canceled radio activation', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ for (const radio of [first, second]) {
+ radio.type = 'radio';
+ radio.name = 'group';
+ }
+ root.append(first, second);
+ first.checked = true;
+ second.addEventListener('click', (event) => {
+ first.name = 'elsewhere';
+ event.preventDefault();
+ });
+ second.click();
+ // WHATWG's legacy-canceled-activation algorithm; Chrome156 currently differs here.
+ expect([first.checked, second.checked]).toEqual([false, false]);
+ });
+
+ it('groups radios by name, tree, and form ownership including reassociation', () => {
+ const { document, root } = fixture();
+ const firstForm = document.createElement('form');
+ firstForm.id = 'first';
+ const secondForm = document.createElement('form');
+ secondForm.id = 'second';
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ const third = document.createElement('input');
+ for (const radio of [first, second, third]) {
+ radio.type = 'radio';
+ radio.name = 'choice';
+ }
+ firstForm.append(first, second);
+ secondForm.append(third);
+ root.append(firstForm, secondForm);
+ first.checked = true;
+ third.checked = true;
+ expect(first.checked).toBe(true);
+ second.checked = true;
+ expect(first.checked).toBe(false);
+ expect(third.checked).toBe(true);
+ third.setAttribute('form', 'first');
+ expect(second.checked).toBe(false);
+ expect(third.checked).toBe(true);
+ second.name = '';
+ second.checked = true;
+ expect(third.checked).toBe(true);
+ });
+
+ it('preserves dirty checkedness in clones and restores a radio group on cancellation', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('input');
+ first.type = 'radio';
+ first.name = 'choice';
+ first.defaultChecked = true;
+ const second = document.createElement('input');
+ second.type = 'radio';
+ second.name = 'choice';
+ root.append(first, second);
+ second.addEventListener('click', (event) => event.preventDefault(), { once: true });
+ second.click();
+ expect(first.checked).toBe(true);
+ expect(second.checked).toBe(false);
+ const changes = vi.fn();
+ first.addEventListener('change', changes);
+ first.click();
+ expect(changes).not.toHaveBeenCalled();
+ first.checked = false;
+ first.indeterminate = true;
+ const clone = first.cloneNode();
+ if (!(clone instanceof HTMLInputElement)) expect.unreachable();
+ expect(clone.defaultChecked).toBe(true);
+ expect(clone.checked).toBe(false);
+ expect(clone.indeterminate).toBe(true);
+ clone.defaultChecked = false;
+ clone.defaultChecked = true;
+ expect(clone.checked).toBe(false);
+ });
+});
+
+describe('input value modes, conversion, and stepping', () => {
+ it('uses reflected values for checkboxes and buttons and restricts file assignments', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ expect(input.value).toBe('on');
+ input.value = 'yes';
+ expect(input.getAttribute('value')).toBe('yes');
+ input.type = 'button';
+ input.value = 'press';
+ expect(input.defaultValue).toBe('press');
+ input.type = 'file';
+ expect(input.value).toBe('');
+ expect(() => {
+ input.value = 'secret.txt';
+ }).toThrowError(/only be cleared/);
+ input.value = '';
+ });
+
+ it.each([
+ ['number', '1\n2', ''],
+ ['number', ' 3 ', ''],
+ ['number', 'Infinity', ''],
+ ['number', '1e2', '1e2'],
+ ['date', '2025-02-29', ''],
+ ['date', '2024-02-29', '2024-02-29'],
+ ['month', '2024-13', ''],
+ ['week', '2021-W53', ''],
+ ['week', '2020-W53', '2020-W53'],
+ ['time', '24:00', ''],
+ ['time', '12:34:56.78', '12:34:56.78'],
+ ['datetime-local', '2024-03-02 09:30:00', '2024-03-02T09:30'],
+ ['color', '#AABBCC', '#aabbcc'],
+ ['color', 'red', '#000000'],
+ ['url', ' \thttps://example.com/ \n', 'https://example.com/'],
+ ])('sanitizes %s value %s', (type, value, expected) => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = type;
+ input.value = value;
+ expect(input.value).toBe(expected);
+ });
+
+ it('normalizes email lists and resets value dirtiness', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'email';
+ input.multiple = true;
+ input.value = ' a@example.com, b@example.com ';
+ expect(input.value).toBe('a@example.com,b@example.com');
+ input.defaultValue = 'default@example.com';
+ expect(input.value).toBe('a@example.com,b@example.com');
+ controlState(input)?.reset();
+ expect(input.value).toBe('default@example.com');
+ input.defaultValue = 'next@example.com';
+ expect(input.value).toBe('next@example.com');
+ });
+
+ it('converts dates and months in UTC and rejects unsupported conversion', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'date';
+ input.value = '2024-02-29';
+ expect(input.valueAsNumber).toBe(Date.UTC(2024, 1, 29));
+ expect(input.valueAsDate?.toISOString()).toBe('2024-02-29T00:00:00.000Z');
+ input.valueAsDate = new Date('2025-05-06T19:40:00Z');
+ expect(input.value).toBe('2025-05-06');
+ input.type = 'month';
+ input.value = '1969-12';
+ expect(input.valueAsNumber).toBe(-1);
+ expect(input.valueAsDate?.toISOString()).toBe('1969-12-01T00:00:00.000Z');
+ input.valueAsNumber = 0;
+ expect(input.value).toBe('1970-01');
+ input.type = 'datetime-local';
+ input.value = '2024-01-02T03:04';
+ expect(input.valueAsDate).toBe(null);
+ expect(() => {
+ input.valueAsDate = null;
+ }).toThrowError(/does not support dates/);
+ input.type = 'text';
+ expect(Number.isNaN(input.valueAsNumber)).toBe(true);
+ expect(() => {
+ input.valueAsNumber = 1;
+ }).toThrowError(/does not support numeric/);
+ });
+
+ it('steps numbers and dates using the minimum, base, bounds, and default scale', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.min = '0.1';
+ input.max = '1';
+ input.step = '0.2';
+ input.value = '0.2';
+ input.stepUp();
+ expect(input.value).toBe('0.3');
+ input.stepUp(10);
+ expect(input.value).toBe('0.9');
+ input.stepDown(2);
+ expect(input.value).toBe('0.5');
+ input.step = 'any';
+ expect(() => input.stepUp()).toThrowError(/step=any/);
+ input.type = 'date';
+ input.min = '';
+ input.max = '';
+ input.step = '';
+ input.value = '2024-02-28';
+ input.stepUp(2);
+ expect(input.value).toBe('2024-03-01');
+ });
+
+ it('uses Monday-based week conversions and minute-based time steps', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'week';
+ input.value = '2020-W53';
+ expect(input.valueAsDate?.toISOString()).toBe('2020-12-28T00:00:00.000Z');
+ input.stepUp();
+ expect(input.value).toBe('2021-W01');
+ input.type = 'time';
+ input.value = '12:30:30';
+ input.stepUp();
+ expect(input.value).toBe('12:31');
+ input.step = '0.001';
+ input.value = '12:31:00.001';
+ input.stepDown();
+ expect(input.value).toBe('12:31');
+ input.min = '22:00';
+ input.max = '02:00';
+ input.value = '12:00';
+ expect(input.validity.rangeUnderflow).toBe(true);
+ expect(input.validity.rangeOverflow).toBe(true);
+ input.value = '23:00';
+ expect(input.validity.rangeUnderflow).toBe(false);
+ });
+
+ it('starts the caret at zero when an input becomes text-selectable', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.value = 'hello@example.com';
+ input.type = 'email';
+ input.type = 'text';
+ expect(input.selectionStart).toBe(0);
+ expect(input.selectionEnd).toBe(0);
+ });
+
+ it('handles invalid numeric constraints and input-type value-mode transitions', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.value = 'edited';
+ input.type = 'checkbox';
+ expect(input.defaultValue).toBe('edited');
+ input.type = 'number';
+ expect(input.value).toBe('');
+ input.min = 'invalid';
+ input.max = 'invalid';
+ input.step = '0';
+ input.value = '2';
+ input.stepUp();
+ expect(input.value).toBe('3');
+ input.valueAsNumber = NaN;
+ expect(input.value).toBe('');
+ expect(() => {
+ input.valueAsNumber = Infinity;
+ }).toThrow(TypeError);
+ input.type = 'unrecognized';
+ expect(input.type).toBe('text');
+ expect(() => input.stepUp()).toThrowError(/cannot step/);
+ });
+
+ it('sanitizes range defaults, bounds, and step alignment', () => {
+ const { document } = fixture();
+ const input = document.createElement('input');
+ input.type = 'range';
+ expect(input.value).toBe('50');
+ input.min = '10';
+ input.max = '20';
+ input.step = '3';
+ input.value = '12';
+ expect(input.value).toBe('13');
+ input.value = '99';
+ expect(input.value).toBe('19');
+ });
+});
+
+describe('form association, labels, disabledness, and constraints', () => {
+ it('resolves explicit owners dynamically, including detached ancestor fallback', () => {
+ const { document, root } = fixture();
+ const form = document.createElement('form');
+ form.id = 'owner';
+ const input = document.createElement('input');
+ input.setAttribute('form', 'owner');
+ root.append(input, form);
+ expect(input.form).toBe(form);
+ form.id = 'other';
+ expect(input.form).toBe(null);
+ form.append(input);
+ form.remove();
+ expect(input.form).toBe(form);
+ const impostor = document.createElement('div');
+ impostor.id = 'owner';
+ form.id = 'owner';
+ root.append(impostor, form);
+ expect(input.form).toBe(null);
+ });
+
+ it('exposes live labels and first-legend disabled-fieldset exceptions', () => {
+ const { document, root } = fixture();
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ const legend = document.createElement('legend');
+ const input = document.createElement('input');
+ input.id = 'field';
+ const other = document.createElement('input');
+ legend.append(input);
+ fieldset.append(legend, other);
+ root.append(fieldset);
+ expect(isDisabled(input)).toBe(false);
+ expect(isDisabled(other)).toBe(true);
+ const labels = input.labels;
+ const label = document.createElement('label');
+ label.htmlFor = 'field';
+ root.append(label);
+ expect(labels).toEqual([label]);
+ label.htmlFor = 'different';
+ expect(labels).toHaveLength(0);
+ input.type = 'hidden';
+ expect(input.labels).toBe(null);
+ });
+
+ it('exposes live validity, custom errors, invalid events, and validation eligibility', () => {
+ const { document, root } = fixture();
+ const input = document.createElement('input');
+ input.required = true;
+ root.append(input);
+ const validity = input.validity;
+ expect(validity.valueMissing).toBe(true);
+ const invalid = vi.fn((event: Event) => {
+ expect(event.bubbles).toBe(false);
+ event.preventDefault();
+ });
+ input.addEventListener('invalid', invalid);
+ expect(input.checkValidity()).toBe(false);
+ expect(invalid).toHaveBeenCalledOnce();
+ input.value = 'okay';
+ expect(validity.valid).toBe(true);
+ input.setCustomValidity('Application constraint');
+ expect(input.validationMessage).toBe('Application constraint');
+ expect(validity.customError).toBe(true);
+ input.disabled = true;
+ expect(input.willValidate).toBe(false);
+ expect(input.validationMessage).toBe('');
+ expect(input.checkValidity()).toBe(true);
+ input.disabled = false;
+ input.setCustomValidity('');
+ input.readOnly = true;
+ expect(input.willValidate).toBe(false);
+ input.type = 'checkbox';
+ expect(input.willValidate).toBe(true);
+ });
+
+ it('invalidates connected validation state independently of attribute mutations', () => {
+ const onInvalidate = vi.fn();
+ const document = new Document({ onInvalidate });
+ const input = document.createElement('input');
+ document.append(input);
+ onInvalidate.mockClear();
+ input.setCustomValidity('Invalid');
+ expect(onInvalidate).toHaveBeenCalledWith({ kind: 'mutation', target: input });
+ onInvalidate.mockClear();
+ input.setCustomValidity('Invalid');
+ expect(onInvalidate).not.toHaveBeenCalled();
+ input.remove();
+ onInvalidate.mockClear();
+ input.setCustomValidity('Different');
+ expect(onInvalidate).not.toHaveBeenCalled();
+ });
+
+ it('validates email, patterns, numeric bounds and steps, and radio requirements', () => {
+ const { document, root } = fixture();
+ const input = document.createElement('input');
+ input.type = 'email';
+ input.value = 'invalid';
+ expect(input.validity.typeMismatch).toBe(true);
+ input.value = 'person@example.com';
+ input.pattern = '.+@example\\.org';
+ expect(input.validity.patternMismatch).toBe(true);
+ input.pattern = '[';
+ expect(input.validity.patternMismatch).toBe(false);
+ input.type = 'number';
+ input.min = '5';
+ input.max = '10';
+ input.step = '2';
+ input.value = '12';
+ expect(input.validity.rangeOverflow).toBe(true);
+ expect(input.validity.stepMismatch).toBe(true);
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ for (const radio of [first, second]) {
+ radio.type = 'radio';
+ radio.name = 'group';
+ }
+ first.required = true;
+ root.append(first, second);
+ expect(second.validity.valueMissing).toBe(true);
+ second.checked = true;
+ expect(first.validity.valueMissing).toBe(false);
+ });
+
+ it('applies text length constraints to user edits, preserving UTF-16 lengths', () => {
+ const { document } = fixture();
+ const textarea = document.createElement('textarea');
+ textarea.maxLength = 2;
+ textarea.value = 'ša';
+ expect(textarea.textLength).toBe(3);
+ expect(textarea.validity.tooLong).toBe(false);
+ markControlUserEdited(textarea);
+ expect(textarea.validity.tooLong).toBe(true);
+ textarea.defaultValue = 'reset';
+ controlState(textarea)?.reset();
+ expect(textarea.value).toBe('reset');
+ expect(textarea.validity.tooLong).toBe(false);
+ expect(() => {
+ textarea.maxLength = -1;
+ }).toThrowError(/negative/);
+ });
+});
diff --git a/packages/dom/test/host-editing.test.ts b/packages/dom/test/host-editing.test.ts
new file mode 100644
index 0000000..8975b69
--- /dev/null
+++ b/packages/dom/test/host-editing.test.ts
@@ -0,0 +1,254 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ Document,
+ type Element,
+ type HTMLInputElement,
+ type HTMLTextAreaElement,
+} from '../src/dom.ts';
+import { InputEvent, KeyboardEvent } from '../src/events.ts';
+
+function fixture(): {
+ document: Document;
+ root: Element;
+ input: HTMLInputElement;
+ textarea: HTMLTextAreaElement;
+} {
+ const document = new Document();
+ const root = document.createElement('application');
+ const input = document.createElement('input');
+ const textarea = document.createElement('textarea');
+ document.append(root);
+ root.append(input, textarea);
+ input.focus();
+ return { document, root, input, textarea };
+}
+
+afterEach(() => vi.useRealTimers());
+
+describe('host editing', () => {
+ it('separates synthetic keyboard dispatch from host editing and delivers input event data', () => {
+ const { document, input } = fixture();
+ const events: InputEvent[] = [];
+ for (const type of ['beforeinput', 'input'])
+ input.addEventListener(type, (event) => {
+ if (event instanceof InputEvent) events.push(event);
+ });
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
+ expect(input.value).toBe('');
+ document.dispatch.keydown({ key: 'a' });
+ expect(input.value).toBe('a');
+ expect(
+ events.map((event) => [
+ event.type,
+ event.inputType,
+ event.data,
+ event.cancelable,
+ event.isTrusted,
+ ]),
+ ).toEqual([
+ ['beforeinput', 'insertText', 'a', true, false],
+ ['input', 'insertText', 'a', false, false],
+ ]);
+ });
+
+ it('honors keydown and beforeinput cancellation, readonly, and focus changes in callbacks', () => {
+ const { document, input, textarea } = fixture();
+ input.addEventListener('keydown', (event) => event.preventDefault(), { once: true });
+ document.dispatch.keydown({ key: 'a' });
+ input.addEventListener('beforeinput', (event) => event.preventDefault(), { once: true });
+ expect(document.dispatch.insertText('b')).toBe(false);
+ input.readOnly = true;
+ document.dispatch.keydown({ key: 'c' });
+ input.readOnly = false;
+ input.addEventListener('beforeinput', () => textarea.focus(), { once: true });
+ document.dispatch.insertText('d');
+ expect(input.value).toBe('');
+ expect(textarea.value).toBe('');
+ });
+
+ it('uses UTF-16 selection offsets while navigation and deletion preserve grapheme clusters', () => {
+ const { document, input } = fixture();
+ document.dispatch.insertText('aš©āš»e\u0301');
+ expect(input.selectionStart).toBe(8);
+ document.dispatch.keydown({ key: 'ArrowLeft' });
+ expect(input.selectionStart).toBe(6);
+ document.dispatch.keydown({ key: 'Backspace' });
+ expect(input.value).toBe('ae\u0301');
+ expect(input.selectionStart).toBe(1);
+ document.dispatch.keydown({ key: 'Delete' });
+ expect(input.value).toBe('a');
+ });
+
+ it('extends and replaces selections, supports multiline Enter and bounded host insertion', () => {
+ const { document, input, textarea } = fixture();
+ input.value = 'abcd';
+ document.dispatch.keydown({ key: 'Home' });
+ document.dispatch.keydown({ key: 'ArrowRight', shiftKey: true });
+ document.dispatch.keydown({ key: 'ArrowRight', shiftKey: true });
+ expect([input.selectionStart, input.selectionEnd, input.selectionDirection]).toEqual([
+ 0,
+ 2,
+ 'forward',
+ ]);
+ document.dispatch.insertText('X');
+ expect(input.value).toBe('Xcd');
+ input.maxLength = 4;
+ document.dispatch.keydown({ key: 'End' });
+ document.dispatch.insertText('YZ');
+ expect(input.value).toBe('XcdY');
+ textarea.focus();
+ document.dispatch.insertText('one');
+ document.dispatch.keydown({ key: 'Enter' });
+ document.dispatch.insertText('two');
+ expect(textarea.value).toBe('one\ntwo');
+ });
+
+ it('commits user changes on blur or Enter, once per changed edit session', () => {
+ const { document, input, textarea } = fixture();
+ const change = vi.fn();
+ input.addEventListener('change', change);
+ input.value = 'script';
+ textarea.focus();
+ expect(change).not.toHaveBeenCalled();
+ input.focus();
+ document.dispatch.insertText('!');
+ document.dispatch.keydown({ key: 'Enter' });
+ expect(change).toHaveBeenCalledOnce();
+ textarea.focus();
+ expect(change).toHaveBeenCalledOnce();
+ input.focus();
+ document.dispatch.insertText('?');
+ textarea.focus();
+ expect(change).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not edit composition keystrokes or reserved shortcuts', () => {
+ const { document, input } = fixture();
+ document.dispatch.keydown({ key: 'a', isComposing: true });
+ document.dispatch.keydown({ key: 'c', ctrlKey: true });
+ expect(input.value).toBe('');
+ input.value = 'whole';
+ document.dispatch.keydown({ key: 'a', metaKey: true });
+ expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5]);
+ });
+
+ it('activates Enter immediately and Space on matching uncanceled keyup', () => {
+ const { document, root } = fixture();
+ const button = document.createElement('button');
+ root.append(button);
+ button.focus();
+ const click = vi.fn();
+ button.addEventListener('click', click);
+ document.dispatch.keydown({ key: 'Enter' });
+ expect(click).toHaveBeenCalledOnce();
+ document.dispatch.keydown({ key: ' ' });
+ expect(click).toHaveBeenCalledOnce();
+ document.dispatch.keyup({ key: ' ' });
+ expect(click).toHaveBeenCalledTimes(2);
+ document.dispatch.keydown({ key: ' ' });
+ button.addEventListener('keyup', (event) => event.preventDefault(), { once: true });
+ document.dispatch.keyup({ key: ' ' });
+ expect(click).toHaveBeenCalledTimes(2);
+ });
+
+ it('navigates and activates a named radio group with arrow keys', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('input');
+ const second = document.createElement('input');
+ first.type = second.type = 'radio';
+ first.name = second.name = 'choice';
+ root.append(first, second);
+ first.focus();
+ first.checked = true;
+ document.dispatch.keydown({ key: 'ArrowRight' });
+ expect(document.activeElement).toBe(second);
+ expect([first.checked, second.checked]).toEqual([false, true]);
+ expect(document.focusManager.focusableElements()).toContain(second);
+ expect(document.focusManager.focusableElements()).not.toContain(first);
+ });
+
+ it('processes autofocus once per document and preserves explicit focus', async () => {
+ vi.useFakeTimers();
+ const document = new Document();
+ const root = document.createElement('application');
+ const input = document.createElement('input');
+ input.setAttribute('autofocus', '');
+ root.append(input);
+ expect(document.activeElement).toBe(null);
+ document.append(root);
+ await vi.runAllTimersAsync();
+ expect(document.activeElement).toBe(input);
+ const later = document.createElement('button');
+ later.setAttribute('autofocus', '');
+ root.append(later);
+ await vi.runAllTimersAsync();
+ expect(document.activeElement).toBe(input);
+ const other = fixture();
+ other.textarea.setAttribute('autofocus', '');
+ await vi.runAllTimersAsync();
+ expect(other.document.activeElement).toBe(other.input);
+ });
+
+ it('replaces a document selection across mixed editable text and element boundaries', () => {
+ const { document, root } = fixture();
+ const editor = document.createElement('box');
+ editor.contentEditable = 'plaintext-only';
+ const first = document.createTextNode('hello ');
+ const emphasized = document.createElement('strong');
+ const last = document.createTextNode('world');
+ emphasized.append(last);
+ editor.append(first, emphasized);
+ root.append(editor);
+ editor.focus();
+ const selection = document.getSelection();
+ selection.setBaseAndExtent(first, 3, last, 2);
+ const events: string[] = [];
+ editor.addEventListener('beforeinput', (event) => events.push(event.type));
+ editor.addEventListener('input', (event) => events.push(event.type));
+ expect(document.dispatch.insertText('X')).toBe(true);
+ expect(editor.textContent).toBe('helXrld');
+ expect(first.data).toBe('hel');
+ expect(last.data).toBe('rld');
+ expect(selection.isCollapsed).toBe(true);
+ expect(events).toEqual(['beforeinput', 'input']);
+ document.dispatch.keydown({ key: 'Backspace' });
+ expect(editor.textContent).toBe('helrld');
+ });
+
+ it('protects noneditable islands and honors canceled editable input', () => {
+ const { document, root } = fixture();
+ const editor = document.createElement('box');
+ editor.contentEditable = 'true';
+ const protectedElement = document.createElement('span');
+ protectedElement.contentEditable = 'false';
+ const protectedText = document.createTextNode('protected');
+ protectedElement.append(protectedText);
+ editor.append('before', protectedElement, 'after');
+ root.append(editor);
+ editor.focus();
+ const selection = document.getSelection();
+ selection.collapse(protectedText, 2);
+ expect(document.dispatch.insertText('X')).toBe(false);
+ selection.selectAllChildren(editor);
+ expect(document.dispatch.insertText('X')).toBe(false);
+ expect(protectedText.data).toBe('protected');
+ selection.collapse(editor, 0);
+ editor.addEventListener('beforeinput', (event) => event.preventDefault(), { once: true });
+ expect(document.dispatch.insertText('X')).toBe(false);
+ expect(editor.textContent).toBe('beforeprotectedafter');
+ });
+
+ it('inserts plaintext line breaks and navigates editable grapheme boundaries', () => {
+ const { document, root } = fixture();
+ const editor = document.createElement('box');
+ editor.contentEditable = 'true';
+ root.append(editor);
+ editor.focus();
+ document.dispatch.insertText('aš');
+ document.dispatch.keydown({ key: 'ArrowLeft', shiftKey: true });
+ expect(document.getSelection().toString()).toBe('š');
+ document.dispatch.insertText('b');
+ document.dispatch.keydown({ key: 'Enter' });
+ expect(editor.textContent).toBe('ab\n');
+ });
+});
diff --git a/packages/dom/test/interactive-widgets.test.ts b/packages/dom/test/interactive-widgets.test.ts
new file mode 100644
index 0000000..6145c45
--- /dev/null
+++ b/packages/dom/test/interactive-widgets.test.ts
@@ -0,0 +1,246 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Document, type Element } from '../src/dom.ts';
+import { Event, ToggleEvent } from '../src/events.ts';
+import { isFocusable } from '../src/focus.ts';
+
+function fixture(): { document: Document; root: Element } {
+ const document = new Document();
+ const root = document.createElement('application');
+ document.append(root);
+ return { document, root };
+}
+
+afterEach(() => vi.useRealTimers());
+
+describe('details and summary', () => {
+ it('activates only the first summary, preserves click cancellation and interactive descendants', () => {
+ const { document, root } = fixture();
+ const details = document.createElement('details');
+ const first = document.createElement('summary');
+ const second = document.createElement('summary');
+ const label = document.createElement('span');
+ const button = document.createElement('button');
+ first.append(label, button);
+ details.append(first, second);
+ root.append(details);
+ expect(first.tabIndex).toBe(0);
+ expect(second.tabIndex).toBe(-1);
+ second.click();
+ expect(details.open).toBe(false);
+ label.click();
+ expect(details.open).toBe(true);
+ button.click();
+ expect(details.open).toBe(true);
+ first.addEventListener('click', (event) => event.preventDefault(), { once: true });
+ first.click();
+ expect(details.open).toBe(true);
+ first.click();
+ expect(details.open).toBe(false);
+ });
+
+ it('coalesces toggle tasks and excludes collapsed content from focus', async () => {
+ vi.useFakeTimers();
+ const { document, root } = fixture();
+ const details = document.createElement('details');
+ const summary = document.createElement('summary');
+ const input = document.createElement('input');
+ details.append(summary, input);
+ root.append(details);
+ const events: ToggleEvent[] = [];
+ details.addEventListener('toggle', (event) => {
+ if (event instanceof ToggleEvent) events.push(event);
+ });
+ expect(isFocusable(input)).toBe(false);
+ expect(isFocusable(summary)).toBe(true);
+ details.open = true;
+ input.focus();
+ expect(document.activeElement).toBe(input);
+ details.open = false;
+ expect(document.activeElement).toBe(root);
+ details.open = true;
+ expect(events).toHaveLength(0);
+ await vi.runAllTimersAsync();
+ expect(events).toHaveLength(1);
+ expect([events[0]?.oldState, events[0]?.newState, events[0]?.bubbles]).toEqual([
+ 'closed',
+ 'open',
+ false,
+ ]);
+ });
+
+ it('enforces named exclusivity for opening, regrouping, and subtree insertion', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('details');
+ const second = document.createElement('details');
+ first.name = second.name = 'group';
+ root.append(first, second);
+ first.open = true;
+ second.open = true;
+ expect(first.open).toBe(false);
+ first.name = 'other';
+ first.open = true;
+ first.name = 'group';
+ expect(first.open).toBe(false);
+ const detached = document.createElement('details');
+ detached.name = 'group';
+ detached.open = true;
+ root.append(detached);
+ expect(detached.open).toBe(false);
+ expect(second.open).toBe(true);
+ });
+
+ it('keeps exclusive groups scoped to a tree including independent shadow roots', () => {
+ const { document, root } = fixture();
+ const first = document.createElement('details');
+ first.name = 'group';
+ first.open = true;
+ root.append(first);
+ const host = document.createElement('box');
+ root.append(host);
+ const second = document.createElement('details');
+ second.name = 'group';
+ host.attachShadow({ mode: 'open' }).append(second);
+ second.open = true;
+ expect(first.open && second.open).toBe(true);
+ });
+});
+
+describe('dialogs', () => {
+ it('focuses autofocus content, traps modal navigation, restores previous focus and queues close', async () => {
+ vi.useFakeTimers();
+ const { document, root } = fixture();
+ const outside = document.createElement('button');
+ const dialog = document.createElement('dialog');
+ const first = document.createElement('input');
+ const preferred = document.createElement('button');
+ preferred.setAttribute('autofocus', '');
+ dialog.append(first, preferred);
+ root.append(outside, dialog);
+ outside.focus();
+ const close = vi.fn();
+ dialog.addEventListener('close', close);
+ dialog.showModal();
+ expect(document.activeElement).toBe(preferred);
+ expect(dialog.closedBy).toBe('closerequest');
+ outside.focus();
+ expect(document.activeElement).toBe(preferred);
+ expect(document.focusManager.focusableElements()).toEqual([first, preferred]);
+ document.dispatch.keydown({ key: 'Tab' });
+ expect(document.activeElement).toBe(first);
+ dialog.close('accepted');
+ expect(dialog.returnValue).toBe('accepted');
+ expect(document.activeElement).toBe(outside);
+ expect(close).not.toHaveBeenCalled();
+ await vi.runAllTimersAsync();
+ expect(close).toHaveBeenCalledOnce();
+ });
+
+ it('supports cancelable beforetoggle and requestClose, including closedby=none', () => {
+ const { document, root } = fixture();
+ const dialog = document.createElement('dialog');
+ root.append(dialog);
+ dialog.addEventListener('beforetoggle', (event) => event.preventDefault(), { once: true });
+ dialog.showModal();
+ expect(dialog.open).toBe(false);
+ dialog.showModal();
+ expect(document.activeElement).toBe(dialog);
+ dialog.closedBy = 'none';
+ document.dispatch.keydown({ key: 'Escape' });
+ expect(dialog.open).toBe(true);
+ dialog.addEventListener('cancel', (event) => event.preventDefault(), { once: true });
+ dialog.requestClose('cancelled');
+ expect(dialog.open).toBe(true);
+ expect(dialog.returnValue).toBe('');
+ dialog.requestClose('accepted');
+ expect(dialog.open).toBe(false);
+ expect(dialog.returnValue).toBe('accepted');
+ });
+
+ it('rejects invalid mode transitions, permits repeated show, and requires modal connectivity', () => {
+ const { document, root } = fixture();
+ const dialog = document.createElement('dialog');
+ expect(() => dialog.showModal()).toThrow('connected');
+ dialog.show();
+ dialog.show();
+ expect(() => dialog.showModal()).toThrow('different mode');
+ dialog.close();
+ root.append(dialog);
+ dialog.showModal();
+ dialog.showModal();
+ expect(() => dialog.show()).toThrow('different mode');
+ });
+
+ it('cleans removed modal subtrees and leaves other documents independent', () => {
+ const first = fixture();
+ const second = fixture();
+ const firstButton = first.document.createElement('button');
+ const secondButton = second.document.createElement('button');
+ const wrapper = first.document.createElement('box');
+ const dialog = first.document.createElement('dialog');
+ wrapper.append(dialog);
+ first.root.append(firstButton, wrapper);
+ second.root.append(secondButton);
+ dialog.showModal();
+ secondButton.focus();
+ expect(second.document.activeElement).toBe(secondButton);
+ wrapper.remove();
+ firstButton.focus();
+ expect(first.document.activeElement).toBe(firstButton);
+ expect(dialog.open).toBe(true);
+ first.root.append(wrapper);
+ expect(dialog.closedBy).toBe('none');
+ });
+
+ it('restores nested modal focus in reverse order', () => {
+ const { document, root } = fixture();
+ const outside = document.createElement('button');
+ const outer = document.createElement('dialog');
+ const inner = document.createElement('dialog');
+ const outerButton = document.createElement('button');
+ outer.append(outerButton);
+ root.append(outside, outer, inner);
+ outside.focus();
+ outer.showModal();
+ inner.showModal();
+ expect(document.activeElement).toBe(inner);
+ inner.close();
+ expect(document.activeElement).toBe(outerButton);
+ outer.close();
+ expect(document.activeElement).toBe(outside);
+ });
+
+ it('releases modal state inside a removed shadow host', () => {
+ const { document, root } = fixture();
+ const button = document.createElement('button');
+ const host = document.createElement('box');
+ const dialog = document.createElement('dialog');
+ host.attachShadow({ mode: 'closed' }).append(dialog);
+ root.append(button, host);
+ dialog.showModal();
+ expect(isFocusable(button)).toBe(false);
+ host.remove();
+ expect(isFocusable(button)).toBe(true);
+ expect(dialog.closedBy).toBe('none');
+ });
+
+ it('retains modal blocking after raw open removal, until the dialog is removed', () => {
+ const { document, root } = fixture();
+ const button = document.createElement('button');
+ const dialog = document.createElement('dialog');
+ root.append(button, dialog);
+ dialog.showModal();
+ dialog.open = false;
+ expect(isFocusable(button)).toBe(false);
+ dialog.remove();
+ expect(isFocusable(button)).toBe(true);
+ });
+
+ it('does not interpret a synthetic cancel event as a request to close', () => {
+ const { document, root } = fixture();
+ const dialog = document.createElement('dialog');
+ root.append(dialog);
+ dialog.showModal();
+ dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
+ expect(dialog.open).toBe(true);
+ });
+});
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/value-elements.test.ts b/packages/dom/test/value-elements.test.ts
new file mode 100644
index 0000000..fceb0b7
--- /dev/null
+++ b/packages/dom/test/value-elements.test.ts
@@ -0,0 +1,471 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ Document,
+ HTMLDataListElement,
+ HTMLMeterElement,
+ HTMLOptionElement,
+ HTMLProgressElement,
+ HTMLSelectElement,
+} from '../src/dom.ts';
+import { Event } from '../src/events.ts';
+import { controlState } from '../src/forms.ts';
+import { changeSelectSelection, handleSelectKey } from '../src/value-elements.ts';
+
+function fixture(): {
+ document: Document;
+ select: HTMLSelectElement;
+ options: HTMLOptionElement[];
+} {
+ const document = new Document();
+ const select = document.createElement('select');
+ const options = ['a', 'b', 'c'].map((value) => {
+ const option = document.createElement('option');
+ option.value = value;
+ option.text = value.toUpperCase();
+ return option;
+ });
+ select.append(...options);
+ return { document, select, options };
+}
+
+describe('selects and options', () => {
+ it('constructs specialized elements and keeps collections live and protected', () => {
+ const { document, select, options } = fixture();
+ expect(select).toBeInstanceOf(HTMLSelectElement);
+ expect(options[0]).toBeInstanceOf(HTMLOptionElement);
+ const collection = select.options;
+ const selected = select.selectedOptions;
+ expect(select.value).toBe('a');
+ expect(selected).toEqual([options[0]]);
+ const fourth = document.createElement('option');
+ fourth.id = 'fourth';
+ fourth.value = 'd';
+ select.append(fourth);
+ expect(collection).toHaveLength(4);
+ expect(select.item(3)).toBe(fourth);
+ expect(select.item(-1)).toBeNull();
+ expect(select.namedItem('fourth')).toBe(fourth);
+ expect(select.namedItem('')).toBeNull();
+ expect(Reflect.set(collection, 0, fourth)).toBe(false);
+ expect(Reflect.deleteProperty(collection, '0')).toBe(false);
+ select.value = 'd';
+ expect(selected).toEqual([fourth]);
+ expect(select.options[0]).toBe(options[0]);
+ });
+
+ it('keeps selectedness separate from selected attributes and resets dirty options', () => {
+ const { select, options } = fixture();
+ const [first, second, third] = options;
+ if (!first || !second || !third) expect.unreachable();
+ second.defaultSelected = true;
+ expect(select.selectedIndex).toBe(1);
+ third.selected = true;
+ expect(select.value).toBe('c');
+ expect(second.defaultSelected).toBe(true);
+ third.defaultSelected = true;
+ third.defaultSelected = false;
+ expect(third.selected).toBe(true);
+ controlState(select)?.reset();
+ expect(select.value).toBe('b');
+ third.defaultSelected = true;
+ expect(select.value).toBe('c');
+ });
+
+ it('supports explicit empty selection and duplicate values without firing change on assignments', () => {
+ const { select, options } = fixture();
+ const listener = vi.fn();
+ select.addEventListener('input', listener);
+ select.addEventListener('change', listener);
+ select.value = 'absent';
+ expect(select.selectedIndex).toBe(-1);
+ expect(select.selectedOptions).toHaveLength(0);
+ select.selectedIndex = 2;
+ expect(select.value).toBe('c');
+ const second = options[1];
+ if (!second) expect.unreachable();
+ second.value = 'a';
+ select.value = 'a';
+ expect(select.selectedIndex).toBe(0);
+ select.selectedIndex = 100;
+ expect(select.value).toBe('');
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('does not change selectedness when an existing selected attribute changes value', () => {
+ const { select, options } = fixture();
+ const [first, second] = options;
+ if (!first || !second) expect.unreachable();
+ first.setAttribute('selected', '');
+ second.selected = true;
+ first.setAttribute('selected', 'changed');
+ expect(select.selectedIndex).toBe(1);
+ first.removeAttribute('selected');
+ first.setAttribute('selected', '');
+ expect(select.selectedIndex).toBe(0);
+ });
+
+ it('follows the HTML reset algorithm even for an unchanged false option setter', () => {
+ const { select, options } = fixture();
+ select.value = 'missing';
+ expect(select.selectedIndex).toBe(-1);
+ const first = options[0];
+ if (!first) expect.unreachable();
+ first.selected = false;
+ // Chrome 156 skips its reset for this no-op setter; the current standard does not.
+ expect(select.selectedIndex).toBe(0);
+ });
+
+ it('updates single selection when selected options are inserted, removed, and moved', () => {
+ const { document, select, options } = fixture();
+ const other = document.createElement('select');
+ const option = document.createElement('option');
+ option.value = 'incoming';
+ option.selected = true;
+ select.insertBefore(option, options[0] ?? null);
+ expect(select.value).toBe('incoming');
+ expect(select.selectedOptions).toEqual([option]);
+ other.append(option);
+ expect(select.value).toBe('a');
+ expect(other.value).toBe('incoming');
+ select.selectedIndex = 2;
+ select.remove(2);
+ expect(select.value).toBe('a');
+ select.remove(100);
+ expect(select.length).toBe(2);
+ });
+
+ it('supports multiple, size, collection mutations and a null anchor', () => {
+ const { document, select, options } = fixture();
+ select.multiple = true;
+ const second = options[1];
+ if (!second) expect.unreachable();
+ second.selected = true;
+ expect(select.type).toBe('select-multiple');
+ expect(select.selectedOptions).toHaveLength(2);
+ select.multiple = false;
+ expect(select.selectedIndex).toBe(1);
+ select.length = 5;
+ expect(select.length).toBe(5);
+ expect(select.options[4]).toBeInstanceOf(HTMLOptionElement);
+ select.length = 1;
+ expect(select.selectedIndex).toBe(0);
+ const listbox = document.createElement('select');
+ listbox.size = 3;
+ listbox.add(document.createElement('option'), null);
+ expect(listbox.selectedIndex).toBe(-1);
+ listbox.size = 1;
+ expect(listbox.selectedIndex).toBe(0);
+ const foreign = document.createElement('option');
+ expect(() => listbox.add(foreign, select)).toThrow();
+ expect(foreign.parentNode).toBeNull();
+ document.append(listbox);
+ listbox.remove();
+ expect(document.documentElement).toBeNull();
+ });
+
+ it('collects options in tree order without crossing datalists or nested selects', () => {
+ const { document, select } = fixture();
+ const group = document.createElement('optgroup');
+ const wrapper = document.createElement('box');
+ const grouped = document.createElement('option');
+ grouped.value = 'grouped';
+ wrapper.append(grouped);
+ group.append(wrapper);
+ select.append(group);
+ const nested = document.createElement('select');
+ nested.append(document.createElement('option'));
+ const datalist = document.createElement('datalist');
+ datalist.append(document.createElement('option'));
+ const nestedGroup = document.createElement('optgroup');
+ nestedGroup.append(document.createElement('option'));
+ group.append(nestedGroup);
+ select.append(nested, datalist);
+ expect(select.options).toHaveLength(4);
+ expect(grouped.index).toBe(3);
+ });
+
+ it('collapses option text using ASCII whitespace and ignores script text', () => {
+ const document = new Document();
+ const option = document.createElement('option');
+ option.append(' one\n\t');
+ const span = document.createElement('span');
+ span.textContent = ' two\u00a0three ';
+ const script = document.createElement('script');
+ script.textContent = 'excluded';
+ option.append(span, script);
+ expect(option.text).toBe('one two\u00a0three');
+ expect(option.value).toBe(option.text);
+ option.value = '';
+ expect(option.value).toBe('');
+ option.label = 'Label';
+ expect(option.label).toBe('Label');
+ expect(option.text).toBe('one two\u00a0three');
+ option.label = '';
+ expect(option.label).toBe('');
+ });
+
+ it('skips disabled options and groups for defaults and host input', () => {
+ const document = new Document();
+ const select = document.createElement('select');
+ const group = document.createElement('optgroup');
+ group.disabled = true;
+ const blocked = document.createElement('option');
+ blocked.value = 'blocked';
+ group.append(blocked);
+ const allowed = document.createElement('option');
+ allowed.value = 'allowed';
+ select.append(group, allowed);
+ expect(select.value).toBe('allowed');
+ expect(changeSelectSelection(select, [0])).toBe(false);
+ expect(select.value).toBe('allowed');
+ blocked.selected = true;
+ expect(select.value).toBe('blocked');
+ select.disabled = true;
+ expect(changeSelectSelection(select, [1])).toBe(false);
+ expect(select.value).toBe('blocked');
+ });
+
+ it('host selection dispatches distinct input/change only for a changed value', () => {
+ const { select } = fixture();
+ const received: string[] = [];
+ select.addEventListener('input', (event) => {
+ received.push(`${event.type}:${select.value}:${event.isTrusted}:${event.composed}`);
+ });
+ select.addEventListener('change', (event) =>
+ received.push(`${event.type}:${select.value}:${event.composed}`),
+ );
+ expect(changeSelectSelection(select, [1])).toBe(true);
+ expect(changeSelectSelection(select, [1])).toBe(false);
+ expect(received).toEqual(['input:b:false:true', 'change:b:false']);
+ select.dispatchEvent(new Event('keydown'));
+ expect(select.value).toBe('b');
+ expect(handleSelectKey(select, 'End')).toBe(true);
+ expect(select.value).toBe('c');
+ expect(handleSelectKey(select, 'Home')).toBe(true);
+ expect(select.value).toBe('a');
+ });
+
+ it('invalidates connected selection changes without pretending they are attribute mutations', () => {
+ const invalidated = vi.fn();
+ const document = new Document({ onInvalidate: invalidated });
+ const select = document.createElement('select');
+ const first = document.createElement('option');
+ const second = document.createElement('option');
+ select.append(first, second);
+ select.selectedIndex = 1;
+ expect(invalidated).not.toHaveBeenCalled();
+ document.append(select);
+ invalidated.mockClear();
+ select.selectedIndex = 0;
+ expect(invalidated).toHaveBeenCalled();
+ expect(first.hasAttribute('selected')).toBe(false);
+ select.remove();
+ invalidated.mockClear();
+ select.selectedIndex = 1;
+ expect(invalidated).not.toHaveBeenCalled();
+ });
+
+ it('keeps dirty selectedness when options are cloned or imported', () => {
+ const { document, select, options } = fixture();
+ const second = options[1];
+ if (!second) expect.unreachable();
+ second.selected = true;
+ const copy = new Document().importNode(select, true);
+ if (!(copy instanceof HTMLSelectElement)) expect.unreachable();
+ expect(copy.value).toBe('b');
+ const optionCopy = copy.options[1];
+ if (!optionCopy) expect.unreachable();
+ optionCopy.defaultSelected = true;
+ optionCopy.defaultSelected = false;
+ expect(optionCopy.selected).toBe(true);
+ expect(optionCopy.ownerDocument).not.toBe(document);
+ select.selectedIndex = -1;
+ const emptyCopy = select.cloneNode(true);
+ if (!(emptyCopy instanceof HTMLSelectElement)) expect.unreachable();
+ expect(emptyCopy.selectedIndex).toBe(-1);
+ });
+});
+
+describe('form values, suggestions, and selected content', () => {
+ it('associates select/option/output with forms and validates placeholder selections', () => {
+ const document = new Document();
+ const form = document.createElement('form');
+ const select = document.createElement('select');
+ select.name = 'choice';
+ select.required = true;
+ const placeholder = document.createElement('option');
+ placeholder.value = '';
+ placeholder.text = 'Choose';
+ const option = document.createElement('option');
+ option.value = 'yes';
+ select.append(placeholder, option);
+ const output = document.createElement('output');
+ form.append(select, output);
+ expect(select.form).toBe(form);
+ expect(option.form).toBe(form);
+ expect(output.form).toBe(form);
+ expect(select.validity.valueMissing).toBe(true);
+ expect(select.checkValidity()).toBe(false);
+ select.value = 'yes';
+ expect(select.checkValidity()).toBe(true);
+ expect(controlState(select)?.entries()).toEqual([['choice', 'yes']]);
+ expect(controlState(output)?.entries()).toEqual([]);
+ select.setCustomValidity('Unavailable');
+ expect(select.validationMessage).toBe('Unavailable');
+ expect(select.checkValidity()).toBe(false);
+ select.disabled = true;
+ expect(select.willValidate).toBe(false);
+ expect(select.checkValidity()).toBe(true);
+ });
+
+ it('multiple selection submits only selected enabled options', () => {
+ const { select, options } = fixture();
+ select.name = 'value';
+ select.multiple = true;
+ for (const option of options) option.selected = true;
+ const second = options[1];
+ if (!second) expect.unreachable();
+ second.disabled = true;
+ expect(controlState(select)?.entries()).toEqual([
+ ['value', 'a'],
+ ['value', 'c'],
+ ]);
+ });
+
+ it('exposes live datalist options independently of selection', () => {
+ const document = new Document();
+ const datalist = document.createElement('datalist');
+ expect(datalist).toBeInstanceOf(HTMLDataListElement);
+ const options = datalist.options;
+ const option = document.createElement('option');
+ datalist.append(option);
+ expect(options).toEqual([option]);
+ expect(option.selected).toBe(false);
+ option.remove();
+ expect(options).toHaveLength(0);
+ });
+
+ it('retains output defaults across computed values and resets to default mode', () => {
+ const document = new Document();
+ const output = document.createElement('output');
+ output.textContent = 'initial';
+ expect(output.defaultValue).toBe('initial');
+ output.value = 'computed';
+ expect(output.defaultValue).toBe('initial');
+ output.defaultValue = 'new default';
+ expect(output.value).toBe('computed');
+ controlState(output)?.reset();
+ expect(output.value).toBe('new default');
+ output.textContent = 'pristine';
+ expect(output.defaultValue).toBe('pristine');
+ expect(output.willValidate).toBe(false);
+ const tokens = output.htmlFor;
+ output.htmlFor = 'first second';
+ expect(output.htmlFor).toBe(tokens);
+ expect([...tokens]).toEqual(['first', 'second']);
+ tokens.add('third');
+ expect(output.getAttribute('for')).toBe('first second third');
+ });
+
+ it('snapshots selected option contents until reselected and clears an empty selection', () => {
+ const { document, select, options } = fixture();
+ const button = document.createElement('button');
+ const content = document.createElement('selectedcontent');
+ button.append(content);
+ select.prepend(button);
+ expect(content.textContent).toBe('A');
+ expect(content.firstChild).not.toBe(options[0]?.firstChild);
+ select.value = 'b';
+ expect(content.textContent).toBe('B');
+ const selected = options[1];
+ if (!selected) expect.unreachable();
+ selected.text = 'Updated';
+ expect(content.textContent).toBe('B');
+ select.value = 'b';
+ expect(content.textContent).toBe('Updated');
+ select.selectedIndex = -1;
+ expect(content.childNodes).toHaveLength(0);
+ });
+
+ it('does not recursively fill selectedcontent inside options', () => {
+ const { document, select, options } = fixture();
+ const content = document.createElement('selectedcontent');
+ options[0]?.append(content);
+ select.selectedIndex = 0;
+ expect(content.childNodes).toHaveLength(0);
+ });
+
+ it('updates removed selected-option snapshots in a microtask without replacing unrelated mirrors', async () => {
+ const { document, select, options } = fixture();
+ const first = document.createElement('selectedcontent');
+ select.prepend(first);
+ const original = first.firstChild;
+ const second = document.createElement('selectedcontent');
+ select.prepend(second);
+ expect(first.firstChild).toBe(original);
+ const extra = document.createElement('option');
+ extra.text = 'extra';
+ select.append(extra);
+ expect(first.firstChild).toBe(original);
+ options[0]?.remove();
+ expect(select.value).toBe('b');
+ expect(first.textContent).toBe('A');
+ await Promise.resolve();
+ expect(first.textContent).toBe('B');
+ expect(second.textContent).toBe('B');
+ });
+});
+
+describe('numeric indicators', () => {
+ it('implements determinate and indeterminate progress with clamped getters', () => {
+ const document = new Document();
+ const progress = document.createElement('progress');
+ expect(progress).toBeInstanceOf(HTMLProgressElement);
+ expect(progress.position).toBe(-1);
+ expect(progress.value).toBe(0);
+ expect(progress.max).toBe(1);
+ progress.max = 10;
+ progress.value = 5;
+ expect(progress.position).toBe(0.5);
+ progress.value = 20;
+ expect(progress.value).toBe(10);
+ expect(progress.getAttribute('value')).toBe('20');
+ progress.max = 0;
+ expect(progress.max).toBe(10);
+ progress.setAttribute('max', '0');
+ expect(progress.max).toBe(1);
+ progress.value = -1;
+ expect(progress.value).toBe(0);
+ progress.removeAttribute('value');
+ expect(progress.position).toBe(-1);
+ expect(() => {
+ progress.value = Number.NaN;
+ }).toThrow(TypeError);
+ });
+
+ it('derives meter bounds in order and retains raw reflected attributes', () => {
+ const document = new Document();
+ const meter = document.createElement('meter');
+ expect(meter).toBeInstanceOf(HTMLMeterElement);
+ expect([meter.min, meter.max, meter.value, meter.low, meter.high, meter.optimum]).toEqual([
+ 0, 1, 0, 0, 1, 0.5,
+ ]);
+ meter.min = 10;
+ meter.max = 20;
+ meter.value = 100;
+ meter.low = 15;
+ meter.high = 12;
+ meter.optimum = -5;
+ expect([meter.value, meter.low, meter.high, meter.optimum]).toEqual([20, 15, 15, 10]);
+ expect(meter.getAttribute('value')).toBe('100');
+ meter.max = -1;
+ expect(meter.max).toBe(10);
+ meter.setAttribute('min', 'invalid');
+ expect(meter.min).toBe(0);
+ meter.setAttribute('max', '2.5 trailing');
+ expect(meter.max).toBe(2.5);
+ expect(() => {
+ meter.optimum = Number.POSITIVE_INFINITY;
+ }).toThrow(TypeError);
+ });
+});
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-options.test.ts b/packages/dom/test/wpt-options.test.ts
new file mode 100644
index 0000000..0a78c37
--- /dev/null
+++ b/packages/dom/test/wpt-options.test.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+import { describe, expect, it } from 'vitest';
+import { Document } from '../src/dom.ts';
+
+describe('upstream WPT: option-selected.html', () => {
+ const source = readFileSync(new URL('./wpt/option-selected.html.txt', import.meta.url), 'utf8');
+ const scripts = [...source.matchAll(/
+
+
+
+
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: {}