diff --git a/tp-tree-nav.js b/tp-tree-nav.js
index 84809d3..6dda1af 100644
--- a/tp-tree-nav.js
+++ b/tp-tree-nav.js
@@ -80,6 +80,18 @@ export class TpTreeNav extends LitElement {
white-space: nowrap;
}
+ .actions {
+ display: flex;
+ gap: 6px;
+ flex: 0 0 auto;
+ }
+
+ .empty-state {
+ padding: 16px;
+ text-align: center;
+ color: var(--tp-tree-empty-color, rgba(0,0,0,0.56));
+ }
+
.context-menu-overlay {
position: absolute;
inset: 0;
@@ -90,7 +102,6 @@ export class TpTreeNav extends LitElement {
position: absolute;
min-width: 180px;
background: var(--tp-tree-menu-bg, #fff);
- color: inherit;
border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1));
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
@@ -129,6 +140,10 @@ export class TpTreeNav extends LitElement {
render() {
const items = this._flattenNodes();
+ if (!items.length) {
+ return html`${this._renderEmpty()}`;
+ }
+
return html`
${virtualize({
@@ -150,6 +165,7 @@ export class TpTreeNav extends LitElement {
states: Array.isArray(node?.states) ? node.states : [],
path: item.path,
hasChildren,
+ expanded,
});
if (custom) {
@@ -175,6 +191,21 @@ export class TpTreeNav extends LitElement {
${icon ? html`` : html``}
${node?.label ?? ''}
+ ${this.showActions && Array.isArray(node?.actions) && node.actions.length
+ ? html`
+
+ ${node.actions.map((action) => html`
+ this._onInlineAction(item, action, e)}
+ >
+ `)}
+
+ `
+ : null}
`;
}
@@ -201,12 +232,31 @@ export class TpTreeNav extends LitElement {
`;
}
+ _renderEmpty() {
+ if (typeof this.renderEmpty === 'function') {
+ return this.renderEmpty();
+ }
+ return html`${this.emptyMessage}
`;
+ }
+
static get properties() {
return {
roots: { type: Array },
defaultActions: { type: Array },
renderNode: { type: Function },
beforeContextMenu: { type: Function },
+ renderEmpty: { type: Function },
+ items: { type: Array },
+ manageState: { type: Boolean },
+ multiSelect: { type: Boolean },
+ expandedPaths: { type: Array },
+ selectedPaths: { type: Array },
+ selectionState: { type: String },
+ autoExpandNew: { type: Boolean },
+ applyStates: { type: Function },
+ emptyMessage: { type: String },
+ showActions: { type: Boolean },
+ expandOnDoubleClick: { type: Boolean },
};
}
@@ -236,9 +286,24 @@ export class TpTreeNav extends LitElement {
this.defaultActions = [];
this.renderNode = null;
this.beforeContextMenu = null;
+ this.renderEmpty = null;
+ this.items = null;
+ this.manageState = false;
+ this.multiSelect = false;
+ this.expandedPaths = [];
+ this.selectedPaths = [];
+ this.selectionState = 'selected';
+ this.autoExpandNew = false;
+ this.applyStates = null;
+ this.emptyMessage = 'No items';
+ this.showActions = false;
+ this.expandOnDoubleClick = false;
this._contextMenu = null;
this._outsideHandler = null;
this._keyHandler = null;
+ this._knownPaths = new Set();
+ this._expandedPathSet = new Set();
+ this._selectedPathSet = new Set();
}
disconnectedCallback() {
@@ -246,6 +311,18 @@ export class TpTreeNav extends LitElement {
this._removeOutsideHandlers();
}
+ 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)) {
+ this._rebuildManagedTree();
+ }
+ }
+
_flattenNodes() {
const results = [];
const roots = Array.isArray(this.roots) ? this.roots : [];
@@ -279,18 +356,65 @@ export class TpTreeNav extends LitElement {
}
_onRowClick(item, originalEvent) {
- const ev = new CustomEvent('node-click', {
+ const isDouble = this.expandOnDoubleClick && originalEvent?.detail === 2;
+
+ if (this.manageState) {
+ const pathStr = item.path.join('/');
+ const set = new Set(this._selectedPathSet);
+
+ if (this.multiSelect) {
+ if (set.has(pathStr)) {
+ set.delete(pathStr);
+ } else {
+ set.add(pathStr);
+ }
+ } else {
+ set.clear();
+ set.add(pathStr);
+ }
+
+ this._selectedPathSet = set;
+ this.selectedPaths = Array.from(set);
+ this._rebuildManagedTree();
+
+ this.dispatchEvent(new CustomEvent('node-selected', {
+ detail: { node: item.node, path: item.path, originalEvent },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ if (isDouble) {
+ this._toggleExpand(item, 'double-click', originalEvent);
+ }
+
+ this.dispatchEvent(new CustomEvent('node-click', {
detail: { node: item.node, path: item.path, originalEvent },
bubbles: true,
composed: true,
cancelable: true,
- });
- this.dispatchEvent(ev);
+ }));
}
_onChevronClick(item, originalEvent) {
originalEvent.stopPropagation();
- this._dispatchAction('toggle', item, 'chevron', originalEvent);
+ this._toggleExpand(item, 'chevron', originalEvent);
+ }
+
+ _toggleExpand(item, source, originalEvent) {
+ if (this.manageState) {
+ const pathStr = item.path.join('/');
+ const set = new Set(this._expandedPathSet);
+ if (set.has(pathStr)) {
+ set.delete(pathStr);
+ } else {
+ set.add(pathStr);
+ }
+ this._expandedPathSet = set;
+ this.expandedPaths = Array.from(set);
+ this._rebuildManagedTree();
+ }
+ this._dispatchAction('toggle', item, source, originalEvent);
}
async _onContextMenu(item, originalEvent) {
@@ -343,6 +467,11 @@ export class TpTreeNav extends LitElement {
this._closeContextMenu();
}
+ _onInlineAction(item, action, originalEvent) {
+ originalEvent.stopPropagation();
+ this._dispatchAction(action?.action, item, 'inline-action', originalEvent);
+ }
+
_attachOutsideHandlers() {
if (this._outsideHandler) return;
this._outsideHandler = (e) => {
@@ -405,6 +534,28 @@ export class TpTreeNav extends LitElement {
return icon;
}
+ _rebuildManagedTree() {
+ this._expandedPathSet = new Set(this.expandedPaths || this._expandedPathSet);
+ this._selectedPathSet = new Set(this.selectedPaths || this._selectedPathSet);
+
+ const { nodes, allPaths, expandedPaths, selectedPaths } = TpTreeNav.buildTree(
+ Array.isArray(this.items) ? this.items : [],
+ {
+ expandedPaths: this._expandedPathSet,
+ selectedPaths: this._selectedPathSet,
+ selectionState: this.selectionState,
+ applyStates: this.applyStates,
+ knownPaths: this._knownPaths,
+ autoExpandNew: this.autoExpandNew,
+ }
+ );
+
+ this._knownPaths = allPaths;
+ this._expandedPathSet = new Set([...expandedPaths].filter((p) => allPaths.has(p)));
+ this._selectedPathSet = new Set([...selectedPaths].filter((p) => allPaths.has(p)));
+ this.roots = nodes;
+ }
+
getNodesWithState(state) {
const matches = [];
this._walkNodes(this.roots, [], (node, path) => {
@@ -477,6 +628,58 @@ export class TpTreeNav extends LitElement {
});
return { nodes: mapped, changed };
}
+
+ static buildTree(items = [], options = {}) {
+ const {
+ expandedPaths = new Set(),
+ selectedPaths = new Set(),
+ selectionState = 'selected',
+ applyStates,
+ knownPaths,
+ autoExpandNew = false,
+ } = options;
+
+ const exp = expandedPaths instanceof Set ? new Set(expandedPaths) : new Set(expandedPaths);
+ const sel = selectedPaths instanceof Set ? new Set(selectedPaths) : new Set(selectedPaths);
+ const allPaths = new Set();
+
+ const mapNode = (node, parentPath = '') => {
+ const segment = node?.slug ?? '';
+ const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
+ allPaths.add(fullPath);
+
+ if (autoExpandNew && knownPaths && !knownPaths.has(fullPath)) {
+ exp.add(fullPath);
+ }
+
+ const states = [];
+ if (exp.has(fullPath)) states.push('expanded');
+ if (sel.has(fullPath) && selectionState) states.push(selectionState);
+
+ const extraStates = applyStates ? applyStates(node, fullPath.split('/'), states) : null;
+ if (Array.isArray(extraStates)) {
+ extraStates.forEach((s) => {
+ if (s && !states.includes(s)) states.push(s);
+ });
+ }
+
+ const children = Array.isArray(node?.children)
+ ? node.children.map((child) => mapNode(child, fullPath))
+ : [];
+
+ return {
+ ...node,
+ states,
+ fullPath,
+ children,
+ source: node,
+ };
+ };
+
+ const nodes = Array.isArray(items) ? items.map((n) => mapNode(n, '')) : [];
+
+ return { nodes, allPaths, expandedPaths: exp, selectedPaths: sel };
+ }
}
window.customElements.define('tp-tree-nav', TpTreeNav);