UNPKG

@schukai/monster

Version:

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

754 lines (651 loc) 17.8 kB
/** * 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 { instanceSymbol } from "../../constants.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { addAttributeToken, removeAttributeToken, } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { CustomElement, attributeObserverSymbol, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; import { getDocument } from "../../dom/util.mjs"; import { getGlobal } from "../../types/global.mjs"; import { ID } from "../../types/id.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { Processing } from "../../util/processing.mjs"; import { STYLE_DISPLAY_MODE_BLOCK } from "./constants.mjs"; import { ButtonBarStyleSheet } from "./stylesheet/button-bar.mjs"; import { positionPopper } from "./util/floating-ui.mjs"; import { convertToPixels } from "../../dom/dimension.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; export { ButtonBar }; /** * @private * @type {symbol} */ const timerCallbackSymbol = Symbol("timerCallback"); /** * local symbol * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("windowResizeObserver"); /** * @private * @type {symbol} */ const dimensionsSymbol = Symbol("dimensions"); /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} */ const buttonBarSlotElementSymbol = Symbol("buttonBarSlotElement"); /** * @private * @type {symbol} */ const popperSlotElementSymbol = Symbol("popperSlotElement"); /** * @private * @type {symbol} */ const buttonBarElementSymbol = Symbol("buttonBarElement"); /** * @private * @type {symbol} */ const popperElementSymbol = Symbol("popperElement"); /** * local symbol * @private * @type {symbol} */ const closeEventHandler = Symbol("closeEventHandler"); /** * @private * @type {symbol} */ const popperSwitchEventHandler = Symbol("popperSwitchEventHandler"); /** * @private * @type {symbol} */ const popperNavElementSymbol = Symbol("popperNavElement"); /** * @private * @type {symbol} */ const switchElementSymbol = Symbol("switchElement"); /** * @private * @type {string} */ const ATTRIBUTE_POPPER_POSITION = "data-monster-popper-position"; /** * A button bar control. * * @fragments /fragments/components/form/button-bar/ * * @example /examples/components/form/button-bar-simple Button bar * * @copyright schukai GmbH * @summary This is a button bar control that can be used to display a set of buttons. * @fires monster-fetched */ class ButtonBar extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/button-bar@@instance"); } /** * 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} labels * @property {Object} popper FloatingUI popper configuration * @property {string} popper.placement=top Placement of the popper * @property {Array<string>} popper.middleware Middleware for the popper */ get defaults() { const obj = Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: {}, popper: { placement: "left", middleware: ["autoPlacement", "shift", "offset:5"], }, }); initDefaultsFromAttributes.call(this, obj); return obj; } /** * This method is called internal and should not be called directly. */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); this[dimensionsSymbol] = new Pathfinder({ data: {} }); initControlReferences.call(this); initEventHandler.call(this); // setup structure initButtonBar.call(this).then(() => { initPopperSwitch.call(this); }); } /** * This method is called internal and should not be called directly. * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ButtonBarStyleSheet]; } /** * This method is called internal and should not be called directly. * * @return {string} */ static getTag() { return "monster-button-bar"; } /** * This method is called by the dom and should not be called directly. * * @return {void} */ connectedCallback() { super.connectedCallback(); const document = getDocument(); for (const [, type] of Object.entries(["click", "touch"])) { // close on outside ui-events document.addEventListener(type, this[closeEventHandler]); } setTimeout(() => { updatePopper.call(this); updateResizeObserverObservation.call(this); }, 0); } /** * This method determines which attributes are to be monitored by `attributeChangedCallback()`. * * @return {string[]} */ static get observedAttributes() { const attributes = super.observedAttributes; attributes.push(ATTRIBUTE_POPPER_POSITION); return attributes; } /** * This method is called by the dom and should not be called directly. * * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); const document = getDocument(); // close on outside ui-events for (const [, type] of Object.entries(["click", "touch"])) { document.removeEventListener(type, this[closeEventHandler]); } disconnectResizeObserver.call(this); } /** * Close the slotted dialog. * @return {ButtonBar} */ hideDialog() { hide.call(this); return this; } /** * Open the slotted dialog. * @return {ButtonBar} */ showDialog() { show.call(this); return this; } /** * Toggle the slotted dialog. * @return {ButtonBar} */ toggleDialog() { toggle.call(this); return this; } } /** * @private * @param obj * @return {*} */ function initDefaultsFromAttributes(obj) { if (this.hasAttribute(ATTRIBUTE_POPPER_POSITION)) { obj.popper.placement = this.getAttribute(ATTRIBUTE_POPPER_POSITION); } return obj; } /** * @private */ function initEventHandler() { const self = this; /** * @param {Event} event */ self[closeEventHandler] = (event) => { const path = event.composedPath(); for (const [, element] of Object.entries(path)) { if (element === self) { return; } } hide.call(self); }; if (self[buttonBarSlotElementSymbol]) { self[buttonBarSlotElementSymbol].addEventListener("slotchange", (event) => { checkAndRearrangeButtons.call(self); }); } if (self[popperElementSymbol]) { self[popperElementSymbol].addEventListener("slotchange", (event) => { checkAndRearrangeButtons.call(self); }); } // data-monster-options self[attributeObserverSymbol][ATTRIBUTE_POPPER_POSITION] = function (value) { self.setOption("classes.button", value); }; self[resizeObserverSymbol] = new ResizeObserver((entries) => { if (self[timerCallbackSymbol] instanceof DeadMansSwitch) { try { self[timerCallbackSymbol].touch(); return; } catch (e) { // catch Error("has already run"); if (e.message !== "has already run") { throw e; } delete self[timerCallbackSymbol]; } } self[timerCallbackSymbol] = new DeadMansSwitch(200, () => { requestAnimationFrame(() => { updatePopper.call(self); self[dimensionsSymbol].setVia("data.calculated", false); try { checkAndRearrangeButtons.call(self); } catch (error) { addErrorAttribute( this, error?.message || "An error occurred while rearranging the buttons", ); } }); }); }); initSlotChangedHandler.call(self); } function initSlotChangedHandler() { this[buttonBarElementSymbol].addEventListener("slotchange", () => { updateResizeObserverObservation.call(this); }); } function checkAndRearrangeButtons() { if (this[dimensionsSymbol].getVia("data.calculated", false) !== true) { calculateButtonBarDimensions.call(this); } rearrangeButtons.call(this); } /** * @private * @return {Object} */ function rearrangeButtons() { let sum = this[switchElementSymbol].offsetWidth; const space = this[dimensionsSymbol].getVia("data.space"); const buttonReferences = this[dimensionsSymbol].getVia( "data.buttonReferences", ); for (const ref of buttonReferences) { sum += this[dimensionsSymbol].getVia(`data.button.${ref}`); let elements = getSlottedElements.call( this, '[data-monster-reference="' + ref + '"]', null, ); // null ↦ o if (elements.size === 0) { elements = getSlottedElements.call( this, '[data-monster-reference="' + ref + '"]', "popper", ); // null ↦ o } const nextValue = elements.values().next(); if (!nextValue) { continue; } const element = nextValue?.value; if (!(element instanceof HTMLElement)) { continue; } if (sum > space) { element.setAttribute("slot", "popper"); } else { element.removeAttribute("slot"); } } const inVisibleButtons = getSlottedElements.call(this, ":scope", "popper"); // null ↦ o if (inVisibleButtons.size > 0) { this[switchElementSymbol].classList.remove("hidden"); } else { this[switchElementSymbol].classList.add("hidden"); setTimeout(() => { hide.call(this); }, 1); } } /** * @private * @param {HTMLElement} node * @return {number} */ function calcBoxWidth(node) { const dim = getGlobal()?.getComputedStyle(node); if (dim === null) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "no computed style"); throw new Error("no computed style"); } const bounding = node.getBoundingClientRect(); return ( convertToPixels(dim["border-left-width"]) + convertToPixels(dim["padding-left"]) + convertToPixels(dim["margin-left"]) + bounding["width"] + convertToPixels(dim["border-right-width"]) + convertToPixels(dim["margin-right"]) + convertToPixels(dim["padding-left"]) ); } /** * @private * @return {Object} */ function calculateButtonBarDimensions() { const computedStyle = getComputedStyle(this.parentElement); if (computedStyle === null) { throw new Error("no computed style"); } let width = this.parentElement.clientWidth; if (computedStyle.getPropertyValue("box-sizing") !== "border-box") { width = computedStyle.getPropertyValue("width"); let pixel = 0; try { pixel = convertToPixels(width); } catch (e) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, e?.message || "An error occurred while calculating the dimensions", ); } this[dimensionsSymbol].setVia("data.space", pixel); } else { let borderWidth = getComputedStyle(this).getPropertyValue( "--monster-border-width", ); if (borderWidth === null || borderWidth === "") { borderWidth = "0px"; } let borderWidthWithoutUnit = 0; try { borderWidthWithoutUnit = convertToPixels(borderWidth); } catch (e) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, e?.message || "An error occurred while calculating the dimensions", ); } // space to be allocated this[dimensionsSymbol].setVia( "data.space", width - 2 * borderWidthWithoutUnit, ); } this[dimensionsSymbol].setVia("data.visible", !(width === 0)); const buttonReferences = []; const visibleButtons = getSlottedElements.call(this, ":scope", null); // null ↦ o for (const [, button] of visibleButtons.entries()) { if (!button.hasAttribute("data-monster-reference")) { button.setAttribute("data-monster-reference", new ID("vb").toString()); } const ref = button.getAttribute("data-monster-reference"); if (ref === null) continue; buttonReferences.push(ref); this[dimensionsSymbol].setVia( `data.button.${ref}`, calcBoxWidth.call(this, button), ); } const invisibleButtons = getSlottedElements.call(this, ":scope", "popper"); // null ↦ o for (const [, button] of invisibleButtons.entries()) { if (!button.hasAttribute("data-monster-reference")) { button.setAttribute("data-monster-reference", new ID("ib").toString()); } const ref = button.getAttribute("data-monster-reference"); if (ref === null) continue; if (ref.indexOf("ib") !== 0) { buttonReferences.push(ref); } } this[dimensionsSymbol].setVia("data.calculated", true); this[dimensionsSymbol].setVia("data.buttonReferences", buttonReferences); } /** * @private */ function updateResizeObserverObservation() { this[resizeObserverSymbol].disconnect(); const slottedNodes = getSlottedElements.call(this); slottedNodes.forEach((node) => { this[resizeObserverSymbol].observe(node); }); requestAnimationFrame(() => { let parent = this.parentNode; while (!(parent instanceof HTMLElement) && parent !== null) { parent = parent.parentNode; } if (parent instanceof HTMLElement) { this[resizeObserverSymbol].observe(parent); } }); } /** * @private */ function disconnectResizeObserver() { if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } } /** * @private */ function toggle() { if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { hide.call(this); } else { show.call(this); } } /** * @private */ function hide() { this[popperElementSymbol].style.display = "none"; removeAttributeToken(this[controlElementSymbol], "class", "open"); } /** * @private * @this PopperButton */ function show() { if (this.getOption("disabled", false) === true) { return; } if (this[popperElementSymbol].style.display === STYLE_DISPLAY_MODE_BLOCK) { return; } this[popperElementSymbol].style.visibility = "hidden"; this[popperElementSymbol].style.display = STYLE_DISPLAY_MODE_BLOCK; addAttributeToken(this[controlElementSymbol], "class", "open"); updatePopper.call(this); } /** * @private */ function updatePopper() { if (this[popperElementSymbol].style.display !== STYLE_DISPLAY_MODE_BLOCK) { return; } if (this.getOption("disabled", false) === true) { return; } positionPopper.call( this, this[switchElementSymbol], this[popperElementSymbol], this.getOption("popper", {}), ); } /** * @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( `[${ATTRIBUTE_ROLE}=control]`, ); this[buttonBarElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=button-bar]`, ); this[popperElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper]`, ); this[popperNavElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=popper-nav]`, ); this[switchElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=switch]`, ); this[buttonBarSlotElementSymbol] = null; if (this[buttonBarElementSymbol]) this[buttonBarSlotElementSymbol] = this[buttonBarElementSymbol].querySelector(`slot`); this[popperSlotElementSymbol] = null; if (this[popperElementSymbol]) this[popperSlotElementSymbol] = this[popperElementSymbol].querySelector(`slot`); } /** * @private * @return {Promise<unknown>} * @throws {Error} no shadow-root is defined * */ function initButtonBar() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } return new Processing(() => { checkAndRearrangeButtons.call(this); }).run(); } /** * @private */ function initPopperSwitch() { /** * @param {Event} event */ this[popperSwitchEventHandler] = (event) => { const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "switch"); if (element instanceof HTMLButtonElement) { toggle.call(this); } }; for (const type of ["click", "touch"]) { this[switchElementSymbol].addEventListener( type, this[popperSwitchEventHandler], ); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div data-monster-role="button-bar"> <slot></slot> <div part="popper-nav" data-monster-role="popper-nav" tabindex="-1"> <button part="popper-switch" data-monster-role="switch" class="monster-button-outline-tertiary hidden"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/> </svg> </button> </div> </div> <div data-monster-role="popper"> <slot name="popper"></slot> </div> </div>`; } registerCustomElement(ButtonBar);