react-native-tree-multi-select 2.0.11 → 3.0.0-beta.1

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.
Files changed (58) hide show
  1. package/README.md +151 -0
  2. package/lib/module/TreeView.js +32 -2
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/DragOverlay.js +101 -0
  5. package/lib/module/components/DragOverlay.js.map +1 -0
  6. package/lib/module/components/DropIndicator.js +79 -0
  7. package/lib/module/components/DropIndicator.js.map +1 -0
  8. package/lib/module/components/NodeList.js +258 -32
  9. package/lib/module/components/NodeList.js.map +1 -1
  10. package/lib/module/helpers/index.js +1 -0
  11. package/lib/module/helpers/index.js.map +1 -1
  12. package/lib/module/helpers/moveTreeNode.helper.js +96 -0
  13. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -0
  14. package/lib/module/helpers/toggleCheckbox.helper.js +88 -0
  15. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  16. package/lib/module/hooks/useDragDrop.js +511 -0
  17. package/lib/module/hooks/useDragDrop.js.map +1 -0
  18. package/lib/module/index.js +1 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/store/treeView.store.js +19 -1
  21. package/lib/module/store/treeView.store.js.map +1 -1
  22. package/lib/module/types/dragDrop.types.js +4 -0
  23. package/lib/module/types/dragDrop.types.js.map +1 -0
  24. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  25. package/lib/typescript/src/components/DragOverlay.d.ts +12 -0
  26. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -0
  27. package/lib/typescript/src/components/DropIndicator.d.ts +13 -0
  28. package/lib/typescript/src/components/DropIndicator.d.ts.map +1 -0
  29. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  30. package/lib/typescript/src/helpers/index.d.ts +1 -0
  31. package/lib/typescript/src/helpers/index.d.ts.map +1 -1
  32. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts +13 -0
  33. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -0
  34. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts +6 -0
  35. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  36. package/lib/typescript/src/hooks/useDragDrop.d.ts +35 -0
  37. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -0
  38. package/lib/typescript/src/index.d.ts +4 -2
  39. package/lib/typescript/src/index.d.ts.map +1 -1
  40. package/lib/typescript/src/store/treeView.store.d.ts +8 -0
  41. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  42. package/lib/typescript/src/types/dragDrop.types.d.ts +21 -0
  43. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -0
  44. package/lib/typescript/src/types/treeView.types.d.ts +90 -0
  45. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/TreeView.tsx +34 -0
  48. package/src/components/DragOverlay.tsx +112 -0
  49. package/src/components/DropIndicator.tsx +95 -0
  50. package/src/components/NodeList.tsx +302 -30
  51. package/src/helpers/index.ts +2 -1
  52. package/src/helpers/moveTreeNode.helper.ts +105 -0
  53. package/src/helpers/toggleCheckbox.helper.ts +96 -0
  54. package/src/hooks/useDragDrop.ts +643 -0
  55. package/src/index.tsx +19 -2
  56. package/src/store/treeView.store.ts +32 -0
  57. package/src/types/dragDrop.types.ts +23 -0
  58. package/src/types/treeView.types.ts +106 -0
@@ -2,16 +2,19 @@ import React from "react";
2
2
  import {
3
3
  View,
4
4
  StyleSheet,
5
-
6
5
  TouchableOpacity,
6
+ type NativeSyntheticEvent,
7
+ type NativeScrollEvent,
7
8
  } from "react-native";
8
9
  import { FlashList } from "@shopify/flash-list";
9
10
 
10
11
  import type {
11
12
  CheckboxValueType,
12
13
  __FlattenedTreeNode__,
14
+ DropIndicatorStyleProps,
13
15
  NodeListProps,
14
16
  NodeProps,
17
+ TreeNode,
15
18
  } from "../types/treeView.types";
16
19
 
17
20
  import { useTreeViewStore } from "../store/treeView.store";
@@ -24,10 +27,13 @@ import {
24
27
  } from "../helpers";
25
28
  import { CheckboxView } from "./CheckboxView";
26
29
  import { CustomExpandCollapseIcon } from "./CustomExpandCollapseIcon";
30
+ import { DragOverlay } from "./DragOverlay";
31
+ import type { DropPosition } from "../types/dragDrop.types";
27
32
  import { defaultIndentationMultiplier } from "../constants/treeView.constants";
28
33
  import { useShallow } from "zustand/react/shallow";
29
34
  import { typedMemo } from "../utils/typedMemo";
30
35
  import { ScrollToNodeHandler } from "../handlers/ScrollToNodeHandler";
36
+ import { useDragDrop } from "../hooks/useDragDrop";
31
37
 
32
38
  const NodeList = typedMemo(_NodeList);
33
39
  export default NodeList;
@@ -46,7 +52,16 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
46
52
  CheckboxComponent,
47
53
  ExpandCollapseIconComponent,
48
54
  ExpandCollapseTouchableComponent,
49
- CustomNodeRowComponent
55
+ CustomNodeRowComponent,
56
+
57
+ dragEnabled,
58
+ onDragEnd,
59
+ longPressDuration = 400,
60
+ autoScrollThreshold = 60,
61
+ autoScrollSpeed = 1.0,
62
+ dragOverlayOffset = -4,
63
+ autoExpandDelay = 800,
64
+ dragDropCustomizations,
50
65
  } = props;
51
66
 
52
67
  const {
@@ -66,6 +81,15 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
66
81
  ));
67
82
 
68
83
  const flashListRef = React.useRef<FlashList<__FlattenedTreeNode__<ID>> | null>(null);
84
+ const containerRef = React.useRef<View>(null);
85
+ const internalDataRef = React.useRef<TreeNode<ID>[] | null>(null);
86
+ const measuredItemHeightRef = React.useRef(0);
87
+
88
+ const handleItemLayout = React.useCallback((height: number) => {
89
+ if (measuredItemHeightRef.current === 0 && height > 0) {
90
+ measuredItemHeightRef.current = height;
91
+ }
92
+ }, []);
69
93
 
70
94
  const [initialScrollIndex, setInitialScrollIndex] = React.useState<number>(-1);
71
95
 
@@ -90,8 +114,46 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
90
114
  updateInnerMostChildrenIds(updatedInnerMostChildrenIds);
91
115
  }, [filteredTree, updateInnerMostChildrenIds]);
92
116
 
117
+ // --- Drag and drop ---
118
+ const {
119
+ panResponder,
120
+ overlayY,
121
+ isDragging,
122
+ draggedNode,
123
+ handleNodeTouchStart,
124
+ cancelLongPressTimer,
125
+ scrollOffsetRef,
126
+ } = useDragDrop<ID>({
127
+ storeId,
128
+ flattenedNodes: flattenedFilteredNodes,
129
+ flashListRef,
130
+ containerRef,
131
+ dragEnabled: dragEnabled ?? false,
132
+ onDragEnd,
133
+ longPressDuration,
134
+ autoScrollThreshold,
135
+ autoScrollSpeed,
136
+ internalDataRef,
137
+ measuredItemHeightRef,
138
+ dragOverlayOffset,
139
+ autoExpandDelay,
140
+ });
141
+
142
+ // Combined onScroll handler
143
+ const handleScroll = React.useCallback((
144
+ event: NativeSyntheticEvent<NativeScrollEvent>
145
+ ) => {
146
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
147
+ // Cancel long press timer if user is scrolling
148
+ cancelLongPressTimer();
149
+ // Forward to user's onScroll
150
+ treeFlashListProps?.onScroll?.(event as any);
151
+ }, [scrollOffsetRef, cancelLongPressTimer, treeFlashListProps]);
152
+
153
+ const effectiveIndentationMultiplier = indentationMultiplier ?? defaultIndentationMultiplier;
154
+
93
155
  const nodeRenderer = React.useCallback((
94
- { item }: { item: __FlattenedTreeNode__<ID>; }
156
+ { item, index }: { item: __FlattenedTreeNode__<ID>; index: number; }
95
157
  ) => {
96
158
  return (
97
159
  <Node<ID>
@@ -107,6 +169,14 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
107
169
  ExpandCollapseIconComponent={ExpandCollapseIconComponent}
108
170
  ExpandCollapseTouchableComponent={ExpandCollapseTouchableComponent}
109
171
  CustomNodeRowComponent={CustomNodeRowComponent}
172
+
173
+ nodeIndex={index}
174
+ dragEnabled={dragEnabled}
175
+ isDragging={isDragging}
176
+ onNodeTouchStart={dragEnabled ? handleNodeTouchStart : undefined}
177
+ onNodeTouchEnd={dragEnabled ? cancelLongPressTimer : undefined}
178
+ onItemLayout={dragEnabled ? handleItemLayout : undefined}
179
+ dragDropCustomizations={dragDropCustomizations}
110
180
  />
111
181
  );
112
182
  }, [
@@ -116,9 +186,37 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
116
186
  ExpandCollapseTouchableComponent,
117
187
  CustomNodeRowComponent,
118
188
  checkBoxViewStyleProps,
119
- indentationMultiplier
189
+ indentationMultiplier,
190
+ dragEnabled,
191
+ isDragging,
192
+ handleNodeTouchStart,
193
+ dragDropCustomizations,
194
+ cancelLongPressTimer,
195
+ handleItemLayout,
120
196
  ]);
121
197
 
198
+ // Extract FlashList props but exclude onScroll (we provide our own combined handler)
199
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
200
+ const { onScroll: _userOnScroll, ...restFlashListProps } = treeFlashListProps ?? {};
201
+
202
+ const flashListElement = (
203
+ <FlashList
204
+ ref={flashListRef}
205
+ estimatedItemSize={36}
206
+ initialScrollIndex={initialScrollIndex}
207
+ removeClippedSubviews={true}
208
+ keyboardShouldPersistTaps="handled"
209
+ drawDistance={50}
210
+ ListHeaderComponent={<HeaderFooterView />}
211
+ ListFooterComponent={<HeaderFooterView />}
212
+ {...restFlashListProps}
213
+ onScroll={handleScroll}
214
+ scrollEnabled={isDragging ? false : (restFlashListProps?.scrollEnabled ?? true)}
215
+ data={flattenedFilteredNodes}
216
+ renderItem={nodeRenderer}
217
+ />
218
+ );
219
+
122
220
  return (
123
221
  <>
124
222
  <ScrollToNodeHandler
@@ -129,19 +227,30 @@ function _NodeList<ID>(props: NodeListProps<ID>) {
129
227
  setInitialScrollIndex={setInitialScrollIndex}
130
228
  initialScrollNodeID={initialScrollNodeID} />
131
229
 
132
- <FlashList
133
- ref={flashListRef}
134
- estimatedItemSize={36}
135
- initialScrollIndex={initialScrollIndex}
136
- removeClippedSubviews={true}
137
- keyboardShouldPersistTaps="handled"
138
- drawDistance={50}
139
- ListHeaderComponent={<HeaderFooterView />}
140
- ListFooterComponent={<HeaderFooterView />}
141
- {...treeFlashListProps}
142
- data={flattenedFilteredNodes}
143
- renderItem={nodeRenderer}
144
- />
230
+ {dragEnabled ? (
231
+ <View
232
+ ref={containerRef}
233
+ style={styles.dragContainer}
234
+ {...panResponder.panHandlers}
235
+ >
236
+ {flashListElement}
237
+ {isDragging && draggedNode && (
238
+ <DragOverlay<ID>
239
+ overlayY={overlayY}
240
+ node={draggedNode}
241
+ level={draggedNode.level ?? 0}
242
+ indentationMultiplier={effectiveIndentationMultiplier}
243
+ CheckboxComponent={CheckboxComponent}
244
+ ExpandCollapseIconComponent={ExpandCollapseIconComponent}
245
+ CustomNodeRowComponent={CustomNodeRowComponent}
246
+ checkBoxViewStyleProps={checkBoxViewStyleProps}
247
+ dragDropCustomizations={dragDropCustomizations}
248
+ />
249
+ )}
250
+ </View>
251
+ ) : (
252
+ flashListElement
253
+ )}
145
254
  </>
146
255
  );
147
256
  };
@@ -179,19 +288,35 @@ function _Node<ID>(props: NodeProps<ID>) {
179
288
  ExpandCollapseIconComponent = CustomExpandCollapseIcon,
180
289
  CheckboxComponent = CheckboxView,
181
290
  ExpandCollapseTouchableComponent = TouchableOpacity,
182
- CustomNodeRowComponent
291
+ CustomNodeRowComponent,
292
+
293
+ nodeIndex = 0,
294
+ dragEnabled,
295
+ isDragging: isDraggingGlobal,
296
+ onNodeTouchStart,
297
+ onNodeTouchEnd,
298
+ onItemLayout,
299
+ dragDropCustomizations,
183
300
  } = props;
184
301
 
185
302
  const {
186
303
  isExpanded,
187
304
  value,
305
+ isBeingDragged,
306
+ isDragInvalid,
307
+ isDropTarget,
308
+ nodeDropPosition,
188
309
  } = useTreeViewStore<ID>(storeId)(useShallow(
189
310
  state => ({
190
311
  isExpanded: state.expanded.has(node.id),
191
312
  value: getValue(
192
- state.checked.has(node.id), // isChecked
193
- state.indeterminate.has(node.id) // isIndeterminate
313
+ state.checked.has(node.id),
314
+ state.indeterminate.has(node.id)
194
315
  ),
316
+ isBeingDragged: state.draggedNodeId === node.id,
317
+ isDragInvalid: state.invalidDragTargetIds.has(node.id),
318
+ isDropTarget: state.dropTargetNodeId === node.id,
319
+ nodeDropPosition: state.dropTargetNodeId === node.id ? state.dropPosition : null,
195
320
  })
196
321
  ));
197
322
 
@@ -203,14 +328,51 @@ function _Node<ID>(props: NodeProps<ID>) {
203
328
  toggleCheckboxes(storeId, [node.id]);
204
329
  }, [storeId, node.id]);
205
330
 
331
+ const handleTouchStart = React.useCallback((e: any) => {
332
+ if (!onNodeTouchStart) return;
333
+ const { pageY, locationY } = e.nativeEvent;
334
+ onNodeTouchStart(node.id, pageY, locationY, nodeIndex);
335
+ }, [node.id, nodeIndex, onNodeTouchStart]);
336
+
337
+ const handleTouchEnd = React.useCallback(() => {
338
+ onNodeTouchEnd?.();
339
+ }, [onNodeTouchEnd]);
340
+
341
+ // Determine opacity for drag state
342
+ const dragOpacity = dragDropCustomizations?.draggedNodeOpacity ?? 0.3;
343
+ const nodeOpacity = (isDraggingGlobal && (isBeingDragged || isDragInvalid))
344
+ ? dragOpacity
345
+ : 1.0;
346
+
347
+ const handleLayout = React.useCallback((e: any) => {
348
+ onItemLayout?.(e.nativeEvent.layout.height);
349
+ }, [onItemLayout]);
350
+
351
+ const touchHandlers = dragEnabled ? {
352
+ onTouchStart: handleTouchStart,
353
+ onTouchEnd: handleTouchEnd,
354
+ onTouchCancel: handleTouchEnd,
355
+ } : undefined;
356
+
357
+ const CustomDropIndicator = dragDropCustomizations?.CustomDropIndicatorComponent;
358
+ const dropIndicator = isDropTarget && nodeDropPosition ? (
359
+ CustomDropIndicator
360
+ ? <CustomDropIndicator position={nodeDropPosition} />
361
+ : <NodeDropIndicator position={nodeDropPosition} styleProps={dragDropCustomizations?.dropIndicatorStyleProps} />
362
+ ) : null;
363
+
206
364
  if (!CustomNodeRowComponent) {
207
365
  return (
208
366
  <View
209
367
  testID={`node_row_${node.id}`}
368
+ {...touchHandlers}
369
+ onLayout={onItemLayout ? handleLayout : undefined}
210
370
  style={[
211
371
  styles.nodeCheckboxAndArrowRow,
212
- { paddingStart: level * indentationMultiplier }
372
+ { paddingStart: level * indentationMultiplier },
373
+ { opacity: nodeOpacity },
213
374
  ]}>
375
+ {dropIndicator}
214
376
  <CheckboxComponent
215
377
  text={node.name}
216
378
  onValueChange={_onCheck}
@@ -233,17 +395,84 @@ function _Node<ID>(props: NodeProps<ID>) {
233
395
  }
234
396
  else {
235
397
  return (
236
- <CustomNodeRowComponent
237
- node={node}
238
- level={level}
239
- checkedValue={value}
240
- isExpanded={isExpanded}
241
- onCheck={_onCheck}
242
- onExpand={_onToggleExpand} />
398
+ <View
399
+ {...touchHandlers}
400
+ onLayout={onItemLayout ? handleLayout : undefined}
401
+ style={{ opacity: nodeOpacity }}
402
+ >
403
+ {dropIndicator}
404
+ <CustomNodeRowComponent
405
+ node={node}
406
+ level={level}
407
+ checkedValue={value}
408
+ isExpanded={isExpanded}
409
+ onCheck={_onCheck}
410
+ onExpand={_onToggleExpand}
411
+ isDragTarget={isDragInvalid}
412
+ isDragging={isDraggingGlobal}
413
+ isDraggedNode={isBeingDragged}
414
+ />
415
+ </View>
243
416
  );
244
417
  }
245
418
  };
246
419
 
420
+ function NodeDropIndicator({ position, styleProps }: {
421
+ position: DropPosition;
422
+ styleProps?: DropIndicatorStyleProps;
423
+ }) {
424
+ const lineColor = styleProps?.lineColor ?? "#0078FF";
425
+ const lineThickness = styleProps?.lineThickness ?? 3;
426
+ const circleSize = styleProps?.circleSize ?? 10;
427
+ const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)";
428
+ const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)";
429
+
430
+ if (position === "inside") {
431
+ return (
432
+ <View
433
+ pointerEvents="none"
434
+ style={[
435
+ styles.dropHighlight,
436
+ {
437
+ backgroundColor: highlightColor,
438
+ borderColor: highlightBorderColor,
439
+ },
440
+ ]}
441
+ />
442
+ );
443
+ }
444
+
445
+ return (
446
+ <View
447
+ pointerEvents="none"
448
+ style={[
449
+ styles.dropLineContainer,
450
+ { height: lineThickness },
451
+ position === "above" ? styles.dropLineTop : styles.dropLineBottom,
452
+ ]}
453
+ >
454
+ <View style={[
455
+ styles.dropLineCircle,
456
+ {
457
+ width: circleSize,
458
+ height: circleSize,
459
+ borderRadius: circleSize / 2,
460
+ backgroundColor: lineColor,
461
+ marginLeft: -(circleSize / 2),
462
+ marginTop: -(circleSize / 2 - lineThickness / 2),
463
+ },
464
+ ]} />
465
+ <View style={[
466
+ styles.dropLine,
467
+ {
468
+ height: lineThickness,
469
+ backgroundColor: lineColor,
470
+ },
471
+ ]} />
472
+ </View>
473
+ );
474
+ }
475
+
247
476
  const styles = StyleSheet.create({
248
477
  defaultHeaderFooter: {
249
478
  padding: 5
@@ -256,6 +485,49 @@ const styles = StyleSheet.create({
256
485
  flexDirection: "row",
257
486
  alignItems: "center",
258
487
  minWidth: "100%"
259
- }
488
+ },
489
+ dragContainer: {
490
+ flex: 1,
491
+ },
492
+ // Drop indicator styles (rendered by each node)
493
+ dropHighlight: {
494
+ position: "absolute",
495
+ top: 0,
496
+ bottom: 0,
497
+ left: 0,
498
+ right: 0,
499
+ backgroundColor: "rgba(0, 120, 255, 0.15)",
500
+ borderWidth: 2,
501
+ borderColor: "rgba(0, 120, 255, 0.5)",
502
+ borderRadius: 4,
503
+ zIndex: 10,
504
+ },
505
+ dropLineContainer: {
506
+ position: "absolute",
507
+ left: 0,
508
+ right: 0,
509
+ flexDirection: "row",
510
+ alignItems: "center",
511
+ height: 3,
512
+ zIndex: 10,
513
+ },
514
+ dropLineTop: {
515
+ top: 0,
516
+ },
517
+ dropLineBottom: {
518
+ bottom: 0,
519
+ },
520
+ dropLineCircle: {
521
+ width: 10,
522
+ height: 10,
523
+ borderRadius: 5,
524
+ backgroundColor: "#0078FF",
525
+ marginLeft: -5,
526
+ marginTop: -4,
527
+ },
528
+ dropLine: {
529
+ flex: 1,
530
+ height: 3,
531
+ backgroundColor: "#0078FF",
532
+ },
260
533
  });
261
-
@@ -5,4 +5,5 @@ export * from "./treeNode.helper";
5
5
  export * from "./selectAll.helper";
6
6
  export * from "./toggleCheckbox.helper";
7
7
  export * from "./search.helper";
8
- export * from "./flattenTree.helper";
8
+ export * from "./flattenTree.helper";
9
+ export * from "./moveTreeNode.helper";
@@ -0,0 +1,105 @@
1
+ import type { TreeNode } from "../types/treeView.types";
2
+ import type { DropPosition } from "../types/dragDrop.types";
3
+
4
+ /**
5
+ * Move a node within a tree structure. Returns a new tree (no mutation).
6
+ *
7
+ * @param data - The current tree data
8
+ * @param draggedNodeId - The ID of the node to move
9
+ * @param targetNodeId - The ID of the target node
10
+ * @param position - Where to place relative to target: "above", "below", or "inside"
11
+ * @returns New tree data with the node moved, or the original data if the move is invalid
12
+ */
13
+ export function moveTreeNode<ID>(
14
+ data: TreeNode<ID>[],
15
+ draggedNodeId: ID,
16
+ targetNodeId: ID,
17
+ position: DropPosition
18
+ ): TreeNode<ID>[] {
19
+ if (draggedNodeId === targetNodeId) return data;
20
+
21
+ // Step 1: Deep clone the tree
22
+ const cloned = deepCloneTree(data);
23
+
24
+ // Step 2: Remove the dragged node
25
+ const removedNode = removeNodeById(cloned, draggedNodeId);
26
+ if (!removedNode) return data;
27
+
28
+ // Step 3: Insert at the new position
29
+ const inserted = insertNode(cloned, removedNode, targetNodeId, position);
30
+ if (!inserted) return data;
31
+
32
+ return cloned;
33
+ }
34
+
35
+ function deepCloneTree<ID>(nodes: TreeNode<ID>[]): TreeNode<ID>[] {
36
+ return nodes.map(node => ({
37
+ ...node,
38
+ children: node.children ? deepCloneTree(node.children) : undefined,
39
+ }));
40
+ }
41
+
42
+ /**
43
+ * Remove a node by ID from the tree. Mutates the cloned tree in-place.
44
+ * Returns the removed node, or null if not found.
45
+ */
46
+ function removeNodeById<ID>(
47
+ nodes: TreeNode<ID>[],
48
+ nodeId: ID,
49
+ ): TreeNode<ID> | null {
50
+ for (let i = 0; i < nodes.length; i++) {
51
+ if (nodes[i]!.id === nodeId) {
52
+ const [removed] = nodes.splice(i, 1);
53
+ return removed!;
54
+ }
55
+ const children = nodes[i]!.children;
56
+ if (children) {
57
+ const removed = removeNodeById(children, nodeId);
58
+ if (removed) {
59
+ // Clean up empty children arrays
60
+ if (children.length === 0) {
61
+ nodes[i] = { ...nodes[i]!, children: undefined };
62
+ }
63
+ return removed;
64
+ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Insert a node relative to a target node. Mutates the cloned tree in-place.
72
+ * Returns true if insertion was successful.
73
+ */
74
+ function insertNode<ID>(
75
+ nodes: TreeNode<ID>[],
76
+ nodeToInsert: TreeNode<ID>,
77
+ targetId: ID,
78
+ position: DropPosition,
79
+ ): boolean {
80
+ for (let i = 0; i < nodes.length; i++) {
81
+ if (nodes[i]!.id === targetId) {
82
+ if (position === "above") {
83
+ nodes.splice(i, 0, nodeToInsert);
84
+ } else if (position === "below") {
85
+ nodes.splice(i + 1, 0, nodeToInsert);
86
+ } else {
87
+ // "inside" - add as first child
88
+ const target = nodes[i]!;
89
+ if (target.children) {
90
+ target.children.unshift(nodeToInsert);
91
+ } else {
92
+ nodes[i] = { ...target, children: [nodeToInsert] };
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+ const children = nodes[i]!.children;
98
+ if (children) {
99
+ if (insertNode(children, nodeToInsert, targetId, position)) {
100
+ return true;
101
+ }
102
+ }
103
+ }
104
+ return false;
105
+ }
@@ -200,3 +200,99 @@ export function toggleCheckboxes<ID>(
200
200
  updateChecked(tempChecked);
201
201
  updateIndeterminate(tempIndeterminate);
202
202
  }
203
+
204
+ /**
205
+ * Recalculates checked/indeterminate state for all parent nodes bottom-up.
206
+ * Should be called after tree structure changes (e.g., drag-drop moves) to ensure
207
+ * parent states correctly reflect their children's checked states.
208
+ */
209
+ export function recalculateCheckedStates<ID>(storeId: string) {
210
+ const treeViewStore = getTreeViewStore<ID>(storeId);
211
+ const {
212
+ checked,
213
+ updateChecked,
214
+ indeterminate,
215
+ updateIndeterminate,
216
+ nodeMap,
217
+ childToParentMap,
218
+ selectionPropagation,
219
+ } = treeViewStore.getState();
220
+
221
+ // Only recalculate if parent propagation is enabled
222
+ if (!selectionPropagation.toParents) return;
223
+
224
+ const tempChecked = new Set(checked);
225
+ const tempIndeterminate = new Set(indeterminate);
226
+
227
+ // Collect parent nodes and clean up leaf nodes that shouldn't be indeterminate.
228
+ // A leaf node (no children) can never be indeterminate — this can happen when
229
+ // all children of a formerly-indeterminate parent are dragged away.
230
+ const parentNodes: ID[] = [];
231
+ for (const [id, node] of nodeMap) {
232
+ if (node.children && node.children.length > 0) {
233
+ parentNodes.push(id);
234
+ } else {
235
+ tempIndeterminate.delete(id);
236
+ }
237
+ }
238
+
239
+ // Sort by depth descending (deepest first) for correct bottom-up propagation
240
+ const nodeDepths = new Map<ID, number>();
241
+ function getDepth(nodeId: ID): number {
242
+ if (nodeDepths.has(nodeId)) return nodeDepths.get(nodeId)!;
243
+ let depth = 0;
244
+ let currentId: ID | undefined = nodeId;
245
+ while (currentId) {
246
+ const parentId = childToParentMap.get(currentId);
247
+ if (parentId) {
248
+ depth++;
249
+ currentId = parentId;
250
+ } else {
251
+ break;
252
+ }
253
+ }
254
+ nodeDepths.set(nodeId, depth);
255
+ return depth;
256
+ }
257
+
258
+ parentNodes.sort((a, b) => getDepth(b) - getDepth(a));
259
+
260
+ // Update each parent based on its children's current state
261
+ for (const parentId of parentNodes) {
262
+ const node = nodeMap.get(parentId);
263
+ if (!node?.children?.length) continue;
264
+
265
+ let allChecked = true;
266
+ let anyCheckedOrIndeterminate = false;
267
+
268
+ for (const child of node.children) {
269
+ const isChecked = tempChecked.has(child.id);
270
+ const isIndeterminate = tempIndeterminate.has(child.id);
271
+
272
+ if (isChecked) {
273
+ anyCheckedOrIndeterminate = true;
274
+ } else if (isIndeterminate) {
275
+ anyCheckedOrIndeterminate = true;
276
+ allChecked = false;
277
+ } else {
278
+ allChecked = false;
279
+ }
280
+
281
+ if (!allChecked && anyCheckedOrIndeterminate) break;
282
+ }
283
+
284
+ if (allChecked) {
285
+ tempChecked.add(parentId);
286
+ tempIndeterminate.delete(parentId);
287
+ } else if (anyCheckedOrIndeterminate) {
288
+ tempChecked.delete(parentId);
289
+ tempIndeterminate.add(parentId);
290
+ } else {
291
+ tempChecked.delete(parentId);
292
+ tempIndeterminate.delete(parentId);
293
+ }
294
+ }
295
+
296
+ updateChecked(tempChecked);
297
+ updateIndeterminate(tempIndeterminate);
298
+ }