diff --git a/README.md b/README.md index 335a070..08a1e67 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ for creating reactive web components that behave just like native HTML elements. -Elements can extend `NudeElement` to get the nicest, most declarative syntax, -or import individual mixins as helper functions and use them with any `HTMLElement` subclass. +Elements can extend `Element` to get the nicest, most declarative syntax, +or import individual mixins and use them with any `HTMLElement` subclass. -**Note:** This is a work in progress, developed in the open. -Try it and please report issues and provide feedback! +> [!NOTE] +> This is a work in progress, developed in the open. +> Try it and please report issues and provide feedback! ## Features @@ -24,18 +25,20 @@ Try it and please report issues and provide feedback! ## Usage -### No hassle, less control: the `NudeElement` class +### No hassle, less control: the `Element` class -Defining your element as a subclass of `NudeElement` gives you the nicest, most declarative syntax. +Defining your element as a subclass of `Element` gives you the nicest, most declarative syntax. +This includes all commonly used mixins automatically, though they are only activated when their relevant properties are used in your element subclass. ```js -import NudeElement from "nude-element"; +import Element from "nude-element"; -class MySlider extends NudeElement { +class MySlider extends Element { constructor () { // ... } + // Automatically activates the props mixin static props = { min: { type: Number, @@ -67,6 +70,7 @@ class MySlider extends NudeElement { }, }; + // Automatically activates the events mixin static events = { // Propagate event from shadow DOM element change: { @@ -81,6 +85,7 @@ class MySlider extends NudeElement { }, }; + // Automatically activates the formAssociated mixin static formAssociated = { like: el => el._el.slider, role: "slider", @@ -90,126 +95,81 @@ class MySlider extends NudeElement { } ``` -### More hassle, more control: Composable mixins +### A little hassle, a little more control: The `NudeElement` class -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. +`Element` inherits from `NudeElement`, which is nearly identical with one exception: +Instead of including all commonly used mixins automatically, +it includes no mixins at all. +To add mixins, you extend it and add a `mixins` static property. -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: +This can be useful when you’re trying to keep bundle size to a minimum, since even if mixins are only activated when your subclass uses them, +they will won't be tree-shaken away, since bundlers don’t understand how this works. ```js -import { - defineProps, - defineEvents, - defineFormAssociated, -} from "nude-element"; +import { NudeElement, Props, Events, FormAssociated } from "nude-element"; -class MySlider extends HTMLElement { - constructor () { - // ... +class MySlider extends NudeElement { + static mixins = [Props, Events, FormAssociated]; - eventHooks.init.call(this); - formAssociatedHooks.init.call(this); - propHooks.init.call(this); - } + // ... } +``` -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", -}); +### With custom base class: Subclass factories + +If you need to use a custom base class (e.g. `LitElement`), rather than `HTMLElement`, all Nude mixins are also available as subclass factories, +include `Element` and `NudeElement`: + +```js +import { ElementMixin } from "nude-element"; +import { LitElement } from "lit"; + +class MySlider extends ElementMixin(LitElement) { + // ... +} ``` -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: +Individual mixins are also available as subclass factories: ```js -import { defineProps } from "nude-element"; -import Hooks from "nude-element/hooks"; +import { Props, Events, FormAssociated } from "nude-element/mixins"; +import { LitElement } from "lit"; -class MyElement extends HTMLElement { - // Caution: if MyElement has subclasses, this will be shared among them! - static hooks = new Hooks(); +class MySlider extends Props(Events(FormAssociated(LitElement))) { + // ... +} +``` + +### More hassle, more control: Composable mixins + +If Nude Element taking over your parent class seems too intrusive, +you can pull in mixins and apply them to any base class you want in-place without affecting the inheritance chain, +at the cost of handling some of the plumbing yourself. + +There are three parts: +1. Apply `applyMixins` to your class to apply the mixins to it +2. Make sure to call `this.init()` in your constructor, since `applyMixins` cannot modify your constructor, so that’s the only way to run initialization logic + +```js +import { Props, Events, FormAssociated, applyMixins } from "nude-element"; +class MySlider extends HTMLElement { constructor () { super(); - // Then you can call the hooks at the appropriate times: - this.constructor.hooks.run("init", this); + // Your own init logic here... + + this.init?.(); } -} -defineProps(MyElement, { - // Props… -}); + static { + applyMixins(this, [Props, Events, FormAssociated]); + } +} ``` -Read more: +Individual mixin docs: - [Using Props](src/props/) - [Events](src/events/) -- [Form-associated elements](src/formAssociated/) -- [Mixins](src/mixins/) - - -## Known Hooks - -These hooks are automatically managed when you use the `NudeElement` class. -If you choose to import mixins directly, you need to manage when to call them yourself. +- [Slots](src/slots/) -- `prepare`: Runs once per class, as soon as a mixin is added -- `setup`: Runs once per class, before any element is fully constructed -- `start`: Runs on element constructor -- `constructed`: Runs after element constructor (async) -- `init`: Runs when element is connected for the first time -- `disconnected`: Runs when element is disconnected diff --git a/mixins-and-hooks.md b/mixins-and-hooks.md new file mode 100644 index 0000000..6c89b05 --- /dev/null +++ b/mixins-and-hooks.md @@ -0,0 +1,13 @@ +| Mixin | Hooks uses | Needs `super`? | Imports mixins | +|-------|-------------|-----------------|-----------------| +| mounted | 1) First `connectedCallback` for class; 2) First `connectedCallback` | | | +| shadowStyles | 1) First `connectedCallback` for class; 2) First `connectedCallback` | ✅ (for now, fakes it with `getSupers()`) | `mounted` | +| globalStyles | 1) First `connectedCallback` for class; 2) First `connectedCallback` | ✅ (for now, fakes it with `getSupers()`) | `mounted` | +| formAssociated | First `connectedCallback` | | `mounted` | +| defineProps | First `connectedCallback` | | | +| defineEvents | 1) First `constructor` (once per class); 2) First `connectedCallback` | | `defineProps` | +| defineSlots | First `connectedCallback` | | | +| `has-slotted` | First `connectedCallback` | | | +| named-manual | First `connectedCallback` | | | +| slots | 1) First `constructor` (once per class); 2) First `connectedCallback` | | | +| states | First `connectedCallback` | | `mounted` | diff --git a/src/Element.js b/src/Element.js deleted file mode 100644 index eda2420..0000000 --- a/src/Element.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Base class for all elements - */ -import defineProps from "./props/defineProps.js"; -import defineEvents from "./events/defineEvents.js"; -import defineFormAssociated from "./form-associated.js"; -import defineMixin from "./mixins/define-mixin.js"; - -import { shadowStyles, globalStyles } from "./styles/index.js"; -import Hooks from "./mixins/hooks.js"; - -const instanceInitialized = Symbol("instanceInitialized"); -const classInitialized = Symbol("classInitialized"); - -const Self = class NudeElement extends HTMLElement { - constructor () { - super(); - - if (!this.constructor[classInitialized]) { - this.constructor.init(); - } - - this.constructor.hooks.run("start", this); - - if (this.propChangedCallback && this.constructor.props) { - this.addEventListener("propchange", this.propChangedCallback); - } - - // We use a microtask so that this executes after the subclass constructor has run as well - Promise.resolve().then(this.constructor.hooks.run("constructed", this)); - } - - connectedCallback () { - if (!this[instanceInitialized]) { - // Stuff that runs once per element - this.constructor.hooks.run("init", this); - - this[instanceInitialized] = true; - } - - this.constructor.hooks.run("connected", this); - } - - disconnectedCallback () { - this.constructor.hooks.run("disconnected", this); - } - - static init () { - // Stuff that runs once per class - if (this[classInitialized]) { - return false; - } - - this.hooks = new Hooks(this.hooks); - - if (this.props) { - defineProps(this); - } - - if (this.events) { - defineEvents(this); - } - - if (this.formAssociated) { - defineFormAssociated(this); - } - - if (this.styles) { - defineMixin(this, shadowStyles); - } - - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; - } - - if (this.globalStyles) { - defineMixin(this, globalStyles); - } - - this.hooks.run("setup", this); - - return (this[classInitialized] = true); - } -}; - -export default Self; diff --git a/src/apply.js b/src/apply.js new file mode 100644 index 0000000..0ee4b30 --- /dev/null +++ b/src/apply.js @@ -0,0 +1,105 @@ +import { extendClass } from "./util/extend-class.js"; +import { satisfiedBy, mixinsApplied, onApply, conflictPolicy } from "./util/symbols.js"; +import lifecycleHooks, { staticLifecycleHooks } from "./lifecycle.js"; +import { ConflictPolicy } from "./util/conflict-policy.js"; +import { getSuper } from "./util/super.js"; + +export function satisfies (Class, requirement) { + if (!requirement) { + // No reqs + return true; + } + + switch (typeof requirement) { + case "function": + return requirement(Class); + case "string": + case "symbol": + return Class[requirement] !== undefined; + } + + if (Array.isArray(requirement)) { + // Array of potential fields (OR) + return requirement.some(req => satisfies(Class, req)); + } + + if (requirement.prototype) { + return satisfies(Class.prototype, requirement.or); + } + + if (requirement.and) { + return requirement.and.every(req => satisfies(Class, req)); + } + + return false; +} + +const defaultConflictPolicy = new ConflictPolicy({ + throw: true, + merge: lifecycleHooks, +}); + +const defaultStaticConflictPolicy = new ConflictPolicy({ + throw: true, + merge: staticLifecycleHooks, +}); + +/** + * Apply a bunch of mixins to a class iff it satisfies their protocols + * @param { FunctionConstructor } Class + * @param { Array } [mixins = Class.mixins] + * @void + */ +export function applyMixins (Class = this, mixins = Class.mixins) { + if (!mixins?.length) { + return; + } + + if (!Object.hasOwn(Class, mixinsApplied)) { + Class[mixinsApplied] = [...(Object.getPrototypeOf(Class)[mixinsApplied] || [])]; + } + + const mixinsToApply = mixins.filter(Mixin => !Class[mixinsApplied].includes(Mixin) && satisfies(Class, Mixin[satisfiedBy])); + + if (mixinsToApply.length === 0) { + return false; + } + + // Make sure any lifecycle hooks are actually applied + // Otherwise we'd be extending the mixin hooks, what a mess! + for (let lifecycleHook of lifecycleHooks) { + if ( + // Doesn't exist on any mixin + !mixinsToApply.some(Mixin => Mixin.prototype[lifecycleHook]) + + // Or already exists on the class + || Class.prototype[lifecycleHook] + ) { + continue; + } + + Class.prototype[lifecycleHook] = function (...args) { + getSuper(this)?.[lifecycleHook]?.call(this, ...args); + } + } + + for (const Mixin of mixinsToApply) { + extendClass(Class, Mixin, { + skippedProperties: [satisfiedBy, onApply], + conflictPolicy: ConflictPolicy.combine(defaultConflictPolicy, Mixin.prototype[conflictPolicy]), + conflictPolicyStatic: ConflictPolicy.combine(defaultStaticConflictPolicy, Mixin[conflictPolicy]), + }); + Class[mixinsApplied].push(Mixin); + + if (Mixin[onApply]) { + Mixin[onApply].call(Class); + } + } + + return true; +} + +export function applyMixin (Class, Mixin) { + return applyMixins(Class, [Mixin]); +} + diff --git a/src/common-mixins.js b/src/common-mixins.js new file mode 100644 index 0000000..e93a8bb --- /dev/null +++ b/src/common-mixins.js @@ -0,0 +1,13 @@ +/** + * All mixins + */ + +import Props from "./mixins/props/defineProps.js"; +import FormAssociated from "./mixins/form-associated.js"; +import Events from "./mixins/events/defineEvents.js"; +import ShadowStyles from "./mixins/styles/shadow.js"; +import GlobalStyles from "./mixins/styles/global.js"; + +export { Props, FormAssociated, Events, ShadowStyles, GlobalStyles }; + +export default [Props, FormAssociated, Events, ShadowStyles, GlobalStyles]; diff --git a/src/element.js b/src/element.js new file mode 100644 index 0000000..655b0f5 --- /dev/null +++ b/src/element.js @@ -0,0 +1,26 @@ +/** + * Base class with all mixins applied + */ + +import { Mixin as NudeElementMixin } from "./nude-element.js"; +import commonMixins from "./common-mixins.js"; +import { initialized } from "./nude-element.js"; + +export const Mixin = (Super = HTMLElement, mixins = commonMixins) => class Element extends NudeElementMixin(Super) { + static mixins = mixins; + + static init () { + if (this[initialized]) { + return; + } + + // Ensure the class has its own, and is not using the superclass' mixins + if (this !== Element && Object.hasOwn(this, "mixins")) { + this.mixins = this.mixins.slice(); + } + + return super.init(); + } +}; + +export default Mixin(); diff --git a/src/events/defineEvents.js b/src/events/defineEvents.js deleted file mode 100644 index 835595b..0000000 --- a/src/events/defineEvents.js +++ /dev/null @@ -1,152 +0,0 @@ -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"; - -/** - * - * @param {Function} Class - * @param {string} name Event name - * @param {object} options - * @returns - */ -function retargetEvent (name, from) { - if (typeof from === "function") { - from = { on: from }; - } - - let type = from?.type ?? name; - - 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); - } - }; -} - -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); - - 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); -} 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/index.js b/src/index.js index 5415fb6..239eb01 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ -export { default as default } from "./Element.js"; +export { default as default } from "./element.js"; +export { default as NudeElement } from "./nude-element.js"; -export { default as defineProps } from "./props/defineProps.js"; +export { default as defineProps } from "./mixins/props/defineProps.js"; export { default as defineEvents } from "./events/defineEvents.js"; export { default as defineSlots } from "./slots/defineSlots.js"; export { default as defineFormAssociated } from "./form-associated.js"; -export { default as defineMixin } from "./mixins/define-mixin.js"; -export { default as Hooks } from "./mixins/hooks.js"; +export { default as Hooks } from "./hooks/hooks.js"; diff --git a/src/lifecycle.js b/src/lifecycle.js new file mode 100644 index 0000000..a33bf09 --- /dev/null +++ b/src/lifecycle.js @@ -0,0 +1,14 @@ +/** + * All known lifecycle hooks + * Mixins can import this set and add their own + */ +export default new Set([ + "init", + "connectedCallback", + "disconnectedCallback", + "adoptedCallback", + "connectedMoveCallback", + "attributeChangedCallback", +]); + +export const staticLifecycleHooks = new Set(); diff --git a/src/mixins/README.md b/src/mixins/README.md deleted file mode 100644 index ba1aac7..0000000 --- a/src/mixins/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# Nude mixins - -This mixin is at the core of all other Nude mixins. -It provides a way to define composable hooks that are executed at various points in the element lifecycle. - -## Usage - -If you are inheriting from `NudeElement`, there is little to do besides specifying what code you need to run: - -```js -defineMixin(MyElement, { - start () { - console.log("The first instance of", this, "was just created"); - }, - init () { - console.log(this, "was just connected for the first time"); - }, -}) -``` - -If you are not inheriting from `NudeElement`, you will need to do the plumbing yourself. -Basically two things: -1. Create a `Class.hooks` object. -2. Call `run()` on it at the appropriate times. - -It looks like this: - -```js -import Hooks from "nude-element/mixins/hooks.js"; - -const Self = class MyElement extends HTMLElement { - constructor () { - super(); - this.constructor.init(); - - // Constructor stuff… - - this.constructor.hooks.run("constructed", this); - } - - connectedCallback () { - if (!this.initialized) { - // Stuff that runs once per element - this.constructor.hooks.run("init", this); - this.initialized = true; - } - - this.constructor.hooks.run("connected", this); - } - - disconnectedCallback () { - this.constructor.hooks.run("disconnected", this); - } - - static init () { - // Stuff that runs once per class - if (this.initialized) { - return; - } - - // Caution: do *not* just use a static class field - // It will be shared between all classes that inherit from this one! - this.hooks = new Hooks(this.hooks); - - this.hooks.run("start", this); - - this.initialized = true; - } -} -``` - - -## Hooks - -| Hook | `this` | Description | -|------|--------|-------------| -| `setup` | Class | Called when the first instance of the class is created. | -| `start` | Instance | Called when the constructor starts, but after `setup` has ran | -| `init` | Instance | Called when the element is first connected to the DOM (once per instance). This is the most common hook used. | -| `constructed` | Instance | Called once per instance | -| `connected` | Instance | Called when the element is connected to the DOM | -| `disconnected` | Instance | Called when the element is disconnected from the DOM | - -Note that these are just the default hooks provided by `NudeElement`. -You can define your own hooks as needed. -All you need to do to define a hook is to add a callback to it. \ No newline at end of file diff --git a/src/mixins/apply-mixins.js b/src/mixins/apply-mixins.js deleted file mode 100644 index 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 c424667..0000000 --- a/src/mixins/define-mixin.js +++ /dev/null @@ -1,36 +0,0 @@ -import { defineLazyProperty } from "../util/lazy.js"; - -export default function defineMixin (Class, config) { - if (Array.isArray(config)) { - // Multiple mixins - return config.map(f => defineMixin(Class, f)); - } - - config = typeof config === "function" ? { init: config } : config; - let { properties, prepare, ...hooks } = config; - - if (properties) { - for (let name in properties) { - defineLazyProperty(Class.prototype, name, properties[name]); - } - } - - if (prepare) { - prepare.call(Class); - } - - if (Class.hooks) { - // Class already supports hooks - if (Class.hooks.add) { - Class.hooks.add(hooks); - } - else { - // Hooks object not created yet? - for (let name in hooks) { - (Class.hooks[name] ??= []).push(hooks[name]); - } - } - } - - return hooks; -} diff --git a/src/mixins/events/README.md b/src/mixins/events/README.md new file mode 100644 index 0000000..fdd45ba --- /dev/null +++ b/src/mixins/events/README.md @@ -0,0 +1,64 @@ +# Events mixin + +This mixin handles: +- Defining props and attributes for UI events on a component (just like native HTML elements) +- Automatically managing events that fire when a specific prop changes (`propchange` property) +- Automatically retargeting events from shadow DOM elements to the host (`from` property) + +Eventually we should split this into three mixins. + +## Usage + +Through the `Element` base class: + +```js +import Element from "nude-element"; + +class MyElement extends Element { + static events = { + // ... + } +} +``` + +As traditional mixin: +```js +import { WithEvents } from "nude-element"; + +class MyElement extends WithEvents { + static events = { + // ... + } +} +``` + +Or, with a custom base class (e.g. `LitElement`): +```js +import { WithEventsMixin } from "nude-element"; +import LitElement from "lit"; + +class MyElement extends WithEventsMixin(LitElement) { + static events = { + // ... + } +} +``` + +As composable mixin: +```js +import { WithEvents, applyMixins } from "nude-element"; + +class MyElement extends HTMLElement { + constructor () { + this.init(); + } + + static events = { + // ... + } + + static { + applyMixins(this, WithEvents); + } +} +``` diff --git a/src/mixins/events/defineEvents.js b/src/mixins/events/defineEvents.js new file mode 100644 index 0000000..e9b4425 --- /dev/null +++ b/src/mixins/events/defineEvents.js @@ -0,0 +1,183 @@ +// To be split into three mixins: A base events mixin, a retargeting mixin, and a propchange event mixin + +import { Mixin as PropsMixin } from "../props/defineProps.js"; +// import PropChangeEvent from "../props/PropChangeEvent.js"; +import { resolveValue } from "../../util/resolve-value.js"; +import { pick } from "../../util/pick.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; + +const { initialized, eventProps, propEvents, retargetedEvents } = newSymbols; + +/** + * + * @param {Function} Class + * @param {string} name Event name + * @param {object} options + * @returns + */ +function retargetEvent (name, from) { + if (typeof from === "function") { + from = { on: from }; + } + + let type = from?.type ?? name; + + 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); + } + }; +} + +export const Mixin = (Super = HTMLElement) => class WithEvents extends Super { + // FIXME these won't apply if we're not using NudeElement somewhere in the inheritance chain + static mixins = [PropsMixin(Super)]; + + constructor () { + super(); + this.init(); + } + + init () { + this.constructor.init(); + + // Deal with existing values on the on* props + for (let name in this.constructor[eventProps]) { + let value = this[name]; + if (typeof value === "function") { + let eventName = name.slice(2); + this.addEventListener(eventName, value); + } + } + + // Often propchange events have already fired by the time the event handlers are added + for (let eventName in this.constructor[propEvents]) { + let propName = this.constructor[propEvents][eventName]; + let value = this[propName]; + + if (value !== undefined) { + this.constructor.props.firePropChangeEvent(this, eventName, { + name: propName, + prop: this.constructor.props.get(propName), + }); + } + } + + // Listen for changes + this.addEventListener("propchange", event => { + if (this.constructor[eventProps][event.name]) { + // Implement onEventName attributes/properties + let eventName = event.name.slice(2); + let change = event.detail; + + if (change.oldInternalValue) { + this.removeEventListener(eventName, change.oldInternalValue); + } + + if (change.parsedValue) { + this.addEventListener(eventName, change.parsedValue); + } + } + }); + + for (let fn of this.constructor[retargetedEvents]) { + fn.call(this); + } + } + + [retargetedEvents] = []; + + static defineEvents (events = this.events) { + let propchange = Object.entries(events) + .filter(([name, options]) => options.propchange) + .map(([eventName, options]) => [eventName, options.propchange]); + + if (propchange.length > 0) { + // Shortcut for events that fire when a specific prop changes + propchange = Object.fromEntries(propchange); + this[propEvents] = propchange; + + // This used to be in a setup hook, do we not want it to just run here? + for (let eventName in propchange) { + let propName = propchange[eventName]; + let prop = this.props.get(propName); + + if (prop) { + (prop.eventNames ??= []).push(eventName); + } + else { + throw new TypeError(`No prop named ${propName} in ${this.name}`); + } + } + } + + let eventPropsDef = Object.keys(events) + // 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 (eventPropsDef.length > 0) { + eventPropsDef = this[eventProps] = Object.fromEntries(eventPropsDef); + this.defineProps(eventPropsDef); + } + + this[retargetedEvents] = []; + + for (let [name, options] of Object.entries(events)) { + if (options.from) { + this[retargetedEvents].push(retargetEvent(name, options.from)); + } + } + } + + static init () { + if (Object.hasOwn(this, initialized)) { + return; + } + + this[initialized] = true; + + // Should this use Object.hasOwn()? + if (this.events) { + this.defineEvents(); + } + } + + static [satisfiedBy] = "events"; +}; + +export default Mixin(); diff --git a/src/mixins/form-associated.js b/src/mixins/form-associated.js new file mode 100644 index 0000000..0067692 --- /dev/null +++ b/src/mixins/form-associated.js @@ -0,0 +1,90 @@ +/** + * Mixin to make an element form-associated + * Handles: + * - Proxying form-related properties from ElementInternals + * - Setting the element's default role + * - Proxying an internal form control for all of the above + */ + +import { resolveValue } from "../util/resolve-value.js"; +import { delegate } from "../util/delegate.js"; +import { getOptions } from "../util/get-options.js"; +import { newSymbols, satisfiedBy, internals, onApply } from "../util/symbols.js"; +import { attachInternals } from "../util/attach-internals.js"; + +const defaultOptions = { + like: undefined, + role: undefined, + valueProp: "value", + changeEvent: "input", + properties: [ + "labels", + "form", + "validity", + "validationMessage", + "willValidate", + ], +}; + +export const { constructed, initialized, init, formAssociated } = newSymbols; + +export const Mixin = (Super = HTMLElement) => class FormAssociated extends Super { + constructor () { + super(); + this.init(); + } + + init () { + // Give any subclasses a chance to execute + Promise.resolve().then(() => this[constructed]()); + } + + attachInternals () { + return attachInternals(this); + } + + [constructed] () { + let config = this.constructor[formAssociated] ?? this.constructor.formAssociated; + let { like, role, valueProp, changeEvent } = config; + let internals = this[internals] || this.attachInternals(); + + if (!this[internals]) { + return; + } + + // Set the element's default role + let source = resolveValue(like, [this, this]); + + if (role) { + this[internals].role = role; + } + + // Set current form value and update on change + this[internals].setFormValue(this[valueProp]); + let changeEventTarget = source || this; + changeEventTarget.addEventListener(changeEvent, () => + this[internals].setFormValue(this[valueProp])); + } + + static [onApply] () { + let config = this[formAssociated] || this.formAssociated; + config = !config || typeof config !== "object" ? {} : config; + + this[formAssociated] = getOptions(defaultOptions, config); + + if (!this.formAssociated) { + this.formAssociated = true; + } + + delegate({ + properties: this[formAssociated].properties, + from: this.prototype, + to: internals, + descriptors: Object.getOwnPropertyDescriptors(ElementInternals.prototype), + }); + } + + static [satisfiedBy] = [formAssociated, "formAssociated"]; +}; + +export default Mixin(); diff --git a/src/mixins/hooks/README.md b/src/mixins/hooks/README.md new file mode 100644 index 0000000..58eba4d --- /dev/null +++ b/src/mixins/hooks/README.md @@ -0,0 +1,66 @@ +# Hooks mixin + +Hooks provide a more composable alternative to hard-coded lifecycle methods. +This mixin provides a way to define composable hooks that are executed at various points in the element lifecycle. + +## Usage + +Basically two things: +1. Create a `Class.hooks` object. +2. Call `run()` on it at the appropriate times. + +It looks like this: + +```js +import Hooks from "nude-element/hooks/with.js"; + +const Self = class MyElement extends HTMLElement { + constructor () { + super(); + this.constructor.init(); + + // Constructor stuff… + + this.constructor.hooks.run("constructed", this); + } + + connectedCallback () { + if (!this.initialized) { + // Stuff that runs once per element + this.constructor.hooks.run("firstConnected", this); + this.initialized = true; + } + + this.constructor.hooks.run("connected", this); + } + + disconnectedCallback () { + this.constructor.hooks.run("disconnected", this); + } + + static init () { + // Stuff that runs once per class + if (this.initialized) { + return; + } + + this.hooks.run("start", this); + + this.initialized = true; + } +} + +function ElementTrait (Class) { + Class.hooks.add({ + firstConnected () { + console.log("The first instance of", this, "was just connected to the DOM"); + }, + connected () { + console.log(this, "was just connected to the DOM"); + }, + disconnected () { + console.log(this, "was just disconnected from the DOM"); + }, + }) +} +``` diff --git a/src/mixins/hooks.js b/src/mixins/hooks/hooks.js similarity index 97% rename from src/mixins/hooks.js rename to src/mixins/hooks/hooks.js index b4b83fc..dc82b3b 100644 --- a/src/mixins/hooks.js +++ b/src/mixins/hooks/hooks.js @@ -1,3 +1,4 @@ +// To be moved to mixin export default class Hooks { constructor (hooks) { if (hooks instanceof Hooks) { diff --git a/src/mixins/hooks/with-hooks.js b/src/mixins/hooks/with-hooks.js new file mode 100644 index 0000000..b771cbb --- /dev/null +++ b/src/mixins/hooks/with-hooks.js @@ -0,0 +1,26 @@ +import Hooks from "./hooks.js"; +import { satisfiedBy } from "../../util/symbols.js"; + +export const Mixin = (Super = HTMLElement) => class WithHooks extends Super { + static hooks = new Hooks(super.hooks || {}); + + constructor () { + super(); + + this.init?.(); + } + + init () { + super.init?.(); + + const Self = this.constructor; + + if (Self.hooks && !(Self.hooks instanceof Hooks)) { + Self.hooks = new Hooks(Self.hooks); + } + } + + static [satisfiedBy] = "hooks"; +}; + +export default Mixin(); diff --git a/src/mixins/lifecycle.js b/src/mixins/lifecycle.js new file mode 100644 index 0000000..c8cdab7 --- /dev/null +++ b/src/mixins/lifecycle.js @@ -0,0 +1,61 @@ +/** + * Mixin to add additional lifecycle hooks + * Instance hooks: + * - `init`: Called when constructing an instance of the class (used by many mixins) + * - `constructed`: Async called after the element is fully constructed (including after any subclasses) + * Class hooks: + * - `init`: Called when the first instance of the class is created + * - `anyConnected`: Called when any instance of the class is connected to the DOM (once per class) + */ +import { newSymbols, satisfiedBy } from "../util/symbols.js"; +import lifecycleHooks, { staticLifecycleHooks } from "../lifecycle.js"; + +const { hasConnected, initialized } = newSymbols; + +const instanceHooks = ["firstConnected", "constructed", "init"]; +const staticHooks = ["anyConnected", "init"]; + +for (let hook of instanceHooks) { + lifecycleHooks.add(hook); +} +for (let hook of staticHooks) { + staticLifecycleHooks.add(hook); +} + +export const Mixin = (Super = HTMLElement) => class WithLifecycle extends Super { + constructor () { + super(); + + if (!Object.hasOwn(this.constructor, initialized) && this.constructor.init) { + // First instance of this class to be created + this.constructor[initialized] = true; + this.constructor.init(); + } + + this.init?.(); + Promise.resolve().then(() => this.constructed?.()); + } + + connectedCallback () { + super.connectedCallback?.(); + + if (!this[hasConnected]) { + // First time this element is connected + if (!Object.hasOwn(this.constructor, hasConnected) && this.constructor.anyConnected) { + // First element of this type to be connected + this.constructor[hasConnected] = true; + this.constructor.anyConnected(); + } + + this[hasConnected] = true; + this.firstConnected?.(); + } + + this[hasConnected] = true; + } + + static [satisfiedBy] = [{prototype: instanceHooks}, staticHooks]; +}; + +export default Mixin(); + diff --git a/src/props/Prop.js b/src/mixins/props/Prop.js similarity index 99% rename from src/props/Prop.js rename to src/mixins/props/Prop.js index cfeb91b..7b7ce58 100644 --- a/src/props/Prop.js +++ b/src/mixins/props/Prop.js @@ -1,4 +1,4 @@ -import { resolveValue } from "../util.js"; +import { resolveValue } from "../../util.js"; import { inferDependencies } from "./util.js"; import * as types from "./types.js"; diff --git a/src/props/PropChangeEvent.js b/src/mixins/props/PropChangeEvent.js similarity index 100% rename from src/props/PropChangeEvent.js rename to src/mixins/props/PropChangeEvent.js diff --git a/src/props/Props.js b/src/mixins/props/Props.js similarity index 100% rename from src/props/Props.js rename to src/mixins/props/Props.js diff --git a/src/props/README.md b/src/mixins/props/README.md similarity index 100% rename from src/props/README.md rename to src/mixins/props/README.md diff --git a/src/mixins/props/defineProps.js b/src/mixins/props/defineProps.js new file mode 100644 index 0000000..7697fee --- /dev/null +++ b/src/mixins/props/defineProps.js @@ -0,0 +1,82 @@ +import Props from "./Props.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; +import { defineLazyProperties } from "../../util/lazy.js"; +const { initialized, propsDef } = newSymbols; + +import lifecycleHooks from "../../lifecycle.js"; +lifecycleHooks.add("propChangedCallback"); + +export const Mixin = (Super = HTMLElement) => class WithProps extends Super { + constructor () { + super(); + this.init(); + } + + init () { + this.constructor.init(); + + defineLazyProperties(this, { + // Internal prop values + props () { + return {}; + }, + // Ignore mutations on these attributes + ignoredAttributes () { + return new Set(); + }, + }); + + // Should this use Object.hasOwn()? + if (this.propChangedCallback) { + this.addEventListener("propchange", this.propChangedCallback); + } + + this.constructor.props.initializeFor(this); + } + + attributeChangedCallback (name, oldValue, value) { + super.attributeChangedCallback?.(name, oldValue, value); + + this.constructor.props.attributeChanged(this, name, oldValue, value); + } + + static get observedAttributes () { + return [ + ...(super.observedAttributes ?? []), + ...(this.constructor.props.observedAttributes ?? []), + ]; + } + + static init () { + if (this[initialized]) { + return; + } + + this[initialized] = true; + + if (this.props) { + this.defineProps(); + } + } + + static defineProps (props = this.props) { + if (props instanceof Props && props.Class === this) { + // Already defined + return null; + } + + if (this.props instanceof Props) { + // Props already defined, add these props to it + this.props.add(props); + return; + } + + // First time processing props for this class + this[propsDef] = this.props; + props = this.props = new Props(this, props); + } + + static [satisfiedBy] = "props"; +}; + +export default Mixin(); diff --git a/src/props/types.js b/src/mixins/props/types.js similarity index 100% rename from src/props/types.js rename to src/mixins/props/types.js diff --git a/src/props/types/basic.js b/src/mixins/props/types/basic.js similarity index 100% rename from src/props/types/basic.js rename to src/mixins/props/types/basic.js diff --git a/src/props/types/dictionaries.js b/src/mixins/props/types/dictionaries.js similarity index 98% rename from src/props/types/dictionaries.js rename to src/mixins/props/types/dictionaries.js index 48b3c61..8c98209 100644 --- a/src/props/types/dictionaries.js +++ b/src/mixins/props/types/dictionaries.js @@ -1,4 +1,4 @@ -import { resolveValue } from "../../util.js"; +import { resolveValue } from "../../../util.js"; import { parse, stringify, equals } from "../types.js"; import { split } from "./util.js"; diff --git a/src/props/types/index.js b/src/mixins/props/types/index.js similarity index 100% rename from src/props/types/index.js rename to src/mixins/props/types/index.js diff --git a/src/props/types/lists.js b/src/mixins/props/types/lists.js similarity index 100% rename from src/props/types/lists.js rename to src/mixins/props/types/lists.js diff --git a/src/props/types/util.js b/src/mixins/props/types/util.js similarity index 100% rename from src/props/types/util.js rename to src/mixins/props/types/util.js diff --git a/src/props/util.js b/src/mixins/props/util.js similarity index 100% rename from src/props/util.js rename to src/mixins/props/util.js diff --git a/src/slots/README.md b/src/mixins/slots/README.md similarity index 100% rename from src/slots/README.md rename to src/mixins/slots/README.md diff --git a/src/mixins/slots/defineSlots.js b/src/mixins/slots/defineSlots.js new file mode 100644 index 0000000..8b21009 --- /dev/null +++ b/src/mixins/slots/defineSlots.js @@ -0,0 +1,39 @@ +import { assignToSlot } from "./util.js"; + +let mutationObserver; + +export const Mixin = (Super = HTMLElement) => class DefineSlots extends Super { + constructor () { + super(); + this.init(); + } + + init () { + if (!this.shadowRoot) { + return; + } + + this._slots = {}; + + 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; + } + + 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) { + let slot = mutation.target; + assignToSlot(slot); + // TODO what to do with affectedSlots? + } + }); + mutationObserver.observe(this, { childList: true }); + } + } +}; + +export default Mixin(); diff --git a/src/slots/has-slotted.js b/src/mixins/slots/has-slotted.js similarity index 82% rename from src/slots/has-slotted.js rename to src/mixins/slots/has-slotted.js index 67734b5..46703a7 100644 --- a/src/slots/has-slotted.js +++ b/src/mixins/slots/has-slotted.js @@ -11,7 +11,12 @@ function update (slot) { const SUPPORTS_HAS_SLOTTED = globalThis.CSS?.supports("selector(:has-slotted)"); -export default { +export const Mixin = (Super = HTMLElement) => class HasSlotted extends Super { + constructor () { + super(); + this.init(); + } + init () { // Get all slots if (SUPPORTS_HAS_SLOTTED || !this.shadowRoot) { @@ -34,5 +39,7 @@ export default { }); slotObserver.observe(this); - }, + } }; + +export default Mixin(); diff --git a/src/slots/named-manual.js b/src/mixins/slots/named-manual.js similarity index 93% rename from src/slots/named-manual.js rename to src/mixins/slots/named-manual.js index 768440e..1a6d119 100644 --- a/src/slots/named-manual.js +++ b/src/mixins/slots/named-manual.js @@ -52,8 +52,13 @@ export function slotsChanged (records) { } } -export default function (Class, options = {}) { - return { +export function Mixin (Super = HTMLElement, options = {}) { + return class NamedManual extends Super { + constructor () { + super(); + this.init(); + } + init () { if (this.shadowRoot?.slotAssignment !== "manual") { // Nothing to do here @@ -103,6 +108,8 @@ export default function (Class, options = {}) { }); slotObserver.observe(this); } - }, + } }; } + +export default Mixin(); diff --git a/src/slots/slots.js b/src/mixins/slots/slot-controller.js similarity index 84% rename from src/slots/slots.js rename to src/mixins/slots/slot-controller.js index 64d0bb7..43065c9 100644 --- a/src/slots/slots.js +++ b/src/mixins/slots/slot-controller.js @@ -1,10 +1,10 @@ -import SlotObserver from "./slot-observer.js"; - /** * 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; @@ -18,15 +18,20 @@ function removeArrayItem (array, item) { return index; } -export class Slots { +export default class SlotController { #host; #slotObserver; #all = {}; static mutationObserver; - constructor (host) { + constructor (host, options = {}) { this.#host = host; + this.dynamic = options.dynamic; + } + + get host () { + return this.#host; } init () { @@ -46,6 +51,10 @@ export class Slots { // See https://codepen.io/leaverou/pen/KKLzBPJ this[name] ??= slot; } + + if (this.dynamic) { + this.observe(); + } } /** Observe slot mutations */ @@ -98,21 +107,3 @@ export class Slots { this.add(slot); } } - -export default function (Class, options = {}) { - let { slotsProperty = "_slots", dynamicSlots = false } = options; - - return { - start () { - this[slotsProperty] = new Slots(this); - }, - init () { - let slots = this[slotsProperty]; - slots.init(); - - if (dynamicSlots) { - slots.observe(); - } - }, - }; -} diff --git a/src/slots/slot-observer.js b/src/mixins/slots/slot-observer.js similarity index 100% rename from src/slots/slot-observer.js rename to src/mixins/slots/slot-observer.js diff --git a/src/mixins/slots/slots.js b/src/mixins/slots/slots.js new file mode 100644 index 0000000..a64e7ba --- /dev/null +++ b/src/mixins/slots/slots.js @@ -0,0 +1,38 @@ +import SlotController from "./slot-controller.js"; +import newSymbols from "../util/symbols.js"; + +const defaultOptions = { + slotsProperty: "_slots", + dynamicSlots: false, +}; + +const { hasConnected } = newSymbols; + +export function Mixin (Super = HTMLElement, options = {}) { + options = { ...defaultOptions, ...options }; + let { slotsProperty, dynamicSlots } = options; + + return class HasSlots extends Super { + constructor () { + super(); + this.init(); + } + + init () { + super.init?.(); + this[slotsProperty] = new SlotController(this, {dynamic: dynamicSlots}); + } + + connectedCallback () { + super.connectedCallback?.(); + + if (this[hasConnected]) { + return; + } + + this[hasConnected] = true; + + this[slotsProperty].init(); + } + } +} diff --git a/src/mixins/slots/util.js b/src/mixins/slots/util.js new file mode 100644 index 0000000..4f618fa --- /dev/null +++ b/src/mixins/slots/util.js @@ -0,0 +1,55 @@ +export function getSlotNodes (slot) { + let childNodes = (slot.getRootNode()).childNodes; + let name = slot.name; + let selector = []; + + if (slot.name !== "default") { + selector.push(`[slot="${name}"]`); + } + + if (slot.dataset.assign) { + // Explicit slot assignment by name takes priority + selector.push(`:is(${slot.dataset.assign}):not([slot])`); + } + + selector = selector.join(", "); + + return childNodes.filter(child => { + if (child.matches) { + return child.matches(selector); + } + if (child.nodeType === Node.TEXT_NODE) { + return slot.name === "default"; + } + }); +} + + +/** + * Assign nodes to slots based on a mix of explicit slot assignment and automatic assignment by CSS selector + */ +export function assignToSlot (slot) { + let nodes = getSlotNodes(slot); + let affectedSlots = new Set(nodes.map(node => node.assignedSlot)); + + let nodesChanged = iterableEquals(slot.assignedNodes(), nodes); + + if (!nodesChanged) { + return; + } + + slot.assign(...nodes); + slot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + + affectedSlots.delete(slot); + + for (let affectedSlot of affectedSlots) { + affectedSlot.dispatchEvent(new Event("slotchange"), { bubbles: true }); + } +} + +function iterableEquals (iterable1, iterable2) { + let set1 = iterable1 instanceof Set ? iterable1 : new Set(iterable1); + let set2 = iterable2 instanceof Set ? iterable2 : new Set(iterable2); + return set1.size === set2.size && Array.from(iterable1).every(item => set2.has(item)); +} diff --git a/src/mixins/states.js b/src/mixins/states.js new file mode 100644 index 0000000..1977a88 --- /dev/null +++ b/src/mixins/states.js @@ -0,0 +1,36 @@ +import { satisfiedBy, internals } from "../util/symbols.js"; +import { attachInternals } from "../util/attach-internals.js"; + +export const Mixin = (Super = HTMLElement) => class StatesMixin extends Super { + // TODO do we also need addState() and removeState() or is toggleState() enough? + /** + * Add or remove a CSS custom state on the element. + * @param {string} state - The name of the state to add or remove. + * @param {boolean} [force] - If omitted, the state will be toggled. If true, the state will be added. If false, the state will be removed. + */ + toggleState (state, force) { + let states = this[internals].states; + + if (!states) { + // TODO rewrite to attributes if states not supported? Possibly as a separate mixin + return; + } + + force ??= !states.has(state); + + if (force) { + states.add(state); + } + else { + states.delete(state); + } + } + + attachInternals () { + return attachInternals(this); + } + + static [satisfiedBy] = "cssStates"; +}; + +export default Mixin(); diff --git a/src/mixins/styles/global.js b/src/mixins/styles/global.js new file mode 100644 index 0000000..b517b2c --- /dev/null +++ b/src/mixins/styles/global.js @@ -0,0 +1,85 @@ +/** + * Mixin for adding light DOM styles + */ +import { adoptCSSRecursive } from "../../util/adopt-css.js"; +import { fetchCSS } from "../../util/fetch-css.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; + +export const { resolvedStyles, render, initialized, self } = newSymbols; + +export const Mixin = (Super = HTMLElement) => class GlobalStyles extends Super { + constructor () { + super(); + + // Initiate fetching when the first element is constructed + // if nothing has called it yet + this.constructor.init(); + } + + async [render] () { + let Self = this.constructor; + + if (!Self[resolvedStyles]?.length) { + return; + } + + let styles = Self[resolvedStyles].map(style => fetchCSS(style, Self.url)); + + for (let css of styles) { + if (css instanceof Promise) { + // Why not just await css anyway? + // Because this way if this is already fetched, we don’t need to wait for a microtask + css = await css; + } + + if (!css) { + continue; + } + + adoptCSSRecursive(css, this); + } + } + + connectedCallback () { + this[render](); + } + + moveCallback () { + this[render](); + } + + static [self] = self; + + static init () { + super.init?.(); + + if (!this.globalStyles || Object.hasOwn(this, initialized)) { + return; + } + + this[initialized] = true; + + let Super = Object.getPrototypeOf(this); + + if (Super[self]) { + // If self is not defined, it means we've gone past the subclass that included this + this.init.call(Super); + } + + if (Object.hasOwn(this, "globalStyles")) { + if (!Array.isArray(this.globalStyles)) { + this.globalStyles = [this.globalStyles]; + } + + this[resolvedStyles] = this.globalStyles.slice(); + + if (Super[resolvedStyles]) { + this[resolvedStyles].unshift(...Super[resolvedStyles]); + } + } + } + + static [satisfiedBy] = "globalStyles"; +}; + +export default Mixin(); diff --git a/src/mixins/styles/shadow.js b/src/mixins/styles/shadow.js new file mode 100644 index 0000000..321e245 --- /dev/null +++ b/src/mixins/styles/shadow.js @@ -0,0 +1,76 @@ +/** + * Mixin for adding shadow DOM styles + */ +import { adoptCSS } from "../../util/adopt-css.js"; +import { fetchCSS } from "../../util/fetch-css.js"; +import { getSuperclasses } from "../../util/super.js"; +import { newSymbols, satisfiedBy } from "../../util/symbols.js"; + +const { fetchedStyles, initialized, render, init } = newSymbols; + +export const Mixin = (Super = HTMLElement) => class ShadowStyles extends Super { + constructor () { + super(); + this.init(); + } + + init () { + if (!this.shadowRoot) { + return; + } + + this.constructor[init](); + this[render](); + } + + async [render] () { + let Self = this.constructor; + + let supers = getSuperclasses(Self, HTMLElement); + supers.push(Self); + + for (let Class of supers) { + if (Class[fetchedStyles]) { + for (let css of Class[fetchedStyles]) { + if (css instanceof Promise) { + // Why not just await css anyway? + // Because this way if this is already fetched, we don’t need to wait for a microtask + css = await css; + } + + adoptCSS(css, this.shadowRoot); + } + } + } + } + + static [init] () { + super[init]?.(); + + if (!this.styles || Object.hasOwn(this, initialized)) { + return; + } + + this[initialized] = true; + + let supers = getSuperclasses(this, HTMLElement); + supers.push(this); + + for (let Class of supers) { + if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, fetchedStyles)) { + // Initiate fetching when the first element is constructed + let styles = (Class[fetchedStyles] = Array.isArray(Class.styles) + ? Class.styles.slice() + : [Class.styles]); + + for (let i = 0; i < styles.length; i++) { + styles[i] = fetchCSS(styles[i], Class.url); + } + } + } + } + + static [satisfiedBy] = "styles"; +}; + +export default Mixin(); 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; diff --git a/src/nude-element.js b/src/nude-element.js new file mode 100644 index 0000000..0c366d3 --- /dev/null +++ b/src/nude-element.js @@ -0,0 +1,51 @@ +/** + * Base class with no mixins applied + * Apply mixins by subclassing it and setting the mixins static property + * ```js + * class MyElement extends NudeElement { + * static mixins = [MyMixin]; + * } + * ``` + */ +import { applyMixins } from "./apply.js"; +import newSymbols from "./util/symbols.js"; + +export const { initialized } = newSymbols; + +export const Mixin = (Super = HTMLElement) => class NudeElement extends Super { + constructor () { + super(); + + this.init(); + } + + init () { + super.init?.(); + + // We repeat the logic because we want to be able to just call Class.init() before instances are constructed + // But also we need to guard against subclasses defining their own init method and forgetting to call super.init() + if (!Object.hasOwn(this.constructor, initialized)) { + this.constructor.init(); + this.constructor[initialized] = true; + } + } + + // To be overridden by subclasses + mixins = Object.freeze([]); + + static applyMixins (mixins = this.mixins) { + return applyMixins(this, mixins); + } + + static init () { + if (Object.hasOwn(this, initialized)) { + return false; + } + + this[initialized] = true; + + this.applyMixins(); + } +}; + +export default Mixin(); diff --git a/src/props/defineProps.js b/src/props/defineProps.js deleted file mode 100644 index 2077fc9..0000000 --- a/src/props/defineProps.js +++ /dev/null @@ -1,50 +0,0 @@ -import Props from "./Props.js"; -import defineMixin from "../mixins/define-mixin.js"; - -let propsSymbol = Symbol("propsSymbol"); - -export default function defineProps (Class, props = Class[propsSymbol] ?? Class.props) { - if (props instanceof Props && props.Class === Class) { - // Already defined - return null; - } - - if (Class.props instanceof Props) { - // Props already defined, add these props to it - Class.props.add(props); - return; - } - - Class[propsSymbol] = Class.props; - props = Class.props = new Props(Class, props); - - let _attributeChangedCallback = Class.prototype.attributeChangedCallback; - Class.prototype.attributeChangedCallback = function (name, oldValue, value) { - this.constructor.props.attributeChanged(this, name, oldValue, value); - _attributeChangedCallback?.call(this, name, oldValue, value); - }; - - // FIXME how to combine with existing observedAttributes? - if (!Object.hasOwn(Class, "observedAttributes")) { - Object.defineProperty(Class, "observedAttributes", { - get: () => 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(); - }, - }, - }); -} diff --git a/src/slots/defineSlots.js b/src/slots/defineSlots.js deleted file mode 100644 index ef6becf..0000000 --- a/src/slots/defineSlots.js +++ /dev/null @@ -1,62 +0,0 @@ -import defineMixin from "../mixins/define-mixin.js"; - -function assignSlots () { - let children = this.childNodes; - let slotElements = Object.values(this._slots); - let assignments = new WeakMap(); - - // Assign to slots - for (let child of children) { - let assignedSlot; - - if (child.slot) { - // Explicit slot assignment by name, this takes priority - assignedSlot = this._slots[child.slot]; - } - else if (child.matches) { - // Does child match any slot selector? - assignedSlot = slotElements.find(slot => child.matches(slot.dataset.assign)); - } - - assignedSlot ??= this._slots.default; - let all = assignments.get(assignedSlot) ?? new Set(); - all.add(child); - assignments.set(assignedSlot, all); - } - - for (let slot of slotElements) { - let all = assignments.get(slot) ?? new Set(); - slot.assign(...all); - } -} - -let mutationObserver; - -export default function (Class) { - // Class.prototype.assignSlots = assignSlots; - - return defineMixin(Class, function init () { - if (!this.shadowRoot) { - return; - } - - this._slots = {}; - - 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; - } - - 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 }); - } - }); -} 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/styles/global.js b/src/styles/global.js deleted file mode 100644 index 2ce4f37..0000000 --- a/src/styles/global.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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"; - -export default { - prepare () { - if (!this.globalStyles) { - return; - } - - let supers = getSupers(this, HTMLElement); - let Super; - - for (let Class of supers) { - if ( - Object.hasOwn(Class, "globalStyles") && - !Object.hasOwn(Class, "fetchedGlobalStyles") - ) { - // Initiate fetching when the first element is constructed - let styles = (Class.fetchedGlobalStyles = Array.isArray(Class.globalStyles) - ? Class.globalStyles.slice() - : [Class.globalStyles]); - Class.roots = new WeakSet(); - - for (let i = 0; i < styles.length; i++) { - styles[i] = fetchCSS(styles[i], Class.url); - } - } - } - }, - - async connected () { - let Self = this.constructor; - - if (!Self.fetchedGlobalStyles?.length) { - return; - } - - 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 - css = await css; - } - - if (!css) { - continue; - } - - // Recursively adopt style on all shadow roots all the way up to the document - let root = this; - do { - root = root.host ?? root; - root = root.getRootNode(); - - if (!Self.roots.has(root)) { - adoptCSS(css, root); - Self.roots.add(root); - } - } while (root && root.nodeType !== Node.DOCUMENT_NODE); - } - }, -}; diff --git a/src/styles/index.js b/src/styles/index.js deleted file mode 100644 index ee0f843..0000000 --- a/src/styles/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as shadowStyles } from "./shadow.js"; -export { default as globalStyles } from "./global.js"; diff --git a/src/styles/shadow.js b/src/styles/shadow.js deleted file mode 100644 index 58de46e..0000000 --- a/src/styles/shadow.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Mixin for adding shadow DOM styles - */ -import { adoptCSS, fetchCSS, getSupers } from "./util.js"; - -export default { - setup () { - if (!this.styles) { - return; - } - - let supers = getSupers(this, HTMLElement); - - for (let Class of supers) { - if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, "fetchedStyles")) { - // Initiate fetching when the first element is constructed - let styles = (Class.fetchedStyles = Array.isArray(Class.styles) - ? Class.styles.slice() - : [Class.styles]); - - for (let i = 0; i < styles.length; i++) { - styles[i] = fetchCSS(styles[i], Class.url); - } - } - } - }, - async init () { - if (!this.shadowRoot) { - return; - } - - let Self = this.constructor; - let supers = getSupers(Self, HTMLElement); - - for (let Class of supers) { - if (Class.fetchedStyles) { - for (let css of Class.fetchedStyles) { - if (css instanceof Promise) { - css = await css; - } - - adoptCSS(css, this.shadowRoot); - } - } - } - }, -}; diff --git a/src/util.js b/src/util.js index b73ad95..f52e7f8 100644 --- a/src/util.js +++ b/src/util.js @@ -3,7 +3,11 @@ 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/extend-object.js"; +export * from "./util/compose-function.js"; export * from "./util/reversible-map.js"; export * from "./util/pick.js"; +export * from "./util/adopt-css.js"; +export * from "./util/fetch-css.js"; +export * from "./util/symbols.js"; +export * from "./util/super.js"; diff --git a/src/util/adopt-css.js b/src/util/adopt-css.js index c45d12e..dde3088 100644 --- a/src/util/adopt-css.js +++ b/src/util/adopt-css.js @@ -12,48 +12,91 @@ const adoptedStyleSheets = new WeakMap(); * @returns {CSSStyleSheet} */ export function adoptCSS (style, root = globalThis.document) { + if (getSheet(style, root)) { + // We never want to adopt the same style multiple times + return; + } + + /** @type {CSSStyleSheet | HTMLStyleElement} */ + let styleObj = getSheet(style, root, { create: true }); + + if (root.adoptedStyleSheets) { + // Newer browsers + if (Object.isFrozen(root.adoptedStyleSheets)) { + // Slightly older browsers + root.adoptedStyleSheets = [...root.adoptedStyleSheets, styleObj]; + } + else { + root.adoptedStyleSheets.push(styleObj); + } + } + else { + // Older browsers + let styleRoot = root.nodeType === Node.DOCUMENT_NODE ? root.head : root; + styleRoot.appendChild(styleObj); + } + + return styleObj; +} + +/** + * Get a stylesheet object (CSSStyleSheet or HTMLStyleElement) from the adopted stylesheets map + * @param {string | CSSStyleSheet} style + * @param {Document | ShadowRoot} root + * @param {object} [options] + * @param {boolean} [options.create=false] - Whether to create a new style object if it doesn't exist + * @returns {CSSStyleSheet | HTMLStyleElement | null} + */ +function getSheet (style, root, { create = false } = {}) { let rootAdoptedStyleSheets = adoptedStyleSheets.get(root); if (!rootAdoptedStyleSheets) { + if (!create) { + return null; + } + rootAdoptedStyleSheets = new Map(); adoptedStyleSheets.set(root, rootAdoptedStyleSheets); } - if (root.adoptedStyleSheets) { - let sheet = rootAdoptedStyleSheets.get(style); + let styleObj = rootAdoptedStyleSheets.get(style); - if (!sheet && typeof style === "string") { - sheet = new CSSStyleSheet(); - sheet.replaceSync(style); - rootAdoptedStyleSheets.set(style, sheet); - style = sheet; - } + if (styleObj) { + return styleObj; + } - if (!root.adoptedStyleSheets.includes(sheet)) { - // Not already adopted, so we need to adopt it - if (Object.isFrozen(root.adoptedStyleSheets)) { - // Slightly older browsers - root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; - } - else { - root.adoptedStyleSheets.push(sheet); - } - } + if (!create) { + return null; + } - return sheet; + // If we’re here, we have no existing object and create is true + if (root.adoptedStyleSheets) { + // Newer browsers + if (typeof style === "string") { + styleObj = new CSSStyleSheet(); + styleObj.replaceSync(style); + } } else { // Older browsers - let styleElement = rootAdoptedStyleSheets.get(style); - - if (!styleElement) { - styleElement = document.createElement("style"); - styleElement.textContent = style; - let styleRoot = root.nodeType === Node.DOCUMENT_NODE ? root.head : root; - styleRoot.appendChild(styleElement); - rootAdoptedStyleSheets.set(style, styleElement); - } - - return styleElement; + styleObj = document.createElement("style"); + styleObj.textContent = style; } + + rootAdoptedStyleSheets.set(style, styleObj); + return styleObj; +} + +/** + * Recursively adopt style on all shadow roots all the way up to the document + * @param {string | CSSStyleSheet} style + * @param {Document | ShadowRoot} root + */ +export function adoptCSSRecursive (style, root = globalThis.document) { + do { + root = root.host ?? root; + root = root.getRootNode(); + + adoptCSS(style, root); + } while (root && root.nodeType !== Node.DOCUMENT_NODE); } diff --git a/src/util/attach-internals.js b/src/util/attach-internals.js new file mode 100644 index 0000000..d1ea071 --- /dev/null +++ b/src/util/attach-internals.js @@ -0,0 +1,13 @@ +import { getSuper } from "./super.js"; +import { internals } from "./symbols.js"; + +export function attachInternals (thisArg = this) { + let superInternals = getSuper(thisArg)?.attachInternals; + + if (!superInternals) { + // Method likely not supported + return; + } + + return thisArg[internals] ??= superInternals.call(thisArg); +} diff --git a/src/util/compose-function.js b/src/util/compose-function.js new file mode 100644 index 0000000..d43e48a --- /dev/null +++ b/src/util/compose-function.js @@ -0,0 +1,49 @@ +/** + * Wrap a function with another function that can be extended with side effects at any point. + * Side effects run before the function body, in the order they are added. + * @param {function} body - The function to wrap + * @param {...function} sideEffects + * @returns {function} + */ +export const sideEffects = Symbol("Side effects"); +export const mutable = Symbol("Mutable"); + +export function composeFunction (body, ...sideEffectFns) { + let mutableFn = body[sideEffects] ? body : body[mutable]; + + if (!mutableFn) { + // First time extending body + let name = body.name || ""; + + // Wrap in object so we can dynamically assign a name + // https://dev.to/tmikeschu/dynamically-assigning-a-function-name-in-javascript-2d70 + let wrapper = { + [name] (...args) { + let ret = body.apply(this, args); + + for (let sideEffect of mutableFn[sideEffects]) { + sideEffect.apply(this, args); + } + + return ret; + }, + }; + + mutableFn = body[mutable] = wrapper[name]; + mutableFn.body = body; + mutableFn[sideEffects] = new Set(); + } + + body = mutableFn.body; + + for (const sideEffect of sideEffectFns) { + if (body === sideEffect) { + // The function cannot be a side effect of itself + continue; + } + + mutableFn[sideEffects].add(sideEffect); + } + + return mutableFn; +} diff --git a/src/util/compose-functions.js b/src/util/compose-functions.js deleted file mode 100644 index 85529d5..0000000 --- a/src/util/compose-functions.js +++ /dev/null @@ -1,55 +0,0 @@ -import { ReversibleMap } from "./reversible-map.js"; - -export const composedFunctions = new ReversibleMap(); -export const functions = Symbol("Constitutent functions"); - -/** - * Compose functions in a way that preserves the originals. - * Will only ever produce one new function, even if called repeatedly - * @param {function} fn1 - * @param {function} fn2 - * @returns {function} - */ -export function composeFunctions (fn1, fn2) { - let isComposed = composedFunctions.getKey(fn1); - let composedFn; - - if (isComposed) { - // A composed function was provided instead of the constituent - composedFn = isComposed; - if (!composedFn[functions]) debugger; - fn1 = composedFn[functions][0]; - } - else { - composedFn = composedFunctions.get(fn1); - } - - if (!composedFn) { - composedFn = function (...args) { - let fns = composedFn[functions]; - let ret; - for (let fn of fns) { - ret = fn.call(this, ...args) ?? ret; - } - return ret; - } - - composedFn[functions] = [fn1, fn2]; - composedFunctions.set(fn1, composedFn) - } - else { - let prev = composedFn[functions].indexOf(fn2); - - if (prev !== composedFn[functions].length - 1) { - if (prev < composedFn[functions].length - 1) { - // If already there, but not at the end, remove first - composedFn[functions].splice(prev, 1); - } - - composedFn[functions].push(fn2); - } - // else Already at the end, nothing to do here - } - - return composedFn; -} diff --git a/src/util/conflict-policy.js b/src/util/conflict-policy.js new file mode 100644 index 0000000..55a692f --- /dev/null +++ b/src/util/conflict-policy.js @@ -0,0 +1,92 @@ +/** + * @typedef { "overwrite" | "skip" | "throw" } ConflictPolicyStrategy + */ + +/** + * @typedef { object } ConflictPolicySource + * @property {boolean | Iterable} [merge] - Allow merge whenever possible? + * @property {true | Iterable } + */ + exceptions = {}; + + /** + * Whether to allow merge whenever possible + * Exceptions can still be provided as an array even if merge is false + * @type { boolean } + */ + merge = false; + + /** + * @param { ConflictPolicySource | ConflictPolicyStrategy | ConflictPolicy } [conflictPolicy="overwrite"] + */ + constructor (conflictPolicy) { + if (conflictPolicy instanceof this.constructor) { + return conflictPolicy; + } + + this.def = conflictPolicy ?? {}; + + if (!conflictPolicy || typeof conflictPolicy === "string") { + this.default = conflictPolicy || "overwrite"; + return; + } + + this.merge = conflictPolicy.merge === true; + + if (conflictPolicy.default) { + this.default = conflictPolicy.default; + } + else { + this.default = ["overwrite", "skip", "throw"].find(p => conflictPolicy[p] === true); + } + + if (conflictPolicy.exceptions) { + this.exceptions = Object.assign(this.exceptions, conflictPolicy.exceptions); + } + else { + for (let type of ["merge", "overwrite", "skip", "throw"]) { + if (Array.isArray(conflictPolicy[type])) { + for (let property of conflictPolicy[type]) { + this.exceptions[property] = type; + } + } + } + } + } + + /** + * Resolve the conflict policy for a given property + * @param {PropertyKey} property + */ + resolve (property) { + return this.exceptions[property] ?? this.default; + } + + canMerge (property) { + return this.merge === true || this.exceptions[property] === "merge"; + } + + static combine (...policies) { + return new this({ + default: policies.at(-1).default ?? "overwrite", + exceptions: policies.filter(Boolean).map(p => new this(p)).reduce((exceptions, policy) => { + return Object.assign(exceptions, policy.exceptions); + }, {}), + merge: policies.at(-1).merge ?? false, + }); + } +} diff --git a/src/util/copy-properties.js b/src/util/copy-properties.js deleted file mode 100644 index c220d10..0000000 --- a/src/util/copy-properties.js +++ /dev/null @@ -1,70 +0,0 @@ -import { composeFunctions } from "./compose-functions.js"; - -/** - * @typedef CopyPropertiesOptions - * @type {object} - * @property recursive {boolean | number} - Whether to try and extend prototypes too. If number, defines max levels. - * @property overwrite {boolean} - Whether to overwrite conflicts that can't be merged - * @property mergeFunctions {boolean} - Whether to try to merge whereever possible - */ - -/** - * Copy properties, respecting descriptors - * @param {object} target - * @param {object} source - * @param {CopyPropertiesOptions} options - */ -export function copyProperties (target, source, options = {}) { - let sourceDescriptors = Object.getOwnPropertyDescriptors(source); - let sourceProto = Object.getPrototypeOf(source); - - for (let key in sourceDescriptors) { - if (key !== "constructor") { - // TODO figure out whether it meaningfully defines a constructor - copyProperty(target, source, key, options); - } - } - - if (options.recursive) { - if (target.prototype && source.prototype) { - copyProperties(target, source, options); - } - else { - let targetProto = Object.getPrototypeOf(target); - - if (isMeaningfulProto(targetProto) && isMeaningfulProto(sourceProto)) { - if (typeof options.recursive === "number") { - options = {...options, recursive: options.recursive - 1}; - } - - copyProperties(targetProto, sourceProto, options); - } - } - } -} - -function isMeaningfulProto (proto) { - return proto !== Object.prototype && proto !== Function.prototype; -} - -function copyProperty (target, source, key, options = {}) { - let sourceDescriptor = Object.getOwnPropertyDescriptor(source, key); - let targetDescriptor = Object.getOwnPropertyDescriptor(target, key); - - if (!sourceDescriptor) { - return; - } - - if (targetDescriptor && options.mergeFunctions !== false) { - if (typeof targetDescriptor.value === "function" && typeof sourceDescriptor.value === "function") { - // Compatible, compose - targetDescriptor.value = composeFunctions(targetDescriptor.value, sourceDescriptor.value); - sourceDescriptor = targetDescriptor; - } - } - - if (!targetDescriptor || options.overwrite || sourceDescriptor === targetDescriptor) { - Object.defineProperty(target, key, sourceDescriptor); - } - -} 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/extend-class.js b/src/util/extend-class.js index 653bb79..986ca17 100644 --- a/src/util/extend-class.js +++ b/src/util/extend-class.js @@ -1,20 +1,48 @@ -import { copyProperties } from "./copy-properties.js"; +import { extendObject } from "./extend-object.js"; +import { getSuperclasses } from "./super.js"; +import { ConflictPolicy } from "./conflict-policy.js"; /** + * @import { ConflictPolicySource, ConflictPolicy } from "./conflict-policy.js"; + */ + +/** + * @typedef { object } ExtendClassOptions + * @property { boolean } [recursive=true] - Whether to try and extend superclasses too. Automatically stops at the first shared superclass. + * @property { Iterable } [skippedProperties = []] - Instance properties to ignore + * @property { Iterable } [skippedPropertiesStatic = []] - Static properties to ignore + * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicy="overwrite"] + * @property { ConflictPolicySource | ConflictPolicy } [conflictPolicyStatic="overwrite"] + * * Use a class as a mixin on another class - * @param Class {Function} - * @param Mixin {Function} - * @param options {CopyPropertiesOptions} + * @param {Function} Class + * @param {Function} Mixin + * @param {ExtendClassOptions} [options={}] + * */ export function extendClass (Class, Mixin, options = {}) { - if (options.recursive) { - copyProperties(Class, Mixin, options); - } - else { - copyProperties(Class, Mixin, options); - copyProperties(Class.prototype, Mixin.prototype, options); + let sources = [Mixin]; + + if (options.recursive !== false) { + let classSupers = getSuperclasses(Class).reverse(); + let mixinSupers = getSuperclasses(Mixin).reverse(); + + // Find the first shared superclass + let index = mixinSupers.findIndex(sharedSuper => classSupers.includes(sharedSuper)); + if (index !== -1) { + sources.push(...mixinSupers.slice(index + 1)); + } } -} + let { conflictPolicy, conflictPolicyStatic } = options; + conflictPolicy = new ConflictPolicy(conflictPolicy); + conflictPolicyStatic = new ConflictPolicy(conflictPolicyStatic); + let skippedProperties = ["constructor"].concat(options.skippedProperties || []); + let skippedPropertiesStatic = ["prototype"].concat(options.skippedPropertiesStatic || []); + for (let source of sources) { + extendObject(Class.prototype, source.prototype, {conflictPolicy, skippedProperties}); + extendObject(Class, source, {conflictPolicy: conflictPolicyStatic, skippedProperties: skippedPropertiesStatic}); + } +} diff --git a/src/util/extend-object.js b/src/util/extend-object.js new file mode 100644 index 0000000..63592a8 --- /dev/null +++ b/src/util/extend-object.js @@ -0,0 +1,78 @@ +import { ConflictPolicy } from "./conflict-policy.js"; +import { canMerge, mergeValues } from "./merge.js"; + +/** + * Copy properties, respecting descriptors + * + * @typedef {object} ExtendObjectOptions + * @property { ConflictPolicy | ConflictPolicySource } [conflictPolicy = "overwrite"] - Whether to overwrite conflicts that can't be merged + * @property {Array} [skippedProperties = []] - Properties to ignore + * + * @param {Record} target + * @param {Record} source + * @param {ExtendObjectOptions} [options={}] + */ +export function extendObject (target, source, options = {}) { + let conflictPolicy = new ConflictPolicy(options.conflictPolicy); + let skippedProperties = new Set(options.skippedProperties || []); + let sourceDescriptors = Object.getOwnPropertyDescriptors(source); + + for (let prop of Reflect.ownKeys(sourceDescriptors)) { + if (skippedProperties.has(prop)) { + continue; + } + + let sourceDescriptor = sourceDescriptors[prop]; + let targetDescriptor = Object.getOwnPropertyDescriptor(target, prop); + let descriptor; + + if (targetDescriptor) { + let propConflictPolicy = conflictPolicy.resolve(prop); + + if (propConflictPolicy === "skip" || descriptorEquals(targetDescriptor, sourceDescriptor)) { + continue; + } + + if (propConflictPolicy === "throw") { + throw new Error(`Property ${prop} already exists on target`); + } + + if (conflictPolicy.canMerge(prop)) { + descriptor = getMergeDescriptor(targetDescriptor, sourceDescriptor); + } + } + + Object.defineProperty(target, prop, descriptor ?? sourceDescriptor); + } +} + +function descriptorEquals (targetDescriptor, sourceDescriptor) { + // Note that this only takes value properties into account and will return true even if + // one has different enumerable, writable, configurable, etc. + return ["value", "get", "set"].every(key => { + return targetDescriptor[key] === sourceDescriptor[key]; + }); +} + +function canMergeDescriptors (targetDescriptor, sourceDescriptor) { + if (targetDescriptor.get || targetDescriptor.set || sourceDescriptor.get || sourceDescriptor.set) { + // Only merge value properties for now + return false; + } + + return canMerge(targetDescriptor.value, sourceDescriptor.value); +} + +function getMergeDescriptor (targetDescriptor, sourceDescriptor) { + if (!canMergeDescriptors(targetDescriptor, sourceDescriptor)) { + return sourceDescriptor; + } + + // TODO merge accessors + return { + value: mergeValues(targetDescriptor.value, sourceDescriptor.value), + writable: targetDescriptor.writable || sourceDescriptor.writable, + configurable: targetDescriptor.configurable || sourceDescriptor.configurable, + enumerable: targetDescriptor.enumerable || sourceDescriptor.enumerable, + }; +} diff --git a/src/util/fetch-css.js b/src/util/fetch-css.js index c351da1..f9070f9 100644 --- a/src/util/fetch-css.js +++ b/src/util/fetch-css.js @@ -19,8 +19,13 @@ export function fetchCSS (style, baseUrl = defaultBaseURL) { if (style instanceof Promise) { return style; } - else if (style?.css) { + + if (style?.css) { // The component provides a CSS string (either as a promise or a string) + if (style.css instanceof Promise) { + style.css.then(css => style.css = css); + } + return style.css; } @@ -32,8 +37,8 @@ export function fetchCSS (style, baseUrl = defaultBaseURL) { if (!css) { // Haven't fetched yet - css = fetchedStyles[fullUrl] = fetch(fullUrl).then(response => response.text()); - css.then(css => (fetchedStyles[fullUrl] = css)); + css = fetchedStyles[fullUrl] = fetch(fullUrl).then(response => response.ok ? response.text() : Promise.reject(response)); + css.then(css => fetchedStyles[fullUrl] = css, error => fetchedStyles[fullUrl] = ""); } } diff --git a/src/util/get-options.js b/src/util/get-options.js new file mode 100644 index 0000000..d65e856 --- /dev/null +++ b/src/util/get-options.js @@ -0,0 +1,11 @@ +export function getOptions (defaults, ...options) { + let ret = Object.create(defaults ?? {}); + + for (let o of options) { + if (typeof o === "object" && o !== null) { + Object.defineProperties(ret, Object.getOwnPropertyDescriptors(o)); + } + } + + return ret; +} diff --git a/src/util/get-supers.js b/src/util/get-supers.js 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/lazy.js b/src/util/lazy.js index 52a1340..1f8686e 100644 --- a/src/util/lazy.js +++ b/src/util/lazy.js @@ -32,3 +32,9 @@ export function defineLazyProperty (object, name, options) { configurable: true, }); } + +export function defineLazyProperties (object, properties) { + for (let name in properties) { + defineLazyProperty(object, name, properties[name]); + } +} diff --git a/src/util/merge.js b/src/util/merge.js new file mode 100644 index 0000000..5603b9c --- /dev/null +++ b/src/util/merge.js @@ -0,0 +1,64 @@ +import { composeFunction } from "./compose-function.js"; + +export function mergeValues (targetValue, sourceValue) { + if (!targetValue || !sourceValue) { + return targetValue || sourceValue; + } + + let mergeType = getMergeType(targetValue, sourceValue); + + switch (mergeType) { + case "function": + return composeFunction(targetValue, sourceValue); + case "array": + return [...targetValue, ...sourceValue]; + case "set": + return new Set([...targetValue, ...sourceValue]); + case "map": + return new Map([...targetValue, ...sourceValue]); + case "object": + return deepMerge(targetValue, sourceValue); + } + + throw new Error(`Cannot merge values`, { cause: { targetValue, sourceValue } }); +} + +export function deepMerge (target, source) { + if (typeof target !== "object" || target === null || typeof source !== "object" || source === null) { + return source ?? target; + } + + let ret = Object.create(target); + + for (let [key, value] of Object.entries(source)) { + ret[key] = deepMerge(ret[key], value); + } + + return ret; +} + +export function canMerge (target, source) { + return !!getMergeType(target, source); +} + +export function getMergeType (target, source) { + if (typeof target === "function" && typeof source === "function") { + return "function"; + } + + if (Array.isArray(target) && Array.isArray(source)) { + return "array"; + } + + if (target instanceof Set && source instanceof Set) { + return "set"; + } + + if (target instanceof Map && source instanceof Map) { + return "map"; + } + + if (typeof target === "object" && typeof source === "object") { + return "object"; + } +} diff --git a/src/util/super.js b/src/util/super.js new file mode 100644 index 0000000..f6a12d2 --- /dev/null +++ b/src/util/super.js @@ -0,0 +1,41 @@ +/** + * 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 getSuperclasses (Class, FromClass) { + const classes = []; + + while (Class = getSuperclass(Class)) { + if (Class === FromClass) { + break; + } + + classes.unshift(Class); + } + + return classes; +} + +export function getSuperclass (Class) { + let Super = Object.getPrototypeOf(Class); + + if (Super === Function.prototype) { + return null; + } + + return Super; +} + +/** + * Get a property from the superclass + * Similar to calling `super` in a method, but dynamically bound + * @param {object} instance + * @returns {FunctionConstructor | null} The superclass prototype is returned, or null if no superclass exists. + * E.g. to emulate `super.foo(arg1, arg2)` in a method, use `getSuper(this).foo.call(this, arg1, arg2)` + */ +export function getSuper (instance) { + let Class = instance.constructor; + return getSuperclass(Class)?.prototype ?? null; +} diff --git a/src/util/symbols.js b/src/util/symbols.js new file mode 100644 index 0000000..f7f4a9e --- /dev/null +++ b/src/util/symbols.js @@ -0,0 +1,16 @@ +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 { satisfiedBy, internals, mixinsApplied, onApply, conflictPolicy } = newSymbols; +export const KNOWN_SYMBOLS = { satisfiedBy, internals, mixinsApplied, onApply, conflictPolicy };