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