From 8ecbddb9da2af4fdf87f883fd030a4067f1d3eba Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 14 Oct 2025 11:07:55 -0400 Subject: [PATCH 01/77] Move Element to use new class mixins --- src/Element.js | 58 ++++++++++++------------------- src/form-associated.js | 77 ++++++++++++++++++++---------------------- 2 files changed, 58 insertions(+), 77 deletions(-) diff --git a/src/Element.js b/src/Element.js index eda2420..d329fd6 100644 --- a/src/Element.js +++ b/src/Element.js @@ -1,15 +1,15 @@ /** * Base class for all elements */ -import defineProps from "./props/defineProps.js"; -import defineEvents from "./events/defineEvents.js"; -import defineFormAssociated from "./form-associated.js"; -import defineMixin from "./mixins/define-mixin.js"; +import mounted from "./mounted.js"; +import props from "./props/defineProps.js"; +import formAssociated from "./form-associated.js"; +import events from "./events/defineEvents.js"; import { shadowStyles, globalStyles } from "./styles/index.js"; import Hooks from "./mixins/hooks.js"; +import { applyMixins } from "./mixins/apply-mixins.js"; -const instanceInitialized = Symbol("instanceInitialized"); const classInitialized = Symbol("classInitialized"); const Self = class NudeElement extends HTMLElement { @@ -31,13 +31,6 @@ const Self = class NudeElement extends HTMLElement { } connectedCallback () { - if (!this[instanceInitialized]) { - // Stuff that runs once per element - this.constructor.hooks.run("init", this); - - this[instanceInitialized] = true; - } - this.constructor.hooks.run("connected", this); } @@ -45,6 +38,23 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } + static mixins = [ + mounted, + props, + events, + formAssociated, + shadowStyles, + globalStyles, + ]; + + static { + if (this.globalStyle) { + this.globalStyles ??= this.globalStyle; + } + + applyMixins(this); + } + static init () { // Stuff that runs once per class if (this[classInitialized]) { @@ -53,30 +63,6 @@ const Self = class NudeElement extends HTMLElement { this.hooks = new Hooks(this.hooks); - if (this.props) { - defineProps(this); - } - - if (this.events) { - defineEvents(this); - } - - if (this.formAssociated) { - defineFormAssociated(this); - } - - if (this.styles) { - defineMixin(this, shadowStyles); - } - - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; - } - - if (this.globalStyles) { - defineMixin(this, globalStyles); - } - this.hooks.run("setup", this); return (this[classInitialized] = true); diff --git a/src/form-associated.js b/src/form-associated.js index bc38366..4492973 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -1,32 +1,44 @@ -import { resolveValue } from "./util.js"; -import defineMixin from "./mixins/define-mixin.js"; - -export default function ( - Class, - { - like, - role, - valueProp = "value", - changeEvent = "input", - internalsProp = "_internals", - getters = [ - "labels", - "form", - "type", - "name", - "validity", - "validationMessage", - "willValidate", - ], - } = Class.formAssociated, -) { +import { resolveValue } from "./util/resolve-value.js"; +import mounted from "./mounted.js"; + +export default function (Class, { + like, + role, + valueProp = "value", + changeEvent = "input", + internalsProp = "_internals", + getters = ["labels", "form", "type", "name", "validity", "validationMessage", "willValidate"], +} = Class.formAssociated) { + // Stuff that runs once per mixin if (HTMLElement.prototype.attachInternals === undefined) { // Not supported return; } + let ret = class FormAssociatedMixin extends HTMLElement { + static mixins = [mounted]; + + mounted () { + let internals = this[internalsProp] ??= this.attachInternals(); + + if (internals) { + let source = resolveValue(like, [this, this]); + role ??= source?.computedRole; + + if (role) { + internals.ariaRole = role ?? source?.computedRole; + } + + internals.setFormValue(this[valueProp]); + (source || this).addEventListener(changeEvent, () => internals.setFormValue(this[valueProp])); + } + } + + static formAssociated = true; + }; + for (let prop of getters) { - Object.defineProperty(Class.prototype, prop, { + Object.defineProperty(ret.prototype, prop, { get () { return this[internalsProp][prop]; }, @@ -34,22 +46,5 @@ export default function ( }); } - Class.formAssociated = true; - - return defineMixin(Class, function init () { - let internals = (this[internalsProp] ??= this.attachInternals()); - - if (internals) { - let source = resolveValue(like, [this, this]); - role ??= source?.computedRole; - - if (role) { - // XXX Should we set a default role? E.g. "textbox"? - internals.ariaRole = role ?? source?.computedRole; - } - - internals.setFormValue(this[valueProp]); - source.addEventListener(changeEvent, () => internals.setFormValue(this[valueProp])); - } - }); + return ret; } From f14aca3551dca791f4dc4954949c5c6d8c69ff6d Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 14 Oct 2025 18:17:31 +0200 Subject: [PATCH 02/77] Prettier + fix typos + fix JSDocs --- src/Element.js | 9 +-------- src/form-associated.js | 32 ++++++++++++++++++++++---------- src/mixins/apply-mixins.js | 2 +- src/mounted.js | 4 ++-- src/states.js | 9 ++++----- src/util/compose-functions.js | 12 ++++++------ src/util/copy-properties.js | 28 ++++++++++++++++------------ src/util/extend-class.js | 9 +++------ 8 files changed, 55 insertions(+), 50 deletions(-) diff --git a/src/Element.js b/src/Element.js index d329fd6..cd641d2 100644 --- a/src/Element.js +++ b/src/Element.js @@ -38,14 +38,7 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } - static mixins = [ - mounted, - props, - events, - formAssociated, - shadowStyles, - globalStyles, - ]; + static mixins = [mounted, props, events, formAssociated, shadowStyles, globalStyles]; static { if (this.globalStyle) { diff --git a/src/form-associated.js b/src/form-associated.js index 4492973..c041251 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -1,14 +1,25 @@ import { resolveValue } from "./util/resolve-value.js"; import mounted from "./mounted.js"; -export default function (Class, { - like, - role, - valueProp = "value", - changeEvent = "input", - internalsProp = "_internals", - getters = ["labels", "form", "type", "name", "validity", "validationMessage", "willValidate"], -} = Class.formAssociated) { +export default function ( + Class, + { + like, + role, + valueProp = "value", + changeEvent = "input", + internalsProp = "_internals", + getters = [ + "labels", + "form", + "type", + "name", + "validity", + "validationMessage", + "willValidate", + ], + } = Class.formAssociated, +) { // Stuff that runs once per mixin if (HTMLElement.prototype.attachInternals === undefined) { // Not supported @@ -19,7 +30,7 @@ export default function (Class, { static mixins = [mounted]; mounted () { - let internals = this[internalsProp] ??= this.attachInternals(); + let internals = (this[internalsProp] ??= this.attachInternals()); if (internals) { let source = resolveValue(like, [this, this]); @@ -30,7 +41,8 @@ export default function (Class, { } internals.setFormValue(this[valueProp]); - (source || this).addEventListener(changeEvent, () => internals.setFormValue(this[valueProp])); + (source || this).addEventListener(changeEvent, () => + internals.setFormValue(this[valueProp])); } } diff --git a/src/mixins/apply-mixins.js b/src/mixins/apply-mixins.js index 9c24563..34daba5 100644 --- a/src/mixins/apply-mixins.js +++ b/src/mixins/apply-mixins.js @@ -18,7 +18,7 @@ export function applyMixins (Class, mixins = Class.mixins) { } } -export function applyMixin(Class, mixin, config) { +export function applyMixin (Class, mixin, config) { if (!Class.mixinsApplied) { Class.mixinsApplied = []; } diff --git a/src/mounted.js b/src/mounted.js index 1d72a63..8541499 100644 --- a/src/mounted.js +++ b/src/mounted.js @@ -5,7 +5,7 @@ export const hasConnected = Symbol("is mounted"); export const anyMounted = Symbol("any instance mounted"); -export class MounteddMixin extends HTMLElement { +export class MountedMixin extends HTMLElement { connectedCallback () { if (this[hasConnected]) { return; @@ -31,4 +31,4 @@ export class MounteddMixin extends HTMLElement { } } -export default MounteddMixin; +export default MountedMixin; diff --git a/src/states.js b/src/states.js index 4177f57..9b61c1a 100644 --- a/src/states.js +++ b/src/states.js @@ -1,9 +1,7 @@ import { resolveValue } from "./util/resolve-value.js"; import mounted from "./mounted.js"; -export default function (Class, { - internalsProp = "_internals" -} = Class.cssStates) { +export default function (Class, { internalsProp = "_internals" } = Class.cssStates) { // Stuff that runs once per mixin if (HTMLElement.prototype.attachInternals === undefined) { // Not supported @@ -14,7 +12,7 @@ export default function (Class, { static mixins = [mounted]; mounted () { - let internals = this[internalsProp] ??= this.attachInternals(); + let internals = (this[internalsProp] ??= this.attachInternals()); if (internals) { let source = resolveValue(like, [this, this]); @@ -25,7 +23,8 @@ export default function (Class, { } internals.setFormValue(this[valueProp]); - (source || this).addEventListener(changeEvent, () => internals.setFormValue(this[valueProp])); + (source || this).addEventListener(changeEvent, () => + internals.setFormValue(this[valueProp])); } } diff --git a/src/util/compose-functions.js b/src/util/compose-functions.js index 85529d5..3ee1b55 100644 --- a/src/util/compose-functions.js +++ b/src/util/compose-functions.js @@ -1,14 +1,14 @@ import { ReversibleMap } from "./reversible-map.js"; export const composedFunctions = new ReversibleMap(); -export const functions = Symbol("Constitutent functions"); +export const functions = Symbol("Constituent functions"); /** * Compose functions in a way that preserves the originals. * Will only ever produce one new function, even if called repeatedly - * @param {function} fn1 - * @param {function} fn2 - * @returns {function} + * @param {Function} fn1 + * @param {Function} fn2 + * @returns {Function} */ export function composeFunctions (fn1, fn2) { let isComposed = composedFunctions.getKey(fn1); @@ -32,10 +32,10 @@ export function composeFunctions (fn1, fn2) { ret = fn.call(this, ...args) ?? ret; } return ret; - } + }; composedFn[functions] = [fn1, fn2]; - composedFunctions.set(fn1, composedFn) + composedFunctions.set(fn1, composedFn); } else { let prev = composedFn[functions].indexOf(fn2); diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index c220d10..e828ed0 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -1,18 +1,17 @@ import { composeFunctions } from "./compose-functions.js"; /** - * @typedef CopyPropertiesOptions - * @type {object} - * @property recursive {boolean | number} - Whether to try and extend prototypes too. If number, defines max levels. - * @property overwrite {boolean} - Whether to overwrite conflicts that can't be merged - * @property mergeFunctions {boolean} - Whether to try to merge whereever possible + * @typedef {object} CopyPropertiesOptions + * @property {boolean | number} recursive - Whether to try and extend prototypes too. If number, defines max levels. + * @property {boolean} overwrite - Whether to overwrite conflicts that can't be merged + * @property {boolean} mergeFunctions - Whether to try to merge wherever possible */ /** * Copy properties, respecting descriptors - * @param {object} target - * @param {object} source - * @param {CopyPropertiesOptions} options + * @param {Record} target + * @param {Record} source + * @param {CopyPropertiesOptions} [options={}] */ export function copyProperties (target, source, options = {}) { let sourceDescriptors = Object.getOwnPropertyDescriptors(source); @@ -34,7 +33,7 @@ export function copyProperties (target, source, options = {}) { if (isMeaningfulProto(targetProto) && isMeaningfulProto(sourceProto)) { if (typeof options.recursive === "number") { - options = {...options, recursive: options.recursive - 1}; + options = { ...options, recursive: options.recursive - 1 }; } copyProperties(targetProto, sourceProto, options); @@ -56,9 +55,15 @@ function copyProperty (target, source, key, options = {}) { } if (targetDescriptor && options.mergeFunctions !== false) { - if (typeof targetDescriptor.value === "function" && typeof sourceDescriptor.value === "function") { + if ( + typeof targetDescriptor.value === "function" && + typeof sourceDescriptor.value === "function" + ) { // Compatible, compose - targetDescriptor.value = composeFunctions(targetDescriptor.value, sourceDescriptor.value); + targetDescriptor.value = composeFunctions( + targetDescriptor.value, + sourceDescriptor.value, + ); sourceDescriptor = targetDescriptor; } } @@ -66,5 +71,4 @@ function copyProperty (target, source, key, options = {}) { if (!targetDescriptor || options.overwrite || sourceDescriptor === targetDescriptor) { Object.defineProperty(target, key, sourceDescriptor); } - } diff --git a/src/util/extend-class.js b/src/util/extend-class.js index 653bb79..b0c1f01 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -2,9 +2,9 @@ import { copyProperties } from "./copy-properties.js"; /** * Use a class as a mixin on another class - * @param Class {Function} - * @param Mixin {Function} - * @param options {CopyPropertiesOptions} + * @param {Function} Class + * @param {Function} Mixin + * @param {import("./copy-properties.js").CopyPropertiesOptions} [options={}] */ export function extendClass (Class, Mixin, options = {}) { if (options.recursive) { @@ -15,6 +15,3 @@ export function extendClass (Class, Mixin, options = {}) { copyProperties(Class.prototype, Mixin.prototype, options); } } - - - From 6d3a93e5660234f4a6d6a236ae281c8188610de7 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 15 Oct 2025 23:37:52 +0200 Subject: [PATCH 03/77] Fix issues with components initialization (#54) - Apply mixins on initialization of the class itself and its children - `observedAttributes` getter should have context --- src/Element.js | 38 +++++++++++++++++++++++++++++++------- src/props/defineProps.js | 4 +++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Element.js b/src/Element.js index cd641d2..03208bd 100644 --- a/src/Element.js +++ b/src/Element.js @@ -38,14 +38,8 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } - static mixins = [mounted, props, events, formAssociated, shadowStyles, globalStyles]; - static { - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; - } - - applyMixins(this); + this.init(); } static init () { @@ -54,8 +48,38 @@ const Self = class NudeElement extends HTMLElement { return false; } + // Every child class has to have the mounted mixin applied, + // but we don't want to share specific child class mixins with all other classes + this.mixins = [mounted]; + this.hooks = new Hooks(this.hooks); + if (this.props) { + this.mixins.push(props); + } + + if (this.events) { + this.mixins.push(events); + } + + if (this.formAssociated) { + this.mixins.push(formAssociated); + } + + if (this.styles) { + this.mixins.push(shadowStyles); + } + + if (this.globalStyle) { + this.globalStyles ??= this.globalStyle; + } + + if (this.globalStyles) { + this.mixins.push(globalStyles); + } + + applyMixins(this); + this.hooks.run("setup", this); return (this[classInitialized] = true); diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 2077fc9..8040f08 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -27,7 +27,9 @@ export default function defineProps (Class, props = Class[propsSymbol] ?? Class. // FIXME how to combine with existing observedAttributes? if (!Object.hasOwn(Class, "observedAttributes")) { Object.defineProperty(Class, "observedAttributes", { - get: () => this.props.observedAttributes, + get () { + return this.props.observedAttributes; + }, configurable: true, }); } From 024455ff2465942b93c4c944d8fde06c6dbf9775 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 16 Oct 2025 10:34:47 -0400 Subject: [PATCH 04/77] Do not duplicate init logic --- src/Element.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Element.js b/src/Element.js index 03208bd..f6bff2a 100644 --- a/src/Element.js +++ b/src/Element.js @@ -16,9 +16,7 @@ const Self = class NudeElement extends HTMLElement { constructor () { super(); - if (!this.constructor[classInitialized]) { - this.constructor.init(); - } + this.constructor.init(); this.constructor.hooks.run("start", this); From 66d88192832a166d0a9e038939812c1fb8594ced Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 16 Oct 2025 10:36:28 -0400 Subject: [PATCH 05/77] Don't be thrown by inheritance for init logic --- src/Element.js | 6 ++++-- src/mounted.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Element.js b/src/Element.js index f6bff2a..1757753 100644 --- a/src/Element.js +++ b/src/Element.js @@ -42,10 +42,12 @@ const Self = class NudeElement extends HTMLElement { static init () { // Stuff that runs once per class - if (this[classInitialized]) { + if (Object.hasOwn(this, classInitialized)) { return false; } + this[classInitialized] = true; + // Every child class has to have the mounted mixin applied, // but we don't want to share specific child class mixins with all other classes this.mixins = [mounted]; @@ -80,7 +82,7 @@ const Self = class NudeElement extends HTMLElement { this.hooks.run("setup", this); - return (this[classInitialized] = true); + return true; } }; diff --git a/src/mounted.js b/src/mounted.js index 8541499..ed3d314 100644 --- a/src/mounted.js +++ b/src/mounted.js @@ -23,7 +23,7 @@ export class MountedMixin extends HTMLElement { /** Automatically gets called the first time an instance is connected */ static mounted () { - if (this[anyMounted]) { + if (Object.hasOwn(this, anyMounted)) { return; } From ab67045841223474060736d86302512df36eae95 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 16 Oct 2025 11:34:00 -0400 Subject: [PATCH 06/77] DRY-fy mixins --- src/Element.js | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/Element.js b/src/Element.js index 1757753..3a89f60 100644 --- a/src/Element.js +++ b/src/Element.js @@ -10,6 +10,8 @@ import { shadowStyles, globalStyles } from "./styles/index.js"; import Hooks from "./mixins/hooks.js"; import { applyMixins } from "./mixins/apply-mixins.js"; +const mixins = {props, events, formAssociated, styles: shadowStyles, shadowStyles, globalStyles}; + const classInitialized = Symbol("classInitialized"); const Self = class NudeElement extends HTMLElement { @@ -54,28 +56,14 @@ const Self = class NudeElement extends HTMLElement { this.hooks = new Hooks(this.hooks); - if (this.props) { - this.mixins.push(props); - } - - if (this.events) { - this.mixins.push(events); - } - - if (this.formAssociated) { - this.mixins.push(formAssociated); - } - - if (this.styles) { - this.mixins.push(shadowStyles); - } - if (this.globalStyle) { this.globalStyles ??= this.globalStyle; } - if (this.globalStyles) { - this.mixins.push(globalStyles); + for (const [name, mixin] of Object.entries(mixins)) { + if (this[name]) { + this.mixins.push(mixin); + } } applyMixins(this); From 415aa2c64521dcd6b50d7c72e869401c87eb8fed Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Mon, 20 Oct 2025 22:31:01 +0200 Subject: [PATCH 07/77] Add MD file with mixins and hooks --- mixins-and-hooks.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 mixins-and-hooks.md diff --git a/mixins-and-hooks.md b/mixins-and-hooks.md new file mode 100644 index 0000000..b634282 --- /dev/null +++ b/mixins-and-hooks.md @@ -0,0 +1,9 @@ +| Mixin | Hooks uses | Needs `super`? | Imports mixins | +|-------|-------------|-----------------|-----------------| +| mounted | 1) First `connectedCallback` for class; 2) First `connectedCallback` | | | +| shadowStyles | 1) First `connectedCallback` for class; 2) First `connectedCallback` | ✅ (for now, fakes it with `getSupers()`) | `mounted` | +| globalStyles | 1) First `connectedCallback` for class; 2) First `connectedCallback` | ✅ (for now, fakes it with `getSupers()`) | `mounted` | +| formAssociated | First `connectedCallback` | | `mounted` | +| defineProps | First `connectedCallback` | | | +| defineEvents | 1) First `constructor` (once per class); 2) First `connectedCallback` | | | +| defineSlots | First `connectedCallback` | | | From 354ccd2b9c866ee69d5d16f477c942a066429179 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 21 Oct 2025 12:37:16 -0400 Subject: [PATCH 08/77] Update mixins-and-hooks.md --- mixins-and-hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixins-and-hooks.md b/mixins-and-hooks.md index b634282..fa8200e 100644 --- a/mixins-and-hooks.md +++ b/mixins-and-hooks.md @@ -5,5 +5,5 @@ | globalStyles | 1) First `connectedCallback` for class; 2) First `connectedCallback` | ✅ (for now, fakes it with `getSupers()`) | `mounted` | | formAssociated | First `connectedCallback` | | `mounted` | | defineProps | First `connectedCallback` | | | -| defineEvents | 1) First `constructor` (once per class); 2) First `connectedCallback` | | | +| defineEvents | 1) First `constructor` (once per class); 2) First `connectedCallback` | | `defineProps` | | defineSlots | First `connectedCallback` | | | From 5c0190ef4df42f4aca85f6c1a8f78295c4335f6a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 21 Oct 2025 12:39:45 -0400 Subject: [PATCH 09/77] Update mixins-and-hooks.md --- mixins-and-hooks.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mixins-and-hooks.md b/mixins-and-hooks.md index fa8200e..c332f02 100644 --- a/mixins-and-hooks.md +++ b/mixins-and-hooks.md @@ -7,3 +7,6 @@ | defineProps | First `connectedCallback` | | | | defineEvents | 1) First `constructor` (once per class); 2) First `connectedCallback` | | `defineProps` | | defineSlots | First `connectedCallback` | | | +| `has-slotted` | First `connectedCallback` | | | +| named-manual | First `connectedCallback` | | | +| states | First `connectedCallback` | | `mounted` | From 4f5afedf04a4aca742549a658d13927698125940 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 21 Oct 2025 18:54:48 +0200 Subject: [PATCH 10/77] Update mixins-and-hooks.md --- mixins-and-hooks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/mixins-and-hooks.md b/mixins-and-hooks.md index c332f02..6c89b05 100644 --- a/mixins-and-hooks.md +++ b/mixins-and-hooks.md @@ -9,4 +9,5 @@ | defineSlots | First `connectedCallback` | | | | `has-slotted` | First `connectedCallback` | | | | named-manual | First `connectedCallback` | | | +| slots | 1) First `constructor` (once per class); 2) First `connectedCallback` | | | | states | First `connectedCallback` | | `mounted` | From e42cce2417d79de37e27ab6052e1e8fe8f4e9fe5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 21 Oct 2025 14:30:31 -0400 Subject: [PATCH 11/77] Rename `mixinsApplied` to `mixinsActive` --- src/mixins/apply-mixins.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mixins/apply-mixins.js b/src/mixins/apply-mixins.js index 398dca7..e0f6d80 100644 --- a/src/mixins/apply-mixins.js +++ b/src/mixins/apply-mixins.js @@ -19,16 +19,16 @@ export function applyMixins (Class, mixins = Class.mixins) { } export function applyMixin (Class, mixin, config) { - if (!Object.hasOwn(Class, "mixinsApplied")) { - Class.mixinsApplied = []; + if (!Object.hasOwn(Class, "mixinsActive")) { + Class.mixinsActive = []; } - if (Class.mixinsApplied.includes(mixin)) { + if (Class.mixinsActive.includes(mixin)) { // Don't apply the same mixin twice return; } - Class.mixinsApplied.push(mixin); + Class.mixinsActive.push(mixin); if (typeof mixin === "function" && !isMixinClass(mixin)) { mixin = mixin(Class, config); @@ -43,6 +43,7 @@ export function applyMixin (Class, mixin, config) { extendClass(Class, mixin); } else { + // Old mixin style defineMixin(Class, mixin, config); } } From 79bed6046e1bd64df27758fbfd2a092fea0644a0 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 21 Oct 2025 15:56:14 -0400 Subject: [PATCH 12/77] Update compose-functions.js Co-Authored-By: Dmitry Sharabin --- src/util/compose-functions.js | 57 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/util/compose-functions.js b/src/util/compose-functions.js index 3ee1b55..e0ec651 100644 --- a/src/util/compose-functions.js +++ b/src/util/compose-functions.js @@ -1,55 +1,58 @@ import { ReversibleMap } from "./reversible-map.js"; +/** + * 1-1 mapping of original functions and their composed versions + * @type {Map} + */ export const composedFunctions = new ReversibleMap(); export const functions = Symbol("Constituent functions"); /** * Compose functions in a way that preserves the originals. * Will only ever produce one new function, even if called repeatedly - * @param {Function} fn1 - * @param {Function} fn2 + * @param {Function} keyFn + * @param {Function} ...fns * @returns {Function} */ -export function composeFunctions (fn1, fn2) { - let isComposed = composedFunctions.getKey(fn1); - let composedFn; +export function composeFunctions (keyFn, ...fns) { + if (!keyFn) { + keyFn = fns.shift(); + } + + if (fns.length === 0) { + // Nothing to do here + return keyFn; + } - if (isComposed) { + let composedFn = composedFunctions.getKey(keyFn); + + if (composedFn) { // A composed function was provided instead of the constituent - composedFn = isComposed; - if (!composedFn[functions]) debugger; - fn1 = composedFn[functions][0]; + keyFn = composedFn[functions][0]; } else { - composedFn = composedFunctions.get(fn1); + composedFn = composedFunctions.get(keyFn); } if (!composedFn) { + // New composed function composedFn = function (...args) { - let fns = composedFn[functions]; let ret; - for (let fn of fns) { - ret = fn.call(this, ...args) ?? ret; + for (let fn of composedFn[functions]) { + let localRet = fn.call(this, ...args); + if (ret === undefined) { + ret = localRet; + } } return ret; }; - composedFn[functions] = [fn1, fn2]; - composedFunctions.set(fn1, composedFn); + composedFn[functions] = [keyFn]; + composedFunctions.set(keyFn, composedFn); } - else { - let prev = composedFn[functions].indexOf(fn2); - if (prev !== composedFn[functions].length - 1) { - if (prev < composedFn[functions].length - 1) { - // If already there, but not at the end, remove first - composedFn[functions].splice(prev, 1); - } - - composedFn[functions].push(fn2); - } - // else Already at the end, nothing to do here - } + // Add new constituents + composedFn[functions].push(...fns.filter(fn => !composedFn[functions].includes(fn))); return composedFn; } From a7bdcec73d69cc89b709466994fbbd2157061f8c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 21 Oct 2025 16:58:36 -0400 Subject: [PATCH 13/77] `copyProperties()` improvements - Separate prototype copying from superclasses - Simplify logic - Drop recursive: number and just automatically stop at the first shared superclass ping @DmitrySharabin --- src/util/copy-properties.js | 50 +++++++++++++++++++++---------------- src/util/extend-class.js | 8 +----- src/util/get-supers.js | 2 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index e828ed0..fbab60e 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -1,8 +1,10 @@ import { composeFunctions } from "./compose-functions.js"; +import { getSupers } from "./get-supers.js"; /** * @typedef {object} CopyPropertiesOptions - * @property {boolean | number} recursive - Whether to try and extend prototypes too. If number, defines max levels. + * @property {boolean} prototypes - Whether to try and extend .prototype too. + * @property {boolean} recursive - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. * @property {boolean} overwrite - Whether to overwrite conflicts that can't be merged * @property {boolean} mergeFunctions - Whether to try to merge wherever possible */ @@ -14,36 +16,40 @@ import { composeFunctions } from "./compose-functions.js"; * @param {CopyPropertiesOptions} [options={}] */ export function copyProperties (target, source, options = {}) { - let sourceDescriptors = Object.getOwnPropertyDescriptors(source); - let sourceProto = Object.getPrototypeOf(source); + let sources = [source]; - for (let key in sourceDescriptors) { - if (key !== "constructor") { - // TODO figure out whether it meaningfully defines a constructor - copyProperty(target, source, key, options); + if (options.recursive) { + let sourceSupers = getSupers(source).reverse(); + let targetSupers = getSupers(target).reverse(); + + // Find the first shared superclass + let index = sourceSupers.findIndex(sharedSuper => targetSupers.includes(sharedSuper)); + if (index !== -1) { + sources.push(...sourceSupers.slice(index + 1)); } } - if (options.recursive) { - if (target.prototype && source.prototype) { - copyProperties(target, source, options); - } - else { - let targetProto = Object.getPrototypeOf(target); + function copyPropertiesFromSources (sources, target) { + let properties = sources.reduce((acc, source) => { + let properties = Object.getOwnPropertyNames(source); + for (let property of properties) { + acc.add(property); + } + return acc; + }, new Set()); - if (isMeaningfulProto(targetProto) && isMeaningfulProto(sourceProto)) { - if (typeof options.recursive === "number") { - options = { ...options, recursive: options.recursive - 1 }; - } + properties.delete("constructor"); - copyProperties(targetProto, sourceProto, options); - } + for (let key of properties) { + copyProperty(target, source, key, options); } } -} -function isMeaningfulProto (proto) { - return proto !== Object.prototype && proto !== Function.prototype; + copyPropertiesFromSources(sources, target); + + if (options.prototypes) { + copyPropertiesFromSources(sources.map(source => source.prototype).filter(Boolean), target.prototype); + } } function copyProperty (target, source, key, options = {}) { diff --git a/src/util/extend-class.js b/src/util/extend-class.js index b0c1f01..ca7956e 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -7,11 +7,5 @@ import { copyProperties } from "./copy-properties.js"; * @param {import("./copy-properties.js").CopyPropertiesOptions} [options={}] */ export function extendClass (Class, Mixin, options = {}) { - if (options.recursive) { - copyProperties(Class, Mixin, options); - } - else { - copyProperties(Class, Mixin, options); - copyProperties(Class.prototype, Mixin.prototype, options); - } + copyProperties(Class, Mixin, {...options, prototypes: true}); } diff --git a/src/util/get-supers.js b/src/util/get-supers.js index f3e3068..f050a84 100644 --- a/src/util/get-supers.js +++ b/src/util/get-supers.js @@ -1,5 +1,5 @@ /** - * Get the class hierarchy to the given class + * Get the class hierarchy to the given class, from superclass to subclass * @param {Function} Class * @param {Function} [FromClass] Optional class to stop at * @returns {Function[]} From 5c24193ef8f163195ae9f709ffd00c11c1a22a3f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 22 Oct 2025 16:07:10 -0400 Subject: [PATCH 14/77] More experiments --- src/Element2.js | 33 +++++++++++++++++++ src/NudeElement.js | 65 +++++++++++++++++++++++++++++++++++++ src/constructed.js | 10 ++++++ src/mixins/apply.js | 27 +++++++++++++++ src/util/copy-properties.js | 6 ++-- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/Element2.js create mode 100644 src/NudeElement.js create mode 100644 src/constructed.js create mode 100644 src/mixins/apply.js diff --git a/src/Element2.js b/src/Element2.js new file mode 100644 index 0000000..34e0c38 --- /dev/null +++ b/src/Element2.js @@ -0,0 +1,33 @@ +/** + * Base class with all mixins applied + */ + +import NudeElement from "./NudeElement.js"; + +import mounted from "./mounted.js"; +import constructed from "./constructed.js"; +import props from "./props/defineProps.js"; +import formAssociated from "./form-associated.js"; +import events from "./events/defineEvents.js"; +import { shadowStyles, globalStyles } from "./styles/index.js"; + +const mixins = [mounted, constructed, props, events, formAssociated, shadowStyles, globalStyles]; + +const Self = class Element extends NudeElement { + static mixins = mixins; + + static init () { + if (this.initialized) { + return; + } + + // Ensure the class has its own, and is not using the superclass' mixins + if (this !== Self) { + this.mixins = this.mixins.slice(); + } + + return super.init(); + } +}; + +export default Self; diff --git a/src/NudeElement.js b/src/NudeElement.js new file mode 100644 index 0000000..d705865 --- /dev/null +++ b/src/NudeElement.js @@ -0,0 +1,65 @@ +/** + * Base class with no mixins applied + * Apply mixins by subclassing it and setting the mixins static property + * ```js + * class MyElement extends NudeElement { + * static mixins = [MyMixin]; + * } + * ``` + */ +import { applyMixins } from "./mixins/apply.js"; + +export const initialized = Symbol("is initialized"); + +export const NudeElement = (SuperClass = HTMLElement) => class MixinElement extends SuperClass { + constructor () { + super(); + + this.constructor.init(); + this.init(); + } + + get initialized () { + return Object.hasOwn(this, initialized); + } + + init () { + if (this.initialized) { + return false; + } + + return this[initialized] = true; + } + + super (name, ...args) { + return super[name]?.(...args); + } + + // To be overridden by subclasses + mixins = Object.freeze([]); + mixinsActive = Object.freeze([]); + + static applyMixins (mixins = this.mixins) { + if (Object.hasOwn(this, "mixinsActive") || !mixins || mixins.length === 0) { + return; + } + + applyMixins(this, mixins); + } + + static get initialized () { + return Object.hasOwn(this, initialized); + } + + static init () { + if (this.initialized) { + return false; + } + + this.applyMixins(); + + return this[initialized] = true; + } +}; + +export default NudeElement(); diff --git a/src/constructed.js b/src/constructed.js new file mode 100644 index 0000000..8dc89c6 --- /dev/null +++ b/src/constructed.js @@ -0,0 +1,10 @@ +export class ConstructedMixin extends HTMLElement { + init() { + // We use a microtask so that this executes after the subclass constructor has run as well + Promise.resolve().then(() => this.constructed()); + } + + constructed () { + // Ensure the method exists + } +} diff --git a/src/mixins/apply.js b/src/mixins/apply.js new file mode 100644 index 0000000..9587fc0 --- /dev/null +++ b/src/mixins/apply.js @@ -0,0 +1,27 @@ +import { copyProperties } from "../util/copy-properties.js"; + +export function applyMixins (Class = this, mixins = Class.mixins) { + if (Object.hasOwn(Class, "mixinsActive") || !Object.hasOwn(this, "mixins")) { + return; + } + + Class.mixinsActive = []; + let methods = {static: {}, instance: {}}; + + for (let Mixin of mixins) { + if (Mixin.autoApply && !Mixin.autoApply(Class)) { + // Not applicable to this class + continue; + } + + if (Class.mixinsActive.includes(Mixin)) { + // Already applied + continue; + } + + copyProperties(Class, Mixin, {recursive: true, prototypes: true}); + + Class.mixinsActive.push(Mixin); + } +} + diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index fbab60e..9ed32d0 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -3,10 +3,10 @@ import { getSupers } from "./get-supers.js"; /** * @typedef {object} CopyPropertiesOptions - * @property {boolean} prototypes - Whether to try and extend .prototype too. - * @property {boolean} recursive - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. + * @property {boolean} [prototypes=false] - Whether to try and extend .prototype too. + * @property {boolean} [recursive=false] - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. * @property {boolean} overwrite - Whether to overwrite conflicts that can't be merged - * @property {boolean} mergeFunctions - Whether to try to merge wherever possible + * @property {boolean} [mergeFunctions=true] - Whether to try to merge wherever possible */ /** From cf15fbbf79042095218e0969b48acd2ea91b3a7a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 22 Oct 2025 16:15:30 -0400 Subject: [PATCH 15/77] Fix composed functions bug Co-Authored-By: Dmitry Sharabin --- src/util/compose-functions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/compose-functions.js b/src/util/compose-functions.js index e0ec651..de0a460 100644 --- a/src/util/compose-functions.js +++ b/src/util/compose-functions.js @@ -27,8 +27,8 @@ export function composeFunctions (keyFn, ...fns) { let composedFn = composedFunctions.getKey(keyFn); if (composedFn) { - // A composed function was provided instead of the constituent - keyFn = composedFn[functions][0]; + // A composed function was provided instead of the constituent, so we need to swap them + [composedFn, keyFn] = [keyFn, composedFn]; } else { composedFn = composedFunctions.get(keyFn); From 39de4789474ca0746bb40e86f350379c5b456d87 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 29 Oct 2025 11:47:43 -0400 Subject: [PATCH 16/77] Adopt CSS improvements - Make it easier to check whether a style was adopted by a given root - add `adoptCSSRecursive()` and use it in the globalStyles mixin --- README.md | 1 + src/styles/global.js | 14 +------ src/util/adopt-css.js | 90 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 335a070..45612c3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Try it and please report issues and provide feedback! ### No hassle, less control: the `NudeElement` class Defining your element as a subclass of `NudeElement` gives you the nicest, most declarative syntax. +This includes all mixins automatically, though they are only activated when their relevant properties are used. ```js import NudeElement from "nude-element"; diff --git a/src/styles/global.js b/src/styles/global.js index 2ce4f37..9642fec 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -2,7 +2,7 @@ * Mixin for adding light DOM styles */ import { getSupers } from "../util/get-supers.js"; -import { adoptCSS } from "../util/adopt-css.js"; +import { adoptCSSRecursive } from "../util/adopt-css.js"; import { fetchCSS } from "../util/fetch-css.js"; export default { @@ -50,17 +50,7 @@ export default { continue; } - // Recursively adopt style on all shadow roots all the way up to the document - let root = this; - do { - root = root.host ?? root; - root = root.getRootNode(); - - if (!Self.roots.has(root)) { - adoptCSS(css, root); - Self.roots.add(root); - } - } while (root && root.nodeType !== Node.DOCUMENT_NODE); + adoptCSSRecursive(css, this); } }, }; diff --git a/src/util/adopt-css.js b/src/util/adopt-css.js index c45d12e..8339395 100644 --- a/src/util/adopt-css.js +++ b/src/util/adopt-css.js @@ -12,48 +12,102 @@ const adoptedStyleSheets = new WeakMap(); * @returns {CSSStyleSheet} */ export function adoptCSS (style, root = globalThis.document) { + if (getSheet(style, root)) { + // We never want to adopt the same style multiple times + return; + } + + /** @type {CSSStyleSheet | HTMLStyleElement} */ + let styleObj = getSheet(style, root, { create: true }); + + let rootAdoptedStyleSheets = adoptedStyleSheets.get(root); + rootAdoptedStyleSheets.set(style, styleObj); + + if (root.adoptedStyleSheets) { + // Newer browsers + if (Object.isFrozen(root.adoptedStyleSheets)) { + // Slightly older browsers + root.adoptedStyleSheets = [...root.adoptedStyleSheets, styleObj]; + } + else { + root.adoptedStyleSheets.push(styleObj); + } + } + else { + // Older browsers + let styleRoot = root.nodeType === Node.DOCUMENT_NODE ? root.head : root; + styleRoot.appendChild(styleObj); + } + + return styleObj; +} + +/** + * Get a stylesheet object (CSSStyleSheet or HTMLStyleElement) from the adopted stylesheets map + * @param {string | CSSStyleSheet} style + * @param {Document | ShadowRoot} root + * @param {object} [options] + * @param {boolean} [options.create=false] - Whether to create a new style object if it doesn't exist + * @returns {CSSStyleSheet | HTMLStyleElement | null} + */ +function getSheet (style, root, { create = false } = {}) { let rootAdoptedStyleSheets = adoptedStyleSheets.get(root); if (!rootAdoptedStyleSheets) { + if (!create) { + return null; + } + rootAdoptedStyleSheets = new Map(); adoptedStyleSheets.set(root, rootAdoptedStyleSheets); } if (root.adoptedStyleSheets) { + // Newer browsers let sheet = rootAdoptedStyleSheets.get(style); - if (!sheet && typeof style === "string") { - sheet = new CSSStyleSheet(); - sheet.replaceSync(style); - rootAdoptedStyleSheets.set(style, sheet); - style = sheet; - } - - if (!root.adoptedStyleSheets.includes(sheet)) { - // Not already adopted, so we need to adopt it - if (Object.isFrozen(root.adoptedStyleSheets)) { - // Slightly older browsers - root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; + if (!sheet ) { + if (!create) { + return null; } - else { - root.adoptedStyleSheets.push(sheet); + + if (typeof style === "string") { + sheet = new CSSStyleSheet(); + sheet.replaceSync(style); + rootAdoptedStyleSheets.set(style, sheet); + style = sheet; } } - return sheet; + return style; } else { // Older browsers let styleElement = rootAdoptedStyleSheets.get(style); if (!styleElement) { + if (!create) { + return null; + } + styleElement = document.createElement("style"); styleElement.textContent = style; - let styleRoot = root.nodeType === Node.DOCUMENT_NODE ? root.head : root; - styleRoot.appendChild(styleElement); - rootAdoptedStyleSheets.set(style, styleElement); } return styleElement; } } + +/** + * Recursively adopt style on all shadow roots all the way up to the document + * @param {string | CSSStyleSheet} style + * @param {Document | ShadowRoot} root + */ +export function adoptCSSRecursive (style, root = globalThis.document) { + do { + root = root.host ?? root; + root = root.getRootNode(); + + adoptCSS(style, root); + } while (root && root.nodeType !== Node.DOCUMENT_NODE); +} From 4a5cd252cd1673149b1b76718eae64711f116bc1 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 29 Oct 2025 12:45:05 -0400 Subject: [PATCH 17/77] Address @DmitrySharabin's adoptCSS feedback --- src/util/adopt-css.js | 45 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/src/util/adopt-css.js b/src/util/adopt-css.js index 8339395..c53c8b1 100644 --- a/src/util/adopt-css.js +++ b/src/util/adopt-css.js @@ -20,9 +20,6 @@ export function adoptCSS (style, root = globalThis.document) { /** @type {CSSStyleSheet | HTMLStyleElement} */ let styleObj = getSheet(style, root, { create: true }); - let rootAdoptedStyleSheets = adoptedStyleSheets.get(root); - rootAdoptedStyleSheets.set(style, styleObj); - if (root.adoptedStyleSheets) { // Newer browsers if (Object.isFrozen(root.adoptedStyleSheets)) { @@ -62,40 +59,28 @@ function getSheet (style, root, { create = false } = {}) { adoptedStyleSheets.set(root, rootAdoptedStyleSheets); } - if (root.adoptedStyleSheets) { - // Newer browsers - let sheet = rootAdoptedStyleSheets.get(style); + let styleObj = rootAdoptedStyleSheets.get(style); - if (!sheet ) { - if (!create) { - return null; - } + if (!styleObj && !create) { + return null; + } - if (typeof style === "string") { - sheet = new CSSStyleSheet(); - sheet.replaceSync(style); - rootAdoptedStyleSheets.set(style, sheet); - style = sheet; - } + // If we’re here, we have no existing object and create is true + if (root.adoptedStyleSheets) { + // Newer browsers + if (typeof style === "string") { + styleObj = new CSSStyleSheet(); + styleObj.replaceSync(style); } - - return style; } else { // Older browsers - let styleElement = rootAdoptedStyleSheets.get(style); - - if (!styleElement) { - if (!create) { - return null; - } - - styleElement = document.createElement("style"); - styleElement.textContent = style; - } - - return styleElement; + styleObj = document.createElement("style"); + styleObj.textContent = style; } + + rootAdoptedStyleSheets.set(style, styleObj); + return styleObj; } /** From a61f099f0723087e21328ede8f651a9e7cd03c49 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 29 Oct 2025 13:11:57 -0400 Subject: [PATCH 18/77] Address @DmitrySharabin feedback --- src/util/adopt-css.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/adopt-css.js b/src/util/adopt-css.js index c53c8b1..dde3088 100644 --- a/src/util/adopt-css.js +++ b/src/util/adopt-css.js @@ -61,7 +61,11 @@ function getSheet (style, root, { create = false } = {}) { let styleObj = rootAdoptedStyleSheets.get(style); - if (!styleObj && !create) { + if (styleObj) { + return styleObj; + } + + if (!create) { return null; } From b844b0c1186e697dcfc3410ec5a50b4730328f16 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 29 Oct 2025 15:19:18 -0400 Subject: [PATCH 19/77] Try class mixins again - Each mixin specified as *both* a regular subclass factory OR a class that can be applied as a mixin through applyMixins() - Don't use dependencies frivolously - Use symbols extensively to avoid method conflicts and super.foo() boilerplate --- src/Element.js | 72 ++------- src/Element2.js | 33 ----- src/events/README.md | 64 ++++++++ src/events/defineEvents.js | 187 ++++++++++++++---------- src/form-associated.js | 125 ++++++++++------ src/hooks/README.md | 66 +++++++++ src/{mixins => hooks}/hooks.js | 1 + src/hooks/with.js | 30 ++++ src/index.js | 6 +- src/lifecycle.js | 59 ++++++++ src/mixins.js | 12 ++ src/mixins/README.md | 86 ----------- src/mixins/apply-mixins.js | 59 -------- src/mixins/apply.js | 20 ++- src/mixins/define-mixin.js | 36 ----- src/mounted.js | 34 ----- src/{NudeElement.js => nude-element.js} | 37 +++-- src/props/defineProps.js | 106 ++++++++------ src/slots/defineSlots.js | 48 ++---- src/slots/has-slotted.js | 6 +- src/slots/named-manual.js | 8 +- src/slots/slot-controller.js | 109 ++++++++++++++ src/slots/slots.js | 125 +++------------- src/slots/util.js | 55 +++++++ src/states.js | 63 ++++---- src/styles/global.js | 80 ++++++---- src/styles/shadow.js | 69 ++++++--- src/styles/util.js | 4 + src/util.js | 6 +- src/util/compose-functions.js | 58 -------- src/util/copy-properties.js | 4 +- src/util/delegate.js | 19 +++ src/util/extend.js | 49 +++++++ src/util/get-options.js | 11 ++ src/util/get-supers.js | 2 +- src/util/get-symbols.js | 9 ++ src/util/lazy.js | 6 + 37 files changed, 965 insertions(+), 799 deletions(-) delete mode 100644 src/Element2.js create mode 100644 src/events/README.md create mode 100644 src/hooks/README.md rename src/{mixins => hooks}/hooks.js (97%) create mode 100644 src/hooks/with.js create mode 100644 src/lifecycle.js create mode 100644 src/mixins.js delete mode 100644 src/mixins/README.md delete mode 100644 src/mixins/apply-mixins.js delete mode 100644 src/mixins/define-mixin.js delete mode 100644 src/mounted.js rename src/{NudeElement.js => nude-element.js} (51%) create mode 100644 src/slots/slot-controller.js create mode 100644 src/slots/util.js create mode 100644 src/styles/util.js delete mode 100644 src/util/compose-functions.js create mode 100644 src/util/delegate.js create mode 100644 src/util/extend.js create mode 100644 src/util/get-options.js create mode 100644 src/util/get-symbols.js diff --git a/src/Element.js b/src/Element.js index 3a89f60..6b8e25e 100644 --- a/src/Element.js +++ b/src/Element.js @@ -1,76 +1,30 @@ /** - * Base class for all elements + * Base class with all mixins applied */ -import mounted from "./mounted.js"; + +import NudeElement from "./nude-element.js"; + import props from "./props/defineProps.js"; import formAssociated from "./form-associated.js"; import events from "./events/defineEvents.js"; - import { shadowStyles, globalStyles } from "./styles/index.js"; -import Hooks from "./mixins/hooks.js"; -import { applyMixins } from "./mixins/apply-mixins.js"; - -const mixins = {props, events, formAssociated, styles: shadowStyles, shadowStyles, globalStyles}; - -const classInitialized = Symbol("classInitialized"); - -const Self = class NudeElement extends HTMLElement { - constructor () { - super(); - this.constructor.init(); +const mixins = [props, events, formAssociated, shadowStyles, globalStyles]; - this.constructor.hooks.run("start", this); - - if (this.propChangedCallback && this.constructor.props) { - this.addEventListener("propchange", this.propChangedCallback); - } - - // We use a microtask so that this executes after the subclass constructor has run as well - Promise.resolve().then(this.constructor.hooks.run("constructed", this)); - } - - connectedCallback () { - this.constructor.hooks.run("connected", this); - } - - disconnectedCallback () { - this.constructor.hooks.run("disconnected", this); - } - - static { - this.init(); - } +const Self = class Element extends NudeElement { + static mixins = mixins; static init () { - // Stuff that runs once per class - if (Object.hasOwn(this, classInitialized)) { - return false; + if (this.initialized) { + return; } - this[classInitialized] = true; - - // Every child class has to have the mounted mixin applied, - // but we don't want to share specific child class mixins with all other classes - this.mixins = [mounted]; - - this.hooks = new Hooks(this.hooks); - - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; - } - - for (const [name, mixin] of Object.entries(mixins)) { - if (this[name]) { - this.mixins.push(mixin); - } + // Ensure the class has its own, and is not using the superclass' mixins + if (this !== Self && Object.hasOwn(this, "mixins")) { + this.mixins = this.mixins.slice(); } - applyMixins(this); - - this.hooks.run("setup", this); - - return true; + return super.init(); } }; diff --git a/src/Element2.js b/src/Element2.js deleted file mode 100644 index 34e0c38..0000000 --- a/src/Element2.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Base class with all mixins applied - */ - -import NudeElement from "./NudeElement.js"; - -import mounted from "./mounted.js"; -import constructed from "./constructed.js"; -import props from "./props/defineProps.js"; -import formAssociated from "./form-associated.js"; -import events from "./events/defineEvents.js"; -import { shadowStyles, globalStyles } from "./styles/index.js"; - -const mixins = [mounted, constructed, props, events, formAssociated, shadowStyles, globalStyles]; - -const Self = class Element extends NudeElement { - static mixins = mixins; - - static init () { - if (this.initialized) { - return; - } - - // Ensure the class has its own, and is not using the superclass' mixins - if (this !== Self) { - this.mixins = this.mixins.slice(); - } - - return super.init(); - } -}; - -export default Self; diff --git a/src/events/README.md b/src/events/README.md new file mode 100644 index 0000000..fdd45ba --- /dev/null +++ b/src/events/README.md @@ -0,0 +1,64 @@ +# Events mixin + +This mixin handles: +- Defining props and attributes for UI events on a component (just like native HTML elements) +- Automatically managing events that fire when a specific prop changes (`propchange` property) +- Automatically retargeting events from shadow DOM elements to the host (`from` property) + +Eventually we should split this into three mixins. + +## Usage + +Through the `Element` base class: + +```js +import Element from "nude-element"; + +class MyElement extends Element { + static events = { + // ... + } +} +``` + +As traditional mixin: +```js +import { WithEvents } from "nude-element"; + +class MyElement extends WithEvents { + static events = { + // ... + } +} +``` + +Or, with a custom base class (e.g. `LitElement`): +```js +import { WithEventsMixin } from "nude-element"; +import LitElement from "lit"; + +class MyElement extends WithEventsMixin(LitElement) { + static events = { + // ... + } +} +``` + +As composable mixin: +```js +import { WithEvents, applyMixins } from "nude-element"; + +class MyElement extends HTMLElement { + constructor () { + this.init(); + } + + static events = { + // ... + } + + static { + applyMixins(this, WithEvents); + } +} +``` diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 835595b..a84b176 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -1,8 +1,12 @@ -import defineProps from "../props/defineProps.js"; -import PropChangeEvent from "../props/PropChangeEvent.js"; +// To be split into three mixins: A base events mixin, a retargeting mixin, and a propchange event mixin + +import { Mixin as PropsMixin } from "../props/defineProps.js"; +// import PropChangeEvent from "../props/PropChangeEvent.js"; import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import defineMixin from "../mixins/define-mixin.js"; +import getSymbols from "../util/get-symbols.js"; + +const { initialized, eventProps, propEvents, retargetedEvents } = getSymbols; /** * @@ -48,21 +52,71 @@ function retargetEvent (name, from) { }; } -export default function defineEvents (Class, events = Class.events) { - let ret = { - setup: [], - init: [], - }; - let propchange = Object.entries(events) - .filter(([name, options]) => options.propchange) - .map(([eventName, options]) => [eventName, options.propchange]); - if (propchange.length > 0) { - // Shortcut for events that fire when a specific prop changes - propchange = Object.fromEntries(propchange); +export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { + static mixins = [PropsMixin(Super)]; + + init () { + this.constructor.init(); + + // Deal with existing values on the on* props + for (let name in this[eventProps]) { + let value = this[name]; + if (typeof value === "function") { + let eventName = name.slice(2); + this.addEventListener(eventName, value); + } + } + + // Often propchange events have already fired by the time the event handlers are added + for (let eventName in this[propEvents]) { + let propName = this[propEvents][eventName]; + let value = this[propName]; + + if (value !== undefined) { + this.props.firePropChangeEvent(this, eventName, { + name: propName, + prop: this.props.get(propName), + }); + } + } + + // Listen for changes + this.addEventListener("propchange", event => { + if (this[eventProps][event.name]) { + // Implement onEventName attributes/properties + let eventName = event.name.slice(2); + let change = event.detail; + + if (change.oldInternalValue) { + this.removeEventListener(eventName, change.oldInternalValue); + } + + if (change.parsedValue) { + this.addEventListener(eventName, change.parsedValue); + } + } + }); + + for (let fn of this[retargetedEvents]) { + fn.call(this); + } + } + + [retargetedEvents] = []; + + static defineEvents (events = this.events) { + let propchange = Object.entries(events) + .filter(([name, options]) => options.propchange) + .map(([eventName, options]) => [eventName, options.propchange]); - ret.setup.push(function setup () { + if (propchange.length > 0) { + // Shortcut for events that fire when a specific prop changes + propchange = Object.fromEntries(propchange); + this[propEvents] = propchange; + + // This used to be in a setup hook, do we not want it to just run here? for (let eventName in propchange) { let propName = propchange[eventName]; let prop = Class.props.get(propName); @@ -74,79 +128,54 @@ export default function defineEvents (Class, events = Class.events) { throw new TypeError(`No prop named ${propName} in ${Class.name}`); } } - }); - } + } - let eventProps = Object.keys(events) - // Is not a native event (e.g. input) - .filter(name => !("on" + name in Class.prototype)) - .map(name => [ - "on" + name, - { - type: { - is: Function, - arguments: ["event"], - }, - reflect: { - from: true, + let eventProps = Object.keys(events) + // Is not a native event (e.g. input) + .filter(name => !("on" + name in Class.prototype)) + .map(name => [ + "on" + name, + { + type: { + is: Function, + arguments: ["event"], + }, + reflect: { + from: true, + }, }, - }, - ]); - - if (eventProps.length > 0) { - eventProps = Object.fromEntries(eventProps); - defineProps(Class, eventProps); - - ret.init.push(function init () { - // Deal with existing values - for (let name in eventProps) { - let value = this[name]; - if (typeof value === "function") { - let eventName = name.slice(2); - this.addEventListener(eventName, value); - } - } + ]); - // Often propchange events have already fired by the time the event handlers are added - for (let eventName in propchange) { - let propName = propchange[eventName]; - let value = this[propName]; + if (eventProps.length > 0) { + eventProps = this[eventProps] = Object.fromEntries(eventProps); + this.defineProps(eventProps); + } - if (value !== undefined) { - Class.props.firePropChangeEvent(this, eventName, { - name: propName, - prop: Class.props.get(propName), - }); - } + this[retargetedEvents] = []; + + for (let [name, options] of Object.entries(events)) { + if (options.from) { + this[retargetedEvents].push(retargetEvent(name, options.from)); } + } + } - // Listen for changes - this.addEventListener("propchange", event => { - if (eventProps[event.name]) { - // Implement onEventName attributes/properties - let eventName = event.name.slice(2); - let change = event.detail; + static init () { + if (Object.hasOwn(this, initialized)) { + return; + } - if (change.oldInternalValue) { - this.removeEventListener(eventName, change.oldInternalValue); - } + this[initialized] = true; - if (change.parsedValue) { - this.addEventListener(eventName, change.parsedValue); - } - } - }); - }); - } - - for (let [name, options] of Object.entries(events)) { - if (options.from) { - let fn = retargetEvent(name, options.from); - if (fn) { - ret.init.push(fn); - } + // Should this use Object.hasOwn()? + if (this.events) { + this.defineEvents(); } } - return defineMixin(Class, ret); + static appliesTo (Class) { + return "events" in Class; + } } + +export default Mixin(); diff --git a/src/form-associated.js b/src/form-associated.js index c041251..87a2ff4 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -1,62 +1,93 @@ +/** + * Mixin to make an element form-associated + * Handles: + * - Proxying form-related properties from ElementInternals + * - Setting the element's default role + * - Proxying an internal form control for all of the above + */ + import { resolveValue } from "./util/resolve-value.js"; -import mounted from "./mounted.js"; - -export default function ( - Class, - { - like, - role, - valueProp = "value", - changeEvent = "input", - internalsProp = "_internals", - getters = [ - "labels", - "form", - "type", - "name", - "validity", - "validationMessage", - "willValidate", - ], - } = Class.formAssociated, -) { - // Stuff that runs once per mixin - if (HTMLElement.prototype.attachInternals === undefined) { - // Not supported - return; - } +import { delegate } from "./util/delegate.js"; +import { getOptions } from "./util/get-options.js"; +import getSymbols from "./util/get-symbols.js"; - let ret = class FormAssociatedMixin extends HTMLElement { - static mixins = [mounted]; +const defaultOptions = { + like: undefined, + role: undefined, + valueProp: "value", + changeEvent: "input", + internalsProp: "_internals", + properties: [ + "labels", + "form", + "type", + "name", + "validity", + "validationMessage", + "willValidate", + ], +}; - mounted () { - let internals = (this[internalsProp] ??= this.attachInternals()); +const { constructed, initialized, init } = getSymbols; - if (internals) { - let source = resolveValue(like, [this, this]); - role ??= source?.computedRole; +export function appliesTo (Class) { + return "formAssociated" in Class; +} - if (role) { - internals.ariaRole = role ?? source?.computedRole; - } +export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", configProp = "formAssociated" } = {}) => class FormAssociated extends Super { + init () { + this.constructor.init(); + + // Give any subclasses a chance to execute + Promise.resolve().then(() => this[constructed]()); + } - internals.setFormValue(this[valueProp]); - (source || this).addEventListener(changeEvent, () => - internals.setFormValue(this[valueProp])); + [constructed] () { + let { like, role, valueProp, changeEvent } = this.constructor[configProp]; + let internals = (this[internalsProp] ??= this.attachInternals?.()); + + if (internals) { + // Set the element's default role + let source = resolveValue(like, [this, this]); + role ??= source?.computedRole; + + if (role) { + internals.ariaRole = role; } + + // Set current form value and update on change + internals.setFormValue(this[valueProp]); + let eventTarget = source || this; + eventTarget.addEventListener(changeEvent, () => + internals.setFormValue(this[valueProp])); + } + } + + static formAssociated = true; + + static [init] () { + if (this[initialized]) { + return; } - static formAssociated = true; - }; + this[initialized] = true; + + this[configProp] = getOptions(defaultOptions, this[configProp]); - for (let prop of getters) { - Object.defineProperty(ret.prototype, prop, { - get () { - return this[internalsProp][prop]; + delegate(this.prototype, { + source () { + return this[internalsProp]; }, + properties: this[configProp].properties, enumerable: true, }); } - return ret; -} + static appliesTo = function (Class) { + return configProp in Class; + }; +}; + +Mixin.appliesTo = appliesTo; + +export default Mixin(); diff --git a/src/hooks/README.md b/src/hooks/README.md new file mode 100644 index 0000000..58eba4d --- /dev/null +++ b/src/hooks/README.md @@ -0,0 +1,66 @@ +# Hooks mixin + +Hooks provide a more composable alternative to hard-coded lifecycle methods. +This mixin provides a way to define composable hooks that are executed at various points in the element lifecycle. + +## Usage + +Basically two things: +1. Create a `Class.hooks` object. +2. Call `run()` on it at the appropriate times. + +It looks like this: + +```js +import Hooks from "nude-element/hooks/with.js"; + +const Self = class MyElement extends HTMLElement { + constructor () { + super(); + this.constructor.init(); + + // Constructor stuff… + + this.constructor.hooks.run("constructed", this); + } + + connectedCallback () { + if (!this.initialized) { + // Stuff that runs once per element + this.constructor.hooks.run("firstConnected", this); + this.initialized = true; + } + + this.constructor.hooks.run("connected", this); + } + + disconnectedCallback () { + this.constructor.hooks.run("disconnected", this); + } + + static init () { + // Stuff that runs once per class + if (this.initialized) { + return; + } + + this.hooks.run("start", this); + + this.initialized = true; + } +} + +function ElementTrait (Class) { + Class.hooks.add({ + firstConnected () { + console.log("The first instance of", this, "was just connected to the DOM"); + }, + connected () { + console.log(this, "was just connected to the DOM"); + }, + disconnected () { + console.log(this, "was just disconnected from the DOM"); + }, + }) +} +``` diff --git a/src/mixins/hooks.js b/src/hooks/hooks.js similarity index 97% rename from src/mixins/hooks.js rename to src/hooks/hooks.js index b4b83fc..dc82b3b 100644 --- a/src/mixins/hooks.js +++ b/src/hooks/hooks.js @@ -1,3 +1,4 @@ +// To be moved to mixin export default class Hooks { constructor (hooks) { if (hooks instanceof Hooks) { diff --git a/src/hooks/with.js b/src/hooks/with.js new file mode 100644 index 0000000..ba0bd79 --- /dev/null +++ b/src/hooks/with.js @@ -0,0 +1,30 @@ +import Hooks from "./hooks.js"; + +export function appliesTo (Class) { + return "hooks" in Class; +} + +export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { + static hooks = new Hooks(super.hooks || {}); + + constructor () { + super(); + + this.init?.(); + } + + init () { + super.init?.(); + + const Self = this.constructor; + + if (Self.hooks && !(Self.hooks instanceof Hooks)) { + Self.hooks = new Hooks(Self.hooks); + } + } + + static appliesTo = appliesTo; +}; + +Mixin.appliesTo = appliesTo; +export default Mixin(); diff --git a/src/index.js b/src/index.js index 5415fb6..602171f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ -export { default as default } from "./Element.js"; +export { default as default } from "./element.js"; +export { default as NudeElement } from "./nude-element.js"; export { default as defineProps } from "./props/defineProps.js"; export { default as defineEvents } from "./events/defineEvents.js"; export { default as defineSlots } from "./slots/defineSlots.js"; export { default as defineFormAssociated } from "./form-associated.js"; -export { default as defineMixin } from "./mixins/define-mixin.js"; -export { default as Hooks } from "./mixins/hooks.js"; +export { default as Hooks } from "./hooks/hooks.js"; diff --git a/src/lifecycle.js b/src/lifecycle.js new file mode 100644 index 0000000..df16480 --- /dev/null +++ b/src/lifecycle.js @@ -0,0 +1,59 @@ +/** + * Mixin to add additional lifecycle hooks + * Instance hooks: + * - `init`: Called when constructing an instance of the class (used by many mixins) + * - `constructed`: Async called after the element is fully constructed (including after any subclasses) + * Class hooks: + * - `init`: Called when the first instance of the class is created + * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) + */ +import { getSymbols } from "./util/get-symbols.js"; +import { defer } from "./util/defer.js"; + +const { hasConnected, initialized } = getSymbols; + +const instanceHooks = ["firstConnected", "constructed", "init"]; +const staticHooks = ["anyConnected", "init"]; + +export function appliesTo (Class) { + return instanceHooks.some(hook => Class.prototype[hook]) || staticHooks.some(hook => Class[hook]); +} + +export const Mixin = (Super = HTMLElement) => class MountedMixin extends Super { + constructor () { + super(); + + if (!Object.hasOwn(this.constructor, initialized) && this.constructor.init) { + // First instance of this class to be created + this.constructor[initialized] = true; + this.constructor.init(); + } + + this.init?.(); + defer(() => this.constructed?.()); + } + + connectedCallback () { + super.connectedCallback?.(); + + if (!this[hasConnected]) { + // First time this element is connected + if (!Object.hasOwn(this.constructor, hasConnected) && this.constructor.anyConnected) { + // First element of this type to be connected + this.constructor[hasConnected] = true; + this.constructor.anyConnected(); + } + + this[hasConnected] = true; + this.firstConnected?.(); + } + + this[hasConnected] = true; + } + + static appliesTo = appliesTo; +}; + +Mixin.appliesTo = appliesTo; +export default Mixin(); + diff --git a/src/mixins.js b/src/mixins.js new file mode 100644 index 0000000..286149c --- /dev/null +++ b/src/mixins.js @@ -0,0 +1,12 @@ +/** + * All mixins + */ + +import * as Props from "./props/index.js"; +import * as Events from "./events/index.js"; +import * as HasSlots from "./slots/slots.js"; +import * as ShadowStyles from "./styles/shadow.js"; +import * as GlobalStyles from "./styles/global.js"; +import * as FormAssociated from "./form-associated.js"; +import * as States from "./states.js"; +import * as Lifecycle from "./lifecycle.js"; diff --git a/src/mixins/README.md b/src/mixins/README.md deleted file mode 100644 index ba1aac7..0000000 --- a/src/mixins/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# Nude mixins - -This mixin is at the core of all other Nude mixins. -It provides a way to define composable hooks that are executed at various points in the element lifecycle. - -## Usage - -If you are inheriting from `NudeElement`, there is little to do besides specifying what code you need to run: - -```js -defineMixin(MyElement, { - start () { - console.log("The first instance of", this, "was just created"); - }, - init () { - console.log(this, "was just connected for the first time"); - }, -}) -``` - -If you are not inheriting from `NudeElement`, you will need to do the plumbing yourself. -Basically two things: -1. Create a `Class.hooks` object. -2. Call `run()` on it at the appropriate times. - -It looks like this: - -```js -import Hooks from "nude-element/mixins/hooks.js"; - -const Self = class MyElement extends HTMLElement { - constructor () { - super(); - this.constructor.init(); - - // Constructor stuff… - - this.constructor.hooks.run("constructed", this); - } - - connectedCallback () { - if (!this.initialized) { - // Stuff that runs once per element - this.constructor.hooks.run("init", this); - this.initialized = true; - } - - this.constructor.hooks.run("connected", this); - } - - disconnectedCallback () { - this.constructor.hooks.run("disconnected", this); - } - - static init () { - // Stuff that runs once per class - if (this.initialized) { - return; - } - - // Caution: do *not* just use a static class field - // It will be shared between all classes that inherit from this one! - this.hooks = new Hooks(this.hooks); - - this.hooks.run("start", this); - - this.initialized = true; - } -} -``` - - -## Hooks - -| Hook | `this` | Description | -|------|--------|-------------| -| `setup` | Class | Called when the first instance of the class is created. | -| `start` | Instance | Called when the constructor starts, but after `setup` has ran | -| `init` | Instance | Called when the element is first connected to the DOM (once per instance). This is the most common hook used. | -| `constructed` | Instance | Called once per instance | -| `connected` | Instance | Called when the element is connected to the DOM | -| `disconnected` | Instance | Called when the element is disconnected from the DOM | - -Note that these are just the default hooks provided by `NudeElement`. -You can define your own hooks as needed. -All you need to do to define a hook is to add a callback to it. \ No newline at end of file diff --git a/src/mixins/apply-mixins.js b/src/mixins/apply-mixins.js deleted file mode 100644 index e0f6d80..0000000 --- a/src/mixins/apply-mixins.js +++ /dev/null @@ -1,59 +0,0 @@ -import defineMixin from "./define-mixin.js"; -import { extendClass, isClass } from "../util.js"; - -export function applyMixins (Class, mixins = Class.mixins) { - if (!mixins) { - return; - } - - if (Array.isArray(mixins)) { - for (let mixin of mixins) { - applyMixin(Class, mixin); - } - } - else { - for (let [mixin, config] of mixins.entries()) { - applyMixin(Class, mixin, config); - } - } -} - -export function applyMixin (Class, mixin, config) { - if (!Object.hasOwn(Class, "mixinsActive")) { - Class.mixinsActive = []; - } - - if (Class.mixinsActive.includes(mixin)) { - // Don't apply the same mixin twice - return; - } - - Class.mixinsActive.push(mixin); - - if (typeof mixin === "function" && !isMixinClass(mixin)) { - mixin = mixin(Class, config); - } - - if (isMixinClass(mixin)) { - // Apply any mixins of this class - if (mixin.mixins) { - applyMixins(Class, mixin.mixins); - } - - extendClass(Class, mixin); - } - else { - // Old mixin style - defineMixin(Class, mixin, config); - } -} - -function isMixinClass (fn) { - if (!isClass(fn)) { - return false; - } - - let proto = Object.getPrototypeOf(fn); - - return proto && proto.prototype instanceof Element; -} diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 9587fc0..25368b1 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -6,21 +6,27 @@ export function applyMixins (Class = this, mixins = Class.mixins) { } Class.mixinsActive = []; - let methods = {static: {}, instance: {}}; for (let Mixin of mixins) { - if (Mixin.autoApply && !Mixin.autoApply(Class)) { + if (Mixin.appliesTo && !Mixin.appliesTo(Class)) { // Not applicable to this class continue; } - if (Class.mixinsActive.includes(Mixin)) { - // Already applied - continue; - } + applyMixin(Mixin); + } +} + +export function applyMixin (Class, Mixin, force = false) { + let alreadyApplied = Class.mixinsActive.includes(Mixin); + if (alreadyApplied && !force) { + // Already applied + return; + } - copyProperties(Class, Mixin, {recursive: true, prototypes: true}); + copyProperties(Class, Mixin, {recursive: true, prototypes: true}); + if (!alreadyApplied) { Class.mixinsActive.push(Mixin); } } diff --git a/src/mixins/define-mixin.js b/src/mixins/define-mixin.js deleted file mode 100644 index c424667..0000000 --- a/src/mixins/define-mixin.js +++ /dev/null @@ -1,36 +0,0 @@ -import { defineLazyProperty } from "../util/lazy.js"; - -export default function defineMixin (Class, config) { - if (Array.isArray(config)) { - // Multiple mixins - return config.map(f => defineMixin(Class, f)); - } - - config = typeof config === "function" ? { init: config } : config; - let { properties, prepare, ...hooks } = config; - - if (properties) { - for (let name in properties) { - defineLazyProperty(Class.prototype, name, properties[name]); - } - } - - if (prepare) { - prepare.call(Class); - } - - if (Class.hooks) { - // Class already supports hooks - if (Class.hooks.add) { - Class.hooks.add(hooks); - } - else { - // Hooks object not created yet? - for (let name in hooks) { - (Class.hooks[name] ??= []).push(hooks[name]); - } - } - } - - return hooks; -} diff --git a/src/mounted.js b/src/mounted.js deleted file mode 100644 index ed3d314..0000000 --- a/src/mounted.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Mixin to add a functions that run the first time an element connects - * both on a per-element and a per-class basis - */ -export const hasConnected = Symbol("is mounted"); -export const anyMounted = Symbol("any instance mounted"); - -export class MountedMixin extends HTMLElement { - connectedCallback () { - if (this[hasConnected]) { - return; - } - - this.mounted?.(); - - this[hasConnected] = true; - } - - /** Automatically gets called the first time the element is connected */ - mounted() { - this.constructor.mounted(); - } - - /** Automatically gets called the first time an instance is connected */ - static mounted () { - if (Object.hasOwn(this, anyMounted)) { - return; - } - - this[anyMounted] = true; - } -} - -export default MountedMixin; diff --git a/src/NudeElement.js b/src/nude-element.js similarity index 51% rename from src/NudeElement.js rename to src/nude-element.js index d705865..9925d3f 100644 --- a/src/NudeElement.js +++ b/src/nude-element.js @@ -8,29 +8,32 @@ * ``` */ import { applyMixins } from "./mixins/apply.js"; +import { getSymbols } from "./util/get-symbols.js"; -export const initialized = Symbol("is initialized"); +const { initialized } = getSymbols; -export const NudeElement = (SuperClass = HTMLElement) => class MixinElement extends SuperClass { +export { initialized }; + +export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { constructor () { super(); - this.constructor.init(); this.init(); } - get initialized () { - return Object.hasOwn(this, initialized); - } - init () { - if (this.initialized) { - return false; - } + super.init?.(); - return this[initialized] = true; + // We repeat the logic because we want to be able to just call Class.init() before instances are constructed + // But also we need to guard against subclasses defining their own init method and forgetting to call super.init() + if (!Object.hasOwn(this, initialized)) { + this.constructor.init(); + this.constructor[initialized] = true; + } } + // Used to call super methods + // Do we actually need this? super (name, ...args) { return super[name]?.(...args); } @@ -47,19 +50,15 @@ export const NudeElement = (SuperClass = HTMLElement) => class MixinElement exte applyMixins(this, mixins); } - static get initialized () { - return Object.hasOwn(this, initialized); - } - static init () { - if (this.initialized) { + if (Object.hasOwn(this, initialized)) { return false; } - this.applyMixins(); + this[initialized] = true; - return this[initialized] = true; + this.applyMixins(); } }; -export default NudeElement(); +export default Mixin(); diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 8040f08..5aacf15 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -1,44 +1,13 @@ import Props from "./Props.js"; -import defineMixin from "../mixins/define-mixin.js"; +import getSymbols from "../util/get-symbols.js"; +import { defineLazyProperties } from "../util/lazy.js"; +const { initialized, propsDef } = getSymbols; -let propsSymbol = Symbol("propsSymbol"); +export const Mixin = (Super = HTMLElement) => class WithProps extends Super { + init () { + this.constructor.init(); -export default function defineProps (Class, props = Class[propsSymbol] ?? Class.props) { - if (props instanceof Props && props.Class === Class) { - // Already defined - return null; - } - - if (Class.props instanceof Props) { - // Props already defined, add these props to it - Class.props.add(props); - return; - } - - Class[propsSymbol] = Class.props; - props = Class.props = new Props(Class, props); - - let _attributeChangedCallback = Class.prototype.attributeChangedCallback; - Class.prototype.attributeChangedCallback = function (name, oldValue, value) { - this.constructor.props.attributeChanged(this, name, oldValue, value); - _attributeChangedCallback?.call(this, name, oldValue, value); - }; - - // FIXME how to combine with existing observedAttributes? - if (!Object.hasOwn(Class, "observedAttributes")) { - Object.defineProperty(Class, "observedAttributes", { - get () { - return this.props.observedAttributes; - }, - configurable: true, - }); - } - - return defineMixin(Class, { - init () { - this.constructor.props.initializeFor(this); - }, - properties: { + defineLazyProperties(this, { // Internal prop values props () { return {}; @@ -47,6 +16,61 @@ export default function defineProps (Class, props = Class[propsSymbol] ?? Class. ignoredAttributes () { return new Set(); }, - }, - }); -} + }); + + // Should this use Object.hasOwn()? + if (this.propChangedCallback) { + this.addEventListener("propchange", this.propChangedCallback); + } + + this.constructor.props.initializeFor(this); + } + + attributeChangedCallback (name, oldValue, value) { + super.attributeChangedCallback?.(name, oldValue, value); + + this.constructor.props.attributeChanged(this, name, oldValue, value); + } + + static get observedAttributes () { + return [ + ...(super.observedAttributes ?? []), + ...(this.constructor.props.observedAttributes ?? []), + ]; + } + + static init () { + if (this[initialized]) { + return; + } + + this[initialized] = true; + + if (this.props) { + this.defineProps(); + } + } + + static defineProps (props = this.props) { + if (props instanceof Props && props.Class === this) { + // Already defined + return null; + } + + if (this.props instanceof Props) { + // Props already defined, add these props to it + this.props.add(props); + return; + } + + // First time processing props for this class + this[propsDef] = this.props; + props = this.props = new Props(this, props); + } + + static appliesTo (Class) { + return "props" in Class; + } +}; + +export default Mixin(); diff --git a/src/slots/defineSlots.js b/src/slots/defineSlots.js index ef6becf..86c3714 100644 --- a/src/slots/defineSlots.js +++ b/src/slots/defineSlots.js @@ -1,41 +1,9 @@ -import defineMixin from "../mixins/define-mixin.js"; - -function assignSlots () { - let children = this.childNodes; - let slotElements = Object.values(this._slots); - let assignments = new WeakMap(); - - // Assign to slots - for (let child of children) { - let assignedSlot; - - if (child.slot) { - // Explicit slot assignment by name, this takes priority - assignedSlot = this._slots[child.slot]; - } - else if (child.matches) { - // Does child match any slot selector? - assignedSlot = slotElements.find(slot => child.matches(slot.dataset.assign)); - } - - assignedSlot ??= this._slots.default; - let all = assignments.get(assignedSlot) ?? new Set(); - all.add(child); - assignments.set(assignedSlot, all); - } - - for (let slot of slotElements) { - let all = assignments.get(slot) ?? new Set(); - slot.assign(...all); - } -} +import { assignToSlot } from "./util.js"; let mutationObserver; -export default function (Class) { - // Class.prototype.assignSlots = assignSlots; - - return defineMixin(Class, function init () { +export const Mixin = (Super = HTMLElement) => class DefineSlots extends Super { + init () { if (!this.shadowRoot) { return; } @@ -53,10 +21,14 @@ export default function (Class) { // slotchange won’t fire in this case, so we need to do this the old-fashioned way mutationObserver ??= new MutationObserver(mutations => { for (let mutation of mutations) { - assignSlots.call(mutation.target); + let slot = mutation.target; + assignToSlot(slot); + // TODO what to do with affectedSlots? } }); mutationObserver.observe(this, { childList: true }); } - }); -} + } +}; + +export default Mixin(); diff --git a/src/slots/has-slotted.js b/src/slots/has-slotted.js index 67734b5..d455e7e 100644 --- a/src/slots/has-slotted.js +++ b/src/slots/has-slotted.js @@ -11,7 +11,7 @@ function update (slot) { const SUPPORTS_HAS_SLOTTED = globalThis.CSS?.supports("selector(:has-slotted)"); -export default { +export const Mixin = (Super = HTMLElement) => class HasSlotted extends Super { init () { // Get all slots if (SUPPORTS_HAS_SLOTTED || !this.shadowRoot) { @@ -34,5 +34,7 @@ export default { }); slotObserver.observe(this); - }, + } }; + +export default Mixin(); diff --git a/src/slots/named-manual.js b/src/slots/named-manual.js index 768440e..df2c7ed 100644 --- a/src/slots/named-manual.js +++ b/src/slots/named-manual.js @@ -52,8 +52,8 @@ export function slotsChanged (records) { } } -export default function (Class, options = {}) { - return { +export function Mixin (Super = HTMLElement, options = {}) { + return class NamedManual extends Super { init () { if (this.shadowRoot?.slotAssignment !== "manual") { // Nothing to do here @@ -103,6 +103,8 @@ export default function (Class, options = {}) { }); slotObserver.observe(this); } - }, + } }; } + +export default Mixin(); diff --git a/src/slots/slot-controller.js b/src/slots/slot-controller.js new file mode 100644 index 0000000..43065c9 --- /dev/null +++ b/src/slots/slot-controller.js @@ -0,0 +1,109 @@ +/** + * Slots data structure + * Gives element classes a this._slots data structure that allows easy access to named slots + */ + +import SlotObserver from "./slot-observer.js"; + +function removeArrayItem (array, item) { + if (!array || array.length === 0) { + return -1; + } + + let index = array.indexOf(item); + if (index !== -1) { + array.splice(index, 1); + } + + return index; +} + +export default class SlotController { + #host; + #slotObserver; + #all = {}; + + static mutationObserver; + + constructor (host, options = {}) { + this.#host = host; + this.dynamic = options.dynamic; + } + + get host () { + return this.#host; + } + + init () { + let shadowRoot = this.#host.shadowRoot; + + if (!shadowRoot) { + return null; + } + + for (let slot of shadowRoot.querySelectorAll("slot")) { + let name = slot.name || ""; + + this.#all[name] ??= []; + this.#all[name].push(slot); + + // This emulates how slots normally work: if there are duplicate names, the first one wins + // See https://codepen.io/leaverou/pen/KKLzBPJ + this[name] ??= slot; + } + + if (this.dynamic) { + this.observe(); + } + } + + /** Observe slot mutations */ + observe (options) { + this.#slotObserver ??= new SlotObserver(records => { + for (let r of records) { + this[r.type](r.target, r.oldName); + } + }); + + this.#slotObserver.observe(this.#host, options); + } + + /** Stop observing slot mutations */ + unobserve () { + this.#slotObserver?.disconnect(); + } + + /** Slot added */ + added (slot) { + let name = slot.name ?? ""; + this.#all[name] ??= []; + + // Insert, maintaining source order + let index = this.#all[name].findIndex( + s => slot.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_PRECEDING, + ); + this.#all[name].splice(index + 1, 0, slot); + this[name] = this.#all[name][0]; + + if (!this[name]) { + delete this[name]; + } + } + + /** Slot removed */ + removed (slot, name = slot.name ?? "") { + removeArrayItem(this.#all[name], slot); + this[name] = this.#all[name][0]; + + if (!this[name]) { + delete this[name]; + } + } + + /** Slot renamed */ + renamed (slot, oldName) { + // ||= is important here, as slot.name is "" in the default slot + this.remove(slot, oldName); + this.add(slot); + } +} diff --git a/src/slots/slots.js b/src/slots/slots.js index 64d0bb7..90cbca2 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,118 +1,33 @@ -import SlotObserver from "./slot-observer.js"; +import SlotController from "./slot-controller.js"; +import { getSymbols } from "../util/get-symbols.js"; -/** - * Slots data structure - * Gives element classes a this._slots data structure that allows easy access to named slots - */ +const defaultOptions = { + slotsProperty: "_slots", + dynamicSlots: false, +}; -function removeArrayItem (array, item) { - if (!array || array.length === 0) { - return -1; - } - - let index = array.indexOf(item); - if (index !== -1) { - array.splice(index, 1); - } - - return index; -} - -export class Slots { - #host; - #slotObserver; - #all = {}; - - static mutationObserver; - - constructor (host) { - this.#host = host; - } +const { hasConnected } = getSymbols; - init () { - let shadowRoot = this.#host.shadowRoot; +export function Mixin (Super = HTMLElement, options = {}) { + options = { ...defaultOptions, ...options }; + let { slotsProperty, dynamicSlots } = options; - if (!shadowRoot) { - return null; + return class HasSlots extends Super { + init () { + super.init?.(); + this[slotsProperty] = new SlotController(this, {dynamic: dynamicSlots}); } - for (let slot of shadowRoot.querySelectorAll("slot")) { - let name = slot.name || ""; - - this.#all[name] ??= []; - this.#all[name].push(slot); - - // This emulates how slots normally work: if there are duplicate names, the first one wins - // See https://codepen.io/leaverou/pen/KKLzBPJ - this[name] ??= slot; - } - } + connectedCallback () { + super.connectedCallback?.(); - /** Observe slot mutations */ - observe (options) { - this.#slotObserver ??= new SlotObserver(records => { - for (let r of records) { - this[r.type](r.target, r.oldName); + if (this[hasConnected]) { + return; } - }); - this.#slotObserver.observe(this.#host, options); - } + this[hasConnected] = true; - /** Stop observing slot mutations */ - unobserve () { - this.#slotObserver?.disconnect(); - } - - /** Slot added */ - added (slot) { - let name = slot.name ?? ""; - this.#all[name] ??= []; - - // Insert, maintaining source order - let index = this.#all[name].findIndex( - s => slot.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_PRECEDING, - ); - this.#all[name].splice(index + 1, 0, slot); - this[name] = this.#all[name][0]; - - if (!this[name]) { - delete this[name]; - } - } - - /** Slot removed */ - removed (slot, name = slot.name ?? "") { - removeArrayItem(this.#all[name], slot); - this[name] = this.#all[name][0]; - - if (!this[name]) { - delete this[name]; + this[slotsProperty].init(); } } - - /** Slot renamed */ - renamed (slot, oldName) { - // ||= is important here, as slot.name is "" in the default slot - this.remove(slot, oldName); - this.add(slot); - } -} - -export default function (Class, options = {}) { - let { slotsProperty = "_slots", dynamicSlots = false } = options; - - return { - start () { - this[slotsProperty] = new Slots(this); - }, - init () { - let slots = this[slotsProperty]; - slots.init(); - - if (dynamicSlots) { - slots.observe(); - } - }, - }; } diff --git a/src/slots/util.js b/src/slots/util.js new file mode 100644 index 0000000..4f618fa --- /dev/null +++ b/src/slots/util.js @@ -0,0 +1,55 @@ +export function getSlotNodes (slot) { + let childNodes = (slot.getRootNode()).childNodes; + let name = slot.name; + let selector = []; + + if (slot.name !== "default") { + selector.push(`[slot="${name}"]`); + } + + if (slot.dataset.assign) { + // Explicit slot assignment by name takes priority + selector.push(`:is(${slot.dataset.assign}):not([slot])`); + } + + selector = selector.join(", "); + + return childNodes.filter(child => { + if (child.matches) { + return child.matches(selector); + } + if (child.nodeType === Node.TEXT_NODE) { + return slot.name === "default"; + } + }); +} + + +/** + * Assign nodes to slots based on a mix of explicit slot assignment and automatic assignment by CSS selector + */ +export function assignToSlot (slot) { + let nodes = getSlotNodes(slot); + let affectedSlots = new Set(nodes.map(node => node.assignedSlot)); + + let nodesChanged = iterableEquals(slot.assignedNodes(), nodes); + + if (!nodesChanged) { + return; + } + + slot.assign(...nodes); + slot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + + affectedSlots.delete(slot); + + for (let affectedSlot of affectedSlots) { + affectedSlot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + } +} + +function iterableEquals (iterable1, iterable2) { + let set1 = iterable1 instanceof Set ? iterable1 : new Set(iterable1); + let set2 = iterable2 instanceof Set ? iterable2 : new Set(iterable2); + return set1.size === set2.size && Array.from(iterable1).every(item => set2.has(item)); +} diff --git a/src/states.js b/src/states.js index 9b61c1a..c99fead 100644 --- a/src/states.js +++ b/src/states.js @@ -1,44 +1,39 @@ -import { resolveValue } from "./util/resolve-value.js"; -import mounted from "./mounted.js"; - -export default function (Class, { internalsProp = "_internals" } = Class.cssStates) { - // Stuff that runs once per mixin - if (HTMLElement.prototype.attachInternals === undefined) { - // Not supported - return; - } +export function appliesTo (Class) { + return "cssStates" in Class; +} - let ret = class StatesMixin extends HTMLElement { - static mixins = [mounted]; - mounted () { - let internals = (this[internalsProp] ??= this.attachInternals()); +export function Mixin (Super = HTMLElement, {internalsProp = "_internals"} = {}) { + return class StatesMixin extends Super { - if (internals) { - let source = resolveValue(like, [this, this]); - role ??= source?.computedRole; + // TODO do we also need addState() and removeState() or is toggleState() enough? + /** + * Add or remove a CSS custom state on the element. + * @param {string} state - The name of the state to add or remove. + * @param {boolean} [force] - If omitted, the state will be toggled. If true, the state will be added. If false, the state will be removed. + */ + toggleState (state, force) { + let states = this[internalsProp].states; + + if (!states) { + // TODO rewrite to attributes if states not supported? Possibly as a separate mixin + return; + } - if (role) { - internals.ariaRole = role ?? source?.computedRole; - } + force ??= !states.has(state); - internals.setFormValue(this[valueProp]); - (source || this).addEventListener(changeEvent, () => - internals.setFormValue(this[valueProp])); + if (force) { + states.add(state); + } + else { + states.delete(state); } } - static formAssociated = true; - }; - - for (let prop of getters) { - Object.defineProperty(ret.prototype, prop, { - get () { - return this[internalsProp][prop]; - }, - enumerable: true, - }); + static appliesTo = appliesTo; } +}; - return ret; -} +Mixin.appliesTo = appliesTo; + +export default Mixin(); diff --git a/src/styles/global.js b/src/styles/global.js index 9642fec..958ccdd 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -4,42 +4,23 @@ import { getSupers } from "../util/get-supers.js"; import { adoptCSSRecursive } from "../util/adopt-css.js"; import { fetchCSS } from "../util/fetch-css.js"; +import getSymbols from "../util/get-symbols.js"; -export default { - prepare () { - if (!this.globalStyles) { - return; - } - - let supers = getSupers(this, HTMLElement); - let Super; - - for (let Class of supers) { - if ( - Object.hasOwn(Class, "globalStyles") && - !Object.hasOwn(Class, "fetchedGlobalStyles") - ) { - // Initiate fetching when the first element is constructed - let styles = (Class.fetchedGlobalStyles = Array.isArray(Class.globalStyles) - ? Class.globalStyles.slice() - : [Class.globalStyles]); - Class.roots = new WeakSet(); +const { fetchedGlobalStyles, globalStyles, roots, render, initialized } = getSymbols; - for (let i = 0; i < styles.length; i++) { - styles[i] = fetchCSS(styles[i], Class.url); - } - } - } - }, +export function appliesTo (Class) { + return "globalStyles" in Class; +} - async connected () { +export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { + async [render] () { let Self = this.constructor; - if (!Self.fetchedGlobalStyles?.length) { + if (!Self[fetchedGlobalStyles]?.length) { return; } - for (let css of Self.fetchedGlobalStyles) { + for (let css of Self[fetchedGlobalStyles]) { if (css instanceof Promise) { // Why not just await css anyway? // Because this way if this is already fetched, we don’t need to wait for a microtask @@ -52,5 +33,46 @@ export default { adoptCSSRecursive(css, this); } - }, + } + + connectedCallback () { + this[render](); + } + + moveCallback () { + this[render](); + } + + static init () { + super.init?.(); + + if (!this.globalStyles || Object.hasOwn(this, initialized)) { + return; + } + + this[initialized] = true; + + let supers = getSupers(this, HTMLElement); + + for (let Class of supers) { + if ( + Object.hasOwn(Class, globalStyles) && + !Object.hasOwn(Class, fetchedGlobalStyles) + ) { + // Initiate fetching when the first element is constructed + let styles = (Class[fetchedGlobalStyles] = Array.isArray(Class[globalStyles]) + ? Class[globalStyles].slice() + : [Class[globalStyles]]); + Class[roots] ??= new WeakSet(); + + for (let i = 0; i < styles.length; i++) { + styles[i] = fetchCSS(styles[i], Class.url); + } + } + } + } + + static appliesTo = appliesTo; }; + +Mixin.appliesTo = appliesTo; diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 58de46e..478352e 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -1,41 +1,35 @@ /** * Mixin for adding shadow DOM styles */ -import { adoptCSS, fetchCSS, getSupers } from "./util.js"; +import { adoptCSS, fetchCSS, getSupers, getSymbols } from "./util.js"; -export default { - setup () { - if (!this.styles) { - return; - } - - let supers = getSupers(this, HTMLElement); +const { fetchedStyles, styles, initialized, render } = getSymbols; - for (let Class of supers) { - if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, "fetchedStyles")) { - // Initiate fetching when the first element is constructed - let styles = (Class.fetchedStyles = Array.isArray(Class.styles) - ? Class.styles.slice() - : [Class.styles]); +export function appliesTo (Class) { + return "shadowStyles" in Class; +} - for (let i = 0; i < styles.length; i++) { - styles[i] = fetchCSS(styles[i], Class.url); - } - } - } - }, +export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { async init () { if (!this.shadowRoot) { return; } + this.constructor.init(); + this[render](); + } + + [render] () { let Self = this.constructor; + let supers = getSupers(Self, HTMLElement); for (let Class of supers) { - if (Class.fetchedStyles) { - for (let css of Class.fetchedStyles) { + if (Class[fetchedStyles]) { + for (let css of Class[fetchedStyles]) { if (css instanceof Promise) { + // Why not just await css anyway? + // Because this way if this is already fetched, we don’t need to wait for a microtask css = await css; } @@ -43,5 +37,34 @@ export default { } } } - }, + } + + static init () { + if (!this[styles] || Object.hasOwn(this, initialized)) { + return; + } + + this[initialized] = true; + + let supers = getSupers(this, HTMLElement); + + for (let Class of supers) { + if (Object.hasOwn(Class, styles) && !Object.hasOwn(Class, fetchedStyles)) { + // Initiate fetching when the first element is constructed + let styles = (Class[fetchedStyles] = Array.isArray(Class[styles]) + ? Class[styles].slice() + : [Class[styles]]); + + for (let i = 0; i < styles.length; i++) { + styles[i] = fetchCSS(styles[i], Class.url); + } + } + } + } + + static appliesTo = appliesTo; }; + +Mixin.appliesTo = appliesTo; + +export default Mixin(); diff --git a/src/styles/util.js b/src/styles/util.js new file mode 100644 index 0000000..2481841 --- /dev/null +++ b/src/styles/util.js @@ -0,0 +1,4 @@ +export * from "./util/adopt-css.js"; +export * from "./util/fetch-css.js"; +export * from "./util/get-symbols.js"; +export * from "./util/get-supers.js"; diff --git a/src/util.js b/src/util.js index b73ad95..ac3e6a9 100644 --- a/src/util.js +++ b/src/util.js @@ -4,6 +4,10 @@ export * from "./util/is-subclass-of.js"; export * from "./util/lazy.js"; export * from "./util/extend-class.js"; export * from "./util/copy-properties.js"; -export * from "./util/compose-functions.js"; +export * from "./util/extend.js"; export * from "./util/reversible-map.js"; export * from "./util/pick.js"; +export * from "./util/adopt-css.js"; +export * from "./util/fetch-css.js"; +export * from "./util/get-symbols.js"; +export * from "./util/get-supers.js"; diff --git a/src/util/compose-functions.js b/src/util/compose-functions.js deleted file mode 100644 index de0a460..0000000 --- a/src/util/compose-functions.js +++ /dev/null @@ -1,58 +0,0 @@ -import { ReversibleMap } from "./reversible-map.js"; - -/** - * 1-1 mapping of original functions and their composed versions - * @type {Map} - */ -export const composedFunctions = new ReversibleMap(); -export const functions = Symbol("Constituent functions"); - -/** - * Compose functions in a way that preserves the originals. - * Will only ever produce one new function, even if called repeatedly - * @param {Function} keyFn - * @param {Function} ...fns - * @returns {Function} - */ -export function composeFunctions (keyFn, ...fns) { - if (!keyFn) { - keyFn = fns.shift(); - } - - if (fns.length === 0) { - // Nothing to do here - return keyFn; - } - - let composedFn = composedFunctions.getKey(keyFn); - - if (composedFn) { - // A composed function was provided instead of the constituent, so we need to swap them - [composedFn, keyFn] = [keyFn, composedFn]; - } - else { - composedFn = composedFunctions.get(keyFn); - } - - if (!composedFn) { - // New composed function - composedFn = function (...args) { - let ret; - for (let fn of composedFn[functions]) { - let localRet = fn.call(this, ...args); - if (ret === undefined) { - ret = localRet; - } - } - return ret; - }; - - composedFn[functions] = [keyFn]; - composedFunctions.set(keyFn, composedFn); - } - - // Add new constituents - composedFn[functions].push(...fns.filter(fn => !composedFn[functions].includes(fn))); - - return composedFn; -} diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index 9ed32d0..7d1c5ea 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -1,4 +1,4 @@ -import { composeFunctions } from "./compose-functions.js"; +import { extend } from "./extend.js"; import { getSupers } from "./get-supers.js"; /** @@ -66,7 +66,7 @@ function copyProperty (target, source, key, options = {}) { typeof sourceDescriptor.value === "function" ) { // Compatible, compose - targetDescriptor.value = composeFunctions( + targetDescriptor.value = extend( targetDescriptor.value, sourceDescriptor.value, ); diff --git a/src/util/delegate.js b/src/util/delegate.js new file mode 100644 index 0000000..5e0c421 --- /dev/null +++ b/src/util/delegate.js @@ -0,0 +1,19 @@ +export function delegate (target, {source, properties, enumerable = true, writable = false, configurable = true}) { + for (let prop of properties) { + let descriptor = { + get () { + let sourceObj = typeof source === "function" ? source.call(this) : source; + return sourceObj[prop]; + }, + enumerable, + configurable, + }; + if (writable) { + descriptor.set = function (value) { + let sourceObj = typeof source === "function" ? source.call(this) : source; + sourceObj[prop] = value; + }; + } + Object.defineProperty(target, prop, descriptor); + } +} diff --git a/src/util/extend.js b/src/util/extend.js new file mode 100644 index 0000000..d529375 --- /dev/null +++ b/src/util/extend.js @@ -0,0 +1,49 @@ +/** + * Wrap a function with another function that can be extended with side effects at any point. + * Side effects run before the function body, in the order they are added. + * @param {function} body - The function to wrap + * @param {...function} sideEffects + * @returns {function} + */ +export const sideEffects = Symbol("Side effects"); +export const mutable = Symbol("Mutable"); + +export function extend (body, ...sideEffects) { + let mutable = body[sideEffects] ? body : body[mutable]; + + if (!mutable) { + // First time extending body + let name = body.name || ""; + + // Wrap in object so we can dynamically assign a name + // https://dev.to/tmikeschu/dynamically-assigning-a-function-name-in-javascript-2d70 + let wrapper = { + [name] (...args) { + let ret = body.apply(this, args); + + for (let sideEffect of mutable[sideEffects]) { + sideEffect.apply(this, args); + } + + return ret; + }, + }; + + mutable = body[mutable] = wrapper[name]; + mutable.body = body; + mutable[sideEffects] = new Set(); + } + + body = mutable.body; + + for (const sideEffect of sideEffects) { + if (body === mutable.body) { + // The function cannot be a side effect of itself + continue; + } + + mutable[sideEffects].add(sideEffect); + } + + return mutable; +} diff --git a/src/util/get-options.js b/src/util/get-options.js new file mode 100644 index 0000000..d65e856 --- /dev/null +++ b/src/util/get-options.js @@ -0,0 +1,11 @@ +export function getOptions (defaults, ...options) { + let ret = Object.create(defaults ?? {}); + + for (let o of options) { + if (typeof o === "object" && o !== null) { + Object.defineProperties(ret, Object.getOwnPropertyDescriptors(o)); + } + } + + return ret; +} diff --git a/src/util/get-supers.js b/src/util/get-supers.js index f050a84..764ed3f 100644 --- a/src/util/get-supers.js +++ b/src/util/get-supers.js @@ -7,7 +7,7 @@ export function getSupers (Class, FromClass) { let classes = []; - while (Class && Class !== FromClass) { + while (Class && Class !== FromClass && Class !== Function.prototype) { classes.unshift(Class); Class = Object.getPrototypeOf(Class); } diff --git a/src/util/get-symbols.js b/src/util/get-symbols.js new file mode 100644 index 0000000..50b9ec8 --- /dev/null +++ b/src/util/get-symbols.js @@ -0,0 +1,9 @@ +export default new Proxy({}, { + get (target, prop) { + if (typeof prop === "string") { + return Symbol(prop); + } + + return target[prop]; + }, +}); diff --git a/src/util/lazy.js b/src/util/lazy.js index 52a1340..1f8686e 100644 --- a/src/util/lazy.js +++ b/src/util/lazy.js @@ -32,3 +32,9 @@ export function defineLazyProperty (object, name, options) { configurable: true, }); } + +export function defineLazyProperties (object, properties) { + for (let name in properties) { + defineLazyProperty(object, name, properties[name]); + } +} From e1184aa8142e0f20d86564d79196bbd58142cdf2 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 29 Oct 2025 22:18:41 +0100 Subject: [PATCH 20/77] Add missed `async` --- src/styles/shadow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 478352e..b1108e5 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -19,7 +19,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { this[render](); } - [render] () { + async [render] () { let Self = this.constructor; let supers = getSupers(Self, HTMLElement); From 32af2e3435557da0b62e5f9a4bd226da913c8267 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 29 Oct 2025 22:59:05 +0100 Subject: [PATCH 21/77] Some fixes Fix imports and exports, function calls, and some name collisions. --- src/mixins/apply.js | 4 ++-- src/nude-element.js | 2 +- src/styles/global.js | 2 ++ src/styles/util.js | 8 ++++---- src/util/extend.js | 20 ++++++++++---------- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 25368b1..e7b168e 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,7 +1,7 @@ import { copyProperties } from "../util/copy-properties.js"; export function applyMixins (Class = this, mixins = Class.mixins) { - if (Object.hasOwn(Class, "mixinsActive") || !Object.hasOwn(this, "mixins")) { + if (Object.hasOwn(Class, "mixinsActive") || this && !Object.hasOwn(this, "mixins")) { return; } @@ -13,7 +13,7 @@ export function applyMixins (Class = this, mixins = Class.mixins) { continue; } - applyMixin(Mixin); + applyMixin(Class, Mixin); } } diff --git a/src/nude-element.js b/src/nude-element.js index 9925d3f..16608cd 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -8,7 +8,7 @@ * ``` */ import { applyMixins } from "./mixins/apply.js"; -import { getSymbols } from "./util/get-symbols.js"; +import getSymbols from "./util/get-symbols.js"; const { initialized } = getSymbols; diff --git a/src/styles/global.js b/src/styles/global.js index 958ccdd..82bfb4a 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -76,3 +76,5 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { }; Mixin.appliesTo = appliesTo; + +export default Mixin(); diff --git a/src/styles/util.js b/src/styles/util.js index 2481841..5f9650e 100644 --- a/src/styles/util.js +++ b/src/styles/util.js @@ -1,4 +1,4 @@ -export * from "./util/adopt-css.js"; -export * from "./util/fetch-css.js"; -export * from "./util/get-symbols.js"; -export * from "./util/get-supers.js"; +export * from "../util/adopt-css.js"; +export * from "../util/fetch-css.js"; +export * as getSymbols from "../util/get-symbols.js"; +export * from "../util/get-supers.js"; diff --git a/src/util/extend.js b/src/util/extend.js index d529375..19bf549 100644 --- a/src/util/extend.js +++ b/src/util/extend.js @@ -9,9 +9,9 @@ export const sideEffects = Symbol("Side effects"); export const mutable = Symbol("Mutable"); export function extend (body, ...sideEffects) { - let mutable = body[sideEffects] ? body : body[mutable]; + let Mutable = body[sideEffects] ? body : body[mutable]; - if (!mutable) { + if (!Mutable) { // First time extending body let name = body.name || ""; @@ -21,7 +21,7 @@ export function extend (body, ...sideEffects) { [name] (...args) { let ret = body.apply(this, args); - for (let sideEffect of mutable[sideEffects]) { + for (let sideEffect of Mutable[sideEffects]) { sideEffect.apply(this, args); } @@ -29,21 +29,21 @@ export function extend (body, ...sideEffects) { }, }; - mutable = body[mutable] = wrapper[name]; - mutable.body = body; - mutable[sideEffects] = new Set(); + Mutable = body[mutable] = wrapper[name]; + Mutable.body = body; + Mutable[sideEffects] = new Set(); } - body = mutable.body; + body = Mutable.body; for (const sideEffect of sideEffects) { - if (body === mutable.body) { + if (body === Mutable.body) { // The function cannot be a side effect of itself continue; } - mutable[sideEffects].add(sideEffect); + Mutable[sideEffects].add(sideEffect); } - return mutable; + return Mutable; } From aa50d798c518457116aa39ddccd3db2a9934a035 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 29 Oct 2025 23:27:11 +0100 Subject: [PATCH 22/77] Simplify imports Align with `shadow.js` --- src/styles/global.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/styles/global.js b/src/styles/global.js index 82bfb4a..29e2ed4 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -1,10 +1,7 @@ /** * Mixin for adding light DOM styles */ -import { getSupers } from "../util/get-supers.js"; -import { adoptCSSRecursive } from "../util/adopt-css.js"; -import { fetchCSS } from "../util/fetch-css.js"; -import getSymbols from "../util/get-symbols.js"; +import { adoptCSSRecursive, fetchCSS, getSupers, getSymbols } from "./util.js"; const { fetchedGlobalStyles, globalStyles, roots, render, initialized } = getSymbols; From 4b5c59941bd738a906d5466cde1e003481ffe765 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 29 Oct 2025 23:32:28 +0100 Subject: [PATCH 23/77] Address feedback --- src/styles/util.js | 2 +- src/util/extend.js | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/styles/util.js b/src/styles/util.js index 5f9650e..f5a23e9 100644 --- a/src/styles/util.js +++ b/src/styles/util.js @@ -1,4 +1,4 @@ export * from "../util/adopt-css.js"; export * from "../util/fetch-css.js"; -export * as getSymbols from "../util/get-symbols.js"; +export { default as getSymbols } from "../util/get-symbols.js"; export * from "../util/get-supers.js"; diff --git a/src/util/extend.js b/src/util/extend.js index 19bf549..372fdf2 100644 --- a/src/util/extend.js +++ b/src/util/extend.js @@ -9,9 +9,9 @@ export const sideEffects = Symbol("Side effects"); export const mutable = Symbol("Mutable"); export function extend (body, ...sideEffects) { - let Mutable = body[sideEffects] ? body : body[mutable]; + let mutableFn = body[sideEffects] ? body : body[mutable]; - if (!Mutable) { + if (!mutableFn) { // First time extending body let name = body.name || ""; @@ -21,7 +21,7 @@ export function extend (body, ...sideEffects) { [name] (...args) { let ret = body.apply(this, args); - for (let sideEffect of Mutable[sideEffects]) { + for (let sideEffect of mutableFn[sideEffects]) { sideEffect.apply(this, args); } @@ -29,21 +29,21 @@ export function extend (body, ...sideEffects) { }, }; - Mutable = body[mutable] = wrapper[name]; - Mutable.body = body; - Mutable[sideEffects] = new Set(); + mutableFn = body[mutable] = wrapper[name]; + mutableFn.body = body; + mutableFn[sideEffects] = new Set(); } - body = Mutable.body; + body = mutableFn.body; for (const sideEffect of sideEffects) { - if (body === Mutable.body) { + if (body === mutableFn.body) { // The function cannot be a side effect of itself continue; } - Mutable[sideEffects].add(sideEffect); + mutableFn[sideEffects].add(sideEffect); } - return Mutable; + return mutableFn; } From 76a62cb86b19d49fd2d51758a4e304b13a6f1d3e Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 29 Oct 2025 23:40:56 +0100 Subject: [PATCH 24/77] Apply another fix --- src/mixins/apply.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index e7b168e..0223f3e 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,7 +1,7 @@ import { copyProperties } from "../util/copy-properties.js"; export function applyMixins (Class = this, mixins = Class.mixins) { - if (Object.hasOwn(Class, "mixinsActive") || this && !Object.hasOwn(this, "mixins")) { + if (Object.hasOwn(Class, "mixinsActive") || !mixins?.length) { return; } From 2a8a2025d8785c4e14ceb80d53348db050430898 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:35:49 -0400 Subject: [PATCH 25/77] Remove leftover `defer` --- src/lifecycle.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lifecycle.js b/src/lifecycle.js index df16480..b6b294c 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -8,7 +8,6 @@ * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ import { getSymbols } from "./util/get-symbols.js"; -import { defer } from "./util/defer.js"; const { hasConnected, initialized } = getSymbols; @@ -30,7 +29,7 @@ export const Mixin = (Super = HTMLElement) => class MountedMixin extends Super { } this.init?.(); - defer(() => this.constructed?.()); + Promise.resolve().then(() => this.constructed?.()); } connectedCallback () { From 257624092659c25eaa90049645b36731e516a7e5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:37:03 -0400 Subject: [PATCH 26/77] Fix bug Co-Authored-By: Dmitry Sharabin --- src/util/extend.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/extend.js b/src/util/extend.js index 372fdf2..4f69d11 100644 --- a/src/util/extend.js +++ b/src/util/extend.js @@ -37,7 +37,7 @@ export function extend (body, ...sideEffects) { body = mutableFn.body; for (const sideEffect of sideEffects) { - if (body === mutableFn.body) { + if (body === sideEffect) { // The function cannot be a side effect of itself continue; } From 119cbe3c2756e3f933cff2923b125a6df7b22ce7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:38:50 -0400 Subject: [PATCH 27/77] Remove pointless `async` Co-Authored-By: Dmitry Sharabin --- src/styles/shadow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/shadow.js b/src/styles/shadow.js index b1108e5..465cc98 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -10,7 +10,7 @@ export function appliesTo (Class) { } export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { - async init () { + init () { if (!this.shadowRoot) { return; } From 47eeb55ee6b15e4e76bf0a91f2c19526c257b583 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:39:04 -0400 Subject: [PATCH 28/77] Convert static `init` to use symbol --- src/styles/shadow.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 465cc98..46f4a7b 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -3,7 +3,7 @@ */ import { adoptCSS, fetchCSS, getSupers, getSymbols } from "./util.js"; -const { fetchedStyles, styles, initialized, render } = getSymbols; +const { fetchedStyles, styles, initialized, render, init } = getSymbols; export function appliesTo (Class) { return "shadowStyles" in Class; @@ -39,7 +39,9 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { } } - static init () { + static [init] () { + super[init]?.(); + if (!this[styles] || Object.hasOwn(this, initialized)) { return; } From b0b082419b71c1c1c933f546eee116e8e3a47c1a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:49:24 -0400 Subject: [PATCH 29/77] Move common mixins to separate file --- README.md | 22 +++++++++++++--------- src/Element.js | 16 +++++----------- src/common-mixins.js | 13 +++++++++++++ src/mixins.js | 12 ------------ 4 files changed, 31 insertions(+), 32 deletions(-) create mode 100644 src/common-mixins.js delete mode 100644 src/mixins.js diff --git a/README.md b/README.md index 45612c3..5af6747 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ for creating reactive web components that behave just like native HTML elements. -Elements can extend `NudeElement` to get the nicest, most declarative syntax, -or import individual mixins as helper functions and use them with any `HTMLElement` subclass. +Elements can extend `Element` to get the nicest, most declarative syntax, +or import individual mixins and use them with any `HTMLElement` subclass. -**Note:** This is a work in progress, developed in the open. -Try it and please report issues and provide feedback! +> [!NOTE] +> This is a work in progress, developed in the open. +> Try it and please report issues and provide feedback! ## Features @@ -24,19 +25,20 @@ Try it and please report issues and provide feedback! ## Usage -### No hassle, less control: the `NudeElement` class +### No hassle, less control: the `Element` class -Defining your element as a subclass of `NudeElement` gives you the nicest, most declarative syntax. -This includes all mixins automatically, though they are only activated when their relevant properties are used. +Defining your element as a subclass of `Element` gives you the nicest, most declarative syntax. +This includes all commonly used mixins automatically, though they are only activated when their relevant properties are used in your element subclass. ```js -import NudeElement from "nude-element"; +import Element from "nude-element"; -class MySlider extends NudeElement { +class MySlider extends Element { constructor () { // ... } + // Automatically activates the props mixin static props = { min: { type: Number, @@ -68,6 +70,7 @@ class MySlider extends NudeElement { }, }; + // Automatically activates the events mixin static events = { // Propagate event from shadow DOM element change: { @@ -82,6 +85,7 @@ class MySlider extends NudeElement { }, }; + // Automatically activates the formAssociated mixin static formAssociated = { like: el => el._el.slider, role: "slider", diff --git a/src/Element.js b/src/Element.js index 6b8e25e..5c52bfa 100644 --- a/src/Element.js +++ b/src/Element.js @@ -2,16 +2,10 @@ * Base class with all mixins applied */ -import NudeElement from "./nude-element.js"; +import { Mixin as NudeElementMixin } from "./nude-element.js"; +import commonMixins from "./common-mixins.js"; -import props from "./props/defineProps.js"; -import formAssociated from "./form-associated.js"; -import events from "./events/defineEvents.js"; -import { shadowStyles, globalStyles } from "./styles/index.js"; - -const mixins = [props, events, formAssociated, shadowStyles, globalStyles]; - -const Self = class Element extends NudeElement { +export const Mixin = (Super = HTMLElement, mixins = commonMixins) => class Element extends NudeElementMixin(Super) { static mixins = mixins; static init () { @@ -20,7 +14,7 @@ const Self = class Element extends NudeElement { } // Ensure the class has its own, and is not using the superclass' mixins - if (this !== Self && Object.hasOwn(this, "mixins")) { + if (this !== Element && Object.hasOwn(this, "mixins")) { this.mixins = this.mixins.slice(); } @@ -28,4 +22,4 @@ const Self = class Element extends NudeElement { } }; -export default Self; +export default Mixin(); diff --git a/src/common-mixins.js b/src/common-mixins.js new file mode 100644 index 0000000..a07a45b --- /dev/null +++ b/src/common-mixins.js @@ -0,0 +1,13 @@ +/** + * All mixins + */ + +import Props from "./props/defineProps.js"; +import FormAssociated from "./form-associated.js"; +import Events from "./events/defineEvents.js"; +import ShadowStyles from "./styles/shadow.js"; +import GlobalStyles from "./styles/global.js"; + +export { Props, FormAssociated, Events, ShadowStyles, GlobalStyles }; + +export default [Props, FormAssociated, Events, ShadowStyles, GlobalStyles]; diff --git a/src/mixins.js b/src/mixins.js deleted file mode 100644 index 286149c..0000000 --- a/src/mixins.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * All mixins - */ - -import * as Props from "./props/index.js"; -import * as Events from "./events/index.js"; -import * as HasSlots from "./slots/slots.js"; -import * as ShadowStyles from "./styles/shadow.js"; -import * as GlobalStyles from "./styles/global.js"; -import * as FormAssociated from "./form-associated.js"; -import * as States from "./states.js"; -import * as Lifecycle from "./lifecycle.js"; From 522ed39753d382103ef0ec904efe602b702092b6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:56:17 -0400 Subject: [PATCH 30/77] Add constructors so that mixins as subclass factories work properly --- README.md | 36 ++++++++++++++++++++++++++++++++++++ src/constructed.js | 10 ---------- src/events/defineEvents.js | 8 ++++++-- src/form-associated.js | 5 +++++ src/lifecycle.js | 2 +- src/props/defineProps.js | 5 +++++ src/slots/defineSlots.js | 5 +++++ src/slots/has-slotted.js | 5 +++++ src/slots/named-manual.js | 5 +++++ src/slots/slots.js | 5 +++++ src/styles/global.js | 5 +++++ src/styles/shadow.js | 5 +++++ 12 files changed, 83 insertions(+), 13 deletions(-) delete mode 100644 src/constructed.js diff --git a/README.md b/README.md index 5af6747..0c83d4e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,42 @@ class MySlider extends Element { } ``` +### A little hassle, a little more control: The `NudeElement` class + +`Element` inherits from `NudeElement`, which is nearly identical with one exception: +Instead of including all commonly used mixins automatically, +it includes no mixins at all. +To add mixins, you extend it and add a `mixins` static property. + +This can be useful when you’re trying to keep bundle size to a minimum, since even if mixins are only activated when your subclass uses them, +they will won't be tree-shaken away, since bundlers don’t understand how this works. + +```js +import { NudeElement, Props, Events, FormAssociated } from "nude-element"; + +class MySlider extends NudeElement { + static mixins = [Props, Events, FormAssociated]; + + // ... +} +``` + +### More hassle, more control: Subclass factories + +To reduce bundle size even further, you can import individual mixins as subclass factories and apply them to your element subclass yourself. + + +```js + +import { Props, Events, FormAssociated } from "nude-element"; + +class MySlider extends HTMLElement { + static mixins = [Props, Events, FormAssociated]; + + // ... +} +``` + ### More hassle, more control: Composable mixins If Nude Element taking over your parent class seems too intrusive, diff --git a/src/constructed.js b/src/constructed.js deleted file mode 100644 index 8dc89c6..0000000 --- a/src/constructed.js +++ /dev/null @@ -1,10 +0,0 @@ -export class ConstructedMixin extends HTMLElement { - init() { - // We use a microtask so that this executes after the subclass constructor has run as well - Promise.resolve().then(() => this.constructed()); - } - - constructed () { - // Ensure the method exists - } -} diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index a84b176..2898fc6 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -52,11 +52,15 @@ function retargetEvent (name, from) { }; } - - export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { + // FIXME these won't apply if we're not using NudeElement somewhere in the inheritance chain static mixins = [PropsMixin(Super)]; + constructor () { + super(); + this.init(); + } + init () { this.constructor.init(); diff --git a/src/form-associated.js b/src/form-associated.js index 87a2ff4..8120e69 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -35,6 +35,11 @@ export function appliesTo (Class) { } export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", configProp = "formAssociated" } = {}) => class FormAssociated extends Super { + constructor () { + super(); + this.init(); + } + init () { this.constructor.init(); diff --git a/src/lifecycle.js b/src/lifecycle.js index b6b294c..8a46a79 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -18,7 +18,7 @@ export function appliesTo (Class) { return instanceHooks.some(hook => Class.prototype[hook]) || staticHooks.some(hook => Class[hook]); } -export const Mixin = (Super = HTMLElement) => class MountedMixin extends Super { +export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super { constructor () { super(); diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 5aacf15..5fe3f91 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -4,6 +4,11 @@ import { defineLazyProperties } from "../util/lazy.js"; const { initialized, propsDef } = getSymbols; export const Mixin = (Super = HTMLElement) => class WithProps extends Super { + constructor () { + super(); + this.init(); + } + init () { this.constructor.init(); diff --git a/src/slots/defineSlots.js b/src/slots/defineSlots.js index 86c3714..8b21009 100644 --- a/src/slots/defineSlots.js +++ b/src/slots/defineSlots.js @@ -3,6 +3,11 @@ import { assignToSlot } from "./util.js"; let mutationObserver; export const Mixin = (Super = HTMLElement) => class DefineSlots extends Super { + constructor () { + super(); + this.init(); + } + init () { if (!this.shadowRoot) { return; diff --git a/src/slots/has-slotted.js b/src/slots/has-slotted.js index d455e7e..46703a7 100644 --- a/src/slots/has-slotted.js +++ b/src/slots/has-slotted.js @@ -12,6 +12,11 @@ function update (slot) { const SUPPORTS_HAS_SLOTTED = globalThis.CSS?.supports("selector(:has-slotted)"); export const Mixin = (Super = HTMLElement) => class HasSlotted extends Super { + constructor () { + super(); + this.init(); + } + init () { // Get all slots if (SUPPORTS_HAS_SLOTTED || !this.shadowRoot) { diff --git a/src/slots/named-manual.js b/src/slots/named-manual.js index df2c7ed..1a6d119 100644 --- a/src/slots/named-manual.js +++ b/src/slots/named-manual.js @@ -54,6 +54,11 @@ export function slotsChanged (records) { export function Mixin (Super = HTMLElement, options = {}) { return class NamedManual extends Super { + constructor () { + super(); + this.init(); + } + init () { if (this.shadowRoot?.slotAssignment !== "manual") { // Nothing to do here diff --git a/src/slots/slots.js b/src/slots/slots.js index 90cbca2..58163ef 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -13,6 +13,11 @@ export function Mixin (Super = HTMLElement, options = {}) { let { slotsProperty, dynamicSlots } = options; return class HasSlots extends Super { + constructor () { + super(); + this.init(); + } + init () { super.init?.(); this[slotsProperty] = new SlotController(this, {dynamic: dynamicSlots}); diff --git a/src/styles/global.js b/src/styles/global.js index 29e2ed4..34429aa 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -10,6 +10,11 @@ export function appliesTo (Class) { } export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { + constructor () { + super(); + this.init(); + } + async [render] () { let Self = this.constructor; diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 46f4a7b..929710e 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -10,6 +10,11 @@ export function appliesTo (Class) { } export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { + constructor () { + super(); + this.init(); + } + init () { if (!this.shadowRoot) { return; From fc1ab7568cd152dd4f478a3c837b3065894293d6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:58:43 -0400 Subject: [PATCH 31/77] Address @DmitrySharabin's feedback --- src/events/defineEvents.js | 6 +++--- src/lifecycle.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 2898fc6..70798db 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -123,20 +123,20 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { // This used to be in a setup hook, do we not want it to just run here? for (let eventName in propchange) { let propName = propchange[eventName]; - let prop = Class.props.get(propName); + let prop = this.props.get(propName); if (prop) { (prop.eventNames ??= []).push(eventName); } else { - throw new TypeError(`No prop named ${propName} in ${Class.name}`); + throw new TypeError(`No prop named ${propName} in ${this.name}`); } } } let eventProps = Object.keys(events) // Is not a native event (e.g. input) - .filter(name => !("on" + name in Class.prototype)) + .filter(name => !("on" + name in this.prototype)) .map(name => [ "on" + name, { diff --git a/src/lifecycle.js b/src/lifecycle.js index 8a46a79..c7e04e2 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -7,7 +7,7 @@ * - `init`: Called when the first instance of the class is created * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ -import { getSymbols } from "./util/get-symbols.js"; +import getSymbols from "./util/get-symbols.js"; const { hasConnected, initialized } = getSymbols; From 18193c065df02100d803579fc71e1213c2b18c34 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 10:58:55 -0400 Subject: [PATCH 32/77] Attempt to rename `Element.js` to `element.js` --- src/{Element.js => element.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{Element.js => element.js} (100%) diff --git a/src/Element.js b/src/element.js similarity index 100% rename from src/Element.js rename to src/element.js From 09325a445b629c60ba54ad66716e3b651391d95f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 11:00:41 -0400 Subject: [PATCH 33/77] Address @DmitrySharabin's feedback --- src/util/copy-properties.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index 7d1c5ea..32a1810 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -41,7 +41,9 @@ export function copyProperties (target, source, options = {}) { properties.delete("constructor"); for (let key of properties) { - copyProperty(target, source, key, options); + for (let source of sources) { + copyProperty(target, source, key, options); + } } } From 51534c7b48863948db200a761798181144ecaf4a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 11:08:00 -0400 Subject: [PATCH 34/77] Update README.md --- README.md | 146 ++++++++++++++++-------------------------------------- 1 file changed, 42 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 0c83d4e..27c0db8 100644 --- a/README.md +++ b/README.md @@ -115,142 +115,80 @@ class MySlider extends NudeElement { } ``` -### More hassle, more control: Subclass factories - -To reduce bundle size even further, you can import individual mixins as subclass factories and apply them to your element subclass yourself. +### With custom base class: Subclass factories +If you need to use a custom base class (e.g. `LitElement`), rather than `HTMLElement`, all Nude mixins are also available as subclass factories, +include `Element` and `NudeElement`: ```js +import { ElementMixin } from "nude-element"; +import { LitElement } from "lit"; -import { Props, Events, FormAssociated } from "nude-element"; - -class MySlider extends HTMLElement { - static mixins = [Props, Events, FormAssociated]; - +class MySlider extends ElementMixin(LitElement) { // ... } ``` -### More hassle, more control: Composable mixins +Individual mixins are also available as subclass factories: -If Nude Element taking over your parent class seems too intrusive, -you can implement the same API via one-off composable helper functions aka mixins, -at the cost of handling some of the plumbing yourself. +```js +import { Props, Events, FormAssociated } from "nude-element/mixins"; +import { LitElement } from "lit"; -Each mixin modifies the base class in a certain way (e.g. adds properties & methods) and returns an init function, -to be called once for each element, -either at the end of its constructor or when it’s first connected. -This is what the example above would look like: +class MySlider extends Props(Events(FormAssociated(LitElement))) { + // ... +} +``` + +Some mixins even have a second argument for parameters that you can customize. +For example, by default they assume your `ElementInternals` instance (if you have one) is stored in a `_internals` property, +but you can change that to whatever you want by passing a second argument to the mixin: ```js -import { - defineProps, - defineEvents, - defineFormAssociated, -} from "nude-element"; +import { FormAssociated } from "nude-element/mixins"; -class MySlider extends HTMLElement { +const internals = Symbol("internals"); +class MySlider extends FormAssociated(LitElement, { internalsProp: internals }) { constructor () { - // ... + super(); - eventHooks.init.call(this); - formAssociatedHooks.init.call(this); - propHooks.init.call(this); + this[internals] = this.attachInternals?.(); } } - -let propHooks = defineProps(MySlider, { - min: { - type: Number, - default: 0, - }, - max: { - type: Number, - default: 1, - }, - step: { - type: Number, - default () { - return Math.abs((this.max - this.min) / 100); - }, - }, - defaultValue: { - type: Number, - default () { - return (this.min + this.max) / 2; - }, - reflect: { - from: "value", - }, - }, - value: { - type: Number, - defaultProp: "defaultValue", - reflect: false, - }, -}); - -let eventHooks = defineEvents(MySlider, { - // Propagate event from shadow DOM element - change: { - from () { - return this._el.slider; - } - }, - - // Fire event when specific prop changes (even programmatically) - valuechange: { - propchange: "value", - }, -}); - -let formAssociatedHooks = defineFormAssociated(MySlider, { - like: el => el._el.slider, - role: "slider", - valueProp: "value", - changeEvent: "valuechange", -}); ``` -Each mixin will also look for a static `hooks` property on the element class and add its lifecycle hooks to it if it exists, -so you can make things a little easier by defining such a property: +### More hassle, more control: Composable mixins + +If Nude Element taking over your parent class seems too intrusive, +you can pull in mixins and apply them to any base class you want in-place without affecting the inheritance chain, +at the cost of handling some of the plumbing yourself. -```js -import { defineProps } from "nude-element"; -import Hooks from "nude-element/hooks"; +There are three parts: +1. Apply `applyMixins` to your class to apply the mixins to it +2. Make sure to call `this.init()` in your constructor, since `applyMixins` cannot modify your constructor, so that’s the only way to run initialization logic -class MyElement extends HTMLElement { - // Caution: if MyElement has subclasses, this will be shared among them! - static hooks = new Hooks(); +```js +import { Props, Events, FormAssociated, applyMixins } from "nude-element"; +class MySlider extends HTMLElement { constructor () { super(); - // Then you can call the hooks at the appropriate times: - this.constructor.hooks.run("init", this); + // Your own init logic here... + + this.init?.(); } -} -defineProps(MyElement, { - // Props… -}); + static { + applyMixins(this, [Props, Events, FormAssociated]); + } +} ``` -Read more: +Individual mixin docs: - [Using Props](src/props/) - [Events](src/events/) - [Form-associated elements](src/formAssociated/) - [Mixins](src/mixins/) -## Known Hooks - -These hooks are automatically managed when you use the `NudeElement` class. -If you choose to import mixins directly, you need to manage when to call them yourself. - -- `prepare`: Runs once per class, as soon as a mixin is added -- `setup`: Runs once per class, before any element is fully constructed -- `start`: Runs on element constructor -- `constructed`: Runs after element constructor (async) -- `init`: Runs when element is connected for the first time -- `disconnected`: Runs when element is disconnected From 8ebf334c2ab96a8586c9e8290342d4e76a63c5d6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 11:09:31 -0400 Subject: [PATCH 35/77] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 27c0db8..de00554 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,5 @@ class MySlider extends HTMLElement { Individual mixin docs: - [Using Props](src/props/) - [Events](src/events/) -- [Form-associated elements](src/formAssociated/) -- [Mixins](src/mixins/) - +- [Slots](src/slots/) From 9d64d498023c208e818a606bfeaf76b1ad42d573 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 11:16:00 -0400 Subject: [PATCH 36/77] Update slots.js --- src/slots/slots.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slots/slots.js b/src/slots/slots.js index 58163ef..7e958b9 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,5 +1,5 @@ import SlotController from "./slot-controller.js"; -import { getSymbols } from "../util/get-symbols.js"; +import getSymbols from "../util/get-symbols.js"; const defaultOptions = { slotsProperty: "_slots", From 159832a8e7dd6e3b30f3751d6acffb8332e0513d Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 30 Oct 2025 11:18:05 -0400 Subject: [PATCH 37/77] Address feedback from @DmitrySharabin --- src/events/defineEvents.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 70798db..34d560d 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -103,8 +103,10 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { } }); - for (let fn of this[retargetedEvents]) { - fn.call(this); + if (this[retargetedEvents]) { + for (let fn of this[retargetedEvents]) { + fn.call(this); + } } } From 036b361d2014da736e18e7eb4ceee0247d2c93f7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 31 Oct 2025 14:25:01 -0400 Subject: [PATCH 38/77] Rename `get-supers.js` to `super.js` --- src/styles/util.js | 2 +- src/util.js | 2 +- src/util/copy-properties.js | 2 +- src/util/{get-supers.js => super.js} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/util/{get-supers.js => super.js} (100%) diff --git a/src/styles/util.js b/src/styles/util.js index f5a23e9..fbece50 100644 --- a/src/styles/util.js +++ b/src/styles/util.js @@ -1,4 +1,4 @@ export * from "../util/adopt-css.js"; export * from "../util/fetch-css.js"; export { default as getSymbols } from "../util/get-symbols.js"; -export * from "../util/get-supers.js"; +export * from "../util/super.js"; diff --git a/src/util.js b/src/util.js index ac3e6a9..5a3b93f 100644 --- a/src/util.js +++ b/src/util.js @@ -10,4 +10,4 @@ export * from "./util/pick.js"; export * from "./util/adopt-css.js"; export * from "./util/fetch-css.js"; export * from "./util/get-symbols.js"; -export * from "./util/get-supers.js"; +export * from "./util/super.js"; diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index 32a1810..b4a91ec 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -1,5 +1,5 @@ import { extend } from "./extend.js"; -import { getSupers } from "./get-supers.js"; +import { getSupers } from "./super.js"; /** * @typedef {object} CopyPropertiesOptions diff --git a/src/util/get-supers.js b/src/util/super.js similarity index 100% rename from src/util/get-supers.js rename to src/util/super.js From cc0666669c17d2ebcf055bf3c03b9fd3d3a1cdae Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 31 Oct 2025 14:45:59 -0400 Subject: [PATCH 39/77] Rename `getSupers()` to `getSuperclasses()` and make it not return the class itself Also add `getSuperclass(Class)` for just one hop --- src/styles/global.js | 4 ++-- src/styles/shadow.js | 7 ++++--- src/util/copy-properties.js | 6 +++--- src/util/super.js | 24 +++++++++++++++++++----- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/styles/global.js b/src/styles/global.js index 34429aa..32b45d3 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -1,7 +1,7 @@ /** * Mixin for adding light DOM styles */ -import { adoptCSSRecursive, fetchCSS, getSupers, getSymbols } from "./util.js"; +import { adoptCSSRecursive, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; const { fetchedGlobalStyles, globalStyles, roots, render, initialized } = getSymbols; @@ -54,7 +54,7 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { this[initialized] = true; - let supers = getSupers(this, HTMLElement); + let supers = getSuperclasses(this, HTMLElement); for (let Class of supers) { if ( diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 929710e..bb3f16c 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -1,7 +1,7 @@ /** * Mixin for adding shadow DOM styles */ -import { adoptCSS, fetchCSS, getSupers, getSymbols } from "./util.js"; +import { adoptCSS, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; const { fetchedStyles, styles, initialized, render, init } = getSymbols; @@ -27,7 +27,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { async [render] () { let Self = this.constructor; - let supers = getSupers(Self, HTMLElement); + let supers = getSuperclasses(Self, HTMLElement); for (let Class of supers) { if (Class[fetchedStyles]) { @@ -53,7 +53,8 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { this[initialized] = true; - let supers = getSupers(this, HTMLElement); + let supers = getSuperclasses(this, HTMLElement); + supers.push(this); for (let Class of supers) { if (Object.hasOwn(Class, styles) && !Object.hasOwn(Class, fetchedStyles)) { diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index b4a91ec..f0bd7ac 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -1,5 +1,5 @@ import { extend } from "./extend.js"; -import { getSupers } from "./super.js"; +import { getSuperclasses } from "./super.js"; /** * @typedef {object} CopyPropertiesOptions @@ -19,8 +19,8 @@ export function copyProperties (target, source, options = {}) { let sources = [source]; if (options.recursive) { - let sourceSupers = getSupers(source).reverse(); - let targetSupers = getSupers(target).reverse(); + let sourceSupers = getSuperclasses(source).reverse(); + let targetSupers = getSuperclasses(target).reverse(); // Find the first shared superclass let index = sourceSupers.findIndex(sharedSuper => targetSupers.includes(sharedSuper)); diff --git a/src/util/super.js b/src/util/super.js index 764ed3f..4a1805e 100644 --- a/src/util/super.js +++ b/src/util/super.js @@ -1,16 +1,30 @@ /** - * Get the class hierarchy to the given class, from superclass to subclass + * Get the class hierarchy to the given class, from topmost ancestor to parent * @param {Function} Class * @param {Function} [FromClass] Optional class to stop at * @returns {Function[]} */ -export function getSupers (Class, FromClass) { - let classes = []; +export function getSuperclasses (Class, FromClass) { + const classes = []; + + while (Class = getSuperclass(Class)) { + if (Class === FromClass) { + break; + } - while (Class && Class !== FromClass && Class !== Function.prototype) { classes.unshift(Class); - Class = Object.getPrototypeOf(Class); } return classes; } + +export function getSuperclass (Class) { + let Super = Object.getPrototypeOf(Class); + + if (Super === Function.prototype) { + return null; + } + + return Super; +} + From 3e71e10d8a93139210634dd2aacd264ba21333d5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 31 Oct 2025 14:46:30 -0400 Subject: [PATCH 40/77] Add `getSuper()` helper --- src/util/super.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/util/super.js b/src/util/super.js index 4a1805e..3b90867 100644 --- a/src/util/super.js +++ b/src/util/super.js @@ -28,3 +28,22 @@ export function getSuperclass (Class) { return Super; } +/** + * Get a property from the superclass + * Similar to calling `super` in a method, but dynamically bound + * @param {object} instance + * @param {string | Symbol} [property] The property to get from super, if any. + * @returns {any} If no property is provided, the superclass prototype is returned. + * If a property is provided, the value of the property is returned. + * E.g. to emulate `super.foo(arg1, arg2)` in a method, use `getSuper(this, "foo").call(this, arg1, arg2)` + */ +export function getSuper (instance, property) { + let Class = instance.constructor; + let superProto = getSuperclass(Class)?.prototype; + + if (!superProto || !property) { + return superProto; + } + + return superProto[property]; +} From c4c7e14e0529db1c4e9fc292cee5915499f299b9 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 31 Oct 2025 19:53:26 +0100 Subject: [PATCH 41/77] Fixes for `class-mixins`. Second take (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Address @LeaVerou's feedback * Remove `connectedCallback` for now * Guh, again shadowing a symbol with a local variable 🤦‍♂️ * FIx another shadowing * Address issues with `WithEvents` --- src/element.js | 3 ++- src/events/defineEvents.js | 26 ++++++++++++-------------- src/styles/shadow.js | 16 ++++++++-------- src/util/copy-properties.js | 6 ++++++ src/util/extend.js | 4 ++-- 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/element.js b/src/element.js index 5c52bfa..655b0f5 100644 --- a/src/element.js +++ b/src/element.js @@ -4,12 +4,13 @@ import { Mixin as NudeElementMixin } from "./nude-element.js"; import commonMixins from "./common-mixins.js"; +import { initialized } from "./nude-element.js"; export const Mixin = (Super = HTMLElement, mixins = commonMixins) => class Element extends NudeElementMixin(Super) { static mixins = mixins; static init () { - if (this.initialized) { + if (this[initialized]) { return; } diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 34d560d..ade7a62 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -65,7 +65,7 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { this.constructor.init(); // Deal with existing values on the on* props - for (let name in this[eventProps]) { + for (let name in this.constructor[eventProps]) { let value = this[name]; if (typeof value === "function") { let eventName = name.slice(2); @@ -74,21 +74,21 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { } // Often propchange events have already fired by the time the event handlers are added - for (let eventName in this[propEvents]) { - let propName = this[propEvents][eventName]; + for (let eventName in this.constructor[propEvents]) { + let propName = this.constructor[propEvents][eventName]; let value = this[propName]; if (value !== undefined) { - this.props.firePropChangeEvent(this, eventName, { + this.constructor.props.firePropChangeEvent(this, eventName, { name: propName, - prop: this.props.get(propName), + prop: this.constructor.props.get(propName), }); } } // Listen for changes this.addEventListener("propchange", event => { - if (this[eventProps][event.name]) { + if (this.constructor[eventProps][event.name]) { // Implement onEventName attributes/properties let eventName = event.name.slice(2); let change = event.detail; @@ -103,10 +103,8 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { } }); - if (this[retargetedEvents]) { - for (let fn of this[retargetedEvents]) { - fn.call(this); - } + for (let fn of this.constructor[retargetedEvents]) { + fn.call(this); } } @@ -136,7 +134,7 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { } } - let eventProps = Object.keys(events) + let eventPropsDef = Object.keys(events) // Is not a native event (e.g. input) .filter(name => !("on" + name in this.prototype)) .map(name => [ @@ -152,9 +150,9 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { }, ]); - if (eventProps.length > 0) { - eventProps = this[eventProps] = Object.fromEntries(eventProps); - this.defineProps(eventProps); + if (eventPropsDef.length > 0) { + eventPropsDef = this[eventProps] = Object.fromEntries(eventPropsDef); + this.defineProps(eventPropsDef); } this[retargetedEvents] = []; diff --git a/src/styles/shadow.js b/src/styles/shadow.js index bb3f16c..f8ff0b1 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -3,10 +3,10 @@ */ import { adoptCSS, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; -const { fetchedStyles, styles, initialized, render, init } = getSymbols; +const { fetchedStyles, initialized, render, init } = getSymbols; export function appliesTo (Class) { - return "shadowStyles" in Class; + return "styles" in Class; } export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { @@ -20,7 +20,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { return; } - this.constructor.init(); + this.constructor[init](); this[render](); } @@ -47,7 +47,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { static [init] () { super[init]?.(); - if (!this[styles] || Object.hasOwn(this, initialized)) { + if (!this.styles || Object.hasOwn(this, initialized)) { return; } @@ -57,11 +57,11 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { supers.push(this); for (let Class of supers) { - if (Object.hasOwn(Class, styles) && !Object.hasOwn(Class, fetchedStyles)) { + if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, fetchedStyles)) { // Initiate fetching when the first element is constructed - let styles = (Class[fetchedStyles] = Array.isArray(Class[styles]) - ? Class[styles].slice() - : [Class[styles]]); + let styles = (Class[fetchedStyles] = Array.isArray(Class["styles"]) + ? Class["styles"].slice() + : [Class["styles"]]); for (let i = 0; i < styles.length; i++) { styles[i] = fetchCSS(styles[i], Class.url); diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index f0bd7ac..3801185 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -35,6 +35,12 @@ export function copyProperties (target, source, options = {}) { for (let property of properties) { acc.add(property); } + + let symbolProperties = Object.getOwnPropertySymbols(source); + for (let property of symbolProperties) { + acc.add(property); + } + return acc; }, new Set()); diff --git a/src/util/extend.js b/src/util/extend.js index 4f69d11..f114d91 100644 --- a/src/util/extend.js +++ b/src/util/extend.js @@ -8,7 +8,7 @@ export const sideEffects = Symbol("Side effects"); export const mutable = Symbol("Mutable"); -export function extend (body, ...sideEffects) { +export function extend (body, ...sideEffectFns) { let mutableFn = body[sideEffects] ? body : body[mutable]; if (!mutableFn) { @@ -36,7 +36,7 @@ export function extend (body, ...sideEffects) { body = mutableFn.body; - for (const sideEffect of sideEffects) { + for (const sideEffect of sideEffectFns) { if (body === sideEffect) { // The function cannot be a side effect of itself continue; From ad5361908b4eb133fff92b7dc6b5a5da63cceb94 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Mon, 3 Nov 2025 11:21:42 +0100 Subject: [PATCH 42/77] Add missed class to `supers` --- src/styles/global.js | 1 + src/styles/shadow.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/styles/global.js b/src/styles/global.js index 32b45d3..699b05a 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -55,6 +55,7 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { this[initialized] = true; let supers = getSuperclasses(this, HTMLElement); + supers.push(this); for (let Class of supers) { if ( diff --git a/src/styles/shadow.js b/src/styles/shadow.js index f8ff0b1..56da236 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -28,6 +28,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { let Self = this.constructor; let supers = getSuperclasses(Self, HTMLElement); + supers.push(Self); for (let Class of supers) { if (Class[fetchedStyles]) { From 1c4230d7f5b5fe8280b710d99cd083925ed8ae29 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Mon, 3 Nov 2025 15:14:55 +0100 Subject: [PATCH 43/77] Another iteration (#63) * Fix method call * Use the property name instead of a symbol * FIx check * Simplify code: access properties directly, not with quotes --- src/form-associated.js | 2 +- src/nude-element.js | 2 +- src/styles/global.js | 10 +++++----- src/styles/shadow.js | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/form-associated.js b/src/form-associated.js index 8120e69..1587f61 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -41,7 +41,7 @@ export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", confi } init () { - this.constructor.init(); + this.constructor[init](); // Give any subclasses a chance to execute Promise.resolve().then(() => this[constructed]()); diff --git a/src/nude-element.js b/src/nude-element.js index 16608cd..9e3735a 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -26,7 +26,7 @@ export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { // We repeat the logic because we want to be able to just call Class.init() before instances are constructed // But also we need to guard against subclasses defining their own init method and forgetting to call super.init() - if (!Object.hasOwn(this, initialized)) { + if (!Object.hasOwn(this.constructor, initialized)) { this.constructor.init(); this.constructor[initialized] = true; } diff --git a/src/styles/global.js b/src/styles/global.js index 699b05a..f82ccf5 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -3,7 +3,7 @@ */ import { adoptCSSRecursive, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; -const { fetchedGlobalStyles, globalStyles, roots, render, initialized } = getSymbols; +const { fetchedGlobalStyles, roots, render, initialized } = getSymbols; export function appliesTo (Class) { return "globalStyles" in Class; @@ -59,13 +59,13 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { for (let Class of supers) { if ( - Object.hasOwn(Class, globalStyles) && + Object.hasOwn(Class, "globalStyles") && !Object.hasOwn(Class, fetchedGlobalStyles) ) { // Initiate fetching when the first element is constructed - let styles = (Class[fetchedGlobalStyles] = Array.isArray(Class[globalStyles]) - ? Class[globalStyles].slice() - : [Class[globalStyles]]); + let styles = (Class[fetchedGlobalStyles] = Array.isArray(Class.globalStyles) + ? Class.globalStyles.slice() + : [Class.globalStyles]); Class[roots] ??= new WeakSet(); for (let i = 0; i < styles.length; i++) { diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 56da236..3606616 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -60,9 +60,9 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { for (let Class of supers) { if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, fetchedStyles)) { // Initiate fetching when the first element is constructed - let styles = (Class[fetchedStyles] = Array.isArray(Class["styles"]) - ? Class["styles"].slice() - : [Class["styles"]]); + let styles = (Class[fetchedStyles] = Array.isArray(Class.styles) + ? Class.styles.slice() + : [Class.styles]); for (let i = 0; i < styles.length; i++) { styles[i] = fetchCSS(styles[i], Class.url); From 50d54bf5f4c84576f814d0d1f5768f664771feb7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 00:58:15 +0800 Subject: [PATCH 44/77] Use symbols to simplify - Wrap `attachInternals` and store result rather than expecting a config property. Generally drop the concept of mixin parameterization as it adds more complexity than it's worth - Make appliesTo a symbol on the class (`satisfiedBy`) - Introduce known symbols for shared state --- README.md | 17 ----------- src/events/defineEvents.js | 8 ++--- src/form-associated.js | 37 +++++++++++------------ src/hooks/with.js | 8 ++--- src/lifecycle.js | 11 +++---- src/mixins/apply.js | 25 +++++++++++++++- src/props/defineProps.js | 6 ++-- src/states.js | 58 +++++++++++++++++-------------------- src/styles/global.js | 11 ++----- src/styles/shadow.js | 11 ++----- src/util/copy-properties.js | 2 +- src/util/get-symbols.js | 9 +++++- 12 files changed, 96 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index de00554..08a1e67 100644 --- a/README.md +++ b/README.md @@ -140,23 +140,6 @@ class MySlider extends Props(Events(FormAssociated(LitElement))) { } ``` -Some mixins even have a second argument for parameters that you can customize. -For example, by default they assume your `ElementInternals` instance (if you have one) is stored in a `_internals` property, -but you can change that to whatever you want by passing a second argument to the mixin: - -```js -import { FormAssociated } from "nude-element/mixins"; - -const internals = Symbol("internals"); -class MySlider extends FormAssociated(LitElement, { internalsProp: internals }) { - constructor () { - super(); - - this[internals] = this.attachInternals?.(); - } -} -``` - ### More hassle, more control: Composable mixins If Nude Element taking over your parent class seems too intrusive, diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index ade7a62..97fc4b8 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -4,7 +4,7 @@ import { Mixin as PropsMixin } from "../props/defineProps.js"; // import PropChangeEvent from "../props/PropChangeEvent.js"; import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import getSymbols from "../util/get-symbols.js"; +import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; const { initialized, eventProps, propEvents, retargetedEvents } = getSymbols; @@ -177,9 +177,7 @@ export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { } } - static appliesTo (Class) { - return "events" in Class; - } -} + static [satisfiedBy] = "events"; +}; export default Mixin(); diff --git a/src/form-associated.js b/src/form-associated.js index 1587f61..6592f15 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -9,14 +9,13 @@ import { resolveValue } from "./util/resolve-value.js"; import { delegate } from "./util/delegate.js"; import { getOptions } from "./util/get-options.js"; -import getSymbols from "./util/get-symbols.js"; +import { getSymbols, satisfiedBy, internals } from "./util/get-symbols.js"; const defaultOptions = { like: undefined, role: undefined, valueProp: "value", changeEvent: "input", - internalsProp: "_internals", properties: [ "labels", "form", @@ -28,13 +27,9 @@ const defaultOptions = { ], }; -const { constructed, initialized, init } = getSymbols; +export const { constructed, initialized, init, formAssociated } = getSymbols; -export function appliesTo (Class) { - return "formAssociated" in Class; -} - -export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", configProp = "formAssociated" } = {}) => class FormAssociated extends Super { +export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super { constructor () { super(); this.init(); @@ -47,9 +42,14 @@ export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", confi Promise.resolve().then(() => this[constructed]()); } + attachInternals () { + return this[internals] ??= super.attachInternals?.(); + } + [constructed] () { - let { like, role, valueProp, changeEvent } = this.constructor[configProp]; - let internals = (this[internalsProp] ??= this.attachInternals?.()); + let config = this.constructor[formAssociated] ?? this.constructor.formAssociated; + let { like, role, valueProp, changeEvent } = config; + let internals = this[internals] || this.attachInternals(); if (internals) { // Set the element's default role @@ -77,22 +77,23 @@ export const Mixin = (Super = HTMLElement, { internalsProp = "_internals", confi this[initialized] = true; - this[configProp] = getOptions(defaultOptions, this[configProp]); + let config = this[formAssociated] || this.formAssociated; + if (!config || typeof config !== "object") { + config = {}; + } + + this[formAssociated] = getOptions(defaultOptions, config); delegate(this.prototype, { source () { - return this[internalsProp]; + return this[internals]; }, - properties: this[configProp].properties, + properties: this[formAssociated].properties, enumerable: true, }); } - static appliesTo = function (Class) { - return configProp in Class; - }; + static [satisfiedBy] = [formAssociated, "formAssociated"]; }; -Mixin.appliesTo = appliesTo; - export default Mixin(); diff --git a/src/hooks/with.js b/src/hooks/with.js index ba0bd79..f845109 100644 --- a/src/hooks/with.js +++ b/src/hooks/with.js @@ -1,8 +1,5 @@ import Hooks from "./hooks.js"; - -export function appliesTo (Class) { - return "hooks" in Class; -} +import { satisfiedBy } from "../util/get-symbols.js"; export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { static hooks = new Hooks(super.hooks || {}); @@ -23,8 +20,7 @@ export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { } } - static appliesTo = appliesTo; + static [satisfiedBy] = "hooks"; }; -Mixin.appliesTo = appliesTo; export default Mixin(); diff --git a/src/lifecycle.js b/src/lifecycle.js index c7e04e2..57423fa 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -7,17 +7,13 @@ * - `init`: Called when the first instance of the class is created * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ -import getSymbols from "./util/get-symbols.js"; +import { getSymbols, satisfiedBy } from "./util/get-symbols.js"; const { hasConnected, initialized } = getSymbols; const instanceHooks = ["firstConnected", "constructed", "init"]; const staticHooks = ["anyConnected", "init"]; -export function appliesTo (Class) { - return instanceHooks.some(hook => Class.prototype[hook]) || staticHooks.some(hook => Class[hook]); -} - export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super { constructor () { super(); @@ -50,9 +46,10 @@ export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super this[hasConnected] = true; } - static appliesTo = appliesTo; + static [satisfiedBy] (Class) { + return instanceHooks.some(hook => Class.prototype[hook]) || staticHooks.some(hook => Class[hook]); + } }; -Mixin.appliesTo = appliesTo; export default Mixin(); diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 0223f3e..5db4637 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,4 +1,27 @@ import { copyProperties } from "../util/copy-properties.js"; +import { satisfiedBy } from "../util/get-symbols.js"; + +export function satisfies (Class, requirement) { + if (!requirement) { + // No reqs + return true; + } + + switch (typeof requirement === "function") { + case "function": + return requirement(Class); + case "string": + case "symbol": + return Class[requirement]; + } + + if (Array.isArray(requirement)) { + // Array of potential fields (OR) + return requirement.some(req => satisfies(Class, req)); + } + + return false; +} export function applyMixins (Class = this, mixins = Class.mixins) { if (Object.hasOwn(Class, "mixinsActive") || !mixins?.length) { @@ -8,7 +31,7 @@ export function applyMixins (Class = this, mixins = Class.mixins) { Class.mixinsActive = []; for (let Mixin of mixins) { - if (Mixin.appliesTo && !Mixin.appliesTo(Class)) { + if (satisfies(Class, Mixin[satisfiedBy])) { // Not applicable to this class continue; } diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 5fe3f91..a1faed5 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -1,5 +1,5 @@ import Props from "./Props.js"; -import getSymbols from "../util/get-symbols.js"; +import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; import { defineLazyProperties } from "../util/lazy.js"; const { initialized, propsDef } = getSymbols; @@ -73,9 +73,7 @@ export const Mixin = (Super = HTMLElement) => class WithProps extends Super { props = this.props = new Props(this, props); } - static appliesTo (Class) { - return "props" in Class; - } + static [satisfiedBy] = "props"; }; export default Mixin(); diff --git a/src/states.js b/src/states.js index c99fead..736d646 100644 --- a/src/states.js +++ b/src/states.js @@ -1,39 +1,35 @@ -export function appliesTo (Class) { - return "cssStates" in Class; -} - - -export function Mixin (Super = HTMLElement, {internalsProp = "_internals"} = {}) { - return class StatesMixin extends Super { - - // TODO do we also need addState() and removeState() or is toggleState() enough? - /** - * Add or remove a CSS custom state on the element. - * @param {string} state - The name of the state to add or remove. - * @param {boolean} [force] - If omitted, the state will be toggled. If true, the state will be added. If false, the state will be removed. - */ - toggleState (state, force) { - let states = this[internalsProp].states; - - if (!states) { - // TODO rewrite to attributes if states not supported? Possibly as a separate mixin - return; - } +import { satisfiedBy, internals } from "./util/get-symbols.js"; + +export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { + // TODO do we also need addState() and removeState() or is toggleState() enough? + /** + * Add or remove a CSS custom state on the element. + * @param {string} state - The name of the state to add or remove. + * @param {boolean} [force] - If omitted, the state will be toggled. If true, the state will be added. If false, the state will be removed. + */ + toggleState (state, force) { + let states = this[internals].states; + + if (!states) { + // TODO rewrite to attributes if states not supported? Possibly as a separate mixin + return; + } - force ??= !states.has(state); + force ??= !states.has(state); - if (force) { - states.add(state); - } - else { - states.delete(state); - } + if (force) { + states.add(state); } + else { + states.delete(state); + } + } - static appliesTo = appliesTo; + attachInternals () { + return this[internals] ??= super.attachInternals?.(); } -}; -Mixin.appliesTo = appliesTo; + static [satisfiedBy] = "cssStates"; +}; export default Mixin(); diff --git a/src/styles/global.js b/src/styles/global.js index f82ccf5..19f9426 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -1,14 +1,11 @@ /** * Mixin for adding light DOM styles */ -import { adoptCSSRecursive, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; +import { adoptCSSRecursive, fetchCSS, getSuperclasses } from "./util.js"; +import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; const { fetchedGlobalStyles, roots, render, initialized } = getSymbols; -export function appliesTo (Class) { - return "globalStyles" in Class; -} - export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { constructor () { super(); @@ -75,9 +72,7 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { } } - static appliesTo = appliesTo; + static [satisfiedBy] = "globalStyles"; }; -Mixin.appliesTo = appliesTo; - export default Mixin(); diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 3606616..9888a37 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -1,14 +1,11 @@ /** * Mixin for adding shadow DOM styles */ -import { adoptCSS, fetchCSS, getSuperclasses, getSymbols } from "./util.js"; +import { adoptCSS, fetchCSS, getSuperclasses } from "./util.js"; +import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; const { fetchedStyles, initialized, render, init } = getSymbols; -export function appliesTo (Class) { - return "styles" in Class; -} - export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { constructor () { super(); @@ -71,9 +68,7 @@ export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { } } - static appliesTo = appliesTo; + static [satisfiedBy] = "styles"; }; -Mixin.appliesTo = appliesTo; - export default Mixin(); diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js index 3801185..5d9cedd 100644 --- a/src/util/copy-properties.js +++ b/src/util/copy-properties.js @@ -30,7 +30,7 @@ export function copyProperties (target, source, options = {}) { } function copyPropertiesFromSources (sources, target) { - let properties = sources.reduce((acc, source) => { + let properties = /* @type Array */ sources.reduce((acc, source) => { let properties = Object.getOwnPropertyNames(source); for (let property of properties) { acc.add(property); diff --git a/src/util/get-symbols.js b/src/util/get-symbols.js index 50b9ec8..425922c 100644 --- a/src/util/get-symbols.js +++ b/src/util/get-symbols.js @@ -1,4 +1,4 @@ -export default new Proxy({}, { +const getSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { return Symbol(prop); @@ -7,3 +7,10 @@ export default new Proxy({}, { return target[prop]; }, }); + +export { getSymbols }; +export default getSymbols; + +// Known symbols +export const { satisfiedBy, internals } = getSymbols; +export const KNOWN_SYMBOLS = { satisfiedBy, internals }; From e2838858f3456f6b48d53cb5a71d0c94ae8fb6bb Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 02:09:04 +0800 Subject: [PATCH 45/77] Refactor class extension logic --- src/mixins/apply.js | 4 +- src/util.js | 2 +- src/util/copy-properties.js | 88 ----------------------------- src/util/extend-class.js | 44 +++++++++++++-- src/util/extend-object.js | 109 ++++++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 97 deletions(-) delete mode 100644 src/util/copy-properties.js create mode 100644 src/util/extend-object.js diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 5db4637..1f68e06 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,4 +1,4 @@ -import { copyProperties } from "../util/copy-properties.js"; +import { extendClass } from "../util/extend-class.js"; import { satisfiedBy } from "../util/get-symbols.js"; export function satisfies (Class, requirement) { @@ -47,7 +47,7 @@ export function applyMixin (Class, Mixin, force = false) { return; } - copyProperties(Class, Mixin, {recursive: true, prototypes: true}); + extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); if (!alreadyApplied) { Class.mixinsActive.push(Mixin); diff --git a/src/util.js b/src/util.js index 5a3b93f..6899912 100644 --- a/src/util.js +++ b/src/util.js @@ -3,7 +3,7 @@ export * from "./util/is-class.js"; export * from "./util/is-subclass-of.js"; export * from "./util/lazy.js"; export * from "./util/extend-class.js"; -export * from "./util/copy-properties.js"; +export * from "./util/extend-object.js"; export * from "./util/extend.js"; export * from "./util/reversible-map.js"; export * from "./util/pick.js"; diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js deleted file mode 100644 index 5d9cedd..0000000 --- a/src/util/copy-properties.js +++ /dev/null @@ -1,88 +0,0 @@ -import { extend } from "./extend.js"; -import { getSuperclasses } from "./super.js"; - -/** - * @typedef {object} CopyPropertiesOptions - * @property {boolean} [prototypes=false] - Whether to try and extend .prototype too. - * @property {boolean} [recursive=false] - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. - * @property {boolean} overwrite - Whether to overwrite conflicts that can't be merged - * @property {boolean} [mergeFunctions=true] - Whether to try to merge wherever possible - */ - -/** - * Copy properties, respecting descriptors - * @param {Record} target - * @param {Record} source - * @param {CopyPropertiesOptions} [options={}] - */ -export function copyProperties (target, source, options = {}) { - let sources = [source]; - - if (options.recursive) { - let sourceSupers = getSuperclasses(source).reverse(); - let targetSupers = getSuperclasses(target).reverse(); - - // Find the first shared superclass - let index = sourceSupers.findIndex(sharedSuper => targetSupers.includes(sharedSuper)); - if (index !== -1) { - sources.push(...sourceSupers.slice(index + 1)); - } - } - - function copyPropertiesFromSources (sources, target) { - let properties = /* @type Array */ sources.reduce((acc, source) => { - let properties = Object.getOwnPropertyNames(source); - for (let property of properties) { - acc.add(property); - } - - let symbolProperties = Object.getOwnPropertySymbols(source); - for (let property of symbolProperties) { - acc.add(property); - } - - return acc; - }, new Set()); - - properties.delete("constructor"); - - for (let key of properties) { - for (let source of sources) { - copyProperty(target, source, key, options); - } - } - } - - copyPropertiesFromSources(sources, target); - - if (options.prototypes) { - copyPropertiesFromSources(sources.map(source => source.prototype).filter(Boolean), target.prototype); - } -} - -function copyProperty (target, source, key, options = {}) { - let sourceDescriptor = Object.getOwnPropertyDescriptor(source, key); - let targetDescriptor = Object.getOwnPropertyDescriptor(target, key); - - if (!sourceDescriptor) { - return; - } - - if (targetDescriptor && options.mergeFunctions !== false) { - if ( - typeof targetDescriptor.value === "function" && - typeof sourceDescriptor.value === "function" - ) { - // Compatible, compose - targetDescriptor.value = extend( - targetDescriptor.value, - sourceDescriptor.value, - ); - sourceDescriptor = targetDescriptor; - } - } - - if (!targetDescriptor || options.overwrite || sourceDescriptor === targetDescriptor) { - Object.defineProperty(target, key, sourceDescriptor); - } -} diff --git a/src/util/extend-class.js b/src/util/extend-class.js index ca7956e..9bfd604 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -1,11 +1,43 @@ -import { copyProperties } from "./copy-properties.js"; +import { extendObject } from "./extend-object.js"; +import { getSuperclasses } from "./super.js"; /** + * @import { ConflictPolicySource, ConflictPolicy } from "./extend-object.js"; + */ + +/** + * @typedef { object } ExtendClassOptions + * @property { boolean } [recursive=true] - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. + * @property { Iterable } [skippedProperties = []] - Instance properties to ignore + * @property { Iterable } [skippedPropertiesStatic = []] - Static properties to ignore + * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicy="overwrite"] + * * Use a class as a mixin on another class - * @param {Function} Class - * @param {Function} Mixin - * @param {import("./copy-properties.js").CopyPropertiesOptions} [options={}] + * @param {Function} target + * @param {Function} source + * @param {ExtendClassOptions} [options={}] + * */ -export function extendClass (Class, Mixin, options = {}) { - copyProperties(Class, Mixin, {...options, prototypes: true}); +export function extendClass (target, source, options = {}) { + let sources = [source]; + + if (options.recursive !== false) { + let sourceSupers = getSuperclasses(source).reverse(); + let targetSupers = getSuperclasses(target).reverse(); + + // Find the first shared superclass + let index = sourceSupers.findIndex(sharedSuper => targetSupers.includes(sharedSuper)); + if (index !== -1) { + sources.push(...sourceSupers.slice(index + 1)); + } + } + + let {conflictPolicy} = options; + let skippedProperties = ["constructor"].concat(options.skippedProperties || []); + let skippedPropertiesStatic = ["prototype"].concat(options.skippedPropertiesStatic || []); + + for (let source of sources) { + extendObject(target.prototype, source.prototype, {conflictPolicy, skippedProperties}); + extendObject(target, source, {conflictPolicy, skippedProperties: skippedPropertiesStatic}); + } } diff --git a/src/util/extend-object.js b/src/util/extend-object.js new file mode 100644 index 0000000..376ae6d --- /dev/null +++ b/src/util/extend-object.js @@ -0,0 +1,109 @@ +import { extend } from "./extend.js"; + +/** + * @typedef {object | "overwrite" | "merge" | "skip" | "throw"} ConflictPolicySource + * @property {boolean} [merge] - Allow merge whenever possible? + * @property {true | Iterable} [skippedProperties = []] - Properties to ignore + * + * @param {Record} target + * @param {Record} source + * @param {ExtendObjectOptions} [options={}] + */ +export function extendObject (target, source, options = {}) { + let conflictPolicy = new ConflictPolicy(options.conflictPolicy); + + let sourceDescriptors = Object.getOwnPropertyDescriptors(source); + + for (let prop in sourceDescriptors) { + let sourceDescriptor = sourceDescriptors[prop]; + let targetDescriptor = Object.getOwnPropertyDescriptor(target, prop); + + if (prop in target) { + let propConflictPolicy = conflictPolicy.resolve(prop); + + if (propConflictPolicy === "skip") { + continue; + } + + if (propConflictPolicy === "throw") { + throw new Error(`Property ${prop} already exists on target`); + } + + // TODO merge + let descriptor = conflictPolicy.canMerge(prop) ? getMergeDescriptor(targetDescriptor, sourceDescriptor) : sourceDescriptor; + Object.defineProperty(target, prop, descriptor); + } + } +} + +function canMerge (targetDescriptor, sourceDescriptor) { + // TODO merge objects and arrays + return typeof targetDescriptor.value === "function" && typeof sourceDescriptor.value === "function"; +} + +function getMergeDescriptor (targetDescriptor, sourceDescriptor) { + if (!canMerge(targetDescriptor, sourceDescriptor)) { + return sourceDescriptor; + } + + return { + value: extend(targetDescriptor.value, sourceDescriptor.value), + writable: targetDescriptor.writable || sourceDescriptor.writable, + configurable: targetDescriptor.configurable || sourceDescriptor.configurable, + enumerable: targetDescriptor.enumerable || sourceDescriptor.enumerable, + }; +} From de80a61a2c6499b2f2956240e8aceab7eba04c9f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 02:51:10 +0800 Subject: [PATCH 46/77] Allow adding mixins multiple times --- src/mixins/apply.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 1f68e06..780441d 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -23,19 +23,24 @@ export function satisfies (Class, requirement) { return false; } +/** + * Apply a bunch of mixins to a class iff it satisfies their protocols + * @param { FunctionConstructor } Class + * @param { Iterable } [mixins = Class.mixins] + * @void + */ export function applyMixins (Class = this, mixins = Class.mixins) { - if (Object.hasOwn(Class, "mixinsActive") || !mixins?.length) { + if (!mixins?.length) { return; } - Class.mixinsActive = []; + if (!Object.hasOwn(Class, "mixinsActive")) { + Class.mixinsActive = [...(Object.getPrototypeOf(Class).mixinsActive || [])]; + } - for (let Mixin of mixins) { - if (satisfies(Class, Mixin[satisfiedBy])) { - // Not applicable to this class - continue; - } + const mixinsToApply = mixins.filter(Mixin => satisfies(Class, Mixin[satisfiedBy])); + for (let Mixin of mixinsToApply) { applyMixin(Class, Mixin); } } From a14a8659ffe3d317af1ae1c84c8bba1e15acbd21 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 02:59:10 +0800 Subject: [PATCH 47/77] Add support for skipped properties in extendObject The extendObject function now accepts a 'skippedProperties' option, allowing specific properties to be excluded from extension. This improves flexibility when merging objects. --- src/util/extend-object.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/extend-object.js b/src/util/extend-object.js index 376ae6d..e4b3c39 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -65,10 +65,14 @@ export class ConflictPolicy { */ export function extendObject (target, source, options = {}) { let conflictPolicy = new ConflictPolicy(options.conflictPolicy); - + let skippedProperties = new Set(options.skippedProperties || []); let sourceDescriptors = Object.getOwnPropertyDescriptors(source); for (let prop in sourceDescriptors) { + if (skippedProperties.has(prop)) { + continue; + } + let sourceDescriptor = sourceDescriptors[prop]; let targetDescriptor = Object.getOwnPropertyDescriptor(target, prop); From f7f8e95e6da703e67063d12b83c18a29b2a48449 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 03:14:33 +0800 Subject: [PATCH 48/77] Support calling `applyMixin()` multiple times - Also, boolean to indicate whether mixins were applied - `applyMixin()` now calls `applyMixins()` instead of the other way around --- src/mixins/apply.js | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 780441d..6e5c222 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -26,7 +26,7 @@ export function satisfies (Class, requirement) { /** * Apply a bunch of mixins to a class iff it satisfies their protocols * @param { FunctionConstructor } Class - * @param { Iterable } [mixins = Class.mixins] + * @param { Array } [mixins = Class.mixins] * @void */ export function applyMixins (Class = this, mixins = Class.mixins) { @@ -38,24 +38,21 @@ export function applyMixins (Class = this, mixins = Class.mixins) { Class.mixinsActive = [...(Object.getPrototypeOf(Class).mixinsActive || [])]; } - const mixinsToApply = mixins.filter(Mixin => satisfies(Class, Mixin[satisfiedBy])); + const mixinsToApply = mixins.filter(Mixin => !Class.mixinsActive.includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); - for (let Mixin of mixinsToApply) { - applyMixin(Class, Mixin); + if (mixinsToApply.length === 0) { + return false; } -} -export function applyMixin (Class, Mixin, force = false) { - let alreadyApplied = Class.mixinsActive.includes(Mixin); - if (alreadyApplied && !force) { - // Already applied - return; + for (const Mixin of mixinsToApply) { + extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); + Class.mixinsActive.push(Mixin); } - extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); + return true; +} - if (!alreadyApplied) { - Class.mixinsActive.push(Mixin); - } +export function applyMixin (Class, Mixin) { + return applyMixins(Class, [Mixin]); } From e1cbe458707a2f0bc510fd0c26ac68476aaa0cf0 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 03:25:24 +0800 Subject: [PATCH 49/77] Rename `mixinsActive` to `mixinsApplied` Refactors mixin tracking by renaming the property 'mixinsActive' to 'mixinsApplied' in both apply.js and nude-element.js for clarity and consistency. --- src/mixins/apply.js | 8 ++++---- src/nude-element.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 6e5c222..6fc9579 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -34,11 +34,11 @@ export function applyMixins (Class = this, mixins = Class.mixins) { return; } - if (!Object.hasOwn(Class, "mixinsActive")) { - Class.mixinsActive = [...(Object.getPrototypeOf(Class).mixinsActive || [])]; + if (!Object.hasOwn(Class, "mixinsApplied")) { + Class.mixinsApplied = [...(Object.getPrototypeOf(Class).mixinsApplied || [])]; } - const mixinsToApply = mixins.filter(Mixin => !Class.mixinsActive.includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); + const mixinsToApply = mixins.filter(Mixin => !Class.mixinsApplied.includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); if (mixinsToApply.length === 0) { return false; @@ -46,7 +46,7 @@ export function applyMixins (Class = this, mixins = Class.mixins) { for (const Mixin of mixinsToApply) { extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); - Class.mixinsActive.push(Mixin); + Class.mixinsApplied.push(Mixin); } return true; diff --git a/src/nude-element.js b/src/nude-element.js index 9e3735a..678985a 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -40,10 +40,10 @@ export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { // To be overridden by subclasses mixins = Object.freeze([]); - mixinsActive = Object.freeze([]); + mixinsApplied = Object.freeze([]); static applyMixins (mixins = this.mixins) { - if (Object.hasOwn(this, "mixinsActive") || !mixins || mixins.length === 0) { + if (Object.hasOwn(this, "mixinsApplied") || !mixins || mixins.length === 0) { return; } From a6a19123c50de6058dbbe6046d82407df7b9fed2 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 03:30:20 +0800 Subject: [PATCH 50/77] Refactor mixin application to use symbol for mixinsApplied --- src/mixins/apply.js | 10 +++++----- src/nude-element.js | 7 +------ src/util/get-symbols.js | 4 ++-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 6fc9579..89d02ee 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,5 +1,5 @@ import { extendClass } from "../util/extend-class.js"; -import { satisfiedBy } from "../util/get-symbols.js"; +import { satisfiedBy, mixinsApplied } from "../util/get-symbols.js"; export function satisfies (Class, requirement) { if (!requirement) { @@ -34,11 +34,11 @@ export function applyMixins (Class = this, mixins = Class.mixins) { return; } - if (!Object.hasOwn(Class, "mixinsApplied")) { - Class.mixinsApplied = [...(Object.getPrototypeOf(Class).mixinsApplied || [])]; + if (!Object.hasOwn(Class, mixinsApplied)) { + Class[mixinsApplied] = [...(Object.getPrototypeOf(Class)[mixinsApplied] || [])]; } - const mixinsToApply = mixins.filter(Mixin => !Class.mixinsApplied.includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); + const mixinsToApply = mixins.filter(Mixin => !Class[mixinsApplied].includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); if (mixinsToApply.length === 0) { return false; @@ -46,7 +46,7 @@ export function applyMixins (Class = this, mixins = Class.mixins) { for (const Mixin of mixinsToApply) { extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); - Class.mixinsApplied.push(Mixin); + Class[mixinsApplied].push(Mixin); } return true; diff --git a/src/nude-element.js b/src/nude-element.js index 678985a..c04dc88 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -40,14 +40,9 @@ export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { // To be overridden by subclasses mixins = Object.freeze([]); - mixinsApplied = Object.freeze([]); static applyMixins (mixins = this.mixins) { - if (Object.hasOwn(this, "mixinsApplied") || !mixins || mixins.length === 0) { - return; - } - - applyMixins(this, mixins); + return applyMixins(this, mixins); } static init () { diff --git a/src/util/get-symbols.js b/src/util/get-symbols.js index 425922c..8154d78 100644 --- a/src/util/get-symbols.js +++ b/src/util/get-symbols.js @@ -12,5 +12,5 @@ export { getSymbols }; export default getSymbols; // Known symbols -export const { satisfiedBy, internals } = getSymbols; -export const KNOWN_SYMBOLS = { satisfiedBy, internals }; +export const { satisfiedBy, internals, mixinsApplied } = getSymbols; +export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied }; From 63b906d015cca590cd793a3cc17c1ff66d75fa6b Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 03:54:26 +0800 Subject: [PATCH 51/77] Refactor mixin satisfaction logic and exports - Updated lifecycle.js to change the satisfiedBy static property to an array structure. - Improved satisfies function in apply.js to handle prototype and 'and' requirements. - Simplified nude-element.js exports and removed unused super method. --- src/lifecycle.js | 4 +--- src/mixins/apply.js | 10 +++++++++- src/nude-element.js | 10 +--------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/lifecycle.js b/src/lifecycle.js index 57423fa..9b65168 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -46,9 +46,7 @@ export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super this[hasConnected] = true; } - static [satisfiedBy] (Class) { - return instanceHooks.some(hook => Class.prototype[hook]) || staticHooks.some(hook => Class[hook]); - } + static [satisfiedBy] = [{prototype: instanceHooks}, staticHooks]; }; export default Mixin(); diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 89d02ee..1328d05 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -12,7 +12,7 @@ export function satisfies (Class, requirement) { return requirement(Class); case "string": case "symbol": - return Class[requirement]; + return Class[requirement] !== undefined; } if (Array.isArray(requirement)) { @@ -20,6 +20,14 @@ export function satisfies (Class, requirement) { return requirement.some(req => satisfies(Class, req)); } + if (requirement.prototype) { + return satisfies(Class.prototype, requirement.or); + } + + if (requirement.and) { + return requirement.and.every(req => satisfies(Class, req)); + } + return false; } diff --git a/src/nude-element.js b/src/nude-element.js index c04dc88..92bb814 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -10,9 +10,7 @@ import { applyMixins } from "./mixins/apply.js"; import getSymbols from "./util/get-symbols.js"; -const { initialized } = getSymbols; - -export { initialized }; +export const { initialized } = getSymbols; export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { constructor () { @@ -32,12 +30,6 @@ export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { } } - // Used to call super methods - // Do we actually need this? - super (name, ...args) { - return super[name]?.(...args); - } - // To be overridden by subclasses mixins = Object.freeze([]); From 4b10e18ae89ab9ab0038966c3f92db8e19309fbe Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 03:55:54 +0800 Subject: [PATCH 52/77] =?UTF-8?q?`getSymbols`=20=E2=86=92=20`newSymbols`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Also renamed util/get-symbols.js to util/symbols.js and updated all imports and references to use the new name. --- src/events/defineEvents.js | 4 ++-- src/form-associated.js | 4 ++-- src/hooks/with.js | 2 +- src/lifecycle.js | 4 ++-- src/mixins/apply.js | 2 +- src/nude-element.js | 4 ++-- src/props/defineProps.js | 4 ++-- src/slots/slots.js | 4 ++-- src/states.js | 2 +- src/styles/global.js | 4 ++-- src/styles/shadow.js | 4 ++-- src/styles/util.js | 2 +- src/util.js | 2 +- src/util/{get-symbols.js => symbols.js} | 8 ++++---- 14 files changed, 25 insertions(+), 25 deletions(-) rename src/util/{get-symbols.js => symbols.js} (58%) diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 97fc4b8..51f493c 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -4,9 +4,9 @@ import { Mixin as PropsMixin } from "../props/defineProps.js"; // import PropChangeEvent from "../props/PropChangeEvent.js"; import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; +import { newSymbols, satisfiedBy } from "../util/symbols.js"; -const { initialized, eventProps, propEvents, retargetedEvents } = getSymbols; +const { initialized, eventProps, propEvents, retargetedEvents } = newSymbols; /** * diff --git a/src/form-associated.js b/src/form-associated.js index 6592f15..4bc9f23 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -9,7 +9,7 @@ import { resolveValue } from "./util/resolve-value.js"; import { delegate } from "./util/delegate.js"; import { getOptions } from "./util/get-options.js"; -import { getSymbols, satisfiedBy, internals } from "./util/get-symbols.js"; +import { newSymbols, satisfiedBy, internals } from "./util/symbols.js"; const defaultOptions = { like: undefined, @@ -27,7 +27,7 @@ const defaultOptions = { ], }; -export const { constructed, initialized, init, formAssociated } = getSymbols; +export const { constructed, initialized, init, formAssociated } = newSymbols; export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super { constructor () { diff --git a/src/hooks/with.js b/src/hooks/with.js index f845109..fa246a8 100644 --- a/src/hooks/with.js +++ b/src/hooks/with.js @@ -1,5 +1,5 @@ import Hooks from "./hooks.js"; -import { satisfiedBy } from "../util/get-symbols.js"; +import { satisfiedBy } from "../util/symbols.js"; export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { static hooks = new Hooks(super.hooks || {}); diff --git a/src/lifecycle.js b/src/lifecycle.js index 9b65168..2d055f6 100644 --- a/src/lifecycle.js +++ b/src/lifecycle.js @@ -7,9 +7,9 @@ * - `init`: Called when the first instance of the class is created * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ -import { getSymbols, satisfiedBy } from "./util/get-symbols.js"; +import { newSymbols, satisfiedBy } from "./util/symbols.js"; -const { hasConnected, initialized } = getSymbols; +const { hasConnected, initialized } = newSymbols; const instanceHooks = ["firstConnected", "constructed", "init"]; const staticHooks = ["anyConnected", "init"]; diff --git a/src/mixins/apply.js b/src/mixins/apply.js index 1328d05..1915fdf 100644 --- a/src/mixins/apply.js +++ b/src/mixins/apply.js @@ -1,5 +1,5 @@ import { extendClass } from "../util/extend-class.js"; -import { satisfiedBy, mixinsApplied } from "../util/get-symbols.js"; +import { satisfiedBy, mixinsApplied } from "../util/symbols.js"; export function satisfies (Class, requirement) { if (!requirement) { diff --git a/src/nude-element.js b/src/nude-element.js index 92bb814..22a7f2b 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -8,9 +8,9 @@ * ``` */ import { applyMixins } from "./mixins/apply.js"; -import getSymbols from "./util/get-symbols.js"; +import newSymbols from "./util/symbols.js"; -export const { initialized } = getSymbols; +export const { initialized } = newSymbols; export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { constructor () { diff --git a/src/props/defineProps.js b/src/props/defineProps.js index a1faed5..e2d27d4 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -1,7 +1,7 @@ import Props from "./Props.js"; -import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; +import { newSymbols, satisfiedBy } from "../util/symbols.js"; import { defineLazyProperties } from "../util/lazy.js"; -const { initialized, propsDef } = getSymbols; +const { initialized, propsDef } = newSymbols; export const Mixin = (Super = HTMLElement) => class WithProps extends Super { constructor () { diff --git a/src/slots/slots.js b/src/slots/slots.js index 7e958b9..a64e7ba 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,12 +1,12 @@ import SlotController from "./slot-controller.js"; -import getSymbols from "../util/get-symbols.js"; +import newSymbols from "../util/symbols.js"; const defaultOptions = { slotsProperty: "_slots", dynamicSlots: false, }; -const { hasConnected } = getSymbols; +const { hasConnected } = newSymbols; export function Mixin (Super = HTMLElement, options = {}) { options = { ...defaultOptions, ...options }; diff --git a/src/states.js b/src/states.js index 736d646..9aeff75 100644 --- a/src/states.js +++ b/src/states.js @@ -1,4 +1,4 @@ -import { satisfiedBy, internals } from "./util/get-symbols.js"; +import { satisfiedBy, internals } from "./util/symbols.js"; export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { // TODO do we also need addState() and removeState() or is toggleState() enough? diff --git a/src/styles/global.js b/src/styles/global.js index 19f9426..52ddd8e 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -2,9 +2,9 @@ * Mixin for adding light DOM styles */ import { adoptCSSRecursive, fetchCSS, getSuperclasses } from "./util.js"; -import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; +import { newSymbols, satisfiedBy } from "../util/symbols.js"; -const { fetchedGlobalStyles, roots, render, initialized } = getSymbols; +const { fetchedGlobalStyles, roots, render, initialized } = newSymbols; export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { constructor () { diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 9888a37..c18137c 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -2,9 +2,9 @@ * Mixin for adding shadow DOM styles */ import { adoptCSS, fetchCSS, getSuperclasses } from "./util.js"; -import { getSymbols, satisfiedBy } from "../util/get-symbols.js"; +import { newSymbols, satisfiedBy } from "../util/symbols.js"; -const { fetchedStyles, initialized, render, init } = getSymbols; +const { fetchedStyles, initialized, render, init } = newSymbols; export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { constructor () { diff --git a/src/styles/util.js b/src/styles/util.js index fbece50..97372de 100644 --- a/src/styles/util.js +++ b/src/styles/util.js @@ -1,4 +1,4 @@ export * from "../util/adopt-css.js"; export * from "../util/fetch-css.js"; -export { default as getSymbols } from "../util/get-symbols.js"; +export { default as newSymbols } from "../util/symbols.js"; export * from "../util/super.js"; diff --git a/src/util.js b/src/util.js index 6899912..b02f484 100644 --- a/src/util.js +++ b/src/util.js @@ -9,5 +9,5 @@ export * from "./util/reversible-map.js"; export * from "./util/pick.js"; export * from "./util/adopt-css.js"; export * from "./util/fetch-css.js"; -export * from "./util/get-symbols.js"; +export * from "./util/symbols.js"; export * from "./util/super.js"; diff --git a/src/util/get-symbols.js b/src/util/symbols.js similarity index 58% rename from src/util/get-symbols.js rename to src/util/symbols.js index 8154d78..123fe98 100644 --- a/src/util/get-symbols.js +++ b/src/util/symbols.js @@ -1,4 +1,4 @@ -const getSymbols = new Proxy({}, { +const newSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { return Symbol(prop); @@ -8,9 +8,9 @@ const getSymbols = new Proxy({}, { }, }); -export { getSymbols }; -export default getSymbols; +export { newSymbols }; +export default newSymbols; // Known symbols -export const { satisfiedBy, internals, mixinsApplied } = getSymbols; +export const { satisfiedBy, internals, mixinsApplied } = newSymbols; export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied }; From 56e33609ad494c889cee661334e6556ebcf9fd9c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 04:07:34 +0800 Subject: [PATCH 53/77] Move `attachInternals()` intercept to separate util function --- src/form-associated.js | 3 ++- src/states.js | 3 ++- src/util/attach-internals.js | 13 +++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/util/attach-internals.js diff --git a/src/form-associated.js b/src/form-associated.js index 4bc9f23..db6714c 100644 --- a/src/form-associated.js +++ b/src/form-associated.js @@ -10,6 +10,7 @@ import { resolveValue } from "./util/resolve-value.js"; import { delegate } from "./util/delegate.js"; import { getOptions } from "./util/get-options.js"; import { newSymbols, satisfiedBy, internals } from "./util/symbols.js"; +import { attachInternals } from "./util/attach-internals.js"; const defaultOptions = { like: undefined, @@ -43,7 +44,7 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super } attachInternals () { - return this[internals] ??= super.attachInternals?.(); + return attachInternals(this); } [constructed] () { diff --git a/src/states.js b/src/states.js index 9aeff75..cbc10c1 100644 --- a/src/states.js +++ b/src/states.js @@ -1,4 +1,5 @@ import { satisfiedBy, internals } from "./util/symbols.js"; +import { attachInternals } from "./util/attach-internals.js"; export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { // TODO do we also need addState() and removeState() or is toggleState() enough? @@ -26,7 +27,7 @@ export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { } attachInternals () { - return this[internals] ??= super.attachInternals?.(); + return attachInternals(this); } static [satisfiedBy] = "cssStates"; diff --git a/src/util/attach-internals.js b/src/util/attach-internals.js new file mode 100644 index 0000000..34b76a3 --- /dev/null +++ b/src/util/attach-internals.js @@ -0,0 +1,13 @@ +import { getSuper } from "./super.js"; +import { internals } from "./symbols.js"; + +export function attachInternals (thisArg = this) { + let superInternals = getSuper(thisArg, "attachInternals"); + + if (!superInternals) { + // Method likely not supported + return; + } + + return thisArg[internals] ??= superInternals.call(thisArg); +} From 6bdfd47f7551afb430761bc8c2a70868f760eafb Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 04:09:29 +0800 Subject: [PATCH 54/77] Move mixins to `mixins/` to separate from supporting code --- src/{mixins => }/apply.js | 4 ++-- src/common-mixins.js | 2 +- src/events/defineEvents.js | 2 +- src/index.js | 2 +- src/{ => mixins}/form-associated.js | 10 +++++----- src/{ => mixins}/lifecycle.js | 2 +- src/{ => mixins}/props/Prop.js | 2 +- src/{ => mixins}/props/PropChangeEvent.js | 0 src/{ => mixins}/props/Props.js | 0 src/{ => mixins}/props/README.md | 0 src/{ => mixins}/props/defineProps.js | 4 ++-- src/{ => mixins}/props/types.js | 0 src/{ => mixins}/props/types/basic.js | 0 src/{ => mixins}/props/types/dictionaries.js | 2 +- src/{ => mixins}/props/types/index.js | 0 src/{ => mixins}/props/types/lists.js | 0 src/{ => mixins}/props/types/util.js | 0 src/{ => mixins}/props/util.js | 0 src/{ => mixins}/slots/README.md | 0 src/{ => mixins}/slots/defineSlots.js | 0 src/{ => mixins}/slots/has-slotted.js | 0 src/{ => mixins}/slots/named-manual.js | 0 src/{ => mixins}/slots/slot-controller.js | 0 src/{ => mixins}/slots/slot-observer.js | 0 src/{ => mixins}/slots/slots.js | 0 src/{ => mixins}/slots/util.js | 0 src/{ => mixins}/states.js | 4 ++-- src/{ => mixins}/styles/global.js | 0 src/{ => mixins}/styles/index.js | 0 src/{ => mixins}/styles/shadow.js | 0 src/{ => mixins}/styles/util.js | 0 src/nude-element.js | 2 +- 32 files changed, 18 insertions(+), 18 deletions(-) rename src/{mixins => }/apply.js (92%) rename src/{ => mixins}/form-associated.js (87%) rename src/{ => mixins}/lifecycle.js (96%) rename src/{ => mixins}/props/Prop.js (99%) rename src/{ => mixins}/props/PropChangeEvent.js (100%) rename src/{ => mixins}/props/Props.js (100%) rename src/{ => mixins}/props/README.md (100%) rename src/{ => mixins}/props/defineProps.js (92%) rename src/{ => mixins}/props/types.js (100%) rename src/{ => mixins}/props/types/basic.js (100%) rename src/{ => mixins}/props/types/dictionaries.js (98%) rename src/{ => mixins}/props/types/index.js (100%) rename src/{ => mixins}/props/types/lists.js (100%) rename src/{ => mixins}/props/types/util.js (100%) rename src/{ => mixins}/props/util.js (100%) rename src/{ => mixins}/slots/README.md (100%) rename src/{ => mixins}/slots/defineSlots.js (100%) rename src/{ => mixins}/slots/has-slotted.js (100%) rename src/{ => mixins}/slots/named-manual.js (100%) rename src/{ => mixins}/slots/slot-controller.js (100%) rename src/{ => mixins}/slots/slot-observer.js (100%) rename src/{ => mixins}/slots/slots.js (100%) rename src/{ => mixins}/slots/util.js (100%) rename src/{ => mixins}/states.js (87%) rename src/{ => mixins}/styles/global.js (100%) rename src/{ => mixins}/styles/index.js (100%) rename src/{ => mixins}/styles/shadow.js (100%) rename src/{ => mixins}/styles/util.js (100%) diff --git a/src/mixins/apply.js b/src/apply.js similarity index 92% rename from src/mixins/apply.js rename to src/apply.js index 1915fdf..6033f2b 100644 --- a/src/mixins/apply.js +++ b/src/apply.js @@ -1,5 +1,5 @@ -import { extendClass } from "../util/extend-class.js"; -import { satisfiedBy, mixinsApplied } from "../util/symbols.js"; +import { extendClass } from "./util/extend-class.js"; +import { satisfiedBy, mixinsApplied } from "./util/symbols.js"; export function satisfies (Class, requirement) { if (!requirement) { diff --git a/src/common-mixins.js b/src/common-mixins.js index a07a45b..5a0fc35 100644 --- a/src/common-mixins.js +++ b/src/common-mixins.js @@ -2,7 +2,7 @@ * All mixins */ -import Props from "./props/defineProps.js"; +import Props from "./mixins/props/defineProps.js"; import FormAssociated from "./form-associated.js"; import Events from "./events/defineEvents.js"; import ShadowStyles from "./styles/shadow.js"; diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 51f493c..7b7e7e2 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -1,6 +1,6 @@ // To be split into three mixins: A base events mixin, a retargeting mixin, and a propchange event mixin -import { Mixin as PropsMixin } from "../props/defineProps.js"; +import { Mixin as PropsMixin } from "../mixins/props/defineProps.js"; // import PropChangeEvent from "../props/PropChangeEvent.js"; import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; diff --git a/src/index.js b/src/index.js index 602171f..239eb01 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ export { default as default } from "./element.js"; export { default as NudeElement } from "./nude-element.js"; -export { default as defineProps } from "./props/defineProps.js"; +export { default as defineProps } from "./mixins/props/defineProps.js"; export { default as defineEvents } from "./events/defineEvents.js"; export { default as defineSlots } from "./slots/defineSlots.js"; export { default as defineFormAssociated } from "./form-associated.js"; diff --git a/src/form-associated.js b/src/mixins/form-associated.js similarity index 87% rename from src/form-associated.js rename to src/mixins/form-associated.js index db6714c..89bc677 100644 --- a/src/form-associated.js +++ b/src/mixins/form-associated.js @@ -6,11 +6,11 @@ * - Proxying an internal form control for all of the above */ -import { resolveValue } from "./util/resolve-value.js"; -import { delegate } from "./util/delegate.js"; -import { getOptions } from "./util/get-options.js"; -import { newSymbols, satisfiedBy, internals } from "./util/symbols.js"; -import { attachInternals } from "./util/attach-internals.js"; +import { resolveValue } from "../util/resolve-value.js"; +import { delegate } from "../util/delegate.js"; +import { getOptions } from "../util/get-options.js"; +import { newSymbols, satisfiedBy, internals } from "../util/symbols.js"; +import { attachInternals } from "../util/attach-internals.js"; const defaultOptions = { like: undefined, diff --git a/src/lifecycle.js b/src/mixins/lifecycle.js similarity index 96% rename from src/lifecycle.js rename to src/mixins/lifecycle.js index 2d055f6..e1b465c 100644 --- a/src/lifecycle.js +++ b/src/mixins/lifecycle.js @@ -7,7 +7,7 @@ * - `init`: Called when the first instance of the class is created * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ -import { newSymbols, satisfiedBy } from "./util/symbols.js"; +import { newSymbols, satisfiedBy } from "../util/symbols.js"; const { hasConnected, initialized } = newSymbols; diff --git a/src/props/Prop.js b/src/mixins/props/Prop.js similarity index 99% rename from src/props/Prop.js rename to src/mixins/props/Prop.js index cfeb91b..7b7ce58 100644 --- a/src/props/Prop.js +++ b/src/mixins/props/Prop.js @@ -1,4 +1,4 @@ -import { resolveValue } from "../util.js"; +import { resolveValue } from "../../util.js"; import { inferDependencies } from "./util.js"; import * as types from "./types.js"; diff --git a/src/props/PropChangeEvent.js b/src/mixins/props/PropChangeEvent.js similarity index 100% rename from src/props/PropChangeEvent.js rename to src/mixins/props/PropChangeEvent.js diff --git a/src/props/Props.js b/src/mixins/props/Props.js similarity index 100% rename from src/props/Props.js rename to src/mixins/props/Props.js diff --git a/src/props/README.md b/src/mixins/props/README.md similarity index 100% rename from src/props/README.md rename to src/mixins/props/README.md diff --git a/src/props/defineProps.js b/src/mixins/props/defineProps.js similarity index 92% rename from src/props/defineProps.js rename to src/mixins/props/defineProps.js index e2d27d4..0195f2d 100644 --- a/src/props/defineProps.js +++ b/src/mixins/props/defineProps.js @@ -1,6 +1,6 @@ import Props from "./Props.js"; -import { newSymbols, satisfiedBy } from "../util/symbols.js"; -import { defineLazyProperties } from "../util/lazy.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; +import { defineLazyProperties } from "../../util/lazy.js"; const { initialized, propsDef } = newSymbols; export const Mixin = (Super = HTMLElement) => class WithProps extends Super { diff --git a/src/props/types.js b/src/mixins/props/types.js similarity index 100% rename from src/props/types.js rename to src/mixins/props/types.js diff --git a/src/props/types/basic.js b/src/mixins/props/types/basic.js similarity index 100% rename from src/props/types/basic.js rename to src/mixins/props/types/basic.js diff --git a/src/props/types/dictionaries.js b/src/mixins/props/types/dictionaries.js similarity index 98% rename from src/props/types/dictionaries.js rename to src/mixins/props/types/dictionaries.js index 48b3c61..8c98209 100644 --- a/src/props/types/dictionaries.js +++ b/src/mixins/props/types/dictionaries.js @@ -1,4 +1,4 @@ -import { resolveValue } from "../../util.js"; +import { resolveValue } from "../../../util.js"; import { parse, stringify, equals } from "../types.js"; import { split } from "./util.js"; diff --git a/src/props/types/index.js b/src/mixins/props/types/index.js similarity index 100% rename from src/props/types/index.js rename to src/mixins/props/types/index.js diff --git a/src/props/types/lists.js b/src/mixins/props/types/lists.js similarity index 100% rename from src/props/types/lists.js rename to src/mixins/props/types/lists.js diff --git a/src/props/types/util.js b/src/mixins/props/types/util.js similarity index 100% rename from src/props/types/util.js rename to src/mixins/props/types/util.js diff --git a/src/props/util.js b/src/mixins/props/util.js similarity index 100% rename from src/props/util.js rename to src/mixins/props/util.js diff --git a/src/slots/README.md b/src/mixins/slots/README.md similarity index 100% rename from src/slots/README.md rename to src/mixins/slots/README.md diff --git a/src/slots/defineSlots.js b/src/mixins/slots/defineSlots.js similarity index 100% rename from src/slots/defineSlots.js rename to src/mixins/slots/defineSlots.js diff --git a/src/slots/has-slotted.js b/src/mixins/slots/has-slotted.js similarity index 100% rename from src/slots/has-slotted.js rename to src/mixins/slots/has-slotted.js diff --git a/src/slots/named-manual.js b/src/mixins/slots/named-manual.js similarity index 100% rename from src/slots/named-manual.js rename to src/mixins/slots/named-manual.js diff --git a/src/slots/slot-controller.js b/src/mixins/slots/slot-controller.js similarity index 100% rename from src/slots/slot-controller.js rename to src/mixins/slots/slot-controller.js diff --git a/src/slots/slot-observer.js b/src/mixins/slots/slot-observer.js similarity index 100% rename from src/slots/slot-observer.js rename to src/mixins/slots/slot-observer.js diff --git a/src/slots/slots.js b/src/mixins/slots/slots.js similarity index 100% rename from src/slots/slots.js rename to src/mixins/slots/slots.js diff --git a/src/slots/util.js b/src/mixins/slots/util.js similarity index 100% rename from src/slots/util.js rename to src/mixins/slots/util.js diff --git a/src/states.js b/src/mixins/states.js similarity index 87% rename from src/states.js rename to src/mixins/states.js index cbc10c1..1977a88 100644 --- a/src/states.js +++ b/src/mixins/states.js @@ -1,5 +1,5 @@ -import { satisfiedBy, internals } from "./util/symbols.js"; -import { attachInternals } from "./util/attach-internals.js"; +import { satisfiedBy, internals } from "../util/symbols.js"; +import { attachInternals } from "../util/attach-internals.js"; export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { // TODO do we also need addState() and removeState() or is toggleState() enough? diff --git a/src/styles/global.js b/src/mixins/styles/global.js similarity index 100% rename from src/styles/global.js rename to src/mixins/styles/global.js diff --git a/src/styles/index.js b/src/mixins/styles/index.js similarity index 100% rename from src/styles/index.js rename to src/mixins/styles/index.js diff --git a/src/styles/shadow.js b/src/mixins/styles/shadow.js similarity index 100% rename from src/styles/shadow.js rename to src/mixins/styles/shadow.js diff --git a/src/styles/util.js b/src/mixins/styles/util.js similarity index 100% rename from src/styles/util.js rename to src/mixins/styles/util.js diff --git a/src/nude-element.js b/src/nude-element.js index 22a7f2b..0c366d3 100644 --- a/src/nude-element.js +++ b/src/nude-element.js @@ -7,7 +7,7 @@ * } * ``` */ -import { applyMixins } from "./mixins/apply.js"; +import { applyMixins } from "./apply.js"; import newSymbols from "./util/symbols.js"; export const { initialized } = newSymbols; From 2f4d66749733ddfa787d33111306c605dedd1208 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 04:37:37 +0800 Subject: [PATCH 55/77] Add onApply symbol for mixin initialization hooks - Introduce the onApply symbol to allow mixins to execute initialization logic when applied. - Refactors form-associated mixin to use onApply for setup, - update delegate utility to take just the property as the param and to read descriptors. --- src/apply.js | 6 ++++- src/mixins/form-associated.js | 51 +++++++++++++++-------------------- src/util/delegate.js | 27 ++++++++++++------- src/util/symbols.js | 4 +-- 4 files changed, 46 insertions(+), 42 deletions(-) diff --git a/src/apply.js b/src/apply.js index 6033f2b..38caf58 100644 --- a/src/apply.js +++ b/src/apply.js @@ -1,5 +1,5 @@ import { extendClass } from "./util/extend-class.js"; -import { satisfiedBy, mixinsApplied } from "./util/symbols.js"; +import { satisfiedBy, mixinsApplied, onApply } from "./util/symbols.js"; export function satisfies (Class, requirement) { if (!requirement) { @@ -55,6 +55,10 @@ export function applyMixins (Class = this, mixins = Class.mixins) { for (const Mixin of mixinsToApply) { extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); Class[mixinsApplied].push(Mixin); + + if (Mixin[onApply]) { + Mixin[onApply](Class); + } } return true; diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index 89bc677..0357e60 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -9,7 +9,7 @@ import { resolveValue } from "../util/resolve-value.js"; import { delegate } from "../util/delegate.js"; import { getOptions } from "../util/get-options.js"; -import { newSymbols, satisfiedBy, internals } from "../util/symbols.js"; +import { newSymbols, satisfiedBy, internals, onApply } from "../util/symbols.js"; import { attachInternals } from "../util/attach-internals.js"; const defaultOptions = { @@ -37,8 +37,6 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super } init () { - this.constructor[init](); - // Give any subclasses a chance to execute Promise.resolve().then(() => this[constructed]()); } @@ -52,45 +50,38 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super let { like, role, valueProp, changeEvent } = config; let internals = this[internals] || this.attachInternals(); - if (internals) { - // Set the element's default role - let source = resolveValue(like, [this, this]); - role ??= source?.computedRole; + if (!this[internals]) { + return; + } - if (role) { - internals.ariaRole = role; - } + // Set the element's default role + let source = resolveValue(like, [this, this]); + role ??= source?.computedRole; - // Set current form value and update on change - internals.setFormValue(this[valueProp]); - let eventTarget = source || this; - eventTarget.addEventListener(changeEvent, () => - internals.setFormValue(this[valueProp])); + if (role) { + this[internals].ariaRole = role; } + + // Set current form value and update on change + this[internals].setFormValue(this[valueProp]); + let eventTarget = source || this; + eventTarget.addEventListener(changeEvent, () => + this[internals].setFormValue(this[valueProp])); } static formAssociated = true; - static [init] () { - if (this[initialized]) { - return; - } - - this[initialized] = true; - + static [onApply] () { let config = this[formAssociated] || this.formAssociated; - if (!config || typeof config !== "object") { - config = {}; - } + config = !config || typeof config !== "object" ? {} : config; this[formAssociated] = getOptions(defaultOptions, config); - delegate(this.prototype, { - source () { - return this[internals]; - }, + delegate({ properties: this[formAssociated].properties, - enumerable: true, + from: this.prototype, + to: internals, + descriptors: Object.getOwnPropertyDescriptors(ElementInternals.prototype), }); } diff --git a/src/util/delegate.js b/src/util/delegate.js index 5e0c421..77ead7a 100644 --- a/src/util/delegate.js +++ b/src/util/delegate.js @@ -1,19 +1,28 @@ -export function delegate (target, {source, properties, enumerable = true, writable = false, configurable = true}) { +/** + * Generate a bunch of accessors to proxy properties through a certain subobject + * @param {Object} options + * @param {Object} options.from - The object to define the delegated properties on + * @param {string} options.to - The name of the subobject property to proxy through + * @param {string[]} options.properties - Array of property names to delegate + * @param {Object} options.descriptors - Property descriptors for each property + */ +export function delegate ({from, to, properties, descriptors}) { for (let prop of properties) { + let sourceDescriptor = descriptors[prop]; let descriptor = { get () { - let sourceObj = typeof source === "function" ? source.call(this) : source; - return sourceObj[prop]; + return this[to][prop]; }, - enumerable, - configurable, + ...sourceDescriptor, + configurable: true, }; - if (writable) { + + if (sourceDescriptor.writable || sourceDescriptor.set) { descriptor.set = function (value) { - let sourceObj = typeof source === "function" ? source.call(this) : source; - sourceObj[prop] = value; + this[to][prop] = value; }; } - Object.defineProperty(target, prop, descriptor); + + Object.defineProperty(from, prop, descriptor); } } diff --git a/src/util/symbols.js b/src/util/symbols.js index 123fe98..1fa85f3 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -12,5 +12,5 @@ export { newSymbols }; export default newSymbols; // Known symbols -export const { satisfiedBy, internals, mixinsApplied } = newSymbols; -export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied }; +export const { satisfiedBy, internals, mixinsApplied, onApply } = newSymbols; +export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied, onApply }; From 4a775d262e25c286053b7f8c3c0d2aab0eb7b1fe Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 04:46:57 +0800 Subject: [PATCH 56/77] Fix role bugs - `computedRole` not shipped - `ariaRole` not an actual ElementInternals property --- src/mixins/form-associated.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index 0357e60..917c997 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -56,10 +56,9 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super // Set the element's default role let source = resolveValue(like, [this, this]); - role ??= source?.computedRole; if (role) { - this[internals].ariaRole = role; + this[internals].role = role; } // Set current form value and update on change From c6100a42d1d35949031e4cd52810474632cea3e7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 04:52:35 +0800 Subject: [PATCH 57/77] Refactor style mixins imports and remove unused files Updated import paths in global.js and shadow.js to reference utility modules directly. Removed unused index.js and util.js from styles mixins to clean up redundant code. --- src/mixins/form-associated.js | 4 ++-- src/mixins/styles/global.js | 6 ++++-- src/mixins/styles/index.js | 2 -- src/mixins/styles/shadow.js | 6 ++++-- src/mixins/styles/util.js | 4 ---- 5 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 src/mixins/styles/index.js delete mode 100644 src/mixins/styles/util.js diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index 917c997..2e72238 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -63,8 +63,8 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super // Set current form value and update on change this[internals].setFormValue(this[valueProp]); - let eventTarget = source || this; - eventTarget.addEventListener(changeEvent, () => + let changeEventTarget = source || this; + changeEventTarget.addEventListener(changeEvent, () => this[internals].setFormValue(this[valueProp])); } diff --git a/src/mixins/styles/global.js b/src/mixins/styles/global.js index 52ddd8e..7104e10 100644 --- a/src/mixins/styles/global.js +++ b/src/mixins/styles/global.js @@ -1,8 +1,10 @@ /** * Mixin for adding light DOM styles */ -import { adoptCSSRecursive, fetchCSS, getSuperclasses } from "./util.js"; -import { newSymbols, satisfiedBy } from "../util/symbols.js"; +import { adoptCSSRecursive } from "../../util/adopt-css.js"; +import { fetchCSS } from "../../util/fetch-css.js"; +import { getSuperclasses } from "../../util/super.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; const { fetchedGlobalStyles, roots, render, initialized } = newSymbols; diff --git a/src/mixins/styles/index.js b/src/mixins/styles/index.js deleted file mode 100644 index ee0f843..0000000 --- a/src/mixins/styles/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as shadowStyles } from "./shadow.js"; -export { default as globalStyles } from "./global.js"; diff --git a/src/mixins/styles/shadow.js b/src/mixins/styles/shadow.js index c18137c..321e245 100644 --- a/src/mixins/styles/shadow.js +++ b/src/mixins/styles/shadow.js @@ -1,8 +1,10 @@ /** * Mixin for adding shadow DOM styles */ -import { adoptCSS, fetchCSS, getSuperclasses } from "./util.js"; -import { newSymbols, satisfiedBy } from "../util/symbols.js"; +import { adoptCSS } from "../../util/adopt-css.js"; +import { fetchCSS } from "../../util/fetch-css.js"; +import { getSuperclasses } from "../../util/super.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; const { fetchedStyles, initialized, render, init } = newSymbols; diff --git a/src/mixins/styles/util.js b/src/mixins/styles/util.js deleted file mode 100644 index 97372de..0000000 --- a/src/mixins/styles/util.js +++ /dev/null @@ -1,4 +0,0 @@ -export * from "../util/adopt-css.js"; -export * from "../util/fetch-css.js"; -export { default as newSymbols } from "../util/symbols.js"; -export * from "../util/super.js"; From cbd780342b3fead4370ac4617f730c60f027dc7e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 23 Nov 2025 10:21:21 +0800 Subject: [PATCH 58/77] Refactor global styles mixin and fetchCSS logic - Simplifies the global styles mixin by removing superclasses traversal and updating style resolution logic. - Refactors fetchCSS to handle promises and failed fetches more robustly. - `fetchCSS()` already uses a cache, so now we're reading it directly on render to get the most updated version @DmitrySharabin can you please check that this works? --- src/mixins/styles/global.js | 49 ++++++++++++++++++++----------------- src/util/fetch-css.js | 11 ++++++--- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/mixins/styles/global.js b/src/mixins/styles/global.js index 7104e10..b1ec89e 100644 --- a/src/mixins/styles/global.js +++ b/src/mixins/styles/global.js @@ -3,25 +3,29 @@ */ import { adoptCSSRecursive } from "../../util/adopt-css.js"; import { fetchCSS } from "../../util/fetch-css.js"; -import { getSuperclasses } from "../../util/super.js"; import { newSymbols, satisfiedBy } from "../../util/symbols.js"; -const { fetchedGlobalStyles, roots, render, initialized } = newSymbols; +export const { resolvedStyles, render, initialized, self } = newSymbols; export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { constructor () { super(); - this.init(); + + // Initiate fetching when the first element is constructed + // if nothing has called it yet + this.constructor.init(); } async [render] () { let Self = this.constructor; - if (!Self[fetchedGlobalStyles]?.length) { + if (!Self.globalStyles?.length) { return; } - for (let css of Self[fetchedGlobalStyles]) { + let styles = Self[resolvedStyles].map(style => fetchCSS(style, Self.url)); + + for (let css of styles) { if (css instanceof Promise) { // Why not just await css anyway? // Because this way if this is already fetched, we don’t need to wait for a microtask @@ -44,6 +48,8 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { this[render](); } + static [self] = self; + static init () { super.init?.(); @@ -53,23 +59,22 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { this[initialized] = true; - let supers = getSuperclasses(this, HTMLElement); - supers.push(this); - - for (let Class of supers) { - if ( - Object.hasOwn(Class, "globalStyles") && - !Object.hasOwn(Class, fetchedGlobalStyles) - ) { - // Initiate fetching when the first element is constructed - let styles = (Class[fetchedGlobalStyles] = Array.isArray(Class.globalStyles) - ? Class.globalStyles.slice() - : [Class.globalStyles]); - Class[roots] ??= new WeakSet(); - - for (let i = 0; i < styles.length; i++) { - styles[i] = fetchCSS(styles[i], Class.url); - } + let Super = Object.getPrototypeOf(this); + + if (Super[self]) { + // If self is not defined, it means we've gone past the subclass that included this + this.init.call(Super); + } + + if (Object.hasOwn(this, "globalStyles")) { + if (!Array.isArray(this.globalStyles)) { + this.globalStyles = [this.globalStyles]; + } + + this[resolvedStyles] = this.globalStyles.slice(); + + if (Super[resolvedStyles]) { + this[resolvedStyles].unshift(...Super[resolvedStyles]); } } } diff --git a/src/util/fetch-css.js b/src/util/fetch-css.js index c351da1..f9070f9 100644 --- a/src/util/fetch-css.js +++ b/src/util/fetch-css.js @@ -19,8 +19,13 @@ export function fetchCSS (style, baseUrl = defaultBaseURL) { if (style instanceof Promise) { return style; } - else if (style?.css) { + + if (style?.css) { // The component provides a CSS string (either as a promise or a string) + if (style.css instanceof Promise) { + style.css.then(css => style.css = css); + } + return style.css; } @@ -32,8 +37,8 @@ export function fetchCSS (style, baseUrl = defaultBaseURL) { if (!css) { // Haven't fetched yet - css = fetchedStyles[fullUrl] = fetch(fullUrl).then(response => response.text()); - css.then(css => (fetchedStyles[fullUrl] = css)); + css = fetchedStyles[fullUrl] = fetch(fullUrl).then(response => response.ok ? response.text() : Promise.reject(response)); + css.then(css => fetchedStyles[fullUrl] = css, error => fetchedStyles[fullUrl] = ""); } } From c237718fbc0259395b9a5a1e30749fc231718b03 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 04:18:26 -0500 Subject: [PATCH 59/77] Fix issues identified by @DmitrySharabin in #65 Co-Authored-By: Dmitry Sharabin --- src/apply.js | 4 ++-- src/common-mixins.js | 6 +++--- src/mixins/form-associated.js | 1 - src/util/extend-object.js | 15 +++++++++------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/apply.js b/src/apply.js index 38caf58..360399f 100644 --- a/src/apply.js +++ b/src/apply.js @@ -7,7 +7,7 @@ export function satisfies (Class, requirement) { return true; } - switch (typeof requirement === "function") { + switch (typeof requirement) { case "function": return requirement(Class); case "string": @@ -57,7 +57,7 @@ export function applyMixins (Class = this, mixins = Class.mixins) { Class[mixinsApplied].push(Mixin); if (Mixin[onApply]) { - Mixin[onApply](Class); + Mixin[onApply].call(Class); } } diff --git a/src/common-mixins.js b/src/common-mixins.js index 5a0fc35..e496993 100644 --- a/src/common-mixins.js +++ b/src/common-mixins.js @@ -3,10 +3,10 @@ */ import Props from "./mixins/props/defineProps.js"; -import FormAssociated from "./form-associated.js"; +import FormAssociated from "./mixins/form-associated.js"; import Events from "./events/defineEvents.js"; -import ShadowStyles from "./styles/shadow.js"; -import GlobalStyles from "./styles/global.js"; +import ShadowStyles from "./mixins/styles/shadow.js"; +import GlobalStyles from "./mixins/styles/global.js"; export { Props, FormAssociated, Events, ShadowStyles, GlobalStyles }; diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index 2e72238..dca6687 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -20,7 +20,6 @@ const defaultOptions = { properties: [ "labels", "form", - "type", "name", "validity", "validationMessage", diff --git a/src/util/extend-object.js b/src/util/extend-object.js index e4b3c39..4c20f86 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -20,7 +20,7 @@ export class ConflictPolicy { return conflictPolicy; } - this.def = conflictPolicy; + this.def = conflictPolicy ?? {}; if (!conflictPolicy || typeof conflictPolicy === "string") { this.default = conflictPolicy || "overwrite"; @@ -68,15 +68,16 @@ export function extendObject (target, source, options = {}) { let skippedProperties = new Set(options.skippedProperties || []); let sourceDescriptors = Object.getOwnPropertyDescriptors(source); - for (let prop in sourceDescriptors) { + for (let prop of Reflect.ownKeys(sourceDescriptors)) { if (skippedProperties.has(prop)) { continue; } let sourceDescriptor = sourceDescriptors[prop]; let targetDescriptor = Object.getOwnPropertyDescriptor(target, prop); + let descriptor; - if (prop in target) { + if (targetDescriptor) { let propConflictPolicy = conflictPolicy.resolve(prop); if (propConflictPolicy === "skip") { @@ -87,10 +88,12 @@ export function extendObject (target, source, options = {}) { throw new Error(`Property ${prop} already exists on target`); } - // TODO merge - let descriptor = conflictPolicy.canMerge(prop) ? getMergeDescriptor(targetDescriptor, sourceDescriptor) : sourceDescriptor; - Object.defineProperty(target, prop, descriptor); + if (conflictPolicy.canMerge(prop)) { + descriptor = getMergeDescriptor(targetDescriptor, sourceDescriptor); + } } + + Object.defineProperty(target, prop, descriptor ?? sourceDescriptor); } } From d09403a2386489fce2371e485df185c6bc7cebdb Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 04:20:06 -0500 Subject: [PATCH 60/77] Move leftover mixins to `/mixins` --- src/{ => mixins}/events/README.md | 0 src/{ => mixins}/events/defineEvents.js | 8 ++++---- src/{ => mixins}/hooks/README.md | 0 src/{ => mixins}/hooks/hooks.js | 0 src/{hooks/with.js => mixins/hooks/with-hooks.js} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename src/{ => mixins}/events/README.md (100%) rename src/{ => mixins}/events/defineEvents.js (95%) rename src/{ => mixins}/hooks/README.md (100%) rename src/{ => mixins}/hooks/hooks.js (100%) rename src/{hooks/with.js => mixins/hooks/with-hooks.js} (100%) diff --git a/src/events/README.md b/src/mixins/events/README.md similarity index 100% rename from src/events/README.md rename to src/mixins/events/README.md diff --git a/src/events/defineEvents.js b/src/mixins/events/defineEvents.js similarity index 95% rename from src/events/defineEvents.js rename to src/mixins/events/defineEvents.js index 7b7e7e2..e9b4425 100644 --- a/src/events/defineEvents.js +++ b/src/mixins/events/defineEvents.js @@ -1,10 +1,10 @@ // To be split into three mixins: A base events mixin, a retargeting mixin, and a propchange event mixin -import { Mixin as PropsMixin } from "../mixins/props/defineProps.js"; +import { Mixin as PropsMixin } from "../props/defineProps.js"; // import PropChangeEvent from "../props/PropChangeEvent.js"; -import { resolveValue } from "../util.js"; -import { pick } from "../util/pick.js"; -import { newSymbols, satisfiedBy } from "../util/symbols.js"; +import { resolveValue } from "../../util/resolve-value.js"; +import { pick } from "../../util/pick.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; const { initialized, eventProps, propEvents, retargetedEvents } = newSymbols; diff --git a/src/hooks/README.md b/src/mixins/hooks/README.md similarity index 100% rename from src/hooks/README.md rename to src/mixins/hooks/README.md diff --git a/src/hooks/hooks.js b/src/mixins/hooks/hooks.js similarity index 100% rename from src/hooks/hooks.js rename to src/mixins/hooks/hooks.js diff --git a/src/hooks/with.js b/src/mixins/hooks/with-hooks.js similarity index 100% rename from src/hooks/with.js rename to src/mixins/hooks/with-hooks.js From 3cc13c4d4206c167f35fcb615fa4ee2b35e92f09 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 04:20:12 -0500 Subject: [PATCH 61/77] Update with-hooks.js --- src/mixins/hooks/with-hooks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/hooks/with-hooks.js b/src/mixins/hooks/with-hooks.js index fa246a8..b771cbb 100644 --- a/src/mixins/hooks/with-hooks.js +++ b/src/mixins/hooks/with-hooks.js @@ -1,5 +1,5 @@ import Hooks from "./hooks.js"; -import { satisfiedBy } from "../util/symbols.js"; +import { satisfiedBy } from "../../util/symbols.js"; export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { static hooks = new Hooks(super.hooks || {}); From 9b03c4cdbd458f57e282f93a68066aa38dc6aa62 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 04:44:47 -0500 Subject: [PATCH 62/77] Skip if equal values --- src/util/extend-object.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/util/extend-object.js b/src/util/extend-object.js index 4c20f86..48ea4fb 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -80,7 +80,7 @@ export function extendObject (target, source, options = {}) { if (targetDescriptor) { let propConflictPolicy = conflictPolicy.resolve(prop); - if (propConflictPolicy === "skip") { + if (propConflictPolicy === "skip" || descriptorEquals(targetDescriptor, sourceDescriptor)) { continue; } @@ -97,6 +97,12 @@ export function extendObject (target, source, options = {}) { } } +function descriptorEquals (targetDescriptor, sourceDescriptor) { + return ["value", "get", "set"].every(key => { + return targetDescriptor[key] === sourceDescriptor[key]; + }); +} + function canMerge (targetDescriptor, sourceDescriptor) { // TODO merge objects and arrays return typeof targetDescriptor.value === "function" && typeof sourceDescriptor.value === "function"; From 6ab62e6b89457201afdea546717c1905aaf54608 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 06:48:14 -0500 Subject: [PATCH 63/77] Update src/common-mixins.js Co-authored-by: Dmitry Sharabin --- src/common-mixins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common-mixins.js b/src/common-mixins.js index e496993..e93a8bb 100644 --- a/src/common-mixins.js +++ b/src/common-mixins.js @@ -4,7 +4,7 @@ import Props from "./mixins/props/defineProps.js"; import FormAssociated from "./mixins/form-associated.js"; -import Events from "./events/defineEvents.js"; +import Events from "./mixins/events/defineEvents.js"; import ShadowStyles from "./mixins/styles/shadow.js"; import GlobalStyles from "./mixins/styles/global.js"; From 1cd74699f13ef8e64f953c2b25d454a573430264 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 06:54:12 -0500 Subject: [PATCH 64/77] Update extend-class.js --- src/util/extend-class.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/util/extend-class.js b/src/util/extend-class.js index 9bfd604..e5360f0 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -13,31 +13,31 @@ import { getSuperclasses } from "./super.js"; * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicy="overwrite"] * * Use a class as a mixin on another class - * @param {Function} target - * @param {Function} source + * @param {Function} Class + * @param {Function} Mixin * @param {ExtendClassOptions} [options={}] * */ -export function extendClass (target, source, options = {}) { - let sources = [source]; +export function extendClass (Class, Mixin, options = {}) { + let sources = [Mixin]; if (options.recursive !== false) { - let sourceSupers = getSuperclasses(source).reverse(); - let targetSupers = getSuperclasses(target).reverse(); + let classSupers = getSuperclasses(Class).reverse(); + let mixinSupers = getSuperclasses(Mixin).reverse(); // Find the first shared superclass - let index = sourceSupers.findIndex(sharedSuper => targetSupers.includes(sharedSuper)); + let index = mixinSupers.findIndex(sharedSuper => classSupers.includes(sharedSuper)); if (index !== -1) { - sources.push(...sourceSupers.slice(index + 1)); + sources.push(...mixinSupers.slice(index + 1)); } } - let {conflictPolicy} = options; + let { conflictPolicy } = options; let skippedProperties = ["constructor"].concat(options.skippedProperties || []); let skippedPropertiesStatic = ["prototype"].concat(options.skippedPropertiesStatic || []); for (let source of sources) { - extendObject(target.prototype, source.prototype, {conflictPolicy, skippedProperties}); - extendObject(target, source, {conflictPolicy, skippedProperties: skippedPropertiesStatic}); + extendObject(Class.prototype, source.prototype, {conflictPolicy, skippedProperties}); + extendObject(Class, source, {conflictPolicy, skippedProperties: skippedPropertiesStatic}); } } From 4fa077346b22542aeea2319b937598294536f52c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 07:00:17 -0500 Subject: [PATCH 65/77] Update src/mixins/form-associated.js Co-authored-by: Dmitry Sharabin --- src/mixins/form-associated.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index dca6687..86ad750 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -20,7 +20,6 @@ const defaultOptions = { properties: [ "labels", "form", - "name", "validity", "validationMessage", "willValidate", From 9782637efafaf5355019f15b5fbc4953b33d0e1e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 07:00:47 -0500 Subject: [PATCH 66/77] Update src/mixins/styles/global.js Co-authored-by: Dmitry Sharabin --- src/mixins/styles/global.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mixins/styles/global.js b/src/mixins/styles/global.js index b1ec89e..b517b2c 100644 --- a/src/mixins/styles/global.js +++ b/src/mixins/styles/global.js @@ -19,7 +19,7 @@ export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { async [render] () { let Self = this.constructor; - if (!Self.globalStyles?.length) { + if (!Self[resolvedStyles]?.length) { return; } From 12ef062811f137f9ec5dd27a476e04755ff34dca Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 14:13:44 -0500 Subject: [PATCH 67/77] Update form-associated.js --- src/mixins/form-associated.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js index dca6687..8c6c4b7 100644 --- a/src/mixins/form-associated.js +++ b/src/mixins/form-associated.js @@ -67,14 +67,16 @@ export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super this[internals].setFormValue(this[valueProp])); } - static formAssociated = true; - static [onApply] () { let config = this[formAssociated] || this.formAssociated; config = !config || typeof config !== "object" ? {} : config; this[formAssociated] = getOptions(defaultOptions, config); + if (!this.formAssociated) { + this.formAssociated = true; + } + delegate({ properties: this[formAssociated].properties, from: this.prototype, From 5ec92698d638c1b0fcbe399960a0ef7060f57089 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 14:20:18 -0500 Subject: [PATCH 68/77] Move `ConflictPolicy` to separate file --- src/util/conflict-policy.js | 51 ++++++++++++++++++++++++++++++++++ src/util/extend-class.js | 2 +- src/util/extend-object.js | 55 ++----------------------------------- 3 files changed, 54 insertions(+), 54 deletions(-) create mode 100644 src/util/conflict-policy.js diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js new file mode 100644 index 0000000..0cb0d1b --- /dev/null +++ b/src/util/conflict-policy.js @@ -0,0 +1,51 @@ +/** + * @typedef {object | "overwrite" | "merge" | "skip" | "throw"} ConflictPolicySource + * @property {boolean} [merge] - Allow merge whenever possible? + * @property {true | Iterable} [skippedProperties = []] - Properties to ignore * * @param {Record} target From 233f8fac0dfddd51e0b26eaddbfc469a9eedab9c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 14:53:15 -0500 Subject: [PATCH 69/77] First stab at supporting a separate conflictPolicy per mixin `ConflictPolicy` seems like an overcomplex solution to the problems, but let's see how it goes, we can always hardcode its abstractions later --- src/apply.js | 20 ++++++++++++++++++-- src/lifecycle.js | 14 ++++++++++++++ src/mixins/lifecycle.js | 8 ++++++++ src/mixins/props/defineProps.js | 3 +++ src/util/conflict-policy.js | 28 +++++++++++++++++++++++++--- src/util/extend-class.js | 4 ++-- src/util/symbols.js | 4 ++-- 7 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 src/lifecycle.js diff --git a/src/apply.js b/src/apply.js index 360399f..c40b041 100644 --- a/src/apply.js +++ b/src/apply.js @@ -1,5 +1,7 @@ import { extendClass } from "./util/extend-class.js"; -import { satisfiedBy, mixinsApplied, onApply } from "./util/symbols.js"; +import { satisfiedBy, mixinsApplied, onApply, conflictPolicy } from "./util/symbols.js"; +import lifecycleHooks, { staticLifecycleHooks } from "./lifecycle.js"; +import { ConflictPolicy } from "./util/conflict-policy.js"; export function satisfies (Class, requirement) { if (!requirement) { @@ -31,6 +33,16 @@ export function satisfies (Class, requirement) { return false; } +const defaultConflictPolicy = new ConflictPolicy({ + throw: true, + merge: lifecycleHooks, +}); + +const defaultStaticConflictPolicy = new ConflictPolicy({ + throw: true, + merge: staticLifecycleHooks, +}); + /** * Apply a bunch of mixins to a class iff it satisfies their protocols * @param { FunctionConstructor } Class @@ -53,7 +65,11 @@ export function applyMixins (Class = this, mixins = Class.mixins) { } for (const Mixin of mixinsToApply) { - extendClass(Class, Mixin, {skippedProperties: [satisfiedBy]}); + extendClass(Class, Mixin, { + skippedProperties: [satisfiedBy, onApply], + conflictPolicy: ConflictPolicy.combine(defaultConflictPolicy, Mixin.prototype[conflictPolicy]), + conflictPolicyStatic: ConflictPolicy.combine(defaultStaticConflictPolicy, Mixin[conflictPolicy]), + }); Class[mixinsApplied].push(Mixin); if (Mixin[onApply]) { diff --git a/src/lifecycle.js b/src/lifecycle.js new file mode 100644 index 0000000..a33bf09 --- /dev/null +++ b/src/lifecycle.js @@ -0,0 +1,14 @@ +/** + * All known lifecycle hooks + * Mixins can import this set and add their own + */ +export default new Set([ + "init", + "connectedCallback", + "disconnectedCallback", + "adoptedCallback", + "connectedMoveCallback", + "attributeChangedCallback", +]); + +export const staticLifecycleHooks = new Set(); diff --git a/src/mixins/lifecycle.js b/src/mixins/lifecycle.js index e1b465c..c8cdab7 100644 --- a/src/mixins/lifecycle.js +++ b/src/mixins/lifecycle.js @@ -8,12 +8,20 @@ * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) */ import { newSymbols, satisfiedBy } from "../util/symbols.js"; +import lifecycleHooks, { staticLifecycleHooks } from "../lifecycle.js"; const { hasConnected, initialized } = newSymbols; const instanceHooks = ["firstConnected", "constructed", "init"]; const staticHooks = ["anyConnected", "init"]; +for (let hook of instanceHooks) { + lifecycleHooks.add(hook); +} +for (let hook of staticHooks) { + staticLifecycleHooks.add(hook); +} + export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super { constructor () { super(); diff --git a/src/mixins/props/defineProps.js b/src/mixins/props/defineProps.js index 0195f2d..7697fee 100644 --- a/src/mixins/props/defineProps.js +++ b/src/mixins/props/defineProps.js @@ -3,6 +3,9 @@ import { newSymbols, satisfiedBy } from "../../util/symbols.js"; import { defineLazyProperties } from "../../util/lazy.js"; const { initialized, propsDef } = newSymbols; +import lifecycleHooks from "../../lifecycle.js"; +lifecycleHooks.add("propChangedCallback"); + export const Mixin = (Super = HTMLElement) => class WithProps extends Super { constructor () { super(); diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js index 0cb0d1b..0061d3e 100644 --- a/src/util/conflict-policy.js +++ b/src/util/conflict-policy.js @@ -1,5 +1,9 @@ /** - * @typedef {object | "overwrite" | "merge" | "skip" | "throw"} ConflictPolicySource + * @typedef { "overwrite" | "merge" | "skip" | "throw" } ConflictPolicyStrategy + */ + +/** + * @typedef { object } ConflictPolicySource * @property {boolean} [merge] - Allow merge whenever possible? * @property {true | Iterable new this(p)).reduce((exceptions, policy) => { + for (let prop in policy.exceptions) { + if (exceptions[prop]) { + // Merge exceptions + exceptions[prop] = [...new Set(exceptions[prop].concat(policy.exceptions[prop]))]; + } + else { + exceptions[prop] = policy.exceptions[prop]; + } + } + return exceptions; + }), + }); + } } diff --git a/src/util/extend-class.js b/src/util/extend-class.js index fe6c09d..0a69bc3 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -32,12 +32,12 @@ export function extendClass (Class, Mixin, options = {}) { } } - let { conflictPolicy } = options; + let { conflictPolicy, conflictPolicyStatic = conflictPolicy } = options; let skippedProperties = ["constructor"].concat(options.skippedProperties || []); let skippedPropertiesStatic = ["prototype"].concat(options.skippedPropertiesStatic || []); for (let source of sources) { extendObject(Class.prototype, source.prototype, {conflictPolicy, skippedProperties}); - extendObject(Class, source, {conflictPolicy, skippedProperties: skippedPropertiesStatic}); + extendObject(Class, source, {conflictPolicy: conflictPolicyStatic, skippedProperties: skippedPropertiesStatic}); } } diff --git a/src/util/symbols.js b/src/util/symbols.js index 1fa85f3..f7f4a9e 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -12,5 +12,5 @@ export { newSymbols }; export default newSymbols; // Known symbols -export const { satisfiedBy, internals, mixinsApplied, onApply } = newSymbols; -export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied, onApply }; +export const { satisfiedBy, internals, mixinsApplied, onApply, conflictPolicy } = newSymbols; +export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied, onApply, conflictPolicy }; From c491956d8588ddb2a55e985655f8fecbf2cda98a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 15:01:57 -0500 Subject: [PATCH 70/77] Update extend-object.js --- src/util/extend-object.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/util/extend-object.js b/src/util/extend-object.js index dc42733..8e5f5c3 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -47,6 +47,8 @@ export function extendObject (target, source, options = {}) { } function descriptorEquals (targetDescriptor, sourceDescriptor) { + // Note that this only takes value properties into account and will return true even if + // one has different enumerable, writable, configurable, etc. return ["value", "get", "set"].every(key => { return targetDescriptor[key] === sourceDescriptor[key]; }); From 8106417b2a489b34f543a8c2ecaed442f85679cf Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 15:14:20 -0500 Subject: [PATCH 71/77] =?UTF-8?q?`extend()`=20=E2=86=92=20`composeFunction?= =?UTF-8?q?()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util.js | 2 +- src/util/{extend.js => compose-function.js} | 2 +- src/util/extend-object.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/util/{extend.js => compose-function.js} (95%) diff --git a/src/util.js b/src/util.js index b02f484..f52e7f8 100644 --- a/src/util.js +++ b/src/util.js @@ -4,7 +4,7 @@ export * from "./util/is-subclass-of.js"; export * from "./util/lazy.js"; export * from "./util/extend-class.js"; export * from "./util/extend-object.js"; -export * from "./util/extend.js"; +export * from "./util/compose-function.js"; export * from "./util/reversible-map.js"; export * from "./util/pick.js"; export * from "./util/adopt-css.js"; diff --git a/src/util/extend.js b/src/util/compose-function.js similarity index 95% rename from src/util/extend.js rename to src/util/compose-function.js index f114d91..d43e48a 100644 --- a/src/util/extend.js +++ b/src/util/compose-function.js @@ -8,7 +8,7 @@ export const sideEffects = Symbol("Side effects"); export const mutable = Symbol("Mutable"); -export function extend (body, ...sideEffectFns) { +export function composeFunction (body, ...sideEffectFns) { let mutableFn = body[sideEffects] ? body : body[mutable]; if (!mutableFn) { diff --git a/src/util/extend-object.js b/src/util/extend-object.js index 8e5f5c3..b443f36 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -1,4 +1,4 @@ -import { extend } from "./extend.js"; +import { composeFunction } from "./compose-function.js"; import { ConflictPolicy } from "./conflict-policy.js"; /** @@ -65,7 +65,7 @@ function getMergeDescriptor (targetDescriptor, sourceDescriptor) { } return { - value: extend(targetDescriptor.value, sourceDescriptor.value), + value: composeFunction(targetDescriptor.value, sourceDescriptor.value), writable: targetDescriptor.writable || sourceDescriptor.writable, configurable: targetDescriptor.configurable || sourceDescriptor.configurable, enumerable: targetDescriptor.enumerable || sourceDescriptor.enumerable, From 4bf9de27c1530708af841fa67d466e5f92d1135c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 15:17:54 -0500 Subject: [PATCH 72/77] Update conflict-policy.js --- src/util/conflict-policy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js index 0061d3e..c3a998b 100644 --- a/src/util/conflict-policy.js +++ b/src/util/conflict-policy.js @@ -53,7 +53,7 @@ export class ConflictPolicy { return this.def.merge === true || this.def.merge?.includes?.(property) || false; } - static merge (...policies) { + static combine (...policies) { return new this({ default: policies.at(-1).default ?? "overwrite", exceptions: policies.filter(Boolean).map(p => new this(p)).reduce((exceptions, policy) => { From c9366281fdbd3756e0335a3c66769ac3d29b60a7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 15:37:39 -0500 Subject: [PATCH 73/77] Refactor ConflictPolicy to improve strategy handling Updated ConflictPolicy to clarify strategy types, refactored property handling for merge, overwrite, skip, and throw, and improved exception management. --- src/util/conflict-policy.js | 59 +++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js index c3a998b..5e39c50 100644 --- a/src/util/conflict-policy.js +++ b/src/util/conflict-policy.js @@ -1,19 +1,35 @@ /** - * @typedef { "overwrite" | "merge" | "skip" | "throw" } ConflictPolicyStrategy + * @typedef { "overwrite" | "skip" | "throw" } ConflictPolicyStrategy */ /** * @typedef { object } ConflictPolicySource - * @property {boolean} [merge] - Allow merge whenever possible? + * @property {boolean | Iterable} [merge] - Allow merge whenever possible? * @property {true | Iterable } + */ exceptions = {}; + /** + * Whether to allow merge whenever possible + * Exceptions can still be provided as an array even if merge is false + * @type { boolean } + */ + merge = false; + /** * @param { ConflictPolicySource | ConflictPolicyStrategy | ConflictPolicy } [conflictPolicy="overwrite"] */ @@ -29,14 +45,21 @@ export class ConflictPolicy { return; } + this.merge = conflictPolicy.merge === true; + + if (conflictPolicy.default) { + this.default = conflictPolicy.default; + } + else { + this.default = ["overwrite", "skip", "throw"].find(p => conflictPolicy[p] === true); + } + // Object - for (let prop in conflictPolicy) { - let value = conflictPolicy[prop]; - if (value === true) { - this.default = value; - } - else { - this.exceptions[prop] = Array.isArray(value) ? value : [value]; + for (let type of ["merge", "overwrite", "skip", "throw"]) { + if (Array.isArray(conflictPolicy[type])) { + for (let property of conflictPolicy[type]) { + this.exceptions[property] = type; + } } } } @@ -50,24 +73,16 @@ export class ConflictPolicy { } canMerge (property) { - return this.def.merge === true || this.def.merge?.includes?.(property) || false; + return this.merge === true || this.exceptions[property] === "merge"; } static combine (...policies) { return new this({ default: policies.at(-1).default ?? "overwrite", exceptions: policies.filter(Boolean).map(p => new this(p)).reduce((exceptions, policy) => { - for (let prop in policy.exceptions) { - if (exceptions[prop]) { - // Merge exceptions - exceptions[prop] = [...new Set(exceptions[prop].concat(policy.exceptions[prop]))]; - } - else { - exceptions[prop] = policy.exceptions[prop]; - } - } - return exceptions; - }), + return Object.assign(exceptions, policy.exceptions); + }, {}), + merge: policies.at(-1).merge ?? false, }); } } From 63274cf3554ed754d5a88be6ae3b1fb04746fcf6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 16:16:25 -0500 Subject: [PATCH 74/77] Refactor getSuper to always return superclass prototype Simplifies the getSuper function to return only the superclass prototype, removing the ability to fetch a specific property. Updates attachInternals to use the new getSuper signature. --- src/util/attach-internals.js | 2 +- src/util/super.js | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/util/attach-internals.js b/src/util/attach-internals.js index 34b76a3..d1ea071 100644 --- a/src/util/attach-internals.js +++ b/src/util/attach-internals.js @@ -2,7 +2,7 @@ import { getSuper } from "./super.js"; import { internals } from "./symbols.js"; export function attachInternals (thisArg = this) { - let superInternals = getSuper(thisArg, "attachInternals"); + let superInternals = getSuper(thisArg)?.attachInternals; if (!superInternals) { // Method likely not supported diff --git a/src/util/super.js b/src/util/super.js index 3b90867..f6a12d2 100644 --- a/src/util/super.js +++ b/src/util/super.js @@ -32,18 +32,10 @@ export function getSuperclass (Class) { * Get a property from the superclass * Similar to calling `super` in a method, but dynamically bound * @param {object} instance - * @param {string | Symbol} [property] The property to get from super, if any. - * @returns {any} If no property is provided, the superclass prototype is returned. - * If a property is provided, the value of the property is returned. - * E.g. to emulate `super.foo(arg1, arg2)` in a method, use `getSuper(this, "foo").call(this, arg1, arg2)` + * @returns {FunctionConstructor | null} The superclass prototype is returned, or null if no superclass exists. + * E.g. to emulate `super.foo(arg1, arg2)` in a method, use `getSuper(this).foo.call(this, arg1, arg2)` */ -export function getSuper (instance, property) { +export function getSuper (instance) { let Class = instance.constructor; - let superProto = getSuperclass(Class)?.prototype; - - if (!superProto || !property) { - return superProto; - } - - return superProto[property]; + return getSuperclass(Class)?.prototype ?? null; } From e96e9fa07a2fbaa89e4a86e30b74b1f69768c73c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 16:20:59 -0500 Subject: [PATCH 75/77] Always define mixin lifecycle hooks on class itself Avoids accidentally extending mixin lifecycle hooks, a problem spotted by @DmitrySharabin --- src/apply.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/apply.js b/src/apply.js index c40b041..0ee4b30 100644 --- a/src/apply.js +++ b/src/apply.js @@ -2,6 +2,7 @@ import { extendClass } from "./util/extend-class.js"; import { satisfiedBy, mixinsApplied, onApply, conflictPolicy } from "./util/symbols.js"; import lifecycleHooks, { staticLifecycleHooks } from "./lifecycle.js"; import { ConflictPolicy } from "./util/conflict-policy.js"; +import { getSuper } from "./util/super.js"; export function satisfies (Class, requirement) { if (!requirement) { @@ -64,6 +65,24 @@ export function applyMixins (Class = this, mixins = Class.mixins) { return false; } + // Make sure any lifecycle hooks are actually applied + // Otherwise we'd be extending the mixin hooks, what a mess! + for (let lifecycleHook of lifecycleHooks) { + if ( + // Doesn't exist on any mixin + !mixinsToApply.some(Mixin => Mixin.prototype[lifecycleHook]) + + // Or already exists on the class + || Class.prototype[lifecycleHook] + ) { + continue; + } + + Class.prototype[lifecycleHook] = function (...args) { + getSuper(this)?.[lifecycleHook]?.call(this, ...args); + } + } + for (const Mixin of mixinsToApply) { extendClass(Class, Mixin, { skippedProperties: [satisfiedBy, onApply], From e8d826b22d9fa0f5da8ac5579340e661ddb06f85 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 16:44:19 -0500 Subject: [PATCH 76/77] Improve conflict policy handling in class extension Updated ConflictPolicy to support direct exceptions object and refactored extendClass to instantiate ConflictPolicy for both instance and static options. --- src/util/conflict-policy.js | 14 +++++++++----- src/util/extend-class.js | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js index 5e39c50..55a692f 100644 --- a/src/util/conflict-policy.js +++ b/src/util/conflict-policy.js @@ -54,11 +54,15 @@ export class ConflictPolicy { this.default = ["overwrite", "skip", "throw"].find(p => conflictPolicy[p] === true); } - // Object - for (let type of ["merge", "overwrite", "skip", "throw"]) { - if (Array.isArray(conflictPolicy[type])) { - for (let property of conflictPolicy[type]) { - this.exceptions[property] = type; + if (conflictPolicy.exceptions) { + this.exceptions = Object.assign(this.exceptions, conflictPolicy.exceptions); + } + else { + for (let type of ["merge", "overwrite", "skip", "throw"]) { + if (Array.isArray(conflictPolicy[type])) { + for (let property of conflictPolicy[type]) { + this.exceptions[property] = type; + } } } } diff --git a/src/util/extend-class.js b/src/util/extend-class.js index 0a69bc3..986ca17 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -1,5 +1,6 @@ import { extendObject } from "./extend-object.js"; import { getSuperclasses } from "./super.js"; +import { ConflictPolicy } from "./conflict-policy.js"; /** * @import { ConflictPolicySource, ConflictPolicy } from "./conflict-policy.js"; @@ -11,6 +12,7 @@ import { getSuperclasses } from "./super.js"; * @property { Iterable } [skippedProperties = []] - Instance properties to ignore * @property { Iterable } [skippedPropertiesStatic = []] - Static properties to ignore * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicy="overwrite"] + * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicyStatic="overwrite"] * * Use a class as a mixin on another class * @param {Function} Class @@ -32,7 +34,10 @@ export function extendClass (Class, Mixin, options = {}) { } } - let { conflictPolicy, conflictPolicyStatic = conflictPolicy } = options; + let { conflictPolicy, conflictPolicyStatic } = options; + conflictPolicy = new ConflictPolicy(conflictPolicy); + conflictPolicyStatic = new ConflictPolicy(conflictPolicyStatic); + let skippedProperties = ["constructor"].concat(options.skippedProperties || []); let skippedPropertiesStatic = ["prototype"].concat(options.skippedPropertiesStatic || []); From af7a105b043bae0ac44d44f6432319c15eec5ea8 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Nov 2025 16:46:48 -0500 Subject: [PATCH 77/77] Merge arrays and objects too --- src/util/extend-object.js | 17 +++++++---- src/util/merge.js | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/util/merge.js diff --git a/src/util/extend-object.js b/src/util/extend-object.js index b443f36..63592a8 100644 --- a/src/util/extend-object.js +++ b/src/util/extend-object.js @@ -1,5 +1,5 @@ -import { composeFunction } from "./compose-function.js"; import { ConflictPolicy } from "./conflict-policy.js"; +import { canMerge, mergeValues } from "./merge.js"; /** * Copy properties, respecting descriptors @@ -54,18 +54,23 @@ function descriptorEquals (targetDescriptor, sourceDescriptor) { }); } -function canMerge (targetDescriptor, sourceDescriptor) { - // TODO merge objects and arrays - return typeof targetDescriptor.value === "function" && typeof sourceDescriptor.value === "function"; +function canMergeDescriptors (targetDescriptor, sourceDescriptor) { + if (targetDescriptor.get || targetDescriptor.set || sourceDescriptor.get || sourceDescriptor.set) { + // Only merge value properties for now + return false; + } + + return canMerge(targetDescriptor.value, sourceDescriptor.value); } function getMergeDescriptor (targetDescriptor, sourceDescriptor) { - if (!canMerge(targetDescriptor, sourceDescriptor)) { + if (!canMergeDescriptors(targetDescriptor, sourceDescriptor)) { return sourceDescriptor; } + // TODO merge accessors return { - value: composeFunction(targetDescriptor.value, sourceDescriptor.value), + value: mergeValues(targetDescriptor.value, sourceDescriptor.value), writable: targetDescriptor.writable || sourceDescriptor.writable, configurable: targetDescriptor.configurable || sourceDescriptor.configurable, enumerable: targetDescriptor.enumerable || sourceDescriptor.enumerable, diff --git a/src/util/merge.js b/src/util/merge.js new file mode 100644 index 0000000..5603b9c --- /dev/null +++ b/src/util/merge.js @@ -0,0 +1,64 @@ +import { composeFunction } from "./compose-function.js"; + +export function mergeValues (targetValue, sourceValue) { + if (!targetValue || !sourceValue) { + return targetValue || sourceValue; + } + + let mergeType = getMergeType(targetValue, sourceValue); + + switch (mergeType) { + case "function": + return composeFunction(targetValue, sourceValue); + case "array": + return [...targetValue, ...sourceValue]; + case "set": + return new Set([...targetValue, ...sourceValue]); + case "map": + return new Map([...targetValue, ...sourceValue]); + case "object": + return deepMerge(targetValue, sourceValue); + } + + throw new Error(`Cannot merge values`, { cause: { targetValue, sourceValue } }); +} + +export function deepMerge (target, source) { + if (typeof target !== "object" || target === null || typeof source !== "object" || source === null) { + return source ?? target; + } + + let ret = Object.create(target); + + for (let [key, value] of Object.entries(source)) { + ret[key] = deepMerge(ret[key], value); + } + + return ret; +} + +export function canMerge (target, source) { + return !!getMergeType(target, source); +} + +export function getMergeType (target, source) { + if (typeof target === "function" && typeof source === "function") { + return "function"; + } + + if (Array.isArray(target) && Array.isArray(source)) { + return "array"; + } + + if (target instanceof Set && source instanceof Set) { + return "set"; + } + + if (target instanceof Map && source instanceof Map) { + return "map"; + } + + if (typeof target === "object" && typeof source === "object") { + return "object"; + } +}