diff --git a/README.md b/README.md
index 704271b..ba5361e 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,148 @@
# 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).
+`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 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`.
+- **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.
-## Usage example
+## Quick example (wrap directly)
```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 = [
+ {
+ 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' },
+ { 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.beforeContextMenu = (node, actions) =>
+ node.slug === 'project-a' ? actions.filter((a) => a.action !== 'delete') : 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.)
+// simple toggle handler
+const targetPath = (path) => path.join('/');
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;
- }
+ if (e.detail.action !== 'toggle') return;
+ const target = targetPath(e.detail.path);
+ tree.roots = tree.applyStateIf('expanded', (_n, p) => targetPath(p) === target);
});
```
-## 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.
+## 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.json b/package.json
index ecce9b6..c79c6a1 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"license": "Apache-2.0",
"dependencies": {
"@tp/tp-icon": "^1.0.1",
- "@tp/tp-spinner": "^1.0.0",
+ "@tp/tp-popup": "^1.0.0",
+ "@lit-labs/virtualizer": "^2.0.0",
"lit": "^3.0.0"
}
}
diff --git a/tp-tree-nav.js b/tp-tree-nav.js
index 6dda1af..f2911b5 100644
--- a/tp-tree-nav.js
+++ b/tp-tree-nav.js
@@ -4,10 +4,13 @@ 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 LitElement {
+export class TpTreeNav extends Position(LitElement) {
static get styles() {
return [
css`
@@ -98,40 +101,10 @@ export class TpTreeNav extends LitElement {
pointer-events: none;
}
- .context-menu {
- position: absolute;
+ tp-popup-menu.context-menu {
min-width: 180px;
- background: var(--tp-tree-menu-bg, #fff);
- 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;
}
`
];
@@ -173,20 +146,9 @@ export class TpTreeNav extends LitElement {
}
return html`
- this._onRowClick(item, e)}
- @contextmenu=${(e) => this._onContextMenu(item, e)}
- >
+
this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} >
-
this._onChevronClick(item, e)}
- >
+
this._onChevronClick(item, e)} >
${icon ? html`
` : html`
`}
@@ -212,22 +174,17 @@ export class TpTreeNav extends LitElement {
_renderContextMenu() {
if (!this._contextMenu) return null;
- const { x, y, item, actions } = this._contextMenu;
+ const { item, actions } = this._contextMenu;
return html`
`;
}
@@ -257,6 +214,7 @@ export class TpTreeNav extends LitElement {
emptyMessage: { type: String },
showActions: { type: Boolean },
expandOnDoubleClick: { type: Boolean },
+ selectOnRightClick: { type: Boolean },
};
}
@@ -298,6 +256,7 @@ export class TpTreeNav extends LitElement {
this.emptyMessage = 'No items';
this.showActions = false;
this.expandOnDoubleClick = false;
+ this.selectOnRightClick = false;
this._contextMenu = null;
this._outsideHandler = null;
this._keyHandler = null;
@@ -420,6 +379,37 @@ export class TpTreeNav extends LitElement {
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,
@@ -442,12 +432,23 @@ export class TpTreeNav extends LitElement {
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;
+ const x = originalEvent.clientX;
+ const y = originalEvent.clientY;
- this._contextMenu = { x, y, item, actions: Array.isArray(actions) ? actions : baseActions };
+ 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();
}
@@ -475,7 +476,7 @@ export class TpTreeNav extends LitElement {
_attachOutsideHandlers() {
if (this._outsideHandler) return;
this._outsideHandler = (e) => {
- const menu = this.shadowRoot?.querySelector('.context-menu');
+ const menu = this.shadowRoot.querySelector('.context-menu');
if (menu && e.composedPath().includes(menu)) return;
this._closeContextMenu();
};