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/) 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/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 eda2420..c1666d3 100644 --- a/src/Element.js +++ b/src/Element.js @@ -1,41 +1,35 @@ /** * 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"; +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"; -const instanceInitialized = Symbol("instanceInitialized"); -const classInitialized = Symbol("classInitialized"); +const { initialized } = symbols.new; -const Self = class NudeElement extends HTMLElement { +export default 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); - } + this.constructor.setup(); // Last resort + this.constructor.hooks.run("constructor-static", this.constructor); + this.constructor.hooks.run("constructor", this); // 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 () { - if (!this[instanceInitialized]) { - // Stuff that runs once per element - this.constructor.hooks.run("init", this); - - this[instanceInitialized] = true; + 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); @@ -45,42 +39,65 @@ const Self = class NudeElement extends HTMLElement { this.constructor.hooks.run("disconnected", this); } - static init () { - // Stuff that runs once per class - if (this[classInitialized]) { - return false; - } + static symbols = symbols.known; + + static hooks = new Hooks(); + static { + defineLazyProperty(this, "hooks", { + value: this.hooks, + get (hooks) { + let ret = new Hooks(hooks); + ret.parent = this.super?.hooks; + return ret; + }, + configurable: true, + writable: true, + }); + } - this.hooks = new Hooks(this.hooks); + /** + * Like super, but dynamic + */ + get super () { + return getSuper(this); + } - if (this.props) { - defineProps(this); - } + /** + * Like super, but dynamic + */ + static get super () { + return getSuper(this); + } - if (this.events) { - defineEvents(this); - } + /** Plugins to install */ + static plugins = []; - if (this.formAssociated) { - defineFormAssociated(this); - } + static hasPlugin (plugin) { + return hasPlugin(this, plugin); + } - if (this.styles) { - defineMixin(this, shadowStyles); - } + static addPlugin (plugin) { + addPlugin(this, plugin); + } - if (this.globalStyle) { - this.globalStyles ??= this.globalStyle; + /** + * 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; } - if (this.globalStyles) { - defineMixin(this, globalStyles); + this.super?.setup?.(); + + for (let plugin of this.plugins) { + this.addPlugin(plugin); } this.hooks.run("setup", this); - return (this[classInitialized] = true); + this[initialized] = true; } -}; - -export default Self; +} diff --git a/src/common-plugins.js b/src/common-plugins.js new file mode 100644 index 0000000..af28581 --- /dev/null +++ b/src/common-plugins.js @@ -0,0 +1,21 @@ +import props from "./props/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"; + +export { + props, + events, + formBehavior, + shadowStyles, + globalStyles, +}; + +export default [ + props, + events, + formBehavior, + shadowStyles, + globalStyles, +]; diff --git a/src/elements/base.js b/src/elements/base.js new file mode 100644 index 0000000..4484cd3 --- /dev/null +++ b/src/elements/base.js @@ -0,0 +1,83 @@ +/** + * 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 = { + first_constructor_static () { + if (this.elements) { + this.defineElements(); + } + }, + + first_connected () { + if (!this[elements]) { + return; + } + + // Ensure fresh references + for (let name in this[elements]) { + this[name] = getElement(this, this.constructor[elements][name]); + } + }, +}; + +export const providesStatic = { + 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]); + }, + configurable: true, + enumerable: true, + }); + } + 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; + } + }, +}; + +export default { dependencies, hooks, providesStatic }; diff --git a/src/events/base.js b/src/events/base.js new file mode 100644 index 0000000..e337855 --- /dev/null +++ b/src/events/base.js @@ -0,0 +1,22 @@ + +import symbols from "../util/symbols.js"; +export const { events } = symbols.known; + +export const hooks = { + first_constructor_static () { + if (this.events) { + this.defineEvents(); + } + }, +}; + +export const providesStatic = { + defineEvents (def = this[events] ?? this.events) { + this[events] ??= {}; + Object.assign(this[events], def); + + this.hooks.run("define-events", {context: this, events: def}); + }, +}; + +export default { hooks, providesStatic }; 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/events/index.js b/src/events/index.js new file mode 100644 index 0000000..f434e5c --- /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, onprops, propchange, retarget]; + +export default {dependencies}; diff --git a/src/events/onprops.js b/src/events/onprops.js new file mode 100644 index 0000000..257720c --- /dev/null +++ b/src/events/onprops.js @@ -0,0 +1,75 @@ + +/** + * 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, base]; + +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.constructor[eventProps]) { + return; + } + + for (let name in this.constructor[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.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); + } + } + }); + }, +}; + +export default {dependencies, hooks}; diff --git a/src/events/propchange.js b/src/events/propchange.js new file mode 100644 index 0000000..1e773a7 --- /dev/null +++ b/src/events/propchange.js @@ -0,0 +1,57 @@ +/** + * Implement propchange events: + * Events that fire when a specific prop changes + */ + +import symbols from "../util/symbols.js"; +import base, { events } from "./base.js"; + +const { propchange } = symbols.new; + +export const dependencies = [base]; + +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]); + + 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.constructor[propchange]) { + let propName = this.constructor[propchange][eventName]; + let value = this[propName]; + + if (value !== undefined) { + this.constructor.props.firePropChangeEvent(this, eventName, { + name: propName, + prop: this.constructor.props.get(propName), + }); + } + } + }, +}; + +export default {dependencies, hooks}; diff --git a/src/events/retarget.js b/src/events/retarget.js new file mode 100644 index 0000000..fa29e52 --- /dev/null +++ b/src/events/retarget.js @@ -0,0 +1,63 @@ +/** + * Retarget events from internal elements to the host + */ + +import { resolveValue } from "../util.js"; +import { pick } from "../util/pick.js"; + +import base, { events } from "./base.js"; + +export const dependencies = [base]; + +export const hooks = { + first_connected () { + if (!this.constructor[events]) { + return; + } + + for (let [name, options] of Object.entries(this.constructor[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); + } + + } + + }, +}; + +export default {dependencies, hooks}; 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..475e86c --- /dev/null +++ b/src/form-behavior/base.js @@ -0,0 +1,35 @@ + + +import symbols from "../util/symbols.js"; + +import internalsPlugin from "../internals/base.js"; + +export const dependencies = [internalsPlugin]; + +export const { formBehavior } = symbols.known; + +export const hooks = { + firstConstructorStatic () { + if (this.formBehavior) { + this.defineFormBehavior(); + } + }, +}; + +export const providesStatic = { + formAssociated: true, + + defineFormBehavior (def = this[formBehavior] ?? this.formBehavior) { + if (!def) { + return; + } + + const env = {context: this, formBehavior: def}; + this.hooks.run("define-form-behavior", env); + + this[formBehavior] ??= {}; + Object.assign(this[formBehavior], env.formBehavior); + }, +}; + +export default { dependencies, hooks, providesStatic }; diff --git a/src/form-behavior/delegate.js b/src/form-behavior/delegate.js new file mode 100644 index 0000000..1b46d4d --- /dev/null +++ b/src/form-behavior/delegate.js @@ -0,0 +1,35 @@ +/** + * Expose form-related ElementInternals properties on the host element + */ +import { delegate } from "../util/delegate.js"; +import symbols from "../util/symbols.js"; +import base, { formBehavior } from "./base.js"; + +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)); +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), + }); + } +}; + +export default {dependencies, hooks}; diff --git a/src/form-behavior/index.js b/src/form-behavior/index.js new file mode 100644 index 0000000..8a774a4 --- /dev/null +++ b/src/form-behavior/index.js @@ -0,0 +1,13 @@ +import base from "./base.js"; +import like from "./like.js"; +import delegate from "./delegate.js"; + +export { base, like, delegate }; + +export const dependencies = [ + base, + like, + delegate, +]; + +export default {dependencies}; diff --git a/src/form-behavior/like.js b/src/form-behavior/like.js new file mode 100644 index 0000000..c6f90ce --- /dev/null +++ b/src/form-behavior/like.js @@ -0,0 +1,38 @@ +import symbols from "../util/symbols.js"; +import { resolveValue } from "../util.js"; +import { getRole } from "./role.js"; +import base, { formBehavior } from "./base.js"; + +export const dependencies = [base]; + +const { 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) { + 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])); + }, +}; + +export default {dependencies, hooks}; diff --git a/src/form-behavior/role.js b/src/form-behavior/role.js new file mode 100644 index 0000000..5d8d73e --- /dev/null +++ b/src/form-behavior/role.js @@ -0,0 +1,67 @@ +import symbols from "../util/symbols.js"; + +const { defaultRole } = symbols.new; +const { internals } = symbols.known; + +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/hooks.js b/src/hooks.js new file mode 100644 index 0000000..b7677df --- /dev/null +++ b/src/hooks.js @@ -0,0 +1,145 @@ +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); + } + } + + /** + * Schedule one or more callbacks to be executed on one or more hooks + * + * @overload + * @param {string} name + * @param {function} callback + * @void + * + * @overload + * @param {Record} hooks + * @void + */ + add (name, callback) { + if (!name) { + return; + } + + if (Array.isArray(name)) { + // Same callbacks for multiple hooks + // Or multiple objects + for (let hook of name) { + this.add(hook, 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]); + } + } + } + else if (Array.isArray(callback)) { + // Multiple callbacks for a single hook + for (let cb of callback) { + this.add(name, cb); + } + } + else { + // Single hook, single callback + name = Hooks.getCanonicalName(name); + if (!this.hooks.has(name)) { + this.hooks.set(name, new Hook()); + } + this.hooks.get(name).add(callback); + } + } + + /** + * Execute all callbacks on a specific hook + * @param {string} name + * @param {object} [env] + */ + run (name, env) { + name = Hooks.getCanonicalName(name); + this.ran.add(name); + + this.parent?.run(name, env); + + if (name.startsWith("first_")) { + this.hooks.get(name)?.runOnce(env); + } + else { + this.run("first_" + name, env); + this.hooks.get(name)?.run(env); + } + } + + 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, "_"); + } +} + +export class Hook extends Set { + /** + * Track which contexts the hook has been run on so far + * @type {WeakMap>} + */ + contexts = new WeakMap(); + + run (env) { + for (let callback of this) { + let context = env?.context ?? env; + callback.call(context, env); + + let callbacks = this.contexts.get(context); + if (!callbacks) { + callbacks = new WeakSet(); + this.contexts.set(context, callbacks); + } + callbacks.add(callback); + } + } + + /** + * Like run(), but only executes the callback once per context + * @param {*} env + */ + runOnce (env) { + for (let callback of this) { + let context = env?.context ?? env; + + let callbacks = this.contexts.get(context); + if (callbacks && callbacks.has(callback)) { + continue; + } + + callback.call(context, env); + // TODO what about callbacks added after this? + if (!callbacks) { + callbacks = new WeakSet(); + this.contexts.set(context, callbacks); + } + callbacks.add(callback); + } + } +} diff --git a/src/index-fn.js b/src/index-fn.js new file mode 100644 index 0000000..a9c74d2 --- /dev/null +++ b/src/index-fn.js @@ -0,0 +1,10 @@ +/** + * 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 "./hooks.js"; +export { default as symbols } from "./util/symbols.js"; +export * from "./plugins.js"; diff --git a/src/index.js b/src/index.js index 5415fb6..4867a5a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +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/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"; +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 * from "./index-fn.js"; +export default Element; diff --git a/src/internals/base.js b/src/internals/base.js new file mode 100644 index 0000000..b86d75d --- /dev/null +++ b/src/internals/base.js @@ -0,0 +1,40 @@ +/** + * Provide access to element internals through a symbol property + */ + +import symbols from "../util/symbols.js"; +import { defineLazyProperty } from "../util/lazy.js"; + +const { internals } = symbols.known; +const _attachInternals = HTMLElement.prototype.attachInternals; + +export const provides = { + attachInternals () { + let descriptor = Object.getOwnPropertyDescriptor(this, internals); + if (descriptor?.value) { + return descriptor.value; + } + + if (_attachInternals === undefined) { + // Not supported + return this[internals] = null; + } + + try { + return this[internals] = _attachInternals.call(this); + } + catch (error) { + return this[internals] = null; + } + }, +}; + +defineLazyProperty(provides, internals, { + get () { + return this.attachInternals(); + }, + configurable: true, + writable: true, +}); + +export default {provides}; 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/hooks.js b/src/mixins/hooks.js deleted file mode 100644 index b4b83fc..0000000 --- a/src/mixins/hooks.js +++ /dev/null @@ -1,60 +0,0 @@ -export default class Hooks { - constructor (hooks) { - if (hooks instanceof Hooks) { - return hooks; - } - - if (hooks) { - this.add(hooks); - } - } - - /** - * Schedule one or more callbacks to be executed on one or more hooks - * @param {*} name - * @param {*} callback - * @param {*} first - * @returns - */ - 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)); - } - - 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); - } - - return; - } - - if (callback) { - this[name] ??= []; - this[name][first ? "unshift" : "push"](callback); - } - } - - /** - * Execute all callbacks on a specific hook - * @param {string} name - * @param {object} [env] - */ - run (name, env) { - if (!this[name]) { - return; - } - - this[name].forEach(function (callback) { - callback.call(env?.context ?? env, env); - }); - } -} 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/plugins.js b/src/plugins.js new file mode 100644 index 0000000..0e9fe7a --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,58 @@ +export function hasPlugin (Class, plugin) { + let Super = Object.getPrototypeOf(Class); + + if (Super && hasPlugin(Super, plugin)) { + return true; + } + + let plugins = Class.symbols ? Class.symbols.plugins : "pluginsInstalled"; + + if (!Object.hasOwn(Class, plugins)) { + // No plugins installed + return false; + } + + return Class[plugins].has(plugin); +} + +export function addPlugin (Class, plugin) { + if (hasPlugin(Class, plugin)) { + return; + } + + let plugins = Class.symbols ? Class.symbols.plugins : "pluginsInstalled"; + + if (!Object.hasOwn(Class, plugins)) { + Class[plugins] = new Set(); + } + + Class[plugins].add(plugin); + + if (plugin.dependencies) { + for (let dependency of plugin.dependencies) { + addPlugin(Class, dependency); + } + } + + if (plugin.provides) { + extend(Class.prototype, plugin.provides); + } + + if (plugin.providesStatic) { + extend(Class, plugin.providesStatic); + } + + 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) { + let descriptors = Object.getOwnPropertyDescriptors(plugin); + Object.defineProperties(base, descriptors); +} diff --git a/src/props/base.js b/src/props/base.js new file mode 100644 index 0000000..f46ef08 --- /dev/null +++ b/src/props/base.js @@ -0,0 +1,97 @@ +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; + +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(this, "observedAttributes")) { + Object.defineProperty(this, "observedAttributes", { + get: () => this.props.observedAttributes, + configurable: true, + }); + } + + if (this.props) { + this.defineProps(); + } +} + +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 provides = { + // ...composed({ + // attributeChangedCallback (name, oldValue, value) { + // this.constructor.props.attributeChanged(this, name, oldValue, value); + // }, + // }), +}; + +// Internal prop values +defineLazyProperty(provides, "props", { + get () { + return {}; + }, + configurable: true, + writable: true, +}); + +// Ignore mutations on these attributes +defineLazyProperty(provides, "ignoredAttributes", { + get () { + return new Set(); + }, + configurable: true, + writable: true, +}); + +export const providesStatic = { + 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]; + // }, + // }), +}; + +export default {hooks, provides, providesStatic}; 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/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..0ddc1be --- /dev/null +++ b/src/shadow/base.js @@ -0,0 +1,45 @@ +/** + * 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.attachShadow; + +export const provides = { + attachShadow (options = this.constructor[shadowRootOptions] ?? this.constructor.shadowRoot) { + let descriptor = Object.getOwnPropertyDescriptor(this, shadowRoot); + if (descriptor?.value) { + return descriptor.value; + } + + if (_attachShadow === undefined) { + // Not supported + return this[shadowRoot] = null; + } + + this[shadowRootOptions] ??= options; + + try { + this[shadowRoot] = _attachShadow.call(this, options); + this.constructor.hooks.run("shadow-attached", {context: this, shadowRoot}); + } + catch (error) { + this[shadowRoot] = null; + } + + return this[shadowRoot]; + }, +}; + +defineLazyProperty(provides, shadowRoot, { + get () { + return this.attachShadow(); + }, + configurable: true, + writable: true, +}); + +export default {provides}; 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 new file mode 100644 index 0000000..9a461fe --- /dev/null +++ b/src/slots/base.js @@ -0,0 +1,30 @@ +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 (this[slots] === def) { + // Nothing to do here + return; + } + + // New slots to add + this[slots].add(def); + + this.hooks.run("define-slots", {context: this, slots: def}); + }, +}; + +export default { hooks, providesStatic }; 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/slots/has-slotted.js b/src/slots/has-slotted.js index 67734b5..b68fbc6 100644 --- a/src/slots/has-slotted.js +++ b/src/slots/has-slotted.js @@ -1,38 +1,20 @@ -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 default { - init () { - // Get all slots - if (SUPPORTS_HAS_SLOTTED || !this.shadowRoot) { - return; - } - - if (this.shadowRoot.slotAssignment === "manual") { - // TODO maybe wrap assign()? - } - else { - this.addEventListener("slotchange", event => { - update(event.target); - }); - } - - let slotObserver = new SlotObserver(records => { - for (let r of records) { - update(r.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); }); - - slotObserver.observe(this); }, }; + +export default { hooks }; 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 768440e..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(mutations => { - 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 new file mode 100644 index 0000000..79e228b --- /dev/null +++ b/src/slots/slot-controller.js @@ -0,0 +1,72 @@ +/** + * Slot controller + * Per-element data structure for accessing and manipulating slots + */ +import symbols from "../util/symbols.js"; + +export const { shadowRoot } = symbols.known; + +export default class SlotController { + constructor (host) { + this.host = host; + } + + /** + * 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); + } + 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); + } + } + + return slot; + } + + get $default () { + return this[""]; + } + + set $default (slot) { + this[""] = slot; + } + + get shadowRoot () { + return this.host[shadowRoot] ?? this.host.shadowRoot; + } + + update (slotName) { + if (slotName === "$default") { + slotName = ""; + } + + let selector = slotName ? `slot[name="${slotName}"]` : `slot:not([name])`; + this[slotName] = this.shadowRoot.querySelector(selector); + return this[slotName]; + } + + get (slotName) { + return this[slotName] ?? this.update(slotName); + } + + 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 64d0bb7..41b6436 100644 --- a/src/slots/slots.js +++ b/src/slots/slots.js @@ -1,118 +1,44 @@ -import SlotObserver from "./slot-observer.js"; +import Hooks from "../hooks.js"; -/** - * Slots data structure - * Gives element classes a this._slots data structure that allows easy access to named slots - */ +export default class Slots { + hooks = new Hooks(); -function removeArrayItem (array, item) { - if (!array || array.length === 0) { - return -1; + constructor (ElementClass, def) { + this.ElementClass = ElementClass; + this.def = def; + this.hooks.run("constructed", this); } - 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; + 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 (!shadowRoot) { - return null; + if (env.definition) { + this[env.name] = env.definition; } - for (let slot of shadowRoot.querySelectorAll("slot")) { - let name = slot.name || ""; - - this.#all[name] ??= []; - this.#all[name].push(slot); + return this[env.name]; + } - // 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; + /** + * Add new slot definitions + * @param {*} def + */ + define (def) { + if (!def) { + return; } - } - /** Observe slot mutations */ - observe (options) { - this.#slotObserver ??= new SlotObserver(records => { - for (let r of records) { - this[r.type](r.target, r.oldName); + if (Array.isArray(def)) { + for (let slot of def) { + this.defineSlot(slot.name, slot); } - }); - - 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]; + else { + for (let name in def) { + this.defineSlot(name, def[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(); - - if (dynamicSlots) { - slots.observe(); - } - }, - }; } 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..0b525c2 --- /dev/null +++ b/src/states/toggle.js @@ -0,0 +1,24 @@ +import symbols from "../util/symbols.js"; +import internalsPlugin from "../internals/base.js"; + +const { internals } = symbols.known; + +export const dependencies = [internalsPlugin]; + +export const provides = { + toggleState (state, force) { + if (!this[internals]) { + return; + } + + if (force === undefined) { + force = !this[internals].states.has(state); + } + + this[internals].states[force ? "add" : "delete"](state); + + return force; + }, +}; + +export default { dependencies, provides }; 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. + diff --git a/src/styles/global.js b/src/styles/global.js index 2ce4f37..cbd71f8 100644 --- a/src/styles/global.js +++ b/src/styles/global.js @@ -1,29 +1,31 @@ /** * 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"; +import symbols from "../util/symbols.js"; -export default { - prepare () { +const { fetchedGlobalStyles, roots } = symbols.new; + +export const hooks = { + first_constructor_static () { if (!this.globalStyles) { return; } let supers = getSupers(this, HTMLElement); + supers.push(this); let Super; 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); @@ -35,11 +37,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 @@ -56,11 +58,13 @@ 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); } }, }; + +export default {hooks}; 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 58de46e..6428e7a 100644 --- a/src/styles/shadow.js +++ b/src/styles/shadow.js @@ -1,20 +1,24 @@ /** * Mixin for adding shadow DOM styles */ -import { adoptCSS, fetchCSS, getSupers } from "./util.js"; +import { getSupers, adoptCSS, fetchCSS } from "./util.js"; +import symbols from "../util/symbols.js"; -export default { - setup () { +const { fetchedStyles } = symbols.new; + +export const hooks = { + first_constructor_static () { if (!this.styles) { return; } let supers = getSupers(this, HTMLElement); + supers.push(this); for (let Class of supers) { - if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, "fetchedStyles")) { + if (Object.hasOwn(Class, "styles") && !Object.hasOwn(Class, fetchedStyles)) { // Initiate fetching when the first element is constructed - let styles = (Class.fetchedStyles = Array.isArray(Class.styles) + let styles = (Class[fetchedStyles] = Array.isArray(Class.styles) ? Class.styles.slice() : [Class.styles]); @@ -24,17 +28,19 @@ export default { } } }, - async init () { + + async first_connected () { if (!this.shadowRoot) { return; } let Self = this.constructor; let supers = getSupers(Self, HTMLElement); + supers.push(Self); 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; } @@ -45,3 +51,5 @@ export default { } }, }; + +export default {hooks}; diff --git a/src/styles/util.js b/src/styles/util.js new file mode 100644 index 0000000..58f5fc0 --- /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, getSuper } from "../util/super.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 diff --git a/src/util.js b/src/util.js index b73ad95..85069ab 100644 --- a/src/util.js +++ b/src/util.js @@ -2,8 +2,5 @@ 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"; +export * from "./util/super.js"; diff --git a/src/util/composed.js b/src/util/composed.js new file mode 100644 index 0000000..8509872 --- /dev/null +++ b/src/util/composed.js @@ -0,0 +1,22 @@ +import symbols from "./symbols.js"; + +let { composed, constituents } = symbols.known; + +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..6473dd6 --- /dev/null +++ b/src/util/delegate.js @@ -0,0 +1,31 @@ +/** + * 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) { + delete descriptor.value; + delete descriptor.writable; + + descriptor.set = function (value) { + this[to][prop] = value; + }; + } + + Object.defineProperty(from, prop, descriptor); + } +} 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..aeda6af 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,38 @@ 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, }); } + +export function defineLazyProperties (object, properties) { + for (let name in properties) { + defineLazyProperty(object, name, properties[name]); + } +} 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; +} diff --git a/src/util/symbols.js b/src/util/symbols.js new file mode 100644 index 0000000..b6c507a --- /dev/null +++ b/src/util/symbols.js @@ -0,0 +1,37 @@ +export const KNOWN_SYMBOLS = {}; + +export const newSymbols = new Proxy({}, { + get (target, prop) { + if (typeof prop === "string") { + return Symbol(prop); + } + + return target[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; + } + + return target[prop]; + }, + }); +} + +export const newKnownSymbols = registry(KNOWN_SYMBOLS); + +export default { + new: newSymbols, + known: newKnownSymbols, + registry, +}; 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 100% rename from src/util/copy-properties.js rename to src/util/unused/copy-properties.js 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