@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
792 lines (675 loc) • 16.7 kB
JavaScript
/**
* 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);