From 0d2f1fb82d45051223e7e1337787e2d718c65354 Mon Sep 17 00:00:00 2001 From: pk Date: Mon, 15 Dec 2025 17:08:43 +0100 Subject: [PATCH] Initial version --- README.md | 90 ++++++++- package-lock.json | 225 ++++++++++++++++++++++ package.json | 10 +- tp-element.js | 35 ---- tp-tree-nav.js | 476 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 796 insertions(+), 40 deletions(-) create mode 100644 package-lock.json delete mode 100644 tp-element.js create mode 100644 tp-tree-nav.js 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` +
+
+ ${actions.map((action) => 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);