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` +
+ + ${actions.map((action) => html` + this._onMenuAction(action, item, e)}> + ${action?.label ?? null} + + `)} + +
+ `; + } + + _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);