Skip to content

Commit ca7eae0

Browse files
committed
Add headless HTML controls and interactive element behavior
1 parent f8dd91c commit ca7eae0

29 files changed

Lines changed: 7152 additions & 141 deletions

‎packages/dom/README.md‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ dispatch.keyup({ key: 'Tab' });
6565

6666
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`.
6767

68-
`element.dispatchEvent()` delivers a synthetic event. It does not move focus, edit text, or perform browser default actions. `Dispatch` is the explicit host-input boundary and implements logical Tab navigation; its dispatched events are still untrusted. See [focus and controls](docs/focus-and-controls.md).
68+
`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).
6969

7070
## Two independent selection systems
7171

@@ -105,6 +105,40 @@ Each environment has an explicit `document.HTMLElement` base and registry. Auton
105105

106106
`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.
107107

108+
## Interactive HTML and terminal elements
109+
110+
Forms, buttons, labels, fieldsets, inputs, textareas, selects/options, output,
111+
progress/meter, details/summary and dialogs have specialized classes and tested
112+
state transitions. See [forms and element extensions](docs/forms-and-elements.md)
113+
for examples and limits, and [interactive standards](docs/interactive-standards.md)
114+
for browser comparisons and known differences.
115+
116+
Arbitrary names still work without registration. To give a terminal element
117+
application behavior, register its factory explicitly on each document:
118+
119+
```ts
120+
import { Document, Element } from '@bomb.sh/dom';
121+
122+
class Box extends Element {
123+
constructor(document: Document) {
124+
super('box', document);
125+
}
126+
status = 'ready';
127+
}
128+
declare module '@bomb.sh/dom' {
129+
interface Elements {
130+
box: Box;
131+
}
132+
}
133+
document.registerElement('box', (owner) => new Box(owner));
134+
document.createElement('box').status; // string, with a real Box instance
135+
```
136+
137+
Declaration merging is a TypeScript contract: install the factory in every
138+
application document that relies on that declaration. Registration affects future
139+
creation, cloning and parsing; it does not upgrade existing nodes. Hyphenated
140+
custom elements use `document.customElements` and its lifecycle model.
141+
108142
## Development
109143

110144
From the playground root:

‎packages/dom/docs/capabilities.md‎

Lines changed: 27 additions & 22 deletions
Large diffs are not rendered by default.

‎packages/dom/docs/focus-and-controls.md‎

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,22 @@ The implementation follows the
1717
where applicable to a headless environment. Eligibility includes native controls,
1818
links with `href`, valid `tabindex`, and editable elements. Disconnected,
1919
hidden, inert, disabled, and hidden-type input elements cannot receive focus.
20-
Disabled fieldsets honor the first-legend exception. There is no CSS visibility,
21-
geometry, platform focus preference, browsing-context, or modal-dialog model.
20+
Disabled fieldsets honor the first-legend exception. Closed dialogs and collapsed
21+
details content are excluded; a details element's first summary remains eligible.
22+
An open modal dialog makes the rest of its document logically inert. There is no
23+
CSS visibility, geometry, platform focus preference, or browsing-context model.
2224

2325
Logical Tab order sorts positive `tabindex` values within each document,
2426
shadow-host, and slot scope, then flattens those scopes. Zero/default values keep
2527
tree order within their scope. Negative scope owners omit their subtree from
2628
sequential navigation while allowing programmatic focus. Navigation wraps within
27-
the document. Shadow delegation preserves an existing delegate and respects
28-
nested delegation boundaries. Browser chrome, popover/navigation-context scopes,
29-
radio-group behavior, and platform focus preferences remain unfinished.
29+
the document, restricted to the topmost modal dialog when one is present. Shadow
30+
delegation preserves an existing delegate and respects nested delegation boundaries.
31+
Autofocus runs in a queued task after connection, once per document, and preserves
32+
focus that was explicitly placed before that task. Dialog opening independently
33+
honors autofocus. Named radio groups contribute their checked member to sequential
34+
navigation, or their first eligible member when unchecked. Browser chrome,
35+
popover/navigation-context scopes and platform focus preferences remain unfinished.
3036

3137
```ts
3238
import { Document, Dispatch } from '@bomb.sh/dom';
@@ -45,10 +51,45 @@ dispatch.keyup({ key: 'Tab' }); // Routes keyup to the newly focused button.
4551
```
4652

4753
`Dispatch.keydown()` and `keyup()` accept host data and construct synthetic
48-
`KeyboardEvent` objects. Uncanceled Tab performs explicit logical navigation.
49-
The DOM event remains untrusted. Dispatching a keyboard or focus event directly
50-
never performs focus navigation, text editing, activation, or other default
51-
actions. Byte decoding belongs to the host.
54+
`KeyboardEvent` objects. Uncanceled Tab performs logical navigation. Enter activates
55+
buttons and summaries; Space activates buttons, summaries, checkboxes, and radios
56+
on an uncanceled matching keyup. Radio arrow navigation changes the focused and
57+
checked member. Select arrow/Home/End keys skip disabled options. Escape requests
58+
closure of the topmost eligible dialog. Text-input Enter commits a pending change
59+
and applies implicit form submission. The generated events remain untrusted.
60+
Dispatching a keyboard or focus event directly never performs these host actions.
61+
Byte decoding belongs to the host.
62+
63+
Host typing and `dispatch.insertText(text)` edit selection-capable inputs and
64+
textareas. `beforeinput` can cancel the edit; a successful edit emits `input`.
65+
Readonly and disabled controls reject editing. Host insertion honors `maxlength`;
66+
script assignments remain unrestricted. Control edits emit `change` on blur or
67+
input Enter when the value has changed since the edit session began. Programmatic
68+
assignments do not independently start an edit session.
69+
70+
Left/Right, Backspace, and Delete use grapheme boundaries while all exposed offsets
71+
remain UTF-16 code units. Shift extends selection, Home/End go to the start/end of
72+
the value, and Ctrl/Meta+A selects it. Textarea Enter inserts a line break. The host
73+
can call `dispatch.select(indices)` to commit an option selection from its own picker.
74+
IME composition orchestration, clipboard access, visual-line movement, and editing
75+
non-selection input types such as number/date/email remain unfinished.
76+
77+
```ts
78+
input.focus();
79+
document.dispatch.insertText('hello');
80+
document.dispatch.keydown({ key: 'ArrowLeft', shiftKey: true });
81+
document.dispatch.insertText('!');
82+
```
83+
84+
Connected `contenteditable` and `plaintext-only` editing hosts also accept host text
85+
insertion, range replacement, selection movement, deletion, and plaintext line
86+
breaks. Their caret is the document's live `Selection`; mixed text/element boundary
87+
points work without replacing the entire subtree. Input events target the editing
88+
host. As an explicit conservative editing policy, edits whose selected range
89+
intersects a `contenteditable=false` island are rejected. Rich-text formatting,
90+
atomic deletion of noneditable widgets, HTML paragraph creation, and editable
91+
selection inside shadow trees are unfinished. This is a defined plaintext editing
92+
subset, not a browser editing-engine compatibility claim.
5293

5394
Input and textarea value selection is separate from document `Selection`.
5495
The [HTML text selection algorithms](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection)
@@ -74,6 +115,31 @@ listeners are not copied. A shallow pristine textarea clone retains the current
74115
value despite having no default-value children. Moving/adopting the existing
75116
element preserves its selection and value.
76117

77-
Form submission, validity, input-type-specific value sanitization beyond newline
78-
handling, native editing actions, and select/option value models are unfinished.
79-
No caret geometry, painting, or terminal width calculation is included.
118+
Details and dialogs follow the state/event algorithms in the
119+
[HTML interactive elements specification](https://html.spec.whatwg.org/multipage/interactive-elements.html).
120+
Details `open` changes queue a coalesced `ToggleEvent`; a nonempty `name` enforces
121+
one open member per tree. Canceling a summary's click prevents its toggle. Dialog
122+
`show()`/`showModal()` send cancelable opening `beforetoggle`, focus an autofocus
123+
candidate, the first eligible descendant, or the dialog itself, and queue `toggle`.
124+
`requestClose()` sends cancelable `cancel`; `close()` bypasses cancellation, updates
125+
`returnValue`, restores applicable prior focus, and queues `close`. Closing
126+
`beforetoggle` is not cancelable. Nested modal dialogs have independent focus
127+
restoration, and removing a dialog subtree releases modal state, including shadow
128+
descendants. State is owned by document-keyed weak maps.
129+
130+
```ts
131+
const dialog = document.createElement('dialog');
132+
const accept = document.createElement('button');
133+
accept.textContent = 'Accept';
134+
accept.addEventListener('click', () => dialog.close('accepted'));
135+
dialog.append(accept);
136+
box.append(dialog);
137+
dialog.showModal();
138+
```
139+
140+
One intentional standards surprise: removing a modal dialog's `open` attribute
141+
directly does not release its modal blocking. Use `close()` or remove the dialog.
142+
`closedBy` controls host Escape requests; an explicit `requestClose()` ignores it.
143+
Pointer light-dismiss, popovers, browser CloseWatcher activation-history policy,
144+
caret geometry, painting, and terminal width calculation are outside this tested
145+
scope.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Forms and element extensions
2+
3+
`Document.createElement()` constructs actual specialized classes for `input`,
4+
`textarea`, `button`, `form`, `label`, `fieldset`, `legend`, `select`, `option`,
5+
`optgroup`, `datalist`, `selectedcontent`, `output`, `progress`, `meter`, `details`,
6+
`summary`, `dialog`, `template` and `slot`. Arbitrary names use the common Element
7+
base unless the document registers an application factory.
8+
9+
## Forms without network submission
10+
11+
```ts
12+
import { Document, FormDataEvent, SubmitEvent } from '@bomb.sh/dom';
13+
14+
const document = new Document();
15+
const form = document.createElement('form');
16+
const name = document.createElement('input');
17+
name.name = 'name';
18+
name.required = true;
19+
const submit = document.createElement('button');
20+
submit.textContent = 'Continue';
21+
form.append(name, submit);
22+
document.append(form);
23+
24+
form.addEventListener('submit', (event) => {
25+
if (event instanceof SubmitEvent) console.info(event.submitter);
26+
});
27+
form.addEventListener('formdata', (event) => {
28+
if (event instanceof FormDataEvent) console.info([...event.formData]);
29+
});
30+
name.value = 'Ada';
31+
form.requestSubmit(submit); // Validation → submit → formdata; no network request.
32+
```
33+
34+
Controls resolve their form through ancestors or an explicit `form` ID in a
35+
connected document. Moving controls, changing IDs, and adoption update association.
36+
Labels resolve their control through `for` or a nested labelable element. Disabled
37+
fieldsets exclude descendants except those inside the first legend. Collections
38+
are live, protected arrays; they do not claim the browser's complete
39+
HTMLFormControlsCollection or HTMLOptionsCollection interface.
40+
41+
`checkValidity()` and `reportValidity()` dispatch cancelable `invalid` events;
42+
reporting focuses an eligible uncanceled invalid control. There is no browser
43+
validation popup. Validity covers the implemented required/type/pattern/length/
44+
min/max/step constraints plus custom errors. Invalid values can still be assigned
45+
by application code. Minimum/maximum text length validity applies to user edits.
46+
47+
`requestSubmit()` validates and sends a cancelable `SubmitEvent`. `submit()` bypasses
48+
validation and that event. Both construct an entry list and send `FormDataEvent`.
49+
`method="dialog"` closes the ancestor dialog after collecting entries. Canceling
50+
`submit` prevents those subsequent actions. Submitting a disconnected form does
51+
nothing. `reset()` sends a cancelable reset event and restores defaults without
52+
synthesizing input/change events. `new FormData(form, submitter)` can collect entries
53+
independently; it uses the host's actual FormData/Blob implementation (Node 22 in
54+
this workspace). Reentrant entry collection is rejected.
55+
56+
Input/textarea dirty values and checkedness are distinct from attributes/defaults.
57+
Checkbox click activation happens before listeners and rolls back when canceled.
58+
Radio groups share a nonempty name, form owner and tree; cloned controls preserve
59+
current values, checkedness, dirty flags and input indeterminateness. Numeric/date
60+
input types support applicable `valueAsNumber`, `valueAsDate`, `stepUp` and `stepDown`.
61+
Programmatic changes invalidate connected state but do not synthesize input/change.
62+
See [focus and controls](focus-and-controls.md) for host editing and selection.
63+
64+
File-input files/selection, picker UI, numeric editor display state (`badInput`),
65+
modern color alpha/color-space handling, form-associated custom elements,
66+
submission encoding/navigation, and complete HTML control properties are unfinished
67+
or outside the package's scope. No file-selection API or browser window is claimed.
68+
69+
## Choices and calculated values
70+
71+
```ts
72+
const select = document.createElement('select');
73+
select.name = 'mode';
74+
for (const value of ['quick', 'thorough']) {
75+
const option = document.createElement('option');
76+
option.value = value;
77+
option.text = value;
78+
select.add(option);
79+
}
80+
form.append(select);
81+
select.value = 'thorough';
82+
select.focus();
83+
document.dispatch.select([0]); // Explicit host selection, with input/change events.
84+
85+
const output = document.createElement('output');
86+
output.defaultValue = 'Ready';
87+
output.value = 'Running';
88+
form.append(output);
89+
form.reset(); // Restores output to Ready, alongside the other controls.
90+
```
91+
92+
Single and multiple selects preserve option selectedness and dirtiness through
93+
mutation, cloning, reset and adoption. Disabled options/groups are excluded from
94+
host selection and form entries. `selectedcontent` mirrors selected option contents
95+
as snapshots; it is not a general live cloning facility. Datalist exposes options
96+
without a popup. Output exposes default/current values and `htmlFor` tokens.
97+
Progress supports determinate/indeterminate position; meter clamps its numeric
98+
range and thresholds. Painting is the renderer's responsibility.
99+
100+
## Templates and shadow composition
101+
102+
```ts
103+
import { installParsing } from '@bomb.sh/dom/parsing';
104+
installParsing(document);
105+
const container = document.createElement('div');
106+
container.setHTMLUnsafe(`
107+
<section>
108+
<template shadowrootmode="open" shadowrootclonable shadowrootserializable>
109+
<slot></slot>
110+
</template>
111+
Light content
112+
</section>
113+
`);
114+
console.info(container.getHTML({ serializableShadowRoots: true }));
115+
```
116+
117+
`innerHTML` keeps shadow templates inert. `setHTMLUnsafe` explicitly enables
118+
declarative shadow parsing for hosts inside the parsed fragment; a top-level
119+
template does not attach a root to the existing context element. There is no
120+
sanitizer, and scripts remain inert. A same-mode `attachShadow` on a declarative
121+
root clears it and returns the same root for hydration. Clonable roots copy their
122+
shadow descendants even when the host clone is shallow. `getHTML` includes roots
123+
only when requested through `serializableShadowRoots` or an explicit `shadowRoots`
124+
list. Ordinary serialization omits them.
125+
126+
```ts
127+
const host = document.createElement('box');
128+
const shadow = host.attachShadow({ mode: 'open', slotAssignment: 'manual' });
129+
const slot = document.createElement('slot');
130+
shadow.append(slot);
131+
const first = document.createElement('text');
132+
const second = document.createTextNode('second');
133+
host.append(first, second);
134+
slot.assign(second, first); // Assignment order; light-tree order stays unchanged.
135+
```
136+
137+
Manual assignments use weak references and survive removal/reinsertion of the
138+
assigned node. Changing connected composition invalidates the host renderer and
139+
queues slotchange without inventing a tree mutation record. The package permits
140+
HTML terminal names as shadow hosts; this deliberately extends the browser's
141+
restricted valid-shadow-host-name list. SVG/MathML shadow hosts are rejected.
142+
Scoped registries, ElementInternals and the complete shadow/custom-element
143+
specification remain unfinished.
144+
145+
Custom-element reactions belong to document-keyed weak state. Mutations share a
146+
bounded local queue across participating documents and drain it in `finally`;
147+
direct parser calls also own a scope, including failed parsing. Backup reactions
148+
receive their own microtask. There is no module-global strong pending-element
149+
queue that waits for an unrelated document mutation.

0 commit comments

Comments
 (0)