UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,379 lines (1,242 loc) 33.1 kB
/** * 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);