diff --git a/tp-tree-nav.js b/tp-tree-nav.js
index 21b6414..4b0d9ef 100644
--- a/tp-tree-nav.js
+++ b/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 ?? '',
})}
${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) {
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) {