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

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.
@@ -20,6 +20,7 @@ export class TreeController {
20
20
  clickBehavior: 'expand-and-focus',
21
21
  showCheckboxes: false,
22
22
  checkboxMode: 'independent',
23
+ clickTogglesCheckbox: false,
23
24
  expandIconClass: 'ltree-icon-expand',
24
25
  collapseIconClass: 'ltree-icon-collapse',
25
26
  leafIconClass: 'ltree-icon-leaf',
@@ -41,7 +42,10 @@ export class TreeController {
41
42
  data = $state.raw([]);
42
43
  focusedNode = $state.raw(null);
43
44
  highlightedPaths = $state.raw(new Set());
44
- lastHighlightedPath = null;
45
+ /** Hidden internal cursor used by Shift+Arrow / Shift+click to extend ranges
46
+ * from the focused node. Set on first Shift action, advances on subsequent
47
+ * Shift actions, cleared on any plain navigation. Not exposed via props. */
48
+ _shiftCursor = null;
45
49
  selectedPaths = $state.raw(new Set());
46
50
  insertResult = $state.raw(null);
47
51
  searchText = $state(undefined);
@@ -84,8 +88,10 @@ export class TreeController {
84
88
  getContextMenuItemsHandler;
85
89
  // Visual config (for nodeConfig updates)
86
90
  clickBehavior = $state('expand-and-focus');
91
+ selectionMode = $state('single');
87
92
  showCheckboxes = $state(false);
88
93
  checkboxMode = $state('independent');
94
+ clickTogglesCheckbox = $state(false);
89
95
  expandIconClass = $state('ltree-icon-expand');
90
96
  collapseIconClass = $state('ltree-icon-collapse');
91
97
  leafIconClass = $state('ltree-icon-leaf');
@@ -207,8 +213,10 @@ export class TreeController {
207
213
  this.autoHandlePaste = props.autoHandlePaste ?? true;
208
214
  this.accordionExpand = props.accordionExpand ?? false;
209
215
  this.clickBehavior = props.clickBehavior ?? 'expand-and-focus';
216
+ this.selectionMode = props.selectionMode ?? 'single';
210
217
  this.showCheckboxes = props.showCheckboxes ?? false;
211
218
  this.checkboxMode = props.checkboxMode ?? 'independent';
219
+ this.clickTogglesCheckbox = props.clickTogglesCheckbox ?? false;
212
220
  this.beforeCheckboxToggleHandler = props.beforeCheckboxToggleCallback;
213
221
  this.expandIconClass = props.expandIconClass ?? 'ltree-icon-expand';
214
222
  this.collapseIconClass = props.collapseIconClass ?? 'ltree-icon-collapse';
@@ -249,7 +257,7 @@ export class TreeController {
249
257
  this.onRenderCompleteHandler = props.onRenderComplete;
250
258
  // ── Create LTree ────────────────────────────────────────────────
251
259
  // svelte-ignore non_reactive_update
252
- this.tree = createLTree(props.idMember, props.pathMember, props.parentPathMember, props.levelMember, props.hasChildrenMember, props.isExpandedMember, props.isSelectableMember, props.isSelectedMember, props.isDraggableMember, props.getIsDraggableCallback, props.isDropAllowedMember, props.allowedDropPositionsMember, props.displayValueMember, props.getDisplayValueCallback, props.searchValueMember, props.getSearchValueCallback, props.getAllowedDropPositionsCallback, props.isCollapsibleMember, props.getIsCollapsibleCallback, props.orderMember, this.treeId, this.treePathSeparator, props.expandLevel, props.shouldUseInternalSearchIndex, props.initializeIndexCallback, props.indexerBatchSize, props.indexerTimeout, {
260
+ this.tree = createLTree(props.idMember, props.pathMember, props.parentPathMember, props.levelMember, props.hasChildrenMember, props.isExpandedMember, props.getIsExpandedCallback, props.isSelectableMember, props.getIsSelectableCallback, props.isSelectedMember, props.getIsSelectedCallback, props.isDraggableMember, props.getIsDraggableCallback, props.isDropAllowedMember, props.allowedDropPositionsMember, props.displayValueMember, props.getDisplayValueCallback, props.searchValueMember, props.getSearchValueCallback, props.getAllowedDropPositionsCallback, props.isCollapsibleMember, props.getIsCollapsibleCallback, props.orderMember, this.treeId, this.treePathSeparator, props.expandLevel, props.shouldUseInternalSearchIndex, props.initializeIndexCallback, props.indexerBatchSize, props.indexerTimeout, {
253
261
  shouldDisplayDebugInformation: props.shouldDisplayDebugInformation,
254
262
  isSorted: props.isSorted,
255
263
  sortCallback: props.sortCallback
@@ -273,7 +281,7 @@ export class TreeController {
273
281
  // ── Create stable nodeCallbacks ─────────────────────────────────
274
282
  this.nodeCallbacks = {
275
283
  onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers),
276
- onCheckboxToggle: (node) => this._onCheckboxToggle(node),
284
+ onCheckboxToggle: (node, options) => this._onCheckboxToggle(node, options),
277
285
  onNodeRightClicked: this._onNodeRightClicked.bind(this),
278
286
  onNodeDragStart: this._onNodeDragStart.bind(this),
279
287
  onNodeDragOver: this._onNodeDragOver.bind(this),
@@ -289,6 +297,7 @@ export class TreeController {
289
297
  clickBehavior: this.clickBehavior,
290
298
  showCheckboxes: this.showCheckboxes,
291
299
  checkboxMode: this.checkboxMode,
300
+ clickTogglesCheckbox: this.clickTogglesCheckbox,
292
301
  expandIconClass: this.expandIconClass,
293
302
  collapseIconClass: this.collapseIconClass,
294
303
  leafIconClass: this.leafIconClass,
@@ -318,6 +327,7 @@ export class TreeController {
318
327
  Object.assign(this.nodeConfig, {
319
328
  clickBehavior: this.clickBehavior,
320
329
  showCheckboxes: this.showCheckboxes,
330
+ clickTogglesCheckbox: this.clickTogglesCheckbox,
321
331
  expandIconClass: this.expandIconClass,
322
332
  collapseIconClass: this.collapseIconClass,
323
333
  leafIconClass: this.leafIconClass,
@@ -353,7 +363,7 @@ export class TreeController {
353
363
  this.vsDetectedHeight = null;
354
364
  this.insertResult = this.tree.insertArray(this.data);
355
365
  // Seed selectedPaths from node.isSelected flags written by insertArray
356
- if (this.tree.isSelectedMember) {
366
+ if (this.tree.isSelectedMember || this.tree.getIsSelectedCallback) {
357
367
  const seeded = new Set();
358
368
  const walk = (node) => {
359
369
  if (node.isSelected)
@@ -498,6 +508,26 @@ export class TreeController {
498
508
  this.isDebugMenuActive = false;
499
509
  }
500
510
  });
511
+ // Mirror hoveredNodeForDrop → dragOverNodeClass on a single DOM element.
512
+ // Direct classList mutation (same approach as the touch path's updateDropTarget) so
513
+ // the cost is O(1) per node-crossing — no per-Node prop propagation across the tree.
514
+ let prevHoveredDragPath = null;
515
+ let prevDragOverClass = null;
516
+ $effect(() => {
517
+ const current = this.hoveredNodeForDrop?.path ?? null;
518
+ const cls = this.dragOverNodeClass ?? null;
519
+ if (current === prevHoveredDragPath && cls === prevDragOverClass)
520
+ return;
521
+ const root = this.containerElement ?? document;
522
+ if (prevHoveredDragPath && prevDragOverClass) {
523
+ root.querySelector(`[data-tree-path="${prevHoveredDragPath}"] .ltree-node-content`)?.classList.remove(prevDragOverClass);
524
+ }
525
+ if (current && cls) {
526
+ root.querySelector(`[data-tree-path="${current}"] .ltree-node-content`)?.classList.add(cls);
527
+ }
528
+ prevHoveredDragPath = current;
529
+ prevDragOverClass = cls;
530
+ });
501
531
  }
502
532
  // ── Virtual scroll handler ──────────────────────────────────────────
503
533
  handleVirtualScroll = (event) => {
@@ -510,17 +540,17 @@ export class TreeController {
510
540
  });
511
541
  };
512
542
  // ── Public API methods ──────────────────────────────────────────────
513
- async expandNodes(nodePath) {
514
- this.tree.expandNodes(nodePath);
543
+ async expandNodes(nodePath, options) {
544
+ this.tree.expandNodes(nodePath, options);
515
545
  }
516
- async collapseNodes(nodePath) {
517
- this.tree.collapseNodes(nodePath);
546
+ async collapseNodes(nodePath, options) {
547
+ this.tree.collapseNodes(nodePath, options);
518
548
  }
519
- expandAll(nodePath) {
520
- this.tree?.expandAll(nodePath);
549
+ expandAll(nodePath, options) {
550
+ this.tree?.expandAll(nodePath, options);
521
551
  }
522
- collapseAll(nodePath) {
523
- this.tree?.collapseAll(nodePath);
552
+ collapseAll(nodePath, options) {
553
+ this.tree?.collapseAll(nodePath, options);
524
554
  }
525
555
  filterNodes(searchTextVal, searchOptions) {
526
556
  this.tree?.filterNodes(searchTextVal, searchOptions);
@@ -1256,10 +1286,14 @@ export class TreeController {
1256
1286
  this.virtualContainerHeight = updates.virtualContainerHeight;
1257
1287
  if (updates.clickBehavior !== undefined)
1258
1288
  this.clickBehavior = updates.clickBehavior ?? 'expand-and-focus';
1289
+ if (updates.selectionMode !== undefined)
1290
+ this.selectionMode = updates.selectionMode ?? 'single';
1259
1291
  if (updates.showCheckboxes !== undefined)
1260
1292
  this.showCheckboxes = updates.showCheckboxes ?? false;
1261
1293
  if (updates.checkboxMode !== undefined)
1262
1294
  this.checkboxMode = updates.checkboxMode ?? 'independent';
1295
+ if (updates.clickTogglesCheckbox !== undefined)
1296
+ this.clickTogglesCheckbox = updates.clickTogglesCheckbox ?? false;
1263
1297
  if (updates.beforeCheckboxToggleCallback !== undefined)
1264
1298
  this.beforeCheckboxToggleHandler = updates.beforeCheckboxToggleCallback;
1265
1299
  if (updates.expandIconClass !== undefined)
@@ -1327,14 +1361,22 @@ export class TreeController {
1327
1361
  this.onSelectionChangeHandler = updates.onSelectionChange;
1328
1362
  }
1329
1363
  // ── Internal event handlers ─────────────────────────────────────────
1330
- async _onNodeClicked(node, modifiers) {
1364
+ async _onNodeClicked(node, modifiers, options) {
1331
1365
  if (this.contextMenuVisible) {
1332
1366
  this.closeContextMenu();
1333
1367
  }
1334
- const ctrl = modifiers?.ctrl ?? false;
1335
- const shift = modifiers?.shift ?? false;
1336
- uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift, lastAnchor: this.lastHighlightedPath, prevCount: this.highlightedPaths.size });
1337
- if (ctrl) {
1368
+ // In single mode, mouse Ctrl/Shift+click degrade to plain click. Programmatic
1369
+ // callers (highlightNode with mode='toggle'/'range') pass forceMultiSemantics
1370
+ // to opt out of the gate the API contract should not depend on selectionMode.
1371
+ const isMulti = options?.forceMultiSemantics || this.selectionMode === 'multi';
1372
+ const ctrl = isMulti && (modifiers?.ctrl ?? false);
1373
+ const shift = isMulti && (modifiers?.shift ?? false);
1374
+ const silent = options?.silent ?? false;
1375
+ uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift, mode: this.selectionMode, shiftCursor: this._shiftCursor, prevCount: this.highlightedPaths.size });
1376
+ // !isSelectable blocks highlight (and therefore the mirror in no-checkbox mode).
1377
+ // Focus still moves so consumers can show detail panels for unselectable rows.
1378
+ const canHighlight = node.isSelectable;
1379
+ if (ctrl && canHighlight) {
1338
1380
  // Toggle this node in/out of highlight
1339
1381
  const newPaths = new Set([...this.highlightedPaths]);
1340
1382
  if (newPaths.has(node.path)) {
@@ -1347,42 +1389,82 @@ export class TreeController {
1347
1389
  }
1348
1390
  node._rev = (node._rev || 0) + 1;
1349
1391
  this.highlightedPaths = newPaths;
1350
- this.lastHighlightedPath = node.path;
1392
+ this._shiftCursor = node.path;
1351
1393
  }
1352
- else if (shift && this.lastHighlightedPath) {
1353
- // Range highlight from lastHighlightedPath to this node
1354
- const rangePaths = this._getNodesBetween(this.lastHighlightedPath, node.path);
1355
- // Clear previous highlights
1394
+ else if (shift && canHighlight && (this._shiftCursor || this.focusedNode)) {
1395
+ // Range highlight from the shift cursor (or focused node if no cursor yet) to this node
1396
+ const anchor = this._shiftCursor ?? this.focusedNode.path;
1397
+ const rangePaths = this._getNodesBetween(anchor, node.path);
1356
1398
  this._clearAllHighlightFlags();
1357
1399
  const newPaths = new Set();
1358
1400
  for (const path of rangePaths) {
1359
- newPaths.add(path);
1360
1401
  const n = this.tree.getNodeByPath(path);
1361
- if (n) {
1362
- n.isHighlighted = true;
1363
- n._rev = (n._rev || 0) + 1;
1364
- }
1402
+ if (!n || !n.isSelectable)
1403
+ continue;
1404
+ newPaths.add(path);
1405
+ n.isHighlighted = true;
1406
+ n._rev = (n._rev || 0) + 1;
1365
1407
  }
1366
1408
  this.highlightedPaths = newPaths;
1367
- // Don't update lastHighlightedPath on shift+click (anchor stays)
1409
+ // Anchor stays put on shift+click _shiftCursor unchanged
1368
1410
  }
1369
- else {
1370
- // Normal click: clear all highlights, highlight only this node
1411
+ else if (canHighlight) {
1412
+ // Plain click (or Ctrl/Shift+click in single mode treated as plain):
1413
+ // clear all highlights, highlight only this node.
1371
1414
  this._clearAllHighlightFlags();
1372
1415
  node.isHighlighted = true;
1373
1416
  node._rev = (node._rev || 0) + 1;
1374
- const newPaths = new Set();
1375
- newPaths.add(node.path);
1376
- this.highlightedPaths = newPaths;
1377
- this.lastHighlightedPath = node.path;
1417
+ this.highlightedPaths = new Set([node.path]);
1418
+ this._shiftCursor = node.path;
1378
1419
  }
1379
- // Update focus
1420
+ else {
1421
+ // Not selectable: clear any prior highlights but don't highlight this row.
1422
+ if (this.highlightedPaths.size > 0) {
1423
+ this._clearAllHighlightFlags();
1424
+ this.highlightedPaths = new Set();
1425
+ }
1426
+ this._shiftCursor = null;
1427
+ }
1428
+ // Update focus (always — focus is independent of selectability)
1380
1429
  this._setFocusedNode(node);
1381
- this.onNodeClickHandler?.(node);
1382
- this._notifyHighlightChanged();
1430
+ if (!silent) {
1431
+ this.onNodeClickHandler?.(node);
1432
+ this._notifyHighlightChanged();
1433
+ this._mirrorHighlightToSelected();
1434
+ }
1383
1435
  this.tree.refresh();
1384
- // Focus the tree container so keyboard navigation works after clicking a node
1385
- this.containerElement?.focus();
1436
+ // Focus the tree container so keyboard navigation works after clicking a node.
1437
+ // Skip in silent mode — programmatic highlight (e.g. from URL params) shouldn't
1438
+ // steal focus from whatever the user is currently interacting with.
1439
+ if (!silent) {
1440
+ this.containerElement?.focus();
1441
+ }
1442
+ }
1443
+ /**
1444
+ * Top-level paths within `highlightedPaths` — paths whose nearest highlighted
1445
+ * ancestor is NOT in the highlighted set. Used by multi-drag to figure out
1446
+ * which subtrees actually need to move (descendants ride along inside).
1447
+ */
1448
+ _getTopLevelHighlightedPaths() {
1449
+ const paths = this.highlightedPaths;
1450
+ if (paths.size === 0)
1451
+ return [];
1452
+ const sep = this.treePathSeparator;
1453
+ const result = [];
1454
+ for (const p of paths) {
1455
+ let cursor = p;
1456
+ let absorbed = false;
1457
+ while (cursor.includes(sep)) {
1458
+ cursor = cursor.substring(0, cursor.lastIndexOf(sep));
1459
+ if (paths.has(cursor)) {
1460
+ absorbed = true;
1461
+ break;
1462
+ }
1463
+ }
1464
+ if (!absorbed)
1465
+ result.push(p);
1466
+ }
1467
+ return result;
1386
1468
  }
1387
1469
  /** Get all descendant paths of a node (depth-first) */
1388
1470
  _getDescendantPaths(node) {
@@ -1397,7 +1479,7 @@ export class TreeController {
1397
1479
  return result;
1398
1480
  }
1399
1481
  /** Handle checkbox toggle with cascade and interceptor support */
1400
- _onCheckboxToggle(node) {
1482
+ _onCheckboxToggle(node, options) {
1401
1483
  // In cascade mode, indeterminate → check all (not fully selected yet)
1402
1484
  const newChecked = this.checkboxMode === 'cascade' && node.visualState === VisualState.indeterminate
1403
1485
  ? true
@@ -1452,27 +1534,30 @@ export class TreeController {
1452
1534
  n._rev = (n._rev || 0) + 1;
1453
1535
  }
1454
1536
  this.selectedPaths = newPaths;
1455
- this._setFocusedNode(node);
1456
- // Update visual states for toggled nodes and their ancestors
1457
- // Collect unique root paths to update (the top-level nodes that were directly toggled)
1458
- const rootPaths = isMultiHighlighted ? [...this.highlightedPaths] : [node.path];
1459
- for (const rp of rootPaths) {
1460
- const rn = this.tree.getNodeByPath(rp);
1461
- if (!rn)
1462
- continue;
1463
- if (this.checkboxMode === 'cascade') {
1537
+ if (!options?.skipFocus)
1538
+ this._setFocusedNode(node);
1539
+ // Update visual states for toggled nodes and their ancestors.
1540
+ // Only in cascade mode in independent mode, checkboxes are standalone and
1541
+ // parents must not be auto-checked just because their descendants are.
1542
+ if (this.checkboxMode === 'cascade') {
1543
+ const rootPaths = isMultiHighlighted ? [...this.highlightedPaths] : [node.path];
1544
+ for (const rp of rootPaths) {
1545
+ const rn = this.tree.getNodeByPath(rp);
1546
+ if (!rn)
1547
+ continue;
1464
1548
  const vs = this._computeVisualState(rn);
1465
1549
  if (rn.visualState !== vs) {
1466
1550
  rn.visualState = vs;
1467
1551
  rn._rev = (rn._rev || 0) + 1;
1468
1552
  }
1553
+ this._updateAncestorVisualStates(rp);
1469
1554
  }
1470
- this._updateAncestorVisualStates(rp);
1471
1555
  }
1472
1556
  this.onNodeClickHandler?.(node);
1473
1557
  this._notifySelectionChanged();
1474
1558
  this.tree.refresh();
1475
- this.containerElement?.focus();
1559
+ if (!options?.skipFocus)
1560
+ this.containerElement?.focus();
1476
1561
  }
1477
1562
  /** Walk up from a node path and set visualState on each ancestor based on descendant selection */
1478
1563
  _updateAncestorVisualStates(startPath) {
@@ -1534,15 +1619,60 @@ export class TreeController {
1534
1619
  }
1535
1620
  /** Set focused node, clearing previous focus flag */
1536
1621
  _setFocusedNode(node) {
1537
- if (this.focusedNode && this.focusedNode.path !== node?.path) {
1538
- this.focusedNode.isFocused = false;
1539
- this.focusedNode._rev = (this.focusedNode._rev || 0) + 1;
1622
+ // IMPORTANT: bidirectional bind on `focusedNode` can route the value through
1623
+ // the parent's `$state`, which deep-clones the node into a reactive proxy.
1624
+ // That clone shares the path but is a different object from the tree's
1625
+ // canonical node. We MUST mutate the tree's actual node — otherwise
1626
+ // `node.isFocused = false` writes to the clone and the rendered row
1627
+ // (which reads the canonical node's flag) never updates. Same applies to
1628
+ // the incoming `node`: prefer the tree's canonical ref.
1629
+ const prevPath = this.focusedNode?.path;
1630
+ if (prevPath && prevPath !== node?.path) {
1631
+ const prev = this.tree.getNodeByPath(prevPath);
1632
+ if (prev) {
1633
+ prev.isFocused = false;
1634
+ prev._rev = (prev._rev || 0) + 1;
1635
+ }
1636
+ }
1637
+ const canonical = node ? (this.tree.getNodeByPath(node.path) ?? node) : null;
1638
+ if (canonical) {
1639
+ canonical.isFocused = true;
1640
+ canonical._rev = (canonical._rev || 0) + 1;
1641
+ }
1642
+ this.focusedNode = canonical;
1643
+ }
1644
+ /**
1645
+ * Mirror highlightedPaths → selectedPaths when checkboxes are off.
1646
+ * Decision 1 + 10 from selection-highlight-model.md: in no-checkbox mode the
1647
+ * highlight set IS the form selection, so writes to highlightedPaths cascade
1648
+ * to selectedPaths and fire onSelectionChange alongside onHighlightChange.
1649
+ */
1650
+ _mirrorHighlightToSelected() {
1651
+ if (this.showCheckboxes)
1652
+ return;
1653
+ // Take a snapshot to avoid identity-loop on parent rebinding
1654
+ const next = new Set(this.highlightedPaths);
1655
+ // Sync the per-node isSelected flag with the mirrored set.
1656
+ // First clear isSelected on anything currently in selectedPaths but not in next.
1657
+ for (const path of this.selectedPaths) {
1658
+ if (!next.has(path)) {
1659
+ const n = this.tree.getNodeByPath(path);
1660
+ if (n) {
1661
+ n.isSelected = false;
1662
+ n._rev = (n._rev || 0) + 1;
1663
+ }
1664
+ }
1540
1665
  }
1541
- if (node) {
1542
- node.isFocused = true;
1543
- node._rev = (node._rev || 0) + 1;
1666
+ // Then set isSelected on the new set.
1667
+ for (const path of next) {
1668
+ const n = this.tree.getNodeByPath(path);
1669
+ if (n && !n.isSelected) {
1670
+ n.isSelected = true;
1671
+ n._rev = (n._rev || 0) + 1;
1672
+ }
1544
1673
  }
1545
- this.focusedNode = node;
1674
+ this.selectedPaths = next;
1675
+ this._notifySelectionChanged();
1546
1676
  }
1547
1677
  /** Clear isHighlighted flag on all currently highlighted nodes */
1548
1678
  _clearAllHighlightFlags() {
@@ -1648,29 +1778,32 @@ export class TreeController {
1648
1778
  return result;
1649
1779
  }
1650
1780
  // ── Public highlight methods (UI selection) ────────────────────────
1651
- /** Highlight a node with the given mode */
1652
- highlightNode(path, mode = 'replace') {
1781
+ /** Highlight a node with the given mode.
1782
+ * Pass `{ silent: true }` to update state without firing `onNodeClick` / `onHighlightChange`
1783
+ * (useful when restoring state from URL params or other external sources). */
1784
+ highlightNode(path, mode = 'replace', options) {
1653
1785
  const node = this.tree.getNodeByPath(path);
1654
1786
  if (!node)
1655
1787
  return;
1656
1788
  if (mode === 'toggle') {
1657
- this._onNodeClicked(node, { ctrl: true, shift: false });
1789
+ this._onNodeClicked(node, { ctrl: true, shift: false }, { ...options, forceMultiSemantics: true });
1658
1790
  }
1659
1791
  else if (mode === 'range') {
1660
- this._onNodeClicked(node, { ctrl: false, shift: true });
1792
+ this._onNodeClicked(node, { ctrl: false, shift: true }, { ...options, forceMultiSemantics: true });
1661
1793
  }
1662
1794
  else {
1663
- this._onNodeClicked(node);
1795
+ this._onNodeClicked(node, undefined, options);
1664
1796
  }
1665
1797
  }
1666
- /** Highlight multiple nodes by paths (replaces current highlights) */
1667
- highlightNodes(paths) {
1798
+ /** Highlight multiple nodes by paths (replaces current highlights).
1799
+ * Pass `{ silent: true }` to skip `onHighlightChange`. */
1800
+ highlightNodes(paths, options) {
1668
1801
  this._clearAllHighlightFlags();
1669
1802
  const newPaths = new Set();
1670
1803
  let lastNode = null;
1671
1804
  for (const path of paths) {
1672
1805
  const node = this.tree.getNodeByPath(path);
1673
- if (node) {
1806
+ if (node && node.isSelectable) {
1674
1807
  node.isHighlighted = true;
1675
1808
  node._rev = (node._rev || 0) + 1;
1676
1809
  newPaths.add(path);
@@ -1680,17 +1813,23 @@ export class TreeController {
1680
1813
  this.highlightedPaths = newPaths;
1681
1814
  if (lastNode) {
1682
1815
  this._setFocusedNode(lastNode);
1683
- this.lastHighlightedPath = lastNode.path;
1816
+ this._shiftCursor = lastNode.path;
1817
+ }
1818
+ if (!options?.silent) {
1819
+ this._notifyHighlightChanged();
1820
+ this._mirrorHighlightToSelected();
1684
1821
  }
1685
- this._notifyHighlightChanged();
1686
1822
  this.tree.refresh();
1687
1823
  }
1688
- /** Clear all highlights */
1689
- clearHighlight() {
1824
+ /** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
1825
+ clearHighlight(options) {
1690
1826
  this._clearAllHighlightFlags();
1691
1827
  this.highlightedPaths = new Set();
1692
- this.lastHighlightedPath = null;
1693
- this._notifyHighlightChanged();
1828
+ this._shiftCursor = null;
1829
+ if (!options?.silent) {
1830
+ this._notifyHighlightChanged();
1831
+ this._mirrorHighlightToSelected();
1832
+ }
1694
1833
  this.tree.refresh();
1695
1834
  }
1696
1835
  /** Get all highlighted nodes */
@@ -1707,6 +1846,15 @@ export class TreeController {
1707
1846
  isNodeHighlighted(path) {
1708
1847
  return this.highlightedPaths.has(path);
1709
1848
  }
1849
+ /** Toggle the focused node in/out of the highlight set. Multi-mode only. */
1850
+ toggleFocusedHighlight() {
1851
+ if (this.selectionMode !== 'multi')
1852
+ return;
1853
+ const node = this.focusedNode;
1854
+ if (!node || !node.isSelectable)
1855
+ return;
1856
+ this._onNodeClicked(node, { ctrl: true, shift: false }, { forceMultiSemantics: true });
1857
+ }
1710
1858
  // ── Public selection methods (checkbox data state) ───────────────
1711
1859
  /** Get all selected (checked) nodes */
1712
1860
  getSelectedNodes() {
@@ -1722,36 +1870,30 @@ export class TreeController {
1722
1870
  isNodeSelected(path) {
1723
1871
  return this.selectedPaths.has(path);
1724
1872
  }
1725
- /** Clear all checkbox selections */
1726
- deselectAll() {
1873
+ /** Clear all checkbox selections. Pass `{ silent: true }` to skip `onSelectionChange`. */
1874
+ deselectAll(options) {
1727
1875
  this._clearAllSelectionFlags();
1728
1876
  this.selectedPaths = new Set();
1729
- this._notifySelectionChanged();
1877
+ if (!options?.silent)
1878
+ this._notifySelectionChanged();
1730
1879
  this.tree.refresh();
1731
1880
  }
1732
1881
  /** @deprecated Use highlightNode() instead */
1733
- selectNode(path, mode = 'replace') {
1734
- this.highlightNode(path, mode);
1882
+ selectNode(path, mode = 'replace', options) {
1883
+ this.highlightNode(path, mode, options);
1735
1884
  }
1736
1885
  /** @deprecated Use highlightNodes() instead */
1737
- selectNodes(paths) {
1738
- this.highlightNodes(paths);
1886
+ selectNodes(paths, options) {
1887
+ this.highlightNodes(paths, options);
1739
1888
  }
1740
1889
  _onNodeRightClicked(node, event) {
1741
1890
  if (!this.hasContextMenuSnippet && !this.getContextMenuItemsHandler) {
1742
1891
  return;
1743
1892
  }
1744
- // If right-clicking on an unhighlighted node, clear highlights and highlight only this node
1745
- if (!this.highlightedPaths.has(node.path)) {
1746
- this._clearAllHighlightFlags();
1747
- node.isHighlighted = true;
1748
- node._rev = (node._rev || 0) + 1;
1749
- this.highlightedPaths = new Set([node.path]);
1750
- this._setFocusedNode(node);
1751
- this.lastHighlightedPath = node.path;
1752
- this._notifyHighlightChanged();
1753
- this.tree.refresh();
1754
- }
1893
+ // Decision 12: right-click does NOT move focus or highlight. It only opens
1894
+ // the context menu at the right-clicked node. If the consumer needs the
1895
+ // menu to act on something other than the highlight set, they can read
1896
+ // the node passed to their getContextMenuItemsCallback.
1755
1897
  uiLogger.debug(`Context menu opened: ${node.path}`);
1756
1898
  event.preventDefault();
1757
1899
  this.openContextMenu(node, event.clientX, event.clientY);
@@ -1786,6 +1928,31 @@ export class TreeController {
1786
1928
  this.draggedNode = node;
1787
1929
  this.isDragInProgress = true;
1788
1930
  this.onNodeDragStartHandler?.(node, event);
1931
+ // OS-convention selection sync: if the user grabs a node that isn't part
1932
+ // of the current highlight set, replace the highlight with just that node.
1933
+ // Mirrors Windows Explorer / macOS Finder where mousedown on an unselected
1934
+ // item selects it. Without this, the prior highlight stayed visible while
1935
+ // the drag silently carried only the single grabbed node — confusing the
1936
+ // user about what's moving. Deferred to rAF (not microtask): microtasks
1937
+ // drain before the browser commits the drag image, so mutating the source
1938
+ // row's DOM there causes `tree.refresh()` to re-create the dragged element
1939
+ // and the browser silently aborts the drag (no dragend fires). rAF runs as
1940
+ // part of the rendering steps, after the drag is committed. Drop handlers
1941
+ // fire well after this rAF, so they read the updated `highlightedPaths`.
1942
+ // Skipped when the node is already in the set (multi-drag) or not
1943
+ // selectable (preserves prior highlight state for unselectable rows).
1944
+ if (node.isSelectable && !this.highlightedPaths.has(node.path)) {
1945
+ requestAnimationFrame(() => {
1946
+ this._clearAllHighlightFlags();
1947
+ node.isHighlighted = true;
1948
+ node._rev = (node._rev || 0) + 1;
1949
+ this.highlightedPaths = new Set([node.path]);
1950
+ this._shiftCursor = node.path;
1951
+ this._notifyHighlightChanged();
1952
+ this._mirrorHighlightToSelected();
1953
+ this.tree.refresh();
1954
+ });
1955
+ }
1789
1956
  }
1790
1957
  _onNodeDragEnd = (event) => {
1791
1958
  dragLogger.debug('Drag ended', {
@@ -1829,6 +1996,50 @@ export class TreeController {
1829
1996
  }
1830
1997
  }
1831
1998
  const isSameTreeDrag = draggedNodeRef.treeId === this.treeId;
1999
+ // Multi-drag (Decision 6 in selection-highlight-model.md):
2000
+ // When the dragged node is part of a multi-highlight, move the whole highlight
2001
+ // set as top-level-selected subtrees. Descendants whose nearest highlighted
2002
+ // ancestor is in the set are absorbed (ride along inside the subtree).
2003
+ const isMultiDrag = isSameTreeDrag &&
2004
+ operation === 'move' &&
2005
+ dropNode &&
2006
+ this.autoHandleMove &&
2007
+ this.highlightedPaths.has(draggedNodeRef.path) &&
2008
+ this.highlightedPaths.size > 1;
2009
+ if (isMultiDrag) {
2010
+ const topLevelPaths = this._getTopLevelHighlightedPaths()
2011
+ // drop target can't be moved onto itself
2012
+ .filter((p) => p !== dropNode.path);
2013
+ dragLogger.info(`Multi-drag: moving ${topLevelPaths.length} top-level subtree(s)`, {
2014
+ topLevelPaths,
2015
+ totalHighlighted: this.highlightedPaths.size,
2016
+ dropTarget: dropNode.path,
2017
+ position
2018
+ });
2019
+ let allOk = true;
2020
+ // First top-level node uses the requested position relative to dropNode.
2021
+ // Subsequent ones chain 'after' the previously moved node so the whole
2022
+ // set lands as siblings in source order: dropping A,B,C 'after D' yields
2023
+ // [D, A, B, C]; 'before D' yields [A, B, C, D]; 'child of D' yields D's
2024
+ // children = [A, B, C]. moveNode mutates the source LTreeNode in place,
2025
+ // so reading the held reference's .path post-move gives the new path.
2026
+ let prevMovedNode = null;
2027
+ for (let i = 0; i < topLevelPaths.length; i++) {
2028
+ const sourcePath = topLevelPaths[i];
2029
+ const targetPath = i === 0 ? dropNode.path : prevMovedNode.path;
2030
+ const pos = i === 0 ? position : 'after';
2031
+ const sourceNode = this.tree.getNodeByPath(sourcePath);
2032
+ const r = this.moveNode(sourcePath, targetPath, pos);
2033
+ if (!r.success) {
2034
+ allOk = false;
2035
+ }
2036
+ else if (sourceNode) {
2037
+ prevMovedNode = sourceNode;
2038
+ }
2039
+ }
2040
+ this.onNodeDropHandler?.(dropNode, draggedNodeRef, position, event, operation);
2041
+ return allOk;
2042
+ }
1832
2043
  if (isSameTreeDrag && operation === 'move' && dropNode) {
1833
2044
  if (this.autoHandleMove) {
1834
2045
  const result = this.moveNode(draggedNodeRef.path, dropNode.path, position);
@@ -2510,14 +2721,17 @@ export class TreeController {
2510
2721
  }
2511
2722
  };
2512
2723
  }
2513
- /** Extend highlight to target path (Shift+nav) — uses range from anchor, moves focus */
2724
+ /** Extend highlight to target path (Shift+nav) — uses range from the shift cursor
2725
+ * (or current focus if no cursor yet), moves focus. No-op in single mode. */
2514
2726
  _navHighlightTo(path) {
2515
- // Set anchor if not set
2516
- if (!this.lastHighlightedPath && this.focusedNode) {
2517
- this.lastHighlightedPath = this.focusedNode.path;
2518
- // Ensure anchor is highlighted
2727
+ if (this.selectionMode !== 'multi')
2728
+ return;
2729
+ // Seed the shift cursor from the focused node on the first Shift+Arrow
2730
+ if (!this._shiftCursor && this.focusedNode) {
2519
2731
  const anchorNode = this.focusedNode;
2520
- if (!anchorNode.isHighlighted) {
2732
+ this._shiftCursor = anchorNode.path;
2733
+ // Ensure anchor is highlighted so range computation has a starting point visible
2734
+ if (anchorNode.isSelectable && !anchorNode.isHighlighted) {
2521
2735
  anchorNode.isHighlighted = true;
2522
2736
  anchorNode._rev = (anchorNode._rev || 0) + 1;
2523
2737
  this.highlightedPaths = new Set([anchorNode.path]);
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { TreeController } from "./core/TreeController.svelte";
6
6
  export type { TreeControllerProps, PasteResult } from "./core/TreeController.svelte";
7
7
  export { createTreeController } from "./core/createTreeController.js";
8
8
  export type { LTreeNode, NodeId, VisualState } from "./ltree/ltree-node.svelte";
9
- export type { Ltree, DropPosition, DragDropMode, DropOperation, ToggleIconMode, ClickBehavior, CheckboxMode, ContextMenuItem, ContextMenuDivider, ContextMenuEntry, InsertArrayResult, InsertBranchResult, DeleteBranchResult, TreeChange, ApplyChangesResult } from "./ltree/types.js";
9
+ export type { Ltree, DropPosition, DragDropMode, DropOperation, ToggleIconMode, ClickBehavior, CheckboxMode, SelectionMode, ContextMenuItem, ContextMenuDivider, ContextMenuEntry, InsertArrayResult, InsertBranchResult, DeleteBranchResult, TreeChange, ApplyChangesResult } from "./ltree/types.js";
10
10
  export type { ClipboardEntry, TreeClipboard } from "./core/clipboard.js";
11
11
  export { setClipboard, getClipboard, clearClipboard, hasClipboard, getClipboardOperation } from "./core/clipboard.js";
12
12
  export type { TreeNavigation, TreeNavigationOverrides } from "./core/navigation.js";