|
| 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