Skip to content

Commit 6371bac

Browse files
authored
[Platform-provided behaviors] Replace main proposal with HTMLButtonBehavior type-attribute approach (#1363)
### Updated: - Main proposal - Developer-defined behaviors doc - Privacy and security
1 parent 295e937 commit 6371bac

3 files changed

Lines changed: 373 additions & 895 deletions

File tree

PlatformProvidedBehaviors/developer-defined-behaviors.md

Lines changed: 117 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -8,87 +8,127 @@
88

99
## Overview
1010

11-
The [Platform-Provided Behaviors](explainer.md) proposal introduces a set of browser-supplied behaviors (e.g., `HTMLSubmitButtonBehavior`) that custom elements can opt into via `attachInternals()`. A natural extension of this model is to allow developers to define their own reusable behaviors by subclassing an `ElementBehavior` base class. This would enable patterns such as:
11+
The [Platform-Provided Behaviors](explainer.md) proposal introduces a set of browser-supplied behaviors (e.g., `HTMLButtonBehavior`) that custom elements declare via a `static behaviors` class property. A natural extension of this model is to allow developers to define their own reusable behaviors by subclassing one of the platform's [category base classes](explainer.md#behavior-categories-and-composition). This would enable patterns such as:
1212

13-
- Encapsulating common interaction patterns (tooltips, drag-and-drop, keyboard shortcuts) as composable units.
13+
- Defining new activation or embedded-content identities that the platform does not provide.
1414
- Polyfilling upcoming platform behaviors before they ship natively.
15-
- Composing developer-defined behaviors with platform-provided ones on the same element.
15+
- Composing developer-defined behaviors with platform-provided ones across categories on the same element.
1616

1717
```javascript
18-
class TooltipBehavior extends ElementBehavior {
19-
#content = '';
20-
#tooltipElement = null;
18+
class QRCodeBehavior extends EmbeddedContentBehavior {
19+
// The accessor name on internals.behaviors; here internals.behaviors.qrCode.
20+
static behaviorName = 'qrCode';
21+
22+
#value = '';
23+
#canvas = null;
24+
#resizeObserver = null;
2125

2226
behaviorAttachedCallback(internals) {
23-
this.element.addEventListener('mouseenter', this.#show);
24-
this.element.addEventListener('mouseleave', this.#hide);
25-
this.element.addEventListener('focus', this.#show);
26-
this.element.addEventListener('blur', this.#hide);
27+
// Render into a canvas that fills the host's replaced box.
28+
this.#canvas = document.createElement('canvas');
29+
this.element.append(this.#canvas);
30+
this.#render();
31+
}
32+
33+
elementConnectedCallback() {
34+
// Re-render at the host's size once it is laid out in the document.
35+
this.#resizeObserver = new ResizeObserver(() => this.#render());
36+
this.#resizeObserver.observe(this.element);
2737
}
2838

29-
#show = () => {
30-
if (!this.#content) {
39+
elementDisconnectedCallback() {
40+
// Tear down the observer created outside the host's own subtree.
41+
this.#resizeObserver?.disconnect();
42+
this.#resizeObserver = null;
43+
}
44+
45+
#render() {
46+
if (!this.#canvas) {
3147
return;
3248
}
33-
this.#tooltipElement = document.createElement('div');
34-
this.#tooltipElement.className = 'tooltip';
35-
this.#tooltipElement.textContent = this.#content;
36-
this.#tooltipElement.setAttribute('role', 'tooltip');
37-
document.body.appendChild(this.#tooltipElement);
38-
// Position tooltip near element.
39-
};
40-
41-
#hide = () => {
42-
this.#tooltipElement?.remove();
43-
this.#tooltipElement = null;
44-
};
45-
46-
get content() {
47-
return this.#content;
49+
const ctx = this.#canvas.getContext('2d');
50+
// Encode this.#value and draw the QR matrix onto the canvas.
51+
}
52+
53+
get value() {
54+
return this.#value;
4855
}
49-
set content(val) {
50-
this.#content = val;
56+
set value(val) {
57+
this.#value = val;
58+
this.#render();
5159
}
5260
}
5361
```
5462
55-
Behaviors are classes with a `behaviorAttachedCallback` method. The behavior is instantiated and passed to `behaviors`:
63+
Behaviors are classes with a `behaviorAttachedCallback` method. A behavior is declared as a class reference in `static behaviors`; the platform instantiates it per host, and the author reaches the instance through `internals.behaviors` under a name the behavior declares in a `static behaviorName`. Platform behaviors use canonical names (`HTMLButtonBehavior` declares `"button"`, reached as `behaviors.button`); a developer-defined behavior declares its own (`QRCodeBehavior` declares `static behaviorName = 'qrCode'`, reached as `behaviors.qrCode`):
5664
5765
```javascript
58-
class CustomButton extends HTMLElement {
66+
class QRCodeButton extends HTMLElement {
67+
static behaviors = [HTMLButtonBehavior, QRCodeBehavior];
68+
69+
#internals;
5970
constructor() {
6071
super();
72+
this.#internals = this.attachInternals();
73+
this.#internals.behaviors.button.type = 'button';
6174

62-
this._tooltipBehavior = new TooltipBehavior();
63-
this._submitBehavior = new HTMLSubmitButtonBehavior();
64-
this._internals = this.attachInternals({
65-
behaviors: [this._tooltipBehavior, this._submitBehavior]
75+
// On activation, copy the encoded URL to the clipboard.
76+
this.addEventListener('click', () => {
77+
const url = this.#internals.behaviors.qrCode.value;
78+
navigator.clipboard.writeText(url);
6679
});
6780
}
6881

6982
connectedCallback() {
70-
// Access state directly via the stored reference.
71-
this._tooltipBehavior.content = this.getAttribute('tooltip');
83+
// Access the platform-created instance via the behaviors collection.
84+
this.#internals.behaviors.qrCode.value = 'https://example.com';
7285
}
7386
}
7487
```
7588
76-
`TooltipBehavior` could be combined with platform-provided behaviors. Here, `CustomButton` gains both tooltip functionality (show on hover/focus) and submit button semantics (click/Enter submits forms, implicit submission, `role="button"`).
89+
`QRCodeBehavior` extends `EmbeddedContentBehavior`, and `HTMLButtonBehavior` is in the activation category. Because the two behaviors are in different categories, they compose: `QRCodeButton` renders a QR code (from `QRCodeBehavior`) and activates like a button (from `HTMLButtonBehavior`), so a click can, for example, copy the encoded link.
90+
91+
## Choosing a category
92+
93+
A developer-defined behavior extends one of the platform's [category base classes](explainer.md#behavior-categories-and-composition) (`ElementBehavior` is the abstract root):
94+
95+
- A behavior that activates (runs an action on click or keyboard) extends `ActivationBehavior`.
96+
- A behavior that renders replaced content extends `EmbeddedContentBehavior`.
97+
98+
The platform enforces composition with the same membership check it uses for platform behaviors, so developer-defined behaviors slot into the model without a separate compatibility mechanism.
99+
100+
A capability that is not an activation or embedded-content identity does not map to a current category.
101+
102+
## Goals
103+
104+
- Let developers define reusable behaviors as subclasses of a platform category base (`ActivationBehavior` or `EmbeddedContentBehavior`) that attach through the same `static behaviors` declaration as platform-provided behaviors.
105+
- Give a behavior a complete, well-defined lifecycle (attach, connect, disconnect) and a clear story for cleaning up resources it creates outside the host element.
106+
- Encourage behaviors to be self-contained units with isolated effects.
107+
- Reuse the platform-provided-behaviors [category composition model](explainer.md#behavior-categories-and-composition) for developer-defined behaviors.
77108
78-
#### ElementBehavior API
109+
## Non-goals
79110
80-
For developer-defined behaviors to work, `ElementBehavior` would need to expose an API that lets web developers set accessibility defaults, receive lifecycle notifications, and reference the host element:
111+
- Cooperating between sibling behaviors through a shared `super` chain.
112+
- Granting a behavior capabilities it cannot already reach from script.
113+
114+
## ElementBehavior API
115+
116+
`ElementBehavior` exposes an API that lets web developers reference the host element, set accessibility and form defaults through `ElementInternals`, receive lifecycle notifications, and clean up resources. The members below mirror the subset of the custom-element lifecycle a behavior needs, without re-exposing callbacks that belong to the element itself:
81117
82118
| Member | Kind | Description |
83119
|--------|------|-------------|
84-
| `element` | Property (read-only) | Reference to the host element. |
85-
| `behaviorAttachedCallback(internals)` | Lifecycle | Called when the behavior is attached to an element via `attachInternals()`. Receives the `ElementInternals` object. |
120+
| `behaviorName` | Static property | The name the behavior is exposed under on `internals.behaviors` (e.g. `static behaviorName = 'qrCode'` is reached as `internals.behaviors.qrCode`). |
121+
| `element` | Property (read-only) | The custom element the behavior is attached to. Set by the platform before `behaviorAttachedCallback` runs. |
122+
| `behaviorAttachedCallback(internals)` | Lifecycle | Called once when the behavior is attached. Receives the host's `ElementInternals`. The place to set defaults (e.g. `internals.role`) and, for a category behavior, to override the category's hooks. |
123+
| `elementConnectedCallback()` | Lifecycle | Called when the host is inserted into the document, after the element's own `connectedCallback`. Use for work that only makes sense while connected (positioning, document-scoped listeners, observers). May run multiple times if the host moves in and out of the document. |
124+
| `elementDisconnectedCallback()` | Lifecycle | Called when the host is removed from the document. The place to tear down anything the behavior created outside the host (elements appended to `document.body`, listeners on `document`/`window`, observers, timers). |
86125
87-
The following example shows how `HTMLButtonBehavior` (`type="button"`) would be implemented in userland:
126+
Because behaviors cannot be detached once attached (per the [platform-provided behaviors model](explainer.md#proposed-approach)), there is no `behaviorDetachedCallback`. Listeners registered directly on `element` are released together with the host when it is garbage-collected, so they do not need explicit removal; resources a behavior creates outside the host do.
127+
128+
The following example shows how a userland behavior would implement `HTMLButtonBehavior` (`type="button"`) as an activation identity. Because it reimplements a platform behavior, the same class doubles as a polyfill. As an `ActivationBehavior` subclass, it receives the activation dispatch path from its base (click, keyboard activation via Space and Enter, `element.click()`, and `preventDefault()`/`stopPropagation()` handling) and overrides the activation algorithm to define what the button does when activated.
88129
89130
```javascript
90-
class HTMLButtonBehaviorExample extends ElementBehavior {
91-
#disabled = false;
131+
class HTMLButtonBehaviorExample extends ActivationBehavior {
92132
#internals = null;
93133
#name = '';
94134
#value = '';
@@ -100,21 +140,17 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
100140

101141
behaviorAttachedCallback(internals) {
102142
this.#internals = internals;
143+
// Declare the identity's defaults.
103144
this.#internals.role = 'button';
104145
this.element.setAttribute('tabindex', '0');
105-
106-
this.element.addEventListener('click', this.#handleClick);
107-
this.element.addEventListener('keydown', this.#handleKeydown);
108-
this.element.addEventListener('keyup', this.#handleKeyup);
109146
}
110147

111-
#handleClick = (e) => {
112-
if (this.#disabled) {
113-
e.stopImmediatePropagation();
114-
e.preventDefault();
148+
// The ActivationBehavior base calls this when the host is activated and the
149+
// click was not canceled.
150+
activationBehavior(event) {
151+
if (this.#internals.states.has('disabled')) {
115152
return;
116153
}
117-
118154
if (this.#popoverTargetElement) {
119155
switch (this.#popoverTargetAction) {
120156
case 'show': {
@@ -140,42 +176,20 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
140176
});
141177
this.#commandForElement.dispatchEvent(commandEvent);
142178
}
143-
};
144-
145-
#handleKeydown = (e) => {
146-
if (this.#disabled) {
147-
return;
148-
}
149-
if (e.key === ' ' || e.key === 'Enter') {
150-
e.preventDefault();
151-
if (e.key === 'Enter') {
152-
this.element.click();
153-
}
154-
}
155-
};
156-
157-
#handleKeyup = (e) => {
158-
if (this.#disabled) {
159-
return;
160-
}
161-
if (e.key === ' ') {
162-
this.element.click();
163-
}
164-
};
179+
}
165180

166181
// Properties
167-
get disabled() { return this.#disabled; }
182+
get disabled() {
183+
return this.#internals.states.has('disabled');
184+
}
168185
set disabled(val) {
169-
this.#disabled = val;
170-
this.#internals?.setDisabled?.(this.#disabled);
186+
if (val) {
187+
this.#internals.states.add('disabled');
188+
} else {
189+
this.#internals.states.delete('disabled');
190+
}
171191
}
172192

173-
get name() { return this.#name; }
174-
set name(val) { this.#name = val; }
175-
176-
get value() { return this.#value; }
177-
set value(val) { this.#value = val; }
178-
179193
// Popover target API.
180194
get popoverTargetElement() { return this.#popoverTargetElement; }
181195
set popoverTargetElement(val) { this.#popoverTargetElement = val; }
@@ -189,97 +203,34 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
189203
set command(val) { this.#command = val; }
190204
}
191205

192-
// Attaching the behavior to a custom element:
206+
// Use the native behavior when available, otherwise the userland polyfill.
207+
const HTMLButtonBehavior = globalThis.HTMLButtonBehavior ?? HTMLButtonBehaviorExample;
208+
193209
class MyButton extends HTMLElement {
210+
static behaviors = [HTMLButtonBehavior];
211+
212+
#internals;
194213
constructor() {
195214
super();
196-
this._buttonBehavior = new HTMLButtonBehaviorExample();
197-
this._internals = this.attachInternals({ behaviors: [this._buttonBehavior] });
215+
this.#internals = this.attachInternals();
198216
}
199217
}
200218
```
201219
202-
- The subclass overrides `behaviorAttachedCallback(internals)` to receive the `ElementInternals` object; sets defaults such as `internals.role`; and register event listeners.
203-
- The platform would set `this.element` before calling `behaviorAttachedCallback`, so it is already available inside the callback. The example uses `this.element` to register event listeners and to trigger clicks during keyboard activation.
204-
- `ElementBehavior` needs to provide a way to affect the `:disabled` pseudo-class. The `setDisabled()` method (called in the `disabled` setter) would need to integrate with `ElementInternals` states.
205-
206-
#### Polyfilling behaviors
207-
208-
This design also would enable **polyfilling** new platform behaviors before they ship natively. Consider `HTMLDialogBehavior` (from `<dialog>`):
220+
- The subclass overrides `behaviorAttachedCallback(internals)` to receive the `ElementInternals` object and set its defaults, such as `internals.role` and focusability.
221+
- The subclass overrides the activation algorithm `activationBehavior(event)`. The `ActivationBehavior` base invokes it when the host is activated by click, keyboard (Space/Enter), or `element.click()`, so the subclass does not register its own activation listeners.
222+
- The platform sets `this.element` before calling `behaviorAttachedCallback`, so the host is available inside the callback and the activation algorithm.
223+
- A developer-defined behavior cannot make the real `:disabled` UA pseudo-class match (that is reserved to platform-provided behaviors), so the example stores disabled state in the host's [`CustomStateSet`](https://developer.mozilla.org/en-US/docs/Web/API/CustomStateSet) (`internals.states`) as the single source of truth for its getter, setter, and activation guard. Authors style it with `:state(disabled)`; a native `HTMLButtonBehavior` would drive `:disabled` directly.
209224
210-
```javascript
211-
// Polyfill for HTMLDialogBehavior.
212-
class HTMLDialogBehaviorPolyfill extends ElementBehavior {
213-
#open = false;
214-
#returnValue = '';
215-
#modal = false;
216-
#previouslyFocused = null;
217-
218-
behaviorAttachedCallback(internals) {
219-
internals.role = 'dialog';
220-
this.element.addEventListener('keydown', this.#handleKeydown);
221-
this.element.addEventListener('click', this.#handleBackdropClick);
222-
}
225+
## Composition and cooperation
223226
224-
show() {
225-
this.#open = true;
226-
this.#modal = false;
227-
this.element.setAttribute('open', '');
228-
// Focus first focusable element.
229-
}
230-
231-
showModal() {
232-
this.#open = true;
233-
this.#modal = true;
234-
this.#previouslyFocused = document.activeElement;
235-
this.element.setAttribute('open', '');
236-
}
227+
Developer-defined behaviors compose the same way platform-provided behaviors do: each is listed as a class in `static behaviors`, the platform instantiates one per host, and each acts on the shared host element and its `ElementInternals`. Sibling behaviors share no prototype chain, so when two behaviors need to coordinate, the host mediates rather than one behavior reaching into another. The `QRCodeButton` example above already shows this shape: the host adds a `click` listener that reacts to the activation behavior and reads the embedded-content behavior's `value`.
237228
238-
close(returnValue) {
239-
if (!this.#open) {
240-
return;
241-
}
242-
if (returnValue !== undefined) {
243-
this.#returnValue = returnValue;
244-
}
245-
this.#open = false;
246-
this.element.removeAttribute('open');
247-
this.#previouslyFocused?.focus();
248-
this.element.dispatchEvent(new Event('close'));
249-
}
250-
251-
#handleKeydown = (e) => {
252-
if (e.key === 'Escape' && this.#open) {
253-
const cancelEvent = new Event('cancel', { cancelable: true });
254-
this.element.dispatchEvent(cancelEvent);
255-
if (!cancelEvent.defaultPrevented) {
256-
this.close();
257-
}
258-
}
259-
};
260-
261-
// Implementation of focus trapping, backdrop click handling, etc.
262-
263-
get open() {
264-
return this.#open;
265-
}
266-
get returnValue() {
267-
return this.#returnValue;
268-
}
269-
set returnValue(val) {
270-
this.#returnValue = val;
271-
}
272-
273-
static behaviorName = 'htmlDialog';
274-
}
275-
276-
// Use polyfill until native support arrives.
277-
const HTMLDialogBehavior = globalThis.HTMLDialogBehavior ?? HTMLDialogBehaviorPolyfill;
278-
```
229+
## Open questions
279230
280-
Although the polyfill above can't fully replicate a native `<dialog>` element (no true top layer, no `::backdrop`, no `:modal`), it provides a reasonable approximation.
231+
- Should the platform offer a channel for one behavior to observe or extend another, or is host-mediated coordination enough?
232+
- Do developer-defined behaviors need any registration or naming convention, or are they purely local to the author's code?
281233
282-
#### Considerations for developer-defined behaviors
234+
## References
283235
284-
- They can compose with platform-provided behaviors.
285-
- The same conflict resolution strategies that apply to platform behaviors would need to work with developer-defined behaviors.
236+
- [Elix functional mixins](https://elix.org/elix/mixins). A component library that composes reusable behavior as class-level functional mixins (cooperating along the prototype chain via `super`, designed for order-independence and isolated effects). Useful prior art for how a behavior-composition system can be designed.

0 commit comments

Comments
 (0)