diff --git a/README.md b/README.md
index 1ab27b7..ba5361e 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,148 @@
-# tp-element
+# tp-tree-nav
+
+`tp-tree-nav` is a low-level, virtualized tree renderer for Lit. It renders what it is given and emits events; parents own state. You can wrap it directly or extend it to add opinionated behavior (selection, expansion, inline actions, empty states, etc.).
+
+## 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, inline actions, and context menu actions). Each event carries `originalEvent` so parents can cancel or coordinate.
+- **Opt-in actions**: provide `defaultActions` 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 (icons per node type, state-based styling). Common states like `expanded`/`selected` are parent-managed via `node.states` or managed mode.
+- **Helpers (pure)**: `getNodesWithState`, `clearState`, `applyStateIf` return new data; parents reassign `roots`.
+- **Default icons**: `chevron`, `folder`, `file` are available via `tp-icon`; pass string keys or custom icons.
+
+## Quick example (wrap directly)
+```js
+import './tp-tree-nav.js';
+
+const tree = document.querySelector('tp-tree-nav');
+
+tree.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.defaultActions = [
+ { label: 'Rename', action: 'rename', icon: 'pencil' },
+ { label: 'Delete', action: 'delete', icon: 'delete' },
+];
+
+tree.beforeContextMenu = (node, actions) =>
+ node.slug === 'project-a' ? actions.filter((a) => a.action !== 'delete') : actions;
+
+// simple toggle handler
+const targetPath = (path) => path.join('/');
+tree.addEventListener('node-action', (e) => {
+ if (e.detail.action !== 'toggle') return;
+ const target = targetPath(e.detail.path);
+ tree.roots = tree.applyStateIf('expanded', (_n, p) => targetPath(p) === target);
+});
+```
+
+## Two ways to use it
+
+### A) Wrap directly (keep tp-tree-nav “dumb”)
+- Manage all state yourself; set `roots` and optional `defaultActions`/`actions`.
+- Listen to `node-action` (chevron/double-click toggles, inline actions, context actions) and `node-click`, mutate your data, then reassign `roots`.
+- Use `renderNode` for custom rows; `renderEmpty` for empty states.
+- Use helpers (`applyStateIf`, `clearState`, `getNodesWithState`) for immutable transforms/queries.
+
+### B) Extend tp-tree-nav (add opinionated behavior)
+Leverage managed mode and hooks in a subclass:
+
+```js
+import { TpTreeNav } from './tp-tree-nav.js';
+
+class MyTree extends TpTreeNav {
+ constructor() {
+ super();
+ this.manageState = true; // base tracks expand/select
+ this.autoExpandNew = false; // set true to auto-expand unseen paths
+ this.selectionState = 'selected'; // state name for selection
+ this.expandOnDoubleClick = true; // toggle expand via double-click
+ this.multiSelect = false; // enable multi-select if needed
+ this.showActions = true; // render inline actions when present
+ this.renderEmpty = () => html`
No items
`;
+ }
+
+ set data(items) {
+ this.items = items; // raw items with slug/label/icon/children/actions
+ }
+
+ _renderRow(item, meta) {
+ const { node } = item;
+ const selected = meta.states.includes(this.selectionState);
+ return html`
+ this._onRowClick(item, e)}
+ >
+
+
this._onChevronClick(item, e)}
+ >
+
+
+
+
${node.label}
+ ${node.actions?.length
+ ? html`
${node.actions.map((action) => html`
+ this._onInlineAction(item, action, e)}
+ >
+ `)}
`
+ : null}
+
+ `;
+ }
+}
+
+customElements.define('my-tree', MyTree);
+```
+
+### Key props when extending
+- `items`: raw data (`slug`, `label`, `icon`, `children`, optional `actions`).
+- `manageState`: base manages expand/select when true.
+- `expandedPaths` / `selectedPaths`: arrays or Sets to seed/restore state.
+- `selectionState`: state name for selection (default `'selected'`).
+- `multiSelect`: allow multiple selections.
+- `expandOnDoubleClick`: toggle expansion on double-click (uses click detail).
+- `autoExpandNew`: auto-expand unseen nodes when first seen (default false).
+- `applyStates`: `(node, pathParts, states) => extraStates[]` to add custom states.
+- `applySelection`: override selection logic `(node, pathParts, states, originalEvent) => nextStates`.
+- `applyToggle`: override expand/collapse logic `(node, pathParts, states, originalEvent) => nextStates`.
+- `showActions`: render inline actions if provided.
+- `renderEmpty`: custom empty renderer.
+
+### Events (both modes)
+- `node-click`: emitted on row click.
+- `node-action`: emitted for toggles (`action: 'toggle'`, source chevron/double-click), inline actions, and context menu actions.
+- `node-context`: before showing context menu; `preventDefault` to cancel.
+
+### Helpers (both modes)
+- `TpTreeNav.buildTree(items, { expandedPaths, selectedPaths, selectionState, applyStates, knownPaths, autoExpandNew })` — pure helper used by managed mode; can be used externally.
+- `applyStateIf(state, predicate)`, `clearState(state)`, `getNodesWithState(state)` for immutable transforms/queries.
+
+### Data shape
+- `slug` (unique per sibling), `label`, `icon` (string key or icon data), `children` (array), optional `actions` (`{ action, label?, icon?, tooltip? }`).
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..e28c4d5 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,21 @@
{
- "name": "@tp/tp-element",
- "version": "0.0.1",
+ "name": "@tp/tp-tree-nav",
+ "version": "1.1.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-popup": "^1.0.0",
+ "@lit-labs/virtualizer": "^2.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..f2911b5
--- /dev/null
+++ b/tp-tree-nav.js
@@ -0,0 +1,686 @@
+/**
+@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%;
+ }
+
+ .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;
+ }
+ `
+ ];
+ }
+
+ render() {
+ const items = this._flattenNodes();
+
+ if (!items.length) {
+ return html`${this._renderEmpty()}`;
+ }
+
+ return html`
+
+ ${virtualize({
+ items,
+ renderItem: (item) => this._renderItem(item),
+ keyFunction: (item) => item.key,
+ })}
+
+ ${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`
+ this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} >
+
+
this._onChevronClick(item, e)} >
+
+
+ ${icon ? html`
` : html`
`}
+
${node?.label ?? ''}
+ ${this.showActions && Array.isArray(node?.actions) && node.actions.length
+ ? html`
+
+ ${node.actions.map((action) => html`
+ this._onInlineAction(item, action, e)}
+ >
+ `)}
+
+ `
+ : null}
+
+ `;
+ }
+
+ _renderContextMenu() {
+ if (!this._contextMenu) return null;
+ const { item, actions } = this._contextMenu;
+
+ return html`
+
+ `;
+ }
+
+ _renderEmpty() {
+ if (typeof this.renderEmpty === 'function') {
+ return this.renderEmpty();
+ }
+ return html`${this.emptyMessage}
`;
+ }
+
+ 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 },
+ };
+ }
+
+ 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.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._contextMenu = null;
+ this._outsideHandler = null;
+ this._keyHandler = 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);
+ }
+
+ _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);