diff --git a/tp-tree-nav.js b/tp-tree-nav.js index 63c0f7e..21b6414 100644 --- a/tp-tree-nav.js +++ b/tp-tree-nav.js @@ -5,6 +5,7 @@ This program is available under Apache License Version 2.0 */ import '@tp/tp-icon/tp-icon.js'; +import '@tp/tp-spinner/tp-spinner.js'; import '@tp/tp-popup/tp-popup-menu.js'; import { LitElement, html, css, svg } from 'lit'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; @@ -167,9 +168,12 @@ export class TpTreeNav extends Position(LitElement) { const { node, depth, hasChildren, expanded } = item; const icon = this._resolveIcon(node?.icon); + const states = Array.isArray(node?.states) ? node.states : []; + const isLoading = states.includes('loading'); + const custom = this.renderNode?.(item, { depth, - states: Array.isArray(node?.states) ? node.states : [], + states, path: item.path, hasChildren, expanded, @@ -192,7 +196,9 @@ export class TpTreeNav extends Position(LitElement) { >
this._onChevronClick(item, e)} > - + ${isLoading + ? html`` + : html``}
${icon ? html`` : html``}
${node?.label ?? ''}
@@ -261,6 +267,7 @@ export class TpTreeNav extends Position(LitElement) { selectOnRightClick: { type: Boolean }, allowDragAndDrop: { type: Boolean }, canDrop: { type: Function }, + loadChildren: { type: Function }, }; } @@ -306,6 +313,7 @@ export class TpTreeNav extends Position(LitElement) { this.selectOnRightClick = false; this.allowDragAndDrop = false; this.canDrop = null; + this.loadChildren = null; this._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; @@ -313,6 +321,8 @@ export class TpTreeNav extends Position(LitElement) { this._knownPaths = new Set(); this._expandedPathSet = new Set(); this._selectedPathSet = new Set(); + this._childLoadControllers = new Map(); + this._childLoadSeq = 0; } disconnectedCallback() { @@ -342,7 +352,7 @@ export class TpTreeNav extends Position(LitElement) { 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 hasChildren = this._isExpandableNode(node); const expanded = states.includes('expanded'); results.push({ @@ -354,7 +364,7 @@ export class TpTreeNav extends Position(LitElement) { key: nextPath.join('/'), }); - if (hasChildren && expanded) { + if (hasChildren && expanded && Array.isArray(node?.children) && node.children.length > 0) { walk(node.children, depth + 1, nextPath); } }); @@ -364,6 +374,17 @@ export class TpTreeNav extends Position(LitElement) { return results; } + _isExpandableNode(node) { + if (!node) return false; + if (Array.isArray(node.children) && node.children.length > 0) return true; + if (typeof this.loadChildren === 'function') { + // If consumer provides an `isDir` hint, don't show chevrons for non-dirs. + if (typeof node.isDir === 'boolean') return node.isDir; + return true; + } + return false; + } + _onRowClick(item, originalEvent) { const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2; @@ -415,21 +436,177 @@ export class TpTreeNav extends Position(LitElement) { } _toggleExpand(item, source, originalEvent) { + const shouldLoad = typeof this.loadChildren === 'function'; + if (this.manageState) { const pathStr = item.path.join('/'); const set = new Set(this._expandedPathSet); - if (set.has(pathStr)) { + const wasExpanded = set.has(pathStr); + + if (wasExpanded) { set.delete(pathStr); - } else { - set.add(pathStr); + this._expandedPathSet = set; + this.expandedPaths = Array.from(set); + this._rebuildManagedTree(); + this._dispatchAction('toggle', item, source, originalEvent); + return; } + + // If we're about to load children, set loading state first so the immediate rebuild + // renders a spinner (and the row stays expandable) rather than flashing an "empty" state. + if (shouldLoad) { + this._updateNodeStatesByKey(item.key, (n) => { + const states = Array.isArray(n.states) ? n.states : []; + const next = states.filter((s) => s !== 'loaded'); + if (!next.includes('loading')) next.push('loading'); + return { ...n, states: next }; + }); + } + + set.add(pathStr); this._expandedPathSet = set; this.expandedPaths = Array.from(set); this._rebuildManagedTree(); + this._dispatchAction('toggle', item, source, originalEvent); + + if (shouldLoad) { + // Load immediately; don't rely on a microtask where external code may reset `roots`. + // Prefer the node from the item we just interacted with. + this._loadChildrenForExpanded(item.key, source, originalEvent, item.node, item.path); + } + return; } + this._dispatchAction('toggle', item, source, originalEvent); } + _findNodeByKey(key) { + let found = null; + const walk = (nodes, path) => { + if (!Array.isArray(nodes)) return; + nodes.forEach((node, index) => { + if (found) return; + const slug = node?.slug ?? `${index}`; + const nextPath = [...path, slug]; + const nodeKey = nextPath.join('/'); + if (nodeKey === key) { + found = node; + return; + } + if (Array.isArray(node?.children) && node.children.length > 0) { + walk(node.children, nextPath); + } + }); + }; + walk(this.roots, []); + return found; + } + + getRootKey() { + const roots = Array.isArray(this.roots) ? this.roots : []; + if (!roots.length) return ''; + const first = roots[0]; + const slug = first?.slug ?? '0'; + return String(slug); + } + + _updateNodeStatesByKey(key, updater) { + const map = (nodes, path = []) => { + if (!Array.isArray(nodes)) return nodes; + return nodes.map((node, index) => { + const slug = node?.slug ?? `${index}`; + const nextPath = [...path, slug]; + const nodeKey = nextPath.join('/'); + + let nextNode = node; + if (nodeKey === key) { + nextNode = updater(node) ?? node; + } + + const children = Array.isArray(nextNode?.children) + ? map(nextNode.children, nextPath) + : nextNode?.children; + + if (children !== nextNode?.children) { + return { ...nextNode, children }; + } + + return nextNode; + }); + }; + + this.roots = map(this.roots); + } + + async _loadChildrenForExpanded(key, source, originalEvent, nodeHint = null, pathHint = null) { + if (typeof this.loadChildren !== 'function') return; + + // Prefer the node passed from the click handler; it is the most stable reference. + // Fall back to searching the current tree by key. + const node = nodeHint ?? this._findNodeByKey(key); + if (!node) { + return; + } + + const controller = new AbortController(); + const seq = ++this._childLoadSeq; + const previous = this._childLoadControllers.get(key); + if (previous) { + try { previous.abort(); } catch (e) { } + } + this._childLoadControllers.set(key, controller); + + this._updateNodeStatesByKey(key, (n) => { + const states = Array.isArray(n.states) ? n.states : []; + const next = states.filter((s) => s !== 'loaded'); + if (!next.includes('loading')) next.push('loading'); + return { ...n, states: next }; + }); + + try { + const ctx = { + node, + key, + path: Array.isArray(pathHint) ? pathHint : key.split('/').filter(Boolean), + signal: controller.signal, + source, + originalEvent, + }; + + // loadChildren is responsible for updating the tree (e.g. setting `node.children`). + await Promise.resolve(this.loadChildren(ctx)); + if (controller.signal.aborted) return; + if (this._childLoadControllers.get(key) !== controller) return; + if (seq !== this._childLoadSeq) { + // newer load started elsewhere; ignore + return; + } + + this._updateNodeStatesByKey(key, (n) => { + const states = Array.isArray(n.states) ? n.states : []; + const next = states.filter((s) => s !== 'loading'); + if (!next.includes('loaded')) next.push('loaded'); + return { ...n, states: next }; + }); + } catch (err) { + if (controller.signal.aborted) return; + this._updateNodeStatesByKey(key, (n) => { + const states = Array.isArray(n.states) ? n.states : []; + const next = states.filter((s) => s !== 'loading'); + return { ...n, states: next }; + }); + this.dispatchEvent(new CustomEvent('node-children-error', { + detail: { key, node, error: err, source, originalEvent }, + bubbles: true, + composed: true, + })); + } finally { + if (this._childLoadControllers.get(key) === controller) { + this._childLoadControllers.delete(key); + } + } + } + async _onContextMenu(item, originalEvent) { originalEvent.preventDefault(); @@ -915,12 +1092,12 @@ export class TpTreeNav extends Position(LitElement) { const mappedNode = mapper(node, nextPath) ?? node; const { nodes: childNodes, changed: childChanged } = this._mapTree( - node?.children, + mappedNode?.children, mapper, nextPath ); - const children = childChanged ? childNodes : node?.children; + const children = childChanged ? childNodes : mappedNode?.children; const nodeChanged = mappedNode !== node || childChanged; if (nodeChanged) { @@ -969,7 +1146,7 @@ export class TpTreeNav extends Position(LitElement) { const children = Array.isArray(node?.children) ? node.children.map((child) => mapNode(child, fullPath)) - : []; + : node?.children; return { ...node,