/** @license Copyright (c) 2025 trading_peter This program is available under Apache License Version 2.0 */ import '@tp/tp-icon/tp-icon.js'; import '@tp/tp-popup/tp-popup-menu.js'; import { LitElement, html, css, svg } from 'lit'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; import { Position } from '../helpers/position.js'; export class TpTreeNav extends Position(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%; position: relative; } .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; } tp-popup-menu.context-menu { min-width: 180px; box-shadow: 0 6px 18px rgba(0,0,0,0.18); pointer-events: auto; } .row.drag-over-inside { background: var(--tp-tree-drag-over-bg, rgba(0,0,0,0.1)); } .row.drag-over-before::after, .row.drag-over-after::after { content: ""; position: absolute; left: calc(var(--tp-tree-indent) * var(--drag-depth, var(--depth))); right: 0; height: 2px; background-color: var(--tp-tree-drag-line-color, #2196f3); pointer-events: none; } .row.drag-over-before::after { top: 0; } .row.drag-over-after::after { bottom: 0; } .drag-clone { opacity: 0.5; position: absolute; z-index: 1000; top: -1000px; left: -1000px; } ` ]; } render() { this._flatItems = this._flattenNodes(); const items = this._flatItems; 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)} @dragstart=${(e) => this._onDragStart(item, e)} @dragend=${(e) => this._onDragEnd(item, e)} @dragover=${(e) => this._onDragOver(item, e)} @dragleave=${(e) => this._onDragLeave(item, e)} @drop=${(e) => this._onDrop(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 { item, actions } = this._contextMenu; return html`
${actions.map((action) => html` this._onMenuAction(action, item, e)}> ${action?.label ?? null} `)}
`; } _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 }, selectOnRightClick: { type: Boolean }, allowDragAndDrop: { type: Boolean }, canDrop: { 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.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.selectOnRightClick = false; this.allowDragAndDrop = false; this.canDrop = null; this._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; this._dragSource = 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(); if (this.selectOnRightClick && this.manageState) { const pathStr = item.path.join('/'); const set = new Set(this._selectedPathSet); let changed = false; if (this.multiSelect) { if (!set.has(pathStr)) { set.add(pathStr); changed = true; } } else { if (!set.has(pathStr) || set.size !== 1) { set.clear(); set.add(pathStr); changed = true; } } if (changed) { 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, })); } } 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 x = originalEvent.clientX; const y = originalEvent.clientY; this._contextMenu = { item, actions: Array.isArray(actions) ? actions : baseActions }; this.requestUpdate(); await this.updateComplete; const menu = this.shadowRoot.querySelector('.context-menu'); if (menu) { const anchor = { getBoundingClientRect: () => ({ top: y, bottom: y, left: x, right: x, width: 0, height: 0 }) }; this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' }); } 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); } _onDragStart(item, e) { if (!this.allowDragAndDrop) { e.preventDefault(); return; } this._dragSource = item; e.dataTransfer.effectAllowed = 'move'; const row = e.currentTarget; const rect = row.getBoundingClientRect(); const clone = document.createElement('div'); clone.classList.add('drag-clone'); clone.textContent = row.textContent; this.shadowRoot.appendChild(clone); const offsetX = e.clientX - rect.left; const offsetY = e.clientY - rect.top; e.dataTransfer.setDragImage(clone, 0, 0); setTimeout(() => { clone.remove(); }, 0); } _onDragEnd(item, e) { this._dragSource = null; } _onDragOver(item, e) { if (!this.allowDragAndDrop || !this._dragSource) return; e.preventDefault(); // Allow drop const row = e.currentTarget; const rect = row.getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; let position = 'inside'; if (y < h * 0.25) position = 'before'; else if (y > h * 0.75) position = 'after'; // Don't allow dropping on itself if (this._dragSource.key === item.key) { this._clearDragClasses(row); e.dataTransfer.dropEffect = 'none'; return; } // Avoid redundant drop target (After Expanded Parent vs Before First Child) if (position === 'after' && item.expanded && item.hasChildren) { this._clearDragClasses(row); e.dataTransfer.dropEffect = 'none'; return; } // Resolve logical target (handle expanded parent case and unindent) const { target: logicalTarget, position: logicalPosition, depth: dragDepth } = this._resolveDropTarget(item, position, e); // Check for no-op if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) { this._clearDragClasses(row); e.dataTransfer.dropEffect = 'none'; return; } // Check user validation if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) { this._clearDragClasses(row); e.dataTransfer.dropEffect = 'none'; return; } e.dataTransfer.dropEffect = 'move'; this._clearDragClasses(row); row.classList.add(`drag-over-${position}`); if (dragDepth !== undefined) { row.style.setProperty('--drag-depth', dragDepth); } } _onDragLeave(item, e) { const row = e.currentTarget; if (row.contains(e.relatedTarget)) return; this._clearDragClasses(row); } _onDrop(item, e) { if (!this.allowDragAndDrop || !this._dragSource) return; e.preventDefault(); const row = e.currentTarget; this._clearDragClasses(row); const rect = row.getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; let position = 'inside'; if (y < h * 0.25) position = 'before'; else if (y > h * 0.75) position = 'after'; if (this._dragSource.key === item.key) return; // Avoid redundant drop target (After Expanded Parent vs Before First Child) if (position === 'after' && item.expanded && item.hasChildren) { return; } // Resolve logical target (handle expanded parent case and unindent) const { target: logicalTarget, position: logicalPosition } = this._resolveDropTarget(item, position, e); if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) { return; } if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) { return; } this.dispatchEvent(new CustomEvent('node-drop', { detail: { source: this._dragSource, target: logicalTarget, position: logicalPosition, originalEvent: e }, bubbles: true, composed: true })); this._dragSource = null; } _isNoOp(source, target, position) { if (!source || !target) return true; if (source.key === target.key) return true; const srcPathStr = source.path.slice(0, -1).join('/'); const tgtPathStr = target.path.slice(0, -1).join('/'); if (srcPathStr !== tgtPathStr) return false; const siblings = this._getSiblings(source.path); if (!siblings) return false; const srcIdx = siblings.indexOf(source.node); const tgtIdx = siblings.indexOf(target.node); if (srcIdx === -1 || tgtIdx === -1) return false; if (position === 'before') { if (srcIdx === tgtIdx - 1) return true; } if (position === 'after') { if (srcIdx === tgtIdx + 1) return true; } return false; } _getSiblings(path) { if (path.length === 1) return this.roots; let nodes = this.roots; for (let i = 0; i < path.length - 1; i++) { const slug = path[i]; const node = nodes.find((n, idx) => (n.slug ?? `${idx}`) === slug); if (!node) return null; nodes = node.children || []; } return nodes; } _resolveDropTarget(item, position, e) { if (!this._flatItems) return { target: item, position }; const index = this._flatItems.findIndex(i => i.key === item.key); if (index === -1) return { target: item, position }; const currentItem = this._flatItems[index]; // Handle Unindent on "After" // Only if we are at the bottom of a block (next item has lower depth or is end of list) if (position === 'after' && e) { const nextItem = this._flatItems[index + 1]; const currentDepth = currentItem.depth; const nextDepth = nextItem ? nextItem.depth : -1; // -1 allows unindenting to root (depth 0) if (nextDepth < currentDepth) { // We are at a step down. Calculate target depth from mouse X. const row = e.currentTarget; const style = getComputedStyle(row); const indentVal = style.getPropertyValue('--tp-tree-indent').trim(); const indent = indentVal ? parseInt(indentVal, 10) : 16; const padding = parseFloat(style.paddingLeft) || 8; const rowRect = row.getBoundingClientRect(); const mouseX = e.clientX - rowRect.left; // Calculate desired depth based on mouse position let targetDepth = Math.floor((mouseX - padding) / indent); // Clamp target depth // Can't be deeper than current // Can't be shallower than next item's depth (visual constraint) // Actually, we can target any ancestor that ends here. // The ancestors end at depths: currentDepth, currentDepth-1, ... nextDepth. // Example: // A (0) // B (1) // C (2) <- Item. Next is D (0). // We can drop after C (depth 2), after B (depth 1), after A (depth 0). // So valid depths are [nextDepth ... currentDepth]. // Note: nextDepth is the depth of the *next sibling* of the ancestor we target. // If next is D(0), we can target A(0). // Ensure targetDepth is within valid range if (targetDepth < nextDepth) targetDepth = nextDepth; if (targetDepth > currentDepth) targetDepth = currentDepth; if (targetDepth < currentDepth) { // Find the ancestor at targetDepth // Walk backwards from current item? No, ancestors are before. // But we want the ancestor that *contains* current item. // We can walk backwards from index. let ancestor = currentItem; for (let i = index; i >= 0; i--) { if (this._flatItems[i].depth === targetDepth) { ancestor = this._flatItems[i]; break; } } return { target: ancestor, position: 'after', depth: targetDepth }; } } } return { target: currentItem, position }; } _clearDragClasses(row) { row.classList.remove('drag-over-inside', 'drag-over-before', 'drag-over-after'); row.style.removeProperty('--drag-depth'); } _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);