Skip to content

Commit 3f5ebe7

Browse files
Ana Sollano KimCopilot
andcommitted
Sync developer-defined behaviors with type-attribute proposal and category model
Aligns the doc with the static behaviors API and the ActivationBehavior/EmbeddedContentBehavior category model, and folds in the lifecycle, disabled-state, cooperation, and references improvements from PR MicrosoftEdge#1343 (which this supersedes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3d93e80 commit 3f5ebe7

1 file changed

Lines changed: 113 additions & 166 deletions

File tree

PlatformProvidedBehaviors/developer-defined-behaviors.md

Lines changed: 113 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -8,87 +8,123 @@
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+
#value = '';
20+
#canvas = null;
21+
#resizeObserver = null;
2122

2223
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);
24+
// Render into a canvas that fills the host's replaced box.
25+
this.#canvas = document.createElement('canvas');
26+
this.element.append(this.#canvas);
27+
this.#render();
28+
}
29+
30+
behaviorConnectedCallback() {
31+
// Re-render at the host's size once it is laid out in the document.
32+
this.#resizeObserver = new ResizeObserver(() => this.#render());
33+
this.#resizeObserver.observe(this.element);
34+
}
35+
36+
behaviorDisconnectedCallback() {
37+
// Tear down the observer created outside the host's own subtree.
38+
this.#resizeObserver?.disconnect();
39+
this.#resizeObserver = null;
2740
}
2841

29-
#show = () => {
30-
if (!this.#content) {
42+
#render() {
43+
if (!this.#canvas) {
3144
return;
3245
}
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;
46+
const ctx = this.#canvas.getContext('2d');
47+
// Encode this.#value and draw the QR matrix onto the canvas.
48+
}
49+
50+
get value() {
51+
return this.#value;
4852
}
49-
set content(val) {
50-
this.#content = val;
53+
set value(val) {
54+
this.#value = val;
55+
this.#render();
5156
}
5257
}
5358
```
5459
55-
Behaviors are classes with a `behaviorAttachedCallback` method. The behavior is instantiated and passed to `behaviors`:
60+
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`:
5661
5762
```javascript
58-
class CustomButton extends HTMLElement {
63+
class QRCodeButton extends HTMLElement {
64+
static behaviors = [HTMLButtonBehavior, QRCodeBehavior];
65+
66+
#internals;
5967
constructor() {
6068
super();
69+
this.#internals = this.attachInternals();
70+
this.#internals.behaviors.get(HTMLButtonBehavior).type = 'button';
6171

62-
this._tooltipBehavior = new TooltipBehavior();
63-
this._submitBehavior = new HTMLSubmitButtonBehavior();
64-
this._internals = this.attachInternals({
65-
behaviors: [this._tooltipBehavior, this._submitBehavior]
72+
// On activation, copy the encoded URL to the clipboard.
73+
this.addEventListener('click', () => {
74+
const url = this.#internals.behaviors.get(QRCodeBehavior).value;
75+
navigator.clipboard.writeText(url);
6676
});
6777
}
6878

6979
connectedCallback() {
70-
// Access state directly via the stored reference.
71-
this._tooltipBehavior.content = this.getAttribute('tooltip');
80+
// Access the platform-created instance via the behaviors collection.
81+
this.#internals.behaviors.get(QRCodeBehavior).value = 'https://example.com';
7282
}
7383
}
7484
```
7585
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"`).
86+
`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.
87+
88+
## Choosing a category
89+
90+
A developer-defined behavior extends one of the platform's [category base classes](explainer.md#behavior-categories-and-composition) (`ElementBehavior` is the abstract root):
91+
92+
- A behavior that activates (runs an action on click or keyboard) extends `ActivationBehavior`.
93+
- A behavior that renders replaced content extends `EmbeddedContentBehavior`.
94+
95+
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.
96+
97+
A capability that is not an activation or embedded-content identity does not map to a current category.
98+
99+
## Goals
100+
101+
- 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.
102+
- 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.
103+
- Encourage behaviors to be self-contained units with isolated effects.
104+
- Reuse the platform-provided-behaviors [category composition model](explainer.md#behavior-categories-and-composition) for developer-defined behaviors.
105+
106+
## Non-goals
77107
78-
#### ElementBehavior API
108+
- Cooperating between sibling behaviors through a shared `super` chain.
109+
- Granting a behavior capabilities it cannot already reach from script.
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+
## ElementBehavior API
112+
113+
`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:
81114
82115
| Member | Kind | Description |
83116
|--------|------|-------------|
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. |
117+
| `element` | Property (read-only) | The custom element the behavior is attached to. Set by the platform before `behaviorAttachedCallback` runs. |
118+
| `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. |
119+
| `behaviorConnectedCallback()` | 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. |
120+
| `behaviorDisconnectedCallback()` | 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). |
121+
122+
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.
86123
87-
The following example shows how `HTMLButtonBehavior` (`type="button"`) would be implemented in userland:
124+
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.
88125
89126
```javascript
90-
class HTMLButtonBehaviorExample extends ElementBehavior {
91-
#disabled = false;
127+
class HTMLButtonBehaviorExample extends ActivationBehavior {
92128
#internals = null;
93129
#name = '';
94130
#value = '';
@@ -100,21 +136,17 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
100136

101137
behaviorAttachedCallback(internals) {
102138
this.#internals = internals;
139+
// Declare the identity's defaults.
103140
this.#internals.role = 'button';
104141
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);
109142
}
110143

111-
#handleClick = (e) => {
112-
if (this.#disabled) {
113-
e.stopImmediatePropagation();
114-
e.preventDefault();
144+
// The ActivationBehavior base calls this when the host is activated and the
145+
// click was not canceled.
146+
activationBehavior(event) {
147+
if (this.#internals.states.has('disabled')) {
115148
return;
116149
}
117-
118150
if (this.#popoverTargetElement) {
119151
switch (this.#popoverTargetAction) {
120152
case 'show': {
@@ -140,42 +172,20 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
140172
});
141173
this.#commandForElement.dispatchEvent(commandEvent);
142174
}
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-
};
175+
}
165176

166177
// Properties
167-
get disabled() { return this.#disabled; }
178+
get disabled() {
179+
return this.#internals.states.has('disabled');
180+
}
168181
set disabled(val) {
169-
this.#disabled = val;
170-
this.#internals?.setDisabled?.(this.#disabled);
182+
if (val) {
183+
this.#internals.states.add('disabled');
184+
} else {
185+
this.#internals.states.delete('disabled');
186+
}
171187
}
172188

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-
179189
// Popover target API.
180190
get popoverTargetElement() { return this.#popoverTargetElement; }
181191
set popoverTargetElement(val) { this.#popoverTargetElement = val; }
@@ -189,97 +199,34 @@ class HTMLButtonBehaviorExample extends ElementBehavior {
189199
set command(val) { this.#command = val; }
190200
}
191201

192-
// Attaching the behavior to a custom element:
202+
// Use the native behavior when available, otherwise the userland polyfill.
203+
const HTMLButtonBehavior = globalThis.HTMLButtonBehavior ?? HTMLButtonBehaviorExample;
204+
193205
class MyButton extends HTMLElement {
206+
static behaviors = [HTMLButtonBehavior];
207+
208+
#internals;
194209
constructor() {
195210
super();
196-
this._buttonBehavior = new HTMLButtonBehaviorExample();
197-
this._internals = this.attachInternals({ behaviors: [this._buttonBehavior] });
211+
this.#internals = this.attachInternals();
198212
}
199213
}
200214
```
201215
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>`):
209-
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-
}
223-
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-
}
237-
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-
}
216+
- The subclass overrides `behaviorAttachedCallback(internals)` to receive the `ElementInternals` object and set its defaults, such as `internals.role` and focusability.
217+
- 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.
218+
- The platform sets `this.element` before calling `behaviorAttachedCallback`, so the host is available inside the callback and the activation algorithm.
219+
- 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.
250220
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-
};
221+
## Composition and cooperation
260222
261-
// Implementation of focus trapping, backdrop click handling, etc.
223+
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`.
262224
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-
```
225+
## Open questions
279226
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.
227+
- Should the platform offer a channel for one behavior to observe or extend another, or is host-mediated coordination enough?
228+
- Do developer-defined behaviors need any registration or naming convention, or are they purely local to the author's code?
281229
282-
#### Considerations for developer-defined behaviors
230+
## References
283231
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.
232+
- [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)