x-element.js (730 lines of code) (raw):

import * as defaultTemplateEngine from './x-template.js'; /** Base element class for creating custom elements. */ export default class XElement extends HTMLElement { /** * Extends HTMLElement.observedAttributes to handle the properties block. * @returns {string[]} */ static get observedAttributes() { XElement.#analyzeConstructor(this); return [...XElement.#constructors.get(this).attributeMap.keys()]; } /** * Default templating engine. Use "templateEngine" to override. * @returns {{[key: string]: Function}} */ static get defaultTemplateEngine() { return defaultTemplateEngine; } /** * Configured templating engine. Defaults to "defaultTemplateEngine". * Override this as needed if x-element's default template engine does not * meet your needs. A "render" method is the only required field. An "html" * tagged template literal is expected, but not strictly required. * @returns {{[key: string]: Function}} */ static get templateEngine() { return XElement.defaultTemplateEngine; } /** * Declare an array of CSSStyleSheet objects to adopt on the shadow root. * Note that a CSSStyleSheet object is the type returned when importing a * stylesheet file via import attributes. * ```js * import importedStyle from './path-to.css' with { type: 'css' }; * class MyElement extends XElement { * static get styles() { * const inlineStyle = new CSSStyleSheet(); * inlineStyle.replaceSync(`:host { display: block; }`); * return [importedStyle, inlineStyle]; * } * } * ``` * @returns {CSSStyleSheet[]} */ static get styles() { return []; } /** * Observe callback. * @callback observeCallback * @param {HTMLElement} host * @param {any} value * @param {any} oldValue */ /** * A property value. * @typedef {object} Property * @property {any} [type] * @property {string} [attribute] * @property {string[]} [input] * @property {Function} [compute] * @property {observeCallback} [observe] * @property {boolean} [reflect] * @property {boolean} [internal] * @property {boolean} [readOnly] * @property {any|Function} [initial] * @property {any|Function} [default] */ /** * Declare watched properties (and related attributes) on an element. * ```js * static get properties() { * return { * property1: { * type: String, * }, * property2: { * type: Number, * input: ['property1'], * compute: this.computeProperty2, * reflect: true, * observe: this.observeProperty2, * default: 0, * } * }; * } * ``` * @returns {{[key: string]: Property}} */ static get properties() { return {}; } /** * Listen callback. * @callback delegatedListenCallback * @param {HTMLElement} host * @param {Event} event */ /** * Declare event handlers on an element. * ```js * static get listeners() { * return { * click: this.onClick, * } * } *``` * Note that listeners are added to the element's render root. Listeners are * added during "connectedCallback" and removed during "disconnectedCallback". * The arguments passed to your callback are always "(host, event)". * @returns {{[key: string]: delegatedListenCallback}} */ static get listeners() { return {}; } /** * Customize shadow root initialization and optionally forgo encapsulation. * E.g., setup focus delegation or return host instead of host.shadowRoot. * @param {HTMLElement} host * @returns {HTMLElement|ShadowRoot} */ static createRenderRoot(host) { return host.attachShadow({ mode: 'open' }); } /** * Template callback. * @callback templateCallback * @param {object} properties * @param {HTMLElement} host */ /** * Setup template callback to update DOM when properties change. * ```js * static template(html) { * return ({ href }) => { * return html`<a href="${href}">click me</a>`; * } * } * ``` * @param {Function} html * @param {{[key: string]: Function}} engine * @returns {templateCallback} */ static template(html, engine) { // eslint-disable-line no-unused-vars return (properties, host) => {}; // eslint-disable-line no-unused-vars } /** * Standard instance constructor. */ constructor() { super(); XElement.#constructHost(this); } /** * Extends HTMLElement.prototype.connectedCallback. */ connectedCallback() { XElement.#connectHost(this); } // TODO: #254: Uncomment once we leverage “moveBefore”. // /** // * Extends HTMLElement.prototype.connectedMoveCallback. // */ // connectedMoveCallback() {} /** * Extends HTMLElement.prototype.attributeChangedCallback. * @param {string} attribute * @param {string|null} oldValue * @param {string|null} value */ attributeChangedCallback(attribute, oldValue, value) { const { attributeMap } = XElement.#constructors.get(this.constructor); // Authors may extend "observedAttributes". Optionally chain to account for // attributes which we don't know about. attributeMap.get(attribute)?.sync(this, value, oldValue); } /** * Extends HTMLElement.prototype.adoptedCallback. */ adoptedCallback() {} /** * Extends HTMLElement.prototype.disconnectedCallback. */ disconnectedCallback() { XElement.#disconnectHost(this); } /** * Uses the result of your template callback to update your render root. * * This is called when properties update, but is exposed for advanced use cases. */ render() { const { template, properties, renderRoot, render } = XElement.#hosts.get(this); const result = template(properties, this); try { render(renderRoot, result); } catch (error) { const pathString = XElement.#toPathString(this); // @ts-ignore — TypeScript doesn’t get that this can accept any class. const tagName = customElements.getName(this.constructor); const message = `Invalid template for "${this.constructor.name}" / <${tagName}> at path "${pathString}".`; throw new Error(message, { cause: error }); } } /** * Listen callback. * @callback listenCallback * @param {Event} event */ /** * Wrapper around HTMLElement.addEventListener. * Advanced — use this only if declaring listeners statically is not possible. * @param {EventTarget} element * @param {string} type * @param {listenCallback} callback * @param {object} [options] */ listen(element, type, callback, options) { if (XElement.#typeIsWrong(EventTarget, element)) { const typeName = XElement.#getTypeName(element); throw new Error(`Unexpected element passed to listen (expected EventTarget, got ${typeName}).`); } if (XElement.#typeIsWrong(String, type)) { const typeName = XElement.#getTypeName(type); throw new Error(`Unexpected type passed to listen (expected String, got ${typeName}).`); } if (XElement.#typeIsWrong(Function, callback)) { const typeName = XElement.#getTypeName(callback); throw new Error(`Unexpected callback passed to listen (expected Function, got ${typeName}).`); } if (XElement.#notNullish(options) && XElement.#typeIsWrong(Object, options)) { const typeName = XElement.#getTypeName(options); throw new Error(`Unexpected options passed to listen (expected Object, got ${typeName}).`); } XElement.#addListener(this, element, type, callback, options); } /** * Wrapper around HTMLElement.removeEventListener. Inverse of "listen". * @param {EventTarget} element * @param {string} type * @param {listenCallback} callback * @param {object} [options] */ unlisten(element, type, callback, options) { if (XElement.#typeIsWrong(EventTarget, element)) { const typeName = XElement.#getTypeName(element); throw new Error(`Unexpected element passed to unlisten (expected EventTarget, got ${typeName}).`); } if (XElement.#typeIsWrong(String, type)) { const typeName = XElement.#getTypeName(type); throw new Error(`Unexpected type passed to unlisten (expected String, got ${typeName}).`); } if (XElement.#typeIsWrong(Function, callback)) { const typeName = XElement.#getTypeName(callback); throw new Error(`Unexpected callback passed to unlisten (expected Function, got ${typeName}).`); } if (XElement.#notNullish(options) && XElement.#typeIsWrong(Object, options)) { const typeName = XElement.#getTypeName(options); throw new Error(`Unexpected options passed to unlisten (expected Object, got ${typeName}).`); } XElement.#removeListener(this, element, type, callback, options); } /** * Helper method to dispatch an "ErrorEvent" on the element. * @param {Error} error */ dispatchError(error) { const { message } = error; const eventData = { error, message, bubbles: true, composed: true }; this.dispatchEvent(new ErrorEvent('error', eventData)); } /** * For element authors. Getter and setter for internal properties. * Note that you can set read-only properties from host.internal. However, you * must get read-only properties directly from the host. * @returns {object} */ get internal() { return XElement.#hosts.get(this).internal; } // Called once per class — kicked off from "static get observedAttributes". static #analyzeConstructor(constructor) { const { styles, properties, listeners } = constructor; const propertiesEntries = Object.entries(properties); const listenersEntries = Object.entries(listeners); XElement.#validateProperties(constructor, properties, propertiesEntries); XElement.#validateListeners(constructor, listeners, listenersEntries); const propertyMap = new Map(propertiesEntries); const internalPropertyMap = new Map(); // Use a normal object for better autocomplete when debugging in console. const propertiesTarget = {}; const internalTarget = {}; const attributeMap = new Map(); for (const [key, property] of propertyMap) { // We mutate (vs copy) to allow cross-referencing property objects. XElement.#mutateProperty(constructor, propertyMap, key, property); if (property.internal || property.readOnly) { internalPropertyMap.set(key, property); internalTarget[key] = undefined; } propertiesTarget[key] = undefined; if (property.attribute) { attributeMap.set(property.attribute, property); } } const listenerMap = new Map(listenersEntries); XElement.#constructors.set(constructor, { styles, propertyMap, internalPropertyMap, attributeMap, listenerMap, propertiesTarget, internalTarget, }); } // Called during constructor analysis. static #validateProperties(constructor, properties, entries) { const path = `${constructor.name}.properties`; for (const [key, property] of entries) { if (XElement.#typeIsWrong(Object, property)) { const typeName = XElement.#getTypeName(property); throw new Error(`${path}.${key} has an unexpected value (expected Object, got ${typeName}).`); } } for (const [key, property] of entries) { XElement.#validateProperty(constructor, key, property); } const attributes = new Set(); const inputMap = new Map(); for (const [key, property] of entries) { if (XElement.#propertyHasAttribute(property)) { // Attribute names are case-insensitive — lowercase to properly check for duplicates. const attribute = property.attribute ?? XElement.#camelToKebab(key); XElement.#validatePropertyAttribute(constructor, key, property, attribute); if (attributes.has(attribute)) { throw new Error(`${path}.${key} causes a duplicated attribute "${attribute}".`); } attributes.add(attribute); } if (property.input) { inputMap.set(property, property.input.map(inputKey => properties[inputKey])); for (const [index, inputKey] of Object.entries(property.input)) { if (XElement.#typeIsWrong(Object, properties[inputKey])) { throw new Error(`${path}.${key}.input[${index}] has an unexpected item ("${inputKey}" has not been declared).`); } } } } for (const [key, property] of entries) { if (XElement.#propertyIsCyclic(property, inputMap)) { throw new Error(`${path}.${key}.input is cyclic.`); } } } static #validateProperty(constructor, key, property) { const path = `${constructor.name}.properties.${key}`; if (key.includes('-')) { throw new Error(`Unexpected key "${path}" contains "-" (property names should be camelCased).`); } for (const propertyKey of Object.keys(property)) { if (XElement.#propertyKeys.has(propertyKey) === false) { throw new Error(`Unexpected key "${path}.${propertyKey}".`); } } const { type, attribute, compute, input, reflect, internal, readOnly } = property; if (Reflect.has(property, 'type') && XElement.#typeIsWrong(Function, type)) { const typeName = XElement.#getTypeName(type); throw new Error(`Unexpected value for "${path}.type" (expected constructor Function, got ${typeName}).`); } for (const subKey of ['compute', 'observe']) { if (Reflect.has(property, subKey) && XElement.#typeIsWrong(Function, property[subKey])) { const typeName = XElement.#getTypeName(property[subKey]); throw new Error(`Unexpected value for "${path}.${subKey}" (expected Function, got ${typeName}).`); } } for (const subKey of ['reflect', 'internal', 'readOnly']) { if (Reflect.has(property, subKey) && XElement.#typeIsWrong(Boolean, property[subKey])) { const typeName = XElement.#getTypeName(property[subKey]); throw new Error(`Unexpected value for "${path}.${subKey}" (expected Boolean, got ${typeName}).`); } } if (!internal && XElement.#prototypeInterface.has(key)) { throw new Error(`Unexpected key "${path}" shadows in XElement.prototype interface.`); } if (Reflect.has(property, 'attribute') && XElement.#typeIsWrong(String, attribute)) { const typeName = XElement.#getTypeName(attribute); throw new Error(`Unexpected value for "${path}.attribute" (expected String, got ${typeName}).`); } if (Reflect.has(property, 'attribute') && attribute === '') { throw new Error(`Unexpected value for "${path}.attribute" (expected non-empty String).`); } for (const subKey of ['initial', 'default']) { const value = Reflect.get(property, subKey); if ( XElement.#notNullish(value) && XElement.#typeIsWrong(Boolean, value) && XElement.#typeIsWrong(String, value) && XElement.#typeIsWrong(Number, value) && XElement.#typeIsWrong(Function, value) ) { const typeName = XElement.#getTypeName(value); throw new Error(`Unexpected value for "${path}.${subKey}" (expected Boolean, String, Number, or Function, got ${typeName}).`); } } if (Reflect.has(property, 'input') && XElement.#typeIsWrong(Array, input)) { const typeName = XElement.#getTypeName(input); throw new Error(`Unexpected value for "${path}.input" (expected Array, got ${typeName}).`); } if (Reflect.has(property, 'input')) { for (const [index, inputKey] of Object.entries(input)) { if (XElement.#typeIsWrong(String, inputKey)) { const typeName = XElement.#getTypeName(inputKey); throw new Error(`Unexpected value for "${path}.input[${index}]" (expected String, got ${typeName}).`); } } } const unserializable = XElement.#serializableTypes.has(property.type) === false; const typeName = property.type?.prototype && property.type?.name ? property.type.name : XElement.#getTypeName(property.type); if (attribute && type && unserializable) { throw new Error(`Found unserializable "${path}.type" (${typeName}) but "${path}.attribute" is defined.`); } if (reflect && unserializable) { throw new Error(`Found unserializable "${path}.type" (${typeName}) but "${path}.reflect" is true.`); } if (compute && !input) { throw new Error(`Found "${path}.compute" without "${path}.input" (computed properties require input).`); } if (input && !compute) { throw new Error(`Found "${path}.input" without "${path}.compute" (computed properties require a compute callback).`); } if (Reflect.has(property, 'initial') && compute) { throw new Error(`Found "${path}.initial" and "${path}.compute" (computed properties cannot set an initial value).`); } if (Reflect.has(property, 'readOnly') && compute) { throw new Error(`Found "${path}.readOnly" and "${path}.compute" (computed properties cannot define read-only).`); } if (reflect && internal) { throw new Error(`Both "${path}.reflect" and "${path}.internal" are true (reflected properties cannot be internal).`); } if (internal && readOnly) { throw new Error(`Both "${path}.internal" and "${path}.readOnly" are true (read-only properties cannot be internal).`); } if (internal && attribute) { throw new Error(`Found "${path}.attribute" but "${path}.internal" is true (internal properties cannot have attributes).`); } } static #validatePropertyAttribute(constructor, key, property, attribute) { const path = `${constructor.name}.properties`; // Attribute names are case-insensitive — lowercase to properly check for duplicates. if (attribute !== attribute.toLowerCase()) { throw new Error(`${path}.${key} has non-standard attribute casing "${attribute}" (use lower-cased names).`); } } // Determines if computed property inputs form a cycle. static #propertyIsCyclic(property, inputMap, seen = new Set()) { if (inputMap.has(property)) { for (const input of inputMap.get(property)) { const nextSeen = new Set([...seen, property]); if ( input === property || seen.has(input) || XElement.#propertyIsCyclic(input, inputMap, nextSeen) ) { return true; } } } } static #validateListeners(constructor, listeners, entries) { const path = `${constructor.name}.listeners`; for (const [type, listener] of entries) { if (XElement.#typeIsWrong(Function, listener)) { const typeName = XElement.#getTypeName(listener); throw new Error(`${path}.${type} has unexpected value (expected Function, got ${typeName}).`); } } } // Called once per-property during constructor analysis. static #mutateProperty(constructor, propertyMap, key, property) { property.key = key; property.attribute = XElement.#propertyHasAttribute(property) ? property.attribute ?? XElement.#camelToKebab(key) : undefined; property.input = new Set((property.input ?? []).map(inputKey => propertyMap.get(inputKey))); property.output = property.output ?? new Set(); for (const input of property.input) { input.output = input.output ?? new Set(); input.output.add(property); } XElement.#addPropertyInitial(constructor, property); XElement.#addPropertyDefault(constructor, property); XElement.#addPropertySync(constructor, property); XElement.#addPropertyCompute(constructor, property); XElement.#addPropertyReflect(constructor, property); XElement.#addPropertyObserve(constructor, property); } // Wrapper to improve ergonomics of coalescing nullish, initial value. static #addPropertyInitial(constructor, property) { // Should take `value` in and spit the initial or value out. if (Reflect.has(property, 'initial')) { const initialValue = property.initial; const isFunction = XElement.#typeIsWrong(Function, initialValue) === false; property.initial = value => value ?? (isFunction ? initialValue.call(constructor) : initialValue); } else { property.initial = value => value; } } // Wrapper to improve ergonomics of coalescing nullish, default value. static #addPropertyDefault(constructor, property) { // Should take `value` in and spit the default or value out. if (Reflect.has(property, 'default')) { const { key, default: defaultValue } = property; const isFunction = XElement.#typeIsWrong(Function, defaultValue) === false; const getOrCreateDefault = host => { const { defaultMap } = XElement.#hosts.get(host); if (!defaultMap.has(key)) { const value = isFunction ? defaultValue.call(constructor) : defaultValue; defaultMap.set(key, value); return value; } return defaultMap.get(key); }; property.default = (host, value) => value ?? getOrCreateDefault(host); } else { property.default = (host, value) => value; } } // Wrapper to improve ergonomics of syncing attributes back to properties. static #addPropertySync(constructor, property) { if (XElement.#propertyHasAttribute(property)) { property.sync = (host, value, oldValue) => { const { initialized, reflecting } = XElement.#hosts.get(host); if (reflecting === false && initialized && value !== oldValue) { const deserialization = XElement.#deserializeProperty(host, property, value); host[property.key] = deserialization; } }; } } // Wrapper to centralize logic needed to perform reflection. static #addPropertyReflect(constructor, property) { if (property.reflect) { property.reflect = host => { const value = XElement.#getPropertyValue(host, property); const serialization = XElement.#serializeProperty(host, property, value); const hostInfo = XElement.#hosts.get(host); hostInfo.reflecting = true; serialization === undefined ? host.removeAttribute(property.attribute) : host.setAttribute(property.attribute, serialization); hostInfo.reflecting = false; }; } } // Wrapper to prevent repeated compute callbacks. static #addPropertyCompute(constructor, property) { const { compute } = property; if (compute) { property.compute = host => { const { computeMap, valueMap } = XElement.#hosts.get(host); const saved = computeMap.get(property); if (saved.valid === false) { const args = []; for (const input of property.input) { args.push(XElement.#getPropertyValue(host, input)); } if (saved.args === undefined || args.some((arg, index) => arg !== saved.args[index])) { const value = property.default(host, compute.call(constructor, ...args)); XElement.#validatePropertyValue(host, property, value); valueMap.set(property, value); saved.args = args; } saved.valid = true; } return valueMap.get(property); }; } } // Wrapper to provide last value to observe callbacks. static #addPropertyObserve(constructor, property) { const { observe } = property; if (observe) { property.observe = host => { const saved = XElement.#hosts.get(host).observeMap.get(property); const value = XElement.#getPropertyValue(host, property); if (Object.is(value, saved.value) === false) { observe.call(constructor, host, value, saved.value); } saved.value = value; }; } } // Called once per-host during construction. static #constructHost(host) { const invalidProperties = new Set(); // The weak map prevents memory leaks. E.g., adding anonymous listeners. const listenerMap = new WeakMap(); const valueMap = new Map(); const renderRoot = host.constructor.createRenderRoot(host); if (!renderRoot || renderRoot !== host && renderRoot !== host.shadowRoot) { throw new Error('Unexpected render root returned. Expected "host" or "host.shadowRoot".'); } const { render, html, ...engine } = host.constructor.templateEngine; const template = host.constructor.template(html, { html, ...engine }).bind(host.constructor); const properties = XElement.#createProperties(host); const internal = XElement.#createInternal(host); const computeMap = new Map(); const observeMap = new Map(); const defaultMap = new Map(); const { styles, propertyMap } = XElement.#constructors.get(host.constructor); if (styles.length > 0) { if (renderRoot === host.shadowRoot) { if (renderRoot.adoptedStyleSheets.length === 0) { renderRoot.adoptedStyleSheets = styles; } else { throw new Error('Unexpected "styles" declared when preexisting "adoptedStyleSheets" exist.'); } } else { throw new Error('Unexpected "styles" declared without a shadow root.'); } } for (const property of propertyMap.values()) { if (property.compute) { computeMap.set(property, { valid: false, args: undefined }); } if (property.observe) { observeMap.set(property, { value: undefined }); } } XElement.#hosts.set(host, { initialized: false, reflecting: false, invalidProperties, listenerMap, renderRoot, render, template, properties, internal, computeMap, observeMap, defaultMap, valueMap, }); } // Called during host construction. static #createInternal(host) { const { propertyMap, internalPropertyMap, internalTarget } = XElement.#constructors.get(host.constructor); // Everything but "get", "set", "has", and "ownKeys" are considered invalid. // Note that impossible traps like "apply" or "construct" are not guarded. const invalid = () => { throw new Error('Invalid use of internal proxy.'); }; const get = (target, key) => { const internalProperty = internalPropertyMap.get(key); if (internalProperty?.internal) { return XElement.#getPropertyValue(host, internalProperty); } else { const path = `${host.constructor.name}.properties.${key}`; const property = propertyMap.get(key); if (property === undefined) { throw new Error(`Property "${path}" does not exist.`); } else { throw new Error(`Property "${path}" is publicly available (use normal getter).`); } } }; const set = (target, key, value) => { const internalProperty = internalPropertyMap.get(key); if (internalProperty && Reflect.has(internalProperty, 'compute') === false) { XElement.#setPropertyValue(host, internalProperty, value); return true; } else { const path = `${host.constructor.name}.properties.${key}`; const property = propertyMap.get(key); if (property === undefined) { throw new Error(`Property "${path}" does not exist.`); } else if (property.compute) { throw new Error(`Property "${path}" is computed (computed properties are read-only).`); } else { throw new Error(`Property "${path}" is publicly available (use normal setter).`); } } }; const has = (target, key) => internalPropertyMap.has(key); const ownKeys = () => [...internalPropertyMap.keys()]; const handler = { defineProperty: invalid, deleteProperty: invalid, get, getOwnPropertyDescriptor: invalid, getPrototypeOf: invalid, has, isExtensible: invalid, ownKeys, preventExtensions: invalid, set, setPrototypeOf: invalid, }; return new Proxy(internalTarget, handler); } // Only available in template callback. Provides getter for all properties. // Called during host construction. static #createProperties(host) { const { propertyMap, propertiesTarget } = XElement.#constructors.get(host.constructor); // Everything but "get", "set", "has", and "ownKeys" are considered invalid. const invalid = () => { throw new Error('Invalid use of properties proxy.'); }; const get = (target, key) => { if (propertyMap.has(key)) { return XElement.#getPropertyValue(host, propertyMap.get(key)); } else { const path = `${host.constructor.name}.properties.${key}`; throw new Error(`Property "${path}" does not exist.`); } }; const set = (target, key) => { const path = `${host.constructor.name}.properties.${key}`; if (propertyMap.has(key)) { throw new Error(`Cannot set "${path}" via "properties".`); } else { throw new Error(`Property "${path}" does not exist.`); } }; const has = (target, key) => propertyMap.has(key); const ownKeys = () => [...propertyMap.keys()]; const handler = { defineProperty: invalid, deleteProperty: invalid, get, getOwnPropertyDescriptor: invalid, getPrototypeOf: invalid, has, isExtensible: invalid, ownKeys, preventExtensions: invalid, set, setPrototypeOf: invalid, }; return new Proxy(propertiesTarget, handler); } // Called once per-host from initial "connectedCallback". static #connectHost(host) { const initialized = XElement.#initializeHost(host); XElement.#addListeners(host); if (initialized) { XElement.#updateHost(host); } } static #disconnectHost(host) { XElement.#removeListeners(host); } static #initializeHost(host) { const hostInfo = XElement.#hosts.get(host); const { computeMap, initialized, invalidProperties } = hostInfo; if (initialized === false) { XElement.#upgradeOwnProperties(host); // Only reflect attributes when the element is connected. const { propertyMap } = XElement.#constructors.get(host.constructor); for (const property of propertyMap.values()) { const { value, found } = XElement.#getPreUpgradePropertyValue(host, property); XElement.#initializeProperty(host, property); if (found) { host[property.key] = property.default(host, property.initial(value)); } else if (!property.compute) { // Set to a nullish value so that it coalesces to the default. XElement.#setPropertyValue(host, property, property.default(host, property.initial())); } invalidProperties.add(property); if (property.compute) { computeMap.get(property).valid = false; } } hostInfo.initialized = true; return true; } return false; } // Prevent shadowing from properties added to element instance pre-upgrade. static #upgradeOwnProperties(host) { for (const key of Reflect.ownKeys(host)) { const value = Reflect.get(host, key); Reflect.deleteProperty(host, key); Reflect.set(host, key, value); } } // Called during host initialization. static #getPreUpgradePropertyValue(host, property) { // Process possible sources of initial state, with this priority: // 1. imperative, e.g. `element.prop = 'value';` // 2. declarative, e.g. `<element prop="value"></element>` const { key, attribute, internal } = property; let value; let found = false; if (!internal) { // Only look for public (i.e., non-internal) properties. if (Reflect.has(host, key)) { value = host[key]; found = true; } else if (attribute && host.hasAttribute(attribute)) { const attributeValue = host.getAttribute(attribute); value = XElement.#deserializeProperty(host, property, attributeValue); found = true; } } return { value, found }; } static #initializeProperty(host, property) { if (!property.internal) { const { key, compute, readOnly } = property; const path = `${host.constructor.name}.properties.${key}`; const get = () => XElement.#getPropertyValue(host, property); const set = compute || readOnly ? () => { if (compute) { throw new Error(`Property "${path}" is computed (computed properties are read-only).`); } else { throw new Error(`Property "${path}" is read-only.`); } } : value => XElement.#setPropertyValue(host, property, value); Reflect.deleteProperty(host, key); Reflect.defineProperty(host, key, { get, set, enumerable: true }); } } static #addListener(host, element, type, callback, options) { callback = XElement.#getListener(host, callback); element.addEventListener(type, callback, options); } static #addListeners(host) { const { listenerMap } = XElement.#constructors.get(host.constructor); const { renderRoot } = XElement.#hosts.get(host); for (const [type, listener] of listenerMap) { XElement.#addListener(host, renderRoot, type, listener); } } static #removeListener(host, element, type, callback, options) { callback = XElement.#getListener(host, callback); element.removeEventListener(type, callback, options); } static #removeListeners(host) { const { listenerMap } = XElement.#constructors.get(host.constructor); const { renderRoot } = XElement.#hosts.get(host); for (const [type, listener] of listenerMap) { XElement.#removeListener(host, renderRoot, type, listener); } } static #getListener(host, listener) { const { listenerMap } = XElement.#hosts.get(host); if (listenerMap.has(listener) === false) { listenerMap.set(listener, listener.bind(host.constructor, host)); } return listenerMap.get(listener); } static #updateHost(host) { // Order of operations: compute, reflect, render, then observe. const { invalidProperties } = XElement.#hosts.get(host); const invalidPropertiesCopy = new Set(invalidProperties); invalidProperties.clear(); for (const property of invalidPropertiesCopy) { property.reflect?.(host); } host.render(); for (const property of invalidPropertiesCopy) { property.observe?.(host); } } // Used to improve error messaging by appending DOM path information. static #toPathString(host) { const path = []; let reference = host; while (reference) { path.push(reference); reference = reference.parentElement ?? reference.getRootNode().host; } return path .map(element => { const tag = element.localName; const attributes = Array.from(element.attributes) .map(({ name, value }) => value ? `${name}="${value}"` : name); return `${tag}${attributes.length ? `[${attributes.join('][')}]` : ''}`; }) .join(' < '); } static async #invalidateProperty(host, property) { const { initialized, invalidProperties, computeMap } = XElement.#hosts.get(host); if (initialized) { for (const output of property.output) { XElement.#invalidateProperty(host, output); } const queueUpdate = invalidProperties.size === 0; invalidProperties.add(property); if (property.compute) { computeMap.get(property).valid = false; } if (queueUpdate) { // Queue a microtask. Allows multiple, synchronous changes. await Promise.resolve(); XElement.#updateHost(host); } } } static #getPropertyValue(host, property) { const { valueMap } = XElement.#hosts.get(host); return property.compute?.(host) ?? valueMap.get(property); } static #validatePropertyValue(host, property, value) { if (property.type && XElement.#notNullish(value)) { if (XElement.#typeIsWrong(property.type, value)) { const path = `${host.constructor.name}.properties.${property.key}`; const typeName = XElement.#getTypeName(value); throw new Error(`Unexpected value for "${path}" (expected ${property.type.name}, got ${typeName}).`); } } } static #setPropertyValue(host, property, value) { const { valueMap } = XElement.#hosts.get(host); if (Object.is(value, valueMap.get(property)) === false) { value = property.default(host, value); XElement.#validatePropertyValue(host, property, value); valueMap.set(property, value); XElement.#invalidateProperty(host, property); } } static #serializeProperty(host, property, value) { if (XElement.#notNullish(value)) { if (property.type === Boolean) { return value ? '' : undefined; } return value.toString(); } } static #deserializeProperty(host, property, value) { if (property.type === Boolean) { // Per HTML spec, every value other than null is considered true. return value !== null; } else if (value === null) { // Null as an attribute is really "undefined" as a property. return undefined; } else if (!property.type) { // Property doesn't have a type, leave it as a string. return value; } else { // Coerce type as needed. switch (property.type) { case Number: // Don't try and coerce something like "Number('') >> 0". return value.trim() ? property.type(value) : Number.NaN; default: return property.type(value); } } } // Public properties which are serializable or typeless have attributes. static #propertyHasAttribute(property) { return !property.internal && (XElement.#serializableTypes.has(property.type) || !property.type); } static #getTypeName(value) { return Object.prototype.toString.call(value).slice(8, -1); } static #notNullish(value) { return value !== undefined && value !== null; } static #typeIsWrong(type, value) { // Because `instanceof` fails on primitives (`'' instanceof String === false`) // and `Object.prototype.toString` cannot handle inheritance, we use both. return ( XElement.#notNullish(value) === false || (!(value instanceof type) && XElement.#getTypeName(value) !== type.name) ); } static #camelToKebab(camel) { if (XElement.#caseMap.has(camel) === false) { XElement.#caseMap.set(camel, camel.replace(/([A-Z])/g, '-$1').toLowerCase()); } return XElement.#caseMap.get(camel); } static #constructors = new WeakMap(); static #hosts = new WeakMap(); static #propertyKeys = new Set(['type', 'attribute', 'input', 'compute', 'observe', 'reflect', 'internal', 'readOnly', 'initial', 'default']); static #serializableTypes = new Set([Boolean, String, Number]); static #caseMap = new Map(); static #prototypeInterface = new Set(Object.getOwnPropertyNames(XElement.prototype)); }