Modifications to make lazy trees possible
This commit is contained in:
201
tp-tree-nav.js
201
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) {
|
||||
>
|
||||
<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,19 +436,175 @@ 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) {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user