react-native-tree-multi-select 3.0.0-beta.2 → 3.0.0-beta.4

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 (96) hide show
  1. package/README.md +85 -25
  2. package/lib/module/TreeView.js +36 -31
  3. package/lib/module/TreeView.js.map +1 -1
  4. package/lib/module/components/CheckboxView.js +8 -4
  5. package/lib/module/components/CheckboxView.js.map +1 -1
  6. package/lib/module/components/CustomExpandCollapseIcon.js +2 -2
  7. package/lib/module/components/CustomExpandCollapseIcon.js.map +1 -1
  8. package/lib/module/components/DragOverlay.js +17 -5
  9. package/lib/module/components/DragOverlay.js.map +1 -1
  10. package/lib/module/components/DropIndicator.js +2 -2
  11. package/lib/module/components/DropIndicator.js.map +1 -1
  12. package/lib/module/components/NodeList.js +80 -56
  13. package/lib/module/components/NodeList.js.map +1 -1
  14. package/lib/module/constants/treeView.constants.js +3 -0
  15. package/lib/module/constants/treeView.constants.js.map +1 -1
  16. package/lib/module/helpers/expandCollapse.helper.js.map +1 -1
  17. package/lib/module/helpers/moveTreeNode.helper.js +30 -0
  18. package/lib/module/helpers/moveTreeNode.helper.js.map +1 -1
  19. package/lib/module/helpers/selectAll.helper.js.map +1 -1
  20. package/lib/module/helpers/toggleCheckbox.helper.js +44 -61
  21. package/lib/module/helpers/toggleCheckbox.helper.js.map +1 -1
  22. package/lib/module/hooks/useDragDrop.js +141 -34
  23. package/lib/module/hooks/useDragDrop.js.map +1 -1
  24. package/lib/module/{handlers/ScrollToNodeHandler.js → hooks/useScrollToNode.js} +27 -26
  25. package/lib/module/hooks/useScrollToNode.js.map +1 -0
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/jest.setup.js +14 -1
  29. package/lib/module/jest.setup.js.map +1 -1
  30. package/lib/module/store/treeView.store.js +3 -0
  31. package/lib/module/store/treeView.store.js.map +1 -1
  32. package/lib/module/utils/typedMemo.js +3 -3
  33. package/lib/module/utils/typedMemo.js.map +1 -1
  34. package/lib/module/utils/useDeepCompareEffect.js +5 -5
  35. package/lib/module/utils/useDeepCompareEffect.js.map +1 -1
  36. package/lib/typescript/src/TreeView.d.ts +3 -3
  37. package/lib/typescript/src/TreeView.d.ts.map +1 -1
  38. package/lib/typescript/src/components/CheckboxView.d.ts +1 -2
  39. package/lib/typescript/src/components/CheckboxView.d.ts.map +1 -1
  40. package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts +1 -2
  41. package/lib/typescript/src/components/CustomExpandCollapseIcon.d.ts.map +1 -1
  42. package/lib/typescript/src/components/DragOverlay.d.ts +1 -0
  43. package/lib/typescript/src/components/DragOverlay.d.ts.map +1 -1
  44. package/lib/typescript/src/components/DropIndicator.d.ts +1 -2
  45. package/lib/typescript/src/components/DropIndicator.d.ts.map +1 -1
  46. package/lib/typescript/src/components/NodeList.d.ts.map +1 -1
  47. package/lib/typescript/src/constants/treeView.constants.d.ts +2 -0
  48. package/lib/typescript/src/constants/treeView.constants.d.ts.map +1 -1
  49. package/lib/typescript/src/helpers/expandCollapse.helper.d.ts +2 -2
  50. package/lib/typescript/src/helpers/expandCollapse.helper.d.ts.map +1 -1
  51. package/lib/typescript/src/helpers/moveTreeNode.helper.d.ts.map +1 -1
  52. package/lib/typescript/src/helpers/selectAll.helper.d.ts +4 -4
  53. package/lib/typescript/src/helpers/selectAll.helper.d.ts.map +1 -1
  54. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts +3 -0
  55. package/lib/typescript/src/helpers/toggleCheckbox.helper.d.ts.map +1 -1
  56. package/lib/typescript/src/hooks/useDragDrop.d.ts +18 -7
  57. package/lib/typescript/src/hooks/useDragDrop.d.ts.map +1 -1
  58. package/lib/typescript/src/{handlers/ScrollToNodeHandler.d.ts → hooks/useScrollToNode.d.ts} +13 -15
  59. package/lib/typescript/src/hooks/useScrollToNode.d.ts.map +1 -0
  60. package/lib/typescript/src/index.d.ts +4 -3
  61. package/lib/typescript/src/index.d.ts.map +1 -1
  62. package/lib/typescript/src/jest.setup.d.ts +1 -1
  63. package/lib/typescript/src/jest.setup.d.ts.map +1 -1
  64. package/lib/typescript/src/store/treeView.store.d.ts +2 -1
  65. package/lib/typescript/src/store/treeView.store.d.ts.map +1 -1
  66. package/lib/typescript/src/types/dragDrop.types.d.ts +19 -0
  67. package/lib/typescript/src/types/dragDrop.types.d.ts.map +1 -1
  68. package/lib/typescript/src/types/treeView.types.d.ts +149 -35
  69. package/lib/typescript/src/types/treeView.types.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/typedMemo.d.ts +1 -1
  71. package/lib/typescript/src/utils/typedMemo.d.ts.map +1 -1
  72. package/lib/typescript/src/utils/useDeepCompareEffect.d.ts +2 -2
  73. package/lib/typescript/src/utils/useDeepCompareEffect.d.ts.map +1 -1
  74. package/package.json +32 -15
  75. package/src/TreeView.tsx +57 -35
  76. package/src/components/CheckboxView.tsx +7 -4
  77. package/src/components/CustomExpandCollapseIcon.tsx +2 -2
  78. package/src/components/DragOverlay.tsx +19 -6
  79. package/src/components/DropIndicator.tsx +2 -2
  80. package/src/components/NodeList.tsx +90 -58
  81. package/src/constants/treeView.constants.ts +4 -1
  82. package/src/helpers/expandCollapse.helper.ts +5 -5
  83. package/src/helpers/moveTreeNode.helper.ts +33 -0
  84. package/src/helpers/selectAll.helper.ts +10 -10
  85. package/src/helpers/toggleCheckbox.helper.ts +57 -69
  86. package/src/hooks/useDragDrop.ts +182 -46
  87. package/src/{handlers/ScrollToNodeHandler.tsx → hooks/useScrollToNode.ts} +48 -45
  88. package/src/index.tsx +9 -0
  89. package/src/jest.setup.ts +14 -1
  90. package/src/store/treeView.store.ts +6 -1
  91. package/src/types/dragDrop.types.ts +21 -0
  92. package/src/types/treeView.types.ts +157 -41
  93. package/src/utils/typedMemo.ts +3 -3
  94. package/src/utils/useDeepCompareEffect.ts +13 -7
  95. package/lib/module/handlers/ScrollToNodeHandler.js.map +0 -1
  96. package/lib/typescript/src/handlers/ScrollToNodeHandler.d.ts.map +0 -1
@@ -1,4 +1,11 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import {
2
+ MutableRefObject,
3
+ RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState
8
+ } from "react";
2
9
  import {
3
10
  Animated,
4
11
  PanResponder,
@@ -7,27 +14,44 @@ import {
7
14
  import type { FlashList } from "@shopify/flash-list";
8
15
 
9
16
  import type { __FlattenedTreeNode__, TreeNode } from "../types/treeView.types";
10
- import type { DragEndEvent, DropTarget } from "../types/dragDrop.types";
17
+ import type { DragCancelEvent, DragEndEvent, DragStartEvent, DropTarget } from "../types/dragDrop.types";
11
18
  import { getTreeViewStore } from "../store/treeView.store";
12
- import { collapseNodes, expandNodes, handleToggleExpand, initializeNodeMaps, recalculateCheckedStates } from "../helpers";
19
+ import {
20
+ collapseNodes,
21
+ expandNodes,
22
+ handleToggleExpand,
23
+ initializeNodeMaps,
24
+ recalculateCheckedStates
25
+ } from "../helpers";
13
26
  import { moveTreeNode } from "../helpers/moveTreeNode.helper";
27
+ import { listHeaderFooterPadding } from "../constants/treeView.constants";
14
28
 
15
29
  interface UseDragDropParams<ID> {
16
30
  storeId: string;
17
31
  flattenedNodes: __FlattenedTreeNode__<ID>[];
18
- flashListRef: React.RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
19
- containerRef: React.RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void } | null>;
32
+ flashListRef: RefObject<FlashList<__FlattenedTreeNode__<ID>> | null>;
33
+ containerRef: RefObject<{ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void; } | null>;
20
34
  dragEnabled: boolean;
35
+ onDragStart?: (event: DragStartEvent<ID>) => void;
21
36
  onDragEnd?: (event: DragEndEvent<ID>) => void;
37
+ onDragCancel?: (event: DragCancelEvent<ID>) => void;
22
38
  longPressDuration: number;
23
39
  autoScrollThreshold: number;
24
40
  autoScrollSpeed: number;
25
- internalDataRef: React.MutableRefObject<TreeNode<ID>[] | null>;
26
- measuredItemHeightRef: React.MutableRefObject<number>;
41
+ internalDataRef: MutableRefObject<TreeNode<ID>[] | null>;
42
+ measuredItemHeightRef: MutableRefObject<number>;
27
43
  dragOverlayOffset: number;
28
44
  autoExpandDelay: number;
29
45
  /** Pixels per nesting level, used for magnetic overlay shift. */
30
46
  indentationMultiplier: number;
47
+ /** Callback to determine if a drop is allowed on a specific target. */
48
+ canDrop?: (draggedNode: TreeNode<ID>, targetNode: TreeNode<ID>, position: "above" | "below" | "inside") => boolean;
49
+ /** Maximum nesting depth allowed. */
50
+ maxDepth?: number;
51
+ /** Callback to determine if a node can accept children. */
52
+ canNodeHaveChildren?: (node: TreeNode<ID>) => boolean;
53
+ /** Callback to determine if a node can be dragged. */
54
+ canDrag?: (node: TreeNode<ID>) => boolean;
31
55
  }
32
56
 
33
57
  interface UseDragDropReturn<ID> {
@@ -46,8 +70,8 @@ interface UseDragDropReturn<ID> {
46
70
  ) => void;
47
71
  handleNodeTouchEnd: () => void;
48
72
  cancelLongPressTimer: () => void;
49
- scrollOffsetRef: React.MutableRefObject<number>;
50
- headerOffsetRef: React.MutableRefObject<number>;
73
+ scrollOffsetRef: MutableRefObject<number>;
74
+ headerOffsetRef: MutableRefObject<number>;
51
75
  }
52
76
 
53
77
  export function useDragDrop<ID>(
@@ -59,7 +83,9 @@ export function useDragDrop<ID>(
59
83
  flashListRef,
60
84
  containerRef,
61
85
  dragEnabled,
86
+ onDragStart,
62
87
  onDragEnd,
88
+ onDragCancel,
63
89
  longPressDuration,
64
90
  autoScrollThreshold,
65
91
  autoScrollSpeed,
@@ -68,6 +94,10 @@ export function useDragDrop<ID>(
68
94
  dragOverlayOffset,
69
95
  autoExpandDelay,
70
96
  indentationMultiplier,
97
+ canDrop: canDropCallback,
98
+ maxDepth,
99
+ canNodeHaveChildren,
100
+ canDrag,
71
101
  } = params;
72
102
 
73
103
  // --- Refs for mutable state (no stale closures in PanResponder) ---
@@ -107,15 +137,42 @@ export function useDragDrop<ID>(
107
137
  const panResponderActiveRef = useRef(false);
108
138
 
109
139
  // Previous drop target for hysteresis (prevents flicker between "below N" / "above N+1")
110
- const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside" } | null>(null);
140
+ const prevDropTargetRef = useRef<{ targetIndex: number; position: "above" | "below" | "inside"; } | null>(null);
141
+
142
+ // Depth of the dragged subtree (computed once at drag start, used for maxDepth check)
143
+ const draggedSubtreeDepthRef = useRef(0);
111
144
 
112
145
  // Keep flattenedNodes ref current for PanResponder closures
113
146
  const flattenedNodesRef = useRef(flattenedNodes);
114
147
  flattenedNodesRef.current = flattenedNodes;
115
148
 
116
149
  // Keep callbacks current
150
+ const onDragStartRef = useRef(onDragStart);
151
+ onDragStartRef.current = onDragStart;
117
152
  const onDragEndRef = useRef(onDragEnd);
118
153
  onDragEndRef.current = onDragEnd;
154
+ const onDragCancelRef = useRef(onDragCancel);
155
+ onDragCancelRef.current = onDragCancel;
156
+ const canDropRef = useRef(canDropCallback);
157
+ canDropRef.current = canDropCallback;
158
+ const canNodeHaveChildrenRef = useRef(canNodeHaveChildren);
159
+ canNodeHaveChildrenRef.current = canNodeHaveChildren;
160
+ const canDragRef = useRef(canDrag);
161
+ canDragRef.current = canDrag;
162
+
163
+ // Keep config values current for PanResponder closures
164
+ const dragOverlayOffsetRef = useRef(dragOverlayOffset);
165
+ dragOverlayOffsetRef.current = dragOverlayOffset;
166
+ const autoScrollThresholdRef = useRef(autoScrollThreshold);
167
+ autoScrollThresholdRef.current = autoScrollThreshold;
168
+ const autoScrollSpeedParamRef = useRef(autoScrollSpeed);
169
+ autoScrollSpeedParamRef.current = autoScrollSpeed;
170
+ const autoExpandDelayRef = useRef(autoExpandDelay);
171
+ autoExpandDelayRef.current = autoExpandDelay;
172
+ const indentationMultiplierRef = useRef(indentationMultiplier);
173
+ indentationMultiplierRef.current = indentationMultiplier;
174
+ const maxDepthRef = useRef(maxDepth);
175
+ maxDepthRef.current = maxDepth;
119
176
 
120
177
  // --- React state (triggers re-renders only at drag start/end + indicator changes) ---
121
178
  const [isDragging, setIsDragging] = useState(false);
@@ -123,7 +180,7 @@ export function useDragDrop<ID>(
123
180
  const [dropTarget, setDropTarget] = useState<DropTarget<ID> | null>(null);
124
181
  const [effectiveDropLevel, setEffectiveDropLevel] = useState(0);
125
182
 
126
- // Ref mirror of dropTarget avoids nesting Zustand updates inside React state updaters
183
+ // Ref mirror of dropTarget - avoids nesting Zustand updates inside React state updaters
127
184
  const dropTargetRef = useRef<DropTarget<ID> | null>(null);
128
185
 
129
186
  // --- Long press timer ---
@@ -157,6 +214,22 @@ export function useDragDrop<ID>(
157
214
  [storeId]
158
215
  );
159
216
 
217
+ // --- Get the maximum depth of a subtree (0 for leaf nodes) ---
218
+ const getSubtreeDepth = useCallback(
219
+ (nodeId: ID): number => {
220
+ const store = getTreeViewStore<ID>(storeId);
221
+ const { nodeMap } = store.getState();
222
+ const node = nodeMap.get(nodeId);
223
+ if (!node?.children?.length) return 0;
224
+ let max = 0;
225
+ for (const child of node.children) {
226
+ max = Math.max(max, 1 + getSubtreeDepth(child.id));
227
+ }
228
+ return max;
229
+ },
230
+ [storeId]
231
+ );
232
+
160
233
  // --- Initiate drag ---
161
234
  const initiateDrag = useCallback(
162
235
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
@@ -187,12 +260,11 @@ export function useDragDrop<ID>(
187
260
  draggedNodeRef.current = node;
188
261
  draggedNodeIdRef.current = nodeId;
189
262
  draggedNodeIndexRef.current = nodeIndex;
263
+ draggedSubtreeDepthRef.current = getSubtreeDepth(nodeId);
190
264
 
191
- // Use measured item height if available, fall back to estimatedItemSize
265
+ // Use measured item height if available, fall back to default
192
266
  const measured = measuredItemHeightRef.current;
193
- const estimatedSize =
194
- (flashListRef.current as any)?.props?.estimatedItemSize ?? 36;
195
- itemHeightRef.current = measured > 0 ? measured : estimatedSize;
267
+ itemHeightRef.current = measured > 0 ? measured : 36;
196
268
 
197
269
  // Calculate headerOffset dynamically:
198
270
  // fingerLocalY = pageY - containerPageY
@@ -207,9 +279,8 @@ export function useDragDrop<ID>(
207
279
 
208
280
  // Delta-based auto-scroll: compute finger's position in the container
209
281
  // from the node's known index (avoids unreliable containerPageY).
210
- // The FlashList header (padding:5 → ~10px) + nodeIndex * itemHeight - scroll + locationY
211
282
  const iH = itemHeightRef.current;
212
- const listHeaderHeight = 10; // HeaderFooterView has padding: 5 → 10px total
283
+ const listHeaderHeight = listHeaderFooterPadding * 2;
213
284
  initialFingerPageYRef.current = pageY;
214
285
  initialFingerContainerYRef.current =
215
286
  listHeaderHeight + nodeIndex * iH - scrollOffsetRef.current + locationY;
@@ -223,7 +294,7 @@ export function useDragDrop<ID>(
223
294
  store.getState().updateInvalidDragTargetIds(descendants);
224
295
 
225
296
  // Set overlay initial position (with configurable offset)
226
- const overlayLocalY = fingerLocalY - locationY + dragOverlayOffset * itemHeightRef.current;
297
+ const overlayLocalY = fingerLocalY - locationY + dragOverlayOffsetRef.current * itemHeightRef.current;
227
298
  overlayY.setValue(overlayLocalY);
228
299
 
229
300
  // Reset magnetic overlay
@@ -238,6 +309,9 @@ export function useDragDrop<ID>(
238
309
  setEffectiveDropLevel(node.level ?? 0);
239
310
  setDropTarget(null);
240
311
 
312
+ // Notify consumer that drag has started
313
+ onDragStartRef.current?.({ draggedNodeId: nodeId });
314
+
241
315
  // Start auto-scroll loop
242
316
  startAutoScrollLoop();
243
317
  });
@@ -249,6 +323,7 @@ export function useDragDrop<ID>(
249
323
  containerRef,
250
324
  flashListRef,
251
325
  getDescendantIds,
326
+ getSubtreeDepth,
252
327
  overlayY,
253
328
  ]
254
329
  );
@@ -258,6 +333,12 @@ export function useDragDrop<ID>(
258
333
  (nodeId: ID, pageY: number, locationY: number, nodeIndex: number) => {
259
334
  if (!dragEnabled) return;
260
335
 
336
+ // Check if this node can be dragged
337
+ if (canDragRef.current) {
338
+ const node = flattenedNodesRef.current[nodeIndex];
339
+ if (node && !canDragRef.current(node)) return;
340
+ }
341
+
261
342
  // Cancel any existing timer
262
343
  cancelLongPressTimer();
263
344
 
@@ -281,7 +362,7 @@ export function useDragDrop<ID>(
281
362
  scrollOffsetRef.current + autoScrollSpeedRef.current
282
363
  );
283
364
  scrollOffsetRef.current = newOffset;
284
- (flashListRef.current as any)?.scrollToOffset?.({
365
+ flashListRef.current?.scrollToOffset?.({
285
366
  offset: newOffset,
286
367
  animated: false,
287
368
  });
@@ -302,8 +383,8 @@ export function useDragDrop<ID>(
302
383
 
303
384
  const updateAutoScroll = useCallback(
304
385
  (fingerInContainer: number) => {
305
- const threshold = autoScrollThreshold;
306
- const maxSpeed = 8 * autoScrollSpeed;
386
+ const threshold = autoScrollThresholdRef.current;
387
+ const maxSpeed = 8 * autoScrollSpeedParamRef.current;
307
388
  const containerH = containerHeightRef.current;
308
389
 
309
390
  if (fingerInContainer < threshold) {
@@ -319,7 +400,7 @@ export function useDragDrop<ID>(
319
400
  autoScrollSpeedRef.current = 0;
320
401
  }
321
402
  },
322
- [autoScrollThreshold, autoScrollSpeed]
403
+ []
323
404
  );
324
405
 
325
406
  // --- Cancel auto-expand timer ---
@@ -365,6 +446,26 @@ export function useDragDrop<ID>(
365
446
  position = "inside";
366
447
  }
367
448
 
449
+ // --- Determine if "inside" drop is allowed for this target ---
450
+ const canDropInsideTarget = (() => {
451
+ // canNodeHaveChildren: structural constraint
452
+ if (canNodeHaveChildrenRef.current && !canNodeHaveChildrenRef.current(targetNode)) {
453
+ return false;
454
+ }
455
+ // maxDepth: the dragged subtree at (targetLevel + 1) must not exceed maxDepth
456
+ if (maxDepthRef.current !== undefined) {
457
+ const targetLevel = targetNode.level ?? 0;
458
+ const deepest = targetLevel + 1 + draggedSubtreeDepthRef.current;
459
+ if (deepest > maxDepthRef.current) return false;
460
+ }
461
+ return true;
462
+ })();
463
+
464
+ // If "inside" is not allowed, convert to nearest zone
465
+ if (position === "inside" && !canDropInsideTarget) {
466
+ position = positionInItem < 0.5 ? "above" : "below";
467
+ }
468
+
368
469
  // --- Horizontal control at level cliffs ---
369
470
  // At the boundary between nodes at different depths, the user's
370
471
  // horizontal finger position decides the drop level:
@@ -391,16 +492,17 @@ export function useDragDrop<ID>(
391
492
  shallowLevel = nextLevel;
392
493
  }
393
494
  } else if (currentLevel > 0) {
394
- // Last item in the list treat as cliff to root level
495
+ // Last item in the list - treat as cliff to root level
395
496
  isCliff = true;
396
497
  shallowLevel = 0;
397
498
  }
398
499
 
399
500
  if (isCliff) {
400
501
  // Generous threshold: midpoint of the two levels + 2× indent buffer
502
+ const indent = indentationMultiplierRef.current;
401
503
  const threshold =
402
- ((currentLevel + shallowLevel) / 2) * indentationMultiplier
403
- + indentationMultiplier * 2;
504
+ ((currentLevel + shallowLevel) / 2) * indent
505
+ + indent * 2;
404
506
 
405
507
  if (fingerLocalX < threshold) {
406
508
  // User wants the shallow level
@@ -434,10 +536,11 @@ export function useDragDrop<ID>(
434
536
  const prevLevel = prevNode?.level ?? 0;
435
537
  const currentLevel = targetNode.level ?? 0;
436
538
  if (prevNode && prevLevel > currentLevel) {
437
- // Level cliff above same generous threshold
539
+ // Level cliff above - same generous threshold
540
+ const indent = indentationMultiplierRef.current;
438
541
  const threshold =
439
- ((prevLevel + currentLevel) / 2) * indentationMultiplier
440
- + indentationMultiplier * 2;
542
+ ((prevLevel + currentLevel) / 2) * indent
543
+ + indent * 2;
441
544
 
442
545
  if (fingerLocalX >= threshold) {
443
546
  clampedIndex = clampedIndex - 1;
@@ -450,7 +553,8 @@ export function useDragDrop<ID>(
450
553
  // --- Suppress "below" when it's redundant or confusing ---
451
554
  // After horizontal control, any remaining "below" that isn't at a
452
555
  // cliff is redundant with "above" on the next node → show "inside".
453
- if (position === "below") {
556
+ // Only convert to "inside" if inside drops are allowed for this target.
557
+ if (position === "below" && canDropInsideTarget) {
454
558
  const expandedSet = getTreeViewStore<ID>(storeId).getState().expanded;
455
559
 
456
560
  // (a) Expanded parent: "below" visually sits at the parent/child junction
@@ -485,10 +589,10 @@ export function useDragDrop<ID>(
485
589
  const lowerLevel = nodes[lowerIdx]?.level ?? 0;
486
590
 
487
591
  if (upperLevel === lowerLevel) {
488
- // Same level pure visual hysteresis, keep previous
592
+ // Same level - pure visual hysteresis, keep previous
489
593
  return;
490
594
  }
491
- // Level cliff horizontal control already resolved this,
595
+ // Level cliff - horizontal control already resolved this,
492
596
  // let the result pass through.
493
597
  }
494
598
  }
@@ -500,14 +604,29 @@ export function useDragDrop<ID>(
500
604
  const store = getTreeViewStore<ID>(storeId);
501
605
  const { invalidDragTargetIds, draggedNodeId, expanded } =
502
606
  store.getState();
607
+
608
+ // maxDepth check for above/below (sibling) positions
609
+ let maxDepthValid = true;
610
+ if (maxDepthRef.current !== undefined && (position === "above" || position === "below")) {
611
+ const targetLevel = targetNode.level ?? 0;
612
+ const deepest = targetLevel + draggedSubtreeDepthRef.current;
613
+ if (deepest > maxDepthRef.current) maxDepthValid = false;
614
+ }
615
+
503
616
  const isValid =
504
617
  targetNode.id !== draggedNodeId &&
505
- !invalidDragTargetIds.has(targetNode.id);
618
+ !invalidDragTargetIds.has(targetNode.id) &&
619
+ maxDepthValid &&
620
+ (!canDropRef.current || canDropRef.current(
621
+ draggedNodeRef.current!,
622
+ targetNode,
623
+ position
624
+ ));
506
625
 
507
626
  // --- Auto-expand: if hovering "inside" a collapsed expandable node ---
508
627
  if (isValid && position === "inside" && targetNode.children?.length && !expanded.has(targetNode.id)) {
509
628
  if (autoExpandTargetRef.current !== targetNode.id) {
510
- // New hover target start timer
629
+ // New hover target - start timer
511
630
  cancelAutoExpandTimer();
512
631
  autoExpandTargetRef.current = targetNode.id;
513
632
  autoExpandTimerRef.current = setTimeout(() => {
@@ -515,10 +634,10 @@ export function useDragDrop<ID>(
515
634
  // Expand the node and track it
516
635
  handleToggleExpand(storeId, targetNode.id);
517
636
  autoExpandedDuringDragRef.current.add(targetNode.id);
518
- }, autoExpandDelay);
637
+ }, autoExpandDelayRef.current);
519
638
  }
520
639
  } else {
521
- // Not hovering inside a collapsed expandable node cancel timer
640
+ // Not hovering inside a collapsed expandable node - cancel timer
522
641
  if (autoExpandTargetRef.current !== null) {
523
642
  cancelAutoExpandTimer();
524
643
  }
@@ -547,7 +666,7 @@ export function useDragDrop<ID>(
547
666
  // then spring to 0 for a smooth "magnetic snap" transition.
548
667
  if (prevLevel !== effectiveLevel) {
549
668
  overlayX.setValue(
550
- (prevLevel - effectiveLevel) * indentationMultiplier
669
+ (prevLevel - effectiveLevel) * indentationMultiplierRef.current
551
670
  );
552
671
  Animated.spring(overlayX, {
553
672
  toValue: 0,
@@ -599,7 +718,7 @@ export function useDragDrop<ID>(
599
718
  return newTarget;
600
719
  });
601
720
  },
602
- [storeId, autoExpandDelay, cancelAutoExpandTimer, indentationMultiplier, overlayX]
721
+ [storeId, cancelAutoExpandTimer, overlayX]
603
722
  );
604
723
 
605
724
  // --- Handle drag end ---
@@ -673,18 +792,35 @@ export function useDragDrop<ID>(
673
792
  newTreeData: newData,
674
793
  });
675
794
 
676
- // Scroll to the dropped node after React processes the expansion
795
+ // Scroll to the dropped node after React processes the expansion,
796
+ // but only if it's outside the visible viewport. An animated
797
+ // scroll would consume the user's next touch (RN stops the
798
+ // animation on tap), so we skip when the node is already visible.
677
799
  setTimeout(() => {
678
800
  const nodes = flattenedNodesRef.current;
679
801
  const idx = nodes.findIndex(n => n.id === droppedNodeId);
680
- if (idx >= 0) {
681
- flashListRef.current?.scrollToIndex?.({
682
- index: idx,
683
- animated: true,
684
- viewPosition: 0.5,
685
- });
802
+ if (idx < 0) return;
803
+
804
+ const itemH = itemHeightRef.current;
805
+ const scrollTop = scrollOffsetRef.current;
806
+ const containerH = containerHeightRef.current;
807
+ const estimatedTop = idx * itemH;
808
+ const estimatedBottom = estimatedTop + itemH;
809
+
810
+ // Already in view → no scroll needed
811
+ if (estimatedTop >= scrollTop && estimatedBottom <= scrollTop + containerH) {
812
+ return;
686
813
  }
814
+
815
+ flashListRef.current?.scrollToIndex?.({
816
+ index: idx,
817
+ animated: true,
818
+ viewPosition: 0.5,
819
+ });
687
820
  }, 100);
821
+ } else if (droppedNodeId !== null) {
822
+ // Drag ended without a valid drop — notify consumer
823
+ onDragCancelRef.current?.({ draggedNodeId: droppedNodeId });
688
824
  }
689
825
 
690
826
  // Collapse auto-expanded nodes that aren't ancestors of the drop target
@@ -777,13 +913,13 @@ export function useDragDrop<ID>(
777
913
 
778
914
  // Update overlay position (with configurable offset)
779
915
  const overlayLocalY =
780
- fingerLocalY - grabOffsetYRef.current + dragOverlayOffset * itemHeightRef.current;
916
+ fingerLocalY - grabOffsetYRef.current + dragOverlayOffsetRef.current * itemHeightRef.current;
781
917
  overlayY.setValue(overlayLocalY);
782
918
 
783
919
  // Calculate drop target (horizontal position used at level cliffs)
784
920
  calculateDropTarget(fingerPageY, evt.nativeEvent.pageX);
785
921
 
786
- // Auto-scroll at edges use delta-based position relative to container
922
+ // Auto-scroll at edges - use delta-based position relative to container
787
923
  const fingerInContainer =
788
924
  initialFingerContainerYRef.current +
789
925
  (fingerPageY - initialFingerPageYRef.current);
@@ -1,7 +1,7 @@
1
1
  /**
2
- * ScrollToNodeHandler Component
2
+ * useScrollToNode Hook
3
3
  *
4
- * This component provides an imperative handle to scroll to a specified node within a tree view.
4
+ * Provides an imperative handle to scroll to a specified node within a tree view.
5
5
  * The scrolling action is orchestrated via a two-step "milestone" mechanism that ensures the target
6
6
  * node is both expanded in the tree and that the rendered list reflects this expansion before the scroll
7
7
  * is performed.
@@ -32,22 +32,23 @@
32
32
  * in the UI, thus preventing issues with attempting to scroll to an element that does not exist yet.
33
33
  */
34
34
 
35
- import React from "react";
35
+ import {
36
+ useEffect,
37
+ useImperativeHandle,
38
+ useLayoutEffect,
39
+ useRef,
40
+ useState,
41
+ type Dispatch,
42
+ type MutableRefObject,
43
+ type RefObject,
44
+ type SetStateAction,
45
+ } from "react";
36
46
  import { expandNodes } from "../helpers/expandCollapse.helper";
37
47
  import { useTreeViewStore } from "../store/treeView.store";
38
48
  import { useShallow } from "zustand/react/shallow";
39
49
  import { type __FlattenedTreeNode__ } from "../types/treeView.types";
40
- import { typedMemo } from "../utils/typedMemo";
41
50
  import { fastIsEqual } from "fast-is-equal";
42
51
 
43
- interface Props<ID> {
44
- storeId: string;
45
- flashListRef: React.MutableRefObject<any>;
46
- flattenedFilteredNodes: __FlattenedTreeNode__<ID>[];
47
- setInitialScrollIndex: React.Dispatch<React.SetStateAction<number>>;
48
- initialScrollNodeID: ID | undefined;
49
- }
50
-
51
52
  export interface ScrollToNodeParams<ID> {
52
53
  nodeId: ID;
53
54
  expandScrolledNode?: boolean;
@@ -57,27 +58,34 @@ export interface ScrollToNodeParams<ID> {
57
58
  viewPosition?: number;
58
59
  }
59
60
 
61
+ export interface ScrollToNodeHandlerRef<ID> {
62
+ scrollToNodeID: (params: ScrollToNodeParams<ID>) => void;
63
+ }
64
+
60
65
  // Enum representing the two milestones needed before scrolling
61
66
  enum ExpandQueueAction {
62
67
  EXPANDED,
63
68
  RENDERED,
64
69
  }
65
70
 
66
- export interface ScrollToNodeHandlerRef<ID> {
67
- scrollToNodeID: (params: ScrollToNodeParams<ID>) => void;
71
+ interface UseScrollToNodeParams<ID> {
72
+ storeId: string;
73
+ scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID>>;
74
+ flashListRef: MutableRefObject<any>;
75
+ flattenedFilteredNodes: __FlattenedTreeNode__<ID>[];
76
+ setInitialScrollIndex: Dispatch<SetStateAction<number>>;
77
+ initialScrollNodeID: ID | undefined;
68
78
  }
69
79
 
70
- function _innerScrollToNodeHandler<ID>(
71
- props: Props<ID>,
72
- ref: React.ForwardedRef<ScrollToNodeHandlerRef<ID>>
73
- ) {
80
+ export function useScrollToNode<ID>(params: UseScrollToNodeParams<ID>) {
74
81
  const {
75
82
  storeId,
83
+ scrollToNodeHandlerRef,
76
84
  flashListRef,
77
85
  flattenedFilteredNodes,
78
86
  setInitialScrollIndex,
79
87
  initialScrollNodeID
80
- } = props;
88
+ } = params;
81
89
 
82
90
  const { expanded, childToParentMap } = useTreeViewStore<ID>(storeId)(useShallow(
83
91
  state => ({
@@ -86,9 +94,16 @@ function _innerScrollToNodeHandler<ID>(
86
94
  })
87
95
  ));
88
96
 
89
- React.useImperativeHandle(ref, () => ({
90
- scrollToNodeID: (params: ScrollToNodeParams<ID>) => {
91
- queuedScrollToNodeParams.current = params;
97
+ // Ref to store the scroll parameters for the queued action.
98
+ const queuedScrollToNodeParams = useRef<ScrollToNodeParams<ID> | null>(null);
99
+
100
+ // State to track progression: first the expansion is triggered, then the list is rendered.
101
+ const [expandAndScrollToNodeQueue, setExpandAndScrollToNodeQueue]
102
+ = useState<ExpandQueueAction[]>([]);
103
+
104
+ useImperativeHandle(scrollToNodeHandlerRef, () => ({
105
+ scrollToNodeID: (scrollParams: ScrollToNodeParams<ID>) => {
106
+ queuedScrollToNodeParams.current = scrollParams;
92
107
  // Mark that expansion is initiated.
93
108
  setExpandAndScrollToNodeQueue([ExpandQueueAction.EXPANDED]);
94
109
  // Trigger expansion logic (this may update the store and subsequently re-render the list).
@@ -100,18 +115,11 @@ function _innerScrollToNodeHandler<ID>(
100
115
  }
101
116
  }), [storeId]);
102
117
 
103
- // Ref to store the scroll parameters for the queued action.
104
- const queuedScrollToNodeParams = React.useRef<ScrollToNodeParams<ID> | null>(null);
105
-
106
- // State to track progression: first the expansion is triggered, then the list is rendered.
107
- const [expandAndScrollToNodeQueue, setExpandAndScrollToNodeQueue]
108
- = React.useState<ExpandQueueAction[]>([]);
109
-
110
- const latestFlattenedFilteredNodesRef = React.useRef(flattenedFilteredNodes);
118
+ const latestFlattenedFilteredNodesRef = useRef(flattenedFilteredNodes);
111
119
 
112
120
  /* When the rendered node list changes, update the ref.
113
121
  If an expansion was triggered, mark that the list is now rendered. */
114
- React.useEffect(() => {
122
+ useEffect(() => {
115
123
  setExpandAndScrollToNodeQueue(prevQueue => {
116
124
  if (prevQueue.includes(ExpandQueueAction.EXPANDED)) {
117
125
  latestFlattenedFilteredNodesRef.current = flattenedFilteredNodes;
@@ -127,7 +135,7 @@ function _innerScrollToNodeHandler<ID>(
127
135
 
128
136
  /* Once the target node is expanded and the list is updated (milestones reached),
129
137
  perform the scroll using the latest node list. */
130
- React.useLayoutEffect(() => {
138
+ useLayoutEffect(() => {
131
139
  if (queuedScrollToNodeParams.current === null)
132
140
  return;
133
141
 
@@ -146,12 +154,16 @@ function _innerScrollToNodeHandler<ID>(
146
154
  parentId = childToParentMap.get(queuedScrollToNodeParams.current.nodeId) as ID;
147
155
  }
148
156
 
149
- // Ensure if the parent is expanded before proceeding to scroll to the node
157
+ // Ensure if the parent is expanded before proceeding to scroll to the node.
158
+ // This fires transiently during the milestone system — the layout effect runs
159
+ // before the expansion has propagated to the store, then retries on next render.
160
+ /* istanbul ignore next -- async timing guard: expansion not yet propagated to store */
150
161
  if (parentId && !expanded.has(parentId))
151
162
  return;
152
163
  }
153
164
  // If node is set to expand
154
165
  else {
166
+ /* istanbul ignore next -- async timing guard: node expansion not yet propagated */
155
167
  if (!expanded.has(queuedScrollToNodeParams.current.nodeId))
156
168
  return;
157
169
  }
@@ -177,6 +189,7 @@ function _innerScrollToNodeHandler<ID>(
177
189
  viewPosition
178
190
  });
179
191
  } else {
192
+ /* istanbul ignore next -- __DEV__ is false in test/production */
180
193
  if (__DEV__) {
181
194
  console.info("Cannot find the item of the mentioned id to scroll in the rendered tree view list data!");
182
195
  }
@@ -193,8 +206,8 @@ function _innerScrollToNodeHandler<ID>(
193
206
  ////////////////////////////// Handle Initial Scroll /////////////////////////////
194
207
  /* On first render, if an initial scroll target is provided, determine its index.
195
208
  This is done only once. */
196
- const initialScrollDone = React.useRef(false);
197
- React.useLayoutEffect(() => {
209
+ const initialScrollDone = useRef(false);
210
+ useLayoutEffect(() => {
198
211
  if (initialScrollDone.current) return;
199
212
 
200
213
  const index = flattenedFilteredNodes.findIndex(
@@ -209,14 +222,4 @@ function _innerScrollToNodeHandler<ID>(
209
222
  // eslint-disable-next-line react-hooks/exhaustive-deps
210
223
  }, [flattenedFilteredNodes, initialScrollNodeID]);
211
224
  /////////////////////////////////////////////////////////////////////////////////
212
-
213
- return null;
214
225
  }
215
-
216
- const _ScrollToNodeHandler = React.forwardRef(_innerScrollToNodeHandler) as <ID>(
217
- props: Props<ID> & { ref?: React.ForwardedRef<ScrollToNodeHandlerRef<ID>>; }
218
- ) => ReturnType<typeof _innerScrollToNodeHandler>;
219
-
220
- export const ScrollToNodeHandler = typedMemo<
221
- typeof _ScrollToNodeHandler
222
- >(_ScrollToNodeHandler);