UNPKG

@schukai/monster

Version:

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

1,856 lines (1,579 loc) 49.8 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 { Datasource } from "./datasource.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, getSlottedElements, updaterTransformerMethodsSymbol, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireCustomEvent, } from "../../dom/events.mjs"; import { clone } from "../../util/clone.mjs"; import { isString, isFunction, isInstance, isObject, isArray, } from "../../types/is.mjs"; import { validateArray, validateInteger, validateObject, } from "../../types/validate.mjs"; import { Observer } from "../../types/observer.mjs"; import { ATTRIBUTE_DATATABLE_HEAD, ATTRIBUTE_DATATABLE_GRID_TEMPLATE, ATTRIBUTE_DATASOURCE_SELECTOR, ATTRIBUTE_DATATABLE_ALIGN, ATTRIBUTE_DATATABLE_SORTABLE, ATTRIBUTE_DATATABLE_MODE, ATTRIBUTE_DATATABLE_INDEX, ATTRIBUTE_DATATABLE_MODE_HIDDEN, ATTRIBUTE_DATATABLE_FEATURES, ATTRIBUTE_DATATABLE_MODE_VISIBLE, ATTRIBUTE_DATATABLE_RESPONSIVE_BREAKPOINT, ATTRIBUTE_DATATABLE_MODE_FIXED, } from "./constants.mjs"; import { instanceSymbol } from "../../constants.mjs"; import { Header, createOrderStatement, DIRECTION_ASC, DIRECTION_DESC, DIRECTION_NONE, } from "./datatable/header.mjs"; import { DatatableStyleSheet } from "./stylesheet/datatable.mjs"; import { handleDataSourceChanges, datasourceLinkedElementSymbol, } from "./util.mjs"; import "./columnbar.mjs"; import "./filter-button.mjs"; import { findElementWithSelectorUpwards, getWindow, waitForCustomElement, } from "../../dom/util.mjs"; import { getDocumentTranslations } from "../../i18n/translations.mjs"; import "../state/state.mjs"; import "../host/collapse.mjs"; import { generateUniqueConfigKey } from "../host/util.mjs"; import "./datasource/dom.mjs"; import "./datasource/rest.mjs"; import "../form/context-help.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; export { DataTable }; /** * @private * @type {symbol} */ const gridElementSymbol = Symbol("gridElement"); /** * @private * @type {symbol} */ const dataControlElementSymbol = Symbol("dataControlElement"); /** * @private * @type {symbol} */ const gridHeadersElementSymbol = Symbol("gridHeadersElement"); /** * @private * @type {symbol} */ const columnBarElementSymbol = Symbol("columnBarElement"); /** * @private * @type {symbol} */ const copyAllElementSymbol = Symbol("copyAllElement"); /** * @private * @type {symbol} */ const tableScrollElementSymbol = Symbol("tableScrollElement"); /** * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const resizeFrameRequestSymbol = Symbol("resizeFrameRequest"); /** * @private * @type {symbol} */ const suppressColumnConfigSaveSymbol = Symbol("suppressColumnConfigSave"); /** * A DataTable * * @fragments /fragments/components/datatable/datatable/ * * @example /examples/components/datatable/empty The empty state * @example /examples/components/datatable/data-using-javascript The data using javascript * @example /examples/components/datatable/alignment The alignment * @example /examples/components/datatable/row-mode The row mode * @example /examples/components/datatable/grid-template The grid template * @example /examples/components/datatable/overview-class The overview class * @example /examples/components/datatable/datasource Use a datasource * @example /examples/components/datatable/pagination Use pagination * @example /examples/components/datatable/filter Filer the data * @example /examples/components/datatable/order-by Sort data * @example /examples/components/datatable/select-rows Select rows * * @issue https://localhost.alvine.dev:8440/development/issues/closed/277.html * @issue https://localhost.alvine.dev:8440/development/issues/closed/289.html * * @copyright Volker Schukai * @summary A beautiful and highly customizable data table. It can be used to display data from a data source. * @fires monster-datatable-row-copied * @fires monster-datatable-row-removed * @fires monster-datatable-row-added * @fires monster-datatable-row-selected * @fires monster-datatable-row-deselected * @fires monster-datatable-all-rows-selected * @fires monster-datatable-all-rows-deselected * @fires monster-datatable-selection-changed **/ class DataTable extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/datatable@@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} datasource Datasource configuration * @property {string} datasource.selector Selector for the datasource * @property {string} datasource.orderDelimiter Order delimiter * @property {Object} mapping Mapping configuration * @property {string} mapping.data Data mapping * @property {Array} data Data * @property {Array} headers Headers * @property {Object} responsive Responsive configuration * @property {number} responsive.breakpoint Breakpoint for responsive mode * @property {Object} labels Labels * @property {string} labels.theListContainsNoEntries Label for empty state * @property {Object} classes Classes * @property {string} classes.container Container class * @property {Object} features Features * @property {boolean} features.settings Settings feature * @property {boolean} features.footer Footer feature * @property {boolean} features.autoInit Auto init feature (init datasource automatically) * @property {boolean} features.doubleClickCopyToClipboard Double click copy to clipboard feature * @property {boolean} features.copyAll Copy all feature * @property {boolean} features.help Help feature * @property {boolean} features.resizeable Columns are resizable * @property {Object} copy Copy configuration * @property {string} copy.delimiter Delimiter character * @property {string} copy.quoteOpen Quote open character * @property {string} copy.quoteClose Quote close character * @property {string} copy.rowBreak Row break character * @property {Object} updater Updater configuration * @property {boolean} updater.batchUpdates Enables batched updater content/attribute updates * @property {Object} templateMapping Template mapping * @property {string} templateMapping.row-key Row key * @property {string} templateMapping.filter-id Filter id **/ get defaults() { return Object.assign( {}, super.defaults, { templates: { main: getTemplate(), emptyState: getEmptyTemplate(), }, datasource: { selector: null, orderDelimiter: ",", // see initOptionsFromArguments() }, mapping: { data: "dataset", }, data: [], headers: [], responsive: { breakpoint: 1440, }, labels: getTranslations(), classes: { control: "monster-theme-control-container-1", container: "", row: "monster-theme-control-row-1", }, features: { settings: true, footer: true, autoInit: true, doubleClickCopyToClipboard: true, copyAll: true, help: true, resizable: true, }, copy: { delimiter: ";", quoteOpen: '"', quoteClose: '"', rowBreak: "\n", }, updater: { batchUpdates: true, }, templateMapping: { "row-key": null, "filter-id": null, }, }, initOptionsFromArguments.call(this), ); } /** * * @param {string} selector * @return {NodeList} */ getGridElements(selector) { return this[gridElementSymbol].querySelectorAll(selector); } /** * * @return {string} */ static getTag() { return "monster-datatable"; } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); if (this?.[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } const resizeFrameRequest = this?.[resizeFrameRequestSymbol]; if (typeof resizeFrameRequest === "number") { getWindow().cancelAnimationFrame?.(resizeFrameRequest); } this[resizeFrameRequestSymbol] = null; } /** * @return {void} */ connectedCallback() { const self = this; super.connectedCallback(); this[resizeObserverSymbol] = new ResizeObserver((entries) => { scheduleGridUpdate.call(self); }); requestAnimationFrame(() => { let parent = this.parentNode; while (!(parent instanceof HTMLElement) && parent !== null) { parent = parent.parentNode; } if (parent instanceof HTMLElement) { this[resizeObserverSymbol].observe(parent); } }); } /** * Get the row number of the selected rows as an array * * @returns {number[]} */ getSelectedRows() { const rows = this.getGridElements(`[data-monster-role="select-row"]`); const selectedRows = []; rows.forEach((row) => { if (row.checked) { const key = row.parentNode.getAttribute( "data-monster-insert-reference", ); const index = key.split("-").pop(); selectedRows.push(parseInt(index, 10)); } }); return selectedRows; } /** * @return void */ [assembleMethodSymbol]() { const self = this; const rawKey = this.getOption("templateMapping.row-key"); if (rawKey === null) { if (this.id !== null && this.id !== "") { const rawKey = this.getOption("templateMapping.row-key"); if (rawKey === null) { this.setOption("templateMapping.row-key", this.id + "-row"); } } else { this.setOption("templateMapping.row-key", "row"); } } if (this.id !== null && this.id !== "") { this.setOption("templateMapping.filter-id", "" + this.id + "-filter"); } else { this.setOption("templateMapping.filter-id", "filter"); } super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); getSlottedElements .call(this, "[data-monster-role=row-action-button]", "bar") .forEach((i, e) => { if (e instanceof HTMLElement) { e.style.visibility = "hidden"; e.style.width = "max-content"; const pN = e.parentNode; if (pN instanceof HTMLElement) { pN.style.flexGrow = "10"; pN.style.display = "flex"; pN.style.justifyContent = "flex-start"; } } }); getHostConfig .call(this, getColumnVisibilityConfigKey) .then((config) => { const headerOrderMap = new Map(); return getHostConfig .call(this, getStoredOrderConfigKey) .then((orderConfig) => { if (isArray(orderConfig) || orderConfig.length > 0) { for (let i = 0; i < orderConfig.length; i++) { const item = orderConfig[i]; const parts = item.split(" "); const field = parts[0]; const direction = parts[1] || DIRECTION_ASC; headerOrderMap.set(field, direction); } } }) .then(() => { try { initGridAndStructs.call(this, config, headerOrderMap); } catch (error) { addErrorAttribute(this, error); } updateColumnBar.call(this); }); }) .catch((error) => { addErrorAttribute(this, error); }) .finally(() => { const selector = this.getOption("datasource.selector"); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { addErrorAttribute( this, "the selector must match exactly one element", ); return; } waitForCustomElement(element, { readyCheck: (el) => isInstance(el, Datasource), }) .then((readyElement) => { this[datasourceLinkedElementSymbol] = readyElement; queueMicrotask(() => { handleDataSourceChanges.call(this, true); if (readyElement && "datasource" in readyElement) { readyElement.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); } }); }) .catch((error) => { addErrorAttribute(this, error); }); } }); this.attachObserver( new Observer((a) => { const selectAll = this[gridHeadersElementSymbol].querySelector( `[data-monster-role="select-all"]`, ); if (selectAll instanceof HTMLInputElement) { selectAll.checked = false; } getSlottedElements .call(this, "[data-monster-role=row-action-button]", "bar") .forEach((i, e) => { const selected = self.getSelectedRows(); if (e instanceof HTMLElement) { e.style.visibility = "hidden"; } }); const rows = this.getGridElements(`[data-monster-role="select-row"]`); rows.forEach((row) => { row.checked = false; }); }), ); } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [DatatableStyleSheet]; } /** * Copy a row from the datatable * * @param {number|string} fromIndex * @param {number|string} toIndex * @return {DataTable} * @fires monster-datatable-row-copied */ copyRow(fromIndex, toIndex) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c; const mapping = this.getOption("mapping.data"); if (mapping) { rows = c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } if (toIndex === undefined) { toIndex = rows.length; } if (isString(fromIndex)) { fromIndex = parseInt(fromIndex); } if (isString(toIndex)) { toIndex = parseInt(toIndex); } if (toIndex < 0 || toIndex > rows.length) { throw new RangeError("index out of bounds"); } validateArray(rows); validateInteger(fromIndex); validateInteger(toIndex); if (fromIndex < 0 || fromIndex >= rows.length) { throw new RangeError("index out of bounds"); } rows.splice(toIndex, 0, clone(rows[fromIndex])); datasource.data = c; fireCustomEvent(this, "monster-datatable-row-copied", { index: toIndex, }); return this; } /** * Remove a row from the datatable * * @param {number|string} index * @return {DataTable} * @fires monster-datatable-row-removed */ removeRow(index) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c; const mapping = this.getOption("mapping.data"); if (mapping) { rows = c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } if (isString(index)) { index = parseInt(index); } validateArray(rows); validateInteger(index); if (index < 0 || index >= rows.length) { throw new RangeError("index out of bounds"); } if (mapping) { rows = c?.[mapping]; } rows.splice(index, 1); datasource.data = c; fireCustomEvent(this, "monster-datatable-row-removed", { index: index, }); return this; } /** * Add a row to the datatable * * @param {Object} data * @return {DataTable} * * @fires monster-datatable-row-added **/ addRow(data) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c; const mapping = this.getOption("mapping.data"); if (mapping) { rows = c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } validateArray(rows); validateObject(data); rows.push(data); datasource.data = c; fireCustomEvent(this, "monster-datatable-row-added", { index: rows.length - 1, }); return this; } } /** * @private * @return {string} */ function getColumnVisibilityConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "columns-visibility"); } /** * @private * @return {string} */ function getFilterConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "filter"); } /** * @private * @return {Promise} */ function getHostConfig(callback) { const host = findElementWithSelectorUpwards(this, "monster-host"); if (!host) { addErrorAttribute(this, "no host found"); return Promise.resolve({}); } if (!this.id) { addErrorAttribute(this, "no id found; id is required for config"); return Promise.resolve({}); } if (!host || !isFunction(host?.getConfig)) { throw new TypeError("the host must be a monster-host"); } const configKey = callback.call(this); return host.hasConfig(configKey).then((hasConfig) => { if (hasConfig) { return host.getConfig(configKey); } else { return {}; } }); } /** * @private */ function updateColumnBar() { if (!this[columnBarElementSymbol]) { return; } const columns = []; for (const header of this.getOption("headers")) { const mode = header.getInternal("mode"); if (mode === ATTRIBUTE_DATATABLE_MODE_FIXED) { continue; } columns.push({ visible: mode !== ATTRIBUTE_DATATABLE_MODE_HIDDEN, name: header.label, index: header.index, }); } this[suppressColumnConfigSaveSymbol] = true; this[columnBarElementSymbol].setOption("columns", columns); queueMicrotask(() => { this[suppressColumnConfigSaveSymbol] = false; }); } /** * @private */ function updateHeaderFromColumnBar() { if (!this[columnBarElementSymbol]) { return; } const options = this[columnBarElementSymbol].getOption("columns"); if (!isArray(options)) return; const invisibleMap = {}; for (let i = 0; i < options.length; i++) { const option = options[i]; invisibleMap[option.index] = option.visible; } for (const header of this.getOption("headers")) { const mode = header.getInternal("mode"); if (mode === ATTRIBUTE_DATATABLE_MODE_FIXED) { continue; } if (invisibleMap[header.index] === false) { header.setInternal("mode", ATTRIBUTE_DATATABLE_MODE_HIDDEN); } else { header.setInternal("mode", ATTRIBUTE_DATATABLE_MODE_VISIBLE); } } } /** * @private */ function updateConfigColumnBar() { if (!this[columnBarElementSymbol]) { return; } const options = this[columnBarElementSymbol].getOption("columns"); if (!isArray(options)) return; const map = {}; for (let i = 0; i < options.length; i++) { const option = options[i]; map[option.name] = option.visible; } const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(host && this.id)) { return; } const configKey = getColumnVisibilityConfigKey.call(this); try { host.setConfig(configKey, map); } catch (error) { addErrorAttribute(this, error); } } /** * @private * @param {HTMLElement} cell * @return {string} */ function getCellValueForCopy(cell) { if (cell.hasAttribute("data-monster-raw-value")) { return cell.getAttribute("data-monster-raw-value"); } return cell.textContent.trim(); } /** * @private */ function initEventHandler() { const self = this; const tableScrollElement = this[tableScrollElementSymbol]; // --- Column resizing state --- let isResizing = false; let resizingColumnIndex = -1; let startX = 0; let startWidth = 0; // --- Table drag scrolling state --- let isTableDragging = false; let tableDragMoved = false; let tableDragPointerId = null; let tableDragStartX = 0; let tableDragStartY = 0; let tableDragStartScrollLeft = 0; let suppressClickUntil = 0; const isInteractiveDragTarget = (event) => { if ( findTargetElementFromEvent(event, "data-monster-role", "resize-handle") ) { return true; } const path = event.composedPath?.(); if (!isArray(path)) { return false; } for (const node of path) { if (!(node instanceof HTMLElement)) { continue; } if (node === tableScrollElement) { return false; } if ( node.matches( [ "button", "a", "input", "select", "textarea", "label", "summary", "[contenteditable]", "[contenteditable='true']", "[draggable='true']", "[role='button']", "[role='link']", "[role='textbox']", "[role='checkbox']", "[role='switch']", "[role='menuitem']", ].join(","), ) ) { return true; } } return false; }; const stopTableDragging = () => { if (tableScrollElement instanceof HTMLElement) { tableScrollElement.classList.remove("is-dragging"); } getWindow().removeEventListener("pointermove", onTablePointerMove); getWindow().removeEventListener("pointerup", onTablePointerUp); getWindow().removeEventListener("pointercancel", onTablePointerUp); if (tableDragPointerId !== null) { tableScrollElement?.releasePointerCapture?.(tableDragPointerId); } isTableDragging = false; tableDragMoved = false; tableDragPointerId = null; }; const onTablePointerDown = (event) => { if (!(tableScrollElement instanceof HTMLElement)) { return; } if ( (event.pointerType === "mouse" && event.button !== 0) || event.isPrimary === false ) { return; } if (tableScrollElement.scrollWidth <= tableScrollElement.clientWidth) { return; } if (isInteractiveDragTarget(event)) { return; } isTableDragging = true; tableDragMoved = false; tableDragPointerId = event.pointerId; tableDragStartX = event.clientX; tableDragStartY = event.clientY; tableDragStartScrollLeft = tableScrollElement.scrollLeft; tableScrollElement.setPointerCapture?.(event.pointerId); getWindow().addEventListener("pointermove", onTablePointerMove); getWindow().addEventListener("pointerup", onTablePointerUp); getWindow().addEventListener("pointercancel", onTablePointerUp); }; const onTablePointerMove = (event) => { if ( !isTableDragging || tableDragPointerId === null || event.pointerId !== tableDragPointerId || !(tableScrollElement instanceof HTMLElement) ) { return; } const deltaX = event.clientX - tableDragStartX; const deltaY = event.clientY - tableDragStartY; if (!tableDragMoved) { if (Math.abs(deltaX) < 8 || Math.abs(deltaX) <= Math.abs(deltaY)) { return; } tableDragMoved = true; tableScrollElement.classList.add("is-dragging"); } event.preventDefault(); tableScrollElement.scrollLeft = tableDragStartScrollLeft - deltaX; }; const onTablePointerUp = (event) => { if (tableDragPointerId === null || event.pointerId !== tableDragPointerId) { return; } if (tableDragMoved) { suppressClickUntil = Date.now() + 250; } stopTableDragging(); }; const onTableClickCapture = (event) => { if (suppressClickUntil < Date.now()) { return; } event.preventDefault(); event.stopPropagation(); }; tableScrollElement?.addEventListener("pointerdown", onTablePointerDown); tableScrollElement?.addEventListener("click", onTableClickCapture, true); // --- Pointer-based resize handlers (robust for mouse/touch/stylus) --- const onPointerDown = (event) => { // Important: Call helper with (attrName, value) – not with CSS selector const resizeHandle = findTargetElementFromEvent( event, "data-monster-role", "resize-handle", ); if (!resizeHandle) return; event.preventDefault(); isResizing = true; const headerCell = resizeHandle.closest("[data-monster-index]"); resizingColumnIndex = parseInt( headerCell.getAttribute("data-monster-index"), 10, ); startX = event.clientX; startWidth = headerCell.offsetWidth; // Capture to prevent losing events during fast dragging event.target.setPointerCapture?.(event.pointerId); getWindow().addEventListener("pointermove", onPointerMove); getWindow().addEventListener("pointerup", onPointerUp); // Global cursor styling during resizing self.shadowRoot.host.classList.add("is-resizing"); }; const onPointerMove = (event) => { if (!isResizing) return; const deltaX = event.clientX - startX; let newWidth = startWidth + deltaX; const headers = self.getOption("headers"); const resizingHeader = headers[resizingColumnIndex]; const minWidth = 50; const visibleHeaders = headers.filter( (h) => h.getInternal("mode") !== ATTRIBUTE_DATATABLE_MODE_HIDDEN, ); const numOtherVisibleColumns = visibleHeaders.length > 1 ? visibleHeaders.length - 1 : 0; const reservedSpaceForOthers = numOtherVisibleColumns * 50; const tableContainerWidth = self[gridElementSymbol].clientWidth; const maxWidth = tableContainerWidth - reservedSpaceForOthers; newWidth = Math.max(minWidth, Math.min(newWidth, maxWidth)); resizingHeader.setInternal("grid", `${newWidth}px`); updateGrid.call(self); }; const onPointerUp = () => { if (!isResizing) return; isResizing = false; getWindow().removeEventListener("pointermove", onPointerMove); getWindow().removeEventListener("pointerup", onPointerUp); self.shadowRoot.host.classList.remove("is-resizing"); }; // Use pointer events instead of mouse: more resilient this[gridHeadersElementSymbol].addEventListener("pointerdown", onPointerDown); // --- Copy configuration --- const quoteOpenChar = this.getOption("copy.quoteOpen"); const quoteCloseChar = this.getOption("copy.quoteClose"); const delimiterChar = this.getOption("copy.delimiter"); const rowBreak = this.getOption("copy.rowBreak"); const writeTextToClipboard = (text) => { if (getWindow().navigator.clipboard && text) { getWindow() .navigator.clipboard.writeText(text) .then( () => {}, () => {}, ); } }; const getCopyTextForRow = (headCell) => { const index = headCell.getAttribute("data-monster-insert-reference"); if (!index) { return ""; } const cols = self.getGridElements( `[data-monster-insert-reference="${index}"]`, ); const colTexts = []; for (let i = 0; i < cols.length; i++) { const col = cols[i]; if ( col.querySelector("monster-button-bar") || col.querySelector("monster-button") ) { continue; } const cellValue = getCellValueForCopy(col); if (cellValue) { colTexts.push(quoteOpenChar + cellValue + quoteCloseChar); } } return colTexts.join(delimiterChar); }; const getCopyTextForCell = (headCell) => { if ( headCell.querySelector("monster-button-bar") || headCell.querySelector("monster-button") ) { return ""; } return getCellValueForCopy(headCell); }; // --- Column-Bar -> Header visibility & persistence --- if (self[columnBarElementSymbol]) { self[columnBarElementSymbol].attachObserver( new Observer(() => { updateHeaderFromColumnBar.call(self); updateGrid.call(self); if (!self[suppressColumnConfigSaveSymbol]) { updateConfigColumnBar.call(self); } }), ); } // --- Sorting (click on header link) --- self[gridHeadersElementSymbol].addEventListener("click", function (event) { const datasource = self[datasourceLinkedElementSymbol]; if (!datasource) return; const element = findTargetElementFromEvent(event, "data-monster-sortable"); if (element instanceof HTMLElement) { const index = element.parentNode?.getAttribute(ATTRIBUTE_DATATABLE_INDEX); const headers = self.getOption("headers"); event.preventDefault(); headers[index].changeDirection(); queueMicrotask(function () { element.setAttribute( ATTRIBUTE_DATATABLE_SORTABLE, `${headers[index].field} ${headers[index].direction}`, ); storeOrderStatement.call(self, true); }); } }); // --- Copy interactions --- const eventHandlerShiftClickCopyRowToClipboard = (event) => { const headCell = findTargetElementFromEvent(event, "data-monster-head"); if (!headCell || !event.shiftKey) { return; } writeTextToClipboard(getCopyTextForRow(headCell)); }; const eventHandlerDoubleClickCopyToClipboard = (event) => { const headCell = findTargetElementFromEvent(event, "data-monster-head"); if (!headCell || event.shiftKey) { return; } writeTextToClipboard(getCopyTextForCell(headCell)); }; if (self.getOption("features.doubleClickCopyToClipboard")) { self[gridElementSymbol].addEventListener( "click", eventHandlerShiftClickCopyRowToClipboard, ); self[gridElementSymbol].addEventListener( "dblclick", eventHandlerDoubleClickCopyToClipboard, ); } // --- Copy All --- if (self.getOption("features.copyAll") && this[copyAllElementSymbol]) { this[copyAllElementSymbol].addEventListener("click", (event) => { event.preventDefault(); const table = []; let currentRow = []; const cols = self.getGridElements(`[data-monster-insert-reference]`); const rowIndexes = new Map(); cols.forEach((col) => { const index = col.getAttribute("data-monster-insert-reference"); rowIndexes.set(index, true); }); rowIndexes.forEach((_, key) => { const rowCols = self.getGridElements( `[data-monster-insert-reference="${key}"]`, ); for (let i = 0; i < rowCols.length; i++) { const col = rowCols[i]; if ( col.querySelector("monster-button-bar") || col.querySelector("monster-button") ) { continue; } const cellValue = getCellValueForCopy(col); if (cellValue) { currentRow.push(quoteOpenChar + cellValue + quoteCloseChar); } } if (currentRow.length > 0) { table.push(currentRow); } currentRow = []; }); if (table.length > 0) { const text = table.map((row) => row.join(delimiterChar)).join(rowBreak); if (getWindow().navigator.clipboard && text) { getWindow() .navigator.clipboard.writeText(text) .then( () => {}, () => {}, ); } } }); } // --- Row Selection (single) --- const selectRowCallback = (event) => { const checkbox = findTargetElementFromEvent( event, "data-monster-role", "select-row", ); if (!(checkbox instanceof HTMLInputElement)) return; const parentNode = checkbox.parentNode; if (!(parentNode instanceof HTMLDivElement)) return; const key = parentNode.getAttribute("data-monster-insert-reference"); const row = self.getGridElements( `[data-monster-insert-reference="${key}"]`, ); const index = key.split("-").pop(); if (checkbox.checked) { row.forEach((col) => col.classList.add("selected")); fireCustomEvent(self, "monster-datatable-row-selected", { index }); } else { row.forEach((col) => col.classList.remove("selected")); fireCustomEvent(self, "monster-datatable-row-deselected", { index }); } fireCustomEvent(this, "monster-datatable-selection-changed", {}); const rows = self.getGridElements(`[data-monster-role="select-row"]`); const allSelected = Array.from(rows).every((r) => r.checked); const selectAll = this[gridHeadersElementSymbol].querySelector( `[data-monster-role="select-all"]`, ); // Row-Action-Buttons visible/invisible depending on selection getSlottedElements .call(this, "[data-monster-role=row-action-button]", "bar") .forEach((i, e) => { const selected = self.getSelectedRows(); const mode = selected.length === 0 ? "hidden" : "visible"; if (e instanceof HTMLElement) e.style.visibility = mode; }); if (selectAll) selectAll.checked = allSelected; }; this[gridElementSymbol].addEventListener("click", selectRowCallback); this[gridElementSymbol].addEventListener("touch", selectRowCallback); // --- Row Selection (all) --- const selectAllCallback = (event) => { const toggle = findTargetElementFromEvent( event, "data-monster-role", "select-all", ); if (!toggle) return; const mode = toggle.checked; const rows = this.getGridElements(`[data-monster-role="select-row"]`); rows.forEach((row) => { row.checked = mode; }); if (mode) { fireCustomEvent(this, "monster-datatable-all-rows-selected", {}); } else { fireCustomEvent(this, "monster-datatable-all-rows-deselected", {}); } getSlottedElements .call(this, "[data-monster-role=row-action-button]", "bar") .forEach((i, e) => { if (e instanceof HTMLElement) e.style.visibility = mode ? "visible" : "hidden"; }); fireCustomEvent(this, "monster-datatable-selection-changed", {}); }; this[gridHeadersElementSymbol].addEventListener("click", selectAllCallback); this[gridHeadersElementSymbol].addEventListener("touch", selectAllCallback); } /** * @private */ function initGridAndStructs(hostConfig, headerOrderMap) { const rowID = this.getOption("templateMapping.row-key"); if (!this[gridElementSymbol]) { addErrorAttribute(this, "no grid element found"); return; } let template; getSlottedElements.call(this).forEach((e) => { if (e instanceof HTMLTemplateElement && e.id === rowID) { template = e; } }); if (!template) { addErrorAttribute(this, "no template found, please add a template"); return; } const rowCount = template.content.children.length; const headers = []; for (let i = 0; i < rowCount; i++) { let hClass = ""; const row = template.content.children[i]; let mode = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_MODE)) { mode = row.getAttribute(ATTRIBUTE_DATATABLE_MODE); } let grid = row.getAttribute(ATTRIBUTE_DATATABLE_GRID_TEMPLATE); if (!grid || grid === "" || grid === "auto") { grid = "minmax(0, 1fr)"; } let label = ""; let labelKey = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_HEAD)) { label = row.getAttribute(ATTRIBUTE_DATATABLE_HEAD); labelKey = label; try { if (label.startsWith("i18n:")) { label = label.substring(5, label.length); label = getDocumentTranslations().getText(label, label); } } catch (e) { label = "i18n error " + label; } } if (!label) { label = i + 1 + ""; mode = ATTRIBUTE_DATATABLE_MODE_FIXED; labelKey = label; } if (isObject(hostConfig) && hostConfig.hasOwnProperty(label)) { if (hostConfig[label] === false) { mode = ATTRIBUTE_DATATABLE_MODE_HIDDEN; } else { mode = ATTRIBUTE_DATATABLE_MODE_VISIBLE; } } let align = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_ALIGN)) { align = row.getAttribute(ATTRIBUTE_DATATABLE_ALIGN); } switch (align) { case "center": hClass = "flex-center"; break; case "end": hClass = "flex-end"; break; case "start": hClass = "flex-start"; break; default: hClass = "flex-start"; } let field = ""; let direction = DIRECTION_NONE; if (row.hasAttribute(ATTRIBUTE_DATATABLE_SORTABLE)) { field = row.getAttribute(ATTRIBUTE_DATATABLE_SORTABLE).trim(); const parts = field.split(" ").map((item) => item.trim()); field = parts[0]; if (headerOrderMap.has(field)) { direction = headerOrderMap.get(field); } else if ( parts.length === 2 && [DIRECTION_ASC, DIRECTION_DESC].indexOf(parts[1]) !== -1 ) { direction = parts[1]; } } if (mode === ATTRIBUTE_DATATABLE_MODE_HIDDEN) { hClass += " hidden"; } const features = []; if (row.hasAttribute(ATTRIBUTE_DATATABLE_FEATURES)) { const fl = row.getAttribute(ATTRIBUTE_DATATABLE_FEATURES).split(" "); fl.forEach((feature) => { features.push(feature.trim()); if (feature === "select") { label = "<input type='checkbox' data-monster-role='select-all' />"; while (row.firstChild) { row.removeChild(row.firstChild); } const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.setAttribute("data-monster-role", "select-row"); row.appendChild(checkbox); } }); } let orderTemplate = "${field} ${direction}"; if (row.hasAttribute("data-monster-order-template")) { orderTemplate = row.getAttribute("data-monster-order-template"); } const isResizable = this.getOption("features.resizable"); const header = new Header(); header.setInternals({ field: field, label: label, classes: hClass, index: i, mode: mode, grid: grid, labelKey: labelKey + "-" + i, direction: direction, features: features, orderTemplate: orderTemplate, resizable: isResizable, }); headers.push(header); } this.setOption("headers", headers); requestAnimationFrame(() => { storeOrderStatement.call(this, this.getOption("features.autoInit")); const headerElements = this[gridHeadersElementSymbol].querySelectorAll( "[data-monster-index]", ); headerElements.forEach((cell) => { const index = parseInt(cell.getAttribute("data-monster-index"), 10); if (headers[index]) { headers[index].setInternal("initialWidth", cell.offsetWidth); } }); }); } /** * @private */ /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { theListContainsNoEntries: "Die Liste enthält keine Einträge", copyAll: "Alles kopieren", helpText: "<p>Sie können die Werte aus einzelnen Zeilen<br>" + "in die Zwischenablage kopieren, indem Sie auf die entsprechende Spalte doppelklicken.</p>" + "<p>Um eine ganze Zeile zu kopieren, halten Sie die Umschalttaste gedrückt, während Sie klicken.<br>" + "Wenn Sie alle Zeilen kopieren möchten, können Sie die Schaltfläche <strong>Alles kopieren</strong> verwenden.</p>", }; case "fr": return { theListContainsNoEntries: "La liste ne contient aucune entrée", copyAll: "Copier tout", helpText: "<p>Vous pouvez copier les valeurs des rangées individuelles<br>" + "dans le presse-papiers en double-cliquant sur la colonne concernée.</p>" + "<p>Pour copier une rangée entière, maintenez la touche Maj enfoncée tout en cliquant.<br>" + "Si vous souhaitez copier toutes les rangées, vous pouvez utiliser le bouton <strong>Copier tout</strong>.</p>", }; case "sp": return { theListContainsNoEntries: "La lista no contiene entradas", copyAll: "Copiar todo", helpText: "<p>Puedes copiar los valores de filas individuales<br>" + "al portapapeles haciendo doble clic en la columna correspondiente.</p>" + "<p>Para copiar una fila entera, mantén presionada la tecla Shift mientras haces clic.<br>" + "Si quieres copiar todas las filas, puedes usar el botón <strong>Copiar todo</strong>.</p>", }; case "it": return { theListContainsNoEntries: "L'elenco non contiene voci", copyAll: "Copia tutto", helpText: "<p>Puoi copiare i valori dalle singole righe<br>" + "negli appunti facendo doppio clic sulla colonna relativa.</p>" + "<p>Per copiare un'intera riga, tieni premuto il tasto Shift mentre clicchi.<br>" + "Se vuoi copiare tutte le righe, puoi usare il pulsante <strong>Copia tutto</strong>.</p>", }; case "pl": return { theListContainsNoEntries: "Lista nie zawiera wpisów", copyAll: "Kopiuj wszystko", helpText: "<p>Możesz skopiować wartości z poszczególnych wierszy<br>" + "do schowka, klikając dwukrotnie na odpowiednią kolumnę.</p>" + "<p>Aby skopiować cały wiersz, przytrzymaj klawisz Shift podczas klikania.<br>" + "Jeśli chcesz skopiować wszystkie wiersze, możesz użyć przycisku <strong>Kopiuj wszystko</strong>.</p>", }; case "no": return { theListContainsNoEntries: "Listen inneholder ingen oppføringer", copyAll: "Kopier alt", helpText: "<p>Du kan kopiere verdier fra enkeltrader<br>" + "til utklippstavlen ved å dobbeltklikke på den relevante kolonnen.</p>" + "<p>For å kopiere en hel rad, hold nede Skift-tasten mens du klikker.<br>" + "Hvis du vil kopiere alle radene, kan du bruke knappen <strong>Kopier alt</strong>.</p>", }; case "dk": return { theListContainsNoEntries: "Listen indeholder ingen poster", copyAll: "Kopiér alt", helpText: "<p>Du kan kopiere værdier fra enkelte rækker<br>" + "til udklipsholderen ved at dobbeltklikke på den relevante kolonne.</p>" + "<p>For at kopiere en hel række, hold Shift-tasten nede, mens du klikker.<br>" + "Hvis du vil kopiere alle rækker, kan du bruge knappen <strong>Kopiér alt</strong>.</p>", }; case "sw": return { theListContainsNoEntries: "Listan innehåller inga poster", copyAll: "Kopiera allt", helpText: "<p>Du kan kopiera värden från enskilda rader<br>" + "till urklipp genom att dubbelklicka på den relevanta kolumnen.</p>" + "<p>För att kopiera en hel rad, håll ned Shift-tangenten medan du klickar.<br>" + "Om du vill kopiera alla rader kan du använda knappen <strong>Kopiera allt</strong>.</p>", }; case "en": default: return { theListContainsNoEntries: "The list contains no entries", copyAll: "Copy all", helpText: "<p>You can copy the values from individual rows<br>" + "to the clipboard by double-clicking on the relevant column.</p>" + "<p>To copy an entire row, hold down the Shift key while clicking.<br>" + "If you want to copy all rows, you can use the <strong>Copy All</strong> button.</p>", }; } } /** * @private * @return {string} */ export function getStoredOrderConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "stored-order"); } /** * @private */ function storeOrderStatement(doFetch) { const headers = this.getOption("headers"); const delimiter = this.getOption("datasource.orderDelimiter"); const statement = createOrderStatement(headers, delimiter); setDataSource.call(this, { order: statement }, doFetch); const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(host && this.id)) { return; } const configKey = getStoredOrderConfigKey.call(this); // statement explode with , and remove all empty const list = statement.split(",").filter((item) => item.trim() !== ""); if (list.length === 0) { return; } host.setConfig(configKey, list); } /** * @private */ function updateGrid() { if (!this[gridElementSymbol]) { throw new Error("no grid element is defined"); } let gridTemplateColumns = ""; const headers = this.getOption("headers"); let styles = ""; for (let i = 0; i < headers.length; i++) { const header = headers[i]; if ( header.mode === ATTRIBUTE_DATATABLE_MODE_HIDDEN && header.mode !== ATTRIBUTE_DATATABLE_MODE_FIXED ) { styles += `[data-monster-role=datatable-headers]>[data-monster-index="${header.index}"] { display: none; }\n`; styles += `[data-monster-role=datatable] > div:nth-child(${headers.length}n+${i + 1}) { display: none; }\n`; } else { gridTemplateColumns += `${header.grid} `; } } const sheet = new CSSStyleSheet(); if (styles !== "") sheet.replaceSync(styles); this.shadowRoot.adoptedStyleSheets = [...DataTable.getCSSStyleSheet(), sheet]; const bodyWidth = this.parentNode.clientWidth; const breakpoint = this.getOption("responsive.breakpoint"); this[dataControlElementSymbol].classList.toggle( "small", bodyWidth <= breakpoint, ); this[columnBarElementSymbol]?.classList.toggle( "small", bodyWidth <= breakpoint, ); if (bodyWidth > breakpoint) { this[gridElementSymbol].style.gridTemplateColumns = `${gridTemplateColumns}`; this[gridHeadersElementSymbol].style.gridTemplateColumns = `${gridTemplateColumns}`; } else { this[gridElementSymbol].style.gridTemplateColumns = "auto"; this[gridHeadersElementSymbol].style.gridTemplateColumns = "auto"; } } /** * @private * @return {void} */ function scheduleGridUpdate() { if (this[resizeFrameRequestSymbol] !== null && this[resizeFrameRequestSymbol] !== undefined) { return; } this[resizeFrameRequestSymbol] = getWindow().requestAnimationFrame(() => { this[resizeFrameRequestSymbol] = null; updateGrid.call(this); }); } /** * @private * @param {Header[]} headers * @param {bool} doFetch */ function setDataSource({ order }, doFetch) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return; } if (isFunction(datasource?.setParameters)) { datasource.setParameters({ order }); } if (doFetch !== false && isFunction(datasource?.fetch)) { datasource.fetch(); } } /** * @private * @return {DataTable} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[dataControlElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); this[gridElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=datatable]", ); this[gridHeadersElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=datatable-headers]", ); this[columnBarElementSymbol] = this.shadowRoot.querySelector("monster-column-bar"); this[copyAllElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=copy-all]", ); this[tableScrollElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=table-scroll]", ); return this; } /** * @private * @return {object} * @throws {TypeError} incorrect arguments passed for the datasource * @throws {Error} the datasource could not be initialized */ function initOptionsFromArguments() { const options = { datasource: {} }; const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR); if (selector) { options.datasource = { selector: selector }; } options.datasource.orderDelimiter = ","; // workaround for the missing orderDelimiter const breakpoint = this.getAttribute( ATTRIBUTE_DATATABLE_RESPONSIVE_BREAKPOINT, ); if (breakpoint) { options.responsive = {}; options.responsive.breakpoint = parseInt(breakpoint); } return options; } /** * @private * @return {string} */ function getEmptyTemplate() { return `<monster-state data-monster-role="empty-without-action"> <div part="visual"> <svg width="4rem" height="4rem" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path d="m21.5 22h-19c-1.378 0-2.5-1.121-2.5-2.5v-7c0-.07.015-.141.044-.205l3.969-8.82c.404-.896 1.299-1.475 2.28-1.475h11.414c.981 0 1.876.579 2.28 1.475l3.969 8.82c.029.064.044.135.044.205v7c0 1.379-1.122 2.5-2.5 2.5zm-20.5-9.393v6.893c0 .827.673 1.5 1.5 1.5h19c.827 0 1.5-.673 1.5-1.5v-6.893l-3.925-8.723c-.242-.536-.779-.884-1.368-.884h-11.414c-.589 0-1.126.348-1.368.885z"/> <path d="m16.807 17h-9.614c-.622 0-1.186-.391-1.404-.973l-1.014-2.703c-.072-.194-.26-.324-.468-.324h-3.557c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h3.557c.622 0 1.186.391 1.405.973l1.013 2.703c.073.194.261.324.468.324h9.613c.208 0 .396-.13.468-.324l1.013-2.703c.22-.582.784-.973 1.406-.973h3.807c.276 0 .5.224.5.5s-.224.5-.5.5h-3.807c-.208 0-.396.13-.468.324l-1.013 2.703c-.219.582-.784.973-1.405.973z"/> </svg> </div> <div part="content" data-monster-replace="path:labels.theListContainsNoEntries"> The list contains no entries. </div> </monster-state>`; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control" data-monster-attributes="class path:classes.control"> <template id="headers-row"> <div data-monster-attributes="class path:headers-row.classes, data-monster-index path:headers-row.index" data-monster-replace="path:headers-row.html"></div> </template> <slot></slot> <div data-monster-attributes="class path:classes.container" data-monster-role="table-container" part="table-container"> <div class="filter"> <slot name="filter"></slot> </div> <div class="bar"> <monster-context-help data-monster-attributes="class path:features.help | ?::hidden" data-monster-replace="path:labels.helpText" ></monster-context-help> <a href="#" data-monster-attributes="class path:features.copyAll | ?::copyAllHidden" data-monster-role="copy-all" data-monster-replace="path:labels.copyAll">Copy all</a> <monster-column-bar data-monster-attributes="class path:features.settings | ?::hidden"></monster-column-bar> <slot name="bar"></slot> </div> <div data-monster-role="table-scroll"> <div data-monster-role="datatable-headers" data-monster-insert="headers-row path:headers"></div> <div data-monster-replace="path:templates.emptyState" data-monster-attributes="class path:data | has-entries | ?:hidden:empty-state-container"></div> <div data-monster-role="datatable" data-monster-insert="\${row-key} path:data"> </div> </div> </div> <div data-monster-role="footer" data-monster-select-this="true" data-monster-attributes="class path:data | has-entries | ?::hidden"> <slot name="footer" data-monster-attributes="class path:features.footer | ?::hidden"></slot> </div> </div> `; } registerCustomElement(DataTable);