/** @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'; 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; } .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 { margin-right: 6px; width: 18px; height: 18px; flex: 0 0 auto; } .label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .context-menu-overlay { position: absolute; inset: 0; pointer-events: none; } .context-menu { position: absolute; min-width: 180px; background: var(--tp-tree-menu-bg, #fff); color: inherit; 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(); return html`
${virtualize({ items, renderItem: (item) => this._renderItem(item), keyFunction: (item) => item.key, })}
${this._renderContextMenu()} `; } static get properties() { return { roots: { type: Array }, defaultActions: { type: Array }, renderNode: { type: Function }, beforeContextMenu: { type: Function }, }; } 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._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; } disconnectedCallback() { super.disconnectedCallback(); this._removeOutsideHandlers(); } _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; } _renderItem(item) { const { node, depth, hasChildren, expanded } = item; const icon = this._resolveIcon(node?.icon); const custom = this.renderNode?.(node, { depth, states: Array.isArray(node?.states) ? node.states : [], path: item.path, hasChildren, }); 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 ?? ''}
`; } _onRowClick(item, originalEvent) { const ev = new CustomEvent('node-click', { detail: { node: item.node, path: item.path, originalEvent }, bubbles: true, composed: true, cancelable: true, }); this.dispatchEvent(ev); } _onChevronClick(item, originalEvent) { originalEvent.stopPropagation(); this._dispatchAction('toggle', item, 'chevron', originalEvent); } async _onContextMenu(item, originalEvent) { originalEvent.preventDefault(); const contextEvent = new CustomEvent('node-context', { detail: { node: item.node, path: item.path, 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.node, 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, node: item.node, path: item.path, source, originalEvent }, bubbles: true, composed: true, cancelable: true, }); this.dispatchEvent(ev); } _renderContextMenu() { if (!this._contextMenu) return null; const { x, y, item, actions } = this._contextMenu; return html`
${actions.map((action) => html` `)}
`; } _onMenuAction(action, item, originalEvent) { originalEvent.stopPropagation(); this._dispatchAction(action?.action, item, 'context-menu', originalEvent); this._closeContextMenu(); } _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; } 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 }; } } window.customElements.define('tp-tree-nav', TpTreeNav);