UNPKG

@lexml/lexml-eta

Version:

Webcomponent lexml-eta following open-wc recommendations

1,387 lines (1,292 loc) 1.3 MB
/** * Collection of shared Symbol objects for internal component communication. * * The shared `Symbol` objects in this module let mixins and a component * internally communicate without exposing these internal properties and methods * in the component's public API. They also help avoid unintentional name * collisions, as a component developer must specifically import the `internal` * module and reference one of its symbols. * * To use these `Symbol` objects in your own component, include this module and * then create a property or method whose key is the desired Symbol. E.g., * [ShadowTemplateMixin](ShadowTemplateMixin) expects a component to define * a property called [template](#template): * * import { template } from 'elix/src/core/internal.js'; * import { templateFrom } from 'elix/src/core/htmlLiterals.js' * import ShadowTemplateMixin from 'elix/src/core/ShadowTemplateMixin.js'; * * class MyElement extends ShadowTemplateMixin(HTMLElement) { * [template]() { * return templateFrom.html`Hello, <em>world</em>.`; * } * } * * The above use of the internal `template` member lets the mixin find the * component's template in a way that will not pollute the component's public * API or interfere with other component logic. For example, if for some reason * the component wants to define a separate property with the plain string name, * "template", it can do so without affecting the above property setter. * * @module internal */ /** * Symbol for the default state for this element. */ const defaultState$1 = Symbol("defaultState"); /** * Symbol for the `delegatesFocus` property. * * [DelegatesFocusMixin](DelegatesFocusMixin) defines this property, returning * true to indicate that the focus is being delegated, even in browsers that * don't support that natively. Mixins like [KeyboardMixin](KeyboardMixin) use * this to accommodate focus delegation. */ const delegatesFocus$1 = Symbol("delegatesFocus"); /** * Symbol for the `firstRender` property. * * [ReactiveMixin](ReactiveMixin) sets the property to `true` during the * element's first `render` and `rendered` callback, then `false` in subsequent * callbacks. * * You can inspect this property in your own `rendered` callback handler to do * work like wiring up events that should only happen once. */ const firstRender$1 = Symbol("firstRender"); /** * Symbol for the `focusTarget` property. * * [DelegatesFocusMixin](DelegatesFocusMixin) defines this property as either: * 1) the element itself, in browsers that support native focus delegation or, * 2) the shadow root's first focusable element. */ const focusTarget$1 = Symbol("focusTarget"); /** * Symbol for the `hasDynamicTemplate` property. * * If your component class does not always use the same template, define a * static class property getter with this symbol and have it return `true`. * This will disable template caching for your component. */ const hasDynamicTemplate$1 = Symbol("hasDynamicTemplate"); /** * Symbol for the `ids` property. * * [ShadowTemplateMixin](ShadowTemplateMixin) defines a shorthand function * `internal.ids` that can be used to obtain a reference to a shadow element with * a given ID. * * Example: if component's template contains a shadow element * `<button id="foo">`, you can use the reference `this[ids].foo` to obtain * the corresponding button in the component instance's shadow tree. * The `ids` function is simply a shorthand for `getElementById`, so * `this[ids].foo` is the same as `this.shadowRoot.getElementById('foo')`. */ const ids$1 = Symbol("ids"); /** * Symbol for access to native HTML element internals. */ const nativeInternals$1 = Symbol("nativeInternals"); /** * Symbol for the `raiseChangeEvents` property. * * This property is used by mixins to determine whether they should raise * property change events. The standard HTML pattern is to only raise such * events in response to direct user interactions. For a detailed discussion * of this point, see the Gold Standard checklist item for * [Propery Change Events](https://github.com/webcomponents/gold-standard/wiki/Property%20Change%20Events). * * The above article describes a pattern for using a flag to track whether * work is being performed in response to internal component activity, and * whether the component should therefore raise property change events. * This `raiseChangeEvents` symbol is a shared flag used for that purpose by * all Elix mixins and components. Sharing this flag ensures that internal * activity (e.g., a UI event listener) in one mixin can signal other mixins * handling affected properties to raise change events. * * All UI event listeners (and other forms of internal handlers, such as * timeouts and async network handlers) should set `raiseChangeEvents` to * `true` at the start of the event handler, then `false` at the end: * * this.addEventListener('click', event => { * this[raiseChangeEvents] = true; * // Do work here, possibly setting properties, like: * this.foo = 'Hello'; * this[raiseChangeEvents] = false; * }); * * Elsewhere, property setters that raise change events should only do so it * this property is `true`: * * set foo(value) { * // Save foo value here, do any other work. * if (this[raiseChangeEvents]) { * export const event = new CustomEvent('foochange'); * this.dispatchEvent(event); * } * } * * In this way, programmatic attempts to set the `foo` property will not trigger * the `foochange` event, but UI interactions that update that property will * cause those events to be raised. */ const raiseChangeEvents$1 = Symbol("raiseChangeEvents"); /** * Symbol for the `render` method. * * [ReactiveMixin](ReactiveMixin) invokes this `internal.render` method to give * the component a chance to render recent changes in component state. */ const render$3 = Symbol("render"); /** * Symbol for the `renderChanges` method. * * [ReactiveMixin](ReactiveMixin) invokes this method in response to a * `setState` call; you should generally not invoke this method yourself. */ const renderChanges$1 = Symbol("renderChanges"); /** * Symbol for the `rendered` method. * * [ReactiveMixin](ReactiveMixin) will invoke this method after your * element has completely finished rendering. * * If you only want to do work the first time rendering happens (for example, if * you want to wire up event handlers), your `internal.rendered` implementation * can inspect the `internal.firstRender` flag. */ const rendered$1 = Symbol("rendered"); /** * Symbol for the `rendering` property. * * [ReactiveMixin](ReactiveMixin) sets this property to true during rendering, * at other times it will be false. */ const rendering$1 = Symbol("rendering"); /** * Symbol for the `setState` method. * * A component using [ReactiveMixin](ReactiveMixin) can invoke this method to * apply changes to the element's current state. */ const setState$1 = Symbol("setState"); /** * Symbol for the `shadowRoot` property. * * This property holds a reference to an element's shadow root, like * `this.shadowRoot`. This propery exists because `this.shadowRoot` is not * available for components with closed shadow roots. * [ShadowTemplateMixin](ShadowTemplateMixin) creates open shadow roots by * default, but you can opt into creating closed shadow roots; see * [shadowRootMode](internal#internal.shadowRootMode). */ const shadowRoot$1 = Symbol("shadowRoot"); /** * Symbol for the `shadowRootMode` property. * * If true (the default), then [ShadowTemplateMixin](ShadowTemplateMixin) will * create an open shadow root when the component is instantiated. Set this to * false if you want to programmatically hide component internals in a closed * shadow root. */ const shadowRootMode$1 = Symbol("shadowRootMode"); /** * Symbol for the element's current state. * * This is managed by [ReactiveMixin](ReactiveMixin). */ const state$1 = Symbol("state"); /** * Symbol for the `stateEffects` method. * * See [stateEffects](ReactiveMixin#stateEffects). */ const stateEffects$1 = Symbol("stateEffects"); /** * Symbol for the `template` method. * * [ShadowTemplateMixin](ShadowTemplateMixin) uses this property to obtain a * component's template, which it will clone into a component's shadow root. */ const template$1 = Symbol("template"); /** * Given a string value for a named boolean attribute, return `true` if the * value is either: a) the empty string, or b) a case-insensitive match for the * name. * * This is native HTML behavior; see the MDN documentation on [boolean * attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#Boolean_Attributes) * for the reasoning. * * Given a null value, this return `false`. * Given a boolean value, this return the value as is. * * @param {string} name * @param {string|boolean|null} value */ function booleanAttributeValue(name, value) { return typeof value === "boolean" ? value : typeof value === "string" ? value === "" || name.toLowerCase() === value.toLowerCase() : false; } /** * Return the closest focusable node that's either the node itself (if it's * focusable), or the closest focusable ancestor in the *composed* tree. * * If no focusable node is found, this returns null. * * @param {Node} node * @returns {HTMLElement|null} */ function closestFocusableNode(node) { for (const current of selfAndComposedAncestors(node)) { // If the current element defines a focusTarget (e.g., via // DelegateFocusMixin), use that, otherwise use the element itself. const target = current[focusTarget$1] || current; // We want an element that has a tabIndex of 0 or more. We ignore disabled // elements, and slot elements (which oddly have a tabIndex of 0). /** @type {any} */ const cast = target; const focusable = target instanceof HTMLElement && target.tabIndex >= 0 && !cast.disabled && !(target instanceof HTMLSlotElement); if (focusable) { return target; } } return null; } /** * Return the ancestors of the given node in the composed tree. * * In the composed tree, the ancestor of a node assigned to a slot is that slot, * not the node's DOM ancestor. The ancestor of a shadow root is its host. * * @param {Node} node * @returns {Iterable<Node>} */ function* composedAncestors(node) { /** @type {Node|null} */ let current = node; while (true) { current = current instanceof HTMLElement && current.assignedSlot ? current.assignedSlot : current instanceof ShadowRoot ? current.host : current.parentNode; if (current) { yield current; } else { break; } } } /** * Returns true if the first node contains the second, even if the second node * is in a shadow tree. * * The standard Node.contains() function does not account for Shadow DOM, and * returns false if the supplied target node is sitting inside a shadow tree * within the container. * * @param {Node} container - The container to search within. * @param {Node} target - The node that may be inside the container. * @returns {boolean} - True if the container contains the target node. */ function deepContains(container, target) { /** @type {any} */ let current = target; while (current) { const parent = current.assignedSlot || current.parentNode || current.host; if (parent === container) { return true; } current = parent; } return false; } /** * Return the first focusable element in the composed tree below the given root. * The composed tree includes nodes assigned to slots. * * This heuristic considers only the document order of the elements below the * root and whether a given element is focusable. It currently does not respect * the tab sort order defined by tabindex values greater than zero. * * @param {Node} root - the root of the tree in which to search * @returns {HTMLElement|null} - the first focusable element, or null if none * was found */ function firstFocusableElement(root) { // CSS selectors for focusable elements from // https://stackoverflow.com/a/30753870/76472 const focusableQuery = 'a[href],area[href],button:not([disabled]),details,iframe,input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[contentEditable="true"],[tabindex]'; // Walk the tree looking for nodes that match the above selectors. const walker = walkComposedTree( root, (/** @type {Node} */ node) => node instanceof HTMLElement && node.matches(focusableQuery) && node.tabIndex >= 0 ); // We only actually need the first matching value. const { value } = walker.next(); // value, if defined, will always be an HTMLElement, but we do the following // check to pass static type checking. return value instanceof HTMLElement ? value : null; } /** * Search a list element for the item that contains the specified target. * * When dealing with UI events (e.g., mouse clicks) that may occur in * subelements inside a list item, you can use this routine to obtain the * containing list item. * * @param {NodeList|Node[]} items - A list element containing a set of items * @param {Node} target - A target element that may or may not be an item in the * list. * @returns {number} - The index of the list child that is or contains the * indicated target node. Returns -1 if not found. */ function indexOfItemContainingTarget(items, target) { return Array.prototype.findIndex.call( items, (/** @type Node */ item) => item === target || deepContains(item, target) ); } /** * Return true if the event came from within the node (or from the node itself); * false otherwise. * * @param {Node} node - The node to consider in relation to the event * @param {Event} event - The event which may have been raised within/by the * node * @returns {boolean} - True if the event was raised within or by the node */ function ownEvent(node, event) { /** @type {any} */ const cast = event; const eventSource = cast.composedPath()[0]; return node === eventSource || deepContains(node, eventSource); } /** * Returns the set that includes the given node and all of its ancestors in the * composed tree. See [composedAncestors](#composedAncestors) for details on the * latter. * * @param {Node} node * @returns {Iterable<Node>} */ function* selfAndComposedAncestors(node) { if (node) { yield node; yield* composedAncestors(node); } } /** * Set an internal state for browsers that support the `:state` selector, as * well as an attribute of the same name to permit state-based styling on older * browsers. * * When all browsers support that, we'd like to deprecate use of attributes. * * @param {Element} element * @param {string} name * @param {boolean} value */ function setInternalState(element, name, value) { element.toggleAttribute(name, value); if (element[nativeInternals$1] && element[nativeInternals$1].states) { element[nativeInternals$1].states.toggle(name, value); } } /** @type {IndexedObject<boolean>} */ const standardBooleanAttributes = { checked: true, defer: true, disabled: true, hidden: true, ismap: true, multiple: true, noresize: true, readonly: true, selected: true, }; /** * Adds or removes the element's `childNodes` as necessary to match the nodes * indicated in the `childNodes` parameter. * * This operation is useful in cases where you maintain your own set of nodes * which should be rendered as the children of some element. When you insert or * remove nodes in that set, you can invoke this function to efficiently apply * the new set as a delta to the existing children. Only the items in the set * that have actually changed will be added or removed. * * @param {Element} element - the element to update * @param {(NodeList|Node[])} childNodes - the set of nodes to apply */ function updateChildNodes(element, childNodes) { // If the childNodes parameter is the actual childNodes of an element, then as // we append those nodes to the indicated target element, they'll get removed // from the original set. To keep the list stable, we make a copy. const copy = [...childNodes]; const oldLength = element.childNodes.length; const newLength = copy.length; const length = Math.max(oldLength, newLength); for (let i = 0; i < length; i++) { const oldChild = element.childNodes[i]; const newChild = copy[i]; if (i >= oldLength) { // Add new item not in old set. element.append(newChild); } else if (i >= newLength) { // Remove old item past end of new set. element.removeChild(element.childNodes[newLength]); } else if (oldChild !== newChild) { if (copy.indexOf(oldChild, i) >= i) { // Old node comes later in final set. Insert the new node rather than // replacing it so that we don't detach the old node only to have to // reattach it later. element.insertBefore(newChild, oldChild); } else { // Replace old item with new item. element.replaceChild(newChild, oldChild); } } } } /** * Walk the composed tree at the root for elements that pass the given filter. * * Note: the jsDoc types required for the filter function are too complex for * the current jsDoc parser to support strong type-checking. * * @private * @param {Node} node * @param {function} filter * @returns {IterableIterator<Node>} */ function* walkComposedTree(node, filter) { if (filter(node)) { yield node; } let children; if (node instanceof HTMLElement && node.shadowRoot) { // Walk the shadow instead of the light DOM. children = node.shadowRoot.children; } else { const assignedNodes = node instanceof HTMLSlotElement ? node.assignedNodes({ flatten: true }) : []; children = assignedNodes.length > 0 ? // Walk light DOM nodes assigned to this slot. assignedNodes : // Walk light DOM children. node.childNodes; } if (children) { for (let i = 0; i < children.length; i++) { yield* walkComposedTree(children[i], filter); } } } /** * JavaScript template literals for constructing DOM nodes from HTML * * @module html */ /** * A JavaScript template string literal that returns an HTML document fragment. * * Example: * * const fragment = fragmentFrom.html`Hello, <em>world</em>.` * * returns a `DocumentFragment` whose `innerHTML` is `Hello, <em>world</em>.` * * This function is called `html` so that it can be easily used with HTML * syntax-highlighting extensions for various popular code editors. * * See also [templateFrom.html](template#html), which returns a similar result but * as an HTMLTemplateElement. * * @param {TemplateStringsArray} strings - the strings passed to the JavaScript template * literal * @param {string[]} substitutions - the variable values passed to the * JavaScript template literal * @returns {DocumentFragment} */ const fragmentFrom = { html(strings, ...substitutions) { return templateFrom.html(strings, ...substitutions).content; }, }; /** * A JavaScript template string literal that returns an HTML template. * * Example: * * const myTemplate = templateFrom.html`Hello, <em>world</em>.` * * returns an `HTMLTemplateElement` whose `innerHTML` is `Hello, <em>world</em>.` * * This function is called `html` so that it can be easily used with HTML * syntax-highlighting extensions for various popular code editors. * * See also [html](html), a helper which returns a similar result but as an * DocumentFragment. * * @param {TemplateStringsArray} strings - the strings passed to the JavaScript template * literal * @param {string[]} substitutions - the variable values passed to the * JavaScript template literal * @returns {HTMLTemplateElement} */ const templateFrom = { html(strings, ...substitutions) { const template = document.createElement("template"); template.innerHTML = String.raw(strings, ...substitutions); return template; }, }; /** * Helpers for dynamically creating and patching component templates. * * The [ShadowTemplateMixin](ShadowTemplateMixin) lets you define a component * template that will be used to popuplate the shadow subtree of new component * instances. These helpers, especially the [html](#html) function, are intended * to simplify the creation of such templates. * * In particular, these helpers can be useful in [patching * templates](customizing#template-patching) inherited from a base class. * * Some of these functions take _descriptors_ that can either be a class, a tag * name, or an HTML template. These are generally used to fill specific roles in * an element's template; see [element roles](customizing#element-part-types). * * @module template */ // Used by registerCustomElement. const mapBaseTagToCount = new Map(); /** * Create an element from a role descriptor (a component class constructor or * an HTML tag name). * * If the descriptor is an HTML template, and the resulting document fragment * contains a single top-level node, that node is returned directly (instead of * the fragment). * * @param {PartDescriptor} descriptor - the descriptor that will be used to * create the element * @returns {Element} the new element */ function createElement(descriptor) { if (typeof descriptor === "function") { // Instantiable component class constructor let element; try { element = new descriptor(); } catch (e) { if (e.name === "TypeError") { // Most likely this error results from the fact that the indicated // component class hasn't been registered. Register it now with a random // name and try again. registerCustomElement(descriptor); element = new descriptor(); } else { // The exception was for some other reason. throw e; } } return element; // @ts-ignore } else { // String tag name: e.g., 'div' return document.createElement(descriptor); } } /** * Register the indicated constructor as a custom element class. * * This function generates a suitable string tag for the class. If the * constructor is a named function (which is typical for hand-authored code), * the function's `name` will be used as the base for the tag. If the * constructor is an anonymous function (which often happens in * generated/minified code), the tag base will be "custom-element". * * In either case, this function adds a uniquifying number to the end of the * base to produce a complete tag. * * @private * @param {Constructor<HTMLElement>} classFn */ function registerCustomElement(classFn) { let baseTag; // HTML places more restrictions on the first character in a tag than // JavaScript places on the first character of a class name. We apply this // more restrictive condition to the class names we'll convert to tags. Class // names that fail this check -- often generated class names -- will result in // a base tag name of "custom-element". const classNameRegex = /^[A-Za-z][A-Za-z0-9_$]*$/; const classNameMatch = classFn.name && classFn.name.match(classNameRegex); if (classNameMatch) { // Given the class name `FooBar`, calculate the base tag name `foo-bar`. const className = classNameMatch[0]; const uppercaseRegEx = /([A-Z])/g; const hyphenated = className.replace( uppercaseRegEx, (match, letter, offset) => (offset > 0 ? `-${letter}` : letter) ); baseTag = hyphenated.toLowerCase(); } else { baseTag = "custom-element"; } // Add a uniquifying number to the end of the tag until we find a tag // that hasn't been registered yet. let count = mapBaseTagToCount.get(baseTag) || 0; let tag; for (; ; count++) { tag = `${baseTag}-${count}`; if (!customElements.get(tag)) { // Not in use. break; } } // Register with the generated tag. customElements.define(tag, /** @type {any} */ classFn); // Bump number and remember it. If we see the same base tag again later, we'll // start counting at that number in our search for a uniquifying number. mapBaseTagToCount.set(baseTag, count + 1); } /** * Replace an original node in a tree or document fragment with the indicated * replacement node. The attributes, classes, styles, and child nodes of the * original node will be moved to the replacement. * * @param {Node} original - an existing node to be replaced * @param {Node} replacement - the node to replace the existing node with * @returns {Node} the updated replacement node */ function replace(original, replacement) { const parent = original.parentNode; if (!parent) { throw "An element must have a parent before it can be substituted."; } if ( (original instanceof HTMLElement || original instanceof SVGElement) && (replacement instanceof HTMLElement || replacement instanceof SVGElement) ) { // Merge attributes from original to replacement, letting replacement win // conflicts. Handle classes and styles separately (below). Array.prototype.forEach.call(original.attributes, ( /** @type {Attr} */ attribute ) => { if ( !replacement.getAttribute(attribute.name) && attribute.name !== "class" && attribute.name !== "style" ) { replacement.setAttribute(attribute.name, attribute.value); } }); // Copy classes/styles from original to replacement, letting replacement win // conflicts. Array.prototype.forEach.call(original.classList, ( /** @type {string} */ className ) => { replacement.classList.add(className); }); Array.prototype.forEach.call(original.style, ( /** @type {number} */ key ) => { if (!replacement.style[key]) { replacement.style[key] = original.style[key]; } }); } // Copy over children. // @ts-ignore replacement.append(...original.childNodes); parent.replaceChild(replacement, original); return replacement; } /** * Replace a node with a new element, transferring all attributes, classes, * styles, and child nodes from the original(s) to the replacement(s). * * The descriptor used for the replacements can be a 1) component class * constructor, 2) an HTML tag name, or 3) an HTML template. For #1 and #2, if * the existing elements that match the selector are already of the desired * class/tag name, the replacement operation is skipped. * * @param {Element} original - the node to replace * @param {PartDescriptor} descriptor - the descriptor used to generate the * replacement element * @returns {Element} the replacement node(s) */ function transmute(original, descriptor) { if ( (typeof descriptor === "function" && original.constructor === descriptor) || (typeof descriptor === "string" && original instanceof Element && original.localName === descriptor) ) { // Already correct type of element, no transmutation necessary. return original; } else { // Transmute the single node. const replacement = createElement(descriptor); replace(original, replacement); return replacement; } } // Memoized maps of attribute to property names and vice versa. // We initialize this with the special case of the tabindex (lowercase "i") // attribute, which is mapped to the tabIndex (capital "I") property. /** @type {IndexedObject<string>} */ const attributeToPropertyNames = { tabindex: "tabIndex", }; /** @type {IndexedObject<string>} */ const propertyNamesToAttributes = { tabIndex: "tabindex", }; /** * Sets properties when the corresponding attributes change * * If your component exposes a setter for a property, it's generally a good * idea to let devs using your component be able to set that property in HTML * via an element attribute. You can code that yourself by writing an * `attributeChangedCallback`, or you can use this mixin to get a degree of * automatic support. * * This mixin implements an `attributeChangedCallback` that will attempt to * convert a change in an element attribute into a call to the corresponding * property setter. Attributes typically follow hyphenated names ("foo-bar"), * whereas properties typically use camelCase names ("fooBar"). This mixin * respects that convention, automatically mapping the hyphenated attribute * name to the corresponding camelCase property name. * * Example: You define a component using this mixin: * * class MyElement extends AttributeMarshallingMixin(HTMLElement) { * get fooBar() { return this._fooBar; } * set fooBar(value) { this._fooBar = value; } * } * * If someone then instantiates your component in HTML: * * <my-element foo-bar="Hello"></my-element> * * Then, after the element has been upgraded, the `fooBar` setter will * automatically be invoked with the initial value "Hello". * * Attributes can only have string values. If you'd like to convert string * attributes to other types (numbers, booleans), you must implement parsing * yourself. * * @module AttributeMarshallingMixin * @param {Constructor<CustomElement>} Base */ function AttributeMarshallingMixin(Base) { // The class prototype added by the mixin. class AttributeMarshalling extends Base { /** * Handle a change to the attribute with the given name. * * @ignore * @param {string} attributeName * @param {string} oldValue * @param {string} newValue */ attributeChangedCallback(attributeName, oldValue, newValue) { if (super.attributeChangedCallback) { super.attributeChangedCallback(attributeName, oldValue, newValue); } // Sometimes this callback is invoked when there's not actually any // change, in which we skip invoking the property setter. // // We also skip setting properties if we're rendering. A component may // want to reflect property values to attributes during rendering, but // such attribute changes shouldn't trigger property updates. if (newValue !== oldValue && !this[rendering$1]) { const propertyName = attributeToPropertyName(attributeName); // If the attribute name corresponds to a property name, set the property. if (propertyName in this) { // Parse standard boolean attributes. const parsed = standardBooleanAttributes[attributeName] ? booleanAttributeValue(attributeName, newValue) : newValue; this[propertyName] = parsed; } } } // Because maintaining the mapping of attributes to properties is tedious, // this provides a default implementation for `observedAttributes` that // assumes that your component will want to expose all public properties in // your component's API as properties. // // You can override this default implementation of `observedAttributes`. For // example, if you have a system that can statically analyze which // properties are available to your component, you could hand-author or // programmatically generate a definition for `observedAttributes` that // avoids the minor run-time performance cost of inspecting the component // prototype to determine your component's public properties. static get observedAttributes() { return attributesForClass(this); } } return AttributeMarshalling; } /** * Return the custom attributes for the given class. * * E.g., if the supplied class defines a `fooBar` property, then the resulting * array of attribute names will include the "foo-bar" attribute. * * @private * @param {Constructor<HTMLElement>} classFn * @returns {string[]} */ function attributesForClass(classFn) { // We treat the HTMLElement base class as if it has no attributes, since we // don't want to receive attributeChangedCallback for it (or anything further // up the protoype chain). if (classFn === HTMLElement) { return []; } // Get attributes for parent class. const baseClass = Object.getPrototypeOf(classFn.prototype).constructor; // See if parent class defines observedAttributes manually. let baseAttributes = baseClass.observedAttributes; if (!baseAttributes) { // Calculate parent class attributes ourselves. baseAttributes = attributesForClass(baseClass); } // Get the properties for this particular class. const propertyNames = Object.getOwnPropertyNames(classFn.prototype); const setterNames = propertyNames.filter((propertyName) => { const descriptor = Object.getOwnPropertyDescriptor( classFn.prototype, propertyName ); return descriptor && typeof descriptor.set === "function"; }); // Map the property names to attribute names. const attributes = setterNames.map((setterName) => propertyNameToAttribute(setterName) ); // Merge the attribute for this class and its base class. const diff = attributes.filter( (attribute) => baseAttributes.indexOf(attribute) < 0 ); const result = baseAttributes.concat(diff); return result; } /** * Convert hyphenated foo-bar attribute name to camel case fooBar property name. * * @private * @param {string} attributeName */ function attributeToPropertyName(attributeName) { let propertyName = attributeToPropertyNames[attributeName]; if (!propertyName) { // Convert and memoize. const hyphenRegEx = /-([a-z])/g; propertyName = attributeName.replace(hyphenRegEx, (match) => match[1].toUpperCase() ); attributeToPropertyNames[attributeName] = propertyName; } return propertyName; } /** * Convert a camel case fooBar property name to a hyphenated foo-bar attribute. * * @private * @param {string} propertyName */ function propertyNameToAttribute(propertyName) { let attribute = propertyNamesToAttributes[propertyName]; if (!attribute) { // Convert and memoize. const uppercaseRegEx = /([A-Z])/g; attribute = propertyName.replace(uppercaseRegEx, "-$1").toLowerCase(); propertyNamesToAttributes[propertyName] = attribute; } return attribute; } /** @type {any} */ const stateKey = Symbol("state"); /** @type {any} */ const raiseChangeEventsInNextRenderKey = Symbol( "raiseChangeEventsInNextRender" ); // Tracks total set of changes made to elements since their last render. /** @type {any} */ const changedSinceLastRenderKey = Symbol("changedSinceLastRender"); /** * Manages component state and renders changes in state * * This is modeled after React/Preact's state management, and is adapted for * use with web components. Applying this mixin to a component will give it * FRP behavior comparable to React's. * * This model is very basic. It's key aspects are: * * an immutable `state` property updated via `setState` calls. * * a `render` method that will be invoked asynchronously when state changes. * * @module ReactiveMixin * @param {Constructor<CustomElement>} Base */ function ReactiveMixin(Base) { class Reactive extends Base { constructor() { super(); // Components can inspect `firstRender` during rendering to do special // work the first time (like wire up event handlers). Until the first // render actually happens, we set that flag to be undefined so we have a // way of distinguishing between a component that has never rendered and // one that is being rendered for the nth time. this[firstRender$1] = undefined; // We want to support the standard HTML pattern of only raising events in // response to direct user interactions. For a detailed discussion of this // point, see the Gold Standard checklist item for [Propery Change // Events](https://github.com/webcomponents/gold-standard/wiki/Property%20Change%20Events). // // To support this pattern, we define a flag indicating whether change // events should be raised. By default, we want the flag to be false. In // UI event handlers, a component can temporarily set the flag to true. If // a setState call is made while the flag is true, then that fact will be // remembered and passed the subsequent render/rendered methods. That will // let the methods know whether they should raise property change events. this[raiseChangeEvents$1] = false; // Maintain a change log of all fields which have changed since the // component was last rendered. this[changedSinceLastRenderKey] = null; // Set the initial state from the default state defined by the component // and its mixins/base classes. this[setState$1](this[defaultState$1]); } // When the component is attached to the document (or upgraded), we will // generally render the component for the first time. That operation will // include rendering of the default state and any state changes that // happened between constructor time and this connectedCallback. connectedCallback() { if (super.connectedCallback) { super.connectedCallback(); } // Render the component. // // If the component was forced to render before this point, and the state // hasn't changed, this call will be a no-op. this[renderChanges$1](); } /** * The default state for the component. This can be extended by mixins and * classes to provide additional default state. * * @type {PlainObject} */ // @ts-ignore get [defaultState$1]() { // Defer to base implementation if defined. return super[defaultState$1] || {}; } /** * Render the indicated changes in state to the DOM. * * The default implementation of this method does nothing. Override this * method in your component to update your component's host element and * any shadow elements to reflect the component's new state. See the * [rendering example](ReactiveMixin#rendering). * * Be sure to call `super` in your method implementation so that your * component's base classes and mixins have a chance to perform their own * render work. * * @param {ChangedFlags} changed - dictionary of flags indicating which state * members have changed since the last render */ [render$3](changed) { if (super[render$3]) { super[render$3](changed); } } /** * Render any pending component changes to the DOM. * * This method does nothing if the state has not changed since the last * render call. * * ReactiveMixin will invoke this method following a `setState` call; * you should not need to invoke this method yourself. * * This method invokes the internal `render` method, then invokes the * `rendered` method. */ [renderChanges$1]() { if (this[firstRender$1] === undefined) { // First render. this[firstRender$1] = true; } // Get the log of which fields have changed since the last render. const changed = this[changedSinceLastRenderKey]; // We only render if this is the first render, or state has changed since // the last render. if (this[firstRender$1] || changed) { // If at least one of the[setState] calls was made in response // to user interaction or some other component-internal event, set the // raiseChangeEvents flag so that render/rendered methods know whether // to raise property change events. See the comments in the component // constructor where we initialize this flag for details. const saveRaiseChangeEvents = this[raiseChangeEvents$1]; this[raiseChangeEvents$1] = this[raiseChangeEventsInNextRenderKey]; // We set a flag to indicate that rendering is happening. The component // may use this to avoid triggering other updates during the render. this[rendering$1] = true; // Invoke any internal render implementations. this[render$3](changed); this[rendering$1] = false; // Since we've now rendered all changes, clear the change log. If other // async render calls are queued up behind this call, they'll see an // empty change log, and so skip unnecessary render work. this[changedSinceLastRenderKey] = null; // Let the component know it was rendered. this[rendered$1](changed); // We've now rendered for the first time. this[firstRender$1] = false; // Restore state of event flags. this[raiseChangeEvents$1] = saveRaiseChangeEvents; this[raiseChangeEventsInNextRenderKey] = saveRaiseChangeEvents; } } /** * Perform any work that must happen after state changes have been rendered * to the DOM. * * The default implementation of this method does nothing. Override this * method in your component to perform work that requires the component to * be fully rendered, such as setting focus on a shadow element or * inspecting the computed style of an element. If such work should result * in a change in component state, you can safely call `setState` during the * `rendered` method. * * Be sure to call `super` in your method implementation so that your * component's base classes and mixins have a chance to perform their own * post-render work. * * @param {ChangedFlags} changed */ [rendered$1](changed) { if (super[rendered$1]) { super[rendered$1](changed); } } /** * Update the component's state by merging the specified changes on * top of the existing state. If the component is connected to the document, * and the new state has changed, this returns a promise to asynchronously * render the component. Otherwise, this returns a resolved promise. * * @param {PlainObject} changes - the changes to apply to the element's state * @returns {Promise} - resolves when the new state has been rendered */ async [setState$1](changes) { // There's no good reason to have a render method update state. if (this[rendering$1]) { /* eslint-disable no-console */ console.warn( `${this.constructor.name} called [setState] during rendering, which you should avoid.\nSee https://elix.org/documentation/ReactiveMixin.` ); } // Apply the changes to a copy of the component's current state to produce // a new, updated state and a dictionary of flags indicating which fields // actually changed. const { state, changed } = copyStateWithChanges(this, changes); // We only need to apply the changes to the component state if: a) the // current state is undefined (this is the first time setState has been // called), or b) the supplied changes parameter actually contains // substantive changes. if (this[stateKey] && Object.keys(changed).length === 0) { // No need to update state. return; } // Freeze the new state so it's immutable. This prevents accidental // attempts to set state without going through setState. Object.freeze(state); // Set this as the component's new state. this[stateKey] = state; // If setState was called with the raiseChangeEvents flag set, record that // fact for use in rendering. See the comments in the component // constructor for details. if (this[raiseChangeEvents$1]) { this[raiseChangeEventsInNextRenderKey] = true; } // Look to see whether the component is already set up to render. const willRender = this[firstRender$1] === undefined || this[changedSinceLastRenderKey] !== null; // Add this round of changed fields to the complete log of fields that // have changed since the component was last rendered. this[changedSinceLastRenderKey] = Object.assign( this[changedSinceLastRenderKey] || {}, changed ); // We only need to queue a render if we're in the document and a render // operation hasn't already been queued for this component. If we're not // in the document yet, when the component is eventually added to the // document, the connectedCallback will ensure we render at that point. const needsRender = this.isConnected && !willRender; if (needsRender) { // Yield with promise timing. This lets any *synchronous* setState calls // that happen after this current setState call complete first. Their // effects on the state will be batched up, and accumulate in the change // log stored under this[changedSinceLastRenderKey]. await Promise.resolve(); // Now that the above promise has resolved, render the component. By the // time this line is reached, the complete log of batched changes can be // applied in a single render call. this[renderChanges$1](); } } /** * The component's current state. * * The returned state object is immutable. To update it, invoke * `internal.setState`. * * It's extremely useful to be able to inspect component state while * debugging. If you append `?elixdebug=true` to a page's URL, then * ReactiveMixin will conditionally expose a public `state` property that * returns the component's state. You can then access the state in your * browser's debug console. * * @type {PlainObject} */ get [state$1]() { return this[stateKey]; } /** * Ask the component whether a state with a set of recently-changed fields * implies that additional second-order changes should be applied to that * state to make it consistent. * * This method is invoked during a call to `internal.setState` to give all * of a component's mixins and classes a chance to respond to changes in * state. If one mixin/class updates state that it controls, another * mixin/class may want to respond by updating some other state member that * *it* controls. * * This method should return a dictionary of changes that should be applied * to the state. If the dictionary object is not empty, the * `internal.setState` method will apply the changes to the state, and * invoke this `stateEffects` method again to determine whether there are * any third-order effects that should be applied. This process repeats * until all mixins/classes report that they have no additional changes to * make. * * See an example of how `ReactiveMixin` invokes the `stateEffects` to * [ensure state consistency](ReactiveMixin#ensuring-state-consistency). * * @param {PlainObject} state - a proposal for a new state * @param {ChangedFlags} changed - the set of fields changed in this * latest proposal for the new state * @returns {PlainObject} */ [stateEffects$1](state, changed) { return super[stateEffects$1] ? super[stateEffects$1](state, changed) : {}; } } // Expose state when debugging; see note for `[state]` getter. const elixdebug = new URLSearchParams(location.search).get("elixdebug"); if (elixdebug === "true") { Object.defineProperty(Reactive.prototype, "state", { get() { return this[state$1]; }, }); } return Reactive; } /** * Create a copy of the component's state with the indicated changes applied. * Ask the component whether the new state implies any second-order effects. If * so, apply those and loop again until the state has stabilized. Return the new * state and a dictionary of flags indicating which fields were actually * changed. * * @private * @param {Element} element * @param {PlainObject} changes */ function copyStateWithChanges(element, changes) { // Start with a copy of the current state. /** @type {PlainObject} */ const state = Object.assign({}, element[stateKey]); /** @type {ChangedFlags} */ const changed = {}; // Take the supplied changes as the first round of effects. let effects = changes; // Loop until there are no effects to apply. /* eslint-disable no-constant-condition */ while (true) { // See whether the effects actually changed anything in state. const changedByEffects = fieldsChanged(state, effects); if (Object.keys(changedByEffects).length === 0) { // No more effects to apply; we're done. break; } // Apply the effects. Object.assign(state, effects); Object.assign(changed, changedByEffects); // Ask the component if there are any second- (or third-, etc.) order // effects that should be applied. effects = element[stateEffects$1](state, changedByEffects); } return { state, changed }; } /** * Return true if the two values are equal. * * @private * @param {any} value1 * @param {any} value2 * @returns {boolean} */ function equal(value1, value2) { if (value1 instanceof Date && value2 instanceof Date) { return value1.getTime() === value2.getTime(); } return value1 === value2; } /** * Return a dictionary of flags indicating which of the indicated changes to the * state are actually substantive changes. * * @private * @param {PlainObject} state * @param {PlainObject} changes */ function fieldsChanged(state, changes) { /** @type {ChangedFlags} */ const changed = {}; for (const field in changes) { if (!equal(changes[field], state[field])) { changed[field] = true; } } return changed; } // A cache of processed templates, indexed by element class. const classTemplateMap = new Map(); // A Proxy that maps shadow element IDs to shadow