Select Git revision
tables15.0.0.go
customelement.js 21.18 KiB
'use strict';
import {PROPERTY_KEY_INTERNALDATA} from "../constants.js";
import {extend} from "../data/extend.js";
import {Pathfinder} from "../data/pathfinder.js";
/**
* @author schukai GmbH
*/
import {assignToNamespace, Monster} from '../namespace.js';
import {parseDataURL} from "../types/dataurl.js";
import {getGlobalObject} from "../types/global.js";
import {isArray, isObject, isString} from "../types/is.js";
import {Observer} from "../types/observer.js";
import {ProxyObserver} from "../types/proxyobserver.js";
import {validateFunction, validateInstance, validateObject} from "../types/validate.js";
import {clone} from "../util/clone.js";
import {addToObjectLink, getLinkedObjects, hasObjectLink} from "./attributes.js";
import {ATTRIBUTE_OPTIONS, OBJECTLINK_KEY_UPDATER} from "./constants.js";
import {findDocumentTemplate, Template} from "./template.js";
import {Updater} from "./updater.js";
/**
* @private
* @type {symbol}
*/
const internalDataSymbol = Symbol.for(PROPERTY_KEY_INTERNALDATA);
/**
* @private
* @type {symbol}
*/
const objectLinkSymbol = Symbol.for(OBJECTLINK_KEY_UPDATER);
/**
* @memberOf Monster.DOM
* @type {symbol}
*/
const initMethodSymbol = Symbol('initMethodSymbol');
/**
* @memberOf Monster.DOM
* @type {symbol}
*/
const assembleMethodSymbol = Symbol('assembleMethodSymbol');
/**
* 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 monster namespace `new Monster.DOM.CustomElement()`.
*
* ```
* <script type="module">
* import {CustomElement} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@1.21.1/dist/modules/dom/customelement.js';
* console.log(new Monster.DOM.CustomElement())
* </script>
* ```
*
* Alternatively, you can also integrate this function individually.
*
* ```
* <script type="module">
* import {CustomElement} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@1.21.1/dist/modules/dom/customelement.js';
* console.log(new CustomElement())
* </script>
* ```
*
* ## 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;
* }
* ```
*
* @example
*
* // In the example the the user can use his own template by creating a template in the DOM with the ID `my-custom-element`.
* // You can also specify a theme (for example `mytheme`), then it will search for the ID `my-custom-element-mytheme` and
* // if not available for the ID `my-custom-element`.
*
* class MyCustomElement extends CustomElement {
*
* static getTag() {
* return "my-custom-element"
* }
*
* }
*
* // ↦ <my-custom-element></my-custom-element>
*
* @see https://github.com/WICG/webcomponents
* @see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements
* @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[internalDataSymbol] = new ProxyObserver({'options': extend({}, this.defaults, getOptionsFromAttributes.call(this))});
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];
}
/**
* 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()
* ```
*
* @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
* @return {{shadowMode: string, delegatesFocus: boolean}}
* @since 1.8.0
*/
get defaults() {
return {
disabled: false,
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[internalDataSymbol].attachObserver(observer)
return this;
}
/**
* detach a observer
*
* @param {Observer} observer
* @returns {CustomElement}
*/
detachObserver(observer) {
this[internalDataSymbol].detachObserver(observer)
return this;
}
/**
* @param {Observer} observer
* @returns {ProxyObserver}
*/
containsObserver(observer) {
return this[internalDataSymbol].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[internalDataSymbol].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[internalDataSymbol].getSubject()['options']).setVia(path, value);
return this;
}
/**
* @since 1.15.0
* @param {string|object} options
* @return {CustomElement}
*/
setOptions(options) {
if (isString(options)) {
options = parseOptionsJSON(options)
}
const self = this;
extend(self[internalDataSymbol].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;
if (this.getOption('shadowMode', false) !== false) {
try {
initShadowRoot.call(this);
elements = this.shadowRoot.childNodes;
} catch (e) {
}
try {
initCSSStylesheet.call(this);
} catch (e) {
}
}
if (!(elements instanceof NodeList)) {
initHtmlContent.call(this);
elements = this.childNodes;
}
const updater = new Set;
addToObjectLink(this, objectLinkSymbol, updater);
for (const [, element] of Object.entries(elements)) {
if (!(element instanceof HTMLElement)) continue;
if ((element instanceof HTMLTemplateElement)) continue;
const u = new Updater(element, clone(self[internalDataSymbol].getRealSubject()['options']))
updater.add(u);
u.run().then(() => {
u.enableEventProcessing();
});
}
if (this.hasAttribute('disabled')) {
self.setOption('disabled', true);
}
return this;
}
/**
* 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, objectLinkSymbol)) {
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;
if (attrName === ATTRIBUTE_OPTIONS) {
self.setOptions(newVal);
}
}
/**
*
* @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 {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;
}
/**
* @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;
}
if (self.shadowRoot instanceof ShadowRoot) {
const found = self.shadowRoot.querySelectorAll('button, command, fieldset, keygen, optgroup, option, select, textarea, input, [data-monster-objectlink]');
for (const [, element] of Object.entries(found)) {
if (flag === true) {
element.setAttribute('disabled', '');
} else {
element.removeAttribute('disabled');
}
}
}
lastDisabledValue = flag;
}));
self.attachObserver(new Observer(function () {
// not initialised
if (!hasObjectLink(self, Symbol.for(OBJECTLINK_KEY_UPDATER))) {
return;
}
// inform every element
const updaters = getLinkedObjects(self, Symbol.for(OBJECTLINK_KEY_UPDATER));
for (const list of updaters) {
for (const updater of list) {
let d = clone(self[internalDataSymbol].getRealSubject()['options']);
Object.assign(updater.getSubject(), d);
}
}
}));
const observer = new MutationObserver(function (mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
switch (mutation?.attributeName) {
case 'disabled':
self.setOption('disabled', self.hasAttribute('disabled') ? true : undefined);
break;
case ATTRIBUTE_OPTIONS:
const options = getOptionsFromAttributes.call(self);
if (isObject(options)) {
self.setOptions(options);
}
break;
}
}
}
});
observer.observe(this, {attributes: true, attributeOldValue: true, childList: false, subtree: false});
}
/**
* @private
* @return {object}
* @throws {Error} the options attribute does not contain a valid json definition.
*/
function getOptionsFromAttributes() {
if (this.hasAttribute(ATTRIBUTE_OPTIONS)) {
try {
return parseOptionsJSON(this.getAttribute(ATTRIBUTE_OPTIONS))
} catch (e) {
throw new Error('the options attribute ' + ATTRIBUTE_OPTIONS + ' does not contain a valid json definition (actual: ' + this.getAttribute(ATTRIBUTE_OPTIONS) + ').');
}
}
return {};
}
/**
* @private
* @param data
* @return {Object}
*/
function parseOptionsJSON(data) {
if (isString(data)) {
// 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);
validateObject(obj);
return obj;
} catch (e) {
throw new Error('the options does not contain a valid json definition (actual: ' + data + ').');
}
}
return {};
}
/**
* @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
* @since 1.16.0
*/
function initCSSStylesheet() {
const self = this;
if (!(this.shadowRoot instanceof ShadowRoot)) {
return self;
}
const styleSheet = this.constructor.getCSSStyleSheet();
if (styleSheet instanceof CSSStyleSheet) {
this.shadowRoot.adoptedStyleSheets = [styleSheet];
} else if (isArray(styleSheet)) {
this.shadowRoot.adoptedStyleSheets = styleSheet;
} else if (isString(styleSheet)) {
const style = self.shadowRoot.createElement('<style>')
style.innerText = styleSheet;
this.shadowRoot.adoptedStyleSheets = styleSheet;
}
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
* @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}
* @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);
}
assignToNamespace('Monster.DOM', CustomElement, registerCustomElement);
export {Monster, registerCustomElement, CustomElement, initMethodSymbol, assembleMethodSymbol}