UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

792 lines (675 loc) 16.7 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import "../notify/notify.mjs"; import { HostStyleSheet } from "./stylesheet/host.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { Embed } from "../../i18n/providers/embed.mjs"; import { getDocumentTranslations } from "../../i18n/translations.mjs"; import { windowReady } from "../../dom/ready.mjs"; import { FocusManager } from "../../dom/focusmanager.mjs"; import { ResourceManager } from "../../dom/resourcemanager.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { isIterable } from "../../types/is.mjs"; import "./config-manager.mjs"; import { instanceSymbol } from "../../constants.mjs"; export { Host }; /** * @private * @type {symbol} */ const promisesSymbol = Symbol("promisesSymbol"); /** * @private * @type {symbol} */ const notifyElementSymbol = Symbol("notifyElement"); /** * @private * @type {symbol} */ const overlayElementSymbol = Symbol("overlayElement"); /** * @private * @type {symbol} */ const configManagerElementSymbol = Symbol("configManagerElement"); /** * @private * @type {symbol} */ const focusManagerSymbol = Symbol("focusManager"); /** * @private * @type {symbol} */ const resourceManagerSymbol = Symbol("resourceManager"); /** * @private * @type {symbol} */ const dismissablesSymbol = Symbol("dismissables"); /** * @private * @type {symbol} */ const dismissQueueSymbol = Symbol("dismissQueue"); /** * @private * @type {symbol} */ const dismissVersionSymbol = Symbol("dismissVersion"); /** * @private * @type {symbol} */ const dismissSequenceSymbol = Symbol("dismissSequence"); /** * @private * @type {symbol} */ const dismissScheduledSymbol = Symbol("dismissScheduled"); /** * @private * @type {symbol} */ const dismissHandlerSymbol = Symbol("dismissHandler"); /** * @private * @type {symbol} */ const dismissListenersAttachedSymbol = Symbol("dismissListenersAttached"); /** * The Host component is used to encapsulate the content of a web app. * * @fragments /fragments/components/host/host/ * * @example /examples/components/host/host-simple Host container * * @copyright Volker Schukai * @summary A simple host component * @fires monster-host-connected * @fires monster-host-disconnected */ class Host extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/component-host/Host@@instance"); } /** * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} features Feature definitions */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, }); } /** * @param key * @return {Promise} */ getConfig(key) { if (this[configManagerElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no config manager element"); } return this[configManagerElementSymbol].getConfig(key); } /** * @param {string} key * @returns {*} */ hasConfig(key) { if (this[configManagerElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no config manager element"); } return this[configManagerElementSymbol].hasConfig(key); } /** * * @param {key} key * @returns {*} */ deleteConfig(key) { if (this[configManagerElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no config manager element"); } return this[configManagerElementSymbol].deleteConfig(key); } /** * * @param {string} key * @param {*} value * @return {Promise} */ setConfig(key, value) { if (this[configManagerElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no config manager element"); } return this[configManagerElementSymbol].setConfig(key, value); } /** * @private * @fires Host#monster-host-connected */ connectedCallback() { super.connectedCallback(); /** * show the scroll bar always * @type {string} */ document.documentElement.style.overflowY = "scroll"; const classNames = this.getOption("classes.body"); if (document.body.classList.contains(classNames)) { document.body.classList.remove(classNames); } attachDismissListeners.call(this); fireCustomEvent(this, "monster-host-connected"); } /** * @private * @fires Host#monster-host-disconnected */ disconnectedCallback() { super.disconnectedCallback(); document.documentElement.style.overflowY = ""; const classNames = this.getOption("classes.body"); if (!document.body.classList.contains(classNames)) { document.body.classList.add(classNames); } if (isIterable(this[promisesSymbol]) === false) { this[promisesSymbol] = []; } this[promisesSymbol].push( new Promise((resolve, reject) => { this.addEventListener( "monster-host-connected", () => { resolve(); }, { once: true }, ); }), ); fireCustomEvent(this, "monster-host-disconnected"); detachDismissListeners.call(this); } /** * * @return {Host} */ [assembleMethodSymbol]() { this[promisesSymbol] = []; this[promisesSymbol].push(windowReady); super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); initTranslations.call(this); this[focusManagerSymbol] = new FocusManager(this); this[resourceManagerSymbol] = new ResourceManager(this); try { this[promisesSymbol].push(this[resourceManagerSymbol].available()); } catch (e) { return Promise.reject(e); } if (this.isConnected === false) { this[promisesSymbol].push( new Promise((resolve, reject) => { this.addEventListener( "monster-host-connected", () => { resolve(); }, { once: true }, ); }), ); } } /** * The Promise is resolved when the element is connected to the DOM and all resources are available. * If the element is not connected to the DOM, the Promise is rejected. * * @return {Promise} */ onReady() { if (isIterable(this[promisesSymbol]) === false) { this[promisesSymbol] = []; } return Promise.all(this[promisesSymbol]).then(() => { this[promisesSymbol] = []; return this; }); } /** * @see {@link https://monsterjs.org/en/doc/monster/Monster.DOM.FocusManager.html|Monster.DOM.FocusManager} * @return {*} */ get focusManager() { return this[focusManagerSymbol]; } /** * @see {@link https://monsterjs.org/en/doc/monster/Monster.DOM.ResourceManager.html|Monster.DOM.ResourceManager} * @return {*} */ get resourceManager() { return this[resourceManagerSymbol]; } /** * * @return {Host} * @throws {Error} There is no overlay element defined. */ toggleOverlay() { if (this[overlayElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no overlay element defined."); } this[overlayElementSymbol].toggle(); return this; } /** * @return {Host} * @throws {Error} There is no overlay element defined. */ openOverlay() { if (this[overlayElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no overlay element defined."); } this[overlayElementSymbol].open(); return this; } /** * @return {Host} * @throws {Error} There is no overlay element defined. */ closeOverlay() { if (this[overlayElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no overlay element defined."); } this[overlayElementSymbol].close(); return this; } /** * @return {string} */ static getTag() { return "monster-host"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [HostStyleSheet]; } /** * @return {Locale} */ get locale() { return getLocaleOfDocument(); } /** * * @return {Translations} */ get translations() { return getDocumentTranslations(); } /** * * @param {string|Message} message */ pushNotification(message) { if (this[notifyElementSymbol] instanceof HTMLElement === false) { throw new Error("There is no notify element defined."); } this[notifyElementSymbol].push(message); return this; } /** * Register a dismissable overlay (popper/select/dialog) for outside click handling. * @param {Object} entry * @return {Object|null} */ registerDismissable(entry) { return registerDismissable.call(this, entry); } /** * Unregister a dismissable overlay. * @param {Object|HTMLElement} entry * @return {boolean} */ unregisterDismissable(entry) { return unregisterDismissable.call(this, entry); } } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[overlayElementSymbol] = this.querySelector("monster-overlay"); this[notifyElementSymbol] = this.querySelector("monster-notify"); this[configManagerElementSymbol] = this.querySelector( "monster-config-manager", ); } /** * @private */ function initTranslations() { if (isIterable(this[promisesSymbol]) === false) { this[promisesSymbol] = []; } this[promisesSymbol].push(Embed.assignTranslationsToElement()); } /** * @private */ function initEventHandler() { initDismissManager.call(this); return this; } /** * @private */ function initDismissManager() { this[dismissablesSymbol] = new Map(); this[dismissQueueSymbol] = []; this[dismissVersionSymbol] = 0; this[dismissSequenceSymbol] = 0; this[dismissScheduledSymbol] = false; this[dismissListenersAttachedSymbol] = false; this[dismissHandlerSymbol] = (event) => { if (!this.isConnected) { return; } queueDismissEvent.call(this, event); }; } /** * @private */ function attachDismissListeners() { if (this[dismissListenersAttachedSymbol] === true) { return; } const supportsPointer = typeof globalThis.PointerEvent === "function"; const eventTypes = supportsPointer ? ["pointerdown"] : ["mousedown", "touchstart"]; for (const type of eventTypes) { this.addEventListener(type, this[dismissHandlerSymbol], { capture: true, }); } this[dismissListenersAttachedSymbol] = true; } /** * @private */ function detachDismissListeners() { if (this[dismissListenersAttachedSymbol] !== true) { return; } const supportsPointer = typeof globalThis.PointerEvent === "function"; const eventTypes = supportsPointer ? ["pointerdown"] : ["mousedown", "touchstart"]; for (const type of eventTypes) { this.removeEventListener(type, this[dismissHandlerSymbol], { capture: true, }); } this[dismissListenersAttachedSymbol] = false; } /** * @private * @param {Event} event */ function queueDismissEvent(event) { if (!event) { return; } const path = typeof event.composedPath === "function" ? event.composedPath() : []; this[dismissQueueSymbol].push({ path, target: event.target || null, version: this[dismissVersionSymbol], }); if (this[dismissScheduledSymbol] === true) { return; } this[dismissScheduledSymbol] = true; const schedule = typeof queueMicrotask === "function" ? queueMicrotask : (cb) => Promise.resolve().then(cb); schedule(() => { this[dismissScheduledSymbol] = false; processDismissQueue.call(this); }); } /** * @private */ function processDismissQueue() { const queue = this[dismissQueueSymbol]; this[dismissQueueSymbol] = []; if (!queue.length) { return; } for (const entry of queue) { if (entry.version !== this[dismissVersionSymbol]) { continue; } const top = getTopDismissable.call(this); if (!top) { continue; } if (isEventInsideDismissable(entry, top)) { continue; } if (top.options?.dismissOnOutside === false) { continue; } if (typeof top.close === "function") { top.close(); } break; } } /** * @private * @param {Object} entry * @return {Object|null} */ function registerDismissable(entry) { if (this[dismissablesSymbol] instanceof Map === false) { initDismissManager.call(this); } const normalized = normalizeDismissEntry(entry); if (!normalized) { return null; } const existing = this[dismissablesSymbol].get(normalized.element); const record = existing || { element: normalized.element }; record.owner = normalized.owner || record.owner || null; record.close = normalized.close || record.close || null; record.priority = Number.isFinite(normalized.priority) ? normalized.priority : Number.isFinite(record.priority) ? record.priority : 0; record.options = Object.assign( {}, record.options || {}, normalized.options || {}, ); record.sequence = ++this[dismissSequenceSymbol]; this[dismissablesSymbol].set(record.element, record); this[dismissVersionSymbol] += 1; return record; } /** * @private * @param {Object|HTMLElement} entry * @return {boolean} */ function unregisterDismissable(entry) { if (this[dismissablesSymbol] instanceof Map === false) { return false; } const record = resolveDismissRecord.call(this, entry); if (!record) { return false; } this[dismissablesSymbol].delete(record.element); this[dismissVersionSymbol] += 1; return true; } /** * @private * @param {Object} entry * @return {Object|null} */ function normalizeDismissEntry(entry) { if (!entry) { return null; } const element = entry.element || entry.owner || entry; if (!(element instanceof HTMLElement)) { return null; } return { element, owner: entry.owner instanceof HTMLElement ? entry.owner : null, close: typeof entry.close === "function" ? entry.close : null, priority: entry.priority, options: entry.options || {}, }; } /** * @private * @param {Object|HTMLElement} entry * @return {Object|null} */ function resolveDismissRecord(entry) { if (!entry) { return null; } if (entry.element && this[dismissablesSymbol].has(entry.element)) { return this[dismissablesSymbol].get(entry.element); } if (entry instanceof HTMLElement && this[dismissablesSymbol].has(entry)) { return this[dismissablesSymbol].get(entry); } for (const record of this[dismissablesSymbol].values()) { if (record === entry) { return record; } if (record.owner && record.owner === entry) { return record; } } return null; } /** * @private * @return {Object|null} */ function getTopDismissable() { if (this[dismissablesSymbol] instanceof Map === false) { return null; } let top = null; for (const record of this[dismissablesSymbol].values()) { if (!record) { continue; } if (!top) { top = record; continue; } if ((record.priority || 0) > (top.priority || 0)) { top = record; continue; } if ( (record.priority || 0) === (top.priority || 0) && (record.sequence || 0) > (top.sequence || 0) ) { top = record; } } return top; } /** * @private * @param {Object} eventEntry * @param {Object} record * @return {boolean} */ function isEventInsideDismissable(eventEntry, record) { if (!record) { return false; } const path = eventEntry.path || []; const target = eventEntry.target || null; if (path.includes(record.element)) { return true; } if (record.owner && path.includes(record.owner)) { return true; } if (target && record.element?.contains?.(target)) { return true; } if (target && record.owner?.contains?.(target)) { return true; } return false; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="host-container"> <div data-monster-replace="path:host-container.content" data-monster-attributes="part path:host-container.name, data-monster-role path:host-container.name"></div> </template> <div data-monster-role="host-container"> <slot></slot> </div>`; } registerCustomElement(Host);