From d88cf92b79aaded0fe3751ccc54ca89215298f4c Mon Sep 17 00:00:00 2001 From: pk Date: Tue, 16 Dec 2025 13:57:01 +0100 Subject: [PATCH] wip --- README.md | 191 ++++++++++++++++++++++++++++++++----------------- package.json | 3 +- tp-tree-nav.js | 123 +++++++++++++++---------------- 3 files changed, 189 insertions(+), 128 deletions(-) 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`
-
+ ${actions.map((action) => html` - + this._onMenuAction(action, item, e)}> + ${action?.label ?? null} + `)} -
+
`; } @@ -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(); };