This commit is contained in:
2025-12-16 00:50:53 +01:00
parent d121833c2a
commit 09e5b51f5c

View File

@@ -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`
<div class="tree">
${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 {
</div>
${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>
${this.showActions && Array.isArray(node?.actions) && node.actions.length
? html`
<div class="actions" part="actions">
${node.actions.map((action) => html`
<tp-icon
class="action-icon"
part="action"
.icon=${this._resolveIcon(action.icon)}
.tooltip=${action.tooltip}
@click=${(e) => this._onInlineAction(item, action, e)}
></tp-icon>
`)}
</div>
`
: null}
</div>
`;
}
@@ -201,12 +232,31 @@ export class TpTreeNav extends LitElement {
`;
}
_renderEmpty() {
if (typeof this.renderEmpty === 'function') {
return this.renderEmpty();
}
return html`<div class="empty-state" part="empty">${this.emptyMessage}</div>`;
}
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);