Skip to content

Commit f6e0af9

Browse files
authored
Split platform-defined behaviors into separate document, address role and focusability feedback (#1278)
- Add developer-defined behaviors as a separate document - Address feedback on setting role and focusability on developer-defined behaviors - FACEs update
1 parent c54c519 commit f6e0af9

File tree

2 files changed

+289
-275
lines changed

2 files changed

+289
-275
lines changed
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
# Developer-defined behaviors
2+
3+
## Authors:
4+
5+
- [Ana Sollano Kim](https://github.com/anaskim)
6+
7+
*Note: This document is a forward-looking exploration and is **not** part of the [Platform-Provided Behaviors](explainer.md) explainer. The ideas described here represent a possible future direction for extending the behaviors model to developer-defined behaviors. The API surface, semantics, and feasibility are all subject to change as the platform-provided behaviors proposal matures.*
8+
9+
## Overview
10+
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:
12+
13+
- Encapsulating common interaction patterns (tooltips, drag-and-drop, keyboard shortcuts) as composable units.
14+
- Polyfilling upcoming platform behaviors before they ship natively.
15+
- Composing developer-defined behaviors with platform-provided ones on the same element.
16+
17+
```javascript
18+
class TooltipBehavior extends ElementBehavior {
19+
#content = '';
20+
#tooltipElement = null;
21+
22+
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+
}
28+
29+
#show = () => {
30+
if (!this.#content) {
31+
return;
32+
}
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;
48+
}
49+
set content(val) {
50+
this.#content = val;
51+
}
52+
}
53+
```
54+
55+
Behaviors are classes with a `behaviorAttachedCallback` method. The behavior is instantiated and passed to `behaviors`:
56+
57+
```javascript
58+
class CustomButton extends HTMLElement {
59+
constructor() {
60+
super();
61+
62+
this._tooltipBehavior = new TooltipBehavior();
63+
this._submitBehavior = new HTMLSubmitButtonBehavior();
64+
this._internals = this.attachInternals({
65+
behaviors: [this._tooltipBehavior, this._submitBehavior]
66+
});
67+
}
68+
69+
connectedCallback() {
70+
// Access state directly via the stored reference.
71+
this._tooltipBehavior.content = this.getAttribute('tooltip');
72+
}
73+
}
74+
```
75+
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"`).
77+
78+
#### ElementBehavior API
79+
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:
81+
82+
| Member | Kind | Description |
83+
|--------|------|-------------|
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. |
86+
87+
The following example shows how `HTMLButtonBehavior` (`type="button"`) would be implemented in userland:
88+
89+
```javascript
90+
class HTMLButtonBehaviorExample extends ElementBehavior {
91+
#disabled = false;
92+
#internals = null;
93+
#name = '';
94+
#value = '';
95+
96+
#popoverTargetElement = null;
97+
#popoverTargetAction = 'toggle';
98+
#commandForElement = null;
99+
#command = '';
100+
101+
behaviorAttachedCallback(internals) {
102+
this.#internals = internals;
103+
this.#internals.role = 'button';
104+
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);
109+
}
110+
111+
#handleClick = (e) => {
112+
if (this.#disabled) {
113+
e.stopImmediatePropagation();
114+
e.preventDefault();
115+
return;
116+
}
117+
118+
if (this.#popoverTargetElement) {
119+
switch (this.#popoverTargetAction) {
120+
case 'show': {
121+
this.#popoverTargetElement.showPopover();
122+
break;
123+
}
124+
case 'hide': {
125+
this.#popoverTargetElement.hidePopover();
126+
break;
127+
}
128+
default: {
129+
this.#popoverTargetElement.togglePopover();
130+
break;
131+
}
132+
}
133+
return;
134+
}
135+
136+
if (this.#commandForElement && this.#command) {
137+
const commandEvent = new CommandEvent('command', {
138+
source: this.element,
139+
command: this.#command,
140+
});
141+
this.#commandForElement.dispatchEvent(commandEvent);
142+
}
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+
};
165+
166+
// Properties
167+
get disabled() { return this.#disabled; }
168+
set disabled(val) {
169+
this.#disabled = val;
170+
this.#internals?.setDisabled?.(this.#disabled);
171+
}
172+
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+
179+
// Popover target API.
180+
get popoverTargetElement() { return this.#popoverTargetElement; }
181+
set popoverTargetElement(val) { this.#popoverTargetElement = val; }
182+
get popoverTargetAction() { return this.#popoverTargetAction; }
183+
set popoverTargetAction(val) { this.#popoverTargetAction = val; }
184+
185+
// Invoker commands API.
186+
get commandForElement() { return this.#commandForElement; }
187+
set commandForElement(val) { this.#commandForElement = val; }
188+
get command() { return this.#command; }
189+
set command(val) { this.#command = val; }
190+
}
191+
192+
// Attaching the behavior to a custom element:
193+
class MyButton extends HTMLElement {
194+
constructor() {
195+
super();
196+
this._buttonBehavior = new HTMLButtonBehaviorExample();
197+
this._internals = this.attachInternals({ behaviors: [this._buttonBehavior] });
198+
}
199+
}
200+
```
201+
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+
}
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+
```
279+
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.
281+
282+
#### Considerations for developer-defined behaviors
283+
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.

0 commit comments

Comments
 (0)