Lots of improvements
This commit is contained in:
149
README.md
149
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`<div class="empty">No items</div>`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
class="row ${selected ? 'selected' : ''}"
|
||||
style="--depth:${meta.depth}"
|
||||
@click=${(e) => this._onRowClick(item, e)}
|
||||
>
|
||||
<div class="indent"></div>
|
||||
<div
|
||||
class="chevron-btn ${meta.expanded ? 'expanded' : ''}"
|
||||
?hidden=${!meta.hasChildren}
|
||||
@click=${(e) => this._onChevronClick(item, e)}
|
||||
>
|
||||
<tp-icon class="icon" .icon=${TpTreeNav.chevron}></tp-icon>
|
||||
</div>
|
||||
<tp-icon class="icon" .icon=${this._resolveIcon(node.icon)}></tp-icon>
|
||||
<div class="label">${node.label}</div>
|
||||
${node.actions?.length
|
||||
? html`<div class="actions">${node.actions.map((action) => html`
|
||||
<tp-icon
|
||||
class="action-icon"
|
||||
.icon=${this._resolveIcon(action.icon)}
|
||||
@click=${(e) => this._onInlineAction(item, action, e)}
|
||||
></tp-icon>
|
||||
`)}</div>`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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? }`).
|
||||
|
||||
225
package-lock.json
generated
Normal file
225
package-lock.json
generated
Normal file
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"name": "@tp/tp-tree-nav",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tp/tp-tree-nav",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tp/tp-icon": "^1.0.1",
|
||||
"@tp/tp-spinner": "^1.0.0",
|
||||
"lit": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz",
|
||||
"integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz",
|
||||
"integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/helpers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Fhelpers/-/1.3.0/helpers-1.3.0.tgz",
|
||||
"integrity": "sha512-mOAVP45kkEYXwonaOd5jkFQLX1nbeKtl8YX8FpL2ytON0cOSsh6TUAbCEcMU5xqgyD6L1ZEZNvxCjhOKOKdGyA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tp/tp-icon": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-icon/-/1.0.1/tp-icon-1.0.1.tgz",
|
||||
"integrity": "sha512-rBbQoXZ5t35F7yIbPAEGAlDscZhxLZ5/o229kyiBBrXvCrc+aVOsetSwF1jPeBSmb57h2PfinIvQhtMARwWHoA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tp/tp-tooltip": "^1.0.0",
|
||||
"lit": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-icon/node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
|
||||
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-icon/node_modules/lit": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
|
||||
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-icon/node_modules/lit-element": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
|
||||
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-icon/node_modules/lit-html": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
|
||||
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-spinner": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-spinner/-/1.0.0/tp-spinner-1.0.0.tgz",
|
||||
"integrity": "sha512-/OcQNTxeTQ4u3YYiWZ4GO6CjKbG2N0Oy/En8ryt0E5ggdR+GxW0Z6w/6fHl9qMnChIcZ3sY5XzBmazSP8ISU1Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lit": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-spinner/node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
|
||||
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-spinner/node_modules/lit": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
|
||||
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-spinner/node_modules/lit-element": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
|
||||
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-spinner/node_modules/lit-html": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
|
||||
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-tooltip": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://gitea.codeblob.work/api/packages/tp-elements/npm/%40tp%2Ftp-tooltip/-/1.0.0/tp-tooltip-1.0.0.tgz",
|
||||
"integrity": "sha512-wal/DPJH73rz9RbHg66ZciZUyjqfeTKMSImEVWczwjXGoPTG9n5FL5+tPyikpgFr5KDhDKlW8/Q0niBbGnc5KA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tp/helpers": "^1.0.0",
|
||||
"lit": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-tooltip/node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
|
||||
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-tooltip/node_modules/lit": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
|
||||
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-tooltip/node_modules/lit-element": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
|
||||
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tp/tp-tooltip/node_modules/lit-html": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
|
||||
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-element": "^4.2.0",
|
||||
"lit-html": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-element": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz",
|
||||
"integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.4.0",
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
"lit-html": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-html": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz",
|
||||
"integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
package.json
11
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
686
tp-tree-nav.js
Normal file
686
tp-tree-nav.js
Normal file
@@ -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`
|
||||
<div class="tree">
|
||||
${virtualize({
|
||||
items,
|
||||
renderItem: (item) => this._renderItem(item),
|
||||
keyFunction: (item) => item.key,
|
||||
})}
|
||||
</div>
|
||||
${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`
|
||||
<div class="row" part="row" style="--depth: ${depth}" @click=${(e) => this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} >
|
||||
<div class="indent"></div>
|
||||
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
|
||||
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>
|
||||
</div>
|
||||
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
||||
<div class="label" part="label">${node?.label ?? ''}</div>
|
||||
${this.showActions && Array.isArray(node?.actions) && node.actions.length
|
||||
? html`
|
||||
<div class="actions" part="actions">
|
||||
${node.actions.map((action) => html`
|
||||
<tp-icon
|
||||
class="action-icon"
|
||||
part="action"
|
||||
.icon=${this._resolveIcon(action.icon)}
|
||||
.tooltip=${action.tooltip}
|
||||
@click=${(e) => this._onInlineAction(item, action, e)}
|
||||
></tp-icon>
|
||||
`)}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderContextMenu() {
|
||||
if (!this._contextMenu) return null;
|
||||
const { item, actions } = this._contextMenu;
|
||||
|
||||
return html`
|
||||
<div class="context-menu-overlay">
|
||||
<tp-popup-menu class="context-menu" part="context-menu">
|
||||
${actions.map((action) => html`
|
||||
<tp-popup-menu-item .icon=${action.icon} part="context-menu-item" @click=${(e) => this._onMenuAction(action, item, e)}>
|
||||
${action?.label ?? null}
|
||||
</tp-popup-menu-item>
|
||||
`)}
|
||||
</tp-popup-menu>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderEmpty() {
|
||||
if (typeof this.renderEmpty === 'function') {
|
||||
return this.renderEmpty();
|
||||
}
|
||||
return html`<div class="empty-state" part="empty">${this.emptyMessage}</div>`;
|
||||
}
|
||||
|
||||
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`<path fill="var(--tp-icon-color)" d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />`;
|
||||
}
|
||||
|
||||
static get folder() {
|
||||
return svg`<path fill="var(--tp-icon-color)" d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" />`;
|
||||
}
|
||||
|
||||
static get file() {
|
||||
return svg`<path fill="var(--tp-icon-color)" d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" />`;
|
||||
}
|
||||
|
||||
static get defaultIcons() {
|
||||
return {
|
||||
chevron: TpTreeNav.chevron,
|
||||
folder: TpTreeNav.folder,
|
||||
file: TpTreeNav.file,
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.roots = [];
|
||||
this.defaultActions = [];
|
||||
this.renderNode = null;
|
||||
this.beforeContextMenu = null;
|
||||
this.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);
|
||||
Reference in New Issue
Block a user