Files
tp-tree-nav/tp-tree-nav.js
2025-12-17 22:17:40 +01:00

982 lines
28 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-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';
export class TpTreeNav extends Position(LitElement) {
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;
}
`
];
}
render() {
this._flatItems = this._flattenNodes();
const items = this._flatItems;
if (!items.length) {
return html`${this._renderEmpty()}`;
}
return html`
<div class="tree">
${virtualize({
items,
renderItem: (item) => this._renderItem(item),
keyFunction: (item) => item.key,
})}
</div>
${this._renderContextMenu()}
`;
}
_renderItem(item) {
const { node, depth, hasChildren, expanded } = item;
const icon = this._resolveIcon(node?.icon);
const custom = this.renderNode?.(item, {
depth,
states: Array.isArray(node?.states) ? node.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)}
@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)} >
<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 },
expandOnDoubleClick: { type: Boolean },
selectOnRightClick: { type: Boolean },
allowDragAndDrop: { type: Boolean },
canDrop: { type: Function },
};
}
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.expandOnDoubleClick = false;
this.selectOnRightClick = false;
this.allowDragAndDrop = false;
this.canDrop = 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();
}
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 || selectChanged)) {
this._rebuildManagedTree();
}
}
_flattenNodes() {
const results = [];
const roots = Array.isArray(this.roots) ? this.roots : [];
const walk = (nodes, depth, path) => {
if (!Array.isArray(nodes)) return;
nodes.forEach((node, index) => {
const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug];
const states = Array.isArray(node?.states) ? node.states : [];
const hasChildren = Array.isArray(node?.children) && node.children.length > 0;
const expanded = states.includes('expanded');
results.push({
node,
depth,
path: nextPath,
hasChildren,
expanded,
key: nextPath.join('/'),
});
if (hasChildren && expanded) {
walk(node.children, depth + 1, nextPath);
}
});
};
walk(roots, 0, []);
return results;
}
_onRowClick(item, originalEvent) {
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,
}));
}
_onChevronClick(item, originalEvent) {
originalEvent.stopPropagation();
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) {
originalEvent.preventDefault();
if (this.selectOnRightClick && this.manageState) {
const pathStr = item.path.join('/');
const set = new Set(this._selectedPathSet);
let changed = false;
if (this.multiSelect) {
if (!set.has(pathStr)) {
set.add(pathStr);
changed = true;
}
} else {
if (!set.has(pathStr) || set.size !== 1) {
set.clear();
set.add(pathStr);
changed = true;
}
}
if (changed) {
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,
}));
}
}
const contextEvent = new CustomEvent('node-context', {
detail: { item, originalEvent },
bubbles: true,
composed: true,
cancelable: true,
});
this.dispatchEvent(contextEvent);
if (contextEvent.defaultPrevented) return;
const baseActions = this._mergeActions(
this.defaultActions,
this._normalizeActions(item.node?.actions)
);
const maybeModified = this.beforeContextMenu
? this.beforeContextMenu(item, baseActions)
: baseActions;
const actions = await Promise.resolve(maybeModified);
if (actions === false) return;
const x = originalEvent.clientX;
const y = originalEvent.clientY;
this._contextMenu = { item, actions: Array.isArray(actions) ? actions : baseActions };
this.requestUpdate();
await this.updateComplete;
const menu = this.shadowRoot.querySelector('.context-menu');
if (menu) {
const anchor = {
getBoundingClientRect: () => ({
top: y, bottom: y, left: x, right: x, width: 0, height: 0
})
};
this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' });
}
this._attachOutsideHandlers();
}
_dispatchAction(action, item, source, originalEvent) {
const ev = new CustomEvent('node-action', {
detail: { action, item, source, originalEvent },
bubbles: true,
composed: true,
cancelable: true,
});
this.dispatchEvent(ev);
}
_onMenuAction(action, item, originalEvent) {
originalEvent.stopPropagation();
this._dispatchAction(action?.action, item, 'context-menu', originalEvent);
this._closeContextMenu();
}
_onInlineAction(item, action, originalEvent) {
originalEvent.stopPropagation();
this._dispatchAction(action?.action, item, 'inline-action', originalEvent);
}
_onDragStart(item, e) {
if (!this.allowDragAndDrop) {
e.preventDefault();
return;
}
this._dragSource = item;
e.dataTransfer.effectAllowed = 'move';
const row = e.currentTarget;
const rect = row.getBoundingClientRect();
const clone = row.cloneNode(true);
clone.classList.add('drag-clone');
clone.setAttribute('part', 'drag-clone');
clone.style.width = `${rect.width}px`;
clone.style.height = `${rect.height}px`;
clone.style.top = '-1000px';
clone.style.left = '-1000px';
this.shadowRoot.appendChild(clone);
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
e.dataTransfer.setDragImage(clone, offsetX, offsetY);
setTimeout(() => {
clone.remove();
}, 0);
}
_onDragEnd(item, e) {
this._dragSource = null;
}
_onDragOver(item, e) {
if (!this.allowDragAndDrop || !this._dragSource) return;
e.preventDefault(); // Allow drop
const row = e.currentTarget;
const rect = row.getBoundingClientRect();
const y = e.clientY - rect.top;
const h = rect.height;
let position = 'inside';
if (y < h * 0.25) position = 'before';
else if (y > h * 0.75) position = 'after';
// Don't allow dropping on itself
if (this._dragSource.key === item.key) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
// Avoid redundant drop target (After Expanded Parent vs Before First Child)
if (position === 'after' && item.expanded && item.hasChildren) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
// Resolve logical target (handle expanded parent case and unindent)
const { target: logicalTarget, position: logicalPosition, depth: dragDepth } = this._resolveDropTarget(item, position, e);
// Check for no-op
if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
// Check user validation
if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
e.dataTransfer.dropEffect = 'move';
this._clearDragClasses(row);
row.classList.add(`drag-over-${position}`);
if (dragDepth !== undefined) {
row.style.setProperty('--drag-depth', dragDepth);
}
}
_onDragLeave(item, e) {
const row = e.currentTarget;
if (row.contains(e.relatedTarget)) return;
this._clearDragClasses(row);
}
_onDrop(item, e) {
if (!this.allowDragAndDrop || !this._dragSource) return;
e.preventDefault();
const row = e.currentTarget;
this._clearDragClasses(row);
const rect = row.getBoundingClientRect();
const y = e.clientY - rect.top;
const h = rect.height;
let position = 'inside';
if (y < h * 0.25) position = 'before';
else if (y > h * 0.75) position = 'after';
if (this._dragSource.key === item.key) return;
// Avoid redundant drop target (After Expanded Parent vs Before First Child)
if (position === 'after' && item.expanded && item.hasChildren) {
return;
}
// Resolve logical target (handle expanded parent case and unindent)
const { target: logicalTarget, position: logicalPosition } = this._resolveDropTarget(item, position, e);
if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) {
return;
}
if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) {
return;
}
this.dispatchEvent(new CustomEvent('node-drop', {
detail: {
source: this._dragSource,
target: logicalTarget,
position: logicalPosition,
originalEvent: e
},
bubbles: true,
composed: true
}));
this._dragSource = null;
}
_isNoOp(source, target, position) {
if (!source || !target) return true;
if (source.key === target.key) return true;
const srcPathStr = source.path.slice(0, -1).join('/');
const tgtPathStr = target.path.slice(0, -1).join('/');
if (srcPathStr !== tgtPathStr) return false;
const siblings = this._getSiblings(source.path);
if (!siblings) return false;
const srcIdx = siblings.indexOf(source.node);
const tgtIdx = siblings.indexOf(target.node);
if (srcIdx === -1 || tgtIdx === -1) return false;
if (position === 'before') {
if (srcIdx === tgtIdx - 1) return true;
}
if (position === 'after') {
if (srcIdx === tgtIdx + 1) return true;
}
return false;
}
_getSiblings(path) {
if (path.length === 1) return this.roots;
let nodes = this.roots;
for (let i = 0; i < path.length - 1; i++) {
const slug = path[i];
const node = nodes.find((n, idx) => (n.slug ?? `${idx}`) === slug);
if (!node) return null;
nodes = node.children || [];
}
return nodes;
}
_resolveDropTarget(item, position, e) {
if (!this._flatItems) return { target: item, position };
const index = this._flatItems.findIndex(i => i.key === item.key);
if (index === -1) return { target: item, position };
const currentItem = this._flatItems[index];
// Handle Unindent on "After"
// Only if we are at the bottom of a block (next item has lower depth or is end of list)
if (position === 'after' && e) {
const nextItem = this._flatItems[index + 1];
const currentDepth = currentItem.depth;
const nextDepth = nextItem ? nextItem.depth : -1; // -1 allows unindenting to root (depth 0)
if (nextDepth < currentDepth) {
// We are at a step down. Calculate target depth from mouse X.
const indent = 16; // Hardcoded for now, matching CSS default
const padding = 8; // Matching CSS padding
const rowRect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rowRect.left;
// Calculate desired depth based on mouse position
let targetDepth = Math.floor((mouseX - padding) / indent);
// Clamp target depth
// Can't be deeper than current
// Can't be shallower than next item's depth (visual constraint)
// Actually, we can target any ancestor that ends here.
// The ancestors end at depths: currentDepth, currentDepth-1, ... nextDepth.
// Example:
// A (0)
// B (1)
// C (2) <- Item. Next is D (0).
// We can drop after C (depth 2), after B (depth 1), after A (depth 0).
// So valid depths are [nextDepth ... currentDepth].
// Note: nextDepth is the depth of the *next sibling* of the ancestor we target.
// If next is D(0), we can target A(0).
// Ensure targetDepth is within valid range
if (targetDepth < nextDepth) targetDepth = nextDepth;
if (targetDepth > currentDepth) targetDepth = currentDepth;
if (targetDepth < currentDepth) {
// Find the ancestor at targetDepth
// Walk backwards from current item? No, ancestors are before.
// But we want the ancestor that *contains* current item.
// We can walk backwards from index.
let ancestor = currentItem;
for (let i = index; i >= 0; i--) {
if (this._flatItems[i].depth === targetDepth) {
ancestor = this._flatItems[i];
break;
}
}
return { target: ancestor, position: 'after', depth: targetDepth };
}
}
}
return { target: currentItem, position };
}
_clearDragClasses(row) {
row.classList.remove('drag-over-inside', 'drag-over-before', 'drag-over-after');
row.style.removeProperty('--drag-depth');
}
_attachOutsideHandlers() {
if (this._outsideHandler) return;
this._outsideHandler = (e) => {
const menu = this.shadowRoot.querySelector('.context-menu');
if (menu && e.composedPath().includes(menu)) return;
this._closeContextMenu();
};
this._keyHandler = (e) => {
if (e.key === 'Escape') {
this._closeContextMenu();
}
};
window.addEventListener('pointerdown', this._outsideHandler, true);
window.addEventListener('keydown', this._keyHandler, true);
}
_removeOutsideHandlers() {
if (this._outsideHandler) {
window.removeEventListener('pointerdown', this._outsideHandler, true);
this._outsideHandler = null;
}
if (this._keyHandler) {
window.removeEventListener('keydown', this._keyHandler, true);
this._keyHandler = null;
}
}
_closeContextMenu() {
this._contextMenu = null;
this.requestUpdate();
this._removeOutsideHandlers();
}
_normalizeActions(actions) {
if (!actions) return [];
if (Array.isArray(actions)) return actions;
if (typeof actions === 'object') return Object.values(actions);
return [];
}
_mergeActions(defaults = [], nodeActions = []) {
const map = new Map();
const add = (list) => {
list?.forEach((a) => {
if (a && a.action) {
map.set(a.action, a);
}
});
};
add(defaults);
add(nodeActions);
return Array.from(map.values());
}
_resolveIcon(icon) {
if (!icon) return null;
if (typeof icon === 'string') {
return TpTreeNav.defaultIcons[icon] ?? null;
}
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) => {
if (Array.isArray(node?.states) && node.states.includes(state)) {
matches.push({ node, path });
}
});
return matches;
}
clearState(state) {
const { nodes } = this._mapTree(this.roots, (node) => {
const states = Array.isArray(node?.states)
? node.states.filter((s) => s !== state)
: [];
const changed = (node.states?.length ?? 0) !== states.length;
return changed ? { ...node, states } : node;
});
return nodes;
}
applyStateIf(state, predicate) {
if (typeof predicate !== 'function') return this.roots;
const { nodes } = this._mapTree(this.roots, (node, path) => {
const states = Array.isArray(node?.states) ? [...node.states] : [];
if (predicate(node, path) && !states.includes(state)) {
states.push(state);
return { ...node, states };
}
return node;
});
return nodes;
}
_walkNodes(nodes, path, visitor) {
if (!Array.isArray(nodes)) return;
nodes.forEach((node, index) => {
const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug];
visitor(node, nextPath);
if (Array.isArray(node?.children) && node.children.length) {
this._walkNodes(node.children, nextPath, visitor);
}
});
}
_mapTree(nodes, mapper, path = []) {
if (!Array.isArray(nodes)) return { nodes: [], changed: false };
let changed = false;
const mapped = nodes.map((node, index) => {
const slug = node?.slug ?? `${index}`;
const nextPath = [...path, slug];
const mappedNode = mapper(node, nextPath) ?? node;
const { nodes: childNodes, changed: childChanged } = this._mapTree(
node?.children,
mapper,
nextPath
);
const children = childChanged ? childNodes : node?.children;
const nodeChanged = mappedNode !== node || childChanged;
if (nodeChanged) {
changed = true;
return { ...mappedNode, children };
}
return mappedNode;
});
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);