@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
637 lines (541 loc) • 16.1 kB
JavaScript
/**
* 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);