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,