464 lines
13 KiB
JavaScript
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);
|