diff --git a/README.md b/README.md
index 1ab27b7..704271b 100644
--- a/README.md
+++ b/README.md
@@ -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 Code–style 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 don’t 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`
+
tree.dispatchEvent(new CustomEvent('node-click', { detail: { node, path, originalEvent: e }, bubbles: true, composed: true, cancelable: true }))}
+ >
+
+ ${hasChildren
+ ? html`
`
+ : html`
`}
+
+
${node.label}
+
+ `;
+};
+
+// 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.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a72c9cf
--- /dev/null
+++ b/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 24f0225..ecce9b6 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/tp-element.js b/tp-element.js
deleted file mode 100644
index 6195006..0000000
--- a/tp-element.js
+++ /dev/null
@@ -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);
diff --git a/tp-tree-nav.js b/tp-tree-nav.js
new file mode 100644
index 0000000..2b54a9e
--- /dev/null
+++ b/tp-tree-nav.js
@@ -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`
+
+ ${virtualize({
+ items,
+ renderItem: (item) => this._renderItem(item),
+ keyFunction: (item) => item.key,
+ })}
+
+ ${this._renderContextMenu()}
+ `;
+ }
+
+ static get properties() {
+ return {
+ roots: { type: Array },
+ defaultActions: { type: Array },
+ renderNode: { type: Function },
+ beforeContextMenu: { type: Function },
+ };
+ }
+
+ static get chevron() {
+ return svg``;
+ }
+
+ static get folder() {
+ return svg``;
+ }
+
+ static get file() {
+ return svg``;
+ }
+
+ 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`
+ this._onRowClick(item, e)}
+ @contextmenu=${(e) => this._onContextMenu(item, e)}
+ >
+
+
this._onChevronClick(item, e)}
+ >
+
+
+ ${icon ? html`
` : html`
`}
+
${node?.label ?? ''}
+
+ `;
+ }
+
+ _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`
+
+ `;
+ }
+
+ _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);