@keenmate/svelte-treeview 5.0.0-rc11 → 5.0.0-rc12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.0.0-rc12] - 2026-06-28 [PUBLISHED]
9
+
10
+ Showcase / docs polish (a context-menu doc note and a theming-playground CSS fix surfaced by manual review of `/examples/theming` and `/examples/tree-editor`), plus one library correctness fix: keyboard copy/cut/paste threw on default `$state` data.
11
+
12
+ ### Fixed
13
+ - **Context menu: three fixes surfaced by driving the library menu from the Windows Explorer demo's right pane**. (1) **Re-opening on a different node no longer leaves the menu stuck** — the Floating-UI positioning `$effect` in `Tree.svelte` only depended on `contextMenuVisible`, so a second right-click on another node (visible stays `true`) didn't re-run it; `autoUpdate` stayed anchored to the first node's coordinates and only recomputes on scroll/resize, so the menu sat at the old spot until you left-clicked to close it (`visible → false`) and right-clicked again. The effect now also tracks `contextMenuX` / `contextMenuY` / `contextMenuNode`, so it tears down and re-anchors when the menu jumps nodes. (2) **Clicking a menu item now auto-closes the menu** — `ContextMenuLevel` (the `getContextMenuItemsCallback` render path) ran the item's `onclick` but never dismissed the menu, relying on each consumer to call the provided `close()` in every handler (the test/demo callbacks did so 15×). Leaf-item activation (click + Enter/Space) now closes the menu in a `finally`, so `close()` is optional — calling it anyway is harmless. Items that act incrementally (a toggle, a multi-step action) can opt out with the new `shouldCloseOnClick: false` and keep the menu open, dismissing it themselves via the captured `close` when finished (see Added). The snippet-based `ContextMenuItem` path is unchanged (its contract still hands `close` to the consumer). (3) **Dropped a `binding_property_non_reactive` warning** — `ContextMenuLevel` held item element refs in a plain array while `bind:this={itemEls[i]}` wrote into it; it's now `$state`, which also lets the submenu-positioning effect re-run once the parent item attaches. Regression coverage: `e2e/context-menu.spec.ts` gains a reposition-on-second-right-click case; `e2e/windows-explorer.spec.ts` asserts a menu item with no manual `close()` still dismisses the menu.
14
+ - **A copied node now stays on the clipboard across multiple pastes — `pasteNodes` only clears the clipboard on cut**: `pasteNodes` called `clearClipboard()` unconditionally after every paste, so a `copy` was single-use — paste once and the clipboard was empty, contradicting every file manager / IDE (Finder, Explorer, VS Code) where a copy can be pasted repeatedly. Now the clipboard is cleared only when `clip.operation === 'cut'` (a move is one-shot — its source nodes were just removed); a `copy` persists so it can be pasted again (each paste re-runs `beforePasteCallback`, so successive pastes into the same parent get `Copy 1`, `Copy 2`, … via the demo's collision-naming, which strips any prior ` Copy N` first so repeats don't compound into `Copy 1 Copy 1`). Applied to both the auto-handled path and the `shouldAutoHandlePaste=false` forwarding path; cut-dimming (`cutPaths`) is still cleared on every paste. This brings svelte-treeview into parity with `@keenmate/web-treeview`, whose `pasteNodes` already clears the clipboard only inside its `if (operation === 'cut')` block — svelte-treeview was the diverging side. Regression coverage in `e2e/clipboard.spec.ts`: a copy pasted twice adds two nodes, and a cut's second paste is a no-op (clipboard emptied by the move).
15
+ - **Keyboard copy/cut/paste (`copyNodes` / `cutNodes`) no longer throw on `$state` data — `structuredClone` can't clone a Svelte reactive proxy**: `TreeController._collectClipboardEntry` snapshotted each node's data with `structuredClone(node.data)`. For any consumer passing the default deeply-reactive `$state` array (the common case), `node.data` is a `Proxy`, and `structuredClone` throws `DataCloneError: ... could not be cloned` — so the copy silently failed and a subsequent paste found an empty clipboard. Swapped both clone sites (the root entry and the recursive descendant walk) to `$state.snapshot(...)`, which deproxies reactive state into a plain deep clone and is a no-op-style passthrough for already-plain data, so it's correct whether the consumer uses `$state`, `$state.raw`, or unproxied arrays. Surfaced while wiring Ctrl/Cmd+C/X/V into `/examples/tree-editor` (which uses plain `$state`). Regression coverage: new `e2e/clipboard.spec.ts` against a new `/test/clipboard` fixture that deliberately uses plain `$state` data — copy→paste duplicates a node, cut→paste moves it (count unchanged), cut dims + Escape un-dims, and Ctrl+click multi-select copies several nodes at once.
16
+ - **Brand themes in the `/examples/theming` "Dark Mode Playground" no longer lose their surface in per-instance light mode — Glass in particular stopped rendering white-on-white**: the demo's generic per-instance surface rules (`:global(.playground-wrapper:has(.stv__container[data-theme='light'])) { background: #f9fafb }` and the matching dark variant) carried specificity `(0,3,0)`, which outranks every brand theme's *base* rule `:global(.playground-wrapper.brand-X)` at `(0,2,0)`. In **inherit** and **dark** modes nothing collided — there's no light `:has()` match, and each brand owns a higher-specificity `.brand-X:has(...dark)` rule that wins — but in **light** mode the brand themes have no light-variant rule, so the generic gray override won and reverted the wrapper to plain `#f9fafb`. Most brand themes degraded quietly (their `--base-*` tokens still themed the tree, dark text stayed readable), but **Glass** forces `--base-text-color-1: #ffffff`, so picking `theme="light"` left white labels on a now-white surface — the node text vanished, only the emoji icons showed. Fix scopes the four generic `:has()` surface-flip rules (two wrapper, two tree) to `.brand-default` only: the unstyled default theme is the sole case that needs the generic flip, and every real brand theme already covers all three modes via its base rule (light) and `.brand-X:has(...dark)` variant (dark). Regression coverage: new `e2e/theming-brand.spec.ts` (6 cases) asserts each gradient brand (material / neon / soft / glass / forest) keeps a `linear-gradient` wrapper surface across inherit / dark / light, with a dedicated check that Glass light mode renders its `rgb(102, 126, 234)` purple — the existing `e2e/theming.spec.ts` only exercised dark-mode *signal precedence* via a synthetic Debug theme and never touched the real brand themes, which is why this slipped through.
17
+
18
+ ### Added
19
+ - **`ContextMenuItem.shouldCloseOnClick?: boolean` (default `true`) — per-item opt-out of the new auto-close**: the callback menu now auto-closes after a leaf item is activated (see Fixed), which is the right default for one-shot commands (Open, Delete, Copy path). But some items act *incrementally* — a toggle, a counter, a multi-step action — and want the menu to stay open so the user can click again or read updated state. Setting `shouldCloseOnClick: false` on a `ContextMenuItem` suppresses the auto-close for that entry only; the handler then owns dismissal and calls the `close` callback it captured from `getContextMenuItemsCallback(node, close, …)` (or the snippet's `close` prop) when it's done. Named per the codebase's `should*` boolean-prop convention (sibling fields `isDisabled` / `isVisible`). Implemented in `ContextMenuLevel.svelte` by gating both the click and Enter/Space `finally` close on `item.shouldCloseOnClick !== false`. The `/test/context-menu` fixture gains a "Bump (stays open)" item that increments a counter without closing; `e2e/context-menu.spec.ts` asserts the menu survives repeated clicks of it and that a normal item still dismisses afterward.
20
+ - **Data-driven per-node class hooks `nodeClass?: (node) => string` and `nodeContentClass?: (node) => string`**: previously the only per-row class hooks were *state* classes (`highlightedNodeClass` / `focusedNodeClass` / `dragOverNodeClass`) — there was no way to tag a row from its own data (e.g. `is-folder` / `is-file`, a status colour, a grid-participation class) without reaching into `[data-tree-path="…"]` selectors. These two callbacks run per node and return class(es) applied to `.stv__node` (`nodeClass`) and `.stv__node-content` (`nodeContentClass`); they recompute on the node's `_rev` bump like the rest of the render. Plumbed through `NodeConfig` (stable context reference, no prop-drilling) so they don't defeat flat-mode diffing. Wired end-to-end on `<Tree>` (prop → `$effect` sync → `update()` passthrough) and on the `createTreeController` props. New `/test/node-class` fixture + `e2e/node-class.spec.ts` (3 cases) assert the classes land on the right elements and are stylable from app CSS. Motivated by the Explorer demo's right pane needing folder/file row styling.
21
+ - **Public `onNodeDoubleClick?: (node) => void` `<Tree>` event — a real double-click notification, reliable in flat rendering where the native `dblclick` is not**: there was previously no way for a consumer to react to a node double-click. The controller already did manual double-click *detection* (tracking last path + timestamp on the controller, 400ms window) but only internally and only for `clickBehavior="select"`, where a double toggles expand/collapse — and it deliberately avoids the browser's native `dblclick` because the first click bumps `node._rev`, the flat-mode `{#each}` destroys and recreates the row, and the second click lands on a fresh element so the browser refuses to synthesize a `dblclick`. That detection is now generalized to **every** `clickBehavior` and fires the new `onNodeDoubleClick` event; `select` mode additionally keeps its built-in expand/collapse-on-double. On a detected double the second click is consumed (early return) so the gesture reads as a single open rather than a re-toggle — a double therefore fires `onNodeClick` once (the first click) plus `onNodeDoubleClick` once. Detection is gated to genuine UI clicks (a new internal `uiClick` flag on the click path), so programmatic `highlightNode` / `selectNode` calls can never be mistaken for a double-click. Wired end-to-end through `<Tree>` (prop → `$effect` sync → `update()` passthrough) and dogfooded in the Windows Explorer demo's nav tree (double-click a folder opens it in the right pane). New `/test/double-click` fixture + `e2e/double-click.spec.ts` (4 cases) cover firing in both `select` and `expand-and-focus` modes, the single-click negative, and the two-different-nodes negative.
22
+ - **Windows File Explorer demo at `/examples/custom-layout`** (`WindowsExplorer.svelte`): a near-complete dual-pane Explorer clone where **both panes are `<Tree>` instances** with custom `nodeTemplate` renderers, showcasing how far the renderer composes. The left nav pane is a hierarchical `<Tree>` fed a folders-only slice (`leafIconClass=""` so childless folders show no marker, like Explorer); the right pane is a **flat `<Tree>`** rendering the details list (Name / Date modified / Type / Size) as a 4-column grid — the columns line up because each row's `.stv__node-content` is a CSS grid sharing the sticky header's track template inside one scroll area (toggle icons hidden, indent zeroed via `--stv-node-indent-per-level: 0`). The right pane gets **selection** (`selectionMode="multi"` + `bind:highlightedPaths`), **keyboard navigation**, the **right-click context menu** (`getContextMenuItemsCallback` → Open / Copy path / Rename / Delete / New folder / Select all), **double-click-to-open** (`onNodeDoubleClick`), and **per-row folder/file classes** (`nodeClass`) all from the library — replacing the earlier hand-rolled list, custom selection set, debounced click handler and bespoke floating menu. The two panes stay in sync through the tree's public API — `expandNodes()` + `focusNode()` — driven from `onNodeClick` / `onNodeDoubleClick`. The dataset models a real `C:\Windows` install (~30 folders, multi-level subfolders + files). Also: per-column sorting (folders-first); **recursive search** across the current folder's subtree with each hit's location shown and **wildcard globs** (`*.dll`, `img?.jpg`); and working New folder / Rename / Delete that mutate the data and restore nav-tree expansion via `getExpandedPaths()` / `setExpandedPaths()`. Rendered as the first card on the existing Custom Layout page (the iOS-Files and Notepad++ demos remain below). Smoke-covered by `e2e/windows-explorer.spec.ts` (12 cases).
23
+ - **Public `onCopy` / `onCut` / `onPaste` `<Tree>` events — post-operation clipboard notifications, symmetric with the `beforeCopy/Cut/Paste` interceptors**: these belong to the library's **Events** family (`on*`), distinct from the `before*Callback` interceptor and `get*Callback` provider families. The controller already fired an `onPaste` handler, but it was only reachable through `createTreeController` props — the `<Tree>` component never forwarded it, and there were no copy/cut equivalents at all. Added `onCopy?: (paths: string[]) => void` and `onCut?: (paths: string[]) => void` (fired after `copyNodes` / `cutNodes` succeed, with the final paths post-interceptor) on the controller, and forwarded all three (`onCopy`, `onCut`, `onPaste`) as `<Tree>` props with the usual `$effect` sync + `update()` passthrough. This completes the clipboard surface: `before*Callback` to rewrite/block, `on*` to react. The "Copy N"-on-collision rename is done in `beforePasteCallback` (mutating the clipboard entries' data before insert), not in `onPaste` — the `on*` events fire after the tree has already changed. Documented in `docs/usage.md` (event table + a new clipboard-interceptor table).
24
+ - **`/examples/tree-editor` gains multi-select + keyboard copy/cut/paste (Ctrl/Cmd + C / X / V)**: the editor now sets `selectionMode="multi"` (Ctrl/Cmd+click and Shift+click extend `highlightedPaths`) and wires the clipboard through the documented `onTreeKeydown(event, controller)` hook. The library already implemented the operations (`copyNodes` / `cutNodes` / `pasteNodes` / `cancelCut`, a shared cross-tree clipboard, and `Escape`→cancel-cut) but intentionally leaves the key *bindings* to the consumer, because paste needs an app-specific `transformData` (the demo assigns fresh ids so pasted copies don't collide) and a target path (the focused node, or root). Copy/cut act on the highlight set when present, else the focused node; paste lands as a child of the focused node. Cut nodes dim until pasted (the demo renders this from a local set in its `nodeTemplate` — the controller exposes `cutPaths` but doesn't paint dimming itself), and `mod` is `event.ctrlKey || event.metaKey` so it works with Cmd on macOS. The demo also wires the new `onCopy` / `onCut` / `onPaste` events (for its activity log) and a `beforePasteCallback` that (a) appends `"Copy 1"` / `"Copy 2"` / … to a pasted copy whose name already exists under the target — stripping any prior ` Copy N` first so repeated pastes number sequentially instead of compounding (only the root is renamed; descendants keep their names; a move keeps the name), and (b) redirects a paste that lands on the copied node itself — Ctrl/Cmd+C then Ctrl/Cmd+V without moving focus — into that node's parent, so the copy drops in as a sibling ("duplicate in the same folder"). Because `beforePasteCallback` runs before the paste-into-self guard, the redirect also sidesteps that guard cleanly.
25
+
26
+ ### Changed
27
+ - **Context Menu Examples page leads with a "Positioning is handled by Floating UI" note**: a short explainer (moved to sit directly below the page header, above the first example card) documenting that the root menu and submenus are placed by `@floating-ui/dom` via `computePosition` + `offset` / `flip` / `shift` / `autoUpdate`, that submenus flip left when there's no room on the right, and that `contextMenuXOffset` / `contextMenuYOffset` feed the root menu's cursor offset.
28
+ - **`/examples/tree-editor` polish**: single click now selects without expanding and double-click toggles expand (`clickBehavior="select"`, was the default `'expand-and-focus'`), and the code-example import was corrected from a default import (`import Tree from …`) to the named `import { Tree } from '@keenmate/svelte-treeview'` — `Tree` is a named export, so the default form resolved to `undefined`.
29
+
8
30
  ## [5.0.0-rc11] - 2026-06-25 [PUBLISHED]
9
31
 
10
32
  Consolidated release folding the work previously staged under the unpublished rc12 and rc13 headings (npm's last published `rc` was rc10; the rc12/rc13 labels were never shipped, so this lands as rc11). Three themes: a `/validate-web-component` alignment sweep against the BlissFramework `web-components` guidelines (BEM/prefix `ltree`→`stv` rename, `is*/should*` boolean naming, dark-mode Strategy-B rewrite, README split), the three-level selection / highlight / focus API normalization, and a batch of drag-drop and highlight-marker correctness fixes. Within-RC references to "rc12" below denote earlier iterations of this same unpublished cycle.
package/README.md CHANGED
@@ -31,6 +31,16 @@ svelte-treeview is broader (two rendering modes, easier vertical guide lines via
31
31
 
32
32
  Browse interactive code examples and the full API reference at **[svelte-treeview.keenmate.dev](https://svelte-treeview.keenmate.dev)**
33
33
 
34
+ ## What's New in v5.0.0-rc12
35
+
36
+ - **Context menus — items auto-close on click, with a `shouldCloseOnClick` opt-out, plus a reposition fix** — Activating a leaf item in a `getContextMenuItemsCallback` menu now dismisses the menu in a `finally`, so handlers no longer need to call the provided `close()` themselves (the test/demo callbacks were doing it 15×); calling it anyway is harmless. Items that act incrementally — a toggle, a counter, a multi-step action — opt out with the new `ContextMenuItem.shouldCloseOnClick: false` (default `true`, named per the `should*` convention) and keep the menu open, dismissing it via the captured `close` when finished. Also fixed: re-opening the menu on a *different* node no longer leaves it stuck at the first node's coordinates — the Floating-UI positioning effect now tracks the menu's X/Y/node, not just its visibility — and a `binding_property_non_reactive` warning is gone.
37
+ - **Per-node class hooks — `nodeClass` and `nodeContentClass` callbacks** — Two new data-driven callbacks let you tag a row from its own data (`is-folder`/`is-file`, a status color, a grid class) without reaching into `[data-tree-path="…"]` selectors. They run per node, return class(es) applied to `.stv__node` and `.stv__node-content` respectively, and recompute on the node's `_rev` bump like the rest of the render. Plumbed through `NodeConfig` so they don't defeat flat-mode diffing, and wired end-to-end on `<Tree>` (prop → `$effect` sync → `update()` passthrough) and on `createTreeController`.
38
+ - **Double-click — a real `onNodeDoubleClick` event, reliable in flat rendering** — There was previously no way to react to a node double-click; the native `dblclick` is unreliable in flat mode because the first click bumps `_rev` and recreates the row out from under the browser. The controller's existing manual detection (last-path + timestamp, 400ms window) is now generalized to *every* `clickBehavior` and fires the new `onNodeDoubleClick`; `select` mode still toggles expand/collapse on a double. Detection is gated to genuine UI clicks, so programmatic `highlightNode`/`selectNode` can never be mistaken for a double.
39
+ - **Clipboard events — `onCopy` / `onCut` / `onPaste` on `<Tree>`** — Post-operation notifications symmetric with the `beforeCopy/Cut/Paste` interceptors complete the clipboard surface (`before*Callback` to rewrite or block, `on*` to react). `onPaste` existed on the controller but `<Tree>` never forwarded it and there were no copy/cut equivalents; all three are now controller events forwarded as `<Tree>` props with the usual sync + `update()` passthrough. `/examples/tree-editor` gains multi-select and keyboard Ctrl/Cmd+C/X/V wired through the documented `onTreeKeydown` hook.
40
+ - **Clipboard correctness — a copy now survives repeated pastes, and `$state` data no longer throws** — `pasteNodes` used to clear the clipboard after *every* paste, making a copy single-use (contradicting Finder/Explorer/VS Code); it now clears only on `cut` (a move is one-shot), so a copy can be pasted repeatedly. Separately, copy/cut threw `DataCloneError` on the common default `$state` data because `structuredClone` can't clone a Svelte reactive proxy — both clone sites now use `$state.snapshot(...)`, which deproxies reactive state and passes plain data through unchanged. Both fixed with new Playwright coverage.
41
+ - **New demo — Windows File Explorer, where both panes are `<Tree>` instances** — A near-complete dual-pane Explorer clone at `/examples/custom-layout`: a hierarchical nav `<Tree>` on the left and a *flat* `<Tree>` rendering the details list as a 4-column CSS grid on the right, with selection, keyboard nav, right-click menu, double-click-to-open, and per-row folder/file classes all driven from the library — replacing the earlier hand-rolled list and bespoke menu. It dogfoods this release's new surfaces (`nodeClass`, `onNodeDoubleClick`, the context-menu auto-close) and stays in sync through the public `expandNodes()` / `focusNode()` API.
42
+ - **Theming — brand themes keep their surface in per-instance light mode** — In the `/examples/theming` "Dark Mode Playground", the demo's generic `:has()` surface rules (specificity `(0,3,0)`) outranked each brand theme's base rule `(0,2,0)`, so picking `theme="light"` reverted the wrapper to plain gray — and Glass, which forces white text, rendered white-on-white with the labels vanishing. The four generic surface-flip rules are now scoped to `.brand-default` only; every real brand theme already covers all three modes. New `e2e/theming-brand.spec.ts` (6 cases) guards each gradient brand across inherit/dark/light.
43
+
34
44
  ## What's New in v5.0.0-rc11
35
45
 
36
46
  - **Selection model — three symmetric imperative families (highlight / select / focus)** — The imperative API had drifted into aliases and asymmetries: `selectNode`/`selectNodes` secretly drove the *highlight* set, the highlight set cleared with `clearHighlight()` while checkboxes cleared with `deselectAll()`, and `highlightNodes()` silently replaced rather than added. The surface is now three concerns × one shape — **highlight** (`highlightNode`/`highlightNodes`/`setHighlightedPaths`/`highlightAll`/`clearHighlight`) for the UI multi-select set, **select** (`selectNode`/`selectNodes`/`setSelectedPaths`/`selectAll`/`deselectNode`/`clearSelection`) for the checkbox set, and **focus** (`focusNode`/`clearFocus`) for the single cursor. Two shared types, `HighlightMode` and `TreeMutationOptions { silent? }`, replace the inline literals. Breaking within the RC: `deselectAll` → `clearSelection`, `highlightNodes` is now additive, and `selectNode`/`selectNodes` are now real checkbox setters.
@@ -41,16 +51,6 @@ Browse interactive code examples and the full API reference at **[svelte-treevie
41
51
  - **Context menus — positioning ported to `@floating-ui/dom`** — Root menu and submenus are now placed by `computePosition` + `autoUpdate` with `flip()`/`shift()` instead of raw inline `left/top` and CSS `:hover`, so menus flip above the cursor near the viewport bottom and submenus slide sideways instead of clipping. `contextMenuXOffset`/`contextMenuYOffset` keep their exact semantics.
42
52
  - **Docs — README cut to ~350 lines with topical docs split out** — The README dropped from 1103 to 347 lines: Advanced Usage, Styling, and API Reference moved into `docs/usage.md`, `docs/theming.md`, `docs/examples.md`, and `docs/accessibility.md`, with a new intro and a Demos & docs link block up top.
43
53
 
44
- ## What's New in v5.0.0-rc10
45
-
46
- - **Built-in dark mode — all four canonical signals covered** — a `dark-mode.css` partial flips the tree's surface, text, border, and accent palette when any of the canonical signals is active: OS preference (`prefers-color-scheme: dark`), page `<html style="color-scheme: dark">` resolved via `light-dark()`, framework theme classes (`[data-theme="dark"]`, `[data-bs-theme="dark"]`, `.dark`), and the new per-instance `theme` prop. Symmetric `light` selectors let a single tree force light on an otherwise-dark page. Zero JavaScript — pure CSS resolution.
47
- - **`theme` prop on `<Tree>` — per-instance dark/light override** — `'dark' | 'light' | null | undefined`. Forwarded to the root `.stv__container` as `data-theme`, where per-instance CSS selectors take over and beat ambient signals. Leave `undefined` to inherit from the page. (rc10 originally landed this on `.ltree-container`; the BEM rename to `.stv__container` shipped in rc12.)
48
- - **CSS variables rescoped from `:root` to `.stv__container` — subtree theming actually works** — mirrors the `:host`-scoped pattern from sibling `@keenmate/*` components and is the only way `--base-*` theming flows at subtree scope. A wrapper around the tree that sets `--base-accent-color: red` now re-tints the tree (it previously had no effect because the substitution was frozen at `:root`). Multiple trees on the same page can be themed differently via wrapper-scoped `--base-*` overrides. Consumer note: setting `--stv-*` directly on a wrapper no longer cascades — use `--base-*` on the wrapper or target `.stv__container` explicitly.
49
- - **`--stv-bg` — the tree paints its own surface** — `.stv__container` gets a `background: var(--stv-bg)` so consumers don't need to wrap the tree in a colored container for a visible surface. Override to `transparent` to restore the pre-rc10 layered behavior. Companion `--stv-elevated-bg` reads through the `--base-elevated-bg` chain for floating chrome (context menu).
50
- - **CSS file layout aligned with the Bliss web-component guidelines** — all `_xxx.css` partials renamed to `xxx.css`, `main.css` now declares `@layer variables, component, overrides;` and imports each partial into the matching layer. Consumers' unlayered overrides automatically beat every rule in the library — no `!important` needed. Note: if your app ships an unlayered universal CSS reset (`* { padding: 0 }` from normalize / Bootstrap / Tailwind preflight / etc.), wrap it in a low-priority `@layer reset` or the tree's defaults won't apply.
51
- - **`getIsDropAllowedCallback` prop + `getIsDraggableCallback` seeded at insert-time** — callback variant for `isDropAllowedMember`, matching the pattern rc09 introduced for `getIsExpandedCallback` / `getIsSelectableCallback` / `getIsSelectedCallback`. Also, the existing `getIsDraggableCallback` prop is now actually applied during the seed walk — previously it was only consumed lazily in some paths.
52
- - **Bug fixes — virtual scroll + `clickBehavior='select'` double-click** — virtual scroll no longer gets stuck at bottom after a filter shrinks the tree. Double-click to expand in `clickBehavior='select'` mode finally works (the browser's native `dblclick` event couldn't fire because the first click destroyed the row via the focus re-render; the controller now detects the double manually).
53
-
54
54
  ## v5.0: Core/Renderer Split + Virtual Scroll
55
55
 
56
56
  > [!IMPORTANT]
@@ -16,7 +16,9 @@
16
16
 
17
17
  let openIdx = $state<number | null>(null);
18
18
  let hideTimeout: ReturnType<typeof setTimeout> | null = null;
19
- const itemEls: HTMLElement[] = [];
19
+ // $state so `bind:this={itemEls[i]}` writes are reactive and the submenu
20
+ // positioning $effect re-runs once the parent item element is attached.
21
+ let itemEls = $state<HTMLElement[]>([]);
20
22
  let submenuEl = $state<HTMLElement | null>(null);
21
23
 
22
24
  function cancelHide() {
@@ -82,6 +84,13 @@
82
84
  await item.onclick?.();
83
85
  } catch (error) {
84
86
  console.error('Context menu callback error:', error);
87
+ } finally {
88
+ // Auto-close after activating a leaf item — selecting an entry dismisses
89
+ // the menu, like every native/desktop menu. Consumers no longer need to
90
+ // call the close callback themselves (doing so anyway is harmless).
91
+ // Opt out with shouldCloseOnClick: false to keep the menu open for incremental
92
+ // actions; the handler then dismisses it via its captured close callback.
93
+ if (item.shouldCloseOnClick !== false) closeContextMenu();
85
94
  }
86
95
  }}
87
96
  onkeydown={async (e) => {
@@ -91,6 +100,8 @@
91
100
  await item.onclick?.();
92
101
  } catch (error) {
93
102
  console.error('Context menu callback error:', error);
103
+ } finally {
104
+ if (item.shouldCloseOnClick !== false) closeContextMenu();
94
105
  }
95
106
  }
96
107
  }}
@@ -62,6 +62,10 @@
62
62
  const leafIconClass = $derived(config.leafIconClass);
63
63
  const highlightedNodeClass = $derived(config.highlightedNodeClass);
64
64
  const focusedNodeClass = $derived(config.focusedNodeClass);
65
+ // Data-driven per-row classes. The callbacks read node data; recompute when the
66
+ // node's _rev bumps (same trigger the rest of the render uses).
67
+ const customNodeClass = $derived(config.nodeClass ? (config.nodeClass(node) ?? '') : '');
68
+ const customNodeContentClass = $derived(config.nodeContentClass ? (config.nodeContentClass(node) ?? '') : '');
65
69
  // dragOverNodeClass is applied to the DOM directly by the controller
66
70
  // (see hoveredNodeForDrop $effect in TreeController) — no per-Node binding.
67
71
  const isCopyAllowed = $derived(config.isCopyAllowed);
@@ -333,7 +337,7 @@
333
337
 
334
338
  <!-- svelte-ignore a11y_no_static_element_interactions -->
335
339
  <div
336
- class="stv__node"
340
+ class="stv__node {customNodeClass}"
337
341
  id="{node.treeId}-{node.id}"
338
342
  data-tree-path="{node.path}"
339
343
  style={indentStyle}
@@ -378,7 +382,7 @@
378
382
  <!-- svelte-ignore a11y_click_events_have_key_events -->
379
383
  <!-- svelte-ignore a11y_no_static_element_interactions -->
380
384
  <div
381
- class="stv__node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''}"
385
+ class="stv__node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''} {customNodeContentClass}"
382
386
  class:stv__node-content--highlighted={node.isHighlighted && !highlightedNodeClass}
383
387
  class:stv__node-content--focused={node.isFocused}
384
388
  class:stv__clickable={node.isSelectable}
@@ -148,11 +148,17 @@
148
148
 
149
149
  // EVENTS (on* = fire-and-forget notifications)
150
150
  onNodeClick?: (node: LTreeNode<T>) => void;
151
+ onNodeDoubleClick?: (node: LTreeNode<T>) => void;
151
152
  onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
152
153
  onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
153
154
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
154
155
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
155
156
  onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
157
+ // Post-operation clipboard notifications (fired AFTER the op). onCopy/onCut
158
+ // receive the final paths; onPaste receives the PasteResult.
159
+ onCopy?: (paths: string[]) => void;
160
+ onCut?: (paths: string[]) => void;
161
+ onPaste?: (result: import('../core/TreeController.svelte.js').PasteResult<T>) => void;
156
162
 
157
163
  // INTERCEPTORS (before*Callback = can modify/block)
158
164
  /**
@@ -178,6 +184,10 @@
178
184
  bodyClass?: string | null | undefined;
179
185
  highlightedNodeClass?: string | null | undefined;
180
186
  focusedNodeClass?: string | null | undefined;
187
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
188
+ nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
189
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
190
+ nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
181
191
  dragOverNodeClass?: string | null | undefined;
182
192
  expandIconClass?: string | null | undefined;
183
193
  collapseIconClass?: string | null | undefined;
@@ -296,11 +306,15 @@
296
306
 
297
307
  // EVENTS
298
308
  onNodeClick,
309
+ onNodeDoubleClick,
299
310
  onHighlightChange,
300
311
  onSelectionChange,
301
312
  onNodeDragStart,
302
313
  onNodeDragOver,
303
314
  onNodeDrop,
315
+ onCopy,
316
+ onCut,
317
+ onPaste,
304
318
  // INTERCEPTORS
305
319
  beforeDropCallback,
306
320
  beforeCopyCallback,
@@ -318,6 +332,8 @@
318
332
  toggleIconMode = 'rotate',
319
333
  highlightedNodeClass,
320
334
  focusedNodeClass,
335
+ nodeClass,
336
+ nodeContentClass,
321
337
  dragOverNodeClass,
322
338
  scrollHighlightTimeout = 4000,
323
339
  scrollHighlightClass = 'stv__node-content--scroll-highlight',
@@ -405,11 +421,15 @@
405
421
  shouldAutoHandlePaste,
406
422
  isAccordionExpand,
407
423
  onNodeClick,
424
+ onNodeDoubleClick,
408
425
  onHighlightChange,
409
426
  onSelectionChange,
410
427
  onNodeDragStart,
411
428
  onNodeDragOver,
412
429
  onNodeDrop,
430
+ onCopy,
431
+ onCut,
432
+ onPaste,
413
433
  beforeDropCallback,
414
434
  beforeCopyCallback,
415
435
  beforeCutCallback,
@@ -419,6 +439,8 @@
419
439
  bodyClass,
420
440
  highlightedNodeClass,
421
441
  focusedNodeClass,
442
+ nodeClass,
443
+ nodeContentClass,
422
444
  dragOverNodeClass,
423
445
  expandIconClass,
424
446
  collapseIconClass,
@@ -463,6 +485,13 @@
463
485
  let contextMenuEl = $state<HTMLDivElement | null>(null);
464
486
  $effect(() => {
465
487
  if (!controller.contextMenuVisible || !contextMenuEl) return;
488
+ // Track position + target so the effect re-runs (tears down the old autoUpdate
489
+ // and re-anchors) when a right-click moves the menu to a DIFFERENT node while
490
+ // it's already open — otherwise autoUpdate only recomputes on scroll/resize and
491
+ // the menu stays stuck at the first node's position.
492
+ void controller.contextMenuX;
493
+ void controller.contextMenuY;
494
+ void controller.contextMenuNode;
466
495
  const xOff = controller.contextMenuXOffset ?? 0;
467
496
  const yOff = controller.contextMenuYOffset ?? 0;
468
497
  const virtualRef = {
@@ -523,6 +552,8 @@
523
552
  $effect(() => { controller.leafIconClass = leafIconClass ?? 'stv__toggle-icon--leaf'; });
524
553
  $effect(() => { controller.toggleIconMode = toggleIconMode ?? 'rotate'; });
525
554
  $effect(() => { controller.highlightedNodeClass = highlightedNodeClass; });
555
+ $effect(() => { controller.nodeClass = nodeClass; });
556
+ $effect(() => { controller.nodeContentClass = nodeContentClass; });
526
557
  $effect(() => { controller.focusedNodeClass = focusedNodeClass; });
527
558
  $effect(() => { controller.dragOverNodeClass = dragOverNodeClass; });
528
559
  $effect(() => { controller.dropZoneMode = dropZoneMode ?? 'glow'; });
@@ -536,11 +567,15 @@
536
567
 
537
568
  // Callback sync
538
569
  $effect(() => { controller.onNodeClickHandler = onNodeClick; });
570
+ $effect(() => { controller.onNodeDoubleClickHandler = onNodeDoubleClick; });
539
571
  $effect(() => { controller.onHighlightChangeHandler = onHighlightChange; });
540
572
  $effect(() => { controller.onSelectionChangeHandler = onSelectionChange; });
541
573
  $effect(() => { controller.onNodeDragStartHandler = onNodeDragStart; });
542
574
  $effect(() => { controller.onNodeDragOverHandler = onNodeDragOver; });
543
575
  $effect(() => { controller.onNodeDropHandler = onNodeDrop; });
576
+ $effect(() => { controller.onCopyHandler = onCopy; });
577
+ $effect(() => { controller.onCutHandler = onCut; });
578
+ $effect(() => { controller.onPasteHandler = onPaste; });
544
579
  $effect(() => { controller.beforeDropHandler = beforeDropCallback; });
545
580
  $effect(() => { controller.beforeCopyHandler = beforeCopyCallback; });
546
581
  $effect(() => { controller.beforeCutHandler = beforeCutCallback; });
@@ -825,11 +860,15 @@
825
860
  | "shouldDisplayDebugInformation"
826
861
  | "shouldDisplayContextMenuInDebugMode"
827
862
  | "onNodeClick"
863
+ | "onNodeDoubleClick"
828
864
  | "onHighlightChange"
829
865
  | "onSelectionChange"
830
866
  | "onNodeDragStart"
831
867
  | "onNodeDragOver"
832
868
  | "onNodeDrop"
869
+ | "onCopy"
870
+ | "onCut"
871
+ | "onPaste"
833
872
  | "beforeDropCallback"
834
873
  | "beforeCopyCallback"
835
874
  | "beforeCutCallback"
@@ -847,6 +886,8 @@
847
886
  | "leafIconClass"
848
887
  | "toggleIconMode"
849
888
  | "highlightedNodeClass"
889
+ | "nodeClass"
890
+ | "nodeContentClass"
850
891
  | "focusedNodeClass"
851
892
  | "dragOverNodeClass"
852
893
  | "scrollHighlightTimeout"
@@ -904,11 +945,15 @@
904
945
  if (updates.shouldDisplayDebugInformation !== undefined) shouldDisplayDebugInformation = updates.shouldDisplayDebugInformation;
905
946
  if (updates.shouldDisplayContextMenuInDebugMode !== undefined) shouldDisplayContextMenuInDebugMode = updates.shouldDisplayContextMenuInDebugMode;
906
947
  if (updates.onNodeClick !== undefined) onNodeClick = updates.onNodeClick;
948
+ if (updates.onNodeDoubleClick !== undefined) onNodeDoubleClick = updates.onNodeDoubleClick;
907
949
  if (updates.onHighlightChange !== undefined) onHighlightChange = updates.onHighlightChange;
908
950
  if (updates.onSelectionChange !== undefined) onSelectionChange = updates.onSelectionChange;
909
951
  if (updates.onNodeDragStart !== undefined) onNodeDragStart = updates.onNodeDragStart;
910
952
  if (updates.onNodeDragOver !== undefined) onNodeDragOver = updates.onNodeDragOver;
911
953
  if (updates.onNodeDrop !== undefined) onNodeDrop = updates.onNodeDrop;
954
+ if (updates.onCopy !== undefined) onCopy = updates.onCopy;
955
+ if (updates.onCut !== undefined) onCut = updates.onCut;
956
+ if (updates.onPaste !== undefined) onPaste = updates.onPaste;
912
957
  if (updates.beforeDropCallback !== undefined) beforeDropCallback = updates.beforeDropCallback;
913
958
  if (updates.beforeCopyCallback !== undefined) beforeCopyCallback = updates.beforeCopyCallback;
914
959
  if (updates.beforeCutCallback !== undefined) beforeCutCallback = updates.beforeCutCallback;
@@ -926,6 +971,8 @@
926
971
  if (updates.leafIconClass !== undefined) leafIconClass = updates.leafIconClass;
927
972
  if (updates.toggleIconMode !== undefined) toggleIconMode = updates.toggleIconMode;
928
973
  if (updates.highlightedNodeClass !== undefined) highlightedNodeClass = updates.highlightedNodeClass;
974
+ if (updates.nodeClass !== undefined) nodeClass = updates.nodeClass;
975
+ if (updates.nodeContentClass !== undefined) nodeContentClass = updates.nodeContentClass;
929
976
  if (updates.focusedNodeClass !== undefined) focusedNodeClass = updates.focusedNodeClass;
930
977
  if (updates.dragOverNodeClass !== undefined) dragOverNodeClass = updates.dragOverNodeClass;
931
978
  if (updates.scrollHighlightTimeout !== undefined) scrollHighlightTimeout = updates.scrollHighlightTimeout;
@@ -103,11 +103,15 @@ declare function $$render<T>(): {
103
103
  shouldAutoHandlePaste?: boolean;
104
104
  isAccordionExpand?: boolean;
105
105
  onNodeClick?: (node: LTreeNode<T>) => void;
106
+ onNodeDoubleClick?: (node: LTreeNode<T>) => void;
106
107
  onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
107
108
  onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
108
109
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
109
110
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
110
111
  onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
112
+ onCopy?: (paths: string[]) => void;
113
+ onCut?: (paths: string[]) => void;
114
+ onPaste?: (result: import("../core/TreeController.svelte.js").PasteResult<T>) => void;
111
115
  /**
112
116
  * Called before a drop is processed. Return false to cancel the drop.
113
117
  * Return { position, operation } to override the drop position or operation.
@@ -136,6 +140,10 @@ declare function $$render<T>(): {
136
140
  bodyClass?: string | null | undefined;
137
141
  highlightedNodeClass?: string | null | undefined;
138
142
  focusedNodeClass?: string | null | undefined;
143
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
144
+ nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
145
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
146
+ nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
139
147
  dragOverNodeClass?: string | null | undefined;
140
148
  expandIconClass?: string | null | undefined;
141
149
  collapseIconClass?: string | null | undefined;
@@ -325,11 +333,15 @@ declare function $$render<T>(): {
325
333
  shouldAutoHandlePaste?: boolean;
326
334
  isAccordionExpand?: boolean;
327
335
  onNodeClick?: (node: LTreeNode<T>) => void;
336
+ onNodeDoubleClick?: (node: LTreeNode<T>) => void;
328
337
  onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
329
338
  onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
330
339
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
331
340
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
332
341
  onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
342
+ onCopy?: (paths: string[]) => void;
343
+ onCut?: (paths: string[]) => void;
344
+ onPaste?: (result: import("../core/TreeController.svelte.js").PasteResult<T>) => void;
333
345
  /**
334
346
  * Called before a drop is processed. Return false to cancel the drop.
335
347
  * Return { position, operation } to override the drop position or operation.
@@ -358,6 +370,10 @@ declare function $$render<T>(): {
358
370
  bodyClass?: string | null | undefined;
359
371
  highlightedNodeClass?: string | null | undefined;
360
372
  focusedNodeClass?: string | null | undefined;
373
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
374
+ nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
375
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
376
+ nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
361
377
  dragOverNodeClass?: string | null | undefined;
362
378
  expandIconClass?: string | null | undefined;
363
379
  collapseIconClass?: string | null | undefined;
@@ -371,7 +387,7 @@ declare function $$render<T>(): {
371
387
  onTreeKeydown?: (event: KeyboardEvent, controller: TreeController<T>) => boolean | void;
372
388
  /** Override individual navigation methods (e.g. for custom ArrowDown/Up behavior) */
373
389
  navigationOverrides?: TreeNavigationOverrides<T>;
374
- }, "treeId" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "orderMember" | "isSorted" | "sortCallback" | "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "beforeCheckboxToggleCallback" | "rangeSelectionMode" | "shouldUseInternalSearchIndex" | "initializeIndexCallback" | "searchText" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayDebugInformation" | "shouldDisplayContextMenuInDebugMode" | "onNodeClick" | "onHighlightChange" | "onSelectionChange" | "onNodeDragStart" | "onNodeDragOver" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "getContextMenuItemsCallback" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "bodyClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "highlightedNodeClass" | "focusedNodeClass" | "dragOverNodeClass" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset" | "isAccordionExpand">>) => void;
390
+ }, "treeId" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "orderMember" | "isSorted" | "sortCallback" | "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "beforeCheckboxToggleCallback" | "rangeSelectionMode" | "shouldUseInternalSearchIndex" | "initializeIndexCallback" | "searchText" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayDebugInformation" | "shouldDisplayContextMenuInDebugMode" | "onNodeClick" | "onNodeDoubleClick" | "onHighlightChange" | "onSelectionChange" | "onNodeDragStart" | "onNodeDragOver" | "onNodeDrop" | "onCopy" | "onCut" | "onPaste" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "getContextMenuItemsCallback" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "bodyClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "highlightedNodeClass" | "nodeClass" | "nodeContentClass" | "focusedNodeClass" | "dragOverNodeClass" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset" | "isAccordionExpand">>) => void;
375
391
  };
376
392
  bindings: "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "searchText" | "insertResult" | "isRendering";
377
393
  slots: {};
@@ -557,11 +573,15 @@ declare class __sveltets_Render<T> {
557
573
  shouldAutoHandlePaste?: boolean;
558
574
  isAccordionExpand?: boolean;
559
575
  onNodeClick?: ((node: LTreeNode<T>) => void) | undefined;
576
+ onNodeDoubleClick?: ((node: LTreeNode<T>) => void) | undefined;
560
577
  onHighlightChange?: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
561
578
  onSelectionChange?: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
562
579
  onNodeDragStart?: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
563
580
  onNodeDragOver?: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
564
581
  onNodeDrop?: ((dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void) | undefined;
582
+ onCopy?: ((paths: string[]) => void) | undefined;
583
+ onCut?: ((paths: string[]) => void) | undefined;
584
+ onPaste?: ((result: import("../core/TreeController.svelte.js").PasteResult<T>) => void) | undefined;
565
585
  /**
566
586
  * Called before a drop is processed. Return false to cancel the drop.
567
587
  * Return { position, operation } to override the drop position or operation.
@@ -590,6 +610,10 @@ declare class __sveltets_Render<T> {
590
610
  bodyClass?: string | null | undefined;
591
611
  highlightedNodeClass?: string | null | undefined;
592
612
  focusedNodeClass?: string | null | undefined;
613
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node`. */
614
+ nodeClass?: ((node: LTreeNode<T>) => string | null | undefined) | undefined;
615
+ /** Data-driven per-row class hook. Return extra class(es) for `.stv__node-content`. */
616
+ nodeContentClass?: ((node: LTreeNode<T>) => string | null | undefined) | undefined;
593
617
  dragOverNodeClass?: string | null | undefined;
594
618
  expandIconClass?: string | null | undefined;
595
619
  collapseIconClass?: string | null | undefined;
@@ -603,7 +627,7 @@ declare class __sveltets_Render<T> {
603
627
  onTreeKeydown?: ((event: KeyboardEvent, controller: TreeController<T>) => boolean | void) | undefined;
604
628
  /** Override individual navigation methods (e.g. for custom ArrowDown/Up behavior) */
605
629
  navigationOverrides?: Partial<import("../core/navigation.js").TreeNavigation<T>> | undefined;
606
- }, "treeId" | "data" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "orderMember" | "isSorted" | "sortCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "shouldDisplayDebugInformation" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "beforeCheckboxToggleCallback" | "getContextMenuItemsCallback" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "rangeSelectionMode" | "initializeIndexCallback" | "searchText" | "shouldUseInternalSearchIndex" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayContextMenuInDebugMode" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "isAccordionExpand" | "onNodeClick" | "onNodeDragStart" | "onNodeDragOver" | "onHighlightChange" | "onSelectionChange" | "bodyClass" | "highlightedNodeClass" | "focusedNodeClass" | "dragOverNodeClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset">>) => void;
630
+ }, "treeId" | "data" | "treePathSeparator" | "idMember" | "pathMember" | "parentPathMember" | "levelMember" | "hasChildrenMember" | "isExpandedMember" | "getIsExpandedCallback" | "displayValueMember" | "getDisplayValueCallback" | "searchValueMember" | "getSearchValueCallback" | "orderMember" | "isSorted" | "sortCallback" | "isSelectableMember" | "getIsSelectableCallback" | "isSelectedMember" | "getIsSelectedCallback" | "isDraggableMember" | "getIsDraggableCallback" | "isDropAllowedMember" | "getIsDropAllowedCallback" | "isCollapsibleMember" | "getIsCollapsibleCallback" | "shouldDisplayDebugInformation" | "onNodeDrop" | "beforeDropCallback" | "beforeCopyCallback" | "beforeCutCallback" | "beforePasteCallback" | "beforeCheckboxToggleCallback" | "getContextMenuItemsCallback" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "expandLevel" | "clickBehavior" | "selectionMode" | "shouldShowCheckboxes" | "checkboxMode" | "shouldClickToggleCheckbox" | "rangeSelectionMode" | "initializeIndexCallback" | "searchText" | "shouldUseInternalSearchIndex" | "indexerBatchSize" | "indexerTimeout" | "shouldDisplayContextMenuInDebugMode" | "isVirtualScrollEnabled" | "virtualRowHeight" | "virtualOverscan" | "virtualContainerHeight" | "dragDropMode" | "dropZoneMode" | "isAccordionExpand" | "onNodeClick" | "onNodeDoubleClick" | "onNodeDragStart" | "onNodeDragOver" | "onHighlightChange" | "onSelectionChange" | "onCopy" | "onCut" | "onPaste" | "bodyClass" | "highlightedNodeClass" | "focusedNodeClass" | "nodeClass" | "nodeContentClass" | "dragOverNodeClass" | "expandIconClass" | "collapseIconClass" | "leafIconClass" | "toggleIconMode" | "scrollHighlightTimeout" | "scrollHighlightClass" | "contextMenuXOffset" | "contextMenuYOffset">>) => void;
607
631
  };
608
632
  }
609
633
  interface $$IsomorphicComponent {
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "5.0.0-rc11";
1
+ export declare const VERSION = "5.0.0-rc12";
2
2
  export declare const PACKAGE_NAME = "@keenmate/svelte-treeview";
3
3
  export declare const AUTHOR = "KeenMate";
4
4
  export declare const LICENSE = "MIT";
@@ -1,6 +1,6 @@
1
1
  // Auto-generated file - do not edit manually
2
2
  // Generated by scripts/generate-constants.js
3
- export const VERSION = "5.0.0-rc11";
3
+ export const VERSION = "5.0.0-rc12";
4
4
  export const PACKAGE_NAME = "@keenmate/svelte-treeview";
5
5
  export const AUTHOR = "KeenMate";
6
6
  export const LICENSE = "MIT";
@@ -46,6 +46,8 @@ export interface NodeConfig {
46
46
  highlightedNodeClass: string | null | undefined;
47
47
  focusedNodeClass: string | null | undefined;
48
48
  dragOverNodeClass: string | null | undefined;
49
+ nodeClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
50
+ nodeContentClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
49
51
  dropZoneMode: 'floating' | 'glow';
50
52
  dropZoneLayout: 'around' | 'above' | 'below' | 'wave' | 'wave2';
51
53
  dropZoneStart: number | string;
@@ -152,11 +154,14 @@ export interface TreeControllerProps<T> {
152
154
  shouldAutoHandlePaste?: boolean;
153
155
  isAccordionExpand?: boolean;
154
156
  onNodeClick?: (node: LTreeNode<T>) => void;
157
+ onNodeDoubleClick?: (node: LTreeNode<T>) => void;
155
158
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
156
159
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
157
160
  onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
158
161
  onHighlightChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
159
162
  onSelectionChange?: (paths: Set<string>, nodes: LTreeNode<T>[]) => void;
163
+ onCopy?: (paths: string[]) => void;
164
+ onCut?: (paths: string[]) => void;
160
165
  onPaste?: (result: PasteResult<T>) => void;
161
166
  beforeDropCallback?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => boolean | {
162
167
  position?: DropPosition;
@@ -176,6 +181,8 @@ export interface TreeControllerProps<T> {
176
181
  bodyClass?: string | null | undefined;
177
182
  highlightedNodeClass?: string | null | undefined;
178
183
  focusedNodeClass?: string | null | undefined;
184
+ nodeClass?: (node: LTreeNode<T>) => string | null | undefined;
185
+ nodeContentClass?: (node: LTreeNode<T>) => string | null | undefined;
179
186
  dragOverNodeClass?: string | null | undefined;
180
187
  expandIconClass?: string | null | undefined;
181
188
  collapseIconClass?: string | null | undefined;
@@ -200,14 +207,15 @@ export declare class TreeController<T> {
200
207
  * from the focused node. Set on first Shift action, advances on subsequent
201
208
  * Shift actions, cleared on any plain navigation. Not exposed via props. */
202
209
  private _shiftCursor;
203
- /** Manual double-click detection state for clickBehavior='select'. We can't
204
- * rely on the browser's native dblclick event in this mode because the first
205
- * click triggers focus → _setFocusedNode bumps node._rev → flat-mode {#each}
206
- * destroys and recreates the row, so the second click lands on a different
207
- * DOM element and the browser refuses to synthesize a dblclick. Tracking
208
- * the click on the controller (which survives the re-render) sidesteps that. */
209
- private _lastSelectClickPath;
210
- private _lastSelectClickTime;
210
+ /** Manual double-click detection state. We can't rely on the browser's native
211
+ * dblclick event because the first click triggers focus _setFocusedNode bumps
212
+ * node._rev → flat-mode {#each} destroys and recreates the row, so the second
213
+ * click lands on a different DOM element and the browser refuses to synthesize a
214
+ * dblclick. Tracking the click on the controller (which survives the re-render)
215
+ * sidesteps that. Drives both the public onNodeDoubleClick event (all
216
+ * clickBehaviors) and the built-in expand/collapse-on-double for 'select' mode. */
217
+ private _lastClickPath;
218
+ private _lastClickTime;
211
219
  selectedPaths: Set<string>;
212
220
  insertResult: InsertArrayResult<T> | null | undefined;
213
221
  searchText: string | null | undefined;
@@ -228,11 +236,14 @@ export declare class TreeController<T> {
228
236
  shouldAutoHandleMove: boolean;
229
237
  shouldAutoHandlePaste: boolean;
230
238
  onNodeClickHandler: ((node: LTreeNode<T>) => void) | undefined;
239
+ onNodeDoubleClickHandler: ((node: LTreeNode<T>) => void) | undefined;
231
240
  onHighlightChangeHandler: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
232
241
  onSelectionChangeHandler: ((paths: Set<string>, nodes: LTreeNode<T>[]) => void) | undefined;
233
242
  onNodeDragStartHandler: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
234
243
  onNodeDragOverHandler: ((node: LTreeNode<T>, event: DragEvent) => void) | undefined;
235
244
  onNodeDropHandler: TreeControllerProps<T>['onNodeDrop'];
245
+ onCopyHandler: ((paths: string[]) => void) | undefined;
246
+ onCutHandler: ((paths: string[]) => void) | undefined;
236
247
  onPasteHandler: ((result: PasteResult<T>) => void) | undefined;
237
248
  onRenderStartHandler: (() => void) | undefined;
238
249
  onRenderProgressHandler: ((stats: RenderStats) => void) | undefined;
@@ -254,6 +265,8 @@ export declare class TreeController<T> {
254
265
  toggleIconMode: ToggleIconMode;
255
266
  highlightedNodeClass: string | null | undefined;
256
267
  focusedNodeClass: string | null | undefined;
268
+ nodeClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
269
+ nodeContentClass: ((node: LTreeNode<any>) => string | null | undefined) | undefined;
257
270
  dragOverNodeClass: string | null | undefined;
258
271
  dropZoneMode: "floating" | "glow";
259
272
  dropZoneLayout: "around" | "above" | "below" | "wave" | "wave2";
@@ -26,6 +26,8 @@ export class TreeController {
26
26
  leafIconClass: 'stv__toggle-icon--leaf',
27
27
  highlightedNodeClass: undefined,
28
28
  focusedNodeClass: undefined,
29
+ nodeClass: undefined,
30
+ nodeContentClass: undefined,
29
31
  dragOverNodeClass: undefined,
30
32
  dropZoneMode: 'glow',
31
33
  dropZoneLayout: 'around',
@@ -46,14 +48,15 @@ export class TreeController {
46
48
  * from the focused node. Set on first Shift action, advances on subsequent
47
49
  * Shift actions, cleared on any plain navigation. Not exposed via props. */
48
50
  _shiftCursor = null;
49
- /** Manual double-click detection state for clickBehavior='select'. We can't
50
- * rely on the browser's native dblclick event in this mode because the first
51
- * click triggers focus → _setFocusedNode bumps node._rev → flat-mode {#each}
52
- * destroys and recreates the row, so the second click lands on a different
53
- * DOM element and the browser refuses to synthesize a dblclick. Tracking
54
- * the click on the controller (which survives the re-render) sidesteps that. */
55
- _lastSelectClickPath = null;
56
- _lastSelectClickTime = 0;
51
+ /** Manual double-click detection state. We can't rely on the browser's native
52
+ * dblclick event because the first click triggers focus _setFocusedNode bumps
53
+ * node._rev → flat-mode {#each} destroys and recreates the row, so the second
54
+ * click lands on a different DOM element and the browser refuses to synthesize a
55
+ * dblclick. Tracking the click on the controller (which survives the re-render)
56
+ * sidesteps that. Drives both the public onNodeDoubleClick event (all
57
+ * clickBehaviors) and the built-in expand/collapse-on-double for 'select' mode. */
58
+ _lastClickPath = null;
59
+ _lastClickTime = 0;
57
60
  selectedPaths = $state.raw(new Set());
58
61
  insertResult = $state.raw(null);
59
62
  searchText = $state(undefined);
@@ -77,11 +80,14 @@ export class TreeController {
77
80
  shouldAutoHandlePaste = $state(true);
78
81
  // Event handlers (on* = fire-and-forget)
79
82
  onNodeClickHandler;
83
+ onNodeDoubleClickHandler;
80
84
  onHighlightChangeHandler;
81
85
  onSelectionChangeHandler;
82
86
  onNodeDragStartHandler;
83
87
  onNodeDragOverHandler;
84
88
  onNodeDropHandler;
89
+ onCopyHandler;
90
+ onCutHandler;
85
91
  onPasteHandler;
86
92
  onRenderStartHandler;
87
93
  onRenderProgressHandler;
@@ -106,6 +112,8 @@ export class TreeController {
106
112
  toggleIconMode = $state('rotate');
107
113
  highlightedNodeClass = $state(undefined);
108
114
  focusedNodeClass = $state(undefined);
115
+ nodeClass = $state(undefined);
116
+ nodeContentClass = $state(undefined);
109
117
  dragOverNodeClass = $state(undefined);
110
118
  dropZoneMode = $state('glow');
111
119
  dropZoneLayout = $state('around');
@@ -238,6 +246,8 @@ export class TreeController {
238
246
  this.toggleIconMode = props.toggleIconMode ?? 'rotate';
239
247
  this.highlightedNodeClass = props.highlightedNodeClass;
240
248
  this.focusedNodeClass = props.focusedNodeClass;
249
+ this.nodeClass = props.nodeClass;
250
+ this.nodeContentClass = props.nodeContentClass;
241
251
  this.dragOverNodeClass = props.dragOverNodeClass;
242
252
  this.dropZoneMode = props.dropZoneMode ?? 'glow';
243
253
  this.dropZoneLayout = props.dropZoneLayout ?? 'around';
@@ -255,11 +265,14 @@ export class TreeController {
255
265
  this.virtualContainerHeight = props.virtualContainerHeight;
256
266
  // Store callbacks
257
267
  this.onNodeClickHandler = props.onNodeClick;
268
+ this.onNodeDoubleClickHandler = props.onNodeDoubleClick;
258
269
  this.onHighlightChangeHandler = props.onHighlightChange;
259
270
  this.onSelectionChangeHandler = props.onSelectionChange;
260
271
  this.onNodeDragStartHandler = props.onNodeDragStart;
261
272
  this.onNodeDragOverHandler = props.onNodeDragOver;
262
273
  this.onNodeDropHandler = props.onNodeDrop;
274
+ this.onCopyHandler = props.onCopy;
275
+ this.onCutHandler = props.onCut;
263
276
  this.onPasteHandler = props.onPaste;
264
277
  this.beforeDropHandler = props.beforeDropCallback;
265
278
  this.beforeCopyHandler = props.beforeCopyCallback;
@@ -294,7 +307,7 @@ export class TreeController {
294
307
  : null;
295
308
  // ── Create stable nodeCallbacks ─────────────────────────────────
296
309
  this.nodeCallbacks = {
297
- onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers),
310
+ onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers, { uiClick: true }),
298
311
  onCheckboxToggle: (node, options) => this._onCheckboxToggle(node, options),
299
312
  onNodeRightClicked: this._onNodeRightClicked.bind(this),
300
313
  onNodeDragStart: this._onNodeDragStart.bind(this),
@@ -318,6 +331,8 @@ export class TreeController {
318
331
  toggleIconMode: this.toggleIconMode,
319
332
  highlightedNodeClass: this.highlightedNodeClass,
320
333
  focusedNodeClass: this.focusedNodeClass,
334
+ nodeClass: this.nodeClass,
335
+ nodeContentClass: this.nodeContentClass,
321
336
  dragOverNodeClass: this.dragOverNodeClass,
322
337
  dropZoneMode: this.dropZoneMode,
323
338
  dropZoneLayout: this.dropZoneLayout,
@@ -348,6 +363,8 @@ export class TreeController {
348
363
  toggleIconMode: this.toggleIconMode,
349
364
  highlightedNodeClass: this.highlightedNodeClass,
350
365
  focusedNodeClass: this.focusedNodeClass,
366
+ nodeClass: this.nodeClass,
367
+ nodeContentClass: this.nodeContentClass,
351
368
  dragOverNodeClass: this.dragOverNodeClass,
352
369
  dropZoneMode: this.dropZoneMode,
353
370
  dropZoneLayout: this.dropZoneLayout,
@@ -757,13 +774,17 @@ export class TreeController {
757
774
  _collectClipboardEntry(node) {
758
775
  const descendants = [];
759
776
  const sep = this.treePathSeparator;
777
+ // $state.snapshot deproxies Svelte reactive state into a plain deep clone.
778
+ // structuredClone alone throws ("could not be cloned") when node.data is a
779
+ // $state proxy — which it is for any consumer passing default $state data.
780
+ const snapshot = (data) => $state.snapshot(data);
760
781
  const walk = (n) => {
761
782
  for (const child of Object.values(n.children)) {
762
783
  // relativePath = everything after sourcePath + separator
763
784
  const rel = child.path.substring(node.path.length);
764
785
  descendants.push({
765
786
  relativePath: rel,
766
- data: structuredClone(child.data)
787
+ data: snapshot(child.data)
767
788
  });
768
789
  walk(child);
769
790
  }
@@ -772,7 +793,7 @@ export class TreeController {
772
793
  return {
773
794
  sourceTreeId: this.treeId,
774
795
  sourcePath: node.path,
775
- data: structuredClone(node.data),
796
+ data: snapshot(node.data),
776
797
  descendants
777
798
  };
778
799
  }
@@ -808,6 +829,7 @@ export class TreeController {
808
829
  sourceTreeId: this.treeId
809
830
  });
810
831
  uiLogger.debug(`[clipboard] Copied ${entries.length} node(s)`);
832
+ this.onCopyHandler?.(pathsToUse);
811
833
  }
812
834
  /**
813
835
  * Cut nodes to the shared clipboard. Nodes are dimmed but NOT removed until paste.
@@ -851,6 +873,7 @@ export class TreeController {
851
873
  });
852
874
  this.cutPaths = cutSet;
853
875
  uiLogger.debug(`[clipboard] Cut ${entries.length} node(s), dimming ${cutSet.size} paths`);
876
+ this.onCutHandler?.(pathsToUse);
854
877
  }
855
878
  /**
856
879
  * Paste clipboard content under (or beside) the target node.
@@ -901,9 +924,12 @@ export class TreeController {
901
924
  targetPath,
902
925
  position
903
926
  };
904
- // Clear clipboard and cut state
927
+ // Clear cut-dimming. A CUT is a one-shot move, so clear the clipboard;
928
+ // a COPY stays on the clipboard so it can be pasted again (Finder /
929
+ // Explorer / VS Code convention).
905
930
  this.cutPaths = new Set();
906
- clearClipboard();
931
+ if (clip.operation === 'cut')
932
+ clearClipboard();
907
933
  uiLogger.debug(`[clipboard] shouldAutoHandlePaste=false — forwarding ${clip.entries.length} entries to consumer`);
908
934
  this.onPasteHandler?.(result);
909
935
  return result;
@@ -950,9 +976,12 @@ export class TreeController {
950
976
  tick().then(() => {
951
977
  this._skipInsertArray = false;
952
978
  });
953
- // Clear clipboard and cut state
979
+ // Clear cut-dimming. A CUT is a one-shot move (sources were just removed), so
980
+ // clear the clipboard; a COPY stays on the clipboard so it can be pasted again
981
+ // (Finder / Explorer / VS Code convention).
954
982
  this.cutPaths = new Set();
955
- clearClipboard();
983
+ if (clip.operation === 'cut')
984
+ clearClipboard();
956
985
  const result = {
957
986
  success: totalCount > 0,
958
987
  count: totalCount,
@@ -1414,6 +1443,10 @@ export class TreeController {
1414
1443
  this.highlightedNodeClass = updates.highlightedNodeClass;
1415
1444
  if (updates.focusedNodeClass !== undefined)
1416
1445
  this.focusedNodeClass = updates.focusedNodeClass;
1446
+ if (updates.nodeClass !== undefined)
1447
+ this.nodeClass = updates.nodeClass;
1448
+ if (updates.nodeContentClass !== undefined)
1449
+ this.nodeContentClass = updates.nodeContentClass;
1417
1450
  if (updates.dragOverNodeClass !== undefined)
1418
1451
  this.dragOverNodeClass = updates.dragOverNodeClass;
1419
1452
  if (updates.dropZoneMode !== undefined)
@@ -1445,6 +1478,8 @@ export class TreeController {
1445
1478
  // Callbacks
1446
1479
  if (updates.onNodeClick !== undefined)
1447
1480
  this.onNodeClickHandler = updates.onNodeClick;
1481
+ if (updates.onNodeDoubleClick !== undefined)
1482
+ this.onNodeDoubleClickHandler = updates.onNodeDoubleClick;
1448
1483
  if (updates.onNodeDragStart !== undefined)
1449
1484
  this.onNodeDragStartHandler = updates.onNodeDragStart;
1450
1485
  if (updates.onNodeDragOver !== undefined)
@@ -1459,6 +1494,10 @@ export class TreeController {
1459
1494
  this.beforePasteHandler = updates.beforePasteCallback;
1460
1495
  if (updates.onNodeDrop !== undefined)
1461
1496
  this.onNodeDropHandler = updates.onNodeDrop;
1497
+ if (updates.onCopy !== undefined)
1498
+ this.onCopyHandler = updates.onCopy;
1499
+ if (updates.onCut !== undefined)
1500
+ this.onCutHandler = updates.onCut;
1462
1501
  if (updates.onPaste !== undefined)
1463
1502
  this.onPasteHandler = updates.onPaste;
1464
1503
  if (updates.getContextMenuItemsCallback !== undefined)
@@ -1473,31 +1512,39 @@ export class TreeController {
1473
1512
  if (this.contextMenuVisible) {
1474
1513
  this.closeContextMenu();
1475
1514
  }
1476
- // Manual dblclick detection for clickBehavior='select' — see the comment
1477
- // on _lastSelectClickPath for why the browser's native dblclick can't be
1478
- // trusted in this mode. Threshold matches Windows' default double-click
1479
- // interval (500ms is the system default; we use a slightly tighter 400ms
1480
- // to avoid coupling unrelated clicks).
1481
- if (this.clickBehavior === 'select' && !modifiers?.ctrl && !modifiers?.shift) {
1515
+ // Manual double-click detection — see the comment on _lastClickPath for why the
1516
+ // browser's native dblclick can't be trusted (focus bumps node._rev → flat-mode
1517
+ // row is recreated the 2nd click lands on a fresh element). Runs for every
1518
+ // clickBehavior so onNodeDoubleClick fires consistently; the built-in
1519
+ // expand/collapse-on-double-click only applies to clickBehavior='select' (the
1520
+ // other modes already toggle on single click). On a detected double we consume
1521
+ // the 2nd click (return early) so the gesture is a single open, not a re-toggle.
1522
+ // Threshold = 400ms, a touch tighter than Windows' 500ms default to avoid
1523
+ // coupling unrelated clicks. Gated on uiClick so programmatic highlight/select
1524
+ // API calls never get mistaken for a double-click.
1525
+ if (options?.uiClick && !modifiers?.ctrl && !modifiers?.shift) {
1482
1526
  const now = Date.now();
1483
- const isDouble = this._lastSelectClickPath === node.path &&
1484
- now - this._lastSelectClickTime < 400;
1527
+ const isDouble = this._lastClickPath === node.path &&
1528
+ now - this._lastClickTime < 400;
1485
1529
  if (isDouble) {
1486
- this._lastSelectClickPath = null;
1487
- this._lastSelectClickTime = 0;
1488
- const canonical = this.tree.getNodeByPath(node.path) ?? node;
1489
- if (canonical.hasChildren && canonical.isCollapsible !== false) {
1490
- if (canonical.isExpanded) {
1491
- this.collapseNodes(canonical.path);
1492
- }
1493
- else {
1494
- this.expandNodes(canonical.path);
1530
+ this._lastClickPath = null;
1531
+ this._lastClickTime = 0;
1532
+ this.onNodeDoubleClickHandler?.(node);
1533
+ if (this.clickBehavior === 'select') {
1534
+ const canonical = this.tree.getNodeByPath(node.path) ?? node;
1535
+ if (canonical.hasChildren && canonical.isCollapsible !== false) {
1536
+ if (canonical.isExpanded) {
1537
+ this.collapseNodes(canonical.path);
1538
+ }
1539
+ else {
1540
+ this.expandNodes(canonical.path);
1541
+ }
1495
1542
  }
1496
1543
  }
1497
1544
  return;
1498
1545
  }
1499
- this._lastSelectClickPath = node.path;
1500
- this._lastSelectClickTime = now;
1546
+ this._lastClickPath = node.path;
1547
+ this._lastClickTime = now;
1501
1548
  }
1502
1549
  // In single mode, mouse Ctrl/Shift+click degrade to plain click. Programmatic
1503
1550
  // callers (highlightNode with mode='toggle'/'range') pass forceMultiSemantics
@@ -51,6 +51,15 @@ export interface ContextMenuItem {
51
51
  isVisible?: boolean;
52
52
  className?: string;
53
53
  onclick?: () => void | Promise<void>;
54
+ /**
55
+ * Whether activating this item auto-closes the menu. Default `true` —
56
+ * selecting an entry dismisses the menu like every native/desktop menu.
57
+ * Set `false` for items that act incrementally (toggle a flag, run a
58
+ * multi-step action) and want the menu to stay open; the handler is then
59
+ * responsible for dismissing it via the `close` callback passed to
60
+ * getContextMenuItemsCallback (or the snippet's close prop).
61
+ */
62
+ shouldCloseOnClick?: boolean;
54
63
  children?: ContextMenuEntry[];
55
64
  }
56
65
  export type ContextMenuEntry = ContextMenuItem | ContextMenuDivider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/svelte-treeview",
3
- "version": "5.0.0-rc11",
3
+ "version": "5.0.0-rc12",
4
4
  "scripts": {
5
5
  "dev": "vite dev --port 17777",
6
6
  "build": "vite build && npm run prepack",