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