UNPKG

@schukai/monster

Version:

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

987 lines (856 loc) 23 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 { 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 { Observer } from "../../types/observer.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} */ /** * 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 mutationObserverSymbol = Symbol("mutationObserver"); /** * @private * @type {symbol} */ const switchElementSymbol = Symbol("switchElement"); /** * @private * @type {symbol} */ const layoutStateSymbol = Symbol("layoutState"); /** * @private * @type {string} */ const ATTRIBUTE_POPPER_POSITION = "data-monster-popper-position"; /** * @private * @type {string} */ const ATTRIBUTE_LAYOUT_ALIGNMENT = "data-monster-layout-alignment"; /** * A button bar control. * * @fragments /fragments/components/form/button-bar/ * * @example /examples/components/form/button-bar-simple Button bar * * @copyright Volker Schukai * @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: {}, layout: { alignment: "left", }, 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: {} }); this[layoutStateSymbol] = { scheduled: false, needsMeasure: true, needsLayout: true, needsObserve: true, suppressSlotChange: false, }; initControlReferences.call(this); initEventHandler.call(this); // setup structure initButtonBar.call(this); initPopperSwitch.call(this); applyLayoutAlignment.call(this); this.attachObserver( new Observer(() => { applyLayoutAlignment.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]); } scheduleLayout.call(this, { measure: true, layout: true, observe: true }); } /** * 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); if (this[mutationObserverSymbol]) { this[mutationObserverSymbol].disconnect(); } } /** * 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 * @param {HTMLElement} element * @return {boolean} */ function isElementTrulyVisible(element) { if (!(element instanceof HTMLElement)) { return false; } const computedStyle = getComputedStyle(element); return ( computedStyle.display !== "none" && computedStyle.visibility !== "hidden" && computedStyle.opacity !== "0" && element.offsetWidth > 0 && element.offsetHeight > 0 ); } /** * @private */ function initEventHandler() { const self = this; const mutationCallback = (mutationList) => { let needsRecalc = false; for (const mutation of mutationList) { if (mutation.type === "attributes") { const target = mutation.target; if (target instanceof HTMLElement) { const ref = target.getAttribute("data-monster-reference"); if (ref && !isElementTrulyVisible(target)) { self[dimensionsSymbol].setVia(`data.button.${ref}`, 0); needsRecalc = true; } } } } if (needsRecalc) { scheduleLayout.call(self, { measure: true, layout: true }); } }; /** * @param {Event} event */ self[closeEventHandler] = (event) => { const path = event.composedPath(); for (const element of path) { if (element === self) return; } hide.call(self); }; if (self[buttonBarSlotElementSymbol]) { self[buttonBarSlotElementSymbol].addEventListener("slotchange", () => { if (self[layoutStateSymbol]?.suppressSlotChange) { return; } scheduleLayout.call(self, { measure: true, layout: true, observe: true, }); }); } if (self[popperElementSymbol]) { self[popperElementSymbol].addEventListener("slotchange", () => { if (self[layoutStateSymbol]?.suppressSlotChange) { return; } scheduleLayout.call(self, { measure: true, layout: true, observe: true, }); }); } self[attributeObserverSymbol][ATTRIBUTE_POPPER_POSITION] = function (value) { self.setOption("classes.button", value); }; self[resizeObserverSymbol] = new ResizeObserver(() => { scheduleLayout.call(self, { measure: true, layout: true }); }); self[mutationObserverSymbol] = new MutationObserver(mutationCallback); initSlotChangedHandler.call(self); } function initSlotChangedHandler() { this[buttonBarElementSymbol].addEventListener("slotchange", () => { if (this[layoutStateSymbol]?.suppressSlotChange) { return; } scheduleLayout.call(this, { observe: true }); }); } function scheduleLayout(options = {}) { if (!this[layoutStateSymbol]) { return; } const state = this[layoutStateSymbol]; state.needsMeasure = state.needsMeasure || options.measure === true; state.needsLayout = state.needsLayout || options.layout === true; state.needsObserve = state.needsObserve || options.observe === true; if (state.scheduled) { return; } state.scheduled = true; requestAnimationFrame(() => { runLayout.call(this); }); } function runLayout() { const state = this[layoutStateSymbol]; if (!state) { return; } state.scheduled = false; if (!this.isConnected) { return; } if (state.needsObserve) { updateResizeObserverObservation.call(this); state.needsObserve = false; } if (state.needsMeasure) { try { calculateButtonBarDimensions.call(this); } catch (error) { addErrorAttribute( this, error?.message || "An error occurred while calculating dimensions", ); } state.needsMeasure = false; } if (state.needsLayout) { try { rearrangeButtons.call(this); } catch (error) { addErrorAttribute( this, error?.message || "An error occurred while rearranging the buttons", ); } state.needsLayout = false; } updatePopper.call(this); } /** * @private * @return {Object} */ function rearrangeButtons() { const state = this[layoutStateSymbol]; let space = 0; try { space = this[dimensionsSymbol].getVia("data.space"); } catch {} const buttonReferences = this[dimensionsSymbol].getVia( "data.buttonReferences", [], ); const hasButtons = buttonReferences.length > 0; const buttonEntries = []; for (const ref of buttonReferences) { 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; } let buttonWidth = 0; try { buttonWidth = this[dimensionsSymbol].getVia(`data.button.${ref}`); } catch (e) { // If the path does not exist, pathfinder throws an error. // In this case, we assume the width is 0. // This can happen for buttons that have never been visible. } const style = getComputedStyle(element); buttonEntries.push({ element, width: buttonWidth, hidden: style.display === "none", }); } const switchWidth = this[dimensionsSymbol].getVia("data.switchWidth") || 2; const layoutButtons = (availableSpace) => { if (availableSpace < 0) { availableSpace = 0; } let sum = 0; const visibleButtonsInMainSlot = []; const buttonsToMoveToPopper = []; for (const entry of buttonEntries) { if (entry.hidden) { if (entry.width > 0) { buttonsToMoveToPopper.push(entry.element); } else { visibleButtonsInMainSlot.push(entry.element); } continue; } if (sum + entry.width > availableSpace) { buttonsToMoveToPopper.push(entry.element); } else { sum += entry.width; visibleButtonsInMainSlot.push(entry.element); } } return { visibleButtonsInMainSlot, buttonsToMoveToPopper }; }; let layout = layoutButtons(space); if (layout.buttonsToMoveToPopper.length > 0) { layout = layoutButtons(space - switchWidth); } const shouldShowSwitch = layout.buttonsToMoveToPopper.length > 0 && hasButtons; if (state) { state.suppressSlotChange = true; } for (const button of layout.buttonsToMoveToPopper) { button.setAttribute("slot", "popper"); } for (const button of layout.visibleButtonsInMainSlot) { button.removeAttribute("slot"); } if (state) { state.suppressSlotChange = false; } if (shouldShowSwitch) { this[switchElementSymbol].removeAttribute("hidden"); this[switchElementSymbol].classList.remove("hidden"); } else { this[switchElementSymbol].setAttribute("hidden", ""); this[switchElementSymbol].classList.add("hidden"); hide.call(this); } } /** * @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() { if (!(this.parentElement instanceof HTMLElement)) { this[dimensionsSymbol].setVia("data.space", 0); this[dimensionsSymbol].setVia("data.visible", false); this[dimensionsSymbol].setVia("data.calculated", true); this[dimensionsSymbol].setVia("data.buttonReferences", []); return; } 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) { addErrorAttribute( this, 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) { addErrorAttribute( this, 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 = []; // Get all buttons, regardless of their current slot const allButtons = Array.from(getSlottedElements.call(this, ":scope", null)); const popperButtons = Array.from( getSlottedElements.call(this, ":scope", "popper"), ); const combinedButtons = [...allButtons, ...popperButtons].filter( (button, index, self) => { // Filter out duplicates based on data-monster-reference if present, or element itself return ( self.findIndex( (b) => b.dataset.monsterReference === button.dataset.monsterReference || b === button, ) === index ); }, ); for (const button of combinedButtons) { if (!(button instanceof HTMLElement)) { continue; } if (!button.hasAttribute("data-monster-reference")) { button.setAttribute("data-monster-reference", new ID("btn").toString()); } const ref = button.getAttribute("data-monster-reference"); if (ref === null) continue; buttonReferences.push(ref); // Only calculate width for visible buttons. Assume invisible ones // (e.g. in popper) have their width calculated previously and stored. if (isElementTrulyVisible(button)) { this[dimensionsSymbol].setVia( `data.button.${ref}`, calcBoxWidth.call(this, button), ); } } if (this[switchElementSymbol]) { this[dimensionsSymbol].setVia( "data.switchWidth", this[switchElementSymbol].offsetWidth, ); } this[dimensionsSymbol].setVia("data.calculated", true); this[dimensionsSymbol].setVia("data.buttonReferences", buttonReferences); } /** * @private */ function updateResizeObserverObservation() { this[resizeObserverSymbol].disconnect(); if (this[mutationObserverSymbol]) { this[mutationObserverSymbol].disconnect(); } const slottedNodes = getSlottedElements.call(this); slottedNodes.forEach((node) => { this[resizeObserverSymbol].observe(node); if (this[mutationObserverSymbol]) { this[mutationObserverSymbol].observe(node, { attributes: true, attributeFilter: ["style", "class"], }); } }); 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 */ function applyLayoutAlignment() { if (!(this[buttonBarElementSymbol] instanceof HTMLElement)) { return; } const alignment = this.getOption("layout.alignment", "left"); if (alignment === "right") { this[buttonBarElementSymbol].setAttribute( ATTRIBUTE_LAYOUT_ALIGNMENT, "right", ); return; } this[buttonBarElementSymbol].setAttribute(ATTRIBUTE_LAYOUT_ALIGNMENT, "left"); } /** * @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"); } scheduleLayout.call(this, { measure: true, layout: true, observe: true }); } /** * @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" 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);