Skip to content
Snippets Groups Projects
Select Git revision
  • 8ede41bd9268ae4c726e0f3973b860ec96bbf5f3
  • master default protected
  • 1.31
  • 4.38.2
  • 4.38.1
  • 4.38.0
  • 4.37.2
  • 4.37.1
  • 4.37.0
  • 4.36.0
  • 4.35.0
  • 4.34.1
  • 4.34.0
  • 4.33.1
  • 4.33.0
  • 4.32.2
  • 4.32.1
  • 4.32.0
  • 4.31.0
  • 4.30.1
  • 4.30.0
  • 4.29.1
  • 4.29.0
23 results

collapse.mjs

Blame
  • dragable-tree-menu.mjs 17.47 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 { Datasource } from "../../data/datasource.mjs";
    import { addAttributeToken } from "../../dom/attributes.mjs";
    import {
    	ATTRIBUTE_DISABLED,
    	ATTRIBUTE_ERRORMESSAGE,
    	ATTRIBUTE_ROLE,
    	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
    } from "../../dom/constants.mjs";
    import {
    	assembleMethodSymbol,
    	CustomElement,
    	initMethodSymbol,
    	registerCustomElement,
    } from "../../dom/customelement.mjs";
    import { findTargetElementFromEvent } from "../../dom/events.mjs";
    import { findElementWithSelectorUpwards } from "../../dom/util.mjs";
    import { Formatter } from "../../text/formatter.mjs";
    import { isObject, isString } from "../../types/is.mjs";
    import { Node } from "../../types/node.mjs";
    import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs";
    import { Observer } from "../../types/observer.mjs";
    import { ProxyObserver } from "../../types/proxyobserver.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 controlElementSymbol = Symbol("controlElement");
    
    /**
     * @private
     * @type {symbol}
     */
    const openEntryEventHandlerSymbol = Symbol("openEntryEventHandler");
    
    /**
     * @private
     * @type {symbol}
     */
    const dragstartEventHandlerSymbol = Symbol("dragstartEventHandler");
    /**
     * @private
     * @type {symbol}
     */
    const dragenterEventHandlerSymbol = Symbol("dragenterEventHandler");
    
    /**
     * @private
     * @type {symbol}
     */
    const dragleaveEventHandlerSymbol = Symbol("dragleaveEventHandler");
    
    /**
     * @private
     * @type {symbol}
     */
    const dragEventHandlerSymbol = Symbol("dragEventHandler");
    
    /**
     * @private
     * @type {symbol}
     */
    const dragoverEventHandlerSymbol = Symbol("dragoverEventHandler");
    
    /**
     * @private
     * @type {symbol}
     */
    const dropEventHandlerSymbol = Symbol("dropEventHandlerSymbol");
    
    /**
     * TreeMenu
     *
     * <img src="./images/tree-menu.png">
     *
     * You can create this control either by specifying the HTML tag `<monster-tree-menu />` directly in the HTML
     *
     * ```html
     * <monster-tree-menu></monster-tree-menu>
     * ```
     *
     * or using Javascript via the `document.createElement('monster-tree-menu');` method.
     *
     * ```javascript
     * import {TreeMenu} from 'https://cdn.jsdelivr.net/npm/@schukai/component-treemenu@0.1.0/dist/modules/treemenu.js';
     * document.createElement('monster-treemenu');
     * ```
     *
     * @startuml tree-menu.png
     * skinparam monochrome true
     * skinparam shadowing false
     * HTMLElement <|-- CustomElement
     * CustomElement <|-- CustomControl
     * CustomControl <|-- TreeMenu
     * @enduml
     * @since 1.0.0
     * @copyright schukai GmbH
     * @memberOf Monster.Components.TreeMenu
     * @summary A TreeMenu control
     * @fires Monster.Components.TreeMenu.event:monster-fetched
     */
    class TreeMenu extends CustomElement {
    	/**
    	 * 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} toggleEventType=click,touch List of event types to be observed for opening the dropdown
    	 * @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=['0', undefined, null]
    	 * @property {String} mapping.idTemplate=id
    	 * @property {String} mapping.parentTemplate=parent
    	 * @property {String} mapping.selection
    	 */
    	get defaults() {
    		return Object.assign(
    			{},
    			super.defaults,
    			{
    				toggleEventType: ["click", "touch"],
    				mapping: {
    					rootReferences: ["0", undefined, null],
    					idTemplate: "id",
    					parentTemplate: "parent",
    					selector: "*",
    					labelTemplate: "",
    					valueTemplate: "",
    					filter: undefined,
    				},
    				templates: {
    					main: getTemplate(),
    				},
    
    				datasource: {
    					selector: null,
    				},
    
    				data: [],
    			},
    			initOptionsFromArguments.call(this),
    		);
    	}
    
    	/**
    	 * This method determines which attributes are to be monitored by `attributeChangedCallback()`.
    	 *
    	 * @return {string[]}
    	 * @since 1.15.0
    	 */
    	static get observedAttributes() {
    		const list = super.observedAttributes;
    		//list.push(ATTRIBUTE_FORM_URL);
    		return list;
    	}
    
    	/**
    	 *
    	 */
    	[initMethodSymbol]() {
    		super[initMethodSymbol]();
    	}
    
    	/**
    	 *
    	 * @return {Monster.Components.TreeMenu.Form}
    	 */
    	[assembleMethodSymbol]() {
    		super[assembleMethodSymbol]();
    
    		initControlReferences.call(this);
    		initEventHandler.call(this);
    		// importEntriesFromDatasource.call(this);
    		initObserver.call(this);
    
    		return 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";
    	}
    }
    
    /**
     * @private
     */
    function initEventHandler() {
    	switchToConfig.call(this);
    
    	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 Datasource)) {
    			throw new TypeError("the element must be a datasource");
    		}
    
    		this[datasourceLinkedElementSymbol] = element;
    		element.datasource.attachObserver(
    			new Observer(handleDataSourceChanges.bind(this)),
    		);
    	}
    
    	this[openEntryEventHandlerSymbol] = (event) => {
    		const container = findTargetElementFromEvent(
    			event,
    			ATTRIBUTE_ROLE,
    			"entry",
    		);
    		if (!(container instanceof HTMLElement)) {
    			return;
    		}
    
    		//let container = findClosestByAttribute(element, ATTRIBUTE_ROLE, 'option');
    		const index = container
    			.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
    			.split("-")
    			.pop();
    
    		const currentState = this.getOption("data." + index + ".state");
    
    		const newState = currentState === "close" ? "open" : "close";
    		this.setOption("data." + 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 &&
    				ref.hasAttribute(ATTRIBUTE_INTEND) &&
    				cmp(parseInt(ref.getAttribute(ATTRIBUTE_INTEND)), childIntend)
    			) {
    				const refIndex = ref
    					.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
    					.split("-")
    					.pop();
    				this.setOption("data." + refIndex + ".visibility", newVisibility);
    
    				if (newState === "close") {
    					this.setOption("data." + refIndex + ".state", "close");
    				}
    
    				ref = ref.nextElementSibling;
    			}
    		}
    	};
    
    	const types = this.getOption("toggleEventType", ["click"]);
    	for (const [, type] of Object.entries(types)) {
    		this.shadowRoot.addEventListener(type, this[openEntryEventHandlerSymbol]);
    	}
    
    	// for (const [, type] of Object.entries(types)) {
    	//
    	//     self[controlElementSymbol].addEventListener(type, function (event) {
    	//
    	//         const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, 'entry');
    	//         if (!(element instanceof HTMLElement)) {
    	//             return;
    	//         }
    	//
    	//         toggle.call(self);
    	//
    	//
    	//     })
    	//
    	// }
    
    	return this;
    }
    
    /**
     * @private
     * @this Form
     */
    function initObserver() {}
    
    /**
     * Import Menu Entries from dataset
     *
     * @since 1.0.0
     * @param {array|object|Map|Set} data
     * @return {TreeMenu}
     * @throws {Error} map is not iterable
     * @private
     */
    function importEntries(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("entries", options);
    	return this;
    }
    
    /**
     * @private
     */
    function importEntriesFromDatasource() {
    	const self = this;
    	self.setAttribute(ATTRIBUTE_DISABLED, ATTRIBUTE_DISABLED);
    
    	const datasource = self.getOption("datasource");
    	if (!(datasource instanceof Datasource)) {
    		addAttributeToken(
    			self,
    			ATTRIBUTE_ERRORMESSAGE,
    			"datasource is not defined",
    		);
    		return;
    	}
    
    	datasource.attachObserver(
    		new Observer(function () {
    			if (isObject(this) && this instanceof ProxyObserver) {
    				importEntries.call(self, datasource.get());
    			}
    		}),
    	);
    
    	datasource
    		.read()
    		.then(() => {
    			new Processing(() => {
    				self.removeAttribute(ATTRIBUTE_DISABLED);
    			}).run();
    		})
    		.catch((e) => {
    			addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, e.toString());
    		});
    
    	return self;
    }
    
    /**
     *
     * @param {Node} node
     * @return {array<label, value>}
     * @memberOf Monster.Components.TreeMenu
     * @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
     * @return {Monster.Components.TreeMenu.Form}
     */
    function initControlReferences() {
    	if (!this.shadowRoot) {
    		throw new Error("no shadow-root is defined");
    	}
    
    	this[controlElementSymbol] = this.shadowRoot.querySelector(
    		"[data-monster-role=control]",
    	);
    
    	return this;
    }
    
    /**
     *
     * ```
     * <monster-tree-menu data-monster-url="https://example.com/"></monster-tree-menu>
     * ```
    
     * @private
     * @return {object}
     */
    function initOptionsFromArguments() {
    	const options = {};
    
    	// let url = self.getAttribute(ATTRIBUTE_FORM_URL);
    	//
    	// if (isString(url)) {
    	//     options['url'] = new URL(url, document.location).toString()
    	// }
    
    	return options;
    }
    
    function switchToConfig() {
    	if (!this.shadowRoot) {
    		throw new Error("no shadow-root is defined");
    	}
    
    	this[dragoverEventHandlerSymbol] = (event) => {
    		const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "entry");
    		event.preventDefault();
    		if (!(element instanceof HTMLElement)) {
    			return;
    		}
    
    		const dropzone = document.createElement("div");
    		dropzone.classList.add("dropzone");
    
    		element.prepend(dropzone);
    
    		console.log("over", element.outerHTML, event);
    
    		event.dataTransfer.dropEffect = "move";
    	};
    
    	this[dragenterEventHandlerSymbol] = (event) => {
    		const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "entry");
    		console.log("enter", element.outerHTML, event);
    
    		event.dataTransfer.dropEffect = "move";
    		event.preventDefault();
    	};
    
    	this[dragleaveEventHandlerSymbol] = (event) => {
    		const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "entry");
    
    		event.preventDefault();
    		if (!(element instanceof HTMLElement)) {
    			return;
    		}
    
    		console.log("leave", element.outerHTML, event);
    
    		event.dataTransfer.dropEffect = "move";
    		event.preventDefault();
    	};
    
    	this[dragEventHandlerSymbol] = (event) => {
    		event.preventDefault();
    	};
    
    	this[dropEventHandlerSymbol] = (event) => {
    		const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "entry");
    		console.log("drop", element.outerHTML, event);
    		event.preventDefault();
    	};
    
    	this[dragstartEventHandlerSymbol] = (event) => {
    		const element = findTargetElementFromEvent(event, ATTRIBUTE_ROLE, "entry");
    		if (!(element instanceof HTMLElement)) {
    			return;
    		}
    
    		//let container = findClosestByAttribute(element, ATTRIBUTE_ROLE, 'option');
    		const index = element
    			.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
    			.split("-")
    			.pop();
    
    		const currentState = this.getOption("entries." + index + ".state");
    		event.dataTransfer.setData("text/plain", "22");
    		event.dataTransfer.setData("text/html", "22");
    		event.dataTransfer.effectAllowed = "move";
    	};
    
    	this[controlElementSymbol].addEventListener(
    		"dragstart",
    		this[dragstartEventHandlerSymbol],
    	);
    	this[controlElementSymbol].addEventListener(
    		"dragenter",
    		this[dragenterEventHandlerSymbol],
    	);
    	this[controlElementSymbol].addEventListener(
    		"dragleave",
    		this[dragleaveEventHandlerSymbol],
    	);
    	this[controlElementSymbol].addEventListener(
    		"dragover",
    		this[dragoverEventHandlerSymbol],
    	);
    	this[controlElementSymbol].addEventListener(
    		"drop",
    		this[dropEventHandlerSymbol],
    	);
    }
    
    // /**
    //  * @private
    //  * @throws {Error} missing default slot
    //  * @throws {Error} no shadow-root is defined
    //  * @throws {Error} missing url
    //  * @throws {Error} we won't be able to read the data
    //  * @throws {Error} request failed
    //  * @throws {Error} not found
    //  * @throws {Error} undefined status or type
    //  * @fires Monster.Components.TreeMenu.event:monster-fetched
    //  */
    // function initIntersectionObserver() {
    //     const self = this;
    //
    //     if (self[intersectionObserverWasInitialized] === true) {
    //         return
    //     }
    //
    //     self[intersectionObserverWasInitialized] = true;
    //
    //     let options = {
    //         threshold: [0.5]
    //     }
    //
    //     const callback = (entries, observer) => {
    //
    //         for (const [, entry] of entries.entries()) {
    //             if (entry.isIntersecting === true) {
    //                 if (!self.hasAttribute(ATTRIBUTE_FORM_RELOAD) || self.getAttribute(ATTRIBUTE_FORM_RELOAD).toLowerCase() === 'onshow') {
    //                     observer.disconnect();
    //                 }
    //
    //                 try {
    //                     loadContent.call(self);
    //                 } catch (e) {
    //                     self.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.toString());
    //                 }
    //
    //
    //             }
    //         }
    //     }
    //
    //     const observer = new IntersectionObserver(callback, options);
    //     observer.observe(self);
    //
    //
    // }
    
    /**
     * @private
     * @return {string}
     */
    function getTemplate() {
    	// language=HTML
    	return `
            <template id="entries">
                <div data-monster-role="entry"
                     draggable="true"
                     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">
    
                    <button data-monster-role="button"
                            data-monster-attributes="
                                        type path:type,
                                        role path:role,
                                        value path:entries.value, 
                                        name path:name, 
                                        part path:type | prefix:option- | suffix: form" tabindex="-1">
                        <span data-monster-role="folder-handler"></span>
                        <span data-monster-replace="path:entries | index:label" part="entry-label"></span>
                    </button>
            </template>
    
            <div data-monster-role="control" part="control">
                <div part="entries" data-monster-role="entries"
                     data-monster-insert="entries path:entries"
                     tabindex="-1"></div>
            </div>
        `;
    }
    
    registerCustomElement(TreeMenu);