UNPKG

@schukai/monster

Version:

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

573 lines (500 loc) 13.2 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, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { CollapseStyleSheet } from "./stylesheet/collapse.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getDocument } from "../../dom/util.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs"; import { Host } from "../host/host.mjs"; import { generateUniqueConfigKey } from "../host/util.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { instanceSymbol } from "../../constants.mjs"; import { Queue } from "../../types/queue.mjs"; export { Collapse, nameSymbol }; /** * @private * @type {symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** * @private * @type {symbol} */ const detailsElementSymbol = Symbol("detailsElement"); /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * local symbol * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const detailsSlotElementSymbol = Symbol("detailsSlotElement"); /** * @private * @type {symbol} */ const detailsContainerElementSymbol = Symbol("detailsContainerElement"); /** * @private * @type {symbol} */ const detailsDecoElementSymbol = Symbol("detailsDecoElement"); /** * @private * @type {symbol} */ const eventQueueSymbol = Symbol("eventQueue"); /** * @private * @type {symbol} */ const isTransitioningSymbol = Symbol("isTransitioning"); /** * @private * @type {symbol} */ const nameSymbol = Symbol("name"); /** * A Collapse component * * @fragments /fragments/components/layout/collapse/ * * @example /examples/components/layout/collapse-simple * * @since 3.74.0 * @copyright Volker Schukai * @summary A simple collapse component. */ class Collapse extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/layout/collapse@@instance"); } /** * */ constructor() { super(); // the name is only used for the host config and the event name this[nameSymbol] = "collapse"; this[eventQueueSymbol] = new Queue(); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} classes CSS classes * @property {string} classes.container CSS class for the container * @property {Object} features Feature configuration * @property {boolean} features.accordion Enable accordion mode * @property {boolean} features.persistState Enable persist state (Host and Config-Manager required) * @property {boolean} features.useScrollValues Use scroll values (scrollHeight) instead of clientHeight for the height calculation * @property {boolean} openByDefault Open the details by default */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, classes: { container: "padding", }, features: { accordion: true, persistState: true, useScrollValues: false, }, openByDefault: false, }); } /** * * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initStateFromHostConfig.call(this); initResizeObserver.call(this); initEventHandler.call(this); if (this.getOption("openByDefault")) { this.open(); } } /** * @return {void} */ connectedCallback() { super.connectedCallback(); updateResizeObserverObservation.call(this); } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); } /** * @return {Collapse} */ toggle() { if (this[isTransitioningSymbol]) return this; if (this[detailsElementSymbol].classList.contains("active")) { this.close(); } else { this.open(); } return this; } /** * @return {boolean} */ isClosed() { return !this[detailsElementSymbol].classList.contains("active"); } /** * @return {boolean} */ isOpen() { return !this.isClosed(); } /** * Open the collapse * @return {Collapse} * @fires monster-collapse-before-open This event is fired before the collapse is opened. * @fires monster-collapse-open This event is fired after the collapse is opened. */ open() { this[eventQueueSymbol].add("open"); runEventQueue.call(this); return this; } /** * Close the collapse * @return {Collapse} * @fires monster-collapse-before-close This event is fired before the collapse is closed. * @fires monster-collapse-closed This event is fired after the collapse is closed. */ close() { this[eventQueueSymbol].add("close"); runEventQueue.call(this); return this; } /** * @return {string} */ static getTag() { return "monster-collapse"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [CollapseStyleSheet]; } /** * This method is called when the element is inserted into a document, including into a shadow tree. * @return {Collapse} * @fires monster-collapse-adjust-height This event is fired when the height is adjusted. As a detail, the height is passed. */ adjustHeight() { adjustHeight.call(this); return this; } } function runEventQueue() { if (this[isTransitioningSymbol]) { return; } if (this[eventQueueSymbol].isEmpty()) { return; } const command = this[eventQueueSymbol].peek(); if (command === "open") { this[eventQueueSymbol].poll(); handleOpenCommand.call(this); } else if (command === "close") { this[eventQueueSymbol].poll(); handleCloseCommand.call(this); } else { this[eventQueueSymbol].remove(); throw new Error("Unknown command: " + command); } } /** * @private * @returns {handleCloseCommand} */ function handleCloseCommand() { if (!this[detailsElementSymbol].classList.contains("active")) { return this; } this[isTransitioningSymbol] = true; fireCustomEvent(this, "monster-" + this[nameSymbol] + "-before-close", {}); this[controlElementSymbol].classList.add("overflow-hidden"); setTimeout(() => { this[detailsElementSymbol].classList.remove("active"); setTimeout(() => { updateStateConfig.call(this); fireCustomEvent(this, "monster-" + this[nameSymbol] + "-closed", {}); this[isTransitioningSymbol] = false; // <<< Sperre freigeben runEventQueue.call(this); }, 0); }, 0); } /** * @private * @returns {handleOpenCommand} */ function handleOpenCommand() { let node; if (this[detailsElementSymbol].classList.contains("active")) { return this; } this[isTransitioningSymbol] = true; fireCustomEvent(this, "monster-" + this[nameSymbol] + "-before-open", {}); adjustHeight.call(this); this[detailsElementSymbol].classList.add("active"); if (this.getOption("features.accordion") === true) { node = this; while (node.nextElementSibling instanceof Collapse) { node = node.nextElementSibling; node.close(); } node = this; while (node.previousElementSibling instanceof Collapse) { node = node.previousElementSibling; node.close(); } } setTimeout(() => { setTimeout(() => { updateStateConfig.call(this); fireCustomEvent(this, "monster-" + this[nameSymbol] + "-open", {}); setTimeout(() => { this[controlElementSymbol].classList.remove("overflow-hidden"); this[isTransitioningSymbol] = false; runEventQueue.call(this); }, 500); }, 0); }, 0); return this; } /** * @private * @return {void} */ function adjustHeight() { let height = 0; if (this[detailsContainerElementSymbol]) { if (this.getOption("features.useScrollValues")) { height += this[detailsContainerElementSymbol].scrollHeight; } else { height += this[detailsContainerElementSymbol].clientHeight; } } if (this[detailsDecoElementSymbol]) { if (this.getOption("features.useScrollValues")) { height += this[detailsDecoElementSymbol].scrollHeight; } else { height += this[detailsDecoElementSymbol].clientHeight + 1; } } if (height === 0) { if (this.getOption("features.useScrollValues")) { height = this[detailsElementSymbol].scrollHeight; } else { height = this[detailsElementSymbol].clientHeight; } if (height === 0) { height = "auto"; } } else { height += "px"; } this[detailsElementSymbol].style.setProperty( "--monster-height", height, "important", ); fireCustomEvent(this, "monster-" + this[nameSymbol] + "-adjust-height", { height, }); } function updateResizeObserverObservation() { this[resizeObserverSymbol].disconnect(); const slottedNodes = getSlottedElements.call(this); slottedNodes.forEach((node) => { this[resizeObserverSymbol].observe(node); }); if (this[detailsContainerElementSymbol]) { this[resizeObserverSymbol].observe(this[detailsContainerElementSymbol]); } this.adjustHeight(); } /** * @private */ function initEventHandler() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } initSlotChangedHandler.call(this); return this; } function initSlotChangedHandler() { this[detailsSlotElementSymbol].addEventListener("slotchange", () => { updateResizeObserverObservation.call(this); }); } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[controlElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); this[detailsElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=detail]", ); this[detailsSlotElementSymbol] = this.shadowRoot.querySelector("slot"); this[detailsContainerElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=container]", ); this[detailsDecoElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=deco]", ); } /** * @private * @return {string} */ function getConfigKey() { return generateUniqueConfigKey(this[nameSymbol], this.id, "state"); } /** * @private */ function updateStateConfig() { if (!this.getOption("features.persistState")) { return; } if (!this[detailsElementSymbol]) { return; } const document = getDocument(); const host = document.querySelector("monster-host"); if (!(host && this.id)) { return; } const configKey = getConfigKey.call(this); try { host.setConfig(configKey, this.isOpen()); } catch (error) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, String(error)); } } /** * @private * @return {Promise} */ function initStateFromHostConfig() { if (!this.getOption("features.persistState")) { return Promise.resolve({}); } const document = getDocument(); const host = document.querySelector("monster-host"); if (!(host && this.id)) { return Promise.resolve({}); } const configKey = getConfigKey.call(this); return host .getConfig(configKey) .then((state) => { if (state === true) { this.open(); } else { this.close(); } }) .catch((error) => { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString()); }); } /** * @private */ function initResizeObserver() { // against flickering this[resizeObserverSymbol] = new ResizeObserver((entries) => { if (this[timerCallbackSymbol] instanceof DeadMansSwitch) { try { this[timerCallbackSymbol].touch(); return; } catch (e) { delete this[timerCallbackSymbol]; } } this[timerCallbackSymbol] = new DeadMansSwitch(200, () => { checkAndRearrangeContent.call(this); }); }); } function checkAndRearrangeContent() { this.adjustHeight(); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control" class="overflow-hidden"> <div data-monster-role="detail"> <div data-monster-attributes="class path:classes.container" part="container" data-monster-role="container"> <slot></slot> </div> <div class="deco-line" data-monster-role="deco" part="deco"></div> </div> </div>`; } registerCustomElement(Collapse);