Skip to content

Commit 7750229

Browse files
authored
Address feedback on platform-provided mixins explainer (2) (#1243)
# Updates - Added `mixinList` to main proposal. - Dropped `Mixin` in `this.internals.mixins.htmlSubmitButton` calls to access state. - Updated example of a design system button. - Added two new subsections in "Future work" to mention user-defined mixins and mixins in native HTML elements. - Minor nits.
1 parent e35a74c commit 7750229

1 file changed

Lines changed: 147 additions & 77 deletions

File tree

PlatformProvidedMixins/explainer.md

Lines changed: 147 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ This creates a gap between what's possible with native elements and custom eleme
3838
This proposal is informed by:
3939

4040
1. Issue discussions spanning multiple years:
41+
4142
- [WICG/webcomponents#814](https://github.com/WICG/webcomponents/issues/814) - Form submission
4243
- [whatwg/html#11061](https://github.com/whatwg/html/issues/11061) - ElementInternals.type proposal
4344
- [whatwg/html#9110](https://github.com/whatwg/html/issues/9110) - Popover invocation from custom elements
@@ -73,14 +74,17 @@ This proposal is informed by:
7374

7475
## Proposed Approach
7576

76-
This proposal introduces a `mixins` option to `attachInternals()` and a read-only `mixins` property on `ElementInternals` which allows custom elements to attach and inspect specific native behaviors. This approach enables composition while keeping the API simple, allowing elements to adopt behaviors during initialization.
77+
This proposal introduces a `mixins` option to `attachInternals()` and a `mixinList` property on `ElementInternals` which allows custom elements to attach, inspect, and dynamically update native behaviors. This approach enables composition while keeping the API simple, supporting both initialization-time configuration and runtime updates.
7778

7879
```javascript
7980
// Attach a mixin during initialization.
8081
this._internals = this.attachInternals({ mixins: [HTMLSubmitButtonMixin] });
8182

82-
// Inspect attached mixins.
83-
console.log(this._internals.mixins);
83+
// Access and modify mixin state.
84+
this._internals.mixins.htmlSubmitButton.formAction = '/custom';
85+
86+
// Dynamically update the mixin list.
87+
this._internals.mixinList = [HTMLResetButtonMixin];
8488
```
8589

8690
### Configuration via attachInternals
@@ -104,7 +108,7 @@ Each platform behavior mixin must provide:
104108
- Event handling: Automatic wiring of platform events (click, keydown, etc.)
105109
- ARIA defaults: Implicit roles and properties for accessibility.
106110

107-
### Accessing Mixin State
111+
### Accessing mixin state
108112

109113
Platform-provided mixins expose useful public properties and methods of their corresponding native elements. Authors can expose these capabilities on their custom element's public API by defining accessors that delegate to the mixin state.
110114

@@ -132,33 +136,76 @@ class CustomSubmitButton extends HTMLElement {
132136
}
133137

134138
get disabled() {
135-
return this._internals.mixins.htmlSubmitButtonMixin.disabled;
139+
return this._internals.mixins.htmlSubmitButton.disabled;
136140
}
137141

138142
set disabled(val) {
139-
this._internals.mixins.htmlSubmitButtonMixin.disabled = val;
143+
this._internals.mixins.htmlSubmitButton.disabled = val;
140144
}
141145

142146
get formAction() {
143-
return this._internals.mixins.htmlSubmitButtonMixin.formAction;
147+
return this._internals.mixins.htmlSubmitButton.formAction;
144148
}
145149

146150
set formAction(val) {
147-
this._internals.mixins.htmlSubmitButtonMixin.formAction = val;
151+
this._internals.mixins.htmlSubmitButton.formAction = val;
148152
}
149153
}
150154
```
151155

152156
This ensures web authors don't have to reimplement the state logic that the mixin is supposed to provide.
153157

154-
### Composition via attachInternals
158+
### Updating mixins dynamically
159+
160+
To support dynamic behavior changes (e.g., when the `type` attribute changes), `ElementInternals` exposes a settable `mixinList` property that allows developers to replace the entire mixin list at once:
161+
162+
```javascript
163+
// Get the current mixin list.
164+
console.log(this._internals.mixinList); // [HTMLButtonMixin]
165+
166+
// Replace with a different mixin.
167+
this._internals.mixinList = [HTMLSubmitButtonMixin];
168+
```
169+
170+
#### Mixin lifecycle
171+
172+
When the `mixinList` is updated, the implementation compares the old and new lists:
173+
174+
| Scenario | Behavior |
175+
|----------|----------|
176+
| Mixin added | The mixin is attached. Its event handlers become active. Default ARIA role is applied unless overridden by `ElementInternals.role`. |
177+
| Mixin removed | The mixin is detached. Its event handlers are deactivated. Mixin-specific state (e.g., `formAction`, `disabled`) is cleared to default values. |
178+
| Mixin retained (in both lists) | The mixin's state is preserved. Its position in the list may change. |
179+
180+
*Note:* Mixin state is preserved when the custom element is disconnected and reconnected to the DOM (e.g., moved within the document). State is only cleared when a mixin is explicitly removed from `mixinList`.
181+
182+
#### Mixin state
183+
184+
When a mixin is removed from the list, its state is cleared. If the same mixin is added back later, it starts with default state:
185+
186+
```javascript
187+
// Set `formAction` on the submit mixin.
188+
this._internals.mixins.htmlSubmitButton.formAction = '/custom-action';
155189

156-
Passing behaviors to `attachInternals()` provides several advantages for web component authors:
190+
// Replace with a different mixin — submit mixin state is cleared.
191+
this._internals.mixinList = [HTMLResetButtonMixin];
157192

158-
- Behaviors are defined once during initialization, avoiding the complexity of managing behavior lifecycle (adding/removing) and state synchronization.
159-
- Authors can define a single class that handles multiple modes (submit, reset, button) by checking attributes before attaching internals, without needing to define separate classes for each behavior.
160-
- A child class extends the parent's functionality and retains access to the `ElementInternals` object and its active mixins, allowing for standard object-oriented extension patterns.
161-
- While this proposal uses an imperative API, the design supports future declarative custom elements. Once a declarative syntax for `ElementInternals` is established, attaching mixins could be modeled as an attribute, decoupling behavior from the JavaScript class definition. Platform-provided mixins could be referenced by string identifiers in markup. The following snippet shows a hypothetical example of declarative usage and how platform-provided mixins could be attached via an attribute.
193+
// Re-add the submit mixin — it starts with default state.
194+
this._internals.mixinList = [HTMLSubmitButtonMixin];
195+
196+
// formAction is now back to default (empty string).
197+
console.log(this._internals.mixins.htmlSubmitButton.formAction); // ''
198+
```
199+
200+
If web authors need to preserve state when swapping mixins, they should save and restore it explicitly.
201+
202+
### Other considerations
203+
204+
This proposal supports common web component patterns:
205+
206+
- Authors can define a single class that handles multiple modes (submit, reset, button) by updating `mixinList` at runtime in response to attribute changes, without needing to define separate classes for each behavior.
207+
- A child class extends the parent's functionality and retains access to the `ElementInternals` object and its active mixins.
208+
- While this proposal uses an imperative API, the design supports future declarative custom elements. Once a declarative syntax for `ElementInternals` is established, attaching mixins could be modeled as an attribute, decoupling behavior from the JavaScript class definition. The following snippet shows a hypothetical example:
162209

163210
```html
164211
<custom-button name="custom-submit-button">
@@ -169,52 +216,83 @@ Passing behaviors to `attachInternals()` provides several advantages for web com
169216

170217
### Use case: Design system button
171218

172-
A design system button that uses the `type` attribute to determine platform behaviors, similar to native elements.
219+
While this proposal only introduces `HTMLSubmitButtonMixin`, the example below references `HTMLResetButtonMixin` and `HTMLButtonMixin` to illustrate how switching would work once additional mixins become available in the future.
173220

174221
```javascript
175222
class DesignSystemButton extends HTMLElement {
176223
static formAssociated = true;
224+
static observedAttributes = ['type', 'disabled', 'formaction'];
177225

178226
constructor() {
179227
super();
228+
this._internals = this.attachInternals();
229+
this.attachShadow({ mode: "open" });
180230
}
181231

182232
connectedCallback() {
183-
if (this._internals) {
184-
return;
185-
}
233+
this._updateMixins();
234+
this._syncAttributes();
235+
this._render();
236+
}
186237

187-
const mixins = [];
238+
attributeChangedCallback(name, oldVal, newVal) {
239+
if (name === 'type') {
240+
this._updateMixins();
241+
}
242+
this._syncAttributes();
243+
this._render();
244+
}
188245

189-
// Check for 'type' attribute to determine behavior.
190-
if (this.getAttribute('type') === 'submit') {
191-
mixins.push(HTMLSubmitButtonMixin);
246+
_updateMixins() {
247+
const type = this.getAttribute('type');
248+
// Set the appropriate mixin based on type.
249+
if (type === 'submit') {
250+
this._internals.mixinList = [HTMLSubmitButtonMixin];
251+
} else if (type === 'reset') {
252+
this._internals.mixinList = [HTMLResetButtonMixin];
192253
} else {
193-
mixins.push(HTMLButtonMixin);
254+
this._internals.mixinList = [HTMLButtonMixin];
255+
}
256+
}
257+
258+
_syncAttributes() {
259+
// Sync HTML attributes to mixin state.
260+
const submitMixin = this._internals.mixins.htmlSubmitButton;
261+
if (submitMixin) {
262+
submitMixin.formAction = this.getAttribute('formaction') || '';
194263
}
264+
// Other attributes like `disabled`, `value`, etc. would be set on
265+
// the proper mixin interface.
266+
}
195267

196-
this._internals = this.attachInternals({ mixins });
197-
this.render();
268+
// Expose element state.
269+
get type() {
270+
return this.getAttribute('type') || 'button';
271+
}
272+
set type(val) {
273+
this.setAttribute('type', val);
198274
}
199275

200-
render() {
201-
// Inspect attached behaviors to determine styling.
202-
const isSubmit = this._internals.mixins.includes(HTMLSubmitButtonMixin);
203-
this.attachShadow({ mode: "open" });
276+
get formAction() {
277+
return this._internals.mixins.htmlSubmitButton?.formAction ?? '';
278+
}
279+
set formAction(val) {
280+
if (this._internals.mixins.htmlSubmitButton) {
281+
this._internals.mixins.htmlSubmitButton.formAction = val;
282+
}
283+
}
284+
285+
// Additional getters/setters for `disabled`, `formMethod`, `formEnctype`,
286+
// `formNoValidate`, `formTarget`, `name`, and `value` would follow the
287+
// same pattern.
288+
289+
_render() {
290+
const isSubmit = this._internals.mixinList.includes(HTMLSubmitButtonMixin);
291+
const isReset = this._internals.mixinList.includes(HTMLResetButtonMixin);
292+
204293
this.shadowRoot.innerHTML = `
205-
<style>
206-
:host {
207-
display: inline-block;
208-
background: 'blue';
209-
color: white;
210-
cursor: pointer;
211-
}
212-
:host(:default) {
213-
box-shadow: 0 0 0 2px gold;
214-
font-weight: bold;
215-
}
216-
</style>
217-
${isSubmit ? '💾' : ''} <slot></slot>
294+
<style>...</style>
295+
${isSubmit ? '💾' : isReset ? '🔄' : ''} <slot></slot>
218296
`;
219297
}
220298
}
@@ -225,10 +303,16 @@ customElements.define('ds-button', DesignSystemButton);
225303
<form action="/save" method="post">
226304
<input name="username" required>
227305
228-
<!-- Set it as a submit button. -->
306+
<!-- Submit button with custom form action -->
307+
<ds-button type="submit" formaction="/draft">Save Draft</ds-button>
308+
309+
<!-- Default submit button (matches :default) -->
229310
<ds-button type="submit">Save</ds-button>
230311
231-
<!-- Regular button by default. -->
312+
<!-- Reset button -->
313+
<ds-button type="reset">Reset</ds-button>
314+
315+
<!-- Regular button -->
232316
<ds-button>Cancel</ds-button>
233317
</form>
234318
```
@@ -240,32 +324,12 @@ The element gains:
240324
- `:default` pseudo-class matching.
241325
- Participation in implicit form submission.
242326
- Ability to inspect its own properties via `this._internals.mixins`.
243-
244-
*Note: In this proposal, the set of mixins is fixed when `attachInternals` is called. If the `type` attribute changes later, the element cannot dynamically swap mixins on the existing instance. To support dynamic type changes, web developers create a fresh instance of the element (which hasn't called `attachInternals` yet) with the new attribute and replace the old instance.*
245-
246-
```javascript
247-
attributeChangedCallback(name, oldVal, newVal) {
248-
// Only recreate if we've already initialized (mixins are locked).
249-
if (name === 'type' && this._internals) {
250-
// Create a fresh instance. It hasn't run connectedCallback yet,
251-
// so it hasn't called attachInternals.
252-
const replacement = document.createElement(this.localName);
253-
254-
// Copy the new type to the new instance.
255-
// The new instance will read this 'type' during its initialization.
256-
replacement.setAttribute('type', newVal);
257-
258-
// Swap the old element with the new one.
259-
// This triggers connectedCallback() on the new instance, allowing it
260-
// to call attachInternals() with the new mixin set.
261-
this.replaceWith(replacement);
262-
}
263-
}
264-
```
327+
- The `type` attribute can be changed at runtime to switch between behaviors.
328+
- Mixin properties like `disabled` and `formAction` are accessible and can be exposed.
265329
266330
## Future Work
267331
268-
While this proposal focuses on form submission, the mixin pattern can be extended to other behaviors in the future:
332+
The mixin pattern can be extended to other behaviors in the future:
269333
270334
- **Generic Buttons**: `HTMLButtonMixin` for non-submitting buttons (popover invocation, commands).
271335
- **Reset Buttons**: `HTMLResetButtonMixin` for form resetting.
@@ -275,13 +339,25 @@ While this proposal focuses on form submission, the mixin pattern can be extende
275339
- **Radio Groups**: `HTMLRadioGroupMixin` for `name`-based mutual exclusion.
276340
- **Tables**: `HTMLTableMixin` for table layout semantics and accessibility.
277341
342+
### User-defined mixins
343+
344+
An extension of this proposal would be to allow web developers to define their own reusable mixins. Considerations for user-defined mixins:
345+
346+
- How would custom mixins be defined and registered? Extend `PlatformMixin` or a dedicated registry?
347+
- Custom mixins would need access to lifecycle hooks (connected, disconnected, attribute changes) similar to custom elements.
348+
- The same conflict resolution strategies that apply to platform mixins would need to work with user-defined mixins.
349+
350+
### Mixins in native HTML elements
351+
352+
This proposal currently focuses on custom elements, but the mixin pattern could potentially be generalized to all HTML elements (e.g., a `<div>` element gains button behavior via mixins). Extending mixins to native HTML elements would also raise questions about correctness and accessibility.
353+
278354
### Conflict Resolution
279355
280356
As the number of available mixins grows, we must address how to handle collisions when multiple mixins attempt to control the same attributes or properties. We should explore several strategies to make composition possible without getting unexpected or conflicting behaviors:
281357
282358
1. **Order of Precedence (Default)**: The order of mixins in the array passed to `attachInternals` determines precedence (e.g., "last one wins"). This is simple to implement but may hide subtle incompatibilities.
283359
2. **Compatibility Allow-lists**: Each mixin could define a short list of "compatible" mixins that can be used in combination. Any combination not explicitly allowed would be rejected by `attachInternals`, preventing invalid states (like being both a button and a form).
284-
3. **Explicit Conflict Resolution**: If conflicts occur, the platform could require the author to explicitly alias or exclude specific properties.
360+
3. **Explicit Conflict Resolution**: If conflicts occur, the platform could require the author to explicitly exclude specific properties.
285361
286362
### Future use case: Inheritance and composition
287363
@@ -320,14 +396,7 @@ class CustomButton extends HTMLElement {
320396
render() {
321397
this.attachShadow({ mode: "open" });
322398
this.shadowRoot.innerHTML = `
323-
<style>
324-
:host {
325-
display: inline-block;
326-
cursor: pointer;
327-
border: 1px solid #767676;
328-
padding: 2px 6px;
329-
}
330-
</style>
399+
<style>...</style>
331400
<slot></slot>
332401
`;
333402
}
@@ -344,7 +413,7 @@ class ResetButton extends CustomButton {
344413
345414
// Inspect the mixins to determine the "winning" behavior.
346415
// This assumes the platform rule is "last mixin wins" for conflicts (order matters).
347-
const mixins = this._internals.mixins;
416+
const mixins = this._internals.mixinList;
348417
const effectiveBehavior = mixins[mixins.length - 1];
349418
350419
if (effectiveBehavior === HTMLResetButtonMixin) {
@@ -532,6 +601,7 @@ Many thanks for valuable feedback and advice from:
532601
- [Kevin Babbitt](https://github.com/kbabbitt)
533602
- [Kurt Catti-Schmidt](https://github.com/KurtCattiSchmidt)
534603
- [Mason Freed](https://github.com/mfreed7)
604+
- [Rob Eisenberg](https://github.com/EisenbergEffect)
535605
536606
Thanks to the following proposals, articles, frameworks, and languages for their work on similar problems that influenced this proposal.
537607

0 commit comments

Comments
 (0)