Modifications to make lazy trees possible
This commit is contained in:
197
tp-tree-nav.js
197
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-icon/tp-icon.js';
|
||||||
|
import '@tp/tp-spinner/tp-spinner.js';
|
||||||
import '@tp/tp-popup/tp-popup-menu.js';
|
import '@tp/tp-popup/tp-popup-menu.js';
|
||||||
import { LitElement, html, css, svg } from 'lit';
|
import { LitElement, html, css, svg } from 'lit';
|
||||||
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
|
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 { node, depth, hasChildren, expanded } = item;
|
||||||
const icon = this._resolveIcon(node?.icon);
|
const icon = this._resolveIcon(node?.icon);
|
||||||
|
|
||||||
|
const states = Array.isArray(node?.states) ? node.states : [];
|
||||||
|
const isLoading = states.includes('loading');
|
||||||
|
|
||||||
const custom = this.renderNode?.(item, {
|
const custom = this.renderNode?.(item, {
|
||||||
depth,
|
depth,
|
||||||
states: Array.isArray(node?.states) ? node.states : [],
|
states,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
expanded,
|
expanded,
|
||||||
@@ -192,7 +196,9 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
>
|
>
|
||||||
<div class="indent"></div>
|
<div class="indent"></div>
|
||||||
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
|
<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>
|
</div>
|
||||||
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
${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>
|
<div class="label" part="label">${node?.label ?? ''}</div>
|
||||||
@@ -261,6 +267,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
selectOnRightClick: { type: Boolean },
|
selectOnRightClick: { type: Boolean },
|
||||||
allowDragAndDrop: { type: Boolean },
|
allowDragAndDrop: { type: Boolean },
|
||||||
canDrop: { type: Function },
|
canDrop: { type: Function },
|
||||||
|
loadChildren: { type: Function },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +313,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
this.selectOnRightClick = false;
|
this.selectOnRightClick = false;
|
||||||
this.allowDragAndDrop = false;
|
this.allowDragAndDrop = false;
|
||||||
this.canDrop = null;
|
this.canDrop = null;
|
||||||
|
this.loadChildren = null;
|
||||||
this._contextMenu = null;
|
this._contextMenu = null;
|
||||||
this._outsideHandler = null;
|
this._outsideHandler = null;
|
||||||
this._keyHandler = null;
|
this._keyHandler = null;
|
||||||
@@ -313,6 +321,8 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
this._knownPaths = new Set();
|
this._knownPaths = new Set();
|
||||||
this._expandedPathSet = new Set();
|
this._expandedPathSet = new Set();
|
||||||
this._selectedPathSet = new Set();
|
this._selectedPathSet = new Set();
|
||||||
|
this._childLoadControllers = new Map();
|
||||||
|
this._childLoadSeq = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -342,7 +352,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
const slug = node?.slug ?? `${index}`;
|
const slug = node?.slug ?? `${index}`;
|
||||||
const nextPath = [...path, slug];
|
const nextPath = [...path, slug];
|
||||||
const states = Array.isArray(node?.states) ? node.states : [];
|
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');
|
const expanded = states.includes('expanded');
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
@@ -354,7 +364,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
key: nextPath.join('/'),
|
key: nextPath.join('/'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasChildren && expanded) {
|
if (hasChildren && expanded && Array.isArray(node?.children) && node.children.length > 0) {
|
||||||
walk(node.children, depth + 1, nextPath);
|
walk(node.children, depth + 1, nextPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -364,6 +374,17 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
return results;
|
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) {
|
_onRowClick(item, originalEvent) {
|
||||||
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
|
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
|
||||||
|
|
||||||
@@ -415,21 +436,177 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_toggleExpand(item, source, originalEvent) {
|
_toggleExpand(item, source, originalEvent) {
|
||||||
|
const shouldLoad = typeof this.loadChildren === 'function';
|
||||||
|
|
||||||
if (this.manageState) {
|
if (this.manageState) {
|
||||||
const pathStr = item.path.join('/');
|
const pathStr = item.path.join('/');
|
||||||
const set = new Set(this._expandedPathSet);
|
const set = new Set(this._expandedPathSet);
|
||||||
if (set.has(pathStr)) {
|
const wasExpanded = set.has(pathStr);
|
||||||
|
|
||||||
|
if (wasExpanded) {
|
||||||
set.delete(pathStr);
|
set.delete(pathStr);
|
||||||
} else {
|
this._expandedPathSet = set;
|
||||||
set.add(pathStr);
|
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._expandedPathSet = set;
|
||||||
this.expandedPaths = Array.from(set);
|
this.expandedPaths = Array.from(set);
|
||||||
this._rebuildManagedTree();
|
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);
|
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) {
|
async _onContextMenu(item, originalEvent) {
|
||||||
originalEvent.preventDefault();
|
originalEvent.preventDefault();
|
||||||
|
|
||||||
@@ -915,12 +1092,12 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
const mappedNode = mapper(node, nextPath) ?? node;
|
const mappedNode = mapper(node, nextPath) ?? node;
|
||||||
|
|
||||||
const { nodes: childNodes, changed: childChanged } = this._mapTree(
|
const { nodes: childNodes, changed: childChanged } = this._mapTree(
|
||||||
node?.children,
|
mappedNode?.children,
|
||||||
mapper,
|
mapper,
|
||||||
nextPath
|
nextPath
|
||||||
);
|
);
|
||||||
|
|
||||||
const children = childChanged ? childNodes : node?.children;
|
const children = childChanged ? childNodes : mappedNode?.children;
|
||||||
const nodeChanged = mappedNode !== node || childChanged;
|
const nodeChanged = mappedNode !== node || childChanged;
|
||||||
|
|
||||||
if (nodeChanged) {
|
if (nodeChanged) {
|
||||||
@@ -969,7 +1146,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
|
|
||||||
const children = Array.isArray(node?.children)
|
const children = Array.isArray(node?.children)
|
||||||
? node.children.map((child) => mapNode(child, fullPath))
|
? node.children.map((child) => mapNode(child, fullPath))
|
||||||
: [];
|
: node?.children;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
|||||||
Reference in New Issue
Block a user