Add support for initial paths and some other improvements.
This commit is contained in:
243
tp-tree-nav.js
243
tp-tree-nav.js
@@ -157,7 +157,7 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
items,
|
||||
scroller: this,
|
||||
renderItem: (item) => this._renderItem(item),
|
||||
keyFunction: (item) => item.key,
|
||||
keyFunction: (item) => item?.key ?? '',
|
||||
})}
|
||||
</div>
|
||||
${this._renderContextMenu()}
|
||||
@@ -165,6 +165,7 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
}
|
||||
|
||||
_renderItem(item) {
|
||||
if (!item) return null;
|
||||
const { node, depth, hasChildren, expanded } = item;
|
||||
const icon = this._resolveIcon(node?.icon);
|
||||
|
||||
@@ -187,6 +188,7 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
<div class="row" part="row" style="--depth: ${depth}"
|
||||
draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
|
||||
@click=${(e) => this._onRowClick(item, e)}
|
||||
@dblclick=${(e) => this._onRowDoubleClick(item, e)}
|
||||
@contextmenu=${(e) => this._onContextMenu(item, e)}
|
||||
@dragstart=${(e) => this._onDragStart(item, e)}
|
||||
@dragend=${(e) => this._onDragEnd(item, e)}
|
||||
@@ -268,6 +270,10 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
allowDragAndDrop: { type: Boolean },
|
||||
canDrop: { type: Function },
|
||||
loadChildren: { type: Function },
|
||||
initialPath: { type: String },
|
||||
initialSelect: { type: Boolean },
|
||||
initialScroll: { type: Boolean },
|
||||
initialOpen: { type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,6 +320,12 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
this.allowDragAndDrop = false;
|
||||
this.canDrop = null;
|
||||
this.loadChildren = null;
|
||||
this.initialPath = '';
|
||||
this.initialSelect = true;
|
||||
this.initialScroll = true;
|
||||
this.initialOpen = false;
|
||||
this._initialRevealDoneFor = '';
|
||||
this._revealController = null;
|
||||
this._contextMenu = null;
|
||||
this._outsideHandler = null;
|
||||
this._keyHandler = null;
|
||||
@@ -332,14 +344,183 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
|
||||
updated(changed) {
|
||||
super.updated?.(changed);
|
||||
|
||||
const managed = this.manageState;
|
||||
const itemsChanged = managed && changed.has('items');
|
||||
const expandChanged = managed && changed.has('expandedPaths');
|
||||
const selectChanged = managed && changed.has('selectedPaths');
|
||||
|
||||
if (managed && (itemsChanged || expandChanged || selectChanged)) {
|
||||
if (managed && (itemsChanged || expandChanged)) {
|
||||
this._rebuildManagedTree();
|
||||
} else if (managed && selectChanged) {
|
||||
// Preserve scroll position: selection-only changes shouldn't rebuild
|
||||
// the whole tree (virtualizer may jump to top).
|
||||
this._selectedPathSet = new Set(this.selectedPaths || []);
|
||||
|
||||
this._applySelectionStates();
|
||||
}
|
||||
|
||||
// Declarative initial reveal. Runs once per distinct initialPath value.
|
||||
if (changed.has('initialPath') || itemsChanged) {
|
||||
const p = typeof this.initialPath === 'string' ? this.initialPath.trim() : '';
|
||||
if (p && this._initialRevealDoneFor !== p) {
|
||||
// Only attempt when we have some tree data.
|
||||
const hasData = this.manageState
|
||||
? Array.isArray(this.items) && this.items.length > 0
|
||||
: Array.isArray(this.roots) && this.roots.length > 0;
|
||||
if (hasData) {
|
||||
this._initialRevealDoneFor = p;
|
||||
this.revealPath(p, {
|
||||
select: this.initialSelect !== false,
|
||||
scroll: this.initialScroll !== false,
|
||||
open: this.initialOpen === true,
|
||||
source: 'initialPath',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeKeyPath(path) {
|
||||
if (Array.isArray(path)) return path.map((s) => String(s)).filter(Boolean);
|
||||
if (typeof path !== 'string') return [];
|
||||
return path.split('/').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
_findFlatIndexByKey(key) {
|
||||
const items = Array.isArray(this._flatItems) ? this._flatItems : [];
|
||||
return items.findIndex((it) => it?.key === key);
|
||||
}
|
||||
|
||||
async scrollToKey(key) {
|
||||
await this.updateComplete;
|
||||
// Ensure flat list is up to date for this render.
|
||||
// (render() assigns this._flatItems).
|
||||
const idx = this._findFlatIndexByKey(key);
|
||||
if (idx < 0) return false;
|
||||
const scroller = this.shadowRoot?.querySelector('.tree');
|
||||
if (scroller?.scrollToIndex) {
|
||||
scroller.scrollToIndex(idx, { block: 'center' });
|
||||
return true;
|
||||
}
|
||||
// Fallback: just let it be selected without auto-scroll.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand+load ancestors so `path` becomes visible, then optionally select/scroll it.
|
||||
* `path` is a key path (slug path), like `root/dir/file`.
|
||||
*/
|
||||
async revealPath(path, options = {}) {
|
||||
const segments = this._normalizeKeyPath(path);
|
||||
if (!segments.length) return { found: false, key: '', node: undefined };
|
||||
|
||||
const select = options.select !== false;
|
||||
const scroll = options.scroll !== false;
|
||||
const open = options.open === true;
|
||||
const source = options.source ?? 'revealPath';
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
|
||||
|
||||
// Cancel any in-flight reveal.
|
||||
if (this._revealController) {
|
||||
try { this._revealController.abort(); } catch (e) { }
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this._revealController = controller;
|
||||
|
||||
const externalSignal = options.signal;
|
||||
if (externalSignal?.aborted) return { found: false, key: segments.join('/') };
|
||||
const abortIfExternal = () => {
|
||||
try { controller.abort(); } catch (e) { }
|
||||
};
|
||||
externalSignal?.addEventListener?.('abort', abortIfExternal, { once: true });
|
||||
|
||||
const start = performance.now();
|
||||
const ensureNotTimedOut = () => {
|
||||
if (timeoutMs <= 0) return;
|
||||
if (performance.now() - start > timeoutMs) {
|
||||
const err = new Error('revealPath timeout');
|
||||
err.name = 'TimeoutError';
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureExpanded = (key) => {
|
||||
if (!this.manageState) return;
|
||||
const set = new Set(this._expandedPathSet);
|
||||
if (!set.has(key)) {
|
||||
set.add(key);
|
||||
this._expandedPathSet = set;
|
||||
this.expandedPaths = Array.from(set);
|
||||
this._rebuildManagedTree();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSelected = (key) => {
|
||||
if (!this.manageState) return;
|
||||
const set = new Set();
|
||||
set.add(key);
|
||||
this._selectedPathSet = set;
|
||||
this.selectedPaths = Array.from(set);
|
||||
this._rebuildManagedTree();
|
||||
};
|
||||
|
||||
let currentKey = '';
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
ensureNotTimedOut();
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
currentKey = currentKey ? `${currentKey}/${segments[i]}` : segments[i];
|
||||
const node = this._findNodeByKey(currentKey);
|
||||
|
||||
// If the node isn't present yet, we can't proceed.
|
||||
if (!node) {
|
||||
externalSignal?.removeEventListener?.('abort', abortIfExternal);
|
||||
return { found: false, key: currentKey, node: undefined };
|
||||
}
|
||||
|
||||
// Expand all ancestors; load children for everything except the leaf.
|
||||
const isLeaf = i === segments.length - 1;
|
||||
ensureExpanded(currentKey);
|
||||
|
||||
if (!isLeaf && typeof this.loadChildren === 'function') {
|
||||
// Trigger a load for this expanded node. This is idempotent/cancelable.
|
||||
await this._loadChildrenForExpanded(currentKey, source, null, node, currentKey.split('/'));
|
||||
if (controller.signal.aborted) break;
|
||||
// After loading, the next segment might still be missing (e.g. server returned no such dir).
|
||||
const nextKey = `${currentKey}/${segments[i + 1]}`;
|
||||
if (!this._findNodeByKey(nextKey)) {
|
||||
externalSignal?.removeEventListener?.('abort', abortIfExternal);
|
||||
return { found: false, key: nextKey, node: undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
externalSignal?.removeEventListener?.('abort', abortIfExternal);
|
||||
return { found: false, key: currentKey, node: this._findNodeByKey(currentKey) };
|
||||
}
|
||||
|
||||
// Optionally expand+load the leaf itself ("reveal into") so its children become visible.
|
||||
if (open && typeof this.loadChildren === 'function') {
|
||||
const leafKey = currentKey;
|
||||
const leafNode = this._findNodeByKey(leafKey);
|
||||
if (leafNode && this._isExpandableNode(leafNode)) {
|
||||
ensureExpanded(leafKey);
|
||||
await this._loadChildrenForExpanded(leafKey, source, null, leafNode, leafKey.split('/'));
|
||||
}
|
||||
}
|
||||
|
||||
const finalNode = this._findNodeByKey(currentKey);
|
||||
if (finalNode && select) {
|
||||
ensureSelected(currentKey);
|
||||
}
|
||||
if (finalNode && scroll) {
|
||||
await this.scrollToKey(currentKey);
|
||||
}
|
||||
|
||||
externalSignal?.removeEventListener?.('abort', abortIfExternal);
|
||||
return { found: Boolean(finalNode), key: currentKey, node: finalNode };
|
||||
}
|
||||
|
||||
_flattenNodes() {
|
||||
@@ -378,14 +559,33 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
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;
|
||||
if (typeof node.showAsExpandable === 'boolean') return node.showAsExpandable;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_onRowDoubleClick(item, originalEvent) {
|
||||
this._suppressNextClick = true;
|
||||
queueMicrotask(() => {
|
||||
this._suppressNextClick = false;
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent('node-dblclick', {
|
||||
detail: { node: item.node, path: item.path, originalEvent },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
_onRowClick(item, originalEvent) {
|
||||
if (this._suppressNextClick) {
|
||||
originalEvent?.stopPropagation?.();
|
||||
originalEvent?.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
|
||||
|
||||
if (this.manageState) {
|
||||
@@ -405,7 +605,7 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
|
||||
this._selectedPathSet = set;
|
||||
this.selectedPaths = Array.from(set);
|
||||
this._rebuildManagedTree();
|
||||
this._applySelectionStates();
|
||||
|
||||
this.dispatchEvent(new CustomEvent('node-selected', {
|
||||
detail: { node: item.node, path: item.path, originalEvent },
|
||||
@@ -422,12 +622,33 @@ export class TpTreeNav extends Position(LitElement) {
|
||||
this._toggleExpand(item, 'single-click', originalEvent);
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('node-click', {
|
||||
detail: { node: item.node, path: item.path, originalEvent },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
if (!isDouble) {
|
||||
this.dispatchEvent(new CustomEvent('node-click', {
|
||||
detail: { node: item.node, path: item.path, originalEvent },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_applySelectionStates() {
|
||||
const selected = new Set(this._selectedPathSet);
|
||||
const selectionState = this.selectionState;
|
||||
const { nodes } = this._mapTree(this.roots, (node, path) => {
|
||||
const key = Array.isArray(path) ? path.join('/') : '';
|
||||
const states = Array.isArray(node?.states) ? node.states : [];
|
||||
const without = states.filter((s) => s !== selectionState);
|
||||
if (selected.has(key)) {
|
||||
if (!without.includes(selectionState)) without.push(selectionState);
|
||||
return { ...node, states: without };
|
||||
}
|
||||
if (without.length !== states.length) {
|
||||
return { ...node, states: without };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
this.roots = nodes;
|
||||
}
|
||||
|
||||
_onChevronClick(item, originalEvent) {
|
||||
|
||||
Reference in New Issue
Block a user