From 09e5b51f5c3c4b2da423434d3af6b381d9398fb4 Mon Sep 17 00:00:00 2001 From: pk Date: Tue, 16 Dec 2025 00:50:53 +0100 Subject: [PATCH] wip --- tp-tree-nav.js | 213 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 208 insertions(+), 5 deletions(-) diff --git a/tp-tree-nav.js b/tp-tree-nav.js index 84809d3..6dda1af 100644 --- a/tp-tree-nav.js +++ b/tp-tree-nav.js @@ -80,6 +80,18 @@ export class TpTreeNav extends LitElement { 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; @@ -90,7 +102,6 @@ export class TpTreeNav extends LitElement { 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); @@ -129,6 +140,10 @@ export class TpTreeNav extends LitElement { render() { const items = this._flattenNodes(); + if (!items.length) { + return html`${this._renderEmpty()}`; + } + return html`
${virtualize({ @@ -150,6 +165,7 @@ export class TpTreeNav extends LitElement { states: Array.isArray(node?.states) ? node.states : [], path: item.path, hasChildren, + expanded, }); if (custom) { @@ -175,6 +191,21 @@ export class TpTreeNav extends LitElement {
${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} `; } @@ -201,12 +232,31 @@ export class TpTreeNav extends LitElement { `; } + _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 }, }; } @@ -236,9 +286,24 @@ export class TpTreeNav extends LitElement { 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() { @@ -246,6 +311,18 @@ export class TpTreeNav extends LitElement { 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 : []; @@ -279,18 +356,65 @@ export class TpTreeNav extends LitElement { } _onRowClick(item, originalEvent) { - const ev = new CustomEvent('node-click', { + 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, - }); - this.dispatchEvent(ev); + })); } _onChevronClick(item, originalEvent) { originalEvent.stopPropagation(); - this._dispatchAction('toggle', item, 'chevron', originalEvent); + 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) { @@ -343,6 +467,11 @@ export class TpTreeNav extends LitElement { this._closeContextMenu(); } + _onInlineAction(item, action, originalEvent) { + originalEvent.stopPropagation(); + this._dispatchAction(action?.action, item, 'inline-action', originalEvent); + } + _attachOutsideHandlers() { if (this._outsideHandler) return; this._outsideHandler = (e) => { @@ -405,6 +534,28 @@ export class TpTreeNav extends LitElement { 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) => { @@ -477,6 +628,58 @@ export class TpTreeNav extends LitElement { }); 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);