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,
|
items,
|
||||||
scroller: this,
|
scroller: this,
|
||||||
renderItem: (item) => this._renderItem(item),
|
renderItem: (item) => this._renderItem(item),
|
||||||
keyFunction: (item) => item.key,
|
keyFunction: (item) => item?.key ?? '',
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
${this._renderContextMenu()}
|
${this._renderContextMenu()}
|
||||||
@@ -165,6 +165,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_renderItem(item) {
|
_renderItem(item) {
|
||||||
|
if (!item) return null;
|
||||||
const { node, depth, hasChildren, expanded } = item;
|
const { node, depth, hasChildren, expanded } = item;
|
||||||
const icon = this._resolveIcon(node?.icon);
|
const icon = this._resolveIcon(node?.icon);
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
<div class="row" part="row" style="--depth: ${depth}"
|
<div class="row" part="row" style="--depth: ${depth}"
|
||||||
draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
|
draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
|
||||||
@click=${(e) => this._onRowClick(item, e)}
|
@click=${(e) => this._onRowClick(item, e)}
|
||||||
|
@dblclick=${(e) => this._onRowDoubleClick(item, e)}
|
||||||
@contextmenu=${(e) => this._onContextMenu(item, e)}
|
@contextmenu=${(e) => this._onContextMenu(item, e)}
|
||||||
@dragstart=${(e) => this._onDragStart(item, e)}
|
@dragstart=${(e) => this._onDragStart(item, e)}
|
||||||
@dragend=${(e) => this._onDragEnd(item, e)}
|
@dragend=${(e) => this._onDragEnd(item, e)}
|
||||||
@@ -268,6 +270,10 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
allowDragAndDrop: { type: Boolean },
|
allowDragAndDrop: { type: Boolean },
|
||||||
canDrop: { type: Function },
|
canDrop: { type: Function },
|
||||||
loadChildren: { 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.allowDragAndDrop = false;
|
||||||
this.canDrop = null;
|
this.canDrop = null;
|
||||||
this.loadChildren = null;
|
this.loadChildren = null;
|
||||||
|
this.initialPath = '';
|
||||||
|
this.initialSelect = true;
|
||||||
|
this.initialScroll = true;
|
||||||
|
this.initialOpen = false;
|
||||||
|
this._initialRevealDoneFor = '';
|
||||||
|
this._revealController = null;
|
||||||
this._contextMenu = null;
|
this._contextMenu = null;
|
||||||
this._outsideHandler = null;
|
this._outsideHandler = null;
|
||||||
this._keyHandler = null;
|
this._keyHandler = null;
|
||||||
@@ -332,14 +344,183 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
|
|
||||||
updated(changed) {
|
updated(changed) {
|
||||||
super.updated?.(changed);
|
super.updated?.(changed);
|
||||||
|
|
||||||
const managed = this.manageState;
|
const managed = this.manageState;
|
||||||
const itemsChanged = managed && changed.has('items');
|
const itemsChanged = managed && changed.has('items');
|
||||||
const expandChanged = managed && changed.has('expandedPaths');
|
const expandChanged = managed && changed.has('expandedPaths');
|
||||||
const selectChanged = managed && changed.has('selectedPaths');
|
const selectChanged = managed && changed.has('selectedPaths');
|
||||||
|
|
||||||
if (managed && (itemsChanged || expandChanged || selectChanged)) {
|
if (managed && (itemsChanged || expandChanged)) {
|
||||||
this._rebuildManagedTree();
|
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() {
|
_flattenNodes() {
|
||||||
@@ -378,14 +559,33 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
if (!node) return false;
|
if (!node) return false;
|
||||||
if (Array.isArray(node.children) && node.children.length > 0) return true;
|
if (Array.isArray(node.children) && node.children.length > 0) return true;
|
||||||
if (typeof this.loadChildren === 'function') {
|
if (typeof this.loadChildren === 'function') {
|
||||||
// If consumer provides an `isDir` hint, don't show chevrons for non-dirs.
|
if (typeof node.showAsExpandable === 'boolean') return node.showAsExpandable;
|
||||||
if (typeof node.isDir === 'boolean') return node.isDir;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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) {
|
_onRowClick(item, originalEvent) {
|
||||||
|
if (this._suppressNextClick) {
|
||||||
|
originalEvent?.stopPropagation?.();
|
||||||
|
originalEvent?.preventDefault?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
|
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
|
||||||
|
|
||||||
if (this.manageState) {
|
if (this.manageState) {
|
||||||
@@ -405,7 +605,7 @@ export class TpTreeNav extends Position(LitElement) {
|
|||||||
|
|
||||||
this._selectedPathSet = set;
|
this._selectedPathSet = set;
|
||||||
this.selectedPaths = Array.from(set);
|
this.selectedPaths = Array.from(set);
|
||||||
this._rebuildManagedTree();
|
this._applySelectionStates();
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('node-selected', {
|
this.dispatchEvent(new CustomEvent('node-selected', {
|
||||||
detail: { node: item.node, path: item.path, originalEvent },
|
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._toggleExpand(item, 'single-click', originalEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('node-click', {
|
if (!isDouble) {
|
||||||
detail: { node: item.node, path: item.path, originalEvent },
|
this.dispatchEvent(new CustomEvent('node-click', {
|
||||||
bubbles: true,
|
detail: { node: item.node, path: item.path, originalEvent },
|
||||||
composed: true,
|
bubbles: true,
|
||||||
cancelable: 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) {
|
_onChevronClick(item, originalEvent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user