From 400241414664223597f6ad58b7f6a02da082361c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 8 Dec 2025 17:33:12 -0500 Subject: [PATCH 01/85] Extend lazy properties to support superclasses, define `hooks` that way --- src/Element.js | 15 +++++++++++++-- src/util/lazy.js | 31 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/Element.js b/src/Element.js index eda2420..c6d7389 100644 --- a/src/Element.js +++ b/src/Element.js @@ -7,6 +7,7 @@ import defineFormAssociated from "./form-associated.js"; import defineMixin from "./mixins/define-mixin.js"; import { shadowStyles, globalStyles } from "./styles/index.js"; +import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; const instanceInitialized = Symbol("instanceInitialized"); @@ -45,14 +46,24 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } + static hooks = new Hooks(); + static { + defineLazyProperty(this, "hooks", { + value: this.hooks, + get (hooks) { + return new Hooks(hooks); + }, + configurable: true, + writable: true, + }); + } + static init () { // Stuff that runs once per class if (this[classInitialized]) { return false; } - this.hooks = new Hooks(this.hooks); - if (this.props) { defineProps(this); } diff --git a/src/util/lazy.js b/src/util/lazy.js index 52a1340..da4565b 100644 --- a/src/util/lazy.js +++ b/src/util/lazy.js @@ -4,6 +4,8 @@ * @param {string} name * @param {object | function} options - If function, then it provides the `get` option. * @param {Function} options.get - The getter function + * @param {any} [options.value] - If present, the accessor is never overwritten on the object provided. + * This can be useful for static properties, so that each class instance has its own value. * @param {boolean} [options.writable=true] - Whether the property is writable * @param {boolean} [options.configurable=true] - Whether the property is configurable * @param {boolean} [options.enumerable=false] - Whether the property is enumerable @@ -14,21 +16,32 @@ export function defineLazyProperty (object, name, options) { options = { get: options }; } - let { get, writable = true, configurable = true, enumerable = false } = options; + let { get, value, writable = true, configurable = true, enumerable = false } = options; + let hasValue = "value" in options; + let existingBaseValue = Object.getOwnPropertyDescriptor(object, name)?.value; - let setter = function (value) { - Object.defineProperty(this, name, { value, writable, configurable, enumerable }); - }; Object.defineProperty(object, name, { get () { - let value = get.call(this); - setter.call(this, value); - return value; + let isSameObject = this === object; + if (hasValue && isSameObject) { + return value; + } + + let existingValue = isSameObject ? existingBaseValue : Object.getOwnPropertyDescriptor(this, name)?.value; + let v = get.call(this, existingValue); + Object.defineProperty(this, name, { value: v, writable, configurable, enumerable }); + return v; }, - set (value) { + set (v) { + if (hasValue && this === object) { + value = v; + return; + } + // Blind set - setter.call(this, value); + Object.defineProperty(this, name, { value: v, writable, configurable, enumerable }); }, + enumerable, configurable: true, }); } From e4c3e31e2cf3cae98a59f410790df8f171815d1a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 8 Dec 2025 18:41:56 -0500 Subject: [PATCH 02/85] Refactor hooks, allow any casing for hook name - Allow kebab-case, camelCase OR underscore_case for hook name - Remove unused argument from `add()` - Remove unused signatures from `add()` (multiple callbacks at the same hook or the same callback on multiple hooks) - Remove unised logic from constructor (new Hooks(hooksObj)) - New `Hook` class to manage hook metadata --- src/mixins/hooks.js | 64 +++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index b4b83fc..808acca 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -1,9 +1,8 @@ export default class Hooks { - constructor (hooks) { - if (hooks instanceof Hooks) { - return hooks; - } + /** @type {Record} */ + hooks = {}; + constructor (hooks) { if (hooks) { this.add(hooks); } @@ -11,36 +10,38 @@ export default class Hooks { /** * Schedule one or more callbacks to be executed on one or more hooks - * @param {*} name - * @param {*} callback - * @param {*} first - * @returns + * + * @param {string} name + * @param {function} callback + * @void + * + * @overload + * @param {Record} hooks + * @void */ - add (name, callback, first) { - if (Array.isArray(name)) { - return name.map(name => this.add(name, callback, first)); - } - - if (Array.isArray(callback)) { - return callback.map(callback => this.add(name, callback, first)); + add (name, callback) { + if (!name) { + return; } if (typeof name === "object") { // Adding multiple hooks at once let hooks = name; - first = callback; for (let name in hooks) { - this.add(name, hooks[name], first); + this.add(name, hooks[name]); } return; } - if (callback) { - this[name] ??= []; - this[name][first ? "unshift" : "push"](callback); + if (!callback) { + return; } + + name = Hooks.getCanonicalName(name); + this.hooks[name] ??= new Hook(); + this.hooks[name].add(callback); } /** @@ -49,12 +50,23 @@ export default class Hooks { * @param {object} [env] */ run (name, env) { - if (!this[name]) { - return; - } + name = Hooks.getCanonicalName(name); + this.hooks[name]?.run(env); + } - this[name].forEach(function (callback) { - callback.call(env?.context ?? env, env); - }); + // Allow either camelCase, underscore_case or kebab-case for hook names + static getCanonicalName (name) { + return name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`).replace(/-/g, "_"); } } + +export class Hook extends Set { + + run (env) { + for (let callback of this) { + let context = env?.context ?? env; + callback.call(context, env); + } + } + +} From 3f72e38d87b42d5d036768c054630a4b6baaa50b Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 8 Dec 2025 18:55:11 -0500 Subject: [PATCH 03/85] Support prepending `first_` to a hook to only execute it once per context Unused right now, but the intent is we can replace `init`, `setup` etc that way --- src/mixins/hooks.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index 808acca..00c5118 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -52,6 +52,14 @@ export default class Hooks { run (name, env) { name = Hooks.getCanonicalName(name); this.hooks[name]?.run(env); + + if (name.startsWith("first_")) { + this.hooks[name]?.runOnce(env); + } + else { + this.run("first_" + name, env); + this.hooks[name]?.run(env); + } } // Allow either camelCase, underscore_case or kebab-case for hook names @@ -61,12 +69,35 @@ export default class Hooks { } export class Hook extends Set { + /** + * Track which contexts the hook has been run on so far + * @type {WeakSet} + */ + contexts = new WeakSet(); run (env) { for (let callback of this) { let context = env?.context ?? env; callback.call(context, env); + this.contexts.add(context); } } + /** + * Like run(), but only executes the callback once per context + * @param {*} env + */ + runOnce (env) { + for (let callback of this) { + let context = env?.context ?? env; + + if (this.contexts.has(context)) { + continue; + } + + callback.call(context, env); + // TODO what about callbacks added after this? + this.contexts.add(context); + } + } } From 12d95db8da23ab91aed2839ef32d63aa4c8fb933 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 9 Dec 2025 11:33:48 -0500 Subject: [PATCH 04/85] Add `@overload` Co-authored-by: Dmitry Sharabin --- src/mixins/hooks.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index 00c5118..e675379 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -11,6 +11,7 @@ export default class Hooks { /** * Schedule one or more callbacks to be executed on one or more hooks * + * @overload * @param {string} name * @param {function} callback * @void From ebcfde604ce7e0bdbf10a657d79d6d64fed7a77f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 9 Dec 2025 11:35:59 -0500 Subject: [PATCH 05/85] Remove unnecessary line Co-Authored-By: Dmitry Sharabin --- src/mixins/hooks.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index e675379..8d4e171 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -52,7 +52,6 @@ export default class Hooks { */ run (name, env) { name = Hooks.getCanonicalName(name); - this.hooks[name]?.run(env); if (name.startsWith("first_")) { this.hooks[name]?.runOnce(env); From ca0769f7c52147afba3a609273c8e53be5a43654 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 9 Dec 2025 15:34:34 -0500 Subject: [PATCH 06/85] Move CSS-specific utils back inside `/styles` Co-Authored-By: Dmitry Sharabin --- src/styles/global.js | 4 +--- src/styles/shadow.js | 2 +- src/styles/util.js | 3 +++ src/{ => styles}/util/adopt-css.js | 0 src/{ => styles}/util/fetch-css.js | 0 5 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 src/styles/util.js rename src/{ => styles}/util/adopt-css.js (100%) rename src/{ => styles}/util/fetch-css.js (100%) diff --git a/src/styles/global.js b/src/styles/global.js index 2ce4f37..ca18f92 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -1,9 +1,7 @@ /** * Mixin for adding light DOM styles */ -import { getSupers } from "../util/get-supers.js"; -import { adoptCSS } from "../util/adopt-css.js"; -import { fetchCSS } from "../util/fetch-css.js"; +import { getSupers, adoptCSS, fetchCSS } from "./util.js"; export default { prepare () { diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 58de46e..1a65021 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 } from "./util.js"; +import { getSupers, adoptCSS, fetchCSS } from "./util.js"; export default { setup () { diff --git a/src/styles/util.js b/src/styles/util.js new file mode 100644 index 0000000..0d39ffb --- /dev/null +++ b/src/styles/util.js @@ -0,0 +1,3 @@ +export { adoptCSS } from "./util/adopt-css.js"; +export { fetchCSS } from "./util/fetch-css.js"; +export { getSupers } from "../util/get-supers.js"; diff --git a/src/util/adopt-css.js b/src/styles/util/adopt-css.js similarity index 100% rename from src/util/adopt-css.js rename to src/styles/util/adopt-css.js diff --git a/src/util/fetch-css.js b/src/styles/util/fetch-css.js similarity index 100% rename from src/util/fetch-css.js rename to src/styles/util/fetch-css.js From f7e0f63f0767073d1416e168c7c0c73785da4cf6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 9 Dec 2025 15:35:48 -0500 Subject: [PATCH 07/85] Move unused utils to `util/unused` --- src/util.js | 4 ---- src/util/{ => unused}/compose-functions.js | 0 src/util/{ => unused}/copy-properties.js | 2 +- src/util/{ => unused}/extend-class.js | 0 src/util/{ => unused}/reversible-map.js | 0 5 files changed, 1 insertion(+), 5 deletions(-) rename src/util/{ => unused}/compose-functions.js (100%) rename src/util/{ => unused}/copy-properties.js (97%) rename src/util/{ => unused}/extend-class.js (100%) rename src/util/{ => unused}/reversible-map.js (100%) diff --git a/src/util.js b/src/util.js index b73ad95..980846f 100644 --- a/src/util.js +++ b/src/util.js @@ -2,8 +2,4 @@ export * from "./util/resolve-value.js"; 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/compose-functions.js"; -export * from "./util/reversible-map.js"; export * from "./util/pick.js"; diff --git a/src/util/compose-functions.js b/src/util/unused/compose-functions.js similarity index 100% rename from src/util/compose-functions.js rename to src/util/unused/compose-functions.js diff --git a/src/util/copy-properties.js b/src/util/unused/copy-properties.js similarity index 97% rename from src/util/copy-properties.js rename to src/util/unused/copy-properties.js index c220d10..9b8b5d5 100644 --- a/src/util/copy-properties.js +++ b/src/util/unused/copy-properties.js @@ -1,4 +1,4 @@ -import { composeFunctions } from "./compose-functions.js"; +import { composeFunctions } from "./unused/compose-functions.js"; /** * @typedef CopyPropertiesOptions diff --git a/src/util/extend-class.js b/src/util/unused/extend-class.js similarity index 100% rename from src/util/extend-class.js rename to src/util/unused/extend-class.js diff --git a/src/util/reversible-map.js b/src/util/unused/reversible-map.js similarity index 100% rename from src/util/reversible-map.js rename to src/util/unused/reversible-map.js From c6d012a9ce37a252bec1e0c865c1f62c7d825dfe Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 9 Dec 2025 23:59:04 -0500 Subject: [PATCH 08/85] (WIP) rewrite --- src/Element.js | 90 +++++++++++++------ src/events/defineEvents.js | 163 ++++------------------------------- src/events/onprops.js | 70 +++++++++++++++ src/events/propchange.js | 47 ++++++++++ src/events/retarget.js | 59 +++++++++++++ src/mixins/define-mixin.js | 2 +- src/mixins/hooks.js | 35 +++++--- src/props/defineProps.js | 107 +++++++++++++++-------- src/slots/slot-controller.js | 112 ++++++++++++++++++++++++ src/slots/slots.js | 133 ++++++---------------------- src/styles/global.js | 21 +++-- src/styles/shadow.js | 18 ++-- src/util/composed.js | 22 +++++ src/util/delegate.js | 28 ++++++ src/util/symbols.js | 28 ++++++ 15 files changed, 590 insertions(+), 345 deletions(-) create mode 100644 src/events/onprops.js create mode 100644 src/events/propchange.js create mode 100644 src/events/retarget.js create mode 100644 src/slots/slot-controller.js create mode 100644 src/util/composed.js create mode 100644 src/util/delegate.js create mode 100644 src/util/symbols.js diff --git a/src/Element.js b/src/Element.js index c6d7389..3f6e62a 100644 --- a/src/Element.js +++ b/src/Element.js @@ -4,11 +4,13 @@ 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 { shadowStyles, globalStyles } from "./styles/index.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; +import { internals, initialized, newKnownSymbols } from "./symbols.js"; + +const { plugins } = newKnownSymbols; const instanceInitialized = Symbol("instanceInitialized"); const classInitialized = Symbol("classInitialized"); @@ -17,11 +19,8 @@ const Self = class NudeElement extends HTMLElement { constructor () { super(); - if (!this.constructor[classInitialized]) { - this.constructor.init(); - } - - this.constructor.hooks.run("start", this); + this.constructor.hooks.run("constructor-static", this.constructor); + this.constructor.hooks.run("constructor", this); if (this.propChangedCallback && this.constructor.props) { this.addEventListener("propchange", this.propChangedCallback); @@ -32,13 +31,7 @@ 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("first_connected", this); this.constructor.hooks.run("connected", this); } @@ -46,6 +39,14 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } + attachInternals () { + if (this[internals]) { + return this[internals]; + } + + return this[internals] = super.attachInternals(); + } + static hooks = new Hooks(); static { defineLazyProperty(this, "hooks", { @@ -64,10 +65,6 @@ const Self = class NudeElement extends HTMLElement { return false; } - if (this.props) { - defineProps(this); - } - if (this.events) { defineEvents(this); } @@ -76,22 +73,65 @@ const Self = class NudeElement extends HTMLElement { defineFormAssociated(this); } - if (this.styles) { - defineMixin(this, shadowStyles); + this.hooks.run("setup", this); + + return (this[classInitialized] = true); + } + + /** + * Like super, but dynamic + */ + get super () { + return this.constructor.super?.prototype; + } + + /** + * Like super, but dynamic + */ + static get super () { + let Super = Object.getPrototypeOf(this); + return Super === Function.prototype ? null : Super; + } + + static hasPlugin (plugin) { + if (!Object.hasOwn(this, plugins)) { + return false; + } + + return this[plugins].has(plugin) || this.super?.hasPlugin?.(plugin); + } + + static addPlugin (plugin) { + if (this.hasPlugin(plugin)) { + return; } - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; + if (!Object.hasOwn(this, plugins)) { + this[plugins] = new Set(); } - if (this.globalStyles) { - defineMixin(this, globalStyles); + if (plugin.members) { + extend(this, plugin.members); } - this.hooks.run("setup", this); + if (plugin.membersStatic) { + extend(this, plugin.membersStatic); + } - return (this[classInitialized] = true); + plugin.hooks.add(this.hooks); + + plugin.setup?.call(this); + } + + static { + this.addPlugin(defineProps); + this.addPlugin(shadowStyles); + this.addPlugin(globalStyles); } }; export default Self; + +function extend (base, plugin) { + Object.defineProperties(base, Object.getOwnPropertyDescriptors(plugin)); +} diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 835595b..e755947 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -1,152 +1,25 @@ import defineProps 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 * as propchange from "./propchange.js"; +import * as onprops from "./onprops.js"; +import * as retarget from "./retarget.js"; -/** - * - * @param {Function} Class - * @param {string} name Event name - * @param {object} options - * @returns - */ -function retargetEvent (name, from) { - if (typeof from === "function") { - from = { on: from }; - } +import getSymbols from "../util/symbols.js"; +const { events } = getSymbols; - let type = from?.type ?? name; +export function setup () { + this.addPlugin(defineProps); - return function init () { - // Event is a subset of another event (either on this element or other element(s)) - let target = resolveValue(from?.on, [this]) ?? this; - let host = this; - let listener = event => { - if (!from.when || from.when(event)) { - let EventConstructor = from.event ?? event.constructor; - let source = from.constructor - ? // Construct specific event object - pick(event, ["bubbles", "cancelable", "composed", "detail"]) - : // Retarget this event - event; - let options = Object.assign({}, source, from.options); - - let newEvent = new EventConstructor(name, options); - host.dispatchEvent(newEvent); - } - }; - - if (Array.isArray(target)) { - for (let t of target) { - t.addEventListener(type, listener); - } - } - else { - target.addEventListener(type, listener); - } - }; + // TODO decouple these from core event functionality + this.addPlugin(propchange); + this.addPlugin(onprops); + this.addPlugin(retarget); } -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); - - ret.setup.push(function setup () { - for (let eventName in propchange) { - let propName = propchange[eventName]; - let prop = Class.props.get(propName); - - if (prop) { - (prop.eventNames ??= []).push(eventName); - } - else { - 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, - }, - }, - ]); - - if (eventProps.length > 0) { - eventProps = Object.fromEntries(eventProps); - defineProps(Class, eventProps); +export const membersStatic = { + defineEvents (def = this[events] ?? this.events) { + this[events] ??= {}; + Object.assign(this[events], def); - 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 (value !== undefined) { - Class.props.firePropChangeEvent(this, eventName, { - name: propName, - prop: Class.props.get(propName), - }); - } - } - - // 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; - - if (change.oldInternalValue) { - this.removeEventListener(eventName, change.oldInternalValue); - } - - 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); - } - } - } - - return defineMixin(Class, ret); -} + this.hooks.run("define-events", {context: this, events: def}); + }, +}; diff --git a/src/events/onprops.js b/src/events/onprops.js new file mode 100644 index 0000000..37ec1ba --- /dev/null +++ b/src/events/onprops.js @@ -0,0 +1,70 @@ + +/** + * Add on* props for UI events, just like native UI events + */ + +import getSymbols from "../util/symbols.js"; +const { eventProps } = getSymbols; + +export const hooks = { + defineEvents (env) { + let def = env.events; + + let eventPropsArray = Object.keys(def) + // Is not a native event (e.g. input) + .filter(name => !("on" + name in this.prototype)) + .map(name => [ + "on" + name, + { + type: { + is: Function, + arguments: ["event"], + }, + reflect: { + from: true, + }, + }, + ]); + + if (eventPropsArray.length > 0) { + this[eventProps] = Object.fromEntries(eventPropsArray); + this.defineProps(this[eventProps]); + + this.hooks.add("first-connected", function firstConnected () { + + }); + } + }, + + first_connected () { + // Deal with existing values + if (!this[eventProps]) { + return; + } + + for (let name in this[eventProps]) { + let value = this[name]; + if (typeof value === "function") { + let eventName = name.slice(2); + this.addEventListener(eventName, value); + } + } + + // 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); + } + } + }); + }, +}; diff --git a/src/events/propchange.js b/src/events/propchange.js new file mode 100644 index 0000000..f147d4c --- /dev/null +++ b/src/events/propchange.js @@ -0,0 +1,47 @@ +/** + * Implement propchange events: + * Events that fire when a specific prop changes + */ + +import getSymbols from "../util/symbols.js"; +const { propchange } = getSymbols; + +export const hooks = { + first_constructor_static () { + let propchangeEvents = Object.entries(this[events]) + .filter(([name, options]) => options.propchange) + .map(([eventName, options]) => [eventName, options.propchange]); + + if (propchangeEvents.length > 0) { + // Shortcut for events that fire when a specific prop changes + this[propchange] = Object.fromEntries(propchangeEvents); + + for (let eventName in this[propchange]) { + let propName = this[propchange][eventName]; + let prop = this.props.get(propName); + + if (prop) { + (prop.eventNames ??= []).push(eventName); + } + else { + throw new TypeError(`No prop named ${propName} in ${this.name}`); + } + } + } + }, + + first_connected () { + // Often propchange events have already fired by the time the event handlers are added + for (let eventName in this[propchange]) { + let propName = this[propchange][eventName]; + let value = this[propName]; + + if (value !== undefined) { + this.props.firePropChangeEvent(this, eventName, { + name: propName, + prop: this.props.get(propName), + }); + } + } + }, +}; diff --git a/src/events/retarget.js b/src/events/retarget.js new file mode 100644 index 0000000..bdd0839 --- /dev/null +++ b/src/events/retarget.js @@ -0,0 +1,59 @@ +/** + * Retarget events from internal elements to the host + */ + +import { resolveValue } from "../util.js"; +import { pick } from "../util/pick.js"; +import getSymbols from "../util/symbols.js"; +const { events } = getSymbols; + +export const hooks = { + first_connected () { + if (!this[events]) { + return; + } + + for (let [name, options] of Object.entries(this[events])) { + let { from } = options; + + if (!from) { + continue; + } + + if (typeof from === "function") { + from = { on: from }; + } + + let type = from?.type ?? name; + + // Event is a subset of another event (either on this element or other element(s)) + let target = resolveValue(from?.on, [this]) ?? this; + let host = this; + let listener = event => { + if (!from.when || from.when(event)) { + let EventConstructor = from.event ?? event.constructor; + let source = from.constructor + ? // Construct specific event object + pick(event, ["bubbles", "cancelable", "composed", "detail"]) + : // Retarget this event + event; + let options = Object.assign({}, source, from.options); + + let newEvent = new EventConstructor(name, options); + host.dispatchEvent(newEvent); + } + }; + + if (Array.isArray(target)) { + for (let t of target) { + t.addEventListener(type, listener); + } + } + else { + target.addEventListener(type, listener); + } + + } + + }, +}; diff --git a/src/mixins/define-mixin.js b/src/mixins/define-mixin.js index c424667..f082e49 100644 --- a/src/mixins/define-mixin.js +++ b/src/mixins/define-mixin.js @@ -6,7 +6,7 @@ export default function defineMixin (Class, config) { return config.map(f => defineMixin(Class, f)); } - config = typeof config === "function" ? { init: config } : config; + config = typeof config === "function" ? { firstConnected: config } : config; let { properties, prepare, ...hooks } = config; if (properties) { diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index 8d4e171..83054fe 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -25,24 +25,31 @@ export default class Hooks { return; } - if (typeof name === "object") { - // Adding multiple hooks at once - let hooks = name; + if (Array.isArray(name)) { + // Same callbacks for multiple hooks + // Or multiple objects + name.map(name => this.add(name, callback)); + } + else if (!callback) { + if (typeof name === "object") { + // Adding multiple hooks at once + let hooks = name; - for (let name in hooks) { - this.add(name, hooks[name]); + for (let name in hooks) { + this.add(name, hooks[name]); + } } - - return; } - - if (!callback) { - return; + else if (Array.isArray(callback)) { + // Multiple callbacks for a single hook + callback.map(callback => this.add(name, callback)); + } + else { + // Single hook, single callback + name = Hooks.getCanonicalName(name); + this.hooks[name] ??= new Hook(); + this.hooks[name].add(callback); } - - name = Hooks.getCanonicalName(name); - this.hooks[name] ??= new Hook(); - this.hooks[name].add(callback); } /** diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 2077fc9..0614f44 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -1,50 +1,85 @@ import Props from "./Props.js"; -import defineMixin from "../mixins/define-mixin.js"; +import { newKnownSymbols } from "../util/symbols.js"; +// import { composed } from "../util/composed.js"; -let propsSymbol = Symbol("propsSymbol"); +let { props } = newKnownSymbols; -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) { +function first_constructor_static () { + // TODO how does this work if attributeChangedCallback is inherited? + let _attributeChangedCallback = this.prototype.attributeChangedCallback; + this.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", { + if (!Object.hasOwn(this, "observedAttributes")) { + Object.defineProperty(this, "observedAttributes", { get: () => this.props.observedAttributes, configurable: true, }); } - return defineMixin(Class, { - init () { - this.constructor.props.initializeFor(this); - }, - properties: { - // Internal prop values - props () { - return {}; - }, - // Ignore mutations on these attributes - ignoredAttributes () { - return new Set(); - }, - }, - }); + if (this.props) { + this.defineProps(this); + } } + +export const hooks = { + constructor () { + if (this.propChangedCallback && this.constructor.props) { + this.addEventListener("propchange", this.propChangedCallback); + } + }, + + first_constructor_static, + + first_connected () { + this.constructor.props.initializeFor(this); + }, +}; + +export const members = { + // Internal prop values + props () { + return {}; + }, + // Ignore mutations on these attributes + ignoredAttributes () { + return new Set(); + }, + + // ...composed({ + // attributeChangedCallback (name, oldValue, value) { + // this.constructor.props.attributeChanged(this, name, oldValue, value); + // }, + // }), +}; + +export const membersStatic = { + defineProps (def = this[props] ?? this.props) { + if (def instanceof Props && def.Class === this) { + // Already defined + return null; + } + + // TODO move to symbol for Props object too? + if (this.props instanceof Props) { + // Props already defined, add these props to it + this.props.add(def); + return; + } + + // First time defining props + this[props] = this.props; + this.props = new Props(this, def); + }, + + // ...composed({ + // get observedAttributes () { + // let thisProps = this.props.observedAttributes ?? []; + // let superProps = this.super?.observedAttributes ?? []; + // return [...superProps, ...thisProps]; + // }, + // }), +}; diff --git a/src/slots/slot-controller.js b/src/slots/slot-controller.js new file mode 100644 index 0000000..237d6d4 --- /dev/null +++ b/src/slots/slot-controller.js @@ -0,0 +1,112 @@ +/** + * 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; + + // TODO this should be a slot property + // Unused for now + 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..67dc630 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,118 +1,35 @@ -import SlotObserver from "./slot-observer.js"; +import SlotController from "./slot-controller.js"; +import newSymbols from "../util/symbols.js"; -/** - * Slots data structure - * Gives element classes a this._slots data structure that allows easy access to named slots - */ +const { slots } = newSymbols; -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; - } - - 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; +export const hooks = { + constructor () { + if (!this.constructor[slots]) { + return; } - } - - /** 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]; + this[slots] = new SlotController(this); + }, + firstConnected () { + if (!this.constructor[slots]) { + return; } - } - /** Slot removed */ - removed (slot, name = slot.name ?? "") { - removeArrayItem(this.#all[name], slot); - this[name] = this.#all[name][0]; + this[slots].init(); + }, +}; - if (!this[name]) { - delete this[name]; +export const membersStatic = { + defineSlots (def = this[slots] ?? this.slots) { + if (Array.isArray(def)) { + // Just slot names, no options + def = Object.fromEntries(def.map(name => [name, {}])); } - } - - /** 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(); + this[slots] ??= {}; + Object.assign(this[slots], def); - if (dynamicSlots) { - slots.observe(); - } - }, - }; -} + this.hooks.run("define-slots", {context: this, slots: def}); + }, +}; diff --git a/src/styles/global.js b/src/styles/global.js index ca18f92..adc3394 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -2,9 +2,12 @@ * Mixin for adding light DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; +import getSymbols from "../util/symbols.js"; -export default { - prepare () { +const { fetchedGlobalStyles, roots } = getSymbols; + +export const hooks = { + first_constructor_static () { if (!this.globalStyles) { return; } @@ -15,13 +18,13 @@ export default { for (let Class of supers) { if ( Object.hasOwn(Class, "globalStyles") && - !Object.hasOwn(Class, "fetchedGlobalStyles") + !Object.hasOwn(Class, fetchedGlobalStyles) ) { // Initiate fetching when the first element is constructed - let styles = (Class.fetchedGlobalStyles = Array.isArray(Class.globalStyles) + let styles = (Class[fetchedGlobalStyles] = Array.isArray(Class.globalStyles) ? Class.globalStyles.slice() : [Class.globalStyles]); - Class.roots = new WeakSet(); + Class[roots] = new WeakSet(); for (let i = 0; i < styles.length; i++) { styles[i] = fetchCSS(styles[i], Class.url); @@ -33,11 +36,11 @@ export default { async connected () { 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 @@ -54,9 +57,9 @@ export default { root = root.host ?? root; root = root.getRootNode(); - if (!Self.roots.has(root)) { + if (!Self[roots].has(root)) { adoptCSS(css, root); - Self.roots.add(root); + Self[roots].add(root); } } while (root && root.nodeType !== Node.DOCUMENT_NODE); } diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 1a65021..876c898 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -2,9 +2,12 @@ * Mixin for adding shadow DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; +import getSymbols from "../util/symbols.js"; -export default { - setup () { +const { fetchedStyles } = getSymbols; + +export const hooks = { + first_constructor_static () { if (!this.styles) { return; } @@ -12,9 +15,9 @@ export default { let supers = getSupers(this, HTMLElement); 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) + let styles = (Class[fetchedStyles] = Array.isArray(Class.styles) ? Class.styles.slice() : [Class.styles]); @@ -24,7 +27,8 @@ export default { } } }, - async init () { + + async first_connected () { if (!this.shadowRoot) { return; } @@ -33,8 +37,8 @@ export default { 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) { css = await css; } diff --git a/src/util/composed.js b/src/util/composed.js new file mode 100644 index 0000000..796b19c --- /dev/null +++ b/src/util/composed.js @@ -0,0 +1,22 @@ +import { newKnownSymbols } from "./symbols.js"; + +let { composed, constituents } = newKnownSymbols; + +export default function (value) { + if (!value || typeof value !== "object" && typeof value !== "function") { + return value; + } + + value[composed] = true; + return value; +} + + +export function compose (...values) { + // Base values are last one wins + let baseValue = values.filter(v => !v[composed]).at(-1); + baseValue[constituents] ??= []; + baseValue[constituents].push(...values.filter(v => v[composed])); + + +} diff --git a/src/util/delegate.js b/src/util/delegate.js new file mode 100644 index 0000000..77ead7a --- /dev/null +++ b/src/util/delegate.js @@ -0,0 +1,28 @@ +/** + * 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 () { + return this[to][prop]; + }, + ...sourceDescriptor, + configurable: true, + }; + + if (sourceDescriptor.writable || sourceDescriptor.set) { + descriptor.set = function (value) { + this[to][prop] = value; + }; + } + + Object.defineProperty(from, prop, descriptor); + } +} diff --git a/src/util/symbols.js b/src/util/symbols.js new file mode 100644 index 0000000..bd9c081 --- /dev/null +++ b/src/util/symbols.js @@ -0,0 +1,28 @@ +const newSymbols = new Proxy({}, { + get (target, prop) { + if (typeof prop === "string") { + return Symbol(prop); + } + + return target[prop]; + }, +}); + +export { newSymbols }; +export default newSymbols; + +// Known symbols +export const { internals, initialized } = newSymbols; +export const KNOWN_SYMBOLS = { internals, initialized }; + +export const newKnownSymbols = new Proxy({}, { + get (target, prop) { + if (typeof prop === "string") { + let ret = Symbol(prop); + KNOWN_SYMBOLS[prop] = ret; + return ret; + } + + return target[prop]; + }, +}); From 85cc3d4d9dcd425404e0090de775c5f6ec9fd5b1 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 09:07:08 -0500 Subject: [PATCH 09/85] Update src/events/defineEvents.js Co-authored-by: Dmitry Sharabin --- src/events/defineEvents.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index e755947..e3fc8ce 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -1,4 +1,4 @@ -import defineProps from "../props/defineProps.js"; +import * as defineProps from "../props/defineProps.js"; import * as propchange from "./propchange.js"; import * as onprops from "./onprops.js"; import * as retarget from "./retarget.js"; From 1068aa20c0dd5b7fa9cd8ab077a72752d0b94663 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 09:07:16 -0500 Subject: [PATCH 10/85] Update src/Element.js Co-authored-by: Dmitry Sharabin --- src/Element.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index 3f6e62a..2fe8b0d 100644 --- a/src/Element.js +++ b/src/Element.js @@ -1,8 +1,8 @@ /** * Base class for all elements */ -import defineProps from "./props/defineProps.js"; -import defineEvents from "./events/defineEvents.js"; +import * as defineProps from "./props/defineProps.js"; +import * as defineEvents from "./events/defineEvents.js"; import defineFormAssociated from "./form-associated.js"; import { shadowStyles, globalStyles } from "./styles/index.js"; From b3b80e7d9373aaee7997efd75558d6c5f55eafbd Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 09:21:24 -0500 Subject: [PATCH 11/85] Apply suggestions from code review Co-authored-by: Dmitry Sharabin --- src/Element.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index 2fe8b0d..3e6780d 100644 --- a/src/Element.js +++ b/src/Element.js @@ -8,7 +8,7 @@ import defineFormAssociated from "./form-associated.js"; import { shadowStyles, globalStyles } from "./styles/index.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; -import { internals, initialized, newKnownSymbols } from "./symbols.js"; +import { internals, initialized, newKnownSymbols } from "./util/symbols.js"; const { plugins } = newKnownSymbols; @@ -118,7 +118,7 @@ const Self = class NudeElement extends HTMLElement { extend(this, plugin.membersStatic); } - plugin.hooks.add(this.hooks); + this.hooks.add(plugin.hooks); plugin.setup?.call(this); } From 3aaf8938c001376bcf7d14033484209c5e6b792a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 10:00:40 -0500 Subject: [PATCH 12/85] Update src/util/unused/copy-properties.js Co-authored-by: Dmitry Sharabin --- src/util/unused/copy-properties.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/unused/copy-properties.js b/src/util/unused/copy-properties.js index 9b8b5d5..c220d10 100644 --- a/src/util/unused/copy-properties.js +++ b/src/util/unused/copy-properties.js @@ -1,4 +1,4 @@ -import { composeFunctions } from "./unused/compose-functions.js"; +import { composeFunctions } from "./compose-functions.js"; /** * @typedef CopyPropertiesOptions From 247c81a4c6798fe4ab87de6c7ba3607568662d37 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 10:29:00 -0500 Subject: [PATCH 13/85] Ensure `constructed` hook always runs before connectedCallback --- src/Element.js | 12 ++++++++++-- src/mixins/hooks.js | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index 3f6e62a..2435dde 100644 --- a/src/Element.js +++ b/src/Element.js @@ -27,11 +27,19 @@ const Self = class NudeElement extends HTMLElement { } // 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)); + Promise.resolve().then(() => { + if (!this.constructor.hooks.hasRun("constructed")) { + this.constructor.hooks.run("constructed", this); + } + }); } connectedCallback () { - this.constructor.hooks.run("first_connected", this); + if (!this.constructor.hooks.hasRun("constructed")) { + // If the element starts off connected, this will fire *before* the microtask + this.constructor.hooks.run("constructed", this); + } + this.constructor.hooks.run("connected", this); } diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index 83054fe..df538a0 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -2,6 +2,8 @@ export default class Hooks { /** @type {Record} */ hooks = {}; + ran = new Set(); + constructor (hooks) { if (hooks) { this.add(hooks); @@ -59,6 +61,7 @@ export default class Hooks { */ run (name, env) { name = Hooks.getCanonicalName(name); + this.ran.add(name); if (name.startsWith("first_")) { this.hooks[name]?.runOnce(env); @@ -69,6 +72,11 @@ export default class Hooks { } } + hasRun (name) { + name = Hooks.getCanonicalName(name); + return this.ran.has(name); + } + // Allow either camelCase, underscore_case or kebab-case for hook names static getCanonicalName (name) { return name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`).replace(/-/g, "_"); From 6ddfb02ea4cfc14176af4fb7972a782bb4d35ca7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 11:05:39 -0500 Subject: [PATCH 14/85] Return known symbol if one exists --- src/util/symbols.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/util/symbols.js b/src/util/symbols.js index bd9c081..e6e854d 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -18,6 +18,10 @@ export const KNOWN_SYMBOLS = { internals, initialized }; export const newKnownSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { + if (KNOWN_SYMBOLS[prop]) { + return KNOWN_SYMBOLS[prop]; + } + let ret = Symbol(prop); KNOWN_SYMBOLS[prop] = ret; return ret; From d80694fcec887874fd01df83f6c8397a983f5fcd Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 10 Dec 2025 17:27:42 +0100 Subject: [PATCH 15/85] Some fixes we are agreed on (#67) --- src/events/defineEvents.js | 4 ++-- src/events/onprops.js | 4 ++-- src/events/propchange.js | 3 ++- src/events/retarget.js | 4 ++-- src/mixins/hooks.js | 8 ++++++-- src/styles/global.js | 4 ++-- src/styles/index.js | 4 ++-- src/styles/shadow.js | 4 ++-- 8 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index e3fc8ce..9d21057 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -3,8 +3,8 @@ import * as propchange from "./propchange.js"; import * as onprops from "./onprops.js"; import * as retarget from "./retarget.js"; -import getSymbols from "../util/symbols.js"; -const { events } = getSymbols; +import { newKnownSymbols } from "../util/symbols.js"; +const { events } = newKnownSymbols; export function setup () { this.addPlugin(defineProps); diff --git a/src/events/onprops.js b/src/events/onprops.js index 37ec1ba..8904082 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -3,8 +3,8 @@ * Add on* props for UI events, just like native UI events */ -import getSymbols from "../util/symbols.js"; -const { eventProps } = getSymbols; +import newSymbols from "../util/symbols.js"; +const { eventProps } = newSymbols; export const hooks = { defineEvents (env) { diff --git a/src/events/propchange.js b/src/events/propchange.js index f147d4c..6c1a6aa 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -3,8 +3,9 @@ * Events that fire when a specific prop changes */ -import getSymbols from "../util/symbols.js"; +import getSymbols, { newKnownSymbols } from "../util/symbols.js"; const { propchange } = getSymbols; +const { events } = newKnownSymbols; export const hooks = { first_constructor_static () { diff --git a/src/events/retarget.js b/src/events/retarget.js index bdd0839..4f3d260 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -4,8 +4,8 @@ import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import getSymbols from "../util/symbols.js"; -const { events } = getSymbols; +import newSymbols from "../util/symbols.js"; +const { events } = newSymbols; export const hooks = { first_connected () { diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index df538a0..0f1c026 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -30,7 +30,9 @@ export default class Hooks { if (Array.isArray(name)) { // Same callbacks for multiple hooks // Or multiple objects - name.map(name => this.add(name, callback)); + for (let hook of name) { + this.add(hook, callback); + } } else if (!callback) { if (typeof name === "object") { @@ -44,7 +46,9 @@ export default class Hooks { } else if (Array.isArray(callback)) { // Multiple callbacks for a single hook - callback.map(callback => this.add(name, callback)); + for (let cb of callback) { + this.add(name, cb); + } } else { // Single hook, single callback diff --git a/src/styles/global.js b/src/styles/global.js index adc3394..fccfbce 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -2,9 +2,9 @@ * Mixin for adding light DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; -import getSymbols from "../util/symbols.js"; +import newSymbols from "../util/symbols.js"; -const { fetchedGlobalStyles, roots } = getSymbols; +const { fetchedGlobalStyles, roots } = newSymbols; export const hooks = { first_constructor_static () { diff --git a/src/styles/index.js b/src/styles/index.js index ee0f843..81dda88 100644 --- a/src/styles/index.js +++ b/src/styles/index.js @@ -1,2 +1,2 @@ -export { default as shadowStyles } from "./shadow.js"; -export { default as globalStyles } from "./global.js"; +export * as shadowStyles from "./shadow.js"; +export * as globalStyles from "./global.js"; diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 876c898..0e9d86a 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -2,9 +2,9 @@ * Mixin for adding shadow DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; -import getSymbols from "../util/symbols.js"; +import newSymbols from "../util/symbols.js"; -const { fetchedStyles } = getSymbols; +const { fetchedStyles } = newSymbols; export const hooks = { first_constructor_static () { From 23a8caab78591fc1b2e6141cbee263d2505eb4cf Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 13:24:46 -0500 Subject: [PATCH 16/85] Form behavior mixin - Port formAssociated to the new syntax, - Split to three - Rename to "form behavior" --- index.js | 2 +- src/Element.js | 7 ++-- src/form-associated.js | 55 ----------------------------- src/form-behavior/base.js | 41 ++++++++++++++++++++++ src/form-behavior/delegate.js | 29 +++++++++++++++ src/form-behavior/index.js | 9 +++++ src/form-behavior/like.js | 29 +++++++++++++++ src/form-behavior/role.js | 66 +++++++++++++++++++++++++++++++++++ src/index.js | 2 +- 9 files changed, 180 insertions(+), 60 deletions(-) delete mode 100644 src/form-associated.js create mode 100644 src/form-behavior/base.js create mode 100644 src/form-behavior/delegate.js create mode 100644 src/form-behavior/index.js create mode 100644 src/form-behavior/like.js create mode 100644 src/form-behavior/role.js diff --git a/index.js b/index.js index 5db4c84..c1c38d6 100644 --- a/index.js +++ b/index.js @@ -3,4 +3,4 @@ export { default as Props } from "./src/props/Props.js"; export { default as Prop } from "./src/props/Prop.js"; export { default as PropChangeEvent } from "./src/props/PropChangeEvent.js"; export { default as defineEvents } from "./src/events/defineEvents.js"; -export { default as defineFormAssociated } from "./src/formAssociated.js/defineFormAssociated.js"; +export { default as defineFormBehavior } from "./src/formBehavior.js/defineFormBehavior.js"; diff --git a/src/Element.js b/src/Element.js index 463450b..31b77bd 100644 --- a/src/Element.js +++ b/src/Element.js @@ -1,9 +1,10 @@ /** * Base class for all elements */ + import * as defineProps from "./props/defineProps.js"; import * as defineEvents from "./events/defineEvents.js"; -import defineFormAssociated from "./form-associated.js"; +import defineFormBehavior from "./form-behavior/index.js"; import { shadowStyles, globalStyles } from "./styles/index.js"; import { defineLazyProperty } from "./util/lazy.js"; @@ -77,8 +78,8 @@ const Self = class NudeElement extends HTMLElement { defineEvents(this); } - if (this.formAssociated) { - defineFormAssociated(this); + if (this.formAssociated || this.formBehavior) { + defineFormBehavior(this); } this.hooks.run("setup", this); diff --git a/src/form-associated.js b/src/form-associated.js deleted file mode 100644 index bc38366..0000000 --- a/src/form-associated.js +++ /dev/null @@ -1,55 +0,0 @@ -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, -) { - if (HTMLElement.prototype.attachInternals === undefined) { - // Not supported - return; - } - - for (let prop of getters) { - Object.defineProperty(Class.prototype, prop, { - get () { - return this[internalsProp][prop]; - }, - enumerable: true, - }); - } - - 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])); - } - }); -} diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js new file mode 100644 index 0000000..b616a25 --- /dev/null +++ b/src/form-behavior/base.js @@ -0,0 +1,41 @@ + + +import { internals, newKnownSymbols } from "../util/symbols.js"; +import * as like from "./like.js"; +import * as delegate from "./delegate.js"; + +const { formBehavior } = newKnownSymbols; + +export function setup () { + // TODO decouple these from core functionality + this.addPlugin(like); + this.addPlugin(delegate); +} + +export const hooks = { + constructed () { + if (!this.constructor.formBehavior) { + return; + } + + this[internals] ??= this.attachInternals(); + }, +}; + +export const membersStatic = { + defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { + if (!def) { + return; + } + + if (!this.formBehavior) { + this.formBehavior = true; + } + + const env = {context: this, formBehavior: def}; + this.hooks.run("define-form-behavior", env); + + this[formBehavior] ??= {}; + Object.assign(this[formBehavior], env.formBehavior); + }, +}; diff --git a/src/form-behavior/delegate.js b/src/form-behavior/delegate.js new file mode 100644 index 0000000..3bcfeda --- /dev/null +++ b/src/form-behavior/delegate.js @@ -0,0 +1,29 @@ +/** + * Expose form-related ElementInternals properties on the host element + */ +import { delegate } from "../util/delegate.js"; +import { internals, newKnownSymbols } from "../util/symbols.js"; + +const { formBehavior } = newKnownSymbols; + +let objects = [ElementInternals, HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]; +let props = objects.map(o => new Set(Object.getOwnPropertyNames(o.prototype))); +const defaultProperties = props.reduce((acc, cur) => acc.intersection(cur)); +defaultProperties.delete("constructor"); + +export const hooks = { + first_constructor_static () { + if (!this.formBehavior) { + return; + } + + const properties = this[formBehavior].properties ?? defaultProperties; + + delegate({ + properties, + from: this.prototype, + to: internals, + descriptors: Object.getOwnPropertyDescriptors(ElementInternals.prototype), + }); + } +}; diff --git a/src/form-behavior/index.js b/src/form-behavior/index.js new file mode 100644 index 0000000..07747be --- /dev/null +++ b/src/form-behavior/index.js @@ -0,0 +1,9 @@ +import * as base from "./base.js"; +import * as like from "./like.js"; +import * as delegate from "./delegate.js"; + +export function setup () { + this.addPlugin(base); + this.addPlugin(like); + this.addPlugin(delegate); +} diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js new file mode 100644 index 0000000..8bded80 --- /dev/null +++ b/src/form-behavior/like.js @@ -0,0 +1,29 @@ +import { internals, newKnownSymbols } from "../util/symbols.js"; +import { resolveValue } from "../util.js"; +import { getRole } from "./role.js"; + +const { formBehavior } = newKnownSymbols; + +export const hooks = { + first_connected () { + let { like, role, valueProp = "value", changeEvent = "input" } = this.constructor[formBehavior]; + + if (!like) { + return; + } + + let source = resolveValue(like, [this, this]); + if (source && !role) { + role = source.computedRole ?? getRole(source); + } + + if (role) { + // XXX Should we set a default role? E.g. "textbox"? + this[internals].role = role; + } + + // Set up value reflection + this[internals].setFormValue(this[valueProp]); + source.addEventListener(changeEvent, () => this[internals].setFormValue(this[valueProp])); + }, +}; diff --git a/src/form-behavior/role.js b/src/form-behavior/role.js new file mode 100644 index 0000000..15480ea --- /dev/null +++ b/src/form-behavior/role.js @@ -0,0 +1,66 @@ +import newSymbols, { internals } from "../util/symbols.js"; + +const defaultRole = newSymbols; + +export const defaultRoles = { + textarea: "textbox", + button: "button", + input: { + [defaultRole]: "textbox", + "[type=checkbox][switch]": "switch", + "[type=checkbox]": "checkbox", + "[type=radio]": "radio", + "[type=range]": "slider", + "[type=number]": "spinbutton", + "[type=button], [type=submit], [type=reset]": "button", + }, + select: { + [defaultRole]: "combobox", + "[size]:not([size='1'])": "listbox", + }, + option: "option", + optgroup: "group", +}; + +export function getDefaultRole (element) { + if (!element) { + return null; + } + + let tag = element.tagName.toLowerCase(); + let roles = defaultRoles[tag]; + + if (!roles) { + return null; + } + + if (typeof roles === "string") { + return roles; + } + + for (let [selector, role] of Object.entries(roles)) { + if (element.matches(selector)) { + return role; + } + } + + return roles[defaultRole]; +} + +export function getRole (element) { + if (!element) { + return null; + } + + if (element.role) { + return element.role; + } + + if (element[internals].role) { + return element[internals].role; + } + + return getDefaultRole(element); +} + +export default getRole; diff --git a/src/index.js b/src/index.js index 5415fb6..2409f00 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ export { default as default } from "./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 defineFormBehavior } from "./form-behavior/base.js"; export { default as defineMixin } from "./mixins/define-mixin.js"; export { default as Hooks } from "./mixins/hooks.js"; From 861fb7f799e9f386a7012834c1425c6c7ecfc320 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 13:39:01 -0500 Subject: [PATCH 17/85] Refactor plugin code - Make it easier for subclasses to `setup()` - Declarative way to include plugins --- src/Element.js | 68 +++++++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/src/Element.js b/src/Element.js index 31b77bd..9cf2688 100644 --- a/src/Element.js +++ b/src/Element.js @@ -13,13 +13,11 @@ import { internals, initialized, newKnownSymbols } from "./util/symbols.js"; const { plugins } = newKnownSymbols; -const instanceInitialized = Symbol("instanceInitialized"); -const classInitialized = Symbol("classInitialized"); - const Self = class NudeElement extends HTMLElement { constructor () { super(); + this.constructor.setup(); // Last resort this.constructor.hooks.run("constructor-static", this.constructor); this.constructor.hooks.run("constructor", this); @@ -68,25 +66,6 @@ const Self = class NudeElement extends HTMLElement { }); } - static init () { - // Stuff that runs once per class - if (this[classInitialized]) { - return false; - } - - if (this.events) { - defineEvents(this); - } - - if (this.formAssociated || this.formBehavior) { - defineFormBehavior(this); - } - - this.hooks.run("setup", this); - - return (this[classInitialized] = true); - } - /** * Like super, but dynamic */ @@ -102,12 +81,32 @@ const Self = class NudeElement extends HTMLElement { return Super === Function.prototype ? null : Super; } + /** Plugins to install */ + static plugins = [ + defineProps, + defineEvents, + defineFormBehavior, + shadowStyles, + globalStyles, + ]; + + get allPlugins () { + return [ + ...(this.super?.allPlugins ?? []), + ...(Object.hasOwn(this, "plugins") ? this.plugins : []), + ]; + } + static hasPlugin (plugin) { + if (this.super?.hasPlugin?.(plugin)) { + return true; + } + if (!Object.hasOwn(this, plugins)) { return false; } - return this[plugins].has(plugin) || this.super?.hasPlugin?.(plugin); + return this[plugins].has(plugin); } static addPlugin (plugin) { @@ -132,10 +131,27 @@ const Self = class NudeElement extends HTMLElement { plugin.setup?.call(this); } + /** + * Code initializing the class that needs to be called as soon as possible after class definition + * And needs to be called separately per subclass + * @returns {void} + */ + static setup () { + if (Object.hasOwn(this, initialized)) { + return; + } + + this.super?.setup?.(); + + for (let plugin of this.plugins) { + this.addPlugin(plugin); + } + + this[initialized] = true; + } + static { - this.addPlugin(defineProps); - this.addPlugin(shadowStyles); - this.addPlugin(globalStyles); + this.setup(); } }; From 4e80dc92c448a57eafe4682966d4331708102143 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 13:49:53 -0500 Subject: [PATCH 18/85] `setup` hook --- src/Element.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Element.js b/src/Element.js index 9cf2688..b36ccce 100644 --- a/src/Element.js +++ b/src/Element.js @@ -147,6 +147,8 @@ const Self = class NudeElement extends HTMLElement { this.addPlugin(plugin); } + this.hooks.run("setup", this); + this[initialized] = true; } From f0cc167ff982cae586f4719d36769becc98f4b53 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 13:58:40 -0500 Subject: [PATCH 19/85] Fix imports --- src/Element.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index b36ccce..b119a6a 100644 --- a/src/Element.js +++ b/src/Element.js @@ -4,9 +4,10 @@ import * as defineProps from "./props/defineProps.js"; import * as defineEvents from "./events/defineEvents.js"; -import defineFormBehavior from "./form-behavior/index.js"; +import * as defineFormBehavior from "./form-behavior/index.js"; +import * as shadowStyles from "./styles/shadow.js"; +import * as globalStyles from "./styles/global.js"; -import { shadowStyles, globalStyles } from "./styles/index.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; import { internals, initialized, newKnownSymbols } from "./util/symbols.js"; From ec8a767104813e2b0c1fc4f6118344873defe89c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 13:59:26 -0500 Subject: [PATCH 20/85] Delete mounted.js --- src/mounted.js | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/mounted.js diff --git a/src/mounted.js b/src/mounted.js deleted file mode 100644 index 1d72a63..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 MounteddMixin 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 (this[anyMounted]) { - return; - } - - this[anyMounted] = true; - } -} - -export default MounteddMixin; From 24d03e21d5ec3e659621a6532d99416468fe603e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:14:18 -0500 Subject: [PATCH 21/85] More robust `ElementInternals` - Don't error if internals are already attached or when it's not supported --- src/Element.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index b119a6a..7d9dd4c 100644 --- a/src/Element.js +++ b/src/Element.js @@ -48,11 +48,21 @@ const Self = class NudeElement extends HTMLElement { } attachInternals () { - if (this[internals]) { + if (this[internals] !== undefined) { return this[internals]; } - return this[internals] = super.attachInternals(); + if (HTMLElement.prototype.attachInternals === undefined) { + // Not supported + return this[internals] = null; + } + + try { + return this[internals] = super.attachInternals(); + } + catch (error) { + return this[internals] = null; + } } static hooks = new Hooks(); From 36ab8d24e784c384b26ada921acb3e7cb51f18d5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:15:04 -0500 Subject: [PATCH 22/85] Transparently call `attachInternals()` when `this[internals]` is accessed --- src/Element.js | 11 +++++++++++ src/form-behavior/base.js | 10 ---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Element.js b/src/Element.js index 7d9dd4c..b0d74b0 100644 --- a/src/Element.js +++ b/src/Element.js @@ -65,6 +65,17 @@ const Self = class NudeElement extends HTMLElement { } } + static { + // Transparently call attachInternals() when the internals property is accessed + defineLazyProperty(this.prototype, internals, { + get () { + return this.attachInternals(); + }, + configurable: true, + writable: true, + }); + } + static hooks = new Hooks(); static { defineLazyProperty(this, "hooks", { diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index b616a25..25b0622 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -12,16 +12,6 @@ export function setup () { this.addPlugin(delegate); } -export const hooks = { - constructed () { - if (!this.constructor.formBehavior) { - return; - } - - this[internals] ??= this.attachInternals(); - }, -}; - export const membersStatic = { defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { if (!def) { From 17aad5ef836c903d7a470a13b1c222c16c75567e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:15:17 -0500 Subject: [PATCH 23/85] `toggleState()` plugin --- src/states.js | 45 -------------------------------------------- src/states/toggle.js | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 src/states.js create mode 100644 src/states/toggle.js diff --git a/src/states.js b/src/states.js deleted file mode 100644 index 4177f57..0000000 --- a/src/states.js +++ /dev/null @@ -1,45 +0,0 @@ -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; - } - - let ret = class StatesMixin 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(ret.prototype, prop, { - get () { - return this[internalsProp][prop]; - }, - enumerable: true, - }); - } - - return ret; -} diff --git a/src/states/toggle.js b/src/states/toggle.js new file mode 100644 index 0000000..ba33c7c --- /dev/null +++ b/src/states/toggle.js @@ -0,0 +1,18 @@ +import { internals } from "../util/symbols.js"; + + +export const members = { + toggleState (state, force) { + if (!this[internals]) { + return; + } + + if (force === undefined) { + force = !this[internals].states.has(state); + } + + this[state] = force; + + return force; + } +}; From f5efc529198bb1fe3d60de6ffd148c02b95612bd Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:44:26 -0500 Subject: [PATCH 24/85] Plugin dependencies --- src/Element.js | 6 ++++++ src/events/defineEvents.js | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Element.js b/src/Element.js index b0d74b0..266d028 100644 --- a/src/Element.js +++ b/src/Element.js @@ -140,6 +140,12 @@ const Self = class NudeElement extends HTMLElement { this[plugins] = new Set(); } + if (plugin.dependencies) { + for (let dependency of plugin.dependencies) { + this.addPlugin(dependency); + } + } + if (plugin.members) { extend(this, plugin.members); } diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 9d21057..6d0f205 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -6,9 +6,9 @@ import * as retarget from "./retarget.js"; import { newKnownSymbols } from "../util/symbols.js"; const { events } = newKnownSymbols; -export function setup () { - this.addPlugin(defineProps); +export const dependencies = [defineProps]; +export function setup () { // TODO decouple these from core event functionality this.addPlugin(propchange); this.addPlugin(onprops); From 966f37405f78cebfbf73060746deaf09522923a7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:50:07 -0500 Subject: [PATCH 25/85] Default export for all plugins --- src/Element.js | 10 +++++----- src/events/defineEvents.js | 10 ++++++---- src/events/onprops.js | 2 ++ src/events/propchange.js | 2 ++ src/events/retarget.js | 2 ++ src/form-behavior/base.js | 4 +++- src/form-behavior/delegate.js | 2 ++ src/form-behavior/like.js | 2 ++ src/props/defineProps.js | 2 ++ src/states/toggle.js | 3 ++- src/styles/global.js | 2 ++ src/styles/shadow.js | 2 ++ 12 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/Element.js b/src/Element.js index 266d028..3ce5962 100644 --- a/src/Element.js +++ b/src/Element.js @@ -2,11 +2,11 @@ * Base class for all elements */ -import * as defineProps from "./props/defineProps.js"; -import * as defineEvents from "./events/defineEvents.js"; -import * as defineFormBehavior from "./form-behavior/index.js"; -import * as shadowStyles from "./styles/shadow.js"; -import * as globalStyles from "./styles/global.js"; +import defineProps from "./props/defineProps.js"; +import defineEvents from "./events/defineEvents.js"; +import defineFormBehavior from "./form-behavior/index.js"; +import shadowStyles from "./styles/shadow.js"; +import globalStyles from "./styles/global.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 6d0f205..23a4dd2 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -1,7 +1,7 @@ -import * as defineProps from "../props/defineProps.js"; -import * as propchange from "./propchange.js"; -import * as onprops from "./onprops.js"; -import * as retarget from "./retarget.js"; +import defineProps from "../props/defineProps.js"; +import propchange from "./propchange.js"; +import onprops from "./onprops.js"; +import retarget from "./retarget.js"; import { newKnownSymbols } from "../util/symbols.js"; const { events } = newKnownSymbols; @@ -23,3 +23,5 @@ export const membersStatic = { this.hooks.run("define-events", {context: this, events: def}); }, }; + +export default {dependencies, setup, membersStatic}; diff --git a/src/events/onprops.js b/src/events/onprops.js index 8904082..b3141af 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -68,3 +68,5 @@ export const hooks = { }); }, }; + +export default {hooks}; diff --git a/src/events/propchange.js b/src/events/propchange.js index 6c1a6aa..f9e3732 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -46,3 +46,5 @@ export const hooks = { } }, }; + +export default {hooks}; diff --git a/src/events/retarget.js b/src/events/retarget.js index 4f3d260..be02ad2 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -57,3 +57,5 @@ export const hooks = { }, }; + +export default {hooks}; diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index 25b0622..db8390c 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -1,6 +1,6 @@ -import { internals, newKnownSymbols } from "../util/symbols.js"; +import { newKnownSymbols } from "../util/symbols.js"; import * as like from "./like.js"; import * as delegate from "./delegate.js"; @@ -29,3 +29,5 @@ export const membersStatic = { Object.assign(this[formBehavior], env.formBehavior); }, }; + +export default {setup, membersStatic}; diff --git a/src/form-behavior/delegate.js b/src/form-behavior/delegate.js index 3bcfeda..2815c93 100644 --- a/src/form-behavior/delegate.js +++ b/src/form-behavior/delegate.js @@ -27,3 +27,5 @@ export const hooks = { }); } }; + +export default {hooks}; diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js index 8bded80..05b07e8 100644 --- a/src/form-behavior/like.js +++ b/src/form-behavior/like.js @@ -27,3 +27,5 @@ export const hooks = { source.addEventListener(changeEvent, () => this[internals].setFormValue(this[valueProp])); }, }; + +export default {hooks}; diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 0614f44..729535c 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -83,3 +83,5 @@ export const membersStatic = { // }, // }), }; + +export default {hooks, members, membersStatic}; diff --git a/src/states/toggle.js b/src/states/toggle.js index ba33c7c..11f9835 100644 --- a/src/states/toggle.js +++ b/src/states/toggle.js @@ -1,6 +1,5 @@ import { internals } from "../util/symbols.js"; - export const members = { toggleState (state, force) { if (!this[internals]) { @@ -16,3 +15,5 @@ export const members = { return force; } }; + +export default {members}; diff --git a/src/styles/global.js b/src/styles/global.js index fccfbce..a1a5c73 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -65,3 +65,5 @@ export const hooks = { } }, }; + +export default {hooks}; diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 0e9d86a..1ff2cdd 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -49,3 +49,5 @@ export const hooks = { } }, }; + +export default {hooks}; From 1b47ff57fc6f06958206aefbb69b3c887361bcf7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:55:11 -0500 Subject: [PATCH 26/85] Move `ElementInternals` stuff to plugin --- src/Element.js | 31 +----------------------------- src/form-behavior/base.js | 6 +++++- src/internals/base.js | 40 +++++++++++++++++++++++++++++++++++++++ src/states/toggle.js | 6 +++++- 4 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 src/internals/base.js diff --git a/src/Element.js b/src/Element.js index 3ce5962..b56c9dd 100644 --- a/src/Element.js +++ b/src/Element.js @@ -10,7 +10,7 @@ import globalStyles from "./styles/global.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; -import { internals, initialized, newKnownSymbols } from "./util/symbols.js"; +import { initialized, newKnownSymbols } from "./util/symbols.js"; const { plugins } = newKnownSymbols; @@ -47,35 +47,6 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } - attachInternals () { - if (this[internals] !== undefined) { - return this[internals]; - } - - if (HTMLElement.prototype.attachInternals === undefined) { - // Not supported - return this[internals] = null; - } - - try { - return this[internals] = super.attachInternals(); - } - catch (error) { - return this[internals] = null; - } - } - - static { - // Transparently call attachInternals() when the internals property is accessed - defineLazyProperty(this.prototype, internals, { - get () { - return this.attachInternals(); - }, - configurable: true, - writable: true, - }); - } - static hooks = new Hooks(); static { defineLazyProperty(this, "hooks", { diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index db8390c..faf2768 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -4,6 +4,10 @@ import { newKnownSymbols } from "../util/symbols.js"; import * as like from "./like.js"; import * as delegate from "./delegate.js"; +import internalsPlugin from "../internals/base.js"; + +export const dependencies = [internalsPlugin]; + const { formBehavior } = newKnownSymbols; export function setup () { @@ -30,4 +34,4 @@ export const membersStatic = { }, }; -export default {setup, membersStatic}; +export default {dependencies, setup, membersStatic}; diff --git a/src/internals/base.js b/src/internals/base.js new file mode 100644 index 0000000..fcdc101 --- /dev/null +++ b/src/internals/base.js @@ -0,0 +1,40 @@ +/** + * Provide access to element internals through a symbol property + */ + +import { newKnownSymbols } from "../util/symbols.js"; +import { defineLazyProperty } from "../util/lazy.js"; + +const { internals } = newKnownSymbols; + +const attachInternals = HTMLElement.prototype.attachInternals; + +export const members = { + attachInternals () { + if (this[internals] !== undefined) { + return this[internals]; + } + + if (attachInternals === undefined) { + // Not supported + return this[internals] = null; + } + + try { + return this[internals] = attachInternals.call(this); + } + catch (error) { + return this[internals] = null; + } + }, +}; + +defineLazyProperty(members, internals, { + get () { + return this.attachInternals(); + }, + configurable: true, + writable: true, +}); + +export default {members}; diff --git a/src/states/toggle.js b/src/states/toggle.js index 11f9835..484c74e 100644 --- a/src/states/toggle.js +++ b/src/states/toggle.js @@ -1,5 +1,9 @@ import { internals } from "../util/symbols.js"; +import internalsPlugin from "../internals/base.js"; + +export const dependencies = [internalsPlugin]; + export const members = { toggleState (state, force) { if (!this[internals]) { @@ -16,4 +20,4 @@ export const members = { } }; -export default {members}; +export default {dependencies, members}; From 751503ade0935f096ef05571f05faf5b9504048c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 14:56:43 -0500 Subject: [PATCH 27/85] Update symbols.js --- src/util/symbols.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/symbols.js b/src/util/symbols.js index e6e854d..698cff0 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -12,8 +12,8 @@ export { newSymbols }; export default newSymbols; // Known symbols -export const { internals, initialized } = newSymbols; -export const KNOWN_SYMBOLS = { internals, initialized }; +export const { initialized } = newSymbols; +export const KNOWN_SYMBOLS = { initialized }; export const newKnownSymbols = new Proxy({}, { get (target, prop) { From a450f091ad0a295f6a46eb21438638de2b9492f5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 15:06:14 -0500 Subject: [PATCH 28/85] Make DX around symbols even nicer --- src/Element.js | 4 ++-- src/events/defineEvents.js | 4 ++-- src/events/onprops.js | 4 ++-- src/events/propchange.js | 6 +++--- src/events/retarget.js | 4 ++-- src/form-behavior/base.js | 4 ++-- src/form-behavior/delegate.js | 4 ++-- src/form-behavior/like.js | 4 ++-- src/form-behavior/role.js | 5 +++-- src/internals/base.js | 4 ++-- src/props/defineProps.js | 4 ++-- src/slots/slots.js | 4 ++-- src/styles/global.js | 4 ++-- src/styles/shadow.js | 4 ++-- src/util/composed.js | 4 ++-- src/util/symbols.js | 10 ++++++---- 16 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/Element.js b/src/Element.js index b56c9dd..641064d 100644 --- a/src/Element.js +++ b/src/Element.js @@ -10,9 +10,9 @@ import globalStyles from "./styles/global.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; -import { initialized, newKnownSymbols } from "./util/symbols.js"; +import symbols, { initialized } from "./util/symbols.js"; -const { plugins } = newKnownSymbols; +const { plugins } = symbols.known; const Self = class NudeElement extends HTMLElement { constructor () { diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js index 23a4dd2..a2d3ed2 100644 --- a/src/events/defineEvents.js +++ b/src/events/defineEvents.js @@ -3,8 +3,8 @@ import propchange from "./propchange.js"; import onprops from "./onprops.js"; import retarget from "./retarget.js"; -import { newKnownSymbols } from "../util/symbols.js"; -const { events } = newKnownSymbols; +import symbols from "../util/symbols.js"; +const { events } = symbols.known; export const dependencies = [defineProps]; diff --git a/src/events/onprops.js b/src/events/onprops.js index b3141af..d02419c 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -3,8 +3,8 @@ * Add on* props for UI events, just like native UI events */ -import newSymbols from "../util/symbols.js"; -const { eventProps } = newSymbols; +import symbols from "../util/symbols.js"; +const { eventProps } = symbols.new; export const hooks = { defineEvents (env) { diff --git a/src/events/propchange.js b/src/events/propchange.js index f9e3732..05571c6 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -3,9 +3,9 @@ * Events that fire when a specific prop changes */ -import getSymbols, { newKnownSymbols } from "../util/symbols.js"; -const { propchange } = getSymbols; -const { events } = newKnownSymbols; +import symbols from "../util/symbols.js"; +const { propchange } = symbols.new; +const { events } = symbols.known; export const hooks = { first_constructor_static () { diff --git a/src/events/retarget.js b/src/events/retarget.js index be02ad2..37f45a2 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -4,8 +4,8 @@ import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import newSymbols from "../util/symbols.js"; -const { events } = newSymbols; +import symbols from "../util/symbols.js"; +const { events } = symbols.new; export const hooks = { first_connected () { diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index faf2768..c387c7c 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -1,6 +1,6 @@ -import { newKnownSymbols } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; import * as like from "./like.js"; import * as delegate from "./delegate.js"; @@ -8,7 +8,7 @@ import internalsPlugin from "../internals/base.js"; export const dependencies = [internalsPlugin]; -const { formBehavior } = newKnownSymbols; +const { formBehavior } = symbols.known; export function setup () { // TODO decouple these from core functionality diff --git a/src/form-behavior/delegate.js b/src/form-behavior/delegate.js index 2815c93..95064fd 100644 --- a/src/form-behavior/delegate.js +++ b/src/form-behavior/delegate.js @@ -2,9 +2,9 @@ * Expose form-related ElementInternals properties on the host element */ import { delegate } from "../util/delegate.js"; -import { internals, newKnownSymbols } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; -const { formBehavior } = newKnownSymbols; +const { formBehavior, internals } = symbols.known; let objects = [ElementInternals, HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]; let props = objects.map(o => new Set(Object.getOwnPropertyNames(o.prototype))); diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js index 05b07e8..005502d 100644 --- a/src/form-behavior/like.js +++ b/src/form-behavior/like.js @@ -1,8 +1,8 @@ -import { internals, newKnownSymbols } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; import { resolveValue } from "../util.js"; import { getRole } from "./role.js"; -const { formBehavior } = newKnownSymbols; +const { formBehavior, internals } = symbols.known; export const hooks = { first_connected () { diff --git a/src/form-behavior/role.js b/src/form-behavior/role.js index 15480ea..5d8d73e 100644 --- a/src/form-behavior/role.js +++ b/src/form-behavior/role.js @@ -1,6 +1,7 @@ -import newSymbols, { internals } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; -const defaultRole = newSymbols; +const { defaultRole } = symbols.new; +const { internals } = symbols.known; export const defaultRoles = { textarea: "textbox", diff --git a/src/internals/base.js b/src/internals/base.js index fcdc101..2dd4912 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -2,10 +2,10 @@ * Provide access to element internals through a symbol property */ -import { newKnownSymbols } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; import { defineLazyProperty } from "../util/lazy.js"; -const { internals } = newKnownSymbols; +const { internals } = symbols.known; const attachInternals = HTMLElement.prototype.attachInternals; diff --git a/src/props/defineProps.js b/src/props/defineProps.js index 729535c..945613d 100644 --- a/src/props/defineProps.js +++ b/src/props/defineProps.js @@ -1,8 +1,8 @@ import Props from "./Props.js"; -import { newKnownSymbols } from "../util/symbols.js"; +import symbols from "../util/symbols.js"; // import { composed } from "../util/composed.js"; -let { props } = newKnownSymbols; +let { props } = symbols.known; function first_constructor_static () { // TODO how does this work if attributeChangedCallback is inherited? diff --git a/src/slots/slots.js b/src/slots/slots.js index 67dc630..ad6c633 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,7 +1,7 @@ import SlotController from "./slot-controller.js"; -import newSymbols from "../util/symbols.js"; +import symbols from "../util/symbols.js"; -const { slots } = newSymbols; +const { slots } = symbols.new; export const hooks = { constructor () { diff --git a/src/styles/global.js b/src/styles/global.js index a1a5c73..65d8204 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -2,9 +2,9 @@ * Mixin for adding light DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; -import newSymbols from "../util/symbols.js"; +import symbols from "../util/symbols.js"; -const { fetchedGlobalStyles, roots } = newSymbols; +const { fetchedGlobalStyles, roots } = symbols.new; export const hooks = { first_constructor_static () { diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 1ff2cdd..544ee6b 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -2,9 +2,9 @@ * Mixin for adding shadow DOM styles */ import { getSupers, adoptCSS, fetchCSS } from "./util.js"; -import newSymbols from "../util/symbols.js"; +import symbols from "../util/symbols.js"; -const { fetchedStyles } = newSymbols; +const { fetchedStyles } = symbols.new; export const hooks = { first_constructor_static () { diff --git a/src/util/composed.js b/src/util/composed.js index 796b19c..8509872 100644 --- a/src/util/composed.js +++ b/src/util/composed.js @@ -1,6 +1,6 @@ -import { newKnownSymbols } from "./symbols.js"; +import symbols from "./symbols.js"; -let { composed, constituents } = newKnownSymbols; +let { composed, constituents } = symbols.known; export default function (value) { if (!value || typeof value !== "object" && typeof value !== "function") { diff --git a/src/util/symbols.js b/src/util/symbols.js index 698cff0..0c2e55e 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -1,4 +1,4 @@ -const newSymbols = new Proxy({}, { +export const newSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { return Symbol(prop); @@ -8,9 +8,6 @@ const newSymbols = new Proxy({}, { }, }); -export { newSymbols }; -export default newSymbols; - // Known symbols export const { initialized } = newSymbols; export const KNOWN_SYMBOLS = { initialized }; @@ -30,3 +27,8 @@ export const newKnownSymbols = new Proxy({}, { return target[prop]; }, }); + +export default { + new: newSymbols, + known: newKnownSymbols, +}; From 25787b9be445b03d0596b2f0a0c1b542ec953b63 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 15:22:21 -0500 Subject: [PATCH 29/85] Export symbols --- src/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.js b/src/index.js index 2409f00..5de9c0f 100644 --- a/src/index.js +++ b/src/index.js @@ -7,3 +7,5 @@ export { default as defineFormBehavior } from "./form-behavior/base.js"; export { default as defineMixin } from "./mixins/define-mixin.js"; export { default as Hooks } from "./mixins/hooks.js"; + +export { default as symbols } from "./util/symbols.js"; From 990546d4e96f886e621ffd047e0f90bdb357b058 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 15:22:27 -0500 Subject: [PATCH 30/85] Update named-manual.js --- src/slots/named-manual.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slots/named-manual.js b/src/slots/named-manual.js index 768440e..fc61a71 100644 --- a/src/slots/named-manual.js +++ b/src/slots/named-manual.js @@ -61,7 +61,7 @@ export default function (Class, options = {}) { } // slotchange won’t fire in this case, so we need to do this the old-fashioned way - mutationObserver ??= new MutationObserver(mutations => { + mutationObserver ??= new MutationObserver(records => { let slots = {}; let nodesToAssign = records.flatMap(r => From f54b3c4747e6f3fb1c2e02d52c8e19ab1e4da78c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 15:30:32 -0500 Subject: [PATCH 31/85] =?UTF-8?q?Rename=20`defineXXX`=20=E2=86=92=20`base`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also move props as a dependency to the events sub-plugins that actually need it --- src/Element.js | 4 ++-- src/events/{defineEvents.js => base.js} | 5 +---- src/events/onprops.js | 6 ++++-- src/index.js | 6 +++--- src/props/{defineProps.js => base.js} | 0 src/slots/{defineSlots.js => base.js} | 0 6 files changed, 10 insertions(+), 11 deletions(-) rename src/events/{defineEvents.js => base.js} (79%) rename src/props/{defineProps.js => base.js} (100%) rename src/slots/{defineSlots.js => base.js} (100%) diff --git a/src/Element.js b/src/Element.js index 641064d..8a97d03 100644 --- a/src/Element.js +++ b/src/Element.js @@ -2,8 +2,8 @@ * Base class for all elements */ -import defineProps from "./props/defineProps.js"; -import defineEvents from "./events/defineEvents.js"; +import defineProps from "./props/base.js"; +import defineEvents from "./events/base.js"; import defineFormBehavior from "./form-behavior/index.js"; import shadowStyles from "./styles/shadow.js"; import globalStyles from "./styles/global.js"; diff --git a/src/events/defineEvents.js b/src/events/base.js similarity index 79% rename from src/events/defineEvents.js rename to src/events/base.js index a2d3ed2..163806e 100644 --- a/src/events/defineEvents.js +++ b/src/events/base.js @@ -1,4 +1,3 @@ -import defineProps from "../props/defineProps.js"; import propchange from "./propchange.js"; import onprops from "./onprops.js"; import retarget from "./retarget.js"; @@ -6,8 +5,6 @@ import retarget from "./retarget.js"; import symbols from "../util/symbols.js"; const { events } = symbols.known; -export const dependencies = [defineProps]; - export function setup () { // TODO decouple these from core event functionality this.addPlugin(propchange); @@ -24,4 +21,4 @@ export const membersStatic = { }, }; -export default {dependencies, setup, membersStatic}; +export default { setup, membersStatic }; diff --git a/src/events/onprops.js b/src/events/onprops.js index d02419c..2af2dac 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -2,10 +2,12 @@ /** * Add on* props for UI events, just like native UI events */ - +import propsPlugin from "../props/base.js"; import symbols from "../util/symbols.js"; const { eventProps } = symbols.new; +export const dependencies = [propsPlugin]; + export const hooks = { defineEvents (env) { let def = env.events; @@ -69,4 +71,4 @@ export const hooks = { }, }; -export default {hooks}; +export default {dependencies, hooks}; diff --git a/src/index.js b/src/index.js index 5de9c0f..87288f4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,8 @@ export { default as default } from "./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 defineProps } from "./props/base.js"; +export { default as defineEvents } from "./events/base.js"; +export { default as defineSlots } from "./slots/base.js"; export { default as defineFormBehavior } from "./form-behavior/base.js"; export { default as defineMixin } from "./mixins/define-mixin.js"; diff --git a/src/props/defineProps.js b/src/props/base.js similarity index 100% rename from src/props/defineProps.js rename to src/props/base.js diff --git a/src/slots/defineSlots.js b/src/slots/base.js similarity index 100% rename from src/slots/defineSlots.js rename to src/slots/base.js From 8926f3cd5037d7937f3fa823bb312541e3fae5c7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:09:41 -0500 Subject: [PATCH 32/85] Tweaks --- src/events/propchange.js | 1 + src/internals/base.js | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/events/propchange.js b/src/events/propchange.js index 05571c6..fa3f28e 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -4,6 +4,7 @@ */ import symbols from "../util/symbols.js"; + const { propchange } = symbols.new; const { events } = symbols.known; diff --git a/src/internals/base.js b/src/internals/base.js index 2dd4912..407e821 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -6,8 +6,7 @@ import symbols from "../util/symbols.js"; import { defineLazyProperty } from "../util/lazy.js"; const { internals } = symbols.known; - -const attachInternals = HTMLElement.prototype.attachInternals; +const { attachInternals } = HTMLElement.prototype; export const members = { attachInternals () { From ed53a9617eff468734c88e0f7a0b08dbb5c872e4 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:09:53 -0500 Subject: [PATCH 33/85] [styles] README --- src/styles/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/styles/README.md diff --git a/src/styles/README.md b/src/styles/README.md new file mode 100644 index 0000000..b0c8611 --- /dev/null +++ b/src/styles/README.md @@ -0,0 +1,8 @@ +# Component styles + +Levels of visibility: +- `shadow` (default): Add to the component’s shadow DOM +- `light`: Add to the component’s closest root node (TODO) +- `root`: Add to the document root (TODO) +- `global`: Add to the component’s shadow DOM and every root node all the way up to the document. Superset of all of the above. + From 941068f1db5d2f7bdb635f0fc9c1e9e402b2d861 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:10:05 -0500 Subject: [PATCH 34/85] Shadow DOM plugin To be used by slots, dom etc --- src/shadow/README.md | 5 +++++ src/shadow/base.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/shadow/README.md create mode 100644 src/shadow/base.js diff --git a/src/shadow/README.md b/src/shadow/README.md new file mode 100644 index 0000000..69e5f2b --- /dev/null +++ b/src/shadow/README.md @@ -0,0 +1,5 @@ +# Shadow DOM + +## Hooks + +- `shadow-attached`: Runs when the shadow root is attached to the element diff --git a/src/shadow/base.js b/src/shadow/base.js new file mode 100644 index 0000000..620374b --- /dev/null +++ b/src/shadow/base.js @@ -0,0 +1,44 @@ +/** + * Provide access to an element's shadow root through a symbol property (even when it’s closed) + */ + +import symbols from "../util/symbols.js"; +import { defineLazyProperty } from "../util/lazy.js"; + +const { shadowRoot, shadowRootOptions } = symbols.known; +const { attachShadow } = HTMLElement.prototype; + +export const members = { + attachShadow (options = this.constructor[shadowRootOptions] ?? this.constructor.shadowRoot) { + if (this[shadowRoot] !== undefined) { // We want to include null + return this[shadowRoot]; + } + + if (attachShadow === undefined) { + // Not supported + return this[shadowRoot] = null; + } + + this[shadowRootOptions] ??= options; + + try { + this[shadowRoot] = attachShadow.call(this, options); + this.hooks.run("shadow-attached", {context: this, shadowRoot}); + } + catch (error) { + this[shadowRoot] = null; + } + + return this[shadowRoot]; + }, +}; + +defineLazyProperty(members, shadowRoot, { + get () { + return this.attachShadow(); + }, + configurable: true, + writable: true, +}); + +export default {members}; From 77590ba4e3f2885876f687ace0b21a27fc4cceec Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:33:21 -0500 Subject: [PATCH 35/85] Update has-slotted.js --- src/slots/has-slotted.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/slots/has-slotted.js b/src/slots/has-slotted.js index 67734b5..2c2e7f0 100644 --- a/src/slots/has-slotted.js +++ b/src/slots/has-slotted.js @@ -11,21 +11,16 @@ function update (slot) { const SUPPORTS_HAS_SLOTTED = globalThis.CSS?.supports("selector(:has-slotted)"); -export default { - init () { +export const hooks = { + first_connected () { // Get all slots - if (SUPPORTS_HAS_SLOTTED || !this.shadowRoot) { + if (SUPPORTS_HAS_SLOTTED) { return; } - if (this.shadowRoot.slotAssignment === "manual") { - // TODO maybe wrap assign()? - } - else { - this.addEventListener("slotchange", event => { - update(event.target); - }); - } + this.addEventListener("slotchange", event => { + update(event.target); + }); let slotObserver = new SlotObserver(records => { for (let r of records) { @@ -36,3 +31,5 @@ export default { slotObserver.observe(this); }, }; + +export default { hooks }; From 4e7ddb1d8a58b6152e41f6e70403b59b0055c2f1 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:41:56 -0500 Subject: [PATCH 36/85] Move plugin code to separate file --- src/Element.js | 51 +++++--------------------------------------- src/plugins.js | 52 +++++++++++++++++++++++++++++++++++++++++++++ src/util/symbols.js | 6 ++---- 3 files changed, 59 insertions(+), 50 deletions(-) create mode 100644 src/plugins.js diff --git a/src/Element.js b/src/Element.js index 8a97d03..a2bdbf1 100644 --- a/src/Element.js +++ b/src/Element.js @@ -10,9 +10,10 @@ import globalStyles from "./styles/global.js"; import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; -import symbols, { initialized } from "./util/symbols.js"; +import { hasPlugin, addPlugin } from "./plugins.js"; +import symbols from "./util/symbols.js"; -const { plugins } = symbols.known; +const { initialized } = symbols.new; const Self = class NudeElement extends HTMLElement { constructor () { @@ -83,51 +84,12 @@ const Self = class NudeElement extends HTMLElement { globalStyles, ]; - get allPlugins () { - return [ - ...(this.super?.allPlugins ?? []), - ...(Object.hasOwn(this, "plugins") ? this.plugins : []), - ]; - } - static hasPlugin (plugin) { - if (this.super?.hasPlugin?.(plugin)) { - return true; - } - - if (!Object.hasOwn(this, plugins)) { - return false; - } - - return this[plugins].has(plugin); + return hasPlugin(this, plugin); } static addPlugin (plugin) { - if (this.hasPlugin(plugin)) { - return; - } - - if (!Object.hasOwn(this, plugins)) { - this[plugins] = new Set(); - } - - if (plugin.dependencies) { - for (let dependency of plugin.dependencies) { - this.addPlugin(dependency); - } - } - - if (plugin.members) { - extend(this, plugin.members); - } - - if (plugin.membersStatic) { - extend(this, plugin.membersStatic); - } - - this.hooks.add(plugin.hooks); - - plugin.setup?.call(this); + addPlugin(this, plugin); } /** @@ -158,6 +120,3 @@ const Self = class NudeElement extends HTMLElement { export default Self; -function extend (base, plugin) { - Object.defineProperties(base, Object.getOwnPropertyDescriptors(plugin)); -} diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 0000000..fc5a37c --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,52 @@ +import symbols from "./util/symbols.js"; + +const { plugins } = symbols.known; + +export function hasPlugin (Class, plugin) { + if (Class.super?.hasPlugin?.(plugin)) { + return true; + } + + if (!Object.hasOwn(Class, plugins)) { + return false; + } + + return Class[plugins].has(plugin); +} + +export function addPlugin (Class, plugin) { + if (Class.hasPlugin(plugin)) { + return; + } + + if (!Object.hasOwn(Class, plugins)) { + Class[plugins] = new Set(); + } + + if (plugin.dependencies) { + for (let dependency of plugin.dependencies) { + Class.addPlugin(dependency); + } + } + + if (plugin.members) { + extend(Class, plugin.members); + } + + if (plugin.membersStatic) { + extend(Class, plugin.membersStatic); + } + + Class.hooks.add(plugin.hooks); + + plugin.setup?.call(Class); +} + +/** + * Extend an object with the properties of another object + * @param {Object} base + * @param {Object} plugin + */ +function extend (base, plugin) { + Object.defineProperties(base, Object.getOwnPropertyDescriptors(plugin)); +} diff --git a/src/util/symbols.js b/src/util/symbols.js index 0c2e55e..849cf72 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -1,3 +1,5 @@ +export const KNOWN_SYMBOLS = {}; + export const newSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { @@ -8,10 +10,6 @@ export const newSymbols = new Proxy({}, { }, }); -// Known symbols -export const { initialized } = newSymbols; -export const KNOWN_SYMBOLS = { initialized }; - export const newKnownSymbols = new Proxy({}, { get (target, prop) { if (typeof prop === "string") { From 699661aacfb42748eb011399f6159a2bfa81f301 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 16:53:06 -0500 Subject: [PATCH 37/85] Remove unnecessary code --- src/Element.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Element.js b/src/Element.js index a2bdbf1..1974401 100644 --- a/src/Element.js +++ b/src/Element.js @@ -23,10 +23,6 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("constructor-static", this.constructor); this.constructor.hooks.run("constructor", 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(() => { if (!this.constructor.hooks.hasRun("constructed")) { From b5923acf0239edab37aa1f806355661f7fede8d3 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 17:04:16 -0500 Subject: [PATCH 38/85] Split `Element` class from plugins - New `common-plugins.js` file - New tree-shakeable entrypoint - Separate entrypoint that adds common plugins to `Element`, documented as having side effects --- package.json | 2 ++ src/Element.js | 25 +++---------------------- src/common-plugins.js | 21 +++++++++++++++++++++ src/index-fn.js | 9 +++++++++ src/index.js | 18 ++++++++++-------- 5 files changed, 45 insertions(+), 30 deletions(-) create mode 100644 src/common-plugins.js create mode 100644 src/index-fn.js diff --git a/package.json b/package.json index d9bf970..2995cd4 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ }, "exports": { ".": "./src/index.js", + "./fn": "./src/index-fn.js", "./hooks": "./src/mixins/hooks.js" }, + "sideEffects": ["./src/index.js"], "repository": { "type": "git", "url": "git+https://github.com/nudeui/element.git" diff --git a/src/Element.js b/src/Element.js index 1974401..511abc5 100644 --- a/src/Element.js +++ b/src/Element.js @@ -2,12 +2,6 @@ * Base class for all elements */ -import defineProps from "./props/base.js"; -import defineEvents from "./events/base.js"; -import defineFormBehavior from "./form-behavior/index.js"; -import shadowStyles from "./styles/shadow.js"; -import globalStyles from "./styles/global.js"; - import { defineLazyProperty } from "./util/lazy.js"; import Hooks from "./mixins/hooks.js"; import { hasPlugin, addPlugin } from "./plugins.js"; @@ -15,7 +9,7 @@ import symbols from "./util/symbols.js"; const { initialized } = symbols.new; -const Self = class NudeElement extends HTMLElement { +export default class NudeElement extends HTMLElement { constructor () { super(); @@ -72,13 +66,7 @@ const Self = class NudeElement extends HTMLElement { } /** Plugins to install */ - static plugins = [ - defineProps, - defineEvents, - defineFormBehavior, - shadowStyles, - globalStyles, - ]; + static plugins = []; static hasPlugin (plugin) { return hasPlugin(this, plugin); @@ -108,11 +96,4 @@ const Self = class NudeElement extends HTMLElement { this[initialized] = true; } - - static { - this.setup(); - } -}; - -export default Self; - +} diff --git a/src/common-plugins.js b/src/common-plugins.js new file mode 100644 index 0000000..32a9248 --- /dev/null +++ b/src/common-plugins.js @@ -0,0 +1,21 @@ +import props from "./props/base.js"; +import events from "./events/base.js"; +import formBehavior from "./form-behavior/index.js"; +import shadowStyles from "./styles/shadow.js"; +import globalStyles from "./styles/global.js"; + +export { + props, + events, + formBehavior, + shadowStyles, + globalStyles, +}; + +export default [ + props, + events, + formBehavior, + shadowStyles, + globalStyles, +]; diff --git a/src/index-fn.js b/src/index-fn.js new file mode 100644 index 0000000..732238b --- /dev/null +++ b/src/index-fn.js @@ -0,0 +1,9 @@ +/** + * Main entry point + * when tree-shaking and no side effects are desired + */ +export { default as Element } from "./Element.js"; +export * from "./common-plugins.js"; +export { default as commonPlugins } from "./common-plugins.js"; +export { default as Hooks } from "./mixins/hooks.js"; +export { default as symbols } from "./util/symbols.js"; diff --git a/src/index.js b/src/index.js index 87288f4..4867a5a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,13 @@ -export { default as default } from "./Element.js"; +/** + * Main entry point. + * Use @link{index-fn} for tree-shaking and no side effects + * @modifies {Element} + */ -export { default as defineProps } from "./props/base.js"; -export { default as defineEvents } from "./events/base.js"; -export { default as defineSlots } from "./slots/base.js"; -export { default as defineFormBehavior } from "./form-behavior/base.js"; +import { Element, commonPlugins } from "./index-fn.js"; -export { default as defineMixin } from "./mixins/define-mixin.js"; -export { default as Hooks } from "./mixins/hooks.js"; +Element.plugins.push(...commonPlugins); +Element.setup(); -export { default as symbols } from "./util/symbols.js"; +export * from "./index-fn.js"; +export default Element; From 7715e2cfcb8ba83888ae5e2617a445f1e224c002 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 17:06:18 -0500 Subject: [PATCH 39/85] [form-behavior] Use new `dependencies` field for index --- src/form-behavior/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/form-behavior/index.js b/src/form-behavior/index.js index 07747be..1a9576e 100644 --- a/src/form-behavior/index.js +++ b/src/form-behavior/index.js @@ -2,8 +2,10 @@ import * as base from "./base.js"; import * as like from "./like.js"; import * as delegate from "./delegate.js"; -export function setup () { - this.addPlugin(base); - this.addPlugin(like); - this.addPlugin(delegate); -} +export const dependencies = [ + base, + like, + delegate, +]; + +export default {dependencies}; From 58dee4fffc4fb46bbe2e15e7887b7b0eedaf3514 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 17:12:49 -0500 Subject: [PATCH 40/85] Hooks: Use a `Map` so that we don't get conflicts for reserved names Co-Authored-By: Dmitry Sharabin --- src/mixins/hooks.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index 0f1c026..c129ed1 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -1,6 +1,6 @@ export default class Hooks { - /** @type {Record} */ - hooks = {}; + /** @type {Map} */ + hooks = new Map(); ran = new Set(); @@ -53,8 +53,10 @@ export default class Hooks { else { // Single hook, single callback name = Hooks.getCanonicalName(name); - this.hooks[name] ??= new Hook(); - this.hooks[name].add(callback); + if (!this.hooks.has(name)) { + this.hooks.set(name, new Hook()); + } + this.hooks.get(name).add(callback); } } @@ -68,11 +70,11 @@ export default class Hooks { this.ran.add(name); if (name.startsWith("first_")) { - this.hooks[name]?.runOnce(env); + this.hooks.get(name)?.runOnce(env); } else { this.run("first_" + name, env); - this.hooks[name]?.run(env); + this.hooks.get(name)?.run(env); } } From 2d87ebb4a2cb35475572b7a7c7dcb07b0c9be804 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 17:22:25 -0500 Subject: [PATCH 41/85] Make sure plugins can be used without the class methods --- src/index-fn.js | 1 + src/plugins.js | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index-fn.js b/src/index-fn.js index 732238b..9326842 100644 --- a/src/index-fn.js +++ b/src/index-fn.js @@ -7,3 +7,4 @@ export * from "./common-plugins.js"; export { default as commonPlugins } from "./common-plugins.js"; export { default as Hooks } from "./mixins/hooks.js"; export { default as symbols } from "./util/symbols.js"; +export * from "./plugins.js"; diff --git a/src/plugins.js b/src/plugins.js index fc5a37c..8b6cf8b 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -3,7 +3,9 @@ import symbols from "./util/symbols.js"; const { plugins } = symbols.known; export function hasPlugin (Class, plugin) { - if (Class.super?.hasPlugin?.(plugin)) { + let Super = Object.getPrototypeOf(Class); + + if (Super && hasPlugin(Super, plugin)) { return true; } @@ -15,7 +17,7 @@ export function hasPlugin (Class, plugin) { } export function addPlugin (Class, plugin) { - if (Class.hasPlugin(plugin)) { + if (hasPlugin(Class, plugin)) { return; } @@ -25,7 +27,7 @@ export function addPlugin (Class, plugin) { if (plugin.dependencies) { for (let dependency of plugin.dependencies) { - Class.addPlugin(dependency); + addPlugin(Class, dependency); } } @@ -48,5 +50,6 @@ export function addPlugin (Class, plugin) { * @param {Object} plugin */ function extend (base, plugin) { - Object.defineProperties(base, Object.getOwnPropertyDescriptors(plugin)); + let descriptors = Object.getOwnPropertyDescriptors(plugin); + Object.defineProperties(base, descriptors); } From 8c86dea7f2eca282abad2c111f5d048a18059917 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 17:27:59 -0500 Subject: [PATCH 42/85] Update README.md --- README.md | 169 +++++++++++++++++++----------------------------------- 1 file changed, 60 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 335a070..4f45edb 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ 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 `NudeElement` with their desired set of plugins to get exactly the functionality they need, +without any complexity they don’t need. +It is also possible to import individual plugins and connect them to an existing class, for transparent extension, but that is a little more involved. **Note:** This is a work in progress, developed in the open. Try it and please report issues and provide feedback! @@ -22,9 +23,21 @@ Try it and please report issues and provide feedback! - Accessible, form associated elements with a single line of code - No build process required, just import and use -## Usage +## Architecture + +Nude Element consists of two parts: +- The `NudeElement` class +- Plugins + +While it is *technically* possible to use the plugins directly on any base class, it would involve a lot of manual plumbing. +You can take a look at the [`NudeElement` class](src/Element.js) to see how it works. + +A plugin installed on a parent class will be inherited by all subclasses, +but subclasses can also define a static `plugins` property to add additional plugins. -### No hassle, less control: the `NudeElement` class +Plugins also include other plugins as dependencies. + +## Usage Defining your element as a subclass of `NudeElement` gives you the nicest, most declarative syntax. @@ -90,126 +103,64 @@ class MySlider extends NudeElement { } ``` -### More hassle, more control: Composable mixins +### Defining your element -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. +As a design principle, Nude elements have everything out in the open: their public API is largely self-documenting and allows programmatic introspection. +There are certain static properties that relevant plugins expect on the element class to work their magic: -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: +| Property | Description | +|----------|-------------| +| `props` | Attributes and properties that the element supports | +| `events` | Events emitted by the element | +| `slots` | Slots that the element supports | +| `styles` | Styles that the element imports | +| `cssStates` | States that the element supports (TODO) | +| `cssParts` | Parts that the element supports (TODO) | +| `cssProperties` | Custom properties that the element reads or exposes (TODO) | +| `formBehavior` | Parameters for form associated behavior | -```js -import { - defineProps, - defineEvents, - defineFormAssociated, -} from "nude-element"; - -class MySlider extends HTMLElement { - constructor () { - // ... - - eventHooks.init.call(this); - formAssociatedHooks.init.call(this); - propHooks.init.call(this); - } -} +These can be either regular properties (e.g. `MyElement.props`) or known symbols for when that is not an option. +This makes it trivial to generate documentation for the element, or even to build generic tooling around it. -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", -}); -``` +### Known symbols -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: +To import any of the known symbols, use the `symbols` export, and then destructure `symbols.known`: ```js -import { defineProps } from "nude-element"; -import Hooks from "nude-element/hooks"; +import { symbols } from "nude-element"; +// or +// import symbols from "nude-element/symbols"; -class MyElement extends HTMLElement { - // Caution: if MyElement has subclasses, this will be shared among them! - static hooks = new Hooks(); +const { props, events, slots, internals } = symbols.known; +``` - constructor () { - super(); +Note that any symbols you destructure that have not already been defined, will be created on the fly. - // Then you can call the hooks at the appropriate times: - this.constructor.hooks.run("init", this); - } -} +## Using Nude Element plugins on your own base class -defineProps(MyElement, { - // Props… -}); -``` +If Nude Element taking over your parent class seems too intrusive, +you can implement the same API via one-off composable plugins, +at the cost of handling some of the plumbing yourself. -Read more: -- [Using Props](src/props/) -- [Events](src/events/) -- [Form-associated elements](src/formAssociated/) -- [Mixins](src/mixins/) +To use the plugins directly on your own base class you need to: +- Include a static `hooks` instance and run its hooks at the appropriate times +- Use `addPlugin()` to install the plugins -## Known Hooks +### 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. +If you choose to import plugins 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 +- `prepare`: Runs once per class, as soon as a plugin is added +- `constructor-static`: Runs once per class, before any element is fully constructed +- `constructor`: Runs on `NudeElement` element constructor +- `constructed`: Runs after element constructor is done, including any subclasses (async) +- `connected`: Runs when element is connected to the DOM - `disconnected`: Runs when element is disconnected + +### Read more + +- [Props](src/props/) +- [Events](src/events/) +- [Form-associated elements](src/form-behavior/) From 08125f4d8a203b65e7954b8e87ddbbcbc9cc013c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 18:12:59 -0500 Subject: [PATCH 43/85] Fix bug Co-Authored-By: Dmitry Sharabin --- src/props/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/props/base.js b/src/props/base.js index 945613d..bc01639 100644 --- a/src/props/base.js +++ b/src/props/base.js @@ -21,7 +21,7 @@ function first_constructor_static () { } if (this.props) { - this.defineProps(this); + this.defineProps(); } } From d4fd26c8a9eaf1b044add7157a765739a2df956c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 10 Dec 2025 18:25:52 -0500 Subject: [PATCH 44/85] Fix bug Co-Authored-By: Dmitry Sharabin --- src/plugins.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins.js b/src/plugins.js index 8b6cf8b..46a73a2 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -32,7 +32,7 @@ export function addPlugin (Class, plugin) { } if (plugin.members) { - extend(Class, plugin.members); + extend(Class.prototype, plugin.members); } if (plugin.membersStatic) { From 3a2affa08a557be127be2d62c635c8d29c5a571f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 02:15:38 -0500 Subject: [PATCH 45/85] Update src/events/base.js Co-authored-by: Dmitry Sharabin --- src/events/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/base.js b/src/events/base.js index 163806e..fd7f5f4 100644 --- a/src/events/base.js +++ b/src/events/base.js @@ -21,4 +21,4 @@ export const membersStatic = { }, }; -export default { setup, membersStatic }; +export default { setup, hooks, membersStatic }; From 4ffadc854636bdfe826a53e5cfdcc984eebcc5d9 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 09:13:41 -0500 Subject: [PATCH 46/85] Update src/events/base.js Co-authored-by: Dmitry Sharabin --- src/events/base.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/events/base.js b/src/events/base.js index fd7f5f4..f02ed4e 100644 --- a/src/events/base.js +++ b/src/events/base.js @@ -12,6 +12,14 @@ export function setup () { this.addPlugin(retarget); } +export const hooks = { + first_constructor_static () { + if (this.events) { + this.defineEvents(); + } + }, +}; + export const membersStatic = { defineEvents (def = this[events] ?? this.events) { this[events] ??= {}; From bff14c1e2928f7f22f804d3634201fa753f478f1 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 09:14:22 -0500 Subject: [PATCH 47/85] Update src/form-behavior/base.js Co-authored-by: Dmitry Sharabin --- src/form-behavior/base.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index c387c7c..f316b1e 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -16,6 +16,14 @@ export function setup () { this.addPlugin(delegate); } +export const hooks = { + firstConstructorStatic () { + if (this.formBehavior) { + this.defineFormBehavior(); + } + }, +}; + export const membersStatic = { defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { if (!def) { From 5bb2bfaed751fa87705a80d1c1f2cc787b751e27 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 09:15:45 -0500 Subject: [PATCH 48/85] Update src/events/retarget.js Co-authored-by: Dmitry Sharabin --- src/events/retarget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/retarget.js b/src/events/retarget.js index 37f45a2..0bb3af9 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -5,7 +5,7 @@ import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; import symbols from "../util/symbols.js"; -const { events } = symbols.new; +const { events } = symbols.known; export const hooks = { first_connected () { From 6970ac7816ffc9af810a76069e5e568c287f30cd Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 09:20:26 -0500 Subject: [PATCH 49/85] Rename variable Co-Authored-By: Dmitry Sharabin --- src/internals/base.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/internals/base.js b/src/internals/base.js index 407e821..cba54b1 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -6,7 +6,7 @@ import symbols from "../util/symbols.js"; import { defineLazyProperty } from "../util/lazy.js"; const { internals } = symbols.known; -const { attachInternals } = HTMLElement.prototype; +const _attachInternals = HTMLElement.prototype.attachInternals; export const members = { attachInternals () { @@ -14,13 +14,13 @@ export const members = { return this[internals]; } - if (attachInternals === undefined) { + if (_attachInternals === undefined) { // Not supported return this[internals] = null; } try { - return this[internals] = attachInternals.call(this); + return this[internals] = _attachInternals.call(this); } catch (error) { return this[internals] = null; From bccc2f51a22d0ba782a89138a1a669fa1c2c3166 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 09:26:06 -0500 Subject: [PATCH 50/85] Apply suggestions from code review Co-authored-by: Dmitry Sharabin --- src/events/onprops.js | 6 +++--- src/events/propchange.js | 8 ++++---- src/events/retarget.js | 4 ++-- src/form-behavior/base.js | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/events/onprops.js b/src/events/onprops.js index 2af2dac..b011bd2 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -40,11 +40,11 @@ export const hooks = { first_connected () { // Deal with existing values - if (!this[eventProps]) { + if (!this.constructor[eventProps]) { return; } - 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); @@ -54,7 +54,7 @@ export const hooks = { // 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; diff --git a/src/events/propchange.js b/src/events/propchange.js index fa3f28e..f3534c5 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -34,14 +34,14 @@ export const hooks = { first_connected () { // Often propchange events have already fired by the time the event handlers are added - for (let eventName in this[propchange]) { - let propName = this[propchange][eventName]; + for (let eventName in this.constructor[propchange]) { + let propName = this.constructor[propchange][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), }); } } diff --git a/src/events/retarget.js b/src/events/retarget.js index 0bb3af9..99e0f92 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -9,11 +9,11 @@ const { events } = symbols.known; export const hooks = { first_connected () { - if (!this[events]) { + if (!this.constructor[events]) { return; } - for (let [name, options] of Object.entries(this[events])) { + for (let [name, options] of Object.entries(this.constructor[events])) { let { from } = options; if (!from) { diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index f316b1e..2235fbd 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -42,4 +42,4 @@ export const membersStatic = { }, }; -export default {dependencies, setup, membersStatic}; +export default { dependencies, setup, hooks, membersStatic }; From f380ea364e2b643c06ec291184e28e8e360cfe9b Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 10:12:25 -0500 Subject: [PATCH 51/85] Fix infinite recursion Co-Authored-By: Dmitry Sharabin --- src/internals/base.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/internals/base.js b/src/internals/base.js index cba54b1..c3a1a30 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -10,8 +10,9 @@ const _attachInternals = HTMLElement.prototype.attachInternals; export const members = { attachInternals () { - if (this[internals] !== undefined) { - return this[internals]; + let descriptor = Object.getOwnPropertyDescriptor(this, internals); + if (descriptor.value) { + return descriptor.value; } if (_attachInternals === undefined) { From 60080e43ba0cbeb2ef37df8db860aba12bec13e1 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Thu, 11 Dec 2025 16:27:52 +0100 Subject: [PATCH 52/85] Track hooks run in each context --- src/mixins/hooks.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/mixins/hooks.js b/src/mixins/hooks.js index c129ed1..562f906 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks.js @@ -92,15 +92,21 @@ export default class Hooks { export class Hook extends Set { /** * Track which contexts the hook has been run on so far - * @type {WeakSet} + * @type {WeakMap>} */ - contexts = new WeakSet(); + contexts = new WeakMap(); run (env) { for (let callback of this) { let context = env?.context ?? env; callback.call(context, env); - this.contexts.add(context); + + let callbacks = this.contexts.get(context); + if (!callbacks) { + callbacks = new WeakSet(); + this.contexts.set(context, callbacks); + } + callbacks.add(callback); } } @@ -112,13 +118,18 @@ export class Hook extends Set { for (let callback of this) { let context = env?.context ?? env; - if (this.contexts.has(context)) { + let callbacks = this.contexts.get(context); + if (callbacks && callbacks.has(callback)) { continue; } callback.call(context, env); // TODO what about callbacks added after this? - this.contexts.add(context); + if (!callbacks) { + callbacks = new WeakSet(); + this.contexts.set(context, callbacks); + } + callbacks.add(callback); } } } From 1152d8ab6bf6fc1d5405ba279817fddb0cb33fa1 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Thu, 11 Dec 2025 16:52:10 +0100 Subject: [PATCH 53/85] Prevent bugs --- src/internals/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internals/base.js b/src/internals/base.js index c3a1a30..6cdcf21 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -11,7 +11,7 @@ const _attachInternals = HTMLElement.prototype.attachInternals; export const members = { attachInternals () { let descriptor = Object.getOwnPropertyDescriptor(this, internals); - if (descriptor.value) { + if (descriptor?.value) { return descriptor.value; } From 01f1827396babd73b70abbbac6491bfe7d9bae8b Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 10:58:19 -0500 Subject: [PATCH 54/85] Add elements plugin stub --- src/elements/base.js | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/elements/base.js diff --git a/src/elements/base.js b/src/elements/base.js new file mode 100644 index 0000000..fcc210c --- /dev/null +++ b/src/elements/base.js @@ -0,0 +1,73 @@ +/** + * Provide easy access to certain shadow and light DOM elements + * TODO update references when DOM changes + */ + +import symbols from "../util/symbols.js"; +import { defineLazyProperty } from "../util/lazy.js"; +import shadowPlugin from "../shadow/base.js"; + +const { shadowRoot, elements } = symbols.known; + +export const dependencies = [shadowPlugin]; + +function getElement (host, options) { + let { selector, light, multiple } = options; + let root = light ? host : host[shadowRoot]; + + if (multiple) { + return Array.from(root.querySelectorAll(selector)); + } + else { + return root.querySelector(selector); + } +} + +export const hooks = { + connected () { + if (!this[elements]) { + return; + } + + // Ensure fresh references + for (let name in this[elements]) { + this[name] = getElement(this, this.constructor[elements][name]); + } + }, +}; + +export const membersStatic = { + defineElements (def = this[elements] ?? this.elements) { + if (!def) { + return; + } + + if (!this[elements]) { + this[elements] = {}; + defineLazyProperty(this.prototype, elements, { + value: {}, // mainly to ensure the getter doesn't get overwritten on the prototype + get () { + let ret = {}; + for (let name in this.constructor[elements]) { + defineLazyProperty(ret, name, { + get () { + return getElement(this, this.constructor[elements][name]); + }, + }); + } + return ret; + }, + configurable: true, + enumerable: true, + }); + } + + for (let [name, options] of Object.entries(def)) { + if (typeof options === "string") { + options = { selector: options }; + } + + this[elements][name] = options; + } + }, +} From 6c3b27b5a0c4d41105d100c8e942b5de1ca6ad74 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 11 Dec 2025 16:01:25 -0500 Subject: [PATCH 55/85] Fix bug Co-Authored-By: Dmitry Sharabin --- src/form-behavior/base.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index 2235fbd..b2b686f 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -30,8 +30,8 @@ export const membersStatic = { return; } - if (!this.formBehavior) { - this.formBehavior = true; + if (!this.formAssociated) { + this.formAssociated = true; } const env = {context: this, formBehavior: def}; From 49ec984de931cc71eb242e5ba82197dabd8c9b8d Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Thu, 11 Dec 2025 23:12:43 +0100 Subject: [PATCH 56/85] [props] Make `props` and `ignoredAttributes` member properties lazy (#69) So they are both correctly accessed and are not shared between instances. --- src/props/base.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/props/base.js b/src/props/base.js index bc01639..eacb665 100644 --- a/src/props/base.js +++ b/src/props/base.js @@ -1,5 +1,6 @@ import Props from "./Props.js"; import symbols from "../util/symbols.js"; +import { defineLazyProperty } from "../util.js"; // import { composed } from "../util/composed.js"; let { props } = symbols.known; @@ -40,15 +41,6 @@ export const hooks = { }; export const members = { - // Internal prop values - props () { - return {}; - }, - // Ignore mutations on these attributes - ignoredAttributes () { - return new Set(); - }, - // ...composed({ // attributeChangedCallback (name, oldValue, value) { // this.constructor.props.attributeChanged(this, name, oldValue, value); @@ -56,6 +48,24 @@ export const members = { // }), }; +// Internal prop values +defineLazyProperty(members, "props", { + get () { + return {}; + }, + configurable: true, + writable: true, +}); + +// Ignore mutations on these attributes +defineLazyProperty(members, "ignoredAttributes", { + get () { + return new Set(); + }, + configurable: true, + writable: true, +}); + export const membersStatic = { defineProps (def = this[props] ?? this.props) { if (def instanceof Props && def.Class === this) { From e16fbba0b817a185d34975ea16fe5b7eb11345c0 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 00:26:28 -0500 Subject: [PATCH 57/85] Update src/form-behavior/like.js Co-authored-by: Dmitry Sharabin --- src/form-behavior/like.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js index 005502d..09597ac 100644 --- a/src/form-behavior/like.js +++ b/src/form-behavior/like.js @@ -6,6 +6,10 @@ const { formBehavior, internals } = symbols.known; export const hooks = { first_connected () { + if (!this.constructor[formBehavior]) { + return; + } + let { like, role, valueProp = "value", changeEvent = "input" } = this.constructor[formBehavior]; if (!like) { From da10473870eefafae80be46770dfee532b1f0817 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 00:26:40 -0500 Subject: [PATCH 58/85] Update src/events/propchange.js Co-authored-by: Dmitry Sharabin --- src/events/propchange.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/events/propchange.js b/src/events/propchange.js index f3534c5..230ffbc 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -10,6 +10,10 @@ const { events } = symbols.known; export const hooks = { first_constructor_static () { + if (!this[events]) { + return; + } + let propchangeEvents = Object.entries(this[events]) .filter(([name, options]) => options.propchange) .map(([eventName, options]) => [eventName, options.propchange]); From 711b46b0934cd3a6cb4beeaf24beffa8882971b6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 00:29:20 -0500 Subject: [PATCH 59/85] Fix bug Co-Authored-By: Dmitry Sharabin --- src/form-behavior/base.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index b2b686f..ed01e8c 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -25,15 +25,13 @@ export const hooks = { }; export const membersStatic = { + formAssociated: true, + defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { if (!def) { return; } - if (!this.formAssociated) { - this.formAssociated = true; - } - const env = {context: this, formBehavior: def}; this.hooks.run("define-form-behavior", env); From 3583d29d7cce956543e8ca6e0d95cad6bdf74db0 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 00:31:04 -0500 Subject: [PATCH 60/85] Add defineLazyProperties utility function Introduces defineLazyProperties to allow defining multiple lazy properties on an object using defineLazyProperty in a loop. --- src/util/lazy.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/util/lazy.js b/src/util/lazy.js index da4565b..aeda6af 100644 --- a/src/util/lazy.js +++ b/src/util/lazy.js @@ -45,3 +45,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 5c184fc67f58ac2fcac07b66b103e48785090415 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 00:32:12 -0500 Subject: [PATCH 61/85] Update src/util/delegate.js Co-authored-by: Dmitry Sharabin --- src/util/delegate.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/util/delegate.js b/src/util/delegate.js index 77ead7a..6473dd6 100644 --- a/src/util/delegate.js +++ b/src/util/delegate.js @@ -18,6 +18,9 @@ export function delegate ({from, to, properties, descriptors}) { }; if (sourceDescriptor.writable || sourceDescriptor.set) { + delete descriptor.value; + delete descriptor.writable; + descriptor.set = function (value) { this[to][prop] = value; }; From fba8ee931dcec4ebc1807ea01c2fef09793998fa Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 12 Dec 2025 15:44:46 +0100 Subject: [PATCH 62/85] [states] Some fixes (#70) --- src/states/toggle.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/states/toggle.js b/src/states/toggle.js index 484c74e..420362f 100644 --- a/src/states/toggle.js +++ b/src/states/toggle.js @@ -1,7 +1,8 @@ -import { internals } from "../util/symbols.js"; - +import symbols from "../util/symbols.js"; import internalsPlugin from "../internals/base.js"; +const { internals } = symbols.known; + export const dependencies = [internalsPlugin]; export const members = { @@ -14,10 +15,10 @@ export const members = { force = !this[internals].states.has(state); } - this[state] = force; + this[internals].states[force ? "add" : "delete"](state); return force; - } + }, }; -export default {dependencies, members}; +export default { dependencies, members }; From d44faffb9aaff81b785138c79a49b49cf0b6bbc0 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 11:47:34 -0500 Subject: [PATCH 63/85] =?UTF-8?q?Rename=20`members`=20=E2=86=92=20`provide?= =?UTF-8?q?s`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by first-class protocols --- src/elements/base.js | 2 +- src/events/base.js | 4 ++-- src/form-behavior/base.js | 4 ++-- src/internals/base.js | 6 +++--- src/plugins.js | 8 ++++---- src/props/base.js | 10 +++++----- src/shadow/base.js | 6 +++--- src/states/toggle.js | 4 ++-- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/elements/base.js b/src/elements/base.js index fcc210c..949a5d1 100644 --- a/src/elements/base.js +++ b/src/elements/base.js @@ -36,7 +36,7 @@ export const hooks = { }, }; -export const membersStatic = { +export const providesStatic = { defineElements (def = this[elements] ?? this.elements) { if (!def) { return; diff --git a/src/events/base.js b/src/events/base.js index f02ed4e..4375036 100644 --- a/src/events/base.js +++ b/src/events/base.js @@ -20,7 +20,7 @@ export const hooks = { }, }; -export const membersStatic = { +export const providesStatic = { defineEvents (def = this[events] ?? this.events) { this[events] ??= {}; Object.assign(this[events], def); @@ -29,4 +29,4 @@ export const membersStatic = { }, }; -export default { setup, hooks, membersStatic }; +export default { setup, hooks, providesStatic }; diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index ed01e8c..fd8e8b2 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -24,7 +24,7 @@ export const hooks = { }, }; -export const membersStatic = { +export const providesStatic = { formAssociated: true, defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { @@ -40,4 +40,4 @@ export const membersStatic = { }, }; -export default { dependencies, setup, hooks, membersStatic }; +export default { dependencies, setup, hooks, providesStatic }; diff --git a/src/internals/base.js b/src/internals/base.js index 6cdcf21..b86d75d 100644 --- a/src/internals/base.js +++ b/src/internals/base.js @@ -8,7 +8,7 @@ import { defineLazyProperty } from "../util/lazy.js"; const { internals } = symbols.known; const _attachInternals = HTMLElement.prototype.attachInternals; -export const members = { +export const provides = { attachInternals () { let descriptor = Object.getOwnPropertyDescriptor(this, internals); if (descriptor?.value) { @@ -29,7 +29,7 @@ export const members = { }, }; -defineLazyProperty(members, internals, { +defineLazyProperty(provides, internals, { get () { return this.attachInternals(); }, @@ -37,4 +37,4 @@ defineLazyProperty(members, internals, { writable: true, }); -export default {members}; +export default {provides}; diff --git a/src/plugins.js b/src/plugins.js index 46a73a2..6546687 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -31,12 +31,12 @@ export function addPlugin (Class, plugin) { } } - if (plugin.members) { - extend(Class.prototype, plugin.members); + if (plugin.provides) { + extend(Class.prototype, plugin.provides); } - if (plugin.membersStatic) { - extend(Class, plugin.membersStatic); + if (plugin.providesStatic) { + extend(Class, plugin.providesStatic); } Class.hooks.add(plugin.hooks); diff --git a/src/props/base.js b/src/props/base.js index eacb665..f46ef08 100644 --- a/src/props/base.js +++ b/src/props/base.js @@ -40,7 +40,7 @@ export const hooks = { }, }; -export const members = { +export const provides = { // ...composed({ // attributeChangedCallback (name, oldValue, value) { // this.constructor.props.attributeChanged(this, name, oldValue, value); @@ -49,7 +49,7 @@ export const members = { }; // Internal prop values -defineLazyProperty(members, "props", { +defineLazyProperty(provides, "props", { get () { return {}; }, @@ -58,7 +58,7 @@ defineLazyProperty(members, "props", { }); // Ignore mutations on these attributes -defineLazyProperty(members, "ignoredAttributes", { +defineLazyProperty(provides, "ignoredAttributes", { get () { return new Set(); }, @@ -66,7 +66,7 @@ defineLazyProperty(members, "ignoredAttributes", { writable: true, }); -export const membersStatic = { +export const providesStatic = { defineProps (def = this[props] ?? this.props) { if (def instanceof Props && def.Class === this) { // Already defined @@ -94,4 +94,4 @@ export const membersStatic = { // }), }; -export default {hooks, members, membersStatic}; +export default {hooks, provides, providesStatic}; diff --git a/src/shadow/base.js b/src/shadow/base.js index 620374b..4e3be58 100644 --- a/src/shadow/base.js +++ b/src/shadow/base.js @@ -8,7 +8,7 @@ import { defineLazyProperty } from "../util/lazy.js"; const { shadowRoot, shadowRootOptions } = symbols.known; const { attachShadow } = HTMLElement.prototype; -export const members = { +export const provides = { attachShadow (options = this.constructor[shadowRootOptions] ?? this.constructor.shadowRoot) { if (this[shadowRoot] !== undefined) { // We want to include null return this[shadowRoot]; @@ -33,7 +33,7 @@ export const members = { }, }; -defineLazyProperty(members, shadowRoot, { +defineLazyProperty(provides, shadowRoot, { get () { return this.attachShadow(); }, @@ -41,4 +41,4 @@ defineLazyProperty(members, shadowRoot, { writable: true, }); -export default {members}; +export default {provides}; diff --git a/src/states/toggle.js b/src/states/toggle.js index 420362f..0b525c2 100644 --- a/src/states/toggle.js +++ b/src/states/toggle.js @@ -5,7 +5,7 @@ const { internals } = symbols.known; export const dependencies = [internalsPlugin]; -export const members = { +export const provides = { toggleState (state, force) { if (!this[internals]) { return; @@ -21,4 +21,4 @@ export const members = { }, }; -export default { dependencies, members }; +export default { dependencies, provides }; From 777c34fd0c4aeb7569fdf542b24b553b9bf0858c Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 12 Dec 2025 18:13:46 +0100 Subject: [PATCH 64/85] [events] Fix the plugin order (#72) --- src/events/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/base.js b/src/events/base.js index 4375036..e51d756 100644 --- a/src/events/base.js +++ b/src/events/base.js @@ -7,8 +7,8 @@ const { events } = symbols.known; export function setup () { // TODO decouple these from core event functionality - this.addPlugin(propchange); this.addPlugin(onprops); + this.addPlugin(propchange); this.addPlugin(retarget); } From 830de332bc6217f92d36690bac3a754ef50e9613 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 12:54:05 -0500 Subject: [PATCH 65/85] [elements] Default export --- src/elements/base.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/elements/base.js b/src/elements/base.js index 949a5d1..f67cd95 100644 --- a/src/elements/base.js +++ b/src/elements/base.js @@ -70,4 +70,6 @@ export const providesStatic = { this[elements][name] = options; } }, -} +}; + +export default { dependencies, hooks, providesStatic }; From 34cc5cd1205bee89232b7224dee5f2c967f4ec3a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 13:16:52 -0500 Subject: [PATCH 66/85] =?UTF-8?q?`mixins/hooks.js`=20=E2=86=92=20`hooks.js?= =?UTF-8?q?`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Element.js | 2 +- src/{mixins => }/hooks.js | 0 src/index-fn.js | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{mixins => }/hooks.js (100%) diff --git a/src/Element.js b/src/Element.js index 511abc5..5f29e31 100644 --- a/src/Element.js +++ b/src/Element.js @@ -3,7 +3,7 @@ */ import { defineLazyProperty } from "./util/lazy.js"; -import Hooks from "./mixins/hooks.js"; +import Hooks from "./hooks.js"; import { hasPlugin, addPlugin } from "./plugins.js"; import symbols from "./util/symbols.js"; diff --git a/src/mixins/hooks.js b/src/hooks.js similarity index 100% rename from src/mixins/hooks.js rename to src/hooks.js diff --git a/src/index-fn.js b/src/index-fn.js index 9326842..a9c74d2 100644 --- a/src/index-fn.js +++ b/src/index-fn.js @@ -5,6 +5,6 @@ export { default as Element } from "./Element.js"; export * from "./common-plugins.js"; export { default as commonPlugins } from "./common-plugins.js"; -export { default as Hooks } from "./mixins/hooks.js"; +export { default as Hooks } from "./hooks.js"; export { default as symbols } from "./util/symbols.js"; export * from "./plugins.js"; From 178f3b19e2d729a606bd75fcd2e54142a1e6a24e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 17:11:16 -0500 Subject: [PATCH 67/85] Remove unused mixin utility files Deleted apply-mixins.js and define-mixin.js from src/mixins as they are no longer needed. --- src/mixins/apply-mixins.js | 58 -------------------------------------- src/mixins/define-mixin.js | 36 ----------------------- 2 files changed, 94 deletions(-) delete mode 100644 src/mixins/apply-mixins.js delete mode 100644 src/mixins/define-mixin.js diff --git a/src/mixins/apply-mixins.js b/src/mixins/apply-mixins.js deleted file mode 100644 index fab3b58..0000000 --- a/src/mixins/apply-mixins.js +++ /dev/null @@ -1,58 +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, "mixinsApplied")) { - Class.mixinsApplied = []; - } - - if (Class.mixinsApplied.includes(mixin)) { - // Don't apply the same mixin twice - return; - } - - Class.mixinsApplied.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 { - 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/define-mixin.js b/src/mixins/define-mixin.js deleted file mode 100644 index f082e49..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" ? { firstConnected: 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; -} From 0e968e97a3c1916bfc5547d8f59b51a40c4d75d6 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 17:21:23 -0500 Subject: [PATCH 68/85] Support multiple symbol registries --- src/util/symbols.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/util/symbols.js b/src/util/symbols.js index 849cf72..f8344d1 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -10,23 +10,28 @@ export const newSymbols = new Proxy({}, { }, }); -export const newKnownSymbols = new Proxy({}, { - get (target, prop) { - if (typeof prop === "string") { - if (KNOWN_SYMBOLS[prop]) { - return KNOWN_SYMBOLS[prop]; +export function registry (knownSymbols) { + return new Proxy({}, { + get (target, prop) { + if (typeof prop === "string") { + if (knownSymbols[prop]) { + return knownSymbols[prop]; + } + + let ret = Symbol(prop); + knownSymbols[prop] = ret; + return ret; } - let ret = Symbol(prop); - KNOWN_SYMBOLS[prop] = ret; - return ret; - } + return target[prop]; + }, + }); +} - return target[prop]; - }, -}); +export const newKnownSymbols = registry(KNOWN_SYMBOLS); export default { new: newSymbols, known: newKnownSymbols, + registry, }; From dce2fb41c8a9692e88e8f971df4dd737edba0ad4 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 17:21:50 -0500 Subject: [PATCH 69/85] Set default value for knownSymbols in registry --- src/util/symbols.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/symbols.js b/src/util/symbols.js index f8344d1..b6c507a 100644 --- a/src/util/symbols.js +++ b/src/util/symbols.js @@ -10,7 +10,7 @@ export const newSymbols = new Proxy({}, { }, }); -export function registry (knownSymbols) { +export function registry (knownSymbols = []) { return new Proxy({}, { get (target, prop) { if (typeof prop === "string") { From b9628e152e9cf0bfbf9b6e347fba45913498abe4 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 17:26:52 -0500 Subject: [PATCH 70/85] Delete README.md --- src/mixins/README.md | 86 -------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 src/mixins/README.md 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 From a23ec273bc005874b29580f2eae9d9b9be47ec5c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 12 Dec 2025 18:29:48 -0500 Subject: [PATCH 71/85] Decouple plugin code from symbols --- src/Element.js | 2 ++ src/plugins.js | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Element.js b/src/Element.js index 5f29e31..0a47193 100644 --- a/src/Element.js +++ b/src/Element.js @@ -38,6 +38,8 @@ export default class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } + static symbols = symbols.known; + static hooks = new Hooks(); static { defineLazyProperty(this, "hooks", { diff --git a/src/plugins.js b/src/plugins.js index 6546687..8b62ff0 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,7 +1,3 @@ -import symbols from "./util/symbols.js"; - -const { plugins } = symbols.known; - export function hasPlugin (Class, plugin) { let Super = Object.getPrototypeOf(Class); @@ -9,7 +5,10 @@ export function hasPlugin (Class, plugin) { return true; } + let plugins = Class.symbols ? Class.symbols.plugins : "pluginsInstalled"; + if (!Object.hasOwn(Class, plugins)) { + // No plugins installed return false; } @@ -21,6 +20,8 @@ export function addPlugin (Class, plugin) { return; } + let plugins = Class.symbols ? Class.symbols.plugins : "pluginsInstalled"; + if (!Object.hasOwn(Class, plugins)) { Class[plugins] = new Set(); } From 4b63646c41f15a2c44f746a0f0e4f0e2f9387659 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 13:14:59 -0500 Subject: [PATCH 72/85] First part of slot rewrite Still missing dynamic slots and auto-slotting by selector --- src/slots/README.md | 6 +- src/slots/base.js | 80 +++++++-------------- src/slots/has-slotted.js | 29 ++------ src/slots/index.js | 2 + src/slots/named-manual.js | 129 ++++++++++----------------------- src/slots/slot-controller.js | 136 +++++++++++++---------------------- src/slots/slots.js | 61 +++++++++------- 7 files changed, 158 insertions(+), 285 deletions(-) create mode 100644 src/slots/index.js diff --git a/src/slots/README.md b/src/slots/README.md index 8146692..18ba4f5 100644 --- a/src/slots/README.md +++ b/src/slots/README.md @@ -1,5 +1,5 @@ # Nude Slots -- Convenient `this._slots` data structure that allows easy access to named slots -- Allows combining manual and named slot assignment -- Allows slotting via CSS selector (Use `data-assign` on the slot element) +- Convenient data structure that allows easy access to named slots +- Use manual slot assignment without giving up named slot assignment! +- Auto-slot by CSS selector (Use `data-assign` on the slot element) diff --git a/src/slots/base.js b/src/slots/base.js index ef6becf..9a461fe 100644 --- a/src/slots/base.js +++ b/src/slots/base.js @@ -1,62 +1,30 @@ -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]; +import symbols from "../util/symbols.js"; +import Slots from "./slots.js"; +import SlotController from "./slot-controller.js"; + +export const { slots } = symbols.known; + +export const hooks = { + constructed () { + this[slots] = SlotController.create(this); + }, +}; + +export const providesStatic = { + defineSlots (def = this[slots] ?? this.slots) { + if (!(this[slots] instanceof Slots)) { + this[slots] = new Slots(this, def); } - 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); - } -} - -let mutationObserver; - -export default function (Class) { - // Class.prototype.assignSlots = assignSlots; - - return defineMixin(Class, function init () { - if (!this.shadowRoot) { + else if (this[slots] === def) { + // Nothing to do here return; } - this._slots = {}; + // New slots to add + this[slots].add(def); - for (let slot of this.shadowRoot.querySelectorAll("slot")) { - let name = slot.name ?? "default"; - // This emulates how slots normally work: if there are duplicate names, the first one wins - // See https://codepen.io/leaverou/pen/KKLzBPJ - this._slots[name] ??= slot; - } + this.hooks.run("define-slots", {context: this, slots: def}); + }, +}; - if (this.shadowRoot.slotAssignment === "manual") { - // 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); - } - }); - mutationObserver.observe(this, { childList: true }); - } - }); -} +export default { hooks, providesStatic }; diff --git a/src/slots/has-slotted.js b/src/slots/has-slotted.js index 2c2e7f0..b68fbc6 100644 --- a/src/slots/has-slotted.js +++ b/src/slots/has-slotted.js @@ -1,34 +1,19 @@ -import SlotObserver from "./slot-observer.js"; +import symbols from "../util/symbols.js"; /** * :has-slotted polyfill * Use like :is(:has-slotted, .has-slotted) */ -function update (slot) { - slot.classList.toggle("has-slotted", slot.assignedNodes().length > 0); -} - const SUPPORTS_HAS_SLOTTED = globalThis.CSS?.supports("selector(:has-slotted)"); -export const hooks = { - first_connected () { - // Get all slots - if (SUPPORTS_HAS_SLOTTED) { - return; - } - - this.addEventListener("slotchange", event => { - update(event.target); +export const hooks = SUPPORTS_HAS_SLOTTED ? {} : { + constructed () { + let shadowRoot = this[symbols.known.shadowRoot] ?? this.shadowRoot; + shadowRoot.addEventListener("slotchange", event => { + let slot = event.target; + slot.classList.toggle("has-slotted", slot.assignedNodes().length > 0); }); - - let slotObserver = new SlotObserver(records => { - for (let r of records) { - update(r.target); - } - }); - - slotObserver.observe(this); }, }; diff --git a/src/slots/index.js b/src/slots/index.js new file mode 100644 index 0000000..616d435 --- /dev/null +++ b/src/slots/index.js @@ -0,0 +1,2 @@ +export { default as default } from "./base.js"; +export { default as manualNamedSlots } from "./named-manual.js"; diff --git a/src/slots/named-manual.js b/src/slots/named-manual.js index fc61a71..fa11159 100644 --- a/src/slots/named-manual.js +++ b/src/slots/named-manual.js @@ -5,104 +5,53 @@ * - React to dynamic slot changes (added slots, removed slots, renamed slots) * */ -import SlotObserver from "./slot-observer.js"; -let mutationObserver; +import base, { slots } from "./base.js"; -export function getSlot (host, child, slots) { - let slotName = child.slot; +export const dependencies = [base]; - if (slotName) { - slots[slotName] ??= host.shadowRoot.querySelector(`slot[name="${slotName}"]`); - return slots[slotName]; +export const slottedObserver = new MutationObserver(records => { + let host = records[0].target; + if (host.nodeType !== Node.ELEMENT_NODE) { + host = host.parentNode; } - // Default slot - return slots[""]; -} - -export function assign (child, slots) { - let slot = getSlot(this, child, slots); - if (slot) { - let isAssigned = slot.assignedNodes().includes(child); - - if (!isAssigned) { - slot.assign(child); - slot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + for (let r of records) { + if (r.type === "attributes") { + host[slots].assign(r.target); } - } -} - -export function unassign (child, slots) { - let slot = getSlot(this, child, slots); - if (slot) { - const assignedNodes = slot.assignedNodes(); - let isAssigned = assignedNodes.includes(child); - - if (isAssigned) { - slot.assign(...assignedNodes.filter(node => node !== child)); - slot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + else { + for (let node of r.addedNodes) { + host[slots].assign(node); + } } } -} - -export function slotsChanged (records) { - for (let r of records) { - // TODO - } -} - -export default function (Class, options = {}) { - return { - init () { - if (this.shadowRoot?.slotAssignment !== "manual") { - // Nothing to do here - return; - } - - // slotchange won’t fire in this case, so we need to do this the old-fashioned way - mutationObserver ??= new MutationObserver(records => { - let slots = {}; - - let nodesToAssign = records.flatMap(r => - r.type === "attributes" ? [r.target] : r.addedNodes); - let nodesToUnassign = records.flatMap(r => - r.type === "attributes" ? [] : r.removedNodes); +}); - for (let node of nodesToAssign) { - assign(node, slots); - } - - for (let node of nodesToUnassign) { - unassign(node, slots); - } - }); +export const hooks = { + connected () { + if (this[slots].shadowRoot?.slotAssignment !== "manual") { + // Nothing to do here + return; + } - // Fire when either a slot attribute changes, or the children change - mutationObserver.observe(this, { - childList: true, - attributes: true, - attributeFilter: ["slot"], - }); + // Assign all children to their slots + for (let child of this.childNodes) { + this[slots].assign(child); + } - if (options.dynamicSlots) { - let slotObserver = new SlotObserver(records => { - for (let r of records) { - let slot = r.target; - if (r.type === "renamed") { - // TODO unassign all children from the old slot (or assign to other slot with that name, if one exists) - // TODO assign any children with that slot name to the new slot - } - else if (r.type === "added") { - // TODO are there any elements with that slot name? - } - else if (r.type === "removed") { - // TODO Unassign all children from this slot - } - } - }); - slotObserver.observe(this); - } - }, - }; -} + // Observe future changes + // Fire when either a slot attribute changes, or the children change + slottedObserver.observe(this, { + childList: true, + attributes: true, + attributeFilter: ["slot"], + }); + }, + + disconnected () { + slottedObserver.disconnect(); + }, +}; + +export default { dependencies, hooks }; diff --git a/src/slots/slot-controller.js b/src/slots/slot-controller.js index 237d6d4..79e228b 100644 --- a/src/slots/slot-controller.js +++ b/src/slots/slot-controller.js @@ -1,112 +1,72 @@ /** - * Slots data structure - * Gives element classes a this._slots data structure that allows easy access to named slots + * Slot controller + * Per-element data structure for accessing and manipulating slots */ +import symbols from "../util/symbols.js"; -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 const { shadowRoot } = symbols.known; export default class SlotController { - #host; - #slotObserver; - #all = {}; - - static mutationObserver; - - constructor (host, options = {}) { - this.#host = host; - - // TODO this should be a slot property - // Unused for now - this.dynamic = options.dynamic; - } - - get host () { - return this.#host; + constructor (host) { + this.host = host; } - init () { - let shadowRoot = this.#host.shadowRoot; - - if (!shadowRoot) { - return null; + /** + * Assign a child to a slot while preserving all other assigned nodes + * Only for manual assignment + * @param {string} slotName + * @param {Node} child + * @returns {HTMLSlotElement | null} The slot that was assigned to, or null if the slot is not found + */ + assign (child, slotName = child.slot) { + let slot = this[slotName]; + + if (slot) { + slot.assign(...slot.assignedNodes(), child); } - - 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; + else { + // Slot with that name not found, but still unassign from any existing slot + if (child.assignedSlot) { + let assignedNodes = new Set(child.assignedSlot.assignedNodes()); + assignedNodes.delete(child); + child.assignedSlot.assign(...assignedNodes); + } } - if (this.dynamic) { - this.observe(); - } + return slot; } - /** 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); + get $default () { + return this[""]; } - /** Stop observing slot mutations */ - unobserve () { - this.#slotObserver?.disconnect(); + set $default (slot) { + this[""] = slot; } - /** 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]; + get shadowRoot () { + return this.host[shadowRoot] ?? this.host.shadowRoot; + } - if (!this[name]) { - delete this[name]; + update (slotName) { + if (slotName === "$default") { + slotName = ""; } - } - /** Slot removed */ - removed (slot, name = slot.name ?? "") { - removeArrayItem(this.#all[name], slot); - this[name] = this.#all[name][0]; + let selector = slotName ? `slot[name="${slotName}"]` : `slot:not([name])`; + this[slotName] = this.shadowRoot.querySelector(selector); + return this[slotName]; + } - if (!this[name]) { - delete this[name]; - } + get (slotName) { + return this[slotName] ?? this.update(slotName); } - /** Slot renamed */ - renamed (slot, oldName) { - // ||= is important here, as slot.name is "" in the default slot - this.remove(slot, oldName); - this.add(slot); + static create (host) { + return new Proxy(new SlotController(host), { + get (target, slotName) { + return target.get(slotName); + }, + }); } } diff --git a/src/slots/slots.js b/src/slots/slots.js index ad6c633..41b6436 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,35 +1,44 @@ -import SlotController from "./slot-controller.js"; -import symbols from "../util/symbols.js"; +import Hooks from "../hooks.js"; -const { slots } = symbols.new; +export default class Slots { + hooks = new Hooks(); -export const hooks = { - constructor () { - if (!this.constructor[slots]) { - return; + constructor (ElementClass, def) { + this.ElementClass = ElementClass; + this.def = def; + this.hooks.run("constructed", this); + } + + defineSlot (name, def = {}) { + def = typeof def === "string" ? { name: def } : def; + let env = { name, definition: def, oldDefinition: this[name] }; + this.ElementClass.hooks.run("define-slot", env); + + if (env.definition) { + this[env.name] = env.definition; } - this[slots] = new SlotController(this); - }, - firstConnected () { - if (!this.constructor[slots]) { + return this[env.name]; + } + + /** + * Add new slot definitions + * @param {*} def + */ + define (def) { + if (!def) { return; } - this[slots].init(); - }, -}; - -export const membersStatic = { - defineSlots (def = this[slots] ?? this.slots) { if (Array.isArray(def)) { - // Just slot names, no options - def = Object.fromEntries(def.map(name => [name, {}])); + for (let slot of def) { + this.defineSlot(slot.name, slot); + } } - - this[slots] ??= {}; - Object.assign(this[slots], def); - - this.hooks.run("define-slots", {context: this, slots: def}); - }, -}; + else { + for (let name in def) { + this.defineSlot(name, def[name]); + } + } + } +} From 1559a70f7655c847fdd68b2c8df4b8fa69d53907 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 13:47:04 -0500 Subject: [PATCH 73/85] Decouple event plugins from base --- src/common-plugins.js | 2 +- src/events/base.js | 14 ++------------ src/events/index.js | 8 ++++++++ src/events/onprops.js | 3 ++- src/events/propchange.js | 6 ++++-- src/events/retarget.js | 8 +++++--- 6 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 src/events/index.js diff --git a/src/common-plugins.js b/src/common-plugins.js index 32a9248..af28581 100644 --- a/src/common-plugins.js +++ b/src/common-plugins.js @@ -1,5 +1,5 @@ import props from "./props/base.js"; -import events from "./events/base.js"; +import events from "./events/index.js"; import formBehavior from "./form-behavior/index.js"; import shadowStyles from "./styles/shadow.js"; import globalStyles from "./styles/global.js"; diff --git a/src/events/base.js b/src/events/base.js index e51d756..e337855 100644 --- a/src/events/base.js +++ b/src/events/base.js @@ -1,16 +1,6 @@ -import propchange from "./propchange.js"; -import onprops from "./onprops.js"; -import retarget from "./retarget.js"; import symbols from "../util/symbols.js"; -const { events } = symbols.known; - -export function setup () { - // TODO decouple these from core event functionality - this.addPlugin(onprops); - this.addPlugin(propchange); - this.addPlugin(retarget); -} +export const { events } = symbols.known; export const hooks = { first_constructor_static () { @@ -29,4 +19,4 @@ export const providesStatic = { }, }; -export default { setup, hooks, providesStatic }; +export default { hooks, providesStatic }; diff --git a/src/events/index.js b/src/events/index.js new file mode 100644 index 0000000..63340e6 --- /dev/null +++ b/src/events/index.js @@ -0,0 +1,8 @@ +import base from "./base.js"; +import propchange from "./propchange.js"; +import onprops from "./onprops.js"; +import retarget from "./retarget.js"; + +export const dependencies = [base, propchange, onprops, retarget]; + +export default {dependencies}; diff --git a/src/events/onprops.js b/src/events/onprops.js index b011bd2..257720c 100644 --- a/src/events/onprops.js +++ b/src/events/onprops.js @@ -3,10 +3,11 @@ * Add on* props for UI events, just like native UI events */ import propsPlugin from "../props/base.js"; +import base from "./base.js"; import symbols from "../util/symbols.js"; const { eventProps } = symbols.new; -export const dependencies = [propsPlugin]; +export const dependencies = [propsPlugin, base]; export const hooks = { defineEvents (env) { diff --git a/src/events/propchange.js b/src/events/propchange.js index 230ffbc..1e773a7 100644 --- a/src/events/propchange.js +++ b/src/events/propchange.js @@ -4,9 +4,11 @@ */ import symbols from "../util/symbols.js"; +import base, { events } from "./base.js"; const { propchange } = symbols.new; -const { events } = symbols.known; + +export const dependencies = [base]; export const hooks = { first_constructor_static () { @@ -52,4 +54,4 @@ export const hooks = { }, }; -export default {hooks}; +export default {dependencies, hooks}; diff --git a/src/events/retarget.js b/src/events/retarget.js index 99e0f92..fa29e52 100644 --- a/src/events/retarget.js +++ b/src/events/retarget.js @@ -4,8 +4,10 @@ import { resolveValue } from "../util.js"; import { pick } from "../util/pick.js"; -import symbols from "../util/symbols.js"; -const { events } = symbols.known; + +import base, { events } from "./base.js"; + +export const dependencies = [base]; export const hooks = { first_connected () { @@ -58,4 +60,4 @@ export const hooks = { }, }; -export default {hooks}; +export default {dependencies, hooks}; From 2817b6a89efd4648f6570deaf4780616e7ec22a3 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 13:51:03 -0500 Subject: [PATCH 74/85] Decouple form behavior plugins from base --- src/form-behavior/base.js | 12 ++---------- src/form-behavior/delegate.js | 8 ++++++-- src/form-behavior/index.js | 8 +++++--- src/form-behavior/like.js | 7 +++++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/form-behavior/base.js b/src/form-behavior/base.js index fd8e8b2..475e86c 100644 --- a/src/form-behavior/base.js +++ b/src/form-behavior/base.js @@ -1,20 +1,12 @@ import symbols from "../util/symbols.js"; -import * as like from "./like.js"; -import * as delegate from "./delegate.js"; import internalsPlugin from "../internals/base.js"; export const dependencies = [internalsPlugin]; -const { formBehavior } = symbols.known; - -export function setup () { - // TODO decouple these from core functionality - this.addPlugin(like); - this.addPlugin(delegate); -} +export const { formBehavior } = symbols.known; export const hooks = { firstConstructorStatic () { @@ -40,4 +32,4 @@ export const providesStatic = { }, }; -export default { dependencies, setup, hooks, providesStatic }; +export default { dependencies, hooks, providesStatic }; diff --git a/src/form-behavior/delegate.js b/src/form-behavior/delegate.js index 95064fd..1b46d4d 100644 --- a/src/form-behavior/delegate.js +++ b/src/form-behavior/delegate.js @@ -3,9 +3,13 @@ */ import { delegate } from "../util/delegate.js"; import symbols from "../util/symbols.js"; +import base, { formBehavior } from "./base.js"; -const { formBehavior, internals } = symbols.known; +const { internals } = symbols.known; +export const dependencies = [base]; + +// Find built-in properties to delegate let objects = [ElementInternals, HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]; let props = objects.map(o => new Set(Object.getOwnPropertyNames(o.prototype))); const defaultProperties = props.reduce((acc, cur) => acc.intersection(cur)); @@ -28,4 +32,4 @@ export const hooks = { } }; -export default {hooks}; +export default {dependencies, hooks}; diff --git a/src/form-behavior/index.js b/src/form-behavior/index.js index 1a9576e..8a774a4 100644 --- a/src/form-behavior/index.js +++ b/src/form-behavior/index.js @@ -1,6 +1,8 @@ -import * as base from "./base.js"; -import * as like from "./like.js"; -import * as delegate from "./delegate.js"; +import base from "./base.js"; +import like from "./like.js"; +import delegate from "./delegate.js"; + +export { base, like, delegate }; export const dependencies = [ base, diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js index 09597ac..c6f90ce 100644 --- a/src/form-behavior/like.js +++ b/src/form-behavior/like.js @@ -1,8 +1,11 @@ import symbols from "../util/symbols.js"; import { resolveValue } from "../util.js"; import { getRole } from "./role.js"; +import base, { formBehavior } from "./base.js"; -const { formBehavior, internals } = symbols.known; +export const dependencies = [base]; + +const { internals } = symbols.known; export const hooks = { first_connected () { @@ -32,4 +35,4 @@ export const hooks = { }, }; -export default {hooks}; +export default {dependencies, hooks}; From fc3fd4b69d99547a778e7b0a8ea026aee4f6c803 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 14:07:05 -0500 Subject: [PATCH 75/85] Add support for parent hooks So that subclasses do not overwrite parent class hooks and plugins don't need to be installed separately on every subclass --- src/Element.js | 4 +++- src/hooks.js | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Element.js b/src/Element.js index 0a47193..d1cf816 100644 --- a/src/Element.js +++ b/src/Element.js @@ -45,7 +45,9 @@ export default class NudeElement extends HTMLElement { defineLazyProperty(this, "hooks", { value: this.hooks, get (hooks) { - return new Hooks(hooks); + let ret = new Hooks(hooks); + ret.parent = this.super?.hooks; + return ret; }, configurable: true, writable: true, diff --git a/src/hooks.js b/src/hooks.js index 562f906..9670f2f 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -2,8 +2,16 @@ export default class Hooks { /** @type {Map} */ hooks = new Map(); + /** @type {Set} */ ran = new Set(); + /** + * A parent hooks object to inherit from, if any. + * Any parent hooks will be executed before this object's hooks. + * @type {Hooks | null} + */ + parent = null; + constructor (hooks) { if (hooks) { this.add(hooks); @@ -69,6 +77,12 @@ export default class Hooks { name = Hooks.getCanonicalName(name); this.ran.add(name); + this.parent?.run(name, env); + + for (let hooks of this.parent?.hooks) { + hooks.run(name, env); + } + if (name.startsWith("first_")) { this.hooks.get(name)?.runOnce(env); } From 1c973b35c249497b4a380a8d0dad06354be29f7a Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 14:09:03 -0500 Subject: [PATCH 76/85] Update hooks.js --- src/hooks.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/hooks.js b/src/hooks.js index 9670f2f..b7677df 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -79,10 +79,6 @@ export default class Hooks { this.parent?.run(name, env); - for (let hooks of this.parent?.hooks) { - hooks.run(name, env); - } - if (name.startsWith("first_")) { this.hooks.get(name)?.runOnce(env); } From 85fca37bd967774a81363adaefffac0bff58a4a9 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 15 Dec 2025 17:17:02 -0500 Subject: [PATCH 77/85] Update src/events/index.js Co-authored-by: Dmitry Sharabin --- src/events/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/events/index.js b/src/events/index.js index 63340e6..f434e5c 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -3,6 +3,6 @@ import propchange from "./propchange.js"; import onprops from "./onprops.js"; import retarget from "./retarget.js"; -export const dependencies = [base, propchange, onprops, retarget]; +export const dependencies = [base, onprops, propchange, retarget]; export default {dependencies}; From 302dc39ecf6ab00b49c33ce9f8d1d3c62e178250 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 16 Dec 2025 00:10:22 +0100 Subject: [PATCH 78/85] [shadow] Some fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Don’t shadow `attachShadow` from `HTML.prototype` - Avoid infinite recursion --- src/shadow/base.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/shadow/base.js b/src/shadow/base.js index 4e3be58..34ddef9 100644 --- a/src/shadow/base.js +++ b/src/shadow/base.js @@ -6,15 +6,16 @@ import symbols from "../util/symbols.js"; import { defineLazyProperty } from "../util/lazy.js"; const { shadowRoot, shadowRootOptions } = symbols.known; -const { attachShadow } = HTMLElement.prototype; +const _attachShadow = HTMLElement.prototype.attachShadow; export const provides = { attachShadow (options = this.constructor[shadowRootOptions] ?? this.constructor.shadowRoot) { - if (this[shadowRoot] !== undefined) { // We want to include null - return this[shadowRoot]; + let descriptor = Object.getOwnPropertyDescriptor(this, shadowRoot); + if (descriptor?.value) { + return descriptor.value; } - if (attachShadow === undefined) { + if (_attachShadow === undefined) { // Not supported return this[shadowRoot] = null; } @@ -22,7 +23,7 @@ export const provides = { this[shadowRootOptions] ??= options; try { - this[shadowRoot] = attachShadow.call(this, options); + this[shadowRoot] = _attachShadow.call(this, options); this.hooks.run("shadow-attached", {context: this, shadowRoot}); } catch (error) { From 0c398b5c81164cc8414595da9433fe1ce859d5f6 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 16 Dec 2025 00:31:48 +0100 Subject: [PATCH 79/85] Correctly run hooks --- src/shadow/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shadow/base.js b/src/shadow/base.js index 34ddef9..0ddc1be 100644 --- a/src/shadow/base.js +++ b/src/shadow/base.js @@ -24,7 +24,7 @@ export const provides = { try { this[shadowRoot] = _attachShadow.call(this, options); - this.hooks.run("shadow-attached", {context: this, shadowRoot}); + this.constructor.hooks.run("shadow-attached", {context: this, shadowRoot}); } catch (error) { this[shadowRoot] = null; From a86249782fb417679a735ddf91f13007cfc066e7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 16 Dec 2025 10:29:26 -0500 Subject: [PATCH 80/85] `super` --- src/Element.js | 6 +++--- src/styles/util.js | 2 +- src/util.js | 1 + src/util/get-supers.js | 16 ---------------- src/util/super.js | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 20 deletions(-) delete mode 100644 src/util/get-supers.js create mode 100644 src/util/super.js diff --git a/src/Element.js b/src/Element.js index d1cf816..c1666d3 100644 --- a/src/Element.js +++ b/src/Element.js @@ -3,6 +3,7 @@ */ import { defineLazyProperty } from "./util/lazy.js"; +import { getSuper } from "./util/super.js"; import Hooks from "./hooks.js"; import { hasPlugin, addPlugin } from "./plugins.js"; import symbols from "./util/symbols.js"; @@ -58,15 +59,14 @@ export default class NudeElement extends HTMLElement { * Like super, but dynamic */ get super () { - return this.constructor.super?.prototype; + return getSuper(this); } /** * Like super, but dynamic */ static get super () { - let Super = Object.getPrototypeOf(this); - return Super === Function.prototype ? null : Super; + return getSuper(this); } /** Plugins to install */ diff --git a/src/styles/util.js b/src/styles/util.js index 0d39ffb..58f5fc0 100644 --- a/src/styles/util.js +++ b/src/styles/util.js @@ -1,3 +1,3 @@ export { adoptCSS } from "./util/adopt-css.js"; export { fetchCSS } from "./util/fetch-css.js"; -export { getSupers } from "../util/get-supers.js"; +export { getSupers, getSuper } from "../util/super.js"; diff --git a/src/util.js b/src/util.js index 980846f..e9ea7ea 100644 --- a/src/util.js +++ b/src/util.js @@ -3,3 +3,4 @@ export * from "./util/is-class.js"; export * from "./util/is-subclass-of.js"; export * from "./util/lazy.js"; export * from "./util/pick.js"; +export * from "./util/get-supers.js"; diff --git a/src/util/get-supers.js b/src/util/get-supers.js deleted file mode 100644 index f3e3068..0000000 --- a/src/util/get-supers.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Get the class hierarchy to the given class - * @param {Function} Class - * @param {Function} [FromClass] Optional class to stop at - * @returns {Function[]} - */ -export function getSupers (Class, FromClass) { - let classes = []; - - while (Class && Class !== FromClass) { - classes.unshift(Class); - Class = Object.getPrototypeOf(Class); - } - - return classes; -} diff --git a/src/util/super.js b/src/util/super.js new file mode 100644 index 0000000..23ca133 --- /dev/null +++ b/src/util/super.js @@ -0,0 +1,34 @@ +/** + * 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) { + const classes = []; + + while (Class = getSuper(Class)) { + if (Class === FromClass) { + break; + } + + classes.unshift(Class); + } + + return classes; +} + +/** + * Similar to calling `super` in a method, but dynamically bound + * @param {object | Function} obj - An object, class or instance + * @returns {Function | null} The superclass of the object, or null if no superclass exists. + */ +export function getSuper (obj) { + let Super = Object.getPrototypeOf(obj); + + if (Super === Function.prototype) { + return null; + } + + return Super; +} From ec1ba1d67e0338b1dd227371b4966dcbfd8586af Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 16 Dec 2025 10:30:07 -0500 Subject: [PATCH 81/85] Fix bug Co-Authored-By: Dmitry Sharabin --- src/plugins.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins.js b/src/plugins.js index 8b62ff0..0e9fe7a 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -26,6 +26,8 @@ export function addPlugin (Class, plugin) { Class[plugins] = new Set(); } + Class[plugins].add(plugin); + if (plugin.dependencies) { for (let dependency of plugin.dependencies) { addPlugin(Class, dependency); From 335e8464493d0d7e0deb22e8b92760bc19ebb46a Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 16 Dec 2025 17:08:34 +0100 Subject: [PATCH 82/85] [styles] Add self to the list of supers --- src/styles/global.js | 1 + src/styles/shadow.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/styles/global.js b/src/styles/global.js index 65d8204..cbd71f8 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -13,6 +13,7 @@ export const hooks = { } let supers = getSupers(this, HTMLElement); + supers.push(this); let Super; for (let Class of supers) { diff --git a/src/styles/shadow.js b/src/styles/shadow.js index 544ee6b..6428e7a 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -13,6 +13,7 @@ export const hooks = { } let supers = getSupers(this, HTMLElement); + supers.push(this); for (let Class of supers) { if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, fetchedStyles)) { @@ -35,6 +36,7 @@ export const hooks = { let Self = this.constructor; let supers = getSupers(Self, HTMLElement); + supers.push(Self); for (let Class of supers) { if (Class[fetchedStyles]) { From 4f41be8114d9ae5046423fe95d95a723f9c2a222 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 16 Dec 2025 11:10:34 -0500 Subject: [PATCH 83/85] Update src/util.js Co-authored-by: Dmitry Sharabin --- src/util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util.js b/src/util.js index e9ea7ea..85069ab 100644 --- a/src/util.js +++ b/src/util.js @@ -3,4 +3,4 @@ export * from "./util/is-class.js"; export * from "./util/is-subclass-of.js"; export * from "./util/lazy.js"; export * from "./util/pick.js"; -export * from "./util/get-supers.js"; +export * from "./util/super.js"; From af314fb98fab85db1df7289f58456ad35bced68a Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 16 Dec 2025 10:27:51 +0100 Subject: [PATCH 84/85] Make the elements plugin work --- src/elements/base.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/elements/base.js b/src/elements/base.js index f67cd95..d0f4508 100644 --- a/src/elements/base.js +++ b/src/elements/base.js @@ -24,7 +24,13 @@ function getElement (host, options) { } export const hooks = { - connected () { + first_constructor_static () { + if (this.elements) { + this.defineElements(); + } + }, + + constructed () { if (!this[elements]) { return; } @@ -53,6 +59,8 @@ export const providesStatic = { get () { return getElement(this, this.constructor[elements][name]); }, + configurable: true, + enumerable: true, }); } return ret; From 267f80134f5d461aa7b99aa819e100e243af987e Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Tue, 16 Dec 2025 16:49:24 +0100 Subject: [PATCH 85/85] Use the hook that runs earlier --- src/elements/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/base.js b/src/elements/base.js index d0f4508..4484cd3 100644 --- a/src/elements/base.js +++ b/src/elements/base.js @@ -30,7 +30,7 @@ export const hooks = { } }, - constructed () { + first_connected () { if (!this[elements]) { return; }