@lexml/lexml-eta
Version:
Webcomponent lexml-eta following open-wc recommendations
1,387 lines (1,292 loc) • 1.3 MB
JavaScript
/**
* 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