@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.
@@ -0,0 +1,142 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/keenmate/schemas/main/component-variables.schema.json",
3
+ "component": "@keenmate/svelte-treeview",
4
+ "prefix": "ltree",
5
+ "baseVariables": [
6
+ { "name": "base-accent-color", "required": true, "usage": "Primary accent — selection, drop-zones, focus rings, debug panel. Tints derived via color-mix()." },
7
+ { "name": "base-success-color", "required": false, "usage": "Drop-valid border and dragover highlight" },
8
+ { "name": "base-danger-color", "required": false, "usage": "Drop-invalid border, danger context-menu items, scroll-highlight arrows" },
9
+ { "name": "base-main-bg", "required": false, "usage": "Used for --ltree-light (context-menu hover tint, debug stat chips)" },
10
+ { "name": "base-border-color", "required": true, "usage": "Default border color (checkbox, context-menu, spinner track)" },
11
+ { "name": "base-hover-bg", "required": false, "usage": "Node row hover background" },
12
+ { "name": "base-text-color-1", "required": true, "usage": "Primary body text color (node labels, context-menu items)" },
13
+ { "name": "base-text-color-3", "required": false, "usage": "Muted text (path, toggle icon, loading 'more', node-path)" },
14
+ { "name": "base-text-color-4", "required": false, "usage": "Subtle text (context-menu shortcut, submenu arrow, divider label)" },
15
+ { "name": "base-text-color-on-accent", "required": false, "usage": "Text on accent backgrounds — checkbox checkmark color" },
16
+ { "name": "base-input-bg", "required": false, "usage": "Checkbox background (unchecked)" },
17
+ { "name": "base-dropdown-bg", "required": false, "usage": "Context-menu and submenu background" },
18
+ { "name": "base-dropdown-box-shadow", "required": false, "usage": "Context-menu shadow" },
19
+ { "name": "base-font-family", "required": false, "usage": "All text in component" },
20
+ { "name": "base-font-size-xs", "required": false, "usage": "Small text — node path, context-menu shortcut, icon font-size (multiplier × --ltree-rem)" },
21
+ { "name": "base-font-size-sm", "required": false, "usage": "Default node text and context-menu items (multiplier × --ltree-rem)" },
22
+ { "name": "base-font-weight-medium", "required": false, "usage": "Node label weight" },
23
+ { "name": "base-border-radius-sm", "required": false, "usage": "Checkbox, context-menu, node-content border radius (multiplier × --ltree-rem)" }
24
+ ],
25
+ "componentVariables": [
26
+ { "name": "ltree-rem", "category": "sizing", "usage": "Base sizing unit for proportional scaling (default 10px). Set to 1rem to scale with document font-size." },
27
+
28
+ { "name": "ltree-primary", "category": "color", "usage": "Primary accent color (selection, focus, links). Tints derived via color-mix()." },
29
+ { "name": "ltree-success", "category": "color", "usage": "Success color (drop-valid border, dragover-highlight)" },
30
+ { "name": "ltree-danger", "category": "color", "usage": "Danger color (drop-invalid, scroll-highlight arrows, danger menu items)" },
31
+ { "name": "ltree-light", "category": "color", "usage": "Light surface (context-menu hover, debug stat chips)" },
32
+ { "name": "ltree-border", "category": "color", "usage": "Default border color" },
33
+ { "name": "ltree-body-color", "category": "color", "usage": "Default body text color" },
34
+
35
+ { "name": "ltree-font-family", "category": "typography", "usage": "Font family for all tree text" },
36
+ { "name": "ltree-node-font-size", "category": "typography", "usage": "Node row text size" },
37
+ { "name": "ltree-node-icon-font-size", "category": "typography", "usage": "Per-node icon font size (emoji, etc.)" },
38
+ { "name": "ltree-node-icon-margin-right", "category": "spacing", "usage": "Gap between node icon and label" },
39
+ { "name": "ltree-node-label-font-weight", "category": "typography", "usage": "Node label weight" },
40
+ { "name": "ltree-node-label-margin-right", "category": "spacing", "usage": "Gap after node label" },
41
+ { "name": "ltree-node-path-font-size", "category": "typography", "usage": "Path / debug muted text size" },
42
+ { "name": "ltree-node-path-color", "category": "color", "usage": "Path / debug muted text color" },
43
+
44
+ { "name": "ltree-node-indent-per-level", "category": "spacing", "usage": "Indentation added per nesting level" },
45
+ { "name": "ltree-node-content-padding", "category": "spacing", "usage": "Inner padding of a node row" },
46
+ { "name": "ltree-node-content-border-radius", "category": "border", "usage": "Node row corner rounding" },
47
+ { "name": "ltree-node-hover-bg", "category": "surface", "usage": "Node row hover background" },
48
+ { "name": "ltree-children-margin-top", "category": "spacing", "usage": "Top margin of nested child list" },
49
+
50
+ { "name": "ltree-toggle-icon-size", "category": "sizing", "usage": "Toggle icon (chevron / +/- / arrow / leaf) SVG size" },
51
+ { "name": "ltree-toggle-icon-width", "category": "sizing", "usage": "Width reserved for the toggle column" },
52
+ { "name": "ltree-toggle-icon-color", "category": "color", "usage": "Toggle icon color (via currentColor mask)" },
53
+ { "name": "ltree-toggle-icon-margin-right", "category": "spacing", "usage": "Gap between toggle and node content" },
54
+ { "name": "ltree-toggle-icon-transition", "category": "animation", "usage": "Rotation animation timing" },
55
+
56
+ { "name": "ltree-checkbox-size", "category": "sizing", "usage": "Checkbox square dimension" },
57
+ { "name": "ltree-checkbox-border-width", "category": "border", "usage": "Checkbox border thickness (hairline, does not scale)" },
58
+ { "name": "ltree-checkbox-border-color", "category": "color", "usage": "Unchecked checkbox border" },
59
+ { "name": "ltree-checkbox-border-radius", "category": "border", "usage": "Checkbox corner rounding" },
60
+ { "name": "ltree-checkbox-bg", "category": "surface", "usage": "Unchecked checkbox background" },
61
+ { "name": "ltree-checkbox-checked-bg", "category": "surface", "usage": "Checked / indeterminate checkbox background" },
62
+ { "name": "ltree-checkbox-checked-border-color", "category": "color", "usage": "Checked checkbox border" },
63
+ { "name": "ltree-checkbox-checkmark-color", "category": "color", "usage": "Tick / dash glyph color" },
64
+ { "name": "ltree-checkbox-focus-ring-width", "category": "border", "usage": "Focus-visible ring thickness" },
65
+ { "name": "ltree-checkbox-focus-ring-color", "category": "color", "usage": "Focus-visible ring color (defaults to a 25% tint of --ltree-primary)" },
66
+ { "name": "ltree-checkbox-focus-ring", "category": "border", "usage": "Composed focus-visible box-shadow (uses the two parts above; override directly for full control)" },
67
+
68
+ { "name": "ltree-highlight-bg", "category": "state", "usage": "Explorer-style highlighted node background" },
69
+ { "name": "ltree-highlight-color", "category": "state", "usage": "Highlighted node text color" },
70
+ { "name": "ltree-multi-selected-bg", "category": "state", "usage": "Multi-select tint background" },
71
+ { "name": "ltree-multi-selected-outline", "category": "state", "usage": "Multi-select outline" },
72
+
73
+ { "name": "ltree-dragover-bg", "category": "state", "usage": "Drag-over background tint" },
74
+ { "name": "ltree-dragover-shadow", "category": "state", "usage": "Drag-over glow shadow" },
75
+ { "name": "ltree-drop-placeholder-bg", "category": "state", "usage": "Empty-tree drop placeholder background" },
76
+ { "name": "ltree-drop-placeholder-color", "category": "state", "usage": "Placeholder text color" },
77
+ { "name": "ltree-drop-placeholder-border-radius", "category": "border", "usage": "Placeholder corner rounding" },
78
+ { "name": "ltree-drop-placeholder-min-height", "category": "sizing", "usage": "Placeholder min height" },
79
+
80
+ { "name": "ltree-drop-zone-border-radius", "category": "border", "usage": "Drop zone corner rounding" },
81
+ { "name": "ltree-drop-zone-before-bg", "category": "drop-zone", "usage": "Before zone resting background (sage)" },
82
+ { "name": "ltree-drop-zone-before-color", "category": "drop-zone", "usage": "Before zone resting text" },
83
+ { "name": "ltree-drop-zone-before-active-bg", "category": "drop-zone", "usage": "Before zone active background" },
84
+ { "name": "ltree-drop-zone-before-active-color", "category": "drop-zone", "usage": "Before zone active text" },
85
+ { "name": "ltree-drop-zone-before-active-shadow", "category": "drop-zone", "usage": "Before zone active shadow" },
86
+ { "name": "ltree-drop-zone-after-bg", "category": "drop-zone", "usage": "After zone resting background (coral)" },
87
+ { "name": "ltree-drop-zone-after-color", "category": "drop-zone", "usage": "After zone resting text" },
88
+ { "name": "ltree-drop-zone-after-active-bg", "category": "drop-zone", "usage": "After zone active background" },
89
+ { "name": "ltree-drop-zone-after-active-color", "category": "drop-zone", "usage": "After zone active text" },
90
+ { "name": "ltree-drop-zone-after-active-shadow", "category": "drop-zone", "usage": "After zone active shadow" },
91
+ { "name": "ltree-drop-zone-child-bg", "category": "drop-zone", "usage": "Child zone resting background (lavender)" },
92
+ { "name": "ltree-drop-zone-child-color", "category": "drop-zone", "usage": "Child zone resting text" },
93
+ { "name": "ltree-drop-zone-child-active-bg", "category": "drop-zone", "usage": "Child zone active background" },
94
+ { "name": "ltree-drop-zone-child-active-color", "category": "drop-zone", "usage": "Child zone active text" },
95
+ { "name": "ltree-drop-zone-child-active-shadow", "category": "drop-zone", "usage": "Child zone active shadow" },
96
+
97
+ { "name": "ltree-drop-glow-before-color", "category": "drop-zone", "usage": "Border glow color for 'before' position" },
98
+ { "name": "ltree-drop-glow-after-color", "category": "drop-zone", "usage": "Border glow color for 'after' position" },
99
+ { "name": "ltree-drop-glow-child-color", "category": "drop-zone", "usage": "Border glow color for 'child' position" },
100
+ { "name": "ltree-drop-glow-child-bg", "category": "drop-zone", "usage": "Child zone glow background tint" },
101
+ { "name": "ltree-drop-glow-size", "category": "border", "usage": "Glow border thickness (hairline, does not scale)" },
102
+ { "name": "ltree-drop-arrow-size", "category": "sizing", "usage": "Direction arrow size in glow mode" },
103
+ { "name": "ltree-drop-arrow-position", "category": "spacing", "usage": "Horizontal arrow position (% of node width)" },
104
+ { "name": "ltree-drop-arrow-before-rotation", "category": "animation", "usage": "Arrow rotation for 'before' position" },
105
+ { "name": "ltree-drop-arrow-after-rotation", "category": "animation", "usage": "Arrow rotation for 'after' position" },
106
+ { "name": "ltree-drop-arrow-child-rotation", "category": "animation", "usage": "Arrow rotation for 'child' position (45deg by default)" },
107
+ { "name": "ltree-drop-arrow-before-image", "category": "icon", "usage": "SVG data URL for the 'before' direction arrow" },
108
+ { "name": "ltree-drop-arrow-after-image", "category": "icon", "usage": "SVG data URL for the 'after' direction arrow" },
109
+ { "name": "ltree-drop-arrow-child-image", "category": "icon", "usage": "SVG data URL for the 'child' direction arrow" },
110
+
111
+ { "name": "tree-ghost-bg", "category": "state", "usage": "Touch drag ghost background (legacy prefix, kept for backward compat)" },
112
+ { "name": "tree-ghost-color", "category": "state", "usage": "Touch drag ghost text color (legacy prefix)" },
113
+
114
+ { "name": "ltree-context-menu-bg", "category": "context-menu", "usage": "Menu background" },
115
+ { "name": "ltree-context-menu-border-color", "category": "context-menu", "usage": "Menu border color" },
116
+ { "name": "ltree-context-menu-border-radius", "category": "border", "usage": "Menu corner rounding" },
117
+ { "name": "ltree-context-menu-shadow", "category": "context-menu", "usage": "Menu drop shadow" },
118
+ { "name": "ltree-context-menu-min-width", "category": "sizing", "usage": "Menu minimum width" },
119
+ { "name": "ltree-context-menu-padding", "category": "spacing", "usage": "Menu inner padding" },
120
+ { "name": "ltree-context-menu-item-padding", "category": "spacing", "usage": "Menu item padding" },
121
+ { "name": "ltree-context-menu-item-font-size", "category": "typography", "usage": "Menu item font size" },
122
+ { "name": "ltree-context-menu-item-color", "category": "color", "usage": "Menu item text color" },
123
+ { "name": "ltree-context-menu-item-hover-bg", "category": "surface", "usage": "Menu item hover background" },
124
+ { "name": "ltree-context-menu-icon-size", "category": "sizing", "usage": "Menu icon column width" },
125
+ { "name": "ltree-context-menu-icon-font-size", "category": "typography", "usage": "Menu icon font size" },
126
+ { "name": "ltree-context-menu-shortcut-color", "category": "color", "usage": "Keyboard shortcut hint color" },
127
+ { "name": "ltree-context-menu-shortcut-font-size", "category": "typography", "usage": "Keyboard shortcut hint font size" },
128
+ { "name": "ltree-context-menu-arrow-color", "category": "color", "usage": "Submenu arrow color" },
129
+ { "name": "ltree-context-menu-divider-color", "category": "color", "usage": "Divider line color" },
130
+ { "name": "ltree-context-menu-divider-label-color", "category": "color", "usage": "Named divider label color" },
131
+
132
+ { "name": "ltree-loading-bg", "category": "surface", "usage": "Loading overlay background" },
133
+ { "name": "ltree-loading-color", "category": "color", "usage": "'Loading more…' text color" },
134
+ { "name": "ltree-spinner-size", "category": "sizing", "usage": "Spinner diameter" },
135
+ { "name": "ltree-spinner-track", "category": "color", "usage": "Spinner background ring color" },
136
+ { "name": "ltree-spinner-color", "category": "color", "usage": "Spinner active color" },
137
+
138
+ { "name": "ltree-scroll-highlight-bg", "category": "state", "usage": "scrollToPath highlight background flash" },
139
+ { "name": "ltree-scroll-highlight-shadow", "category": "state", "usage": "scrollToPath highlight glow" },
140
+ { "name": "ltree-scroll-highlight-arrow-color", "category": "state", "usage": "scrollToPath direction arrow color" }
141
+ ]
142
+ }
@@ -54,21 +54,21 @@
54
54
  const callbacks = getContext<NodeCallbacks<T>>('NodeCallbacks');
55
55
  const config = getContext<NodeConfig>('NodeConfig');
56
56
 
57
- // Destructure config for convenience.
58
- // Works reactively because nodeConfig uses $state() (not .raw()) and is mutated
59
- // in-place via Object.assign, so the proxy reference stays the same.
60
- const {
61
- expandIconClass,
62
- collapseIconClass,
63
- leafIconClass,
64
- highlightedNodeClass,
65
- focusedNodeClass,
66
- dragOverNodeClass,
67
- allowCopy,
68
- } = config;
57
+ // Read every config field through $derived so updates from controller.update()
58
+ // or the controller's runtime $effect syncs propagate into this Node's render.
59
+ // Plain destructuring would snapshot primitives once and never react.
60
+ const expandIconClass = $derived(config.expandIconClass);
61
+ const collapseIconClass = $derived(config.collapseIconClass);
62
+ const leafIconClass = $derived(config.leafIconClass);
63
+ const highlightedNodeClass = $derived(config.highlightedNodeClass);
64
+ const focusedNodeClass = $derived(config.focusedNodeClass);
65
+ // dragOverNodeClass is applied to the DOM directly by the controller
66
+ // (see hoveredNodeForDrop $effect in TreeController) — no per-Node binding.
67
+ const allowCopy = $derived(config.allowCopy);
69
68
  const clickBehavior = $derived(config.clickBehavior);
70
69
  const showCheckboxes = $derived(config.showCheckboxes);
71
70
  const checkboxMode = $derived(config.checkboxMode);
71
+ const clickTogglesCheckbox = $derived(config.clickTogglesCheckbox);
72
72
 
73
73
  // Indeterminate state: driven by controller's _updateAncestorVisualStates
74
74
  const isIndeterminate = $derived(checkboxMode === 'cascade' && node.visualState === 'indeterminate');
@@ -81,15 +81,15 @@
81
81
  const accordionExpand = $derived(config.accordionExpand);
82
82
  const toggleIconMode = $derived(config.toggleIconMode);
83
83
 
84
- // Compute if THIS node is the one being hovered for drop
84
+ // Compute if THIS node is the one being hovered for drop.
85
+ // Single source of truth from the controller — guarantees exactly one highlighted
86
+ // row at a time, unlike the per-node local flag we used before (HTML5 dragleave
87
+ // is unreliable and leaked stale highlights when moving fast between rows).
85
88
  const isHoveredForDrop = $derived(hoveredNodeForDropPath === node.path);
86
89
 
87
90
  const tree = getContext<Ltree<T>>("Ltree")
88
91
  const renderCoordinator = getContext<RenderCoordinator | null>("RenderCoordinator")
89
92
 
90
- // Drag over state
91
- let isDraggedOver = $state(false);
92
-
93
93
  // Track glow position for glow mode
94
94
  let glowPosition = $state<'before' | 'after' | 'child' | null>(null);
95
95
 
@@ -177,8 +177,8 @@
177
177
  // — only applied before first-child nodes (where level > previous node's level).
178
178
  const indentStyle = $derived(
179
179
  flatMode
180
- ? `margin-left: calc(${node?.level || 1} * var(--tree-node-indent-per-level, 0.5rem))${flatGap ? '; margin-top: 2px' : ''}`
181
- : `margin-left: var(--tree-node-indent-per-level, 0.5rem)`,
180
+ ? `margin-left: calc(${node?.level || 1} * var(--ltree-node-indent-per-level, 0.5rem))${flatGap ? '; margin-top: 2px' : ''}`
181
+ : `margin-left: var(--ltree-node-indent-per-level, 0.5rem)`,
182
182
  )
183
183
 
184
184
  // Progressive rendering state - only used in recursive mode
@@ -296,14 +296,27 @@
296
296
  function _onNodeClicked(event?: MouseEvent) {
297
297
  uiLogger.debug(`Node clicked: ${node.path}`, { id: node.id, hasChildren: node.hasChildren })
298
298
  const modifiers = event ? { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey } : undefined;
299
+ const hasModifiers = !!(modifiers?.ctrl || modifiers?.shift);
300
+
301
+ // Plain click on a selectable node with checkboxes shown → toggle the checkbox
302
+ // instead of focusing/highlighting. Expand still happens if clickBehavior asks for it.
303
+ // Modified clicks (Ctrl/Shift) fall through to the normal multi-highlight path.
304
+ if (clickTogglesCheckbox && showCheckboxes && node.isSelectable && !hasModifiers) {
305
+ callbacks.onCheckboxToggle(node, { skipFocus: true });
306
+ if (clickBehavior !== 'select') toggleExpanded();
307
+ return;
308
+ }
299
309
 
300
310
  if (clickBehavior === 'expand') {
301
- // Expand only — no selection callback
302
- toggleExpanded()
311
+ // Expand only — no selection callback. Ctrl/Shift have no meaning here
312
+ // (no selection to extend), so they shouldn't toggle expand either.
313
+ if (!hasModifiers) toggleExpanded()
303
314
  } else if (clickBehavior === 'expand-and-focus') {
304
- // Select + expand on single click
315
+ // Select + expand on single click. Modified clicks are reserved for
316
+ // highlight management — don't also toggle expand (matches OS file
317
+ // explorers where Ctrl/Shift+click manages selection without opening).
305
318
  callbacks.onNodeClicked(node, modifiers)
306
- toggleExpanded()
319
+ if (!hasModifiers) toggleExpanded()
307
320
  } else {
308
321
  // 'select' — single click selects only
309
322
  callbacks.onNodeClicked(node, modifiers)
@@ -365,7 +378,7 @@
365
378
  <!-- svelte-ignore a11y_click_events_have_key_events -->
366
379
  <!-- svelte-ignore a11y_no_static_element_interactions -->
367
380
  <div
368
- class="ltree-node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''} {isDraggedOver && dragOverNodeClass ? dragOverNodeClass : ''}"
381
+ class="ltree-node-content {node.isHighlighted ? highlightedNodeClass : ''} {node.isFocused && focusedNodeClass ? focusedNodeClass : ''}"
369
382
  class:ltree-clickable={node.isSelectable}
370
383
  class:ltree-dragged={isDraggedNode}
371
384
  class:ltree-draggable={node?.isDraggable}
@@ -403,7 +416,6 @@
403
416
  if (e.dataTransfer) {
404
417
  e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move';
405
418
  }
406
- isDraggedOver = true;
407
419
  // In glow mode, calculate and update the glow position
408
420
  if (dropZoneMode === 'glow') {
409
421
  glowPosition = calculateGlowPosition(e, e.currentTarget as HTMLElement);
@@ -417,7 +429,6 @@
417
429
  const y = e.clientY;
418
430
 
419
431
  if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
420
- isDraggedOver = false;
421
432
  glowPosition = null;
422
433
  callbacks.onNodeDragLeave(node, e);
423
434
  }
@@ -428,7 +439,6 @@
428
439
  if (e.dataTransfer) {
429
440
  e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move';
430
441
  }
431
- isDraggedOver = false;
432
442
  // In glow mode, use the calculated glowPosition for the drop
433
443
  if (dropZoneMode === 'glow' && glowPosition) {
434
444
  callbacks.onZoneDrop(node, glowPosition, e);
@@ -12,6 +12,7 @@
12
12
  type DropOperation,
13
13
  type ClickBehavior,
14
14
  type CheckboxMode,
15
+ type SelectionMode,
15
16
  type TreeChange,
16
17
  type ApplyChangesResult
17
18
  } from '../ltree/types.js';
@@ -31,8 +32,11 @@
31
32
  parentPathMember?: string | null | undefined;
32
33
  levelMember?: string | null | undefined;
33
34
  isExpandedMember?: string | null | undefined;
35
+ getIsExpandedCallback?: (node: LTreeNode<T>) => boolean;
34
36
  isSelectableMember?: string | null | undefined;
37
+ getIsSelectableCallback?: (node: LTreeNode<T>) => boolean;
35
38
  isSelectedMember?: string | null | undefined;
39
+ getIsSelectedCallback?: (node: LTreeNode<T>) => boolean;
36
40
  isDraggableMember?: string | null | undefined;
37
41
  getIsDraggableCallback?: (node: LTreeNode<T>) => boolean;
38
42
  isDropAllowedMember?: string | null | undefined;
@@ -76,8 +80,16 @@
76
80
  // BEHAVIOUR
77
81
  expandLevel?: number | null | undefined;
78
82
  clickBehavior?: ClickBehavior | null | undefined;
83
+ /**
84
+ * `'single'` (default): plain click highlights one node; Ctrl/Shift+click
85
+ * degrade to plain click; Shift+Arrow / Enter are no-ops.
86
+ * `'multi'`: Ctrl-toggle, Shift-range, Shift+Arrow extend, Enter toggles
87
+ * highlight on the focused node.
88
+ */
89
+ selectionMode?: SelectionMode | null | undefined;
79
90
  showCheckboxes?: boolean | null | undefined;
80
91
  checkboxMode?: CheckboxMode | null | undefined;
92
+ clickTogglesCheckbox?: boolean | null | undefined;
81
93
  beforeCheckboxToggleCallback?: (node: LTreeNode<T>, checked: boolean, affectedPaths: string[]) => boolean | string[] | void;
82
94
  rangeSelectionMode?: 'visual' | 'logical';
83
95
  initializeIndexCallback?: () => Index;
@@ -185,8 +197,11 @@
185
197
  hasChildrenMember,
186
198
 
187
199
  isExpandedMember,
200
+ getIsExpandedCallback,
188
201
  isSelectableMember,
202
+ getIsSelectableCallback,
189
203
  isSelectedMember,
204
+ getIsSelectedCallback,
190
205
  isDraggableMember,
191
206
  getIsDraggableCallback,
192
207
  isDropAllowedMember,
@@ -223,8 +238,10 @@
223
238
  expandLevel = 2,
224
239
 
225
240
  clickBehavior = 'expand-and-focus',
241
+ selectionMode = 'single',
226
242
  showCheckboxes = false,
227
243
  checkboxMode = 'independent',
244
+ clickTogglesCheckbox = false,
228
245
  beforeCheckboxToggleCallback,
229
246
  rangeSelectionMode = 'visual',
230
247
  shouldUseInternalSearchIndex = true,
@@ -306,8 +323,11 @@
306
323
  levelMember,
307
324
  hasChildrenMember,
308
325
  isExpandedMember,
326
+ getIsExpandedCallback,
309
327
  isSelectableMember,
328
+ getIsSelectableCallback,
310
329
  isSelectedMember,
330
+ getIsSelectedCallback,
311
331
  isDraggableMember,
312
332
  getIsDraggableCallback,
313
333
  isDropAllowedMember,
@@ -330,8 +350,10 @@
330
350
  selectedPaths,
331
351
  expandLevel,
332
352
  clickBehavior,
353
+ selectionMode,
333
354
  showCheckboxes,
334
355
  checkboxMode,
356
+ clickTogglesCheckbox,
335
357
  beforeCheckboxToggleCallback,
336
358
  rangeSelectionMode,
337
359
  shouldUseInternalSearchIndex,
@@ -440,8 +462,10 @@
440
462
 
441
463
  // Visual config sync (drives nodeConfig update via controller's internal effect)
442
464
  $effect(() => { controller.clickBehavior = clickBehavior ?? 'expand-and-focus'; });
465
+ $effect(() => { controller.selectionMode = selectionMode ?? 'single'; });
443
466
  $effect(() => { controller.showCheckboxes = showCheckboxes ?? false; });
444
467
  $effect(() => { controller.checkboxMode = checkboxMode ?? 'independent'; });
468
+ $effect(() => { controller.clickTogglesCheckbox = clickTogglesCheckbox ?? false; });
445
469
  $effect(() => { controller.beforeCheckboxToggleHandler = beforeCheckboxToggleCallback; });
446
470
  $effect(() => { controller.rangeSelectionMode = rangeSelectionMode ?? 'visual'; });
447
471
  $effect(() => { controller.expandIconClass = expandIconClass ?? 'ltree-icon-expand'; });
@@ -507,20 +531,32 @@
507
531
  );
508
532
 
509
533
  // ── Export public methods (thin proxies) ────────────────────────────
510
- export async function expandNodes(nodePath: string) {
511
- controller.expandNodes(nodePath);
534
+ export async function expandNodes(
535
+ nodePath: string | string[],
536
+ options?: { exclusive?: boolean; noEmit?: boolean }
537
+ ) {
538
+ controller.expandNodes(nodePath, options);
512
539
  }
513
540
 
514
- export async function collapseNodes(nodePath: string) {
515
- controller.collapseNodes(nodePath);
541
+ export async function collapseNodes(
542
+ nodePath: string | string[],
543
+ options?: { noEmit?: boolean }
544
+ ) {
545
+ controller.collapseNodes(nodePath, options);
516
546
  }
517
547
 
518
- export function expandAll(nodePath?: string | null | undefined) {
519
- controller.expandAll(nodePath);
548
+ export function expandAll(
549
+ nodePath?: string | string[] | null | undefined,
550
+ options?: { exclusive?: boolean; noEmit?: boolean }
551
+ ) {
552
+ controller.expandAll(nodePath, options);
520
553
  }
521
554
 
522
- export function collapseAll(nodePath?: string | null | undefined) {
523
- controller.collapseAll(nodePath);
555
+ export function collapseAll(
556
+ nodePath?: string | string[] | null | undefined,
557
+ options?: { noEmit?: boolean }
558
+ ) {
559
+ controller.collapseAll(nodePath, options);
524
560
  }
525
561
 
526
562
  export function filterNodes(searchTextVal: string, searchOptions?: SearchOptions): void {
@@ -613,16 +649,28 @@
613
649
  }
614
650
 
615
651
  // Multi-select methods
616
- export function selectNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace') {
617
- controller.selectNode(path, mode);
652
+ export function selectNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace', options?: { silent?: boolean }) {
653
+ controller.selectNode(path, mode, options);
618
654
  }
619
655
 
620
- export function selectNodes(paths: string[]) {
621
- controller.selectNodes(paths);
656
+ export function selectNodes(paths: string[], options?: { silent?: boolean }) {
657
+ controller.selectNodes(paths, options);
622
658
  }
623
659
 
624
- export function deselectAll() {
625
- controller.deselectAll();
660
+ export function highlightNode(path: string, mode: 'replace' | 'toggle' | 'range' = 'replace', options?: { silent?: boolean }) {
661
+ controller.highlightNode(path, mode, options);
662
+ }
663
+
664
+ export function highlightNodes(paths: string[], options?: { silent?: boolean }) {
665
+ controller.highlightNodes(paths, options);
666
+ }
667
+
668
+ export function clearHighlight(options?: { silent?: boolean }) {
669
+ controller.clearHighlight(options);
670
+ }
671
+
672
+ export function deselectAll(options?: { silent?: boolean }) {
673
+ controller.deselectAll(options);
626
674
  }
627
675
 
628
676
  export function getSelectedNodes(): LTreeNode<T>[] {
@@ -659,8 +707,11 @@
659
707
  | "levelMember"
660
708
  | "hasChildrenMember"
661
709
  | "isExpandedMember"
710
+ | "getIsExpandedCallback"
662
711
  | "isSelectableMember"
712
+ | "getIsSelectableCallback"
663
713
  | "isSelectedMember"
714
+ | "getIsSelectedCallback"
664
715
  | "isDraggableMember"
665
716
  | "getIsDraggableCallback"
666
717
  | "isDropAllowedMember"
@@ -679,8 +730,10 @@
679
730
  | "selectedPaths"
680
731
  | "expandLevel"
681
732
  | "clickBehavior"
733
+ | "selectionMode"
682
734
  | "showCheckboxes"
683
735
  | "checkboxMode"
736
+ | "clickTogglesCheckbox"
684
737
  | "beforeCheckboxToggleCallback"
685
738
  | "rangeSelectionMode"
686
739
  | "shouldUseInternalSearchIndex"
@@ -732,8 +785,11 @@
732
785
  if (updates.levelMember !== undefined) levelMember = updates.levelMember;
733
786
  if (updates.hasChildrenMember !== undefined) hasChildrenMember = updates.hasChildrenMember;
734
787
  if (updates.isExpandedMember !== undefined) isExpandedMember = updates.isExpandedMember;
788
+ if (updates.getIsExpandedCallback !== undefined) getIsExpandedCallback = updates.getIsExpandedCallback;
735
789
  if (updates.isSelectableMember !== undefined) isSelectableMember = updates.isSelectableMember;
790
+ if (updates.getIsSelectableCallback !== undefined) getIsSelectableCallback = updates.getIsSelectableCallback;
736
791
  if (updates.isSelectedMember !== undefined) isSelectedMember = updates.isSelectedMember;
792
+ if (updates.getIsSelectedCallback !== undefined) getIsSelectedCallback = updates.getIsSelectedCallback;
737
793
  if (updates.isDraggableMember !== undefined) isDraggableMember = updates.isDraggableMember;
738
794
  if (updates.getIsDraggableCallback !== undefined) getIsDraggableCallback = updates.getIsDraggableCallback;
739
795
  if (updates.isDropAllowedMember !== undefined) isDropAllowedMember = updates.isDropAllowedMember;
@@ -752,8 +808,10 @@
752
808
  if (updates.selectedPaths !== undefined) selectedPaths = updates.selectedPaths;
753
809
  if (updates.expandLevel !== undefined) expandLevel = updates.expandLevel;
754
810
  if (updates.clickBehavior !== undefined) clickBehavior = updates.clickBehavior;
811
+ if (updates.selectionMode !== undefined) selectionMode = updates.selectionMode;
755
812
  if (updates.showCheckboxes !== undefined) showCheckboxes = updates.showCheckboxes;
756
813
  if (updates.checkboxMode !== undefined) checkboxMode = updates.checkboxMode;
814
+ if (updates.clickTogglesCheckbox !== undefined) clickTogglesCheckbox = updates.clickTogglesCheckbox;
757
815
  if (updates.beforeCheckboxToggleCallback !== undefined) beforeCheckboxToggleCallback = updates.beforeCheckboxToggleCallback;
758
816
  if (updates.rangeSelectionMode !== undefined) rangeSelectionMode = updates.rangeSelectionMode;
759
817
  if (updates.shouldUseInternalSearchIndex !== undefined) shouldUseInternalSearchIndex = updates.shouldUseInternalSearchIndex;
@@ -813,6 +871,8 @@
813
871
 
814
872
  let handled = true;
815
873
 
874
+ // Shift+nav extends the highlight range. In single mode the controller
875
+ // short-circuits these to no-ops (see _navHighlightTo).
816
876
  switch (event.key) {
817
877
  case 'ArrowDown': event.shiftKey ? controller.navHighlightNext() : controller.navNextSibling(); break;
818
878
  case 'ArrowUp': event.shiftKey ? controller.navHighlightPrev() : controller.navPrevSibling(); break;
@@ -824,7 +884,19 @@
824
884
  case 'PageDown': event.shiftKey ? controller.navHighlightPageDown() : controller.navPageDown(); break;
825
885
  case 'PageUp': event.shiftKey ? controller.navHighlightPageUp() : controller.navPageUp(); break;
826
886
  case 'Enter':
827
- case ' ': controller.navToggle(); break;
887
+ // single: no-op (per spec); multi: toggle highlight on focused node.
888
+ if (controller.selectionMode === 'multi') controller.toggleFocusedHighlight();
889
+ else handled = false;
890
+ break;
891
+ case ' ':
892
+ // With checkboxes: toggle the focused node's checkbox.
893
+ // Without: keep the legacy expand/collapse behaviour as a useful fallback.
894
+ if (controller.showCheckboxes && controller.focusedNode?.isSelectable) {
895
+ controller.nodeCallbacks.onCheckboxToggle(controller.focusedNode);
896
+ } else {
897
+ controller.navToggle();
898
+ }
899
+ break;
828
900
  default: handled = false;
829
901
  }
830
902