UNPKG

@schukai/monster

Version:

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

675 lines (590 loc) 17.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 { instanceSymbol } from "../../constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; import { clone } from "../../util/clone.mjs"; import { ColumnBarStyleSheet } from "./stylesheet/column-bar.mjs"; import { createPopper } from "@popperjs/core"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { hasObjectLink } from "../../dom/attributes.mjs"; import { customElementUpdaterLinkSymbol } from "../../dom/constants.mjs"; import { getGlobalObject } from "../../types/global.mjs"; export { ColumnBar }; /** * @private * @type {symbol} */ const settingsButtonElementSymbol = Symbol("settingButtonElement"); /** * @private * @type {symbol} */ const settingsButtonEventHandlerSymbol = Symbol("settingsButtonEventHandler"); /** * @private * @type {symbol} */ const settingsLayerElementSymbol = Symbol("settingsLayerElement"); /** * @private * @type {symbol} */ const dotsContainerElementSymbol = Symbol("dotsContainerElement"); /** * @private * @type {symbol} */ const popperInstanceSymbol = Symbol("popperInstance"); /** * @private * @type {symbol} */ const closeEventHandlerSymbol = Symbol("closeEventHandler"); /** * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const dotsMutationObserverSymbol = Symbol("dotsMutationObserver"); /** * @private * @type {symbol} */ const dotsUpdateInProgressSymbol = Symbol("dotsUpdateInProgress"); /** * @private * @type {symbol} */ const dotsFrameRequestSymbol = Symbol("dotsFrameRequest"); /** * @private * @type {CSSStyleSheet} */ const ResponsiveColumnBarStyleSheet = new CSSStyleSheet(); ResponsiveColumnBarStyleSheet.replaceSync(` :host(:not(.small)) [data-monster-role=control] { align-items: center; flex-direction: row; gap: 0; } :host(:not(.small)) [data-monster-role=dots] { margin: 0 15px 0 0; } :host(.small) [data-monster-role=control] { align-items: flex-end; flex-direction: column; gap: 0.5rem; } :host(.small) [data-monster-role=dots] { margin: 0; } `); /** * A column bar for a datatable * * @fragments /fragments/components/datatable/datatable/ * * @example /examples/components/datatable/empty * * @copyright Volker Schukai * @summary The ColumnBar component is used to show and configure the columns of a datatable. **/ class ColumnBar extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/column-bar@@instance"); } /** * This method is called to customize the component. * @returns {Map<unknown, unknown>} */ get customization() { return new Map([...super.customization, ["templateFormatter.i18n", true]]); } /** * 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 Locale definitions * @property {string} locale.settings The text for the settings button * @property {object} dots Dots configuration * @property {number} dots.maxVisible Max dots to show (0 disables limit) */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: getTranslations(), dots: { maxVisible: 15, }, columns: [], }); } /** * Called every time the element is added to the DOM. Useful for running initialization code. * @return {void} * @since 4.14.0 */ connectedCallback() { super.connectedCallback(); this[closeEventHandlerSymbol] = (event) => { const path = event.composedPath(); const isOutsideElement = !path.includes(this); const isOutsideShadow = !path.includes(this.shadowRoot); if ( isOutsideElement && isOutsideShadow && this[settingsLayerElementSymbol] ) { this[settingsLayerElementSymbol].classList.remove("visible"); } }; getGlobalObject("document").addEventListener( "click", this[closeEventHandlerSymbol], ); getGlobalObject("document").addEventListener( "touch", this[closeEventHandlerSymbol], ); queueMicrotask(() => { initDotsObservers.call(this); scheduleDotsVisibilityUpdate.call(this); }); } /** * Called every time the element is removed from the DOM. Useful for running clean up code. * * @return {void} * @since 4.14.0 */ disconnectedCallback() { super.disconnectedCallback(); if (this[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); this[resizeObserverSymbol] = null; } if (this[dotsMutationObserverSymbol] instanceof MutationObserver) { this[dotsMutationObserverSymbol].disconnect(); this[dotsMutationObserverSymbol] = null; } if (typeof this[dotsFrameRequestSymbol] === "number") { cancelAnimationFrame(this[dotsFrameRequestSymbol]); this[dotsFrameRequestSymbol] = null; } if (this[closeEventHandlerSymbol]) { getGlobalObject("document").removeEventListener( "click", this[closeEventHandlerSymbol], ); getGlobalObject("document").removeEventListener( "touch", this[closeEventHandlerSymbol], ); this[closeEventHandlerSymbol] = null; } } /** * @return {string} */ static getTag() { return "monster-column-bar"; } /** * * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ColumnBarStyleSheet, ResponsiveColumnBarStyleSheet]; } } /** * @private * @returns {{settings: string}} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": // German return { settings: "Einstellungen" }; case "fr": // French return { settings: "Paramètres" }; case "es": // Spanish return { settings: "Configuración" }; case "zh": // Mandarin (Chinese) return { settings: "设置" }; case "hi": // Hindi return { settings: "सेटिंग्स" }; case "bn": // Bengali return { settings: "সেটিংস" }; case "pt": // Portuguese return { settings: "Configurações" }; case "ru": // Russian return { settings: "Настройки" }; case "ja": // Japanese return { settings: "設定" }; case "pa": // Western Punjabi return { settings: "ਸੈਟਿੰਗਾਂ" }; case "mr": // Marathi return { settings: "सेटिंग्ज" }; case "it": // Italian return { settings: "Impostazioni" }; case "nl": // Dutch return { settings: "Instellingen" }; case "sv": // Swedish return { settings: "Inställningar" }; case "pl": // Polish return { settings: "Ustawienia" }; case "da": // Danish return { settings: "Indstillinger" }; case "fi": // Finnish return { settings: "Asetukset" }; case "no": // Norwegian return { settings: "Innstillinger" }; case "cs": // Czech return { settings: "Nastavení" }; default: // English fallback case "en": return { settings: "Settings" }; } } /** * @private * @return {ColumnBar} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[settingsButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=settings-button]", ); this[settingsLayerElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=settings-layer]", ); this[dotsContainerElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=dots]", ); return this; } /** * @private */ function initEventHandler() { const self = this; self[popperInstanceSymbol] = createPopper( self[settingsButtonElementSymbol], self[settingsLayerElementSymbol], { placement: "auto", modifiers: [ { name: "offset", options: { offset: [10, 10], }, }, ], }, ); self[dotsContainerElementSymbol].addEventListener("click", function (event) { const element = findTargetElementFromEvent( event, "data-monster-role", "column", ); if (element) { const index = element.getAttribute("data-monster-index"); event.preventDefault(); const columns = clone(self.getOption("columns")); const column = columns.find((col) => { return parseInt(col.index) === parseInt(index); }); column.visible = !column.visible; self.setOption("columns", columns); } }); self[settingsButtonEventHandlerSymbol] = (event) => { const clickTarget = event.composedPath()?.[0]; if ( self[settingsLayerElementSymbol] === clickTarget || self[settingsLayerElementSymbol].contains(clickTarget) ) { return; } document.body.removeEventListener( "click", self[settingsButtonEventHandlerSymbol], ); }; self[settingsButtonElementSymbol].addEventListener("click", function (event) { const element = findTargetElementFromEvent( event, "data-monster-role", "settings-button", ); if (element) { self[settingsLayerElementSymbol].classList.toggle("visible"); event.preventDefault(); if (self[settingsLayerElementSymbol].classList.contains("visible")) { self[popperInstanceSymbol].update(); queueMicrotask(() => { document.body.addEventListener( "click", self[settingsButtonEventHandlerSymbol], ); }); } } }); self[settingsLayerElementSymbol].addEventListener("change", function (event) { const control = event.target; const index = control.getAttribute("data-monster-index"); const columns = clone(self.getOption("columns")); const column = columns.find((col) => { return parseInt(col.index) === parseInt(index); }); column.visible = control.checked; self.setOption("columns", columns); }); } /** * @private */ function initDotsObservers() { const controlElement = this.shadowRoot?.querySelector( "[data-monster-role=control]", ); const parentElement = this.parentElement; if (controlElement && !this[resizeObserverSymbol]) { this[resizeObserverSymbol] = new ResizeObserver(() => { scheduleDotsVisibilityUpdate.call(this); }); this[resizeObserverSymbol].observe(controlElement); if (parentElement) { this[resizeObserverSymbol].observe(parentElement); } } if (this[dotsContainerElementSymbol] && !this[dotsMutationObserverSymbol]) { this[dotsMutationObserverSymbol] = new MutationObserver(() => { if (this[dotsUpdateInProgressSymbol]) { return; } scheduleDotsVisibilityUpdate.call(this); }); this[dotsMutationObserverSymbol].observe(this[dotsContainerElementSymbol], { childList: true, }); } } /** * @private */ function scheduleDotsVisibilityUpdate() { if ( this[dotsFrameRequestSymbol] !== null && this[dotsFrameRequestSymbol] !== undefined ) { return; } this[dotsFrameRequestSymbol] = requestAnimationFrame(() => { this[dotsFrameRequestSymbol] = null; updateDotsVisibility.call(this); }); } /** * @private */ function updateDotsVisibility() { if (this[dotsUpdateInProgressSymbol]) { return; } if (!this.shadowRoot || !this[dotsContainerElementSymbol]) { return; } const controlElement = this.shadowRoot.querySelector( "[data-monster-role=control]", ); const settingsButton = this[settingsButtonElementSymbol]; const parentElement = this.parentElement; if (!controlElement || !settingsButton) { return; } const dotsContainer = this[dotsContainerElementSymbol]; dotsContainer.classList.remove("dots-hidden"); this[dotsUpdateInProgressSymbol] = true; try { const dots = Array.from(dotsContainer.querySelectorAll("li")); if (dots.length === 0) { return; } for (const dot of dots) { dot.classList.remove("dots-overflow-hidden"); } const indicator = dotsContainer.querySelector(".dots-overflow-indicator"); if (indicator) { indicator.remove(); } const controlWidth = controlElement.getBoundingClientRect().width; const settingsWidth = settingsButton.getBoundingClientRect().width; const availableWidth = Math.max( 0, getAvailableWidth(parentElement, settingsWidth, this) ?? controlWidth - settingsWidth - 12, ); const dotSlotWidth = getDotSlotWidth(dots[0]); const maxDots = dotSlotWidth > 0 ? Math.floor(availableWidth / dotSlotWidth) : dots.length; const configuredMaxVisible = parseInt( this.getOption("dots.maxVisible"), 10, ); const enforceMaxVisible = Number.isFinite(configuredMaxVisible) && configuredMaxVisible > 0; if (maxDots <= 1) { dotsContainer.classList.add("dots-hidden"); return; } const configLimit = enforceMaxVisible ? configuredMaxVisible : Number.POSITIVE_INFINITY; const baseLimit = Math.min(maxDots, configLimit, dots.length); const needsIndicator = baseLimit < dots.length; let visibleDots = baseLimit; if (needsIndicator) { visibleDots = Math.min( Math.max(1, maxDots - 1), configLimit, dots.length, ); } for (let i = visibleDots; i < dots.length; i++) { dots[i].classList.add("dots-overflow-hidden"); } const hiddenCount = dots.length - visibleDots; if (hiddenCount > 0) { const overflowIndicator = document.createElement("li"); overflowIndicator.className = "dots-overflow-indicator"; overflowIndicator.textContent = `+${hiddenCount}`; dotsContainer.appendChild(overflowIndicator); } } finally { this[dotsUpdateInProgressSymbol] = false; } } /** * @private * @param {HTMLElement} dot * @return {number} */ function getDotSlotWidth(dot) { if (!dot) return 0; const styles = getComputedStyle(dot); const marginRight = parseFloat(styles.marginRight || "0"); const marginLeft = parseFloat(styles.marginLeft || "0"); return dot.getBoundingClientRect().width + marginLeft + marginRight; } /** * @private * @param {HTMLElement|null} parent * @param {number} settingsWidth * @return {number|null} */ function getAvailableWidth(parent, settingsWidth, hostElement) { if (!parent) return null; const parentWidth = parent.getBoundingClientRect().width; if (!parentWidth) return null; const styles = getComputedStyle(parent); const gapValue = styles.columnGap || styles.gap || "0"; const gap = parseFloat(gapValue) || 0; const siblings = Array.from(parent.children).filter( (el) => el !== hostElement, ); let siblingsWidth = 0; for (const sibling of siblings) { const siblingStyles = getComputedStyle(sibling); if (siblingStyles.display === "none") { continue; } siblingsWidth += sibling.getBoundingClientRect().width; } const gaps = siblings.length > 0 ? siblings.length : 0; return Math.max(0, parentWidth - siblingsWidth - gap * gaps - settingsWidth); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="column"> <div data-monster-role="column"> <label><input type="checkbox" data-monster-attributes=" data-monster-index path:column.index, checked path:column.visible | ?:checked:"><span data-monster-replace="path:column.name" ></span></label> </div> </template> <template id="dots"> <li data-monster-insert=""> <a href="#" data-monster-role="column" data-monster-attributes=" class path:dots.visible | ?:is-hidden:is-visible, title path:dots.name, data-monster-index path:dots.index"> </a> </li> </template> <div data-monster-role="control" part="control" data-monster-select-this="true" data-monster-attributes="class path:columns | has-entries | ?::hidden"> <ul data-monster-insert="dots path:columns" data-monster-role="dots"></ul> <a href="#" data-monster-role="settings-button">i18n{settings}</a> <div data-monster-role="settings-layer"> <div data-monster-insert="column path:columns" data-monster-role="settings-popup-list"> </div> </div> </div> `; } registerCustomElement(ColumnBar);