/** * Copyright schukai GmbH and contributors 2022. All Rights Reserved. * Node module: @schukai/monster * This file is licensed under the AGPLv3 License. * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html */ import {internalSymbol} from "../constants.mjs"; import {extend} from "../data/extend.mjs"; import {Pathfinder} from "../data/pathfinder.mjs"; import {parseDataURL} from "../types/dataurl.mjs"; import {getGlobalObject} from "../types/global.mjs"; import {isArray, isFunction, isObject, isString} from "../types/is.mjs"; import {Observer} from "../types/observer.mjs"; import {ProxyObserver} from "../types/proxyobserver.mjs"; import {validateFunction, validateInstance, validateObject, validateString} from "../types/validate.mjs"; import {clone} from "../util/clone.mjs"; import {addAttributeToken, addToObjectLink, getLinkedObjects, hasObjectLink} from "./attributes.mjs"; import { ATTRIBUTE_DISABLED, ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_OPTIONS, ATTRIBUTE_OPTIONS_SELECTOR, objectUpdaterLinkSymbol } from "./constants.mjs"; import {findDocumentTemplate, Template} from "./template.mjs"; import {Updater} from "./updater.mjs"; export {CustomElement, initMethodSymbol, assembleMethodSymbol, attributeObserverSymbol, registerCustomElement, assignUpdaterToElement} /** * @memberOf Monster.DOM * @type {symbol} */ const initMethodSymbol = Symbol('initMethodSymbol'); /** * @memberOf Monster.DOM * @type {symbol} */ const assembleMethodSymbol = Symbol('assembleMethodSymbol'); /** * this symbol holds the attribute observer callbacks. The key is the attribute name. * @memberOf Monster.DOM * @type {symbol} */ const attributeObserverSymbol = Symbol('attributeObserver'); /** * HTMLElement * @external HTMLElement * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement * * @startuml customelement-sequencediagram.png * skinparam monochrome true * skinparam shadowing false * * autonumber * * Script -> DOM: element = document.createElement('my-element') * DOM -> CustomElement: constructor() * CustomElement -> CustomElement: [initMethodSymbol]() * * CustomElement --> DOM: Element * DOM --> Script : element * * * Script -> DOM: document.querySelector('body').append(element) * * DOM -> CustomElement : connectedCallback() * * note right CustomElement: is only called at\nthe first connection * CustomElement -> CustomElement : [assembleMethodSymbol]() * * ... ... * * autonumber * * Script -> DOM: document.querySelector('monster-confirm-button').parentNode.removeChild(element) * DOM -> CustomElement: disconnectedCallback() * * * @enduml * * @startuml customelement-class.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * @enduml */ /** * To define a new HTML element we need the power of CustomElement * * IMPORTANT: after defining a `CustomElement`, the `registerCustomElement` method must be called * with the new class name. only then will the tag defined via the `getTag` method be made known to the DOM. * * <img src="./images/customelement-class.png"> * * You can create the object via the function `document.createElement()`. * * * ## Interaction * * <img src="./images/customelement-sequencediagram.png"> * * ## Styling * * For optimal display of custom-elements the pseudo-class :defined can be used. * * To prevent the custom elements from being displayed and flickering until the control is registered, it is recommended to create a css directive. * * In the simplest case, you can simply hide the control. * * ``` * <style> * * my-custom-element:not(:defined) { * display: none; * } * * my-custom-element:defined { * display: flex; * } * * </style> * ``` * * Alternatively you can also display a loader * * ``` * my-custom-element:not(:defined) { * display: flex; * box-shadow: 0 4px 10px 0 rgba(33, 33, 33, 0.15); * border-radius: 4px; * height: 200px; * position: relative; * overflow: hidden; * } * * my-custom-element:not(:defined)::before { * content: ''; * display: block; * position: absolute; * left: -150px; * top: 0; * height: 100%; * width: 150px; * background: linear-gradient(to right, transparent 0%, #E8E8E8 50%, transparent 100%); * animation: load 1s cubic-bezier(0.4, 0.0, 0.2, 1) infinite; * } * * @keyframes load { * from { * left: -150px; * } * to { * left: 100%; * } * } * * my-custom-element:defined { * display: flex; * } * ``` * * @externalExample ../../example/dom/theme.mjs * @see https://github.com/WICG/webcomponents * @see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements * @license AGPLv3 * @since 1.7.0 * @copyright schukai GmbH * @memberOf Monster.DOM * @extends external:HTMLElement * @summary A base class for HTML5 customcontrols */ class CustomElement extends HTMLElement { /** * A new object is created. First the `initOptions` method is called. Here the * options can be defined in derived classes. Subsequently, the shadowRoot is initialized. * * @throws {Error} the options attribute does not contain a valid json definition. * @since 1.7.0 */ constructor() { super(); this[internalSymbol] = new ProxyObserver({'options': extend({}, this.defaults)}); this[attributeObserverSymbol] = {}; initOptionObserver.call(this); this[initMethodSymbol](); } /** * This method determines which attributes are to be monitored by `attributeChangedCallback()`. * * @return {string[]} * @since 1.15.0 */ static get observedAttributes() { return [ATTRIBUTE_OPTIONS, ATTRIBUTE_DISABLED]; } /** * Derived classes can override and extend this method as follows. * * ``` * get defaults() { * return Object.assign({}, super.defaults, { * myValue:true * }); * } * ``` * * To set the options via the html tag the attribute data-monster-options must be set. * As value a JSON object with the desired values must be defined. * * Since 1.18.0 the JSON can be specified as a DataURI. * * ``` * new Monster.Types.DataUrl(btoa(JSON.stringify({ * shadowMode: 'open', * delegatesFocus: true, * templates: { * main: undefined * } * })),'application/json',true).toString() * ``` * * The attribute data-monster-options-selector can be used to access a script tag that contains additional configuration. * * As value a selector must be specified, which belongs to a script tag and contains the configuration as json. * * ``` * <script id="id-for-this-config" type="application/json"> * { * "config-key": "config-value" * } * </script> * ``` * * The individual configuration values can be found in the table. * * @property {boolean} disabled=false Object The Boolean disabled attribute, when present, makes the element not mutable, focusable, or even submitted with the form. * @property {string} shadowMode=open `open` Elements of the shadow root are accessible from JavaScript outside the root, for example using. `close` Denies access to the node(s) of a closed shadow root from JavaScript outside it * @property {Boolean} delegatesFocus=true A boolean that, when set to true, specifies behavior that mitigates custom element issues around focusability. When a non-focusable part of the shadow DOM is clicked, the first focusable part is given focus, and the shadow host is given any available :focus styling. * @property {Object} templates Templates * @property {string} templates.main=undefined Main template * * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow * @since 1.8.0 */ get defaults() { return { ATTRIBUTE_DISABLED: this.getAttribute(ATTRIBUTE_DISABLED), shadowMode: 'open', delegatesFocus: true, templates: { main: undefined } }; } /** * There is no check on the name by this class. the developer is responsible for assigning an appropriate tag. * if the name is not valid, registerCustomElement() will issue an error * * @link https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name * @return {string} * @throws {Error} the method getTag must be overwritten by the derived class. * @since 1.7.0 */ static getTag() { throw new Error("the method getTag must be overwritten by the derived class."); } /** * At this point a `CSSStyleSheet` object can be returned. If the environment does not * support a constructor, then an object can also be built using the following detour. * * If `undefined` is returned then the shadowRoot does not get a stylesheet. * * ``` * const doc = document.implementation.createHTMLDocument('title'); * * let style = doc.createElement("style"); * style.innerHTML="p{color:red;}"; * * // WebKit Hack * style.appendChild(document.createTextNode("")); * // Add the <style> element to the page * doc.head.appendChild(style); * return doc.styleSheets[0]; * ; * ``` * * @return {CSSStyleSheet|CSSStyleSheet[]|string|undefined} */ static getCSSStyleSheet() { return undefined; } /** * attach a new observer * * @param {Observer} observer * @returns {CustomElement} */ attachObserver(observer) { this[internalSymbol].attachObserver(observer) return this; } /** * detach a observer * * @param {Observer} observer * @returns {CustomElement} */ detachObserver(observer) { this[internalSymbol].detachObserver(observer) return this; } /** * @param {Observer} observer * @returns {ProxyObserver} */ containsObserver(observer) { return this[internalSymbol].containsObserver(observer) } /** * nested options can be specified by path `a.b.c` * * @param {string} path * @param {*} defaultValue * @return {*} * @since 1.10.0 */ getOption(path, defaultValue) { let value; try { value = new Pathfinder(this[internalSymbol].getRealSubject()['options']).getVia(path); } catch (e) { } if (value === undefined) return defaultValue; return value; } /** * Set option and inform elements * * @param {string} path * @param {*} value * @return {CustomElement} * @since 1.14.0 */ setOption(path, value) { new Pathfinder(this[internalSymbol].getSubject()['options']).setVia(path, value); return this; } /** * @since 1.15.0 * @param {string|object} options * @return {CustomElement} */ setOptions(options) { if (isString(options)) { options = parseOptionsJSON.call(this, options) } const self = this; extend(self[internalSymbol].getSubject()['options'], self.defaults, options); return self; } /** * Is called once via the constructor * * @return {CustomElement} * @since 1.8.0 */ [initMethodSymbol]() { return this; } /** * Is called once when the object is included in the DOM for the first time. * * @return {CustomElement} * @since 1.8.0 */ [assembleMethodSymbol]() { const self = this; let elements, nodeList; const AttributeOptions = getOptionsFromAttributes.call(self); if (isObject(AttributeOptions) && Object.keys(AttributeOptions).length > 0) { self.setOptions(AttributeOptions); } const ScriptOptions = getOptionsFromScriptTag.call(self); if (isObject(ScriptOptions) && Object.keys(ScriptOptions).length > 0) { self.setOptions(ScriptOptions); } if (self.getOption('shadowMode', false) !== false) { try { initShadowRoot.call(self); elements = self.shadowRoot.childNodes; } catch (e) { } try { initCSSStylesheet.call(this); } catch (e) { addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, e.toString()); } } if (!(elements instanceof NodeList)) { if (!(elements instanceof NodeList)) { initHtmlContent.call(this); elements = this.childNodes; } } try { nodeList = new Set([ ...elements, ...getSlottedElements.call(self) ]) } catch (e) { nodeList = elements } assignUpdaterToElement.call(self, nodeList, clone(self[internalSymbol].getRealSubject()['options'])); return self; } /** * Called every time the element is inserted into the DOM. Useful for running setup code, such as * fetching resources or rendering. Generally, you should try to delay work until this time. * * @return {void} * @since 1.7.0 */ connectedCallback() { let self = this; if (!hasObjectLink(self, objectUpdaterLinkSymbol)) { self[assembleMethodSymbol]() } } /** * Called every time the element is removed from the DOM. Useful for running clean up code. * * @return {void} * @since 1.7.0 */ disconnectedCallback() { } /** * The custom element has been moved into a new document (e.g. someone called document.adoptNode(el)). * * @return {void} * @since 1.7.0 */ adoptedCallback() { } /** * Called when an observed attribute has been added, removed, updated, or replaced. Also called for initial * values when an element is created by the parser, or upgraded. Note: only attributes listed in the observedAttributes * property will receive this callback. * * @param {string} attrName * @param {string} oldVal * @param {string} newVal * @return {void} * @since 1.15.0 */ attributeChangedCallback(attrName, oldVal, newVal) { const self = this; const callback = self[attributeObserverSymbol]?.[attrName]; if (isFunction(callback)) { callback.call(self, newVal, oldVal); } } /** * * @param {Node} node * @return {boolean} * @throws {TypeError} value is not an instance of * @since 1.19.0 */ hasNode(node) { const self = this; if (containChildNode.call(self, validateInstance(node, Node))) { return true; } if (!(self.shadowRoot instanceof ShadowRoot)) { return false; } return containChildNode.call(self.shadowRoot, node); } } /** * @private * @param {String|undefined} query * @param {String|undefined|null} name name of the slot (if the parameter is undefined, all slots are searched, if the parameter has the value null, all slots without a name are searched. if a string is specified, the slots with this name are searched.) * @return {*} * @this CustomElement * @license AGPLv3 * @since 1.23.0 * @throws {Error} query must be a string */ function getSlottedElements(query, name) { const self = this; const result = new Set; if (!(self.shadowRoot instanceof ShadowRoot)) { return result; } let selector = 'slot'; if (name !== undefined) { if (name === null) { selector += ':not([name])'; } else { selector += '[name=' + validateString(name) + ']'; } } const slots = self.shadowRoot.querySelectorAll(selector); for (const [, slot] of Object.entries(slots)) { slot.assignedElements().forEach(function (node) { if (!(node instanceof HTMLElement)) return; if (isString(query)) { node.querySelectorAll(query).forEach(function (n) { result.add(n); }); if (node.matches(query)) { result.add(node); } } else if (query !== undefined) { throw new Error('query must be a string') } else { result.add(node); } }) } return result; } /** * @this CustomElement * @private * @param {Node} node * @return {boolean} */ function containChildNode(node) { const self = this; if (self.contains(node)) { return true; } for (const [, e] of Object.entries(self.childNodes)) { if (e.contains(node)) { return true; } containChildNode.call(e, node); } return false; } /** * @license AGPLv3 * @since 1.15.0 * @private * @this CustomElement */ function initOptionObserver() { const self = this; let lastDisabledValue = undefined; self.attachObserver(new Observer(function () { const flag = self.getOption('disabled'); if (flag === lastDisabledValue) { return; } lastDisabledValue = flag; if (!(self.shadowRoot instanceof ShadowRoot)) { return; } const query = 'button, command, fieldset, keygen, optgroup, option, select, textarea, input, [data-monster-objectlink]'; const elements = self.shadowRoot.querySelectorAll(query); let nodeList; try { nodeList = new Set([ ...elements, ...getSlottedElements.call(self, query) ]) } catch (e) { nodeList = elements } for (const element of [...nodeList]) { if (flag === true) { element.setAttribute(ATTRIBUTE_DISABLED, ''); } else { element.removeAttribute(ATTRIBUTE_DISABLED); } } })); self.attachObserver(new Observer(function () { // not initialised if (!hasObjectLink(self, objectUpdaterLinkSymbol)) { return; } // inform every element const updaters = getLinkedObjects(self, objectUpdaterLinkSymbol); for (const list of updaters) { for (const updater of list) { let d = clone(self[internalSymbol].getRealSubject()['options']); Object.assign(updater.getSubject(), d); } } })); // disabled self[attributeObserverSymbol][ATTRIBUTE_DISABLED] = () => { if (self.hasAttribute(ATTRIBUTE_DISABLED)) { self.setOption(ATTRIBUTE_DISABLED, true); } else { self.setOption(ATTRIBUTE_DISABLED, undefined); } } // data-monster-options self[attributeObserverSymbol][ATTRIBUTE_OPTIONS] = () => { const options = getOptionsFromAttributes.call(self); if (isObject(options) && Object.keys(options).length > 0) { self.setOptions(options); } } // data-monster-options-selector self[attributeObserverSymbol][ATTRIBUTE_OPTIONS_SELECTOR] = () => { const options = getOptionsFromScriptTag.call(self); if (isObject(options) && Object.keys(options).length > 0) { self.setOptions(options); } } } /** * @private * @return {object} * @throws {TypeError} value is not a object */ function getOptionsFromScriptTag() { const self = this; if (!self.hasAttribute(ATTRIBUTE_OPTIONS_SELECTOR)) { return {}; } const node = document.querySelector(self.getAttribute(ATTRIBUTE_OPTIONS_SELECTOR)); if (!(node instanceof HTMLScriptElement)) { addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'the selector ' + ATTRIBUTE_OPTIONS_SELECTOR + ' for options was specified (' + self.getAttribute(ATTRIBUTE_OPTIONS_SELECTOR) + ') but not found.'); return {}; } let obj = {}; try { obj = parseOptionsJSON.call(this, node.textContent.trim()) } catch (e) { addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'when analyzing the configuration from the script tag there was an error. ' + e); } return obj; } /** * @private * @return {object} */ function getOptionsFromAttributes() { const self = this; if (this.hasAttribute(ATTRIBUTE_OPTIONS)) { try { return parseOptionsJSON.call(self, this.getAttribute(ATTRIBUTE_OPTIONS)) } catch (e) { addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, 'the options attribute ' + ATTRIBUTE_OPTIONS + ' does not contain a valid json definition (actual: ' + this.getAttribute(ATTRIBUTE_OPTIONS) + ').' + e); } } return {}; } /** * @private * @param data * @return {Object} */ function parseOptionsJSON(data) { const self = this, obj = {}; if (!isString(data)) { return obj; } // the configuration can be specified as a data url. try { let dataUrl = parseDataURL(data); data = dataUrl.content; } catch (e) { } try { let obj = JSON.parse(data); return validateObject(obj); } catch (e) { throw e; } return obj; } /** * @private * @return {initHtmlContent} */ function initHtmlContent() { try { let template = findDocumentTemplate(this.constructor.getTag()); this.appendChild(template.createDocumentFragment()); } catch (e) { let html = this.getOption('templates.main', ''); if (isString(html) && html.length > 0) { this.innerHTML = html; } } return this; } /** * @private * @return {CustomElement} * @memberOf Monster.DOM * @this CustomElement * @license AGPLv3 * @since 1.16.0 * @throws {TypeError} value is not an instance of */ function initCSSStylesheet() { const self = this; if (!(this.shadowRoot instanceof ShadowRoot)) { return self; } const styleSheet = this.constructor.getCSSStyleSheet(); if (styleSheet instanceof CSSStyleSheet) { if (styleSheet.cssRules.length > 0) { this.shadowRoot.adoptedStyleSheets = [styleSheet]; } } else if (isArray(styleSheet)) { const assign = []; for (let s of styleSheet) { if (isString(s)) { let trimedStyleSheet = s.trim() if (trimedStyleSheet !== '') { const style = document.createElement('style') style.innerHTML = trimedStyleSheet; self.shadowRoot.prepend(style); } continue; } validateInstance(s, CSSStyleSheet); if (s.cssRules.length > 0) { assign.push(s); } } if (assign.length > 0) { this.shadowRoot.adoptedStyleSheets = assign; } } else if (isString(styleSheet)) { let trimedStyleSheet = styleSheet.trim() if (trimedStyleSheet !== '') { const style = document.createElement('style') style.innerHTML = styleSheet; self.shadowRoot.prepend(style); } } return self; } /** * @private * @return {CustomElement} * @throws {Error} html is not set. * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow * @memberOf Monster.DOM * @license AGPLv3 * @since 1.8.0 */ function initShadowRoot() { let template, html; try { template = findDocumentTemplate(this.constructor.getTag()); } catch (e) { html = this.getOption('templates.main', ''); if (!isString(html) || html === undefined || html === "") { throw new Error("html is not set."); } } this.attachShadow({ mode: this.getOption('shadowMode', 'open'), delegatesFocus: this.getOption('delegatesFocus', true) }); if (template instanceof Template) { this.shadowRoot.appendChild(template.createDocumentFragment()); return this; } this.shadowRoot.innerHTML = html; return this; } /** * This method registers a new element. The string returned by `CustomElement.getTag()` is used as the tag. * * @param {CustomElement} element * @return {void} * @license AGPLv3 * @since 1.7.0 * @copyright schukai GmbH * @memberOf Monster.DOM * @throws {DOMException} Failed to execute 'define' on 'CustomElementRegistry': is not a valid custom element name */ function registerCustomElement(element) { validateFunction(element); getGlobalObject('customElements').define(element.getTag(), element); } /** * * @param element * @param object * @return {Promise[]} * @license AGPLv3 * @since 1.23.0 * @memberOf Monster.DOM */ function assignUpdaterToElement(elements, object) { const updaters = new Set; if (elements instanceof NodeList) { elements = new Set([ ...elements ]) } let result = []; elements.forEach((element) => { if (!(element instanceof HTMLElement)) return; if ((element instanceof HTMLTemplateElement)) return; const u = new Updater(element, object) updaters.add(u); result.push(u.run().then(() => { return u.enableEventProcessing(); })); }); if (updaters.size > 0) { addToObjectLink(this, objectUpdaterLinkSymbol, updaters); } return result; }