/** @license Copyright (c) 2025 trading_peter This program is available under Apache License Version 2.0 */ import { LitElement, html, css, svg } from 'lit'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; export class TpTreeNav extends LitElement { static get styles() { return [ css` :host { display: block; position: relative; --tp-tree-indent: 16px; --tp-tree-row-height: 28px; } .tree { width: 100%; height: 100%; } .row { display: flex; align-items: center; height: var(--tp-tree-row-height); box-sizing: border-box; padding: 0 8px; cursor: default; user-select: none; gap: 5px; width: 100%; } .row:hover { background: var(--tp-tree-hover-bg, rgba(0,0,0,0.04)); } .row:active { background: var(--tp-tree-active-bg, rgba(0,0,0,0.06)); } .indent { width: calc(var(--tp-tree-indent) * var(--depth)); flex: 0 0 auto; } .chevron-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; cursor: pointer; transition: transform 120ms ease; } .chevron-btn[hidden] { visibility: hidden; } .chevron-btn.expanded { transform: rotate(90deg); } .icon { width: 18px; height: 18px; flex: 0 0 auto; } .label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .actions { display: flex; gap: 6px; flex: 0 0 auto; } .empty-state { padding: 16px; text-align: center; color: var(--tp-tree-empty-color, rgba(0,0,0,0.56)); } .context-menu-overlay { position: absolute; inset: 0; pointer-events: none; } .context-menu { position: absolute; min-width: 180px; background: var(--tp-tree-menu-bg, #fff); border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1)); border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 4px 0; pointer-events: auto; z-index: 10; } .menu-item { width: 100%; display: flex; align-items: center; gap: 8px; background: none; border: none; padding: 8px 12px; cursor: pointer; text-align: left; box-sizing: border-box; font: inherit; } .menu-item:hover { background: var(--tp-tree-menu-hover-bg, rgba(0,0,0,0.06)); } .menu-icon { width: 16px; height: 16px; flex: 0 0 auto; } ` ]; } render() { const items = this._flattenNodes(); if (!items.length) { return html`${this._renderEmpty()}`; } return html`
${virtualize({ items, renderItem: (item) => this._renderItem(item), keyFunction: (item) => item.key, })}
${this._renderContextMenu()} `; } _renderItem(item) { const { node, depth, hasChildren, expanded } = item; const icon = this._resolveIcon(node?.icon); const custom = this.renderNode?.(item, { depth, states: Array.isArray(node?.states) ? node.states : [], path: item.path, hasChildren, expanded, }); if (custom) { return custom; } return html`
this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} >
this._onChevronClick(item, e)} >
${icon ? html`` : html``}
${node?.label ?? ''}
${this.showActions && Array.isArray(node?.actions) && node.actions.length ? html`
${node.actions.map((action) => html` this._onInlineAction(item, action, e)} > `)}
` : null}
`; } _renderContextMenu() { if (!this._contextMenu) return null; const { x, y, item, actions } = this._contextMenu; return html`
${actions.map((action) => html` `)}
`; } _renderEmpty() { if (typeof this.renderEmpty === 'function') { return this.renderEmpty(); } return html`
${this.emptyMessage}
`; } static get properties() { return { roots: { type: Array }, defaultActions: { type: Array }, renderNode: { type: Function }, beforeContextMenu: { type: Function }, renderEmpty: { type: Function }, items: { type: Array }, manageState: { type: Boolean }, multiSelect: { type: Boolean }, expandedPaths: { type: Array }, selectedPaths: { type: Array }, selectionState: { type: String }, autoExpandNew: { type: Boolean }, applyStates: { type: Function }, emptyMessage: { type: String }, showActions: { type: Boolean }, expandOnDoubleClick: { type: Boolean }, }; } static get chevron() { return svg``; } static get folder() { return svg``; } static get file() { return svg``; } static get defaultIcons() { return { chevron: TpTreeNav.chevron, folder: TpTreeNav.folder, file: TpTreeNav.file, }; } constructor() { super(); this.roots = []; this.defaultActions = []; this.renderNode = null; this.beforeContextMenu = null; this.renderEmpty = null; this.items = null; this.manageState = false; this.multiSelect = false; this.expandedPaths = []; this.selectedPaths = []; this.selectionState = 'selected'; this.autoExpandNew = false; this.applyStates = null; this.emptyMessage = 'No items'; this.showActions = false; this.expandOnDoubleClick = false; this._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; this._knownPaths = new Set(); this._expandedPathSet = new Set(); this._selectedPathSet = new Set(); } disconnectedCallback() { super.disconnectedCallback(); this._removeOutsideHandlers(); } updated(changed) { super.updated?.(changed); const managed = this.manageState; const itemsChanged = managed && changed.has('items'); const expandChanged = managed && changed.has('expandedPaths'); const selectChanged = managed && changed.has('selectedPaths'); if (managed && (itemsChanged || expandChanged || selectChanged)) { this._rebuildManagedTree(); } } _flattenNodes() { const results = []; const roots = Array.isArray(this.roots) ? this.roots : []; const walk = (nodes, depth, path) => { if (!Array.isArray(nodes)) return; nodes.forEach((node, index) => { const slug = node?.slug ?? `${index}`; const nextPath = [...path, slug]; const states = Array.isArray(node?.states) ? node.states : []; const hasChildren = Array.isArray(node?.children) && node.children.length > 0; const expanded = states.includes('expanded'); results.push({ node, depth, path: nextPath, hasChildren, expanded, key: nextPath.join('/'), }); if (hasChildren && expanded) { walk(node.children, depth + 1, nextPath); } }); }; walk(roots, 0, []); return results; } _onRowClick(item, originalEvent) { const isDouble = this.expandOnDoubleClick && originalEvent?.detail === 2; if (this.manageState) { const pathStr = item.path.join('/'); const set = new Set(this._selectedPathSet); if (this.multiSelect) { if (set.has(pathStr)) { set.delete(pathStr); } else { set.add(pathStr); } } else { set.clear(); set.add(pathStr); } this._selectedPathSet = set; this.selectedPaths = Array.from(set); this._rebuildManagedTree(); this.dispatchEvent(new CustomEvent('node-selected', { detail: { node: item.node, path: item.path, originalEvent }, bubbles: true, composed: true, })); } if (isDouble) { this._toggleExpand(item, 'double-click', originalEvent); } this.dispatchEvent(new CustomEvent('node-click', { detail: { node: item.node, path: item.path, originalEvent }, bubbles: true, composed: true, cancelable: true, })); } _onChevronClick(item, originalEvent) { originalEvent.stopPropagation(); this._toggleExpand(item, 'chevron', originalEvent); } _toggleExpand(item, source, originalEvent) { if (this.manageState) { const pathStr = item.path.join('/'); const set = new Set(this._expandedPathSet); if (set.has(pathStr)) { set.delete(pathStr); } else { set.add(pathStr); } this._expandedPathSet = set; this.expandedPaths = Array.from(set); this._rebuildManagedTree(); } this._dispatchAction('toggle', item, source, originalEvent); } async _onContextMenu(item, originalEvent) { originalEvent.preventDefault(); const contextEvent = new CustomEvent('node-context', { detail: { item, originalEvent }, bubbles: true, composed: true, cancelable: true, }); this.dispatchEvent(contextEvent); if (contextEvent.defaultPrevented) return; const baseActions = this._mergeActions( this.defaultActions, this._normalizeActions(item.node?.actions) ); const maybeModified = this.beforeContextMenu ? this.beforeContextMenu(item, baseActions) : baseActions; const actions = await Promise.resolve(maybeModified); if (actions === false) return; const rect = this.getBoundingClientRect(); const x = originalEvent.clientX - rect.left; const y = originalEvent.clientY - rect.top; this._contextMenu = { x, y, item, actions: Array.isArray(actions) ? actions : baseActions }; this.requestUpdate(); this._attachOutsideHandlers(); } _dispatchAction(action, item, source, originalEvent) { const ev = new CustomEvent('node-action', { detail: { action, item, source, originalEvent }, bubbles: true, composed: true, cancelable: true, }); this.dispatchEvent(ev); } _onMenuAction(action, item, originalEvent) { originalEvent.stopPropagation(); this._dispatchAction(action?.action, item, 'context-menu', originalEvent); this._closeContextMenu(); } _onInlineAction(item, action, originalEvent) { originalEvent.stopPropagation(); this._dispatchAction(action?.action, item, 'inline-action', originalEvent); } _attachOutsideHandlers() { if (this._outsideHandler) return; this._outsideHandler = (e) => { const menu = this.shadowRoot?.querySelector('.context-menu'); if (menu && e.composedPath().includes(menu)) return; this._closeContextMenu(); }; this._keyHandler = (e) => { if (e.key === 'Escape') { this._closeContextMenu(); } }; window.addEventListener('pointerdown', this._outsideHandler, true); window.addEventListener('keydown', this._keyHandler, true); } _removeOutsideHandlers() { if (this._outsideHandler) { window.removeEventListener('pointerdown', this._outsideHandler, true); this._outsideHandler = null; } if (this._keyHandler) { window.removeEventListener('keydown', this._keyHandler, true); this._keyHandler = null; } } _closeContextMenu() { this._contextMenu = null; this.requestUpdate(); this._removeOutsideHandlers(); } _normalizeActions(actions) { if (!actions) return []; if (Array.isArray(actions)) return actions; if (typeof actions === 'object') return Object.values(actions); return []; } _mergeActions(defaults = [], nodeActions = []) { const map = new Map(); const add = (list) => { list?.forEach((a) => { if (a && a.action) { map.set(a.action, a); } }); }; add(defaults); add(nodeActions); return Array.from(map.values()); } _resolveIcon(icon) { if (!icon) return null; if (typeof icon === 'string') { return TpTreeNav.defaultIcons[icon] ?? null; } return icon; } _rebuildManagedTree() { this._expandedPathSet = new Set(this.expandedPaths || this._expandedPathSet); this._selectedPathSet = new Set(this.selectedPaths || this._selectedPathSet); const { nodes, allPaths, expandedPaths, selectedPaths } = TpTreeNav.buildTree( Array.isArray(this.items) ? this.items : [], { expandedPaths: this._expandedPathSet, selectedPaths: this._selectedPathSet, selectionState: this.selectionState, applyStates: this.applyStates, knownPaths: this._knownPaths, autoExpandNew: this.autoExpandNew, } ); this._knownPaths = allPaths; this._expandedPathSet = new Set([...expandedPaths].filter((p) => allPaths.has(p))); this._selectedPathSet = new Set([...selectedPaths].filter((p) => allPaths.has(p))); this.roots = nodes; } getNodesWithState(state) { const matches = []; this._walkNodes(this.roots, [], (node, path) => { if (Array.isArray(node?.states) && node.states.includes(state)) { matches.push({ node, path }); } }); return matches; } clearState(state) { const { nodes } = this._mapTree(this.roots, (node) => { const states = Array.isArray(node?.states) ? node.states.filter((s) => s !== state) : []; const changed = (node.states?.length ?? 0) !== states.length; return changed ? { ...node, states } : node; }); return nodes; } applyStateIf(state, predicate) { if (typeof predicate !== 'function') return this.roots; const { nodes } = this._mapTree(this.roots, (node, path) => { const states = Array.isArray(node?.states) ? [...node.states] : []; if (predicate(node, path) && !states.includes(state)) { states.push(state); return { ...node, states }; } return node; }); return nodes; } _walkNodes(nodes, path, visitor) { if (!Array.isArray(nodes)) return; nodes.forEach((node, index) => { const slug = node?.slug ?? `${index}`; const nextPath = [...path, slug]; visitor(node, nextPath); if (Array.isArray(node?.children) && node.children.length) { this._walkNodes(node.children, nextPath, visitor); } }); } _mapTree(nodes, mapper, path = []) { if (!Array.isArray(nodes)) return { nodes: [], changed: false }; let changed = false; const mapped = nodes.map((node, index) => { const slug = node?.slug ?? `${index}`; const nextPath = [...path, slug]; const mappedNode = mapper(node, nextPath) ?? node; const { nodes: childNodes, changed: childChanged } = this._mapTree( node?.children, mapper, nextPath ); const children = childChanged ? childNodes : node?.children; const nodeChanged = mappedNode !== node || childChanged; if (nodeChanged) { changed = true; return { ...mappedNode, children }; } return mappedNode; }); return { nodes: mapped, changed }; } static buildTree(items = [], options = {}) { const { expandedPaths = new Set(), selectedPaths = new Set(), selectionState = 'selected', applyStates, knownPaths, autoExpandNew = false, } = options; const exp = expandedPaths instanceof Set ? new Set(expandedPaths) : new Set(expandedPaths); const sel = selectedPaths instanceof Set ? new Set(selectedPaths) : new Set(selectedPaths); const allPaths = new Set(); const mapNode = (node, parentPath = '') => { const segment = node?.slug ?? ''; const fullPath = parentPath ? `${parentPath}/${segment}` : segment; allPaths.add(fullPath); if (autoExpandNew && knownPaths && !knownPaths.has(fullPath)) { exp.add(fullPath); } const states = []; if (exp.has(fullPath)) states.push('expanded'); if (sel.has(fullPath) && selectionState) states.push(selectionState); const extraStates = applyStates ? applyStates(node, fullPath.split('/'), states) : null; if (Array.isArray(extraStates)) { extraStates.forEach((s) => { if (s && !states.includes(s)) states.push(s); }); } const children = Array.isArray(node?.children) ? node.children.map((child) => mapNode(child, fullPath)) : []; return { ...node, states, fullPath, children, source: node, }; }; const nodes = Array.isArray(items) ? items.map((n) => mapNode(n, '')) : []; return { nodes, allPaths, expandedPaths: exp, selectedPaths: sel }; } } window.customElements.define('tp-tree-nav', TpTreeNav);