/** * Copyright © schukai GmbH 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 schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { buildTree } from "../../data/buildtree.mjs"; import { findClosestByAttribute } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ROLE, ATTRIBUTE_UPDATER_INSERT_REFERENCE, } from "../../dom/constants.mjs"; import { instanceSymbol } from "../../constants.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireCustomEvent, fireEvent, } from "../../dom/events.mjs"; import { Formatter } from "../../text/formatter.mjs"; import { isString } from "../../types/is.mjs"; import { Node } from "../../types/node.mjs"; import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs"; import { validateInstance } from "../../types/validate.mjs"; import { ATTRIBUTE_FORM_URL, ATTRIBUTE_INTEND } from "./constants.mjs"; import { Select } from "./select.mjs"; import { SelectStyleSheet } from "./stylesheet/select.mjs"; import { TreeSelectStyleSheet } from "./stylesheet/tree-select.mjs"; export { TreeSelect, formatHierarchicalSelection }; /** * @private * @type {symbol} */ const internalNodesSymbol = Symbol("internalNodes"); /** * @private * @type {symbol} */ const keyEventHandler = Symbol("keyEventHandler"); /** * A tree select control is a select control that can be used to select a value from a tree structure. * * @fragments /fragments/components/form/tree-select * * @example /examples/components/form/tree-select * * @since 1.9.0 * @copyright schukai GmbH * @summary A beautiful tree select control with a lot of options * @fires monster-options-set * @fires monster-selected * @fires monster-change * @fires monster-changed */ class TreeSelect extends Select { /** * This method is called by the `instanceof` operator. * @return {symbol} * @since 2.1.0 */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/tree-select@@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. * * @extends Monster.Components.Form.Select * @property {String} mapping.rootReferences=['0', undefined, null] * @property {String} mapping.idTemplate=id * @property {String} mapping.parentTemplate=parent * @property {String} mapping.selection * @property {Object} formatter * @property {String} formatter.separator=" / " */ get defaults() { return Object.assign( {}, super.defaults, { mapping: { rootReferences: ["0", undefined, null], idTemplate: "id", parentTemplate: "parent", }, formatter: { selection: formatHierarchicalSelection, separator: " / ", }, templates: { main: getTemplate(), }, }, initOptionsFromArguments.call(this), ); } /** * * @return {string} */ static getTag() { return "monster-tree-select"; } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [SelectStyleSheet, TreeSelectStyleSheet]; } /** * Import Select Options from dataset * Not to be confused with the control defaults/options * * @param {array|object|Map|Set} data * @return {Select} * @throws {Error} map is not iterable */ importOptions(data) { this[internalNodesSymbol] = new Map(); const mappingOptions = this.getOption("mapping", {}); const filter = mappingOptions?.["filter"]; const rootReferences = mappingOptions?.["rootReferences"]; const id = this.getOption("mapping.idTemplate", "id"); const parentID = this.getOption("mapping.parentTemplate", "parent"); const selector = mappingOptions?.["selector"]; const nodes = buildTree(data, selector, id, parentID, { filter, rootReferences, }); 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 intend = n.level; const visibility = intend > 0 ? "hidden" : "visible"; const state = "close"; this[internalNodesSymbol].set(value, n); options.push({ value, label, intend, state, visibility, ["has-children"]: n.hasChildNodes(), }); } } this.setOption("options", options); fireCustomEvent(this, "monster-options-set", { options, }); return this; } /** * * @return {Monster.Components.Form.Select} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initEventHandler.call(this); } } /** * @private * @param event */ function handleOptionKeyboardEvents(event) { switch (event?.["code"]) { case "ArrowLeft": closeOrOpenCurrentOption.call(this, event, "close"); event.preventDefault(); break; case "ArrowRight": closeOrOpenCurrentOption.call(this, event, "open"); event.preventDefault(); break; } } /** * @private * @param {event} event */ function closeOrOpenCurrentOption(event, mode) { validateInstance(event, Event); if (typeof event.composedPath !== "function") { throw new Error("unsupported event"); } const path = event.composedPath(); const optionNode = path.shift(); const state = optionNode.getAttribute("data-monster-state"); if (state !== mode) { const handler = optionNode.querySelector( "[data-monster-role=folder-handler]", ); if (handler instanceof HTMLElement) { fireEvent(handler, "click"); } } } /** * * @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", ""), ); return { value, label, }; } /** * @private * @param {string} value * @return {Array} */ function buildTreeLabels(value) { let node = this[internalNodesSymbol].get(value); if (node === undefined) { node = this[internalNodesSymbol].get(parseInt(value)); } const parts = []; if (node instanceof Node) { let ptr = node; while (ptr) { const formattedValues = formatKeyLabel.call(this, ptr); parts.unshift(formattedValues.label); ptr = ptr.parent; } } return parts; } /** * This formatter can format a label hierarchically. * The option `formatter.separator` determines the separator. * * ``` * a / b / c * ``` * * This function can be passed as argument of the option `formatter.selection:`. * * @since 1.9.0 * @param {*} value * @return {string} */ function formatHierarchicalSelection(value) { return buildTreeLabels .call(this, value) .join(this.getOption("formatter.separator", " / ")); } /** * @private * @type {symbol} */ const openOptionEventHandler = Symbol("openOptionEventHandler"); /** * @private * @throws {Error} no shadow-root is defined */ function initEventHandler() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[openOptionEventHandler] = (event) => { const element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "folder-handler", ); if (!(element instanceof HTMLElement)) { return; } const container = findClosestByAttribute(element, ATTRIBUTE_ROLE, "option"); const index = container .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) .split("-") .pop(); const currentState = this.getOption(`options.${index}.state`); const newState = currentState === "close" ? "open" : "close"; this.setOption(`options.${index}.state`, newState); 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) && cmp(parseInt(ref.getAttribute(ATTRIBUTE_INTEND)), childIntend) ) { const refIndex = ref .getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE) .split("-") .pop(); this.setOption(`options.${refIndex}.visibility`, newVisibility); if (newState === "close") { this.setOption(`options.${refIndex}.state`, "close"); } ref = ref.nextElementSibling; } } }; this[keyEventHandler] = (event) => { const path = event.composedPath(); const element = path?.[0]; let role; if (element instanceof HTMLElement) { if (element.hasAttribute(ATTRIBUTE_ROLE)) { role = element.getAttribute(ATTRIBUTE_ROLE); } else if (element === this) { show.call(this); focusFilter.call(this); } else { const e = element.closest(`[${ATTRIBUTE_ROLE}]`); if (e instanceof HTMLElement && e.hasAttribute()) { role = e.getAttribute(ATTRIBUTE_ROLE); } } } else { return; } switch (role) { case "option-label": case "option-control": case "option": handleOptionKeyboardEvents.call(this, event); break; } }; this.shadowRoot.addEventListener("keydown", this[keyEventHandler]); this.shadowRoot.addEventListener("click", this[openOptionEventHandler]); } /** * This attribute can be used to pass a URL to this select. * * ```html * <monster-select data-monster-url="https://example.com/"></monster-select> * ``` * * @private * @return {object} */ function initOptionsFromArguments() { const options = {}; const url = this.getAttribute(ATTRIBUTE_FORM_URL); if (isString(url)) { options["url"] = new URL(url).toString(); } return options; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="options"> <div data-monster-role="option" tabindex="-1" data-monster-attributes=" data-monster-intend path:options.intend, data-monster-state path:options.state, data-monster-visibility path:options.visibility, data-monster-filtered path:options.filtered, data-monster-has-children path:options.has-children"> <div data-monster-role="folder-handler"></div> <label part="option" role="option"> <input data-monster-role="option-control" data-monster-attributes=" type path:type, role path:role, value path:options.value, name path:name, part path:type | prefix:option- | suffix: form " tabindex="-1"> <span data-monster-replace="path:options | index:label" part="option-label"></span> </label> </div> </template> <template id="selection"> <div data-monster-role="badge" part="badge" data-monster-attributes=" data-monster-value path:selection | index:value, class path:classes | index:badge, part path:type | suffix:-option | prefix: form-" tabindex="-1"> <div data-monster-replace="path:selection | index:label" part="badge-label" data-monster-role="badge-label"></div> <div part="remove-badge" data-monster-select-this data-monster-attributes="class path:features.clear | ?::hidden " data-monster-role="remove-badge" tabindex="-1"></div> </div> </template> <slot class="hidden"></slot> <div data-monster-role="control" part="control" tabindex="0"> <div data-monster-role="container"> \${selected} </div> <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1"> <div class="option-filter-control" role="search"> <input type="text" role="searchbox" part="popper-filter" name="popper-filter" data-monster-role="filter" autocomplete="off" tabindex="0"> </div> <div part="content" class="flex" data-monster-replace="path:content"> <div part="options" data-monster-role="options" data-monster-insert="options path:options" tabindex="-1"></div> </div> <div part="no-options" data-monster-role="no-options" data-monster-replace="path:messages.emptyOptions"></div> </div> <div part="status-or-remove-badges" data-monster-role="status-or-remove-badges" data-monster-attributes="class path:classes.statusOrRemoveBadge | suffix:\\ status-or-remove-badges"></div> </div> `; } registerCustomElement(TreeSelect);