Something went wrong on our end
Select Git revision
database.go
-
Volker Schukai authoredVolker Schukai authored
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);