UNPKG

@schukai/monster

Version:

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

637 lines (541 loc) 16.1 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 { buildTree } from "../../data/buildtree.mjs"; import { Datasource } from "../datatable/datasource.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, ATTRIBUTE_UPDATER_INSERT_REFERENCE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, CustomElement, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireEvent } from "../../dom/events.mjs"; import { findElementWithSelectorUpwards } from "../../dom/util.mjs"; import { Formatter } from "../../text/formatter.mjs"; import { isFunction, isString } from "../../types/is.mjs"; import { Node } from "../../types/node.mjs"; import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs"; import { Observer } from "../../types/observer.mjs"; import { validateInstance } from "../../types/validate.mjs"; import { datasourceLinkedElementSymbol, handleDataSourceChanges, } from "../datatable/util.mjs"; import { ATTRIBUTE_INTEND } from "./../constants.mjs"; import { CommonStyleSheet } from "../stylesheet/common.mjs"; import { TreeMenuStyleSheet } from "./stylesheet/tree-menu.mjs"; export { TreeMenu }; /** * @private * @type {symbol} */ const internalNodesSymbol = Symbol("internalNodes"); /** * @private * @type {symbol} */ const preventChangeSymbol = Symbol("preventChangeCounter"); /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("controlElement"); /** * @private * @type {symbol} */ const openEntryEventHandlerSymbol = Symbol("openEntryEventHandler"); /** * @private * @type {symbol} */ const firstRunDoneSymbol = Symbol("firstRunDone"); /** * TreeMenu * * @fragments /fragments/components/tree-menu/tree-menu/ * * @example /examples/components/tree-menu/tree-menu-simple Basic tree menu * * @since 1.0.0 * @summary A TreeMenu control * @fires entries-imported */ class TreeMenu extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/tree-menu@@instance"); } /** */ constructor() { super(); this[preventChangeSymbol] = false; } /** * This method is called internal and should not be called directly. * * The defaults can be set either directly in the object or via an attribute in the HTML tag. * The value of the attribute `data-monster-options` in the HTML tag must be a JSON string. * * ``` * <monster-treemenu data-monster-options="{}"></monster-treemenu> * ``` * * Since 1.18.0 the JSON can be specified as a DataURI. * * ``` * new Monster.Types.DataUrl(btoa(JSON.stringify({ * shadowMode: 'open', * })),'application/json',true).toString() * ``` * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Datasource} datasource data source * @property {Object} mapping * @property {String} mapping.selector Path to select the appropriate entries * @property {String} mapping.labelTemplate template with the label placeholders in the form ${name}, where name is the key * @property {String} mapping.keyTemplate template with the key placeholders in the form ${name}, where name is the key * @property {String} mapping.rootReferences the root references * @property {String} mapping.idTemplate template with the id placeholders in the form ${name}, where name is the key * @property {String} mapping.parentKey the parent key * @property {String} mapping.selection the selection * @property {Function} mapping.filter a filter function to filter the entries * @property {Object} classes * @property {String} classes.control the class for the control element * @property {String} classes.label the class for the label element * @property {Object} actions * @property {Function} actions.open the action to open an entry (arguments, etnry, index, event) * @property {Function} actions.close the action to close an entry (arguments, etnry, index, event) * @property {Function} actions.select the action to select an entry (arguments, etnry, index, event) */ get defaults() { return Object.assign({}, super.defaults, { classes: { control: "monster-theme-primary-1", label: "monster-theme-primary-1", }, mapping: { rootReferences: ["0", undefined, null], idTemplate: "id", parentKey: "parent", selector: "*", labelTemplate: "", valueTemplate: "", iconTemplate: "", filter: undefined, }, templates: { main: getTemplate(), }, datasource: { selector: null, }, actions: { open: null, close: null, select: (entry) => { console.warn("select action is not defined", entry); }, }, updater: { batchUpdates: true, }, data: [], entries: [], }); } /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); queueMicrotask(() => { initControlReferences.call(this); initEventHandler.call(this); initObserver.call(this); copyIconMap.call(this); }); } /** * This method is called internal and should not be called directly. * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [CommonStyleSheet, TreeMenuStyleSheet]; } /** * This method is called internal and should not be called directly. * * @return {string} */ static getTag() { return "monster-tree-menu"; } /** * @param {string} value * @param value */ selectEntry(value) { this.shadowRoot .querySelectorAll("[data-monster-role=entry]") .forEach((entry) => { entry.classList.remove("selected"); }); value = String(value); const entries = this.getOption("entries"); const index = entries.findIndex((entry) => entry.value === value); if (index === -1) { return; } const currentNode = this.shadowRoot.querySelector( "[data-monster-insert-reference=entries-" + index + "]", ); if (!currentNode) { return; } currentNode.click(); let intend = Number.parseInt(currentNode.getAttribute(ATTRIBUTE_INTEND)); if (intend > 0) { const refSet = new Set(); let ref = currentNode.previousElementSibling; while (ref?.hasAttribute(ATTRIBUTE_INTEND)) { const i = Number.parseInt(ref.getAttribute(ATTRIBUTE_INTEND)); if (Number.isNaN(i)) { break; } if (i < intend) { if (ref.getAttribute("data-monster-state") !== "open") { ref.click(); } if (i === 0) { break; } intend = i; } ref = ref.previousElementSibling; } } } } /** * @private */ function copyIconMap() { const nodes = getSlottedElements.call(this, "svg", null); if (nodes.size > 0) { for (const node of nodes) { this.shadowRoot.appendChild(node); } } } /** * @private */ function initEventHandler() { const selector = this.getOption("datasource.selector"); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { throw new Error("the selector must match exactly one element"); } if (!(element instanceof HTMLElement)) { throw new TypeError("the element must be an HTMLElement"); } customElements.whenDefined(element.tagName.toLocaleLowerCase()).then(() => { if (!(element instanceof Datasource)) { throw new TypeError("the element must be a datasource"); } this[datasourceLinkedElementSymbol] = element; handleDataSourceChanges.call(this); element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); this.attachObserver( new Observer(() => { if (this[preventChangeSymbol] === true) { return; } this[preventChangeSymbol] = true; queueMicrotask(() => { importEntries.call(this); }); }), ); }); } this[openEntryEventHandlerSymbol] = (event) => { const container = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "entry", ); if (!(container instanceof HTMLElement)) { return; } const index = container .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) .split("-") .pop(); const currentEntry = this.getOption("entries." + index); if (currentEntry["has-children"] === false) { const doAction = this.getOption("actions.select"); this.shadowRoot .querySelectorAll("[data-monster-role=entry].selected") .forEach((entry) => { entry.classList.remove("selected"); }); let intend = currentEntry.intend; if (intend > 0) { let ref = container.previousElementSibling; while (ref?.hasAttribute(ATTRIBUTE_INTEND)) { const i = Number.parseInt(ref.getAttribute(ATTRIBUTE_INTEND)); if (Number.isNaN(i)) { break; } if (i < intend) { ref.classList.add("selected"); if (i === 0) { break; } intend = i; } ref = ref.previousElementSibling; } } container.classList.add("selected"); if (isFunction(doAction)) { doAction.call(this, currentEntry, index, event); } return; } const currentState = this.getOption("entries." + index + ".state"); const newState = currentState === "close" ? "open" : "close"; const doAction = this.getOption("actions." + newState); if (isFunction(doAction)) { doAction.call(this, this.getOption("entries." + index), index); } const entries = this.getOption("entries", []); const nextEntries = [...entries]; let changed = false; nextEntries[index] = Object.assign({}, currentEntry, { state: newState, }); changed = true; const newVisibility = newState === "open" ? "visible" : "hidden"; if (container.hasAttribute(ATTRIBUTE_INTEND)) { const intend = container.getAttribute(ATTRIBUTE_INTEND); let ref = container.nextElementSibling; const childIntend = parseInt(intend) + 1; const cmp = (a, b) => { if (newState === "open") { return a === b; } return a >= b; }; while (ref?.hasAttribute(ATTRIBUTE_INTEND)) { const refIntend = ref.getAttribute(ATTRIBUTE_INTEND); if (!cmp(Number.parseInt(refIntend), childIntend)) { if (refIntend === intend) { break; } ref = ref.nextElementSibling; continue; } const refIndex = ref .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) .split("-") .pop(); const childIndex = Number.parseInt(refIndex); const childEntry = entries[childIndex]; if (!childEntry) { ref = ref.nextElementSibling; continue; } nextEntries[childIndex] = Object.assign({}, childEntry, { visibility: newVisibility, ...(newState === "close" ? { state: "close" } : {}), }); changed = true; ref = ref.nextElementSibling; } } if (changed === true) { this.setOption("entries", nextEntries); } }; const types = this.getOption("toggleEventType", ["click"]); for (const [, type] of Object.entries(types)) { this.shadowRoot.addEventListener(type, this[openEntryEventHandlerSymbol]); } return this; } /** * @private * @this {TreeMenu} */ function initObserver() {} /** * Import Menu Entries from dataset * * @since 1.0.0 * @return {TreeMenu} * @throws {Error} map is not iterable * @private */ function importEntries() { const data = this.getOption("data"); this[internalNodesSymbol] = new Map(); const mappingOptions = this.getOption("mapping", {}); const filter = mappingOptions?.["filter"]; const rootReferences = mappingOptions?.["rootReferences"]; const id = this.getOption("mapping.idTemplate"); const parentKey = this.getOption("mapping.parentKey"); const selector = mappingOptions?.["selector"]; let filteredData; if (this[firstRunDoneSymbol] !== true) { filteredData = data?.filter( (entry) => !entry[parentKey] || entry[parentKey] === null || entry[parentKey] === undefined || entry[parentKey] === 0, ); setTimeout(() => { this[firstRunDoneSymbol] = true; importEntries.call(this); }, 0); } else { filteredData = data; } let nodes; try { nodes = buildTree(filteredData, selector, id, parentKey, { filter, rootReferences, }); } catch (error) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, String(error)); return this; } const options = []; for (const node of nodes) { const iterator = new NodeRecursiveIterator(node); for (const n of iterator) { const formattedValues = formatKeyLabel.call(this, n); const label = formattedValues?.label; const value = formattedValues?.value; const icon = formattedValues?.icon; const intend = n.level; const visibility = intend > 0 ? "hidden" : "visible"; const state = "close"; this[internalNodesSymbol].set(value, n); options.push({ value, label, icon, intend, state, visibility, ["has-children"]: n.hasChildNodes(), }); } } this.setOption("entries", options); fireEvent(this, "entries-imported"); return this; } /** * * @param {Node} node * @return {array<label, value>} * @private */ function formatKeyLabel(node) { validateInstance(node, Node); const label = new Formatter(node.value).format( this.getOption("mapping.labelTemplate"), ); const value = new Formatter(node.value).format( this.getOption("mapping.valueTemplate"), ); const iconID = new Formatter(node.value).format( this.getOption("mapping.iconTemplate"), ); const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><use xlink:href="#${iconID}"></use></svg>`; return { value, label, icon, }; } /** * @private * @return {TreeMenu} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[controlElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); return this; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <slot></slot> <template id="entries"> <div data-monster-role="entry" data-monster-attributes=" data-monster-intend path:entries.intend, data-monster-state path:entries.state, data-monster-visibility path:entries.visibility, data-monster-filtered path:entries.filtered, data-monster-has-children path:entries.has-children"> <div data-monster-role="button" data-monster-attributes=" value path:entries.value | tostring " tabindex="0"> <div data-monster-role="status-badges"></div> <div data-monster-role="icon" data-monster-replace="path:entries.icon"> </div> <div data-monster-replace="path:entries.label" part="entry-label" data-monster-attributes="class static:id"></div> </div> </template> <div data-monster-role="control" part="control" data-monster-attributes="class path:classes.control"> <div part="entries" data-monster-role="entries" data-monster-insert="entries path:entries" tabindex="-1"></div> </div> `; } registerCustomElement(TreeMenu);