@keenmate/svelte-treeview 5.0.0-rc07 → 5.0.0-rc08

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
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.0-rc08] - 2026-05-27
11
+
12
+ ### Added
13
+ - **`{ silent: true }` option on highlight/selection methods**: `highlightNode`, `highlightNodes`, `clearHighlight`, `deselectAll` (and deprecated `selectNode`/`selectNodes`) now accept `{ silent: true }` to update state without firing `onNodeClick` / `onHighlightChange` / `onSelectionChange`. Intended for URL-restore flows (deep links loading form data from query params) where firing the change callback would re-trigger form loaders and clobber the data the URL just supplied. Silent mode also skips focusing the tree container so it doesn't steal focus from whatever the user is interacting with.
14
+ - **Array variants on expand/collapse methods**: `expandNodes`, `collapseNodes`, `expandAll`, and `collapseAll` now accept `string | string[]`. Single emit per call regardless of array length.
15
+ - **`{ exclusive: true }` option on `expandNodes` and `expandAll`**: Opens the target path(s) and collapses anything currently expanded that isn't on the union-of-spines (and, for `expandAll`, not under a target subtree). Equivalent to `collapseAll() + expandNodes(path)` but in a single pass with one emit — downstream listeners (transition animations, URL sync, virtualized renderers) don't see the intermediate fully-collapsed state. Non-collapsible nodes (`isCollapsible === false`) are never touched.
16
+ - **`{ noEmit: true }` option on all four expand/collapse methods**: Skips the change emit, enabling batching multiple operations and emitting once at the end via `tree.refresh()`.
17
+ - **`/examples/silent-highlight` demo page**: URL-restore scenario with loud vs. silent toggle showing how form data is preserved in silent mode.
18
+ - **`/examples/expand-collapse` demo page**: Demonstrates array variants and exclusive focus mode.
19
+
10
20
  ## [5.0.0-rc07] - 2026-05-23
11
21
 
12
22
  ### Added
package/README.md CHANGED
@@ -6,38 +6,19 @@ A high-performance, feature-rich hierarchical tree view component for Svelte 5 w
6
6
 
7
7
  Browse interactive code examples and the full API reference at **[svelte-treeview.keenmate.dev](https://svelte-treeview.keenmate.dev)**
8
8
 
9
+ ## What's New in v5.0.0-rc08
10
+
11
+ - **`{ silent: true }` on highlight/selection methods**: Update tree state from URL params or other external sources without firing `onNodeClick` / `onHighlightChange` / `onSelectionChange` — perfect for deep links where you don't want the change callback to re-trigger your form loader. Applies to `highlightNode`, `highlightNodes`, `clearHighlight`, and `deselectAll`.
12
+ - **Array variants for expand/collapse**: `expandNodes`, `collapseNodes`, `expandAll`, and `collapseAll` now take `string | string[]`. Open or close several places in one call.
13
+ - **Exclusive focus mode**: Pass `{ exclusive: true }` to `expandNodes` / `expandAll` to open the target path and collapse everything else in a single pass — no two-step flicker compared to `collapseAll() + expandNodes(path)`.
14
+ - **`noEmit` option for batching**: Suppress the change emit on individual expand/collapse calls when chaining several operations; emit once at the end.
15
+
9
16
  ## What's New in v5.0.0-rc07
10
17
 
11
18
  - **`isSelectedMember` prop**: Seed `selectedPaths` directly from your data — point the prop at a boolean field and every node where it's truthy lands pre-checked after `insertArray`.
12
19
  - **`isSelectableMember` fully wired through `Tree`**: The prop was already on the core types but wasn't reachable via the component. Now end-to-end usable to control checkbox rendering and clickability per node.
13
20
  - **Fix: filter race during async indexing**: `bind:searchText` no longer hides the entire tree if the FlexSearch index is still being built when the filter is applied. The filter now re-applies itself automatically once indexing completes, regardless of `indexerBatchSize` or dataset size.
14
21
 
15
- ## What's New in v5.0.0-rc06
16
-
17
- - **Three-level selection**: `focusedNode` (click/arrows), `highlightedPaths` (Ctrl/Shift+click), `selectedPaths` (checkbox data state). Highlight and checkbox are independent — build a selection, then check/uncheck all at once.
18
- - **`clickBehavior` prop**: `'select'` | `'expand'` | `'expand-and-focus'` (default). Replaces `shouldToggleOnNodeClick`.
19
- - **`showCheckboxes` + `checkboxMode`**: Custom styled checkboxes with `'independent'` or `'cascade'` mode (parent toggles descendants, indeterminate state).
20
- - **Shift+Arrow/Home/End/PageUp/PageDown**: Extends highlight range via keyboard, like file managers.
21
- - **Pluggable keyboard navigation**: `TreeNavigation<T>` interface — each renderer provides its own spatial implementation. Override individual methods via `TreeNavigationOverrides<T>`.
22
- - **Bulk subtree operations**: `insertBranch()`, `replaceBranch()`, `deleteBranch()` on TreeController — add, replace, or remove entire subtrees with a single emission.
23
- - **Clipboard API**: `copyNodes()`, `cutNodes()`, `pasteNodes()`, `cancelCut()` on TreeController. Cut nodes are visually dimmed. Supports auto-handle and manual (server-driven) paste modes.
24
-
25
- ### v5.0.0-rc03
26
-
27
- - **Multi-select**: Ctrl+click toggles nodes, Shift+click selects ranges, plain click replaces selection. New `selectedPaths` bindable (`Set<string>`), `onSelectionChanged` event, and `rangeSelectionMode` prop (`'visual'` vs `'logical'`).
28
- - **Public multi-select API**: `selectNode(path, mode)`, `selectNodes(paths)`, `deselectAll()`, `getSelectedNodes()`, `isNodeSelected(path)` on TreeController.
29
- - **Selection-aware context menus**: `contextMenuCallback` now receives `selectedNodes` as 3rd parameter for bulk actions.
30
-
31
- ### v5.0.0-rc03
32
-
33
- - **Unified Context Menu API**: New shared type system (`ContextMenuItem`, `ContextMenuDivider`, `ContextMenuEntry`) used by both svelte-treeview and canvas-tree. Breaking: `title` → `label`, `callback` → `onclick`, `isDivider` → separate `ContextMenuDivider` type.
34
- - **Context menu features**: Keyboard shortcuts, nested submenus, named dividers (`──── Section ────`), `isVisible`/`isDisabled` per item, `className="danger"`, async `onclick`.
35
- - **Svelte context menu components**: `ContextMenuItemC` and `ContextMenuDividerC` for declarative snippet-based menus alongside the callback approach.
36
- - **Accordion expand**: `accordionExpand={true}` — expanding a node auto-collapses its siblings.
37
- - **Toggle icon mode**: `toggleIconMode="rotate"` (default) smoothly rotates the expand icon vs `"swap"` which switches between two icons.
38
- - **Fix**: Expand/collapse icon not updating in flat rendering mode.
39
- - **Fix**: `vite.config.ts` no longer requires `@types/node`.
40
-
41
22
  ## v5.0: Core/Renderer Split + Virtual Scroll
42
23
 
43
24
  > [!IMPORTANT]
@@ -507,20 +507,32 @@
507
507
  );
508
508
 
509
509
  // ── Export public methods (thin proxies) ────────────────────────────
510
- export async function expandNodes(nodePath: string) {
511
- controller.expandNodes(nodePath);
510
+ export async function expandNodes(
511
+ nodePath: string | string[],
512
+ options?: { exclusive?: boolean; noEmit?: boolean }
513
+ ) {
514
+ controller.expandNodes(nodePath, options);
512
515
  }
513
516
 
514
- export async function collapseNodes(nodePath: string) {
515
- controller.collapseNodes(nodePath);
517
+ export async function collapseNodes(
518
+ nodePath: string | string[],
519
+ options?: { noEmit?: boolean }
520
+ ) {
521
+ controller.collapseNodes(nodePath, options);
516
522
  }
517
523
 
518
- export function expandAll(nodePath?: string | null | undefined) {
519
- controller.expandAll(nodePath);
524
+ export function expandAll(
525
+ nodePath?: string | string[] | null | undefined,
526
+ options?: { exclusive?: boolean; noEmit?: boolean }
527
+ ) {
528
+ controller.expandAll(nodePath, options);
520
529
  }
521
530
 
522
- export function collapseAll(nodePath?: string | null | undefined) {
523
- controller.collapseAll(nodePath);
531
+ export function collapseAll(
532
+ nodePath?: string | string[] | null | undefined,
533
+ options?: { noEmit?: boolean }
534
+ ) {
535
+ controller.collapseAll(nodePath, options);
524
536
  }
525
537
 
526
538
  export function filterNodes(searchTextVal: string, searchOptions?: SearchOptions): void {
@@ -613,16 +625,28 @@
613
625
  }
614
626
 
615
627
  // Multi-select methods
616
- export function selectNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace') {
617
- controller.selectNode(path, mode);
628
+ export function selectNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace', options?: { silent?: boolean }) {
629
+ controller.selectNode(path, mode, options);
630
+ }
631
+
632
+ export function selectNodes(paths: string[], options?: { silent?: boolean }) {
633
+ controller.selectNodes(paths, options);
634
+ }
635
+
636
+ export function highlightNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace', options?: { silent?: boolean }) {
637
+ controller.highlightNode(path, mode, options);
638
+ }
639
+
640
+ export function highlightNodes(paths: string[], options?: { silent?: boolean }) {
641
+ controller.highlightNodes(paths, options);
618
642
  }
619
643
 
620
- export function selectNodes(paths: string[]) {
621
- controller.selectNodes(paths);
644
+ export function clearHighlight(options?: { silent?: boolean }) {
645
+ controller.clearHighlight(options);
622
646
  }
623
647
 
624
- export function deselectAll() {
625
- controller.deselectAll();
648
+ export function deselectAll(options?: { silent?: boolean }) {
649
+ controller.deselectAll(options);
626
650
  }
627
651
 
628
652
  export function getSelectedNodes(): LTreeNode<T>[] {
@@ -134,10 +134,20 @@ declare function $$render<T>(): {
134
134
  navigationOverrides?: TreeNavigationOverrides<T>;
135
135
  };
136
136
  exports: {
137
- expandNodes: (nodePath: string) => Promise<void>;
138
- collapseNodes: (nodePath: string) => Promise<void>;
139
- expandAll: (nodePath?: string | null | undefined) => void;
140
- collapseAll: (nodePath?: string | null | undefined) => void;
137
+ expandNodes: (nodePath: string | string[], options?: {
138
+ exclusive?: boolean;
139
+ noEmit?: boolean;
140
+ }) => Promise<void>;
141
+ collapseNodes: (nodePath: string | string[], options?: {
142
+ noEmit?: boolean;
143
+ }) => Promise<void>;
144
+ expandAll: (nodePath?: string | string[] | null | undefined, options?: {
145
+ exclusive?: boolean;
146
+ noEmit?: boolean;
147
+ }) => void;
148
+ collapseAll: (nodePath?: string | string[] | null | undefined, options?: {
149
+ noEmit?: boolean;
150
+ }) => void;
141
151
  filterNodes: (searchTextVal: string, searchOptions?: SearchOptions) => void;
142
152
  searchNodes: (searchTextVal: string | null | undefined, searchOptions?: SearchOptions) => LTreeNode<T>[];
143
153
  getChildren: (parentPath: string) => LTreeNode<T>[];
@@ -178,9 +188,24 @@ declare function $$render<T>(): {
178
188
  setExpandedPaths: (paths: string[]) => void;
179
189
  getAllData: () => T[];
180
190
  closeContextMenu: () => void;
181
- selectNode: (path: string, mode?: "replace" | "toggle" | "range") => void;
182
- selectNodes: (paths: string[]) => void;
183
- deselectAll: () => void;
191
+ selectNode: (path: string, mode?: "replace" | "toggle" | "range", options?: {
192
+ silent?: boolean;
193
+ }) => void;
194
+ selectNodes: (paths: string[], options?: {
195
+ silent?: boolean;
196
+ }) => void;
197
+ highlightNode: (path: string, mode?: "replace" | "toggle" | "range", options?: {
198
+ silent?: boolean;
199
+ }) => void;
200
+ highlightNodes: (paths: string[], options?: {
201
+ silent?: boolean;
202
+ }) => void;
203
+ clearHighlight: (options?: {
204
+ silent?: boolean;
205
+ }) => void;
206
+ deselectAll: (options?: {
207
+ silent?: boolean;
208
+ }) => void;
184
209
  getSelectedNodes: () => LTreeNode<T>[];
185
210
  isNodeSelected: (path: string) => boolean;
186
211
  scrollToPath: (path: string, options?: {
@@ -329,10 +354,20 @@ declare class __sveltets_Render<T> {
329
354
  slots(): ReturnType<typeof $$render<T>>['slots'];
330
355
  bindings(): "data" | "focusedNode" | "highlightedPaths" | "selectedPaths" | "searchText" | "insertResult" | "isRendering";
331
356
  exports(): {
332
- expandNodes: (nodePath: string) => Promise<void>;
333
- collapseNodes: (nodePath: string) => Promise<void>;
334
- expandAll: (nodePath?: string | null | undefined) => void;
335
- collapseAll: (nodePath?: string | null | undefined) => void;
357
+ expandNodes: (nodePath: string | string[], options?: {
358
+ exclusive?: boolean;
359
+ noEmit?: boolean;
360
+ } | undefined) => Promise<void>;
361
+ collapseNodes: (nodePath: string | string[], options?: {
362
+ noEmit?: boolean;
363
+ } | undefined) => Promise<void>;
364
+ expandAll: (nodePath?: string | string[] | null | undefined, options?: {
365
+ exclusive?: boolean;
366
+ noEmit?: boolean;
367
+ } | undefined) => void;
368
+ collapseAll: (nodePath?: string | string[] | null | undefined, options?: {
369
+ noEmit?: boolean;
370
+ } | undefined) => void;
336
371
  filterNodes: (searchTextVal: string, searchOptions?: SearchOptions) => void;
337
372
  searchNodes: (searchTextVal: string | null | undefined, searchOptions?: SearchOptions) => LTreeNode<T>[];
338
373
  getChildren: (parentPath: string) => LTreeNode<T>[];
@@ -373,9 +408,24 @@ declare class __sveltets_Render<T> {
373
408
  setExpandedPaths: (paths: string[]) => void;
374
409
  getAllData: () => T[];
375
410
  closeContextMenu: () => void;
376
- selectNode: (path: string, mode?: "replace" | "toggle" | "range") => void;
377
- selectNodes: (paths: string[]) => void;
378
- deselectAll: () => void;
411
+ selectNode: (path: string, mode?: "replace" | "toggle" | "range", options?: {
412
+ silent?: boolean;
413
+ } | undefined) => void;
414
+ selectNodes: (paths: string[], options?: {
415
+ silent?: boolean;
416
+ } | undefined) => void;
417
+ highlightNode: (path: string, mode?: "replace" | "toggle" | "range", options?: {
418
+ silent?: boolean;
419
+ } | undefined) => void;
420
+ highlightNodes: (paths: string[], options?: {
421
+ silent?: boolean;
422
+ } | undefined) => void;
423
+ clearHighlight: (options?: {
424
+ silent?: boolean;
425
+ } | undefined) => void;
426
+ deselectAll: (options?: {
427
+ silent?: boolean;
428
+ } | undefined) => void;
379
429
  getSelectedNodes: () => LTreeNode<T>[];
380
430
  isNodeSelected: (path: string) => boolean;
381
431
  scrollToPath: (path: string, options?: {
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "5.0.0-rc07";
1
+ export declare const VERSION = "5.0.0-rc08";
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-rc07";
3
+ export const VERSION = "5.0.0-rc08";
4
4
  export const PACKAGE_NAME = "@keenmate/svelte-treeview";
5
5
  export const AUTHOR = "KeenMate";
6
6
  export const LICENSE = "MIT";
@@ -298,10 +298,20 @@ export declare class TreeController<T> {
298
298
  };
299
299
  constructor(props: TreeControllerProps<T>);
300
300
  handleVirtualScroll: (event: Event) => void;
301
- expandNodes(nodePath: string): Promise<void>;
302
- collapseNodes(nodePath: string): Promise<void>;
303
- expandAll(nodePath?: string | null | undefined): void;
304
- collapseAll(nodePath?: string | null | undefined): void;
301
+ expandNodes(nodePath: string | string[], options?: {
302
+ exclusive?: boolean;
303
+ noEmit?: boolean;
304
+ }): Promise<void>;
305
+ collapseNodes(nodePath: string | string[], options?: {
306
+ noEmit?: boolean;
307
+ }): Promise<void>;
308
+ expandAll(nodePath?: string | string[] | null | undefined, options?: {
309
+ exclusive?: boolean;
310
+ noEmit?: boolean;
311
+ }): void;
312
+ collapseAll(nodePath?: string | string[] | null | undefined, options?: {
313
+ noEmit?: boolean;
314
+ }): void;
305
315
  filterNodes(searchTextVal: string, searchOptions?: SearchOptions): void;
306
316
  searchNodes(searchTextVal: string | null | undefined, searchOptions?: SearchOptions): LTreeNode<T>[];
307
317
  getChildren(parentPath: string): LTreeNode<T>[];
@@ -441,12 +451,21 @@ export declare class TreeController<T> {
441
451
  /** Get ALL nodes between two paths (inclusive), in depth-first tree order.
442
452
  * Includes collapsed/hidden nodes — "logical" range selection. */
443
453
  private _getAllNodesBetween;
444
- /** Highlight a node with the given mode */
445
- highlightNode(path: string, mode?: 'replace' | 'toggle' | 'range'): void;
446
- /** Highlight multiple nodes by paths (replaces current highlights) */
447
- highlightNodes(paths: string[]): void;
448
- /** Clear all highlights */
449
- clearHighlight(): void;
454
+ /** Highlight a node with the given mode.
455
+ * Pass `{ silent: true }` to update state without firing `onNodeClick` / `onHighlightChange`
456
+ * (useful when restoring state from URL params or other external sources). */
457
+ highlightNode(path: string, mode?: 'replace' | 'toggle' | 'range', options?: {
458
+ silent?: boolean;
459
+ }): void;
460
+ /** Highlight multiple nodes by paths (replaces current highlights).
461
+ * Pass `{ silent: true }` to skip `onHighlightChange`. */
462
+ highlightNodes(paths: string[], options?: {
463
+ silent?: boolean;
464
+ }): void;
465
+ /** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
466
+ clearHighlight(options?: {
467
+ silent?: boolean;
468
+ }): void;
450
469
  /** Get all highlighted nodes */
451
470
  getHighlightedNodes(): LTreeNode<T>[];
452
471
  /** Check if a specific node path is highlighted */
@@ -455,12 +474,18 @@ export declare class TreeController<T> {
455
474
  getSelectedNodes(): LTreeNode<T>[];
456
475
  /** Check if a specific node path is selected (checked) */
457
476
  isNodeSelected(path: string): boolean;
458
- /** Clear all checkbox selections */
459
- deselectAll(): void;
477
+ /** Clear all checkbox selections. Pass `{ silent: true }` to skip `onSelectionChange`. */
478
+ deselectAll(options?: {
479
+ silent?: boolean;
480
+ }): void;
460
481
  /** @deprecated Use highlightNode() instead */
461
- selectNode(path: string, mode?: 'replace' | 'toggle' | 'range'): void;
482
+ selectNode(path: string, mode?: 'replace' | 'toggle' | 'range', options?: {
483
+ silent?: boolean;
484
+ }): void;
462
485
  /** @deprecated Use highlightNodes() instead */
463
- selectNodes(paths: string[]): void;
486
+ selectNodes(paths: string[], options?: {
487
+ silent?: boolean;
488
+ }): void;
464
489
  private _onNodeRightClicked;
465
490
  private isDropAllowedByMode;
466
491
  private calculateDropPosition;
@@ -510,17 +510,17 @@ export class TreeController {
510
510
  });
511
511
  };
512
512
  // ── Public API methods ──────────────────────────────────────────────
513
- async expandNodes(nodePath) {
514
- this.tree.expandNodes(nodePath);
513
+ async expandNodes(nodePath, options) {
514
+ this.tree.expandNodes(nodePath, options);
515
515
  }
516
- async collapseNodes(nodePath) {
517
- this.tree.collapseNodes(nodePath);
516
+ async collapseNodes(nodePath, options) {
517
+ this.tree.collapseNodes(nodePath, options);
518
518
  }
519
- expandAll(nodePath) {
520
- this.tree?.expandAll(nodePath);
519
+ expandAll(nodePath, options) {
520
+ this.tree?.expandAll(nodePath, options);
521
521
  }
522
- collapseAll(nodePath) {
523
- this.tree?.collapseAll(nodePath);
522
+ collapseAll(nodePath, options) {
523
+ this.tree?.collapseAll(nodePath, options);
524
524
  }
525
525
  filterNodes(searchTextVal, searchOptions) {
526
526
  this.tree?.filterNodes(searchTextVal, searchOptions);
@@ -1327,12 +1327,13 @@ export class TreeController {
1327
1327
  this.onSelectionChangeHandler = updates.onSelectionChange;
1328
1328
  }
1329
1329
  // ── Internal event handlers ─────────────────────────────────────────
1330
- async _onNodeClicked(node, modifiers) {
1330
+ async _onNodeClicked(node, modifiers, options) {
1331
1331
  if (this.contextMenuVisible) {
1332
1332
  this.closeContextMenu();
1333
1333
  }
1334
1334
  const ctrl = modifiers?.ctrl ?? false;
1335
1335
  const shift = modifiers?.shift ?? false;
1336
+ const silent = options?.silent ?? false;
1336
1337
  uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift, lastAnchor: this.lastHighlightedPath, prevCount: this.highlightedPaths.size });
1337
1338
  if (ctrl) {
1338
1339
  // Toggle this node in/out of highlight
@@ -1378,11 +1379,17 @@ export class TreeController {
1378
1379
  }
1379
1380
  // Update focus
1380
1381
  this._setFocusedNode(node);
1381
- this.onNodeClickHandler?.(node);
1382
- this._notifyHighlightChanged();
1382
+ if (!silent) {
1383
+ this.onNodeClickHandler?.(node);
1384
+ this._notifyHighlightChanged();
1385
+ }
1383
1386
  this.tree.refresh();
1384
- // Focus the tree container so keyboard navigation works after clicking a node
1385
- this.containerElement?.focus();
1387
+ // Focus the tree container so keyboard navigation works after clicking a node.
1388
+ // Skip in silent mode — programmatic highlight (e.g. from URL params) shouldn't
1389
+ // steal focus from whatever the user is currently interacting with.
1390
+ if (!silent) {
1391
+ this.containerElement?.focus();
1392
+ }
1386
1393
  }
1387
1394
  /** Get all descendant paths of a node (depth-first) */
1388
1395
  _getDescendantPaths(node) {
@@ -1648,23 +1655,26 @@ export class TreeController {
1648
1655
  return result;
1649
1656
  }
1650
1657
  // ── Public highlight methods (UI selection) ────────────────────────
1651
- /** Highlight a node with the given mode */
1652
- highlightNode(path, mode = 'replace') {
1658
+ /** Highlight a node with the given mode.
1659
+ * Pass `{ silent: true }` to update state without firing `onNodeClick` / `onHighlightChange`
1660
+ * (useful when restoring state from URL params or other external sources). */
1661
+ highlightNode(path, mode = 'replace', options) {
1653
1662
  const node = this.tree.getNodeByPath(path);
1654
1663
  if (!node)
1655
1664
  return;
1656
1665
  if (mode === 'toggle') {
1657
- this._onNodeClicked(node, { ctrl: true, shift: false });
1666
+ this._onNodeClicked(node, { ctrl: true, shift: false }, options);
1658
1667
  }
1659
1668
  else if (mode === 'range') {
1660
- this._onNodeClicked(node, { ctrl: false, shift: true });
1669
+ this._onNodeClicked(node, { ctrl: false, shift: true }, options);
1661
1670
  }
1662
1671
  else {
1663
- this._onNodeClicked(node);
1672
+ this._onNodeClicked(node, undefined, options);
1664
1673
  }
1665
1674
  }
1666
- /** Highlight multiple nodes by paths (replaces current highlights) */
1667
- highlightNodes(paths) {
1675
+ /** Highlight multiple nodes by paths (replaces current highlights).
1676
+ * Pass `{ silent: true }` to skip `onHighlightChange`. */
1677
+ highlightNodes(paths, options) {
1668
1678
  this._clearAllHighlightFlags();
1669
1679
  const newPaths = new Set();
1670
1680
  let lastNode = null;
@@ -1682,15 +1692,17 @@ export class TreeController {
1682
1692
  this._setFocusedNode(lastNode);
1683
1693
  this.lastHighlightedPath = lastNode.path;
1684
1694
  }
1685
- this._notifyHighlightChanged();
1695
+ if (!options?.silent)
1696
+ this._notifyHighlightChanged();
1686
1697
  this.tree.refresh();
1687
1698
  }
1688
- /** Clear all highlights */
1689
- clearHighlight() {
1699
+ /** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
1700
+ clearHighlight(options) {
1690
1701
  this._clearAllHighlightFlags();
1691
1702
  this.highlightedPaths = new Set();
1692
1703
  this.lastHighlightedPath = null;
1693
- this._notifyHighlightChanged();
1704
+ if (!options?.silent)
1705
+ this._notifyHighlightChanged();
1694
1706
  this.tree.refresh();
1695
1707
  }
1696
1708
  /** Get all highlighted nodes */
@@ -1722,20 +1734,21 @@ export class TreeController {
1722
1734
  isNodeSelected(path) {
1723
1735
  return this.selectedPaths.has(path);
1724
1736
  }
1725
- /** Clear all checkbox selections */
1726
- deselectAll() {
1737
+ /** Clear all checkbox selections. Pass `{ silent: true }` to skip `onSelectionChange`. */
1738
+ deselectAll(options) {
1727
1739
  this._clearAllSelectionFlags();
1728
1740
  this.selectedPaths = new Set();
1729
- this._notifySelectionChanged();
1741
+ if (!options?.silent)
1742
+ this._notifySelectionChanged();
1730
1743
  this.tree.refresh();
1731
1744
  }
1732
1745
  /** @deprecated Use highlightNode() instead */
1733
- selectNode(path, mode = 'replace') {
1734
- this.highlightNode(path, mode);
1746
+ selectNode(path, mode = 'replace', options) {
1747
+ this.highlightNode(path, mode, options);
1735
1748
  }
1736
1749
  /** @deprecated Use highlightNodes() instead */
1737
- selectNodes(paths) {
1738
- this.highlightNodes(paths);
1750
+ selectNodes(paths, options) {
1751
+ this.highlightNodes(paths, options);
1739
1752
  }
1740
1753
  _onNodeRightClicked(node, event) {
1741
1754
  if (!this.hasContextMenuSnippet && !this.getContextMenuItemsHandler) {
@@ -437,28 +437,89 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
437
437
  this.isFiltered = false;
438
438
  this._emitTreeChanged();
439
439
  },
440
- expandAll(nodePath) {
440
+ expandAll(nodePath, options) {
441
441
  perfStart(`[${_treeId}] expandAll`);
442
+ const self = this;
443
+ const exclusive = options?.exclusive ?? false;
444
+ const noEmit = options?.noEmit ?? false;
442
445
  function setExpandedRecursive(node, value) {
443
446
  node.isExpanded = value;
444
447
  for (const key in node.children) {
445
448
  setExpandedRecursive(node.children[key], value);
446
449
  }
447
450
  }
448
- if (isEmptyString(nodePath)) {
451
+ const paths = Array.isArray(nodePath)
452
+ ? nodePath.filter((p) => !isEmptyString(p))
453
+ : isEmptyString(nodePath)
454
+ ? []
455
+ : [nodePath];
456
+ if (paths.length === 0) {
457
+ // Whole-tree expand. exclusive has no meaning (nothing to exclude from).
449
458
  setExpandedRecursive(root, true);
450
459
  }
451
460
  else {
452
- const target = this.getNodeByPath(nodePath);
453
- if (target)
454
- setExpandedRecursive(target, true);
461
+ if (exclusive) {
462
+ // Build spine: every ancestor path of every target must stay expanded
463
+ // so the target subtree is reachable.
464
+ const spineSet = new Set();
465
+ for (const p of paths) {
466
+ const segs = p.split(this.treePathSeparator);
467
+ for (let i = 0; i < segs.length; i++) {
468
+ spineSet.add(segs.slice(0, i + 1).join(this.treePathSeparator));
469
+ }
470
+ }
471
+ const isUnderTarget = (path) => {
472
+ for (const tp of paths) {
473
+ if (path === tp || path.startsWith(tp + this.treePathSeparator))
474
+ return true;
475
+ }
476
+ return false;
477
+ };
478
+ // Walk currently-expanded subtree; collapse anything not on a spine
479
+ // and not under a target. Only descends into expanded branches.
480
+ const trim = (node) => {
481
+ for (const key in node.children) {
482
+ const child = node.children[key];
483
+ if (!child.isExpanded)
484
+ continue;
485
+ if (spineSet.has(child.path) || isUnderTarget(child.path)) {
486
+ trim(child);
487
+ }
488
+ else if (self.getNodeIsCollapsible(child)) {
489
+ child.isExpanded = false;
490
+ trim(child);
491
+ }
492
+ }
493
+ };
494
+ trim(root);
495
+ }
496
+ // Expand spine + entire subtree under each target.
497
+ for (const p of paths) {
498
+ let node = root;
499
+ const segments = p.split(this.treePathSeparator);
500
+ for (let i = 0; i < segments.length; i++) {
501
+ const segment = segmentPrefix + segments[i];
502
+ if (node && node.children.hasOwnProperty(segment)) {
503
+ node = node.children[segment];
504
+ node.isExpanded = true;
505
+ }
506
+ else {
507
+ node = undefined;
508
+ break;
509
+ }
510
+ }
511
+ if (node)
512
+ setExpandedRecursive(node, true);
513
+ }
455
514
  }
456
- this._emitTreeChanged();
515
+ if (!noEmit)
516
+ this._emitTreeChanged();
457
517
  perfEnd(`[${_treeId}] expandAll`);
458
518
  },
459
- collapseAll(nodePath) {
519
+ collapseAll(nodePath, options) {
460
520
  perfStart(`[${_treeId}] collapseAll`);
461
521
  const self = this;
522
+ const noEmit = options?.noEmit ?? false;
462
523
  function collapseRecursive(node) {
463
524
  if (node.isExpanded && self.getNodeIsCollapsible(node)) {
464
525
  node.isExpanded = false;
@@ -467,15 +528,23 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
467
528
  collapseRecursive(node.children[key]);
468
529
  }
469
530
  }
470
- if (isEmptyString(nodePath)) {
531
+ const paths = Array.isArray(nodePath)
532
+ ? nodePath.filter((p) => !isEmptyString(p))
533
+ : isEmptyString(nodePath)
534
+ ? []
535
+ : [nodePath];
536
+ if (paths.length === 0) {
471
537
  collapseRecursive(root);
472
538
  }
473
539
  else {
474
- const target = this.getNodeByPath(nodePath);
475
- if (target)
476
- collapseRecursive(target);
540
+ for (const p of paths) {
541
+ const target = this.getNodeByPath(p);
542
+ if (target)
543
+ collapseRecursive(target);
544
+ }
477
545
  }
478
- this._emitTreeChanged();
546
+ if (!noEmit)
547
+ this._emitTreeChanged();
479
548
  perfEnd(`[${_treeId}] collapseAll`);
480
549
  },
481
550
  insert: function (path, data, noEmitChanges = false) {
@@ -494,49 +563,91 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
494
563
  this._emitTreeChanged();
495
564
  }
496
565
  },
497
- expandNodes: function (path, noEmitChanges = false) {
566
+ expandNodes: function (path, options) {
498
567
  perfStart(`[${_treeId}] expandNodes`);
499
- let node = this.isFiltered ? filteredRoot : root;
568
+ const self = this;
569
+ const exclusive = options?.exclusive ?? false;
570
+ const noEmit = options?.noEmit ?? false;
571
+ const rootNode = this.isFiltered ? filteredRoot : root;
572
+ const paths = Array.isArray(path) ? path : [path];
500
573
  let hasChanges = false;
501
- const segments = path.split(this.treePathSeparator);
502
- for (let i = 0; i < segments.length; i++) {
503
- const segment = segmentPrefix + segments[i];
504
- if (node.children.hasOwnProperty(segment)) {
505
- node = node.children[segment];
506
- // Only mark as changed if actually changing from collapsed to expanded
507
- if (!node.isExpanded) {
508
- node.isExpanded = true;
509
- hasChanges = true;
574
+ if (exclusive) {
575
+ // Build spine set: union of every ancestor path across all inputs.
576
+ const spineSet = new Set();
577
+ for (const p of paths) {
578
+ const segs = p.split(this.treePathSeparator);
579
+ for (let i = 0; i < segs.length; i++) {
580
+ spineSet.add(segs.slice(0, i + 1).join(this.treePathSeparator));
581
+ }
582
+ }
583
+ // Walk currently-expanded subtree, collapse anything off-spine.
584
+ const trim = (node) => {
585
+ for (const key in node.children) {
586
+ const child = node.children[key];
587
+ if (!child.isExpanded)
588
+ continue;
589
+ if (spineSet.has(child.path)) {
590
+ trim(child);
591
+ }
592
+ else if (self.getNodeIsCollapsible(child)) {
593
+ child.isExpanded = false;
594
+ hasChanges = true;
595
+ trim(child);
596
+ }
597
+ }
598
+ };
599
+ trim(rootNode);
600
+ }
601
+ for (const p of paths) {
602
+ let node = rootNode;
603
+ const segments = p.split(this.treePathSeparator);
604
+ for (let i = 0; i < segments.length; i++) {
605
+ const segment = segmentPrefix + segments[i];
606
+ if (node && node.children.hasOwnProperty(segment)) {
607
+ node = node.children[segment];
608
+ if (!node.isExpanded) {
609
+ node.isExpanded = true;
610
+ hasChanges = true;
611
+ }
612
+ }
613
+ else {
614
+ break;
510
615
  }
511
616
  }
512
617
  }
513
- // Only emit changes if something actually changed
514
- if (!noEmitChanges && hasChanges) {
618
+ if (!noEmit && hasChanges) {
515
619
  this._emitTreeChanged();
516
620
  }
517
621
  perfEnd(`[${_treeId}] expandNodes`);
518
- return this; // Return the API object for chaining
622
+ return this;
519
623
  },
520
- collapseNodes: function (path, noEmitChanges = false) {
521
- let node = this.isFiltered ? filteredRoot : this.root;
624
+ collapseNodes: function (path, options) {
625
+ const noEmit = options?.noEmit ?? false;
626
+ const rootNode = this.isFiltered ? filteredRoot : this.root;
627
+ const paths = Array.isArray(path) ? path : [path];
522
628
  let hasChanges = false;
523
- const segments = path.split(this.treePathSeparator);
524
- for (let i = 0; i < segments.length; i++) {
525
- const segment = segmentPrefix + segments[i];
526
- if (node.children.hasOwnProperty(segment)) {
527
- node = node.children[segment];
629
+ for (const p of paths) {
630
+ let node = rootNode;
631
+ const segments = p.split(this.treePathSeparator);
632
+ for (let i = 0; i < segments.length; i++) {
633
+ const segment = segmentPrefix + segments[i];
634
+ if (node && node.children.hasOwnProperty(segment)) {
635
+ node = node.children[segment];
636
+ }
637
+ else {
638
+ node = undefined;
639
+ break;
640
+ }
641
+ }
642
+ if (node && node.isExpanded) {
643
+ node.isExpanded = false;
644
+ hasChanges = true;
528
645
  }
529
646
  }
530
- // Only collapse the target node, not ancestors
531
- if (node.isExpanded) {
532
- node.isExpanded = false;
533
- hasChanges = true;
534
- }
535
- // Only emit changes if something actually changed
536
- if (!noEmitChanges && hasChanges) {
647
+ if (!noEmit && hasChanges) {
537
648
  this._emitTreeChanged();
538
649
  }
539
- return this; // Return the API object for chaining
650
+ return this;
540
651
  },
541
652
  // Private helper methods
542
653
  getNodeByPath: function (path, _root) {
@@ -117,12 +117,22 @@ export interface Ltree<T> {
117
117
  searchNodes(_searchText: string | null | undefined, _searchOptions?: SearchOptions): LTreeNode<T>[];
118
118
  createFilteredTree(targetPaths: string[]): void;
119
119
  clearFilter(): void;
120
- expandAll(nodePath?: string | null | undefined): void;
121
- collapseAll(nodePath?: string | null | undefined): void;
120
+ expandAll(nodePath?: string | string[] | null | undefined, options?: {
121
+ exclusive?: boolean;
122
+ noEmit?: boolean;
123
+ }): void;
124
+ collapseAll(nodePath?: string | string[] | null | undefined, options?: {
125
+ noEmit?: boolean;
126
+ }): void;
122
127
  insert(path: string, data: T, noEmitChanges?: boolean): void;
123
128
  getNodeByPath(path: string, _root?: LTreeNode<T> | null | undefined): LTreeNode<T> | null;
124
- expandNodes(path: string): Ltree<T>;
125
- collapseNodes(path: string): Ltree<T>;
129
+ expandNodes(path: string | string[], options?: {
130
+ exclusive?: boolean;
131
+ noEmit?: boolean;
132
+ }): Ltree<T>;
133
+ collapseNodes(path: string | string[], options?: {
134
+ noEmit?: boolean;
135
+ }): Ltree<T>;
126
136
  getNodeDisplayValue(node: LTreeNode<T>): string;
127
137
  getNodeSearchValue(node: LTreeNode<T>): string;
128
138
  _defaultSort(self: Ltree<T>, items: LTreeNode<T>[]): LTreeNode<T>[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keenmate/svelte-treeview",
3
- "version": "5.0.0-rc07",
3
+ "version": "5.0.0-rc08",
4
4
  "scripts": {
5
5
  "dev": "vite dev --port 17777",
6
6
  "build": "vite build && npm run prepack",