Files
tp-tree-nav/tp-tree-nav.js
2025-12-28 23:06:37 +01:00

464 lines
13 KiB
JavaScript

/**
@license
Copyright (c) 2025 trading_peter
This program is available under Apache License Version 2.0
*/
import '@tp/tp-icon/tp-icon.js';
import '@tp/tp-spinner/tp-spinner.js';
import '@tp/tp-popup/tp-popup-menu.js';
import { LitElement, html, css, svg } from 'lit';
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
import { Position } from '../helpers/position.js';
import { TreeUtilsMixin } from './mixins/tree-utils.js';
import { TreeFlattenMixin } from './mixins/tree-flatten.js';
import { TreeManagedStateMixin } from './mixins/tree-managed-state.js';
import { TreeLoadChildrenMixin } from './mixins/tree-load-children.js';
import { TreeRevealMixin } from './mixins/tree-reveal.js';
import { TreeContextMenuMixin } from './mixins/tree-context-menu.js';
import { TreeDnDMixin } from './mixins/tree-dnd.js';
import { TreeInteractionsMixin } from './mixins/tree-interactions.js';
const mixins = [
TreeInteractionsMixin,
TreeDnDMixin,
TreeContextMenuMixin,
TreeRevealMixin,
TreeLoadChildrenMixin,
TreeManagedStateMixin,
TreeFlattenMixin,
TreeUtilsMixin,
Position
];
/* @litElement */
const BaseElement = mixins.reduce((baseClass, mixin) => {
return mixin(baseClass);
}, LitElement);
export class TpTreeNav extends BaseElement {
static get styles() {
return [
css`
:host {
display: block;
position: relative;
--tp-tree-indent: 16px;
--tp-tree-row-height: 28px;
}
.tree {
width: 100%;
height: 100%;
}
.row {
display: flex;
align-items: center;
height: var(--tp-tree-row-height);
box-sizing: border-box;
padding: 0 8px;
cursor: default;
user-select: none;
gap: 5px;
width: 100%;
position: relative;
}
.row:hover {
background: var(--tp-tree-hover-bg, rgba(0,0,0,0.04));
}
.row:active {
background: var(--tp-tree-active-bg, rgba(0,0,0,0.06));
}
.indent {
width: calc(var(--tp-tree-indent) * var(--depth));
flex: 0 0 auto;
}
.chevron-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
cursor: pointer;
transition: transform 120ms ease;
}
.chevron-btn[hidden] {
visibility: hidden;
}
.chevron-btn.expanded {
transform: rotate(90deg);
}
.icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
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;
pointer-events: none;
}
tp-popup-menu.context-menu {
min-width: 180px;
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
pointer-events: auto;
}
.row.drag-over-inside {
background: var(--tp-tree-drag-over-bg, rgba(0,0,0,0.1));
}
.row.drag-over-before::after,
.row.drag-over-after::after {
content: "";
position: absolute;
left: calc(var(--tp-tree-indent) * var(--drag-depth, var(--depth)));
right: 0;
height: 2px;
background-color: var(--tp-tree-drag-line-color, #2196f3);
pointer-events: none;
}
.row.drag-over-before::after {
top: 0;
}
.row.drag-over-after::after {
bottom: 0;
}
.drag-clone {
opacity: 0.5;
position: absolute;
z-index: 1000;
top: -1000px;
left: -1000px;
}
`
];
}
render() {
this._flatItems = this._flattenNodes();
const items = this._flatItems;
if (!items.length) {
return html`${this._renderEmpty()}`;
}
return html`
<div class="tree" part="tree">
${virtualize({
items,
scroller: this,
renderItem: (item) => this._renderItem(item),
keyFunction: (item) => item?.key ?? '',
})}
</div>
${this._renderContextMenu()}
`;
}
_renderItem(item) {
if (!item) return null;
const { node, depth, hasChildren, expanded } = item;
const icon = this._resolveIcon(node?.icon);
const states = Array.isArray(node?.states) ? node.states : [];
const isLoading = states.includes('loading');
const custom = this.renderNode?.(item, {
depth,
states,
path: item.path,
hasChildren,
expanded,
});
if (custom) {
return custom;
}
return html`
<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)}
@dragover=${(e) => this._onDragOver(item, e)}
@dragleave=${(e) => this._onDragLeave(item, e)}
@drop=${(e) => this._onDrop(item, e)}
>
<div class="indent"></div>
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
${isLoading
? html`<tp-spinner class="icon" part="spinner"></tp-spinner>`
: html`<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>`}
</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>
`;
}
_renderContextMenu() {
if (!this._contextMenu) return null;
const { item, actions } = this._contextMenu;
return html`
<div class="context-menu-overlay">
<tp-popup-menu class="context-menu" part="context-menu">
${actions.map((action) => html`
<tp-popup-menu-item .icon=${action.icon} part="context-menu-item" @click=${(e) => this._onMenuAction(action, item, e)}>
${action?.label ?? null}
</tp-popup-menu-item>
`)}
</tp-popup-menu>
</div>
`;
}
_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 },
expandOnSingleClick: { type: Boolean },
expandOnDoubleClick: { type: Boolean },
selectOnRightClick: { type: Boolean },
allowDragAndDrop: { type: Boolean },
canDrop: { type: Function },
loadChildren: { type: Function },
initialPath: { type: String },
initialSelect: { type: Boolean },
initialScroll: { type: Boolean },
initialOpen: { type: Boolean },
};
}
static get chevron() {
return svg`<path fill="var(--tp-icon-color)" d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />`;
}
static get folder() {
return svg`<path fill="var(--tp-icon-color)" d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" />`;
}
static get file() {
return svg`<path fill="var(--tp-icon-color)" d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" />`;
}
static get defaultIcons() {
return {
chevron: TpTreeNav.chevron,
folder: TpTreeNav.folder,
file: TpTreeNav.file,
};
}
constructor() {
super();
this.roots = [];
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.expandOnSingleClick = false;
this.expandOnDoubleClick = false;
this.selectOnRightClick = false;
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;
this._dragSource = null;
this._knownPaths = new Set();
this._expandedPathSet = new Set();
this._selectedPathSet = new Set();
this._childLoadControllers = new Map();
this._childLoadSeq = 0;
}
disconnectedCallback() {
super.disconnectedCallback();
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)) {
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',
});
}
}
}
}
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))
: node?.children;
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);