Modifications to make lazy trees possible

This commit is contained in:
2025-12-28 01:21:53 +01:00
parent 4e04f1eae9
commit 0123378aca

View File

@@ -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) {
>
<div class="indent"></div>
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>
${isLoading
? html`<tp-spinner class="icon" part="spinner"></tp-spinner>`
: html`<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>`}
</div>
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
<div class="label" part="label">${node?.label ?? ''}</div>
@@ -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,