Skip to content
Snippets Groups Projects
Select Git revision
  • c107f9fa09aafee0cb4513fd8e61699b1eb87e90
  • master default protected
  • 1.31
  • 4.24.2
  • 4.24.1
  • 4.24.0
  • 4.23.6
  • 4.23.5
  • 4.23.4
  • 4.23.3
  • 4.23.2
  • 4.23.1
  • 4.23.0
  • 4.22.3
  • 4.22.2
  • 4.22.1
  • 4.22.0
  • 4.21.0
  • 4.20.1
  • 4.20.0
  • 4.19.0
  • 4.18.0
  • 4.17.0
23 results

api-button.mjs

Blame
  • tree-select.mjs 13.46 KiB
    /**
     * 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);