@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,379 lines (1,242 loc) • 33.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 { addAttributeToken } from "../../dom/attributes.mjs";
import {
ATTRIBUTE_ERRORMESSAGE,
ATTRIBUTE_ROLE,
ATTRIBUTE_UPDATER_INSERT_REFERENCE,
} from "../../dom/constants.mjs";
import {
assembleMethodSymbol,
CustomElement,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent } from "../../dom/events.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { isFunction, isString } from "../../types/is.mjs";
import { ID } from "../../types/id.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { TreeMenuStyleSheet } from "./stylesheet/tree-menu.mjs";
import { ATTRIBUTE_INTEND } from "./../constants.mjs";
export { HtmlTreeMenu };
/**
* @private
* @type {symbol}
*/
const entryIndexSymbol = Symbol("entryIndex");
/**
* @private
* @type {symbol}
*/
const openEntryEventHandlerSymbol = Symbol("openEntryEventHandler");
/**
* HtmlTreeMenu
*
* @fragments /fragments/components/tree-menu/html-tree-menu/
*
* @example /examples/components/tree-menu/html-tree-menu-simple Basic HTML tree menu
* @example /examples/components/tree-menu/html-tree-menu-lazy Lazy loading
*
* @since 4.62.0
* @summary A TreeMenu control that builds its entries from nested HTML lists.
* @fires entries-imported
*/
class HtmlTreeMenu extends CustomElement {
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/tree-menu/html@@instance");
}
/**
* @property {Object} templates Template definitions
* @property {string} templates.main Main template
* @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} lazy
* @property {boolean} lazy.enabled enables lazy loading by endpoint
* @property {string} lazy.attribute="data-monster-endpoint" attribute for the endpoint
* @property {Object} lazy.fetchOptions fetch options for lazy requests
* @property {Object} features
* @property {boolean} features.selectParents=false allow selecting entries with children
* @property {Object} actions
* @property {Function} actions.open the action to open an entry (entry, index, event)
* @property {Function} actions.close the action to close an entry (entry, index, event)
* @property {Function} actions.select the action to select an entry (entry, index, event)
* @property {Function} actions.onexpand the action to expand an entry (entry, index, event)
* @property {Function} actions.oncollapse the action to collapse an entry (entry, index, event)
* @property {Function} actions.onselect the action to select an entry (entry, index, event)
* @property {Function} actions.onnavigate the action to navigate (entry, index, event)
* @property {Function} actions.onlazyload the action before lazy load (entry, index, event)
* @property {Function} actions.onlazyloaded the action after lazy load (entry, index, event)
* @property {Function} actions.onlazyerror the action on lazy error (entry, index, event)
*/
get defaults() {
return Object.assign({}, super.defaults, {
classes: {
control: "monster-theme-primary-1",
label: "monster-theme-primary-1",
},
lazy: {
enabled: true,
attribute: "data-monster-endpoint",
fetchOptions: {
method: "GET",
},
},
features: {
selectParents: false,
},
templates: {
main: getTemplate(),
},
actions: {
open: null,
close: null,
select: (entry) => {
console.warn("select action is not defined", entry);
},
onexpand: null,
oncollapse: null,
onselect: null,
onnavigate: null,
onlazyload: null,
onlazyloaded: null,
onlazyerror: null,
},
updater: {
batchUpdates: true,
},
entries: [],
});
}
/**
* @return {void}
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
this[entryIndexSymbol] = new Map();
initEventHandler.call(this);
importEntries.call(this);
}
/**
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [CommonStyleSheet, TreeMenuStyleSheet];
}
/**
* @return {string}
*/
static getTag() {
return "monster-html-tree-menu";
}
/**
* Select an entry by value.
*
* @param {string} value
* @return {void}
*/
selectEntry(value) {
this.shadowRoot
.querySelectorAll("[data-monster-role=entry]")
.forEach((entry) => {
entry.classList.remove("selected");
});
value = String(value);
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
const currentNode = this.shadowRoot.querySelector(
"[data-monster-insert-reference=entries-" + index + "]",
);
if (!currentNode) {
return;
}
const allowParentSelect = this.getOption("features.selectParents") === true;
if (currentEntry?.["has-children"] === true && allowParentSelect) {
applySelection.call(this, currentEntry, Number(index), currentNode);
return;
}
currentNode.click();
}
/**
* Find an entry by value.
*
* @param {string} value
* @return {Object|null}
*/
findEntry(value) {
const index = findEntryIndex.call(this, String(value));
if (index === -1) {
return null;
}
return {
entry: this.getOption("entries." + index),
index,
node: getEntryNode.call(this, index),
};
}
/**
* Open a node by value.
*
* @param {string} value
* @return {void}
*/
openEntry(value) {
toggleEntryState.call(this, String(value), "open");
}
/**
* Expand a node and all its descendants by value.
*
* @param {string} value
* @return {void}
*/
expandEntry(value) {
expandEntry.call(this, String(value));
}
/**
* Collapse a node and all its descendants by value.
*
* @param {string} value
* @return {void}
*/
collapseEntry(value) {
collapseEntry.call(this, String(value));
}
/**
* Close a node by value.
*
* @param {string} value
* @return {void}
*/
closeEntry(value) {
toggleEntryState.call(this, String(value), "close");
}
/**
* Show a node by value.
*
* @param {string} value
* @return {void}
*/
showEntry(value) {
setEntryVisibility.call(this, String(value), "visible");
}
/**
* Hide a node by value.
*
* @param {string} value
* @return {void}
*/
hideEntry(value) {
setEntryVisibility.call(this, String(value), "hidden");
}
/**
* Remove a node by value.
*
* @param {string} value
* @return {void}
*/
removeEntry(value) {
removeEntry.call(this, String(value));
}
/**
* Insert a node.
*
* @param {Object} entry
* @param {string|null} parentValue
* @return {void}
*/
insertEntry(entry, parentValue = null) {
insertEntry.call(this, entry, parentValue);
}
/**
* Insert a node before a reference entry.
*
* @param {Object} entry
* @param {string} referenceValue
* @return {void}
*/
insertEntryBefore(entry, referenceValue) {
insertEntryAt.call(this, entry, String(referenceValue), "before");
}
/**
* Insert a node after a reference entry.
*
* @param {Object} entry
* @param {string} referenceValue
* @return {void}
*/
insertEntryAfter(entry, referenceValue) {
insertEntryAt.call(this, entry, String(referenceValue), "after");
}
}
/**
* @private
*/
function initEventHandler() {
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);
const allowParentSelect = this.getOption("features.selectParents") === true;
if (currentEntry["has-children"] === false) {
const href = currentEntry.href;
const isNavigation = isString(href) && href !== "";
const doNavigate = getAction.call(this, ["onnavigate", "navigate"]);
if (isNavigation) {
let allowNavigation = true;
if (isFunction(doNavigate)) {
const result = doNavigate.call(this, currentEntry, index, event);
if (result === false) {
allowNavigation = false;
}
}
const navEvent = dispatchEntryEvent.call(
this,
"monster-html-tree-menu-navigate",
{
entry: currentEntry,
index,
event,
},
);
if (navEvent.defaultPrevented) {
allowNavigation = false;
}
if (!allowNavigation) {
event.preventDefault();
return;
}
if (isAnchorEvent(event) === false) {
window.location.assign(href);
}
return;
}
applySelection.call(this, currentEntry, Number(index), container, event);
return;
}
const currentState = this.getOption("entries." + index + ".state");
const newState = currentState === "close" ? "open" : "close";
if (newState === "open") {
const entry = this.getOption("entries." + index);
if (shouldLazyLoad.call(this, entry)) {
void ensureEntryLoaded
.call(this, Number(index), event)
.then((loaded) => {
if (loaded) {
applyEntryState.call(this, Number(index), newState, event);
}
});
return;
}
}
applyEntryState.call(this, Number(index), newState, event);
if (allowParentSelect) {
applySelection.call(this, currentEntry, Number(index), container, event);
}
};
const types = this.getOption("toggleEventType", ["click"]);
for (const [, type] of Object.entries(types)) {
this.shadowRoot.addEventListener(type, this[openEntryEventHandlerSymbol]);
}
}
/**
* Import menu entries from HTML list.
*
* @private
*/
function importEntries() {
const rootList = this.querySelector("ul,ol");
if (!(rootList instanceof HTMLElement)) {
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "no list found");
return;
}
rootList.setAttribute("hidden", "");
rootList.classList.add("hidden");
const entries = [];
buildEntriesFromList.call(this, rootList, 0, false, entries);
this.setOption("entries", entries);
rebuildEntryIndex.call(this);
}
/**
* @private
* @param {HTMLElement} list
* @param {number} level
* @param {boolean} ancestorHidden
* @param {Array} entries
*/
function buildEntriesFromList(list, level, ancestorHidden, entries) {
const lazyConfig = this.getOption("lazy", {});
const lazyEnabled = lazyConfig?.enabled !== false;
const lazyAttribute = lazyConfig?.attribute || "";
const items = Array.from(list.children).filter((node) => node.matches("li"));
for (const li of items) {
const childList = li.querySelector(":scope > ul,:scope > ol");
const hasChildList =
childList instanceof HTMLElement &&
Array.from(childList.children).some((node) => node.matches("li"));
const endpoint =
lazyEnabled && isString(lazyAttribute) && lazyAttribute !== ""
? li.getAttribute(lazyAttribute)
: null;
const hasLazyEndpoint = isString(endpoint) && endpoint !== "";
const hasChildren = hasChildList || hasLazyEndpoint;
const label = getLabelHtml(li, childList);
const value = getEntryValue(li);
const href = getEntryHref(li, childList);
const liHidden = isHiddenElement(li);
const childHidden = childList ? isHiddenElement(childList) : false;
let state = "close";
if (hasChildren && !(childHidden || hasLazyEndpoint)) {
state = "open";
}
const visibility = ancestorHidden || liHidden ? "hidden" : "visible";
entries.push({
value,
label,
icon: "",
intend: level,
state,
visibility,
["has-children"]: hasChildren,
href,
["lazy-endpoint"]: hasLazyEndpoint ? endpoint : null,
["lazy-loaded"]: hasLazyEndpoint ? hasChildList : true,
["lazy-loading"]: false,
});
if (hasChildList) {
buildEntriesFromList.call(
this,
childList,
level + 1,
ancestorHidden || liHidden || state === "close",
entries,
);
}
}
}
/**
* @private
* @param {HTMLElement} element
* @return {boolean}
*/
function isHiddenElement(element) {
return (
element.hasAttribute("hidden") ||
element.classList.contains("hidden") ||
element.classList.contains("hide") ||
element.classList.contains("none")
);
}
/**
* @private
* @param {HTMLElement} li
* @param {HTMLElement|null} childList
* @return {string}
*/
function getLabelHtml(li, childList) {
const clone = li.cloneNode(true);
if (childList) {
const nested = clone.querySelector("ul,ol");
if (nested) {
nested.remove();
}
}
const html = clone.innerHTML.trim();
if (html !== "") {
return html;
}
return li.textContent.trim();
}
/**
* @private
* @param {HTMLElement} li
* @param {HTMLElement|null} childList
* @return {string|null}
*/
function getEntryHref(li, childList) {
const clone = li.cloneNode(true);
if (childList) {
const nested = clone.querySelector("ul,ol");
if (nested) {
nested.remove();
}
}
const anchor = clone.querySelector("a[href]");
const href = anchor?.getAttribute("href") || li.getAttribute("href");
if (isString(href) && href !== "") {
return href;
}
return null;
}
/**
* @private
* @param {HTMLElement} li
* @return {string}
*/
function getEntryValue(li) {
const value =
li.getAttribute("data-monster-value") ||
li.getAttribute("data-value") ||
li.getAttribute("id");
if (isString(value) && value !== "") {
return value;
}
return new ID().toString();
}
/**
* @private
* @param {string} value
* @return {number}
*/
function findEntryIndex(value) {
if (this[entryIndexSymbol].has(value)) {
return this[entryIndexSymbol].get(value);
}
const entries = this.getOption("entries", []);
return entries.findIndex((entry) => String(entry.value) === value);
}
/**
* @private
*/
function rebuildEntryIndex() {
this[entryIndexSymbol].clear();
const entries = this.getOption("entries", []);
for (let i = 0; i < entries.length; i += 1) {
this[entryIndexSymbol].set(String(entries[i].value), i);
}
}
/**
* @private
* @param {number} index
* @return {HTMLElement|null}
*/
function getEntryNode(index) {
return this.shadowRoot.querySelector(
`[data-monster-insert-reference=entries-${index}]`,
);
}
/**
* @private
* @param {Object} entry
* @return {Object}
*/
function normalizeEntry(entry) {
const rawEndpoint =
entry?.endpoint || entry?.["lazy-endpoint"] || entry?.lazyEndpoint;
const endpoint =
isString(rawEndpoint) && rawEndpoint !== "" ? rawEndpoint : null;
const hasChildren = entry?.["has-children"] === true || endpoint !== null;
return {
value: String(entry?.value || new ID().toString()),
label: entry?.label || "",
icon: entry?.icon || "",
href: entry?.href || null,
intend: 0,
state: "close",
visibility: "visible",
["has-children"]: hasChildren,
["lazy-endpoint"]: endpoint,
["lazy-loaded"]: endpoint === null,
["lazy-loading"]: false,
};
}
/**
* @private
* @param {Array} entries
* @param {number} index
* @return {number}
*/
function getSubtreeEndIndex(entries, index) {
const targetIntend = entries[index].intend;
let end = index + 1;
while (end < entries.length && entries[end].intend > targetIntend) {
end += 1;
}
return end;
}
/**
* @private
* @param {string} value
* @param {string} state
*/
function toggleEntryState(value, state) {
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
if (state === "open") {
const entry = this.getOption("entries." + index);
if (shouldLazyLoad.call(this, entry)) {
void ensureEntryLoaded.call(this, index).then((loaded) => {
if (loaded) {
applyEntryState.call(this, index, state);
}
});
return;
}
}
applyEntryState.call(this, index, state);
}
/**
* @private
* @param {number} index
* @param {string} state
* @param {Event|undefined} event
*/
function applyEntryState(index, state, event) {
const entry = this.getOption("entries." + index);
if (!entry || entry["has-children"] === false) {
return;
}
const actionName = state === "open" ? "onexpand" : "oncollapse";
const doAction = getAction.call(this, [actionName, state]);
if (isFunction(doAction)) {
doAction.call(this, entry, index, event);
}
const eventName =
state === "open"
? "monster-html-tree-menu-expand"
: "monster-html-tree-menu-collapse";
fireCustomEvent(this, eventName, {
entry,
index,
event,
});
const entries = this.getOption("entries", []);
const nextEntries = [...entries];
let changed = false;
nextEntries[index] = Object.assign({}, entry, {
state,
});
changed = true;
const newVisibility = state === "open" ? "visible" : "hidden";
const targetIntend = entry.intend;
const end = getSubtreeEndIndex(entries, index);
const childIntend = targetIntend + 1;
for (let i = index + 1; i < end; i += 1) {
const childEntry = entries[i];
if (state === "open") {
if (childEntry.intend !== childIntend) {
continue;
}
if (childEntry.visibility === newVisibility) {
continue;
}
} else {
if (childEntry.visibility === newVisibility) {
continue;
}
}
const updates = { visibility: newVisibility };
if (state === "close" && childEntry["has-children"]) {
updates.state = "close";
}
nextEntries[i] = Object.assign({}, childEntry, updates);
changed = true;
}
if (changed === true) {
this.setOption("entries", nextEntries);
}
}
/**
* @private
* @param {string} value
* @param {string} visibility
*/
function setEntryVisibility(value, visibility) {
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
const entries = this.getOption("entries", []);
const target = entries[index];
if (!target) {
return;
}
const nextEntries = [...entries];
let changed = false;
if (target.visibility !== visibility) {
nextEntries[index] = Object.assign({}, target, {
visibility,
});
changed = true;
}
const end = getSubtreeEndIndex(entries, index);
for (let i = index + 1; i < end; i += 1) {
if (visibility === "hidden") {
if (entries[i].visibility !== "hidden") {
nextEntries[i] = Object.assign({}, entries[i], {
visibility: "hidden",
});
changed = true;
}
}
}
if (changed === true) {
this.setOption("entries", nextEntries);
}
}
/**
* @private
* @param {string} value
*/
function expandEntry(value) {
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
const entry = this.getOption("entries." + index);
if (!entry || entry["has-children"] === false) {
return;
}
if (shouldLazyLoad.call(this, entry)) {
void ensureEntryLoaded.call(this, index).then((loaded) => {
if (loaded) {
expandEntryByIndex.call(this, index);
}
});
return;
}
expandEntryByIndex.call(this, index);
}
/**
* @private
* @param {string} value
*/
function collapseEntry(value) {
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
const entry = this.getOption("entries." + index);
if (!entry || entry["has-children"] === false) {
return;
}
if (entry.state === "close") {
return;
}
const doAction = getAction.call(this, ["oncollapse", "close"]);
if (isFunction(doAction)) {
doAction.call(this, entry, index);
}
fireCustomEvent(this, "monster-html-tree-menu-collapse", {
entry,
index,
});
const entries = this.getOption("entries", []);
const nextEntries = [...entries];
let changed = false;
nextEntries[index] = Object.assign({}, entries[index], {
state: "close",
});
changed = true;
const end = getSubtreeEndIndex(entries, index);
for (let i = index + 1; i < end; i += 1) {
const childEntry = entries[i];
const hasChildren = childEntry["has-children"] === true;
if (
childEntry.visibility === "hidden" &&
(!hasChildren || childEntry.state === "close")
) {
continue;
}
const updates = {
visibility: "hidden",
};
if (hasChildren && childEntry.state !== "close") {
updates.state = "close";
}
const updatedChild = Object.assign({}, childEntry, updates);
nextEntries[i] = updatedChild;
changed = true;
}
if (changed === true) {
this.setOption("entries", nextEntries);
}
}
/**
* @private
* @param {string} value
*/
function removeEntry(value) {
const entries = this.getOption("entries", []);
const index = findEntryIndex.call(this, value);
if (index === -1) {
return;
}
const targetIntend = entries[index].intend;
let parentIndex = -1;
for (let i = index - 1; i >= 0; i -= 1) {
if (entries[i].intend < targetIntend) {
parentIndex = i;
break;
}
}
const newEntries = [];
for (let i = 0; i < entries.length; i += 1) {
if (i === index) {
continue;
}
if (i > index && entries[i].intend > targetIntend) {
continue;
}
newEntries.push(entries[i]);
}
if (parentIndex !== -1) {
const parentValue = String(entries[parentIndex].value);
const parentEntryIndex = newEntries.findIndex(
(entry) => String(entry.value) === parentValue,
);
if (parentEntryIndex !== -1) {
const parentIntend = newEntries[parentEntryIndex].intend;
const hasChildren = newEntries.some(
(entry, idx) => idx > parentEntryIndex && entry.intend > parentIntend,
);
if (!hasChildren) {
newEntries[parentEntryIndex] = Object.assign(
{},
newEntries[parentEntryIndex],
{
["has-children"]: false,
state: "close",
},
);
}
}
}
this.setOption("entries", newEntries);
rebuildEntryIndex.call(this);
}
/**
* @private
* @param {Object} entry
* @param {string|null} parentValue
*/
function insertEntry(entry, parentValue) {
const entries = this.getOption("entries", []);
const newEntry = normalizeEntry(entry);
let insertIndex = entries.length;
if (isString(parentValue) && parentValue !== "") {
const parentIndex = findEntryIndex.call(this, parentValue);
if (parentIndex !== -1) {
const parent = entries[parentIndex];
newEntry.intend = parent.intend + 1;
newEntry.visibility = parent.state === "open" ? "visible" : "hidden";
entries[parentIndex] = Object.assign({}, parent, {
["has-children"]: true,
});
insertIndex = parentIndex + 1;
while (
insertIndex < entries.length &&
entries[insertIndex].intend > parent.intend
) {
insertIndex += 1;
}
}
}
const nextEntries = [
...entries.slice(0, insertIndex),
newEntry,
...entries.slice(insertIndex),
];
this.setOption("entries", nextEntries);
rebuildEntryIndex.call(this);
}
/**
* @private
* @param {Object} entry
* @param {string} referenceValue
* @param {string} position
*/
function insertEntryAt(entry, referenceValue, position) {
const entries = this.getOption("entries", []);
const referenceIndex = findEntryIndex.call(this, referenceValue);
if (referenceIndex === -1) {
insertEntry.call(this, entry, null);
return;
}
const referenceEntry = entries[referenceIndex];
const newEntry = normalizeEntry(entry);
newEntry.intend = referenceEntry.intend;
newEntry.visibility = referenceEntry.visibility;
let parentIndex = -1;
for (let i = referenceIndex - 1; i >= 0; i -= 1) {
if (entries[i].intend < referenceEntry.intend) {
parentIndex = i;
break;
}
}
if (parentIndex !== -1) {
entries[parentIndex] = Object.assign({}, entries[parentIndex], {
["has-children"]: true,
});
}
let insertIndex = referenceIndex;
if (position === "after") {
insertIndex = getSubtreeEndIndex(entries, referenceIndex);
}
const nextEntries = [
...entries.slice(0, insertIndex),
newEntry,
...entries.slice(insertIndex),
];
this.setOption("entries", nextEntries);
rebuildEntryIndex.call(this);
}
/**
* @private
* @param {number} index
*/
function expandEntryByIndex(index) {
const entry = this.getOption(`entries.${index}`);
if (!entry || entry["has-children"] === false) {
return;
}
const doAction = getAction.call(this, ["onexpand", "open"]);
if (isFunction(doAction)) {
doAction.call(this, entry, index);
}
fireCustomEvent(this, "monster-html-tree-menu-expand", {
entry,
index,
});
const entries = this.getOption("entries", []);
const nextEntries = [...entries];
let changed = false;
nextEntries[index] = Object.assign({}, entry, {
state: "open",
visibility: "visible",
});
changed = true;
const end = getSubtreeEndIndex(entries, index);
for (let i = index + 1; i < end; i += 1) {
const childEntry = entries[i];
const changedVisibility = childEntry.visibility !== "visible";
const changedState =
childEntry["has-children"] && childEntry.state !== "open";
if (!changedVisibility && !changedState) {
continue;
}
const updatedChild = Object.assign({}, childEntry, {
visibility: "visible",
});
if (childEntry["has-children"]) {
updatedChild.state = "open";
}
nextEntries[i] = updatedChild;
changed = true;
}
if (changed === true) {
this.setOption("entries", nextEntries);
}
}
/**
* @private
* @param {Object} entry
* @return {boolean}
*/
function shouldLazyLoad(entry) {
if (!entry) {
return false;
}
const lazyConfig = this.getOption("lazy", {});
if (lazyConfig?.enabled === false) {
return false;
}
const endpoint = entry["lazy-endpoint"];
return (
isString(endpoint) &&
endpoint !== "" &&
entry["lazy-loaded"] !== true &&
entry["lazy-loading"] !== true
);
}
/**
* @private
* @param {number} index
* @param {Event|undefined} event
* @return {Promise<boolean>}
*/
async function ensureEntryLoaded(index, event) {
const entry = this.getOption(`entries.${index}`);
if (!shouldLazyLoad.call(this, entry)) {
return true;
}
const beforeLoadAction = getAction.call(this, ["onlazyload"]);
if (isFunction(beforeLoadAction)) {
beforeLoadAction.call(this, entry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-lazy-load", {
entry,
index,
event,
});
this.setOption(`entries.${index}.lazy-loading`, true);
const endpoint = entry["lazy-endpoint"];
const lazyConfig = this.getOption("lazy", {});
const fetchOptions = Object.assign(
{
method: "GET",
},
lazyConfig?.fetchOptions || {},
);
let response = null;
try {
response = await fetch(endpoint, fetchOptions);
if (!response.ok) {
throw new Error("failed to load lazy entry");
}
} catch (e) {
this.setOption(`entries.${index}.lazy-loading`, false);
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
const errorAction = getAction.call(this, ["onlazyerror"]);
if (isFunction(errorAction)) {
errorAction.call(this, entry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
entry,
index,
event,
});
return false;
}
let html = "";
try {
html = await response.text();
} catch (e) {
this.setOption(`entries.${index}.lazy-loading`, false);
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
const errorAction = getAction.call(this, ["onlazyerror"]);
if (isFunction(errorAction)) {
errorAction.call(this, entry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
entry,
index,
event,
});
return false;
}
const list = parseLazyList(html);
if (!list) {
this.setOption(`entries.${index}.lazy-loading`, false);
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "lazy entry has no list");
const errorAction = getAction.call(this, ["onlazyerror"]);
if (isFunction(errorAction)) {
errorAction.call(this, entry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
entry,
index,
event,
});
return false;
}
const childEntries = [];
buildEntriesFromList.call(this, list, entry.intend + 1, false, childEntries);
const entries = this.getOption("entries", []);
const insertIndex = getSubtreeEndIndex(entries, index);
const updatedEntry = Object.assign({}, entries[index], {
["lazy-loaded"]: true,
["lazy-loading"]: false,
["has-children"]: childEntries.length > 0,
state: childEntries.length > 0 ? "open" : "close",
});
const nextEntries = [
...entries.slice(0, insertIndex),
...childEntries,
...entries.slice(insertIndex),
];
nextEntries[index] = updatedEntry;
this.setOption("entries", nextEntries);
rebuildEntryIndex.call(this);
const loadedEntry = this.getOption(`entries.${index}`);
const loadedAction = getAction.call(this, ["onlazyloaded"]);
if (isFunction(loadedAction)) {
loadedAction.call(this, loadedEntry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-lazy-loaded", {
entry: loadedEntry,
index,
event,
});
return childEntries.length > 0;
}
/**
* @private
* @param {string} html
* @return {HTMLElement|null}
*/
function parseLazyList(html) {
if (!isString(html) || html.trim() === "") {
return null;
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const list = doc.querySelector("ul,ol");
if (list) {
return list;
}
const items = Array.from(doc.body.children).filter((node) =>
node.matches("li"),
);
if (items.length === 0) {
return null;
}
const fallback = document.createElement("ul");
for (const item of items) {
fallback.appendChild(document.importNode(item, true));
}
return fallback;
}
/**
* @private
* @param {string[]} names
* @return {Function|null}
*/
function getAction(names) {
for (const name of names) {
const action = this.getOption(`actions.${name}`);
if (isFunction(action)) {
return action;
}
}
return null;
}
/**
* @private
* @param {Event} event
* @return {boolean}
*/
function isAnchorEvent(event) {
if (!event || typeof event.composedPath !== "function") {
return false;
}
const path = event.composedPath();
for (const node of path) {
if (node instanceof HTMLAnchorElement && node.hasAttribute("href")) {
return true;
}
}
return false;
}
/**
* @private
* @param {string} type
* @param {Object} detail
* @return {CustomEvent}
*/
function dispatchEntryEvent(type, detail) {
const event = new CustomEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
detail,
});
this.dispatchEvent(event);
return event;
}
/**
* @private
* @param {Object} entry
* @param {number} index
* @param {HTMLElement} container
* @param {Event|undefined} event
*/
function applySelection(entry, index, container, event) {
this.shadowRoot
.querySelectorAll("[data-monster-role=entry].selected")
.forEach((node) => {
node.classList.remove("selected");
});
let intend = entry.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");
const doSelect = getAction.call(this, ["onselect", "select"]);
if (isFunction(doSelect)) {
doSelect.call(this, entry, index, event);
}
fireCustomEvent(this, "monster-html-tree-menu-select", {
entry,
index,
event,
});
}
/**
* @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(HtmlTreeMenu);