Select Git revision
collapse.mjs
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);