@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 +10 -0
- package/README.md +7 -26
- package/dist/components/Tree.svelte +38 -14
- package/dist/components/Tree.svelte.d.ts +64 -14
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/core/TreeController.svelte.d.ts +39 -14
- package/dist/core/TreeController.svelte.js +44 -31
- package/dist/ltree/ltree.svelte.js +152 -41
- package/dist/ltree/types.d.ts +14 -4
- package/package.json +1 -1
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(
|
|
511
|
-
|
|
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(
|
|
515
|
-
|
|
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(
|
|
519
|
-
|
|
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(
|
|
523
|
-
|
|
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
|
|
621
|
-
controller.
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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"
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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"
|
|
377
|
-
|
|
378
|
-
|
|
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,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-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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(
|
|
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'
|
|
482
|
+
selectNode(path: string, mode?: 'replace' | 'toggle' | 'range', options?: {
|
|
483
|
+
silent?: boolean;
|
|
484
|
+
}): void;
|
|
462
485
|
/** @deprecated Use highlightNodes() instead */
|
|
463
|
-
selectNodes(paths: string[]
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
475
|
-
|
|
476
|
-
|
|
540
|
+
for (const p of paths) {
|
|
541
|
+
const target = this.getNodeByPath(p);
|
|
542
|
+
if (target)
|
|
543
|
+
collapseRecursive(target);
|
|
544
|
+
}
|
|
477
545
|
}
|
|
478
|
-
|
|
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,
|
|
566
|
+
expandNodes: function (path, options) {
|
|
498
567
|
perfStart(`[${_treeId}] expandNodes`);
|
|
499
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
514
|
-
if (!noEmitChanges && hasChanges) {
|
|
618
|
+
if (!noEmit && hasChanges) {
|
|
515
619
|
this._emitTreeChanged();
|
|
516
620
|
}
|
|
517
621
|
perfEnd(`[${_treeId}] expandNodes`);
|
|
518
|
-
return this;
|
|
622
|
+
return this;
|
|
519
623
|
},
|
|
520
|
-
collapseNodes: function (path,
|
|
521
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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;
|
|
650
|
+
return this;
|
|
540
651
|
},
|
|
541
652
|
// Private helper methods
|
|
542
653
|
getNodeByPath: function (path, _root) {
|
package/dist/ltree/types.d.ts
CHANGED
|
@@ -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
|
|
121
|
-
|
|
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
|
|
125
|
-
|
|
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>[];
|