Initial version

This commit is contained in:
2025-12-15 17:08:43 +01:00
parent a2cd7cf793
commit 0d2f1fb82d
5 changed files with 796 additions and 40 deletions

View File

@@ -1 +1,89 @@
# tp-element
# tp-tree-nav
`tp-tree-nav` is a low-level, virtualized tree renderer for Lit. It is intentionally “dumb”: it only renders what it is given and emits events. Parents own all state (expanded/selected/custom), perform mutations, and pass updated data back in. This makes it a flexible foundation for building higher-level trees (e.g., VS Codestyle explorer, Git ignore/selected indicators, custom icon trees).
## Key ideas
- **Multiple roots**: pass a `roots` array; each node may have `children`.
- **Virtualized list**: uses `@lit-labs/virtualizer` to render a flat, indented list for large trees.
- **Events only**: emits `node-click`, `node-context`, and `node-action` (for chevron toggle and context menu actions). Every event includes `originalEvent` so parents can cancel or coordinate.
- **Opt-in actions**: provide `defaultActions` (array of action objects), and per-node `actions`; node actions override defaults on the same `action` key. Use `beforeContextMenu(node, actions)` to modify or block the menu.
- **Custom render**: `renderNode(node, meta)` lets you override row rendering (e.g., icons per node type, state-based styling). Common states like `expanded`/`collapsed` are parent-managed via `node.states`.
- **Helper methods (pure)**: `getNodesWithState`, `clearState`, `applyStateIf` return data; they dont mutate internal state—parents update `roots`.
- **Default icons**: `chevron`, `folder`, `file` are available via `tp-icon`; pass string keys or custom icons.
## Usage example
```js
import './tp-tree-nav.js';
const tree = document.querySelector('tp-tree-nav');
const roots = [
{
label: 'Project A',
slug: 'project-a',
states: ['expanded'],
icon: 'folder',
children: [
{ label: 'main.js', slug: 'main-js', icon: 'file', states: [] },
{
label: 'src',
slug: 'src',
icon: 'folder',
states: ['expanded'],
children: [
{ label: 'index.js', slug: 'index-js', icon: 'file', states: [] },
],
},
],
},
];
tree.roots = roots;
tree.defaultActions = [
{ label: 'Rename', action: 'rename', icon: 'pencil' },
{ label: 'Delete', action: 'delete', icon: 'delete' },
];
tree.beforeContextMenu = (node, actions) => {
// Example: hide delete on roots
if (node.slug === 'project-a') {
return actions.filter((a) => a.action !== 'delete');
}
return actions;
};
tree.renderNode = (node, { depth, states, path, hasChildren }) => {
const selected = states.includes('selected');
return html`
<div
class="row ${selected ? 'selected' : ''}"
style="--depth: ${depth}"
@click=${(e) => tree.dispatchEvent(new CustomEvent('node-click', { detail: { node, path, originalEvent: e }, bubbles: true, composed: true, cancelable: true }))}
>
<div class="indent"></div>
${hasChildren
? html`<tp-icon class="icon" .icon=${TpTreeNav.chevron}></tp-icon>`
: html`<span class="icon" aria-hidden="true"></span>`}
<tp-icon class="icon" .icon=${tree._resolveIcon(node.icon)}></tp-icon>
<div class="label">${node.label}</div>
</div>
`;
};
// Listen to actions (toggle, context menu items, etc.)
tree.addEventListener('node-action', (e) => {
const { action, node, path } = e.detail;
if (action === 'toggle') {
// Parent mutates data: flip expanded state and reassign roots
const next = tree.applyStateIf('expanded', (n, p) => p.join('/') === path.join('/'));
tree.roots = next;
}
});
```
## Building higher-level trees
Wrap `tp-tree-nav` in a specialized component (e.g., file explorer) that:
- Tracks expansion/selection state on nodes.
- Supplies icons per node type via `renderNode` or `icon` strings.
- Defines default/context actions relevant to the domain.
- Handles action events to mutate the source data and pass updated `roots` back.

225
package-lock.json generated Normal file
View File

@@ -0,0 +1,225 @@
{
"name": "@tp/tp-tree-nav",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tp/tp-tree-nav",
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"@tp/tp-icon": "^1.0.1",
"@tp/tp-spinner": "^1.0.0",
"lit": "^3.0.0"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz",
"integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz",
"integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.4.0"
}
},
"node_modules/@tp/helpers": {
"version": "1.3.0",
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Fhelpers/-/1.3.0/helpers-1.3.0.tgz",
"integrity": "sha512-mOAVP45kkEYXwonaOd5jkFQLX1nbeKtl8YX8FpL2ytON0cOSsh6TUAbCEcMU5xqgyD6L1ZEZNvxCjhOKOKdGyA==",
"license": "Apache-2.0"
},
"node_modules/@tp/tp-icon": {
"version": "1.0.1",
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-icon/-/1.0.1/tp-icon-1.0.1.tgz",
"integrity": "sha512-rBbQoXZ5t35F7yIbPAEGAlDscZhxLZ5/o229kyiBBrXvCrc+aVOsetSwF1jPeBSmb57h2PfinIvQhtMARwWHoA==",
"license": "Apache-2.0",
"dependencies": {
"@tp/tp-tooltip": "^1.0.0",
"lit": "^2.2.0"
}
},
"node_modules/@tp/tp-icon/node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@tp/tp-icon/node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-icon/node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-icon/node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/@tp/tp-spinner": {
"version": "1.0.0",
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-spinner/-/1.0.0/tp-spinner-1.0.0.tgz",
"integrity": "sha512-/OcQNTxeTQ4u3YYiWZ4GO6CjKbG2N0Oy/En8ryt0E5ggdR+GxW0Z6w/6fHl9qMnChIcZ3sY5XzBmazSP8ISU1Q==",
"license": "Apache-2.0",
"dependencies": {
"lit": "^2.2.0"
}
},
"node_modules/@tp/tp-spinner/node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@tp/tp-spinner/node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-spinner/node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-spinner/node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/@tp/tp-tooltip": {
"version": "1.0.0",
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-tooltip/-/1.0.0/tp-tooltip-1.0.0.tgz",
"integrity": "sha512-wal/DPJH73rz9RbHg66ZciZUyjqfeTKMSImEVWczwjXGoPTG9n5FL5+tPyikpgFr5KDhDKlW8/Q0niBbGnc5KA==",
"license": "Apache-2.0",
"dependencies": {
"@tp/helpers": "^1.0.0",
"lit": "^2.2.0"
}
},
"node_modules/@tp/tp-tooltip/node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"node_modules/@tp/tp-tooltip/node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-tooltip/node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/@tp/tp-tooltip/node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/lit": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz",
"integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.4.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz",
"integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
}
}
}

View File

@@ -1,18 +1,20 @@
{
"name": "@tp/tp-element",
"version": "0.0.1",
"name": "@tp/tp-tree-nav",
"version": "1.0.0",
"description": "",
"main": "tp-element.js",
"main": "tp-tree-nav.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://gitea.codeblob.work/tp-elements/tp-element.git"
"url": "https://gitea.codeblob.work/tp-elements/tp-tree-nav.git"
},
"author": "trading_peter",
"license": "Apache-2.0",
"dependencies": {
"@tp/tp-icon": "^1.0.1",
"@tp/tp-spinner": "^1.0.0",
"lit": "^3.0.0"
}
}

View File

@@ -1,35 +0,0 @@
/**
@license
Copyright (c) 2024 trading_peter
This program is available under Apache License Version 2.0
*/
import { LitElement, html, css } from 'lit';
class TpElement extends LitElement {
static get styles() {
return [
css`
:host {
display: block;
}
`
];
}
render() {
const { } = this;
return html`
`;
}
static get properties() {
return { };
}
}
window.customElements.define('tp-element', TpElement);

476
tp-tree-nav.js Normal file
View File

@@ -0,0 +1,476 @@
/**
@license
Copyright (c) 2025 trading_peter
This program is available under Apache License Version 2.0
*/
import { LitElement, html, css, svg } from 'lit';
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
class TpTreeNav extends 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;
}
.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 {
margin-right: 6px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-menu-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.context-menu {
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);
padding: 4px 0;
pointer-events: auto;
z-index: 10;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 8px 12px;
cursor: pointer;
text-align: left;
box-sizing: border-box;
font: inherit;
}
.menu-item:hover {
background: var(--tp-tree-menu-hover-bg, rgba(0,0,0,0.06));
}
.menu-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
`
];
}
render() {
const items = this._flattenNodes();
return html`
<div class="tree">
${virtualize({
items,
renderItem: (item) => this._renderItem(item),
keyFunction: (item) => item.key,
})}
</div>
${this._renderContextMenu()}
`;
}
static get properties() {
return {
roots: { type: Array },
defaultActions: { type: Array },
renderNode: { type: Function },
beforeContextMenu: { 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._contextMenu = null;
this._outsideHandler = null;
this._keyHandler = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this._removeOutsideHandlers();
}
_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;
}
_renderItem(item) {
const { node, depth, hasChildren, expanded } = item;
const icon = this._resolveIcon(node?.icon);
const custom = this.renderNode?.(node, {
depth,
states: Array.isArray(node?.states) ? node.states : [],
path: item.path,
hasChildren,
});
if (custom) {
return custom;
}
return html`
<div
class="row"
part="row"
style="--depth: ${depth}"
@click=${(e) => this._onRowClick(item, e)}
@contextmenu=${(e) => this._onContextMenu(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>
</div>
`;
}
_onRowClick(item, originalEvent) {
const ev = 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);
}
async _onContextMenu(item, originalEvent) {
originalEvent.preventDefault();
const contextEvent = new CustomEvent('node-context', {
detail: { node: item.node, path: item.path, 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.node, baseActions)
: baseActions;
const actions = await Promise.resolve(maybeModified);
if (actions === false) return;
const rect = this.getBoundingClientRect();
const x = originalEvent.clientX - rect.left;
const y = originalEvent.clientY - rect.top;
this._contextMenu = { x, y, item, actions: Array.isArray(actions) ? actions : baseActions };
this.requestUpdate();
this._attachOutsideHandlers();
}
_dispatchAction(action, item, source, originalEvent) {
const ev = new CustomEvent('node-action', {
detail: { action, node: item.node, path: item.path, source, originalEvent },
bubbles: true,
composed: true,
cancelable: true,
});
this.dispatchEvent(ev);
}
_renderContextMenu() {
if (!this._contextMenu) return null;
const { x, y, item, actions } = this._contextMenu;
return html`
<div class="context-menu-overlay">
<div class="context-menu" part="context-menu" style="left:${x}px; top:${y}px;">
${actions.map((action) => html`
<button
class="menu-item"
part="context-menu-item"
@click=${(e) => this._onMenuAction(action, item, e)}
>
${action?.icon ? html`<tp-icon class="menu-icon" part="context-menu-icon" .icon=${this._resolveIcon(action.icon)}></tp-icon>` : html`<span class="menu-icon" part="context-menu-icon" aria-hidden="true"></span>`}
<span>${action?.label ?? ''}</span>
</button>
`)}
</div>
</div>
`;
}
_onMenuAction(action, item, originalEvent) {
originalEvent.stopPropagation();
this._dispatchAction(action?.action, item, 'context-menu', originalEvent);
this._closeContextMenu();
}
_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;
}
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 };
}
}
window.customElements.define('tp-tree-nav', TpTreeNav);