@keenmate/svelte-treeview 5.0.0-rc08 → 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) => {
@@ -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)
@@ -1331,11 +1365,18 @@ export class TreeController {
1331
1365
  if (this.contextMenuVisible) {
1332
1366
  this.closeContextMenu();
1333
1367
  }
1334
- const ctrl = modifiers?.ctrl ?? false;
1335
- const shift = modifiers?.shift ?? false;
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);
1336
1374
  const silent = options?.silent ?? false;
1337
- uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift, lastAnchor: this.lastHighlightedPath, prevCount: this.highlightedPaths.size });
1338
- if (ctrl) {
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) {
1339
1380
  // Toggle this node in/out of highlight
1340
1381
  const newPaths = new Set([...this.highlightedPaths]);
1341
1382
  if (newPaths.has(node.path)) {
@@ -1348,40 +1389,48 @@ export class TreeController {
1348
1389
  }
1349
1390
  node._rev = (node._rev || 0) + 1;
1350
1391
  this.highlightedPaths = newPaths;
1351
- this.lastHighlightedPath = node.path;
1392
+ this._shiftCursor = node.path;
1352
1393
  }
1353
- else if (shift && this.lastHighlightedPath) {
1354
- // Range highlight from lastHighlightedPath to this node
1355
- const rangePaths = this._getNodesBetween(this.lastHighlightedPath, node.path);
1356
- // 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);
1357
1398
  this._clearAllHighlightFlags();
1358
1399
  const newPaths = new Set();
1359
1400
  for (const path of rangePaths) {
1360
- newPaths.add(path);
1361
1401
  const n = this.tree.getNodeByPath(path);
1362
- if (n) {
1363
- n.isHighlighted = true;
1364
- n._rev = (n._rev || 0) + 1;
1365
- }
1402
+ if (!n || !n.isSelectable)
1403
+ continue;
1404
+ newPaths.add(path);
1405
+ n.isHighlighted = true;
1406
+ n._rev = (n._rev || 0) + 1;
1366
1407
  }
1367
1408
  this.highlightedPaths = newPaths;
1368
- // Don't update lastHighlightedPath on shift+click (anchor stays)
1409
+ // Anchor stays put on shift+click _shiftCursor unchanged
1369
1410
  }
1370
- else {
1371
- // 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.
1372
1414
  this._clearAllHighlightFlags();
1373
1415
  node.isHighlighted = true;
1374
1416
  node._rev = (node._rev || 0) + 1;
1375
- const newPaths = new Set();
1376
- newPaths.add(node.path);
1377
- this.highlightedPaths = newPaths;
1378
- this.lastHighlightedPath = node.path;
1417
+ this.highlightedPaths = new Set([node.path]);
1418
+ this._shiftCursor = node.path;
1379
1419
  }
1380
- // 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)
1381
1429
  this._setFocusedNode(node);
1382
1430
  if (!silent) {
1383
1431
  this.onNodeClickHandler?.(node);
1384
1432
  this._notifyHighlightChanged();
1433
+ this._mirrorHighlightToSelected();
1385
1434
  }
1386
1435
  this.tree.refresh();
1387
1436
  // Focus the tree container so keyboard navigation works after clicking a node.
@@ -1391,6 +1440,32 @@ export class TreeController {
1391
1440
  this.containerElement?.focus();
1392
1441
  }
1393
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;
1468
+ }
1394
1469
  /** Get all descendant paths of a node (depth-first) */
1395
1470
  _getDescendantPaths(node) {
1396
1471
  const result = [];
@@ -1404,7 +1479,7 @@ export class TreeController {
1404
1479
  return result;
1405
1480
  }
1406
1481
  /** Handle checkbox toggle with cascade and interceptor support */
1407
- _onCheckboxToggle(node) {
1482
+ _onCheckboxToggle(node, options) {
1408
1483
  // In cascade mode, indeterminate → check all (not fully selected yet)
1409
1484
  const newChecked = this.checkboxMode === 'cascade' && node.visualState === VisualState.indeterminate
1410
1485
  ? true
@@ -1459,27 +1534,30 @@ export class TreeController {
1459
1534
  n._rev = (n._rev || 0) + 1;
1460
1535
  }
1461
1536
  this.selectedPaths = newPaths;
1462
- this._setFocusedNode(node);
1463
- // Update visual states for toggled nodes and their ancestors
1464
- // Collect unique root paths to update (the top-level nodes that were directly toggled)
1465
- const rootPaths = isMultiHighlighted ? [...this.highlightedPaths] : [node.path];
1466
- for (const rp of rootPaths) {
1467
- const rn = this.tree.getNodeByPath(rp);
1468
- if (!rn)
1469
- continue;
1470
- 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;
1471
1548
  const vs = this._computeVisualState(rn);
1472
1549
  if (rn.visualState !== vs) {
1473
1550
  rn.visualState = vs;
1474
1551
  rn._rev = (rn._rev || 0) + 1;
1475
1552
  }
1553
+ this._updateAncestorVisualStates(rp);
1476
1554
  }
1477
- this._updateAncestorVisualStates(rp);
1478
1555
  }
1479
1556
  this.onNodeClickHandler?.(node);
1480
1557
  this._notifySelectionChanged();
1481
1558
  this.tree.refresh();
1482
- this.containerElement?.focus();
1559
+ if (!options?.skipFocus)
1560
+ this.containerElement?.focus();
1483
1561
  }
1484
1562
  /** Walk up from a node path and set visualState on each ancestor based on descendant selection */
1485
1563
  _updateAncestorVisualStates(startPath) {
@@ -1541,15 +1619,60 @@ export class TreeController {
1541
1619
  }
1542
1620
  /** Set focused node, clearing previous focus flag */
1543
1621
  _setFocusedNode(node) {
1544
- if (this.focusedNode && this.focusedNode.path !== node?.path) {
1545
- this.focusedNode.isFocused = false;
1546
- 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
+ }
1547
1665
  }
1548
- if (node) {
1549
- node.isFocused = true;
1550
- 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
+ }
1551
1673
  }
1552
- this.focusedNode = node;
1674
+ this.selectedPaths = next;
1675
+ this._notifySelectionChanged();
1553
1676
  }
1554
1677
  /** Clear isHighlighted flag on all currently highlighted nodes */
1555
1678
  _clearAllHighlightFlags() {
@@ -1663,10 +1786,10 @@ export class TreeController {
1663
1786
  if (!node)
1664
1787
  return;
1665
1788
  if (mode === 'toggle') {
1666
- this._onNodeClicked(node, { ctrl: true, shift: false }, options);
1789
+ this._onNodeClicked(node, { ctrl: true, shift: false }, { ...options, forceMultiSemantics: true });
1667
1790
  }
1668
1791
  else if (mode === 'range') {
1669
- this._onNodeClicked(node, { ctrl: false, shift: true }, options);
1792
+ this._onNodeClicked(node, { ctrl: false, shift: true }, { ...options, forceMultiSemantics: true });
1670
1793
  }
1671
1794
  else {
1672
1795
  this._onNodeClicked(node, undefined, options);
@@ -1680,7 +1803,7 @@ export class TreeController {
1680
1803
  let lastNode = null;
1681
1804
  for (const path of paths) {
1682
1805
  const node = this.tree.getNodeByPath(path);
1683
- if (node) {
1806
+ if (node && node.isSelectable) {
1684
1807
  node.isHighlighted = true;
1685
1808
  node._rev = (node._rev || 0) + 1;
1686
1809
  newPaths.add(path);
@@ -1690,19 +1813,23 @@ export class TreeController {
1690
1813
  this.highlightedPaths = newPaths;
1691
1814
  if (lastNode) {
1692
1815
  this._setFocusedNode(lastNode);
1693
- this.lastHighlightedPath = lastNode.path;
1816
+ this._shiftCursor = lastNode.path;
1694
1817
  }
1695
- if (!options?.silent)
1818
+ if (!options?.silent) {
1696
1819
  this._notifyHighlightChanged();
1820
+ this._mirrorHighlightToSelected();
1821
+ }
1697
1822
  this.tree.refresh();
1698
1823
  }
1699
1824
  /** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
1700
1825
  clearHighlight(options) {
1701
1826
  this._clearAllHighlightFlags();
1702
1827
  this.highlightedPaths = new Set();
1703
- this.lastHighlightedPath = null;
1704
- if (!options?.silent)
1828
+ this._shiftCursor = null;
1829
+ if (!options?.silent) {
1705
1830
  this._notifyHighlightChanged();
1831
+ this._mirrorHighlightToSelected();
1832
+ }
1706
1833
  this.tree.refresh();
1707
1834
  }
1708
1835
  /** Get all highlighted nodes */
@@ -1719,6 +1846,15 @@ export class TreeController {
1719
1846
  isNodeHighlighted(path) {
1720
1847
  return this.highlightedPaths.has(path);
1721
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
+ }
1722
1858
  // ── Public selection methods (checkbox data state) ───────────────
1723
1859
  /** Get all selected (checked) nodes */
1724
1860
  getSelectedNodes() {
@@ -1754,17 +1890,10 @@ export class TreeController {
1754
1890
  if (!this.hasContextMenuSnippet && !this.getContextMenuItemsHandler) {
1755
1891
  return;
1756
1892
  }
1757
- // If right-clicking on an unhighlighted node, clear highlights and highlight only this node
1758
- if (!this.highlightedPaths.has(node.path)) {
1759
- this._clearAllHighlightFlags();
1760
- node.isHighlighted = true;
1761
- node._rev = (node._rev || 0) + 1;
1762
- this.highlightedPaths = new Set([node.path]);
1763
- this._setFocusedNode(node);
1764
- this.lastHighlightedPath = node.path;
1765
- this._notifyHighlightChanged();
1766
- this.tree.refresh();
1767
- }
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.
1768
1897
  uiLogger.debug(`Context menu opened: ${node.path}`);
1769
1898
  event.preventDefault();
1770
1899
  this.openContextMenu(node, event.clientX, event.clientY);
@@ -1799,6 +1928,31 @@ export class TreeController {
1799
1928
  this.draggedNode = node;
1800
1929
  this.isDragInProgress = true;
1801
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
+ }
1802
1956
  }
1803
1957
  _onNodeDragEnd = (event) => {
1804
1958
  dragLogger.debug('Drag ended', {
@@ -1842,6 +1996,50 @@ export class TreeController {
1842
1996
  }
1843
1997
  }
1844
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
+ }
1845
2043
  if (isSameTreeDrag && operation === 'move' && dropNode) {
1846
2044
  if (this.autoHandleMove) {
1847
2045
  const result = this.moveNode(draggedNodeRef.path, dropNode.path, position);
@@ -2523,14 +2721,17 @@ export class TreeController {
2523
2721
  }
2524
2722
  };
2525
2723
  }
2526
- /** 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. */
2527
2726
  _navHighlightTo(path) {
2528
- // Set anchor if not set
2529
- if (!this.lastHighlightedPath && this.focusedNode) {
2530
- this.lastHighlightedPath = this.focusedNode.path;
2531
- // 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) {
2532
2731
  const anchorNode = this.focusedNode;
2533
- 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) {
2534
2735
  anchorNode.isHighlighted = true;
2535
2736
  anchorNode._rev = (anchorNode._rev || 0) + 1;
2536
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";
@@ -1,4 +1,4 @@
1
1
  import { Index } from 'flexsearch';
2
2
  import { type LTreeNode } from './ltree-node.svelte';
3
3
  import type { Ltree } from './types.js';
4
- export declare function createLTree<T>(_idMember: string, _pathMember: string, _parentPathMember?: string | null | undefined, _levelMember?: string | null | undefined, _hasChildrenMember?: string | null | undefined, _isExpandedMember?: string | null | undefined, _isSelectableMember?: string | null | undefined, _isSelectedMember?: string | null | undefined, _isDraggableMember?: string | null | undefined, _getIsDraggableCallback?: (node: LTreeNode<T>) => boolean, _isDropAllowedMember?: string | null | undefined, _allowedDropPositionsMember?: string | null | undefined, _displayValueMember?: string | null | undefined, _getDisplayValueCallback?: (node: LTreeNode<T>) => string, _searchValueMember?: string | null | undefined, _getSearchValueCallback?: (node: LTreeNode<T>) => string, _getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => import('./types.js').DropPosition[] | null | undefined, _isCollapsibleMember?: string | null | undefined, _getIsCollapsibleCallback?: (node: LTreeNode<T>) => boolean, _orderMember?: string | null | undefined, _treeId?: string, _treePathSeparator?: string | null | undefined, _expandLevel?: number | null | undefined, _shouldUseInternalSearchIndex?: boolean | null | undefined, _initializeIndexCallback?: () => Index, _indexerBatchSize?: number | null | undefined, _indexerTimeout?: number | null | undefined, opts?: Partial<Ltree<T>>): Ltree<T>;
4
+ export declare function createLTree<T>(_idMember: string, _pathMember: string, _parentPathMember?: string | null | undefined, _levelMember?: string | null | undefined, _hasChildrenMember?: string | null | undefined, _isExpandedMember?: string | null | undefined, _getIsExpandedCallback?: (node: LTreeNode<T>) => boolean, _isSelectableMember?: string | null | undefined, _getIsSelectableCallback?: (node: LTreeNode<T>) => boolean, _isSelectedMember?: string | null | undefined, _getIsSelectedCallback?: (node: LTreeNode<T>) => boolean, _isDraggableMember?: string | null | undefined, _getIsDraggableCallback?: (node: LTreeNode<T>) => boolean, _isDropAllowedMember?: string | null | undefined, _allowedDropPositionsMember?: string | null | undefined, _displayValueMember?: string | null | undefined, _getDisplayValueCallback?: (node: LTreeNode<T>) => string, _searchValueMember?: string | null | undefined, _getSearchValueCallback?: (node: LTreeNode<T>) => string, _getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => import('./types.js').DropPosition[] | null | undefined, _isCollapsibleMember?: string | null | undefined, _getIsCollapsibleCallback?: (node: LTreeNode<T>) => boolean, _orderMember?: string | null | undefined, _treeId?: string, _treePathSeparator?: string | null | undefined, _expandLevel?: number | null | undefined, _shouldUseInternalSearchIndex?: boolean | null | undefined, _initializeIndexCallback?: () => Index, _indexerBatchSize?: number | null | undefined, _indexerTimeout?: number | null | undefined, opts?: Partial<Ltree<T>>): Ltree<T>;