/** * Copyright © schukai GmbH 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 schukai GmbH. */ 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"; 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 nameSymbol = Symbol("name"); /** * The Collapse component is used to show a details. * * <img src="./images/collapse.png"> * * Dependencies: the system uses functions of the [monsterjs](https://monsterjs.org/) library * * You can create this control either by specifying the HTML tag <monster-collapse />` directly in the HTML or using * Javascript via the `document.createElement('monster-collapse');` method. * * ```html * <monster-collapse></monster-collapse> * ``` * * Or you can create this CustomControl directly in Javascript: * * ```js * import '@schukai/monster/source/components/host/collapse.mjs'; * document.createElement('monster-collapse'); * ``` * * The Body should have a class "hidden" to ensure that the styles are applied correctly. * * ```css * body.hidden { * visibility: hidden; * } * ``` * * @startuml collapse.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * CustomElement <|-- Collapse * @enduml * * @copyright schukai GmbH * @memberOf Monster.Components.Host * @summary A simple collapse component * @fires Monster.Components.Host.Collapse.event:monster-collapse-before-open * @fires Monster.Components.Host.Collapse.event:monster-collapse-open * @fires Monster.Components.Host.Collapse.event:monster-collapse-before-close * @fires Monster.Components.Host.Collapse.event:monster-collapse-closed * @fires Monster.Components.Host.Collapse.event:monster-collapse-adjust-height */ class Collapse extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {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"; } /** * 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, }); } /** * * @returns {Monster.Components.Host.Collapse} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initStateFromHostConfig.call(this); initResizeObserver.call(this); initEventHandler.call(this); if (this.getOption("openByDefault")) { this.open(); } } /** * */ connectedCallback() { super.connectedCallback(); updateResizeObserverObservation.call(this); // this[resizeObserverSymbol].observe(getDocument().body); } /** * */ disconnectedCallback() { super.disconnectedCallback(); //this[resizeObserverSymbol].disconnect(); } /** * * @returns {Monster.Components.Host.Collapse} */ toggle() { if (this[detailsElementSymbol].classList.contains("active")) { this.close(); } else { this.open(); } return this; } /** * * @returns {boolean} */ isClosed() { return !this[detailsElementSymbol].classList.contains("active"); } /** * * @returns {boolean} */ isOpen() { return !this.isClosed(); } /** * * @returns {Monster.Components.Host.Collapse} * @fires Monster.Components.Host.Collapse.event:monster-collapse-before-open * @fires Monster.Components.Host.Collapse.event:monster-collapse-open */ open() { let node; if (this[detailsElementSymbol].classList.contains("active")) { return this; } 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"); }, 500); }, 0); }, 0); return this; } /** * * @returns {Monster.Components.Host.Collapse} * @fires Monster.Components.Host.Collapse.event:monster-collapse-before-close * @fires Monster.Components.Host.Collapse.event:monster-collapse-closed */ close() { if (!this[detailsElementSymbol].classList.contains("active")) { return this; } 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", {}); }, 0); }, 0); return this; } /** * * @return {string} */ static getTag() { return "monster-collapse"; } /** * @return {Array<CSSStyleSheet>} */ static getCSSStyleSheet() { return [CollapseStyleSheet]; } /** * This method is called when the element is inserted into a document, including into a shadow tree. * @return {Monster.Components.Host.Collapse} * @fires Monster.Components.Host.Collapse.event:monster-collapse-adjust-height */ adjustHeight() { adjustHeight.call(this); return this; } } 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", {}); } 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 * @returns {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 * @returns {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);