ngx-virtual-dnd 3.1.2 → 3.1.3

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, isDevMode, input, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Injector, afterNextRender, viewChild, Directive, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
2
+ import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Injector, afterNextRender, viewChild, Directive, isDevMode, output, untracked, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
3
  import { NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
@@ -486,10 +486,14 @@ class DragStateService {
486
486
  #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
487
487
  /** Index where the placeholder should be inserted — updated on each placeholder move */
488
488
  #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
489
+ /** Snapshot captured synchronously immediately before the last drag state reset. */
490
+ #endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
489
491
  /** Flag indicating if the last drag was cancelled (not dropped) */
490
492
  #wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
491
493
  /** Whether the last drag was cancelled (for droppable to check before emitting drop) */
492
494
  wasCancelled = this.#wasCancelled.asReadonly();
495
+ /** Snapshot captured synchronously before the last drag reset. */
496
+ endedDragState = this.#endedDragState.asReadonly();
493
497
  /** Whether a drag operation is in progress */
494
498
  isDragging = computed(() => this.#state().isDragging, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
495
499
  /** The currently dragged item, or null */
@@ -546,7 +550,8 @@ class DragStateService {
546
550
  * Start a drag operation.
547
551
  */
548
552
  startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
549
- // Reset cancellation flag at start of new drag
553
+ // Reset terminal drag metadata at start of new drag
554
+ this.#endedDragState.set(null);
550
555
  this.#wasCancelled.set(false);
551
556
  this.#cursorPosition.set(cursorPosition ?? null);
552
557
  this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
@@ -616,6 +621,7 @@ class DragStateService {
616
621
  * End the drag operation and reset state (normal drop).
617
622
  */
618
623
  endDrag() {
624
+ this.#endedDragState.set(this.getStateSnapshot());
619
625
  this.#wasCancelled.set(false);
620
626
  this.#resetHighFrequencySignals();
621
627
  this.#state.set(INITIAL_CORE_STATE);
@@ -624,6 +630,7 @@ class DragStateService {
624
630
  * Cancel the drag operation (escape key, disabled, etc.).
625
631
  */
626
632
  cancelDrag() {
633
+ this.#endedDragState.set(this.getStateSnapshot());
627
634
  this.#wasCancelled.set(true);
628
635
  this.#resetHighFrequencySignals();
629
636
  this.#state.set(INITIAL_CORE_STATE);
@@ -739,6 +746,8 @@ class PositionCalculatorService {
739
746
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
740
747
  /** Data attribute used to identify droppable groups */
741
748
  #DROPPABLE_GROUP_ATTR = 'data-droppable-group';
749
+ /** Data attribute reflecting a droppable's disabled state (present === disabled) */
750
+ #DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
742
751
  /** Data attribute used to identify draggable elements */
743
752
  #DRAGGABLE_ID_ATTR = 'data-draggable-id';
744
753
  /** Maximum DOM levels to traverse when looking for parent elements */
@@ -766,10 +775,15 @@ class PositionCalculatorService {
766
775
  this.#session = {
767
776
  groupName,
768
777
  candidates,
769
- rects: candidates.map((el) => el.getBoundingClientRect()),
778
+ rects: candidates.map((el) => this.#measureRect(el)),
770
779
  dirty: false,
780
+ candidatesStale: false,
771
781
  onViewportChange,
782
+ resizeObserver: this.#createResizeObserver(),
772
783
  };
784
+ // A container-only reflow (an element resize that fires no window scroll/resize)
785
+ // would otherwise leave stale rects — observe the candidates so it marks them dirty.
786
+ this.#observeCandidates(this.#session);
773
787
  // Capture-phase scroll catches scrolling on any ancestor scroller (scroll does
774
788
  // not bubble); resize covers viewport changes. Both only mark rects dirty —
775
789
  // the actual re-read is deferred to the next hit-test.
@@ -790,6 +804,7 @@ class PositionCalculatorService {
790
804
  return;
791
805
  }
792
806
  this.#session = null;
807
+ session.resizeObserver?.disconnect();
793
808
  if (typeof window !== 'undefined') {
794
809
  window.removeEventListener('scroll', session.onViewportChange, { capture: true });
795
810
  window.removeEventListener('resize', session.onViewportChange);
@@ -805,6 +820,39 @@ class PositionCalculatorService {
805
820
  this.#session.dirty = true;
806
821
  }
807
822
  }
823
+ /**
824
+ * Re-query the candidate droppable *list* for the active session and re-read their
825
+ * rects. Unlike {@link invalidateDroppableRects} (which only refreshes rects), this
826
+ * picks up droppables added or removed since the snapshot was captured at drag start —
827
+ * the frozen candidate list is otherwise blind to mid-drag mounts/unmounts.
828
+ *
829
+ * Safe to call when no session is active (no-op).
830
+ */
831
+ refreshCandidates() {
832
+ const session = this.#session;
833
+ if (!session) {
834
+ return;
835
+ }
836
+ session.candidates = this.#queryDroppables(session.groupName);
837
+ session.rects = session.candidates.map((el) => this.#measureRect(el));
838
+ session.dirty = false;
839
+ session.candidatesStale = false;
840
+ this.#observeCandidates(session);
841
+ }
842
+ /**
843
+ * Signal that the set of droppables for `groupName` may have changed (a droppable
844
+ * registered or unregistered mid-drag). Defers the actual re-query to the next
845
+ * hit-test — where a removed element is already gone from the DOM and a newly added
846
+ * one has its data attributes applied — keeping this notification path cheap.
847
+ *
848
+ * Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
849
+ * belongs to a different group or no session is active.
850
+ */
851
+ notifyCandidatesChanged(groupName) {
852
+ if (this.#session && this.#session.groupName === groupName) {
853
+ this.#session.candidatesStale = true;
854
+ }
855
+ }
808
856
  /**
809
857
  * Find the droppable element at a given point.
810
858
  *
@@ -823,9 +871,13 @@ class PositionCalculatorService {
823
871
  findDroppableAtPoint(x, y, _draggedElement, groupName) {
824
872
  const session = this.#session;
825
873
  if (session && session.groupName === groupName) {
826
- if (session.dirty) {
874
+ if (session.candidatesStale) {
875
+ // A droppable was added/removed mid-drag: re-query the list (also refreshes rects).
876
+ this.refreshCandidates();
877
+ }
878
+ else if (session.dirty) {
827
879
  for (let i = 0; i < session.candidates.length; i++) {
828
- session.rects[i] = session.candidates[i].getBoundingClientRect();
880
+ session.rects[i] = this.#measureRect(session.candidates[i]);
829
881
  }
830
882
  session.dirty = false;
831
883
  }
@@ -833,7 +885,7 @@ class PositionCalculatorService {
833
885
  }
834
886
  // No active session: one-shot geometric query (still avoids elementFromPoint).
835
887
  const candidates = this.#queryDroppables(groupName);
836
- const rects = candidates.map((el) => el.getBoundingClientRect());
888
+ const rects = candidates.map((el) => this.#measureRect(el));
837
889
  return this.#hitTest(x, y, candidates, rects);
838
890
  }
839
891
  /**
@@ -861,6 +913,11 @@ class PositionCalculatorService {
861
913
  }
862
914
  /**
863
915
  * Query all droppable elements belonging to a group, in document order.
916
+ *
917
+ * All group members are included — the disabled check happens per-frame in
918
+ * {@link #hitTest} rather than here, because a droppable can be disabled or
919
+ * re-enabled mid-drag (e.g. a consumer disabling incompatible targets from a
920
+ * `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
864
921
  */
865
922
  #queryDroppables(groupName) {
866
923
  if (typeof document === 'undefined') {
@@ -868,18 +925,88 @@ class PositionCalculatorService {
868
925
  }
869
926
  return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
870
927
  }
928
+ /**
929
+ * Measure a candidate's hit-test rect, clipped to its nearest `.vdnd-scrollable`
930
+ * ancestor. Without clipping a droppable scrolled mostly out of a clipping container
931
+ * still hit-tests over its full unclipped rect (issue #23 case 3). The intersection is
932
+ * built as a plain DOMRect; an empty intersection yields a negative width/height so the
933
+ * `#hitTest` bounds check can never match it.
934
+ */
935
+ #measureRect(el) {
936
+ const rect = el.getBoundingClientRect();
937
+ const scrollable = el.closest('.vdnd-scrollable');
938
+ if (!scrollable || scrollable === el) {
939
+ return rect;
940
+ }
941
+ const clip = scrollable.getBoundingClientRect();
942
+ const top = Math.max(rect.top, clip.top);
943
+ const left = Math.max(rect.left, clip.left);
944
+ const right = Math.min(rect.right, clip.right);
945
+ const bottom = Math.min(rect.bottom, clip.bottom);
946
+ return new DOMRect(left, top, right - left, bottom - top);
947
+ }
948
+ /**
949
+ * Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
950
+ * cached rects dirty when a candidate resizes. Returns null when ResizeObserver is
951
+ * unavailable (SSR / jsdom) so callers degrade gracefully.
952
+ */
953
+ #createResizeObserver() {
954
+ if (typeof ResizeObserver === 'undefined') {
955
+ return null;
956
+ }
957
+ return this.#ngZone.runOutsideAngular(() => new ResizeObserver(() => {
958
+ if (this.#session) {
959
+ this.#session.dirty = true;
960
+ }
961
+ }));
962
+ }
963
+ /**
964
+ * Point the session's ResizeObserver at the current candidate list, dropping any
965
+ * previously observed elements. Called on session start and whenever the candidate
966
+ * list is refreshed.
967
+ */
968
+ #observeCandidates(session) {
969
+ const observer = session.resizeObserver;
970
+ if (!observer) {
971
+ return;
972
+ }
973
+ this.#ngZone.runOutsideAngular(() => {
974
+ observer.disconnect();
975
+ for (const candidate of session.candidates) {
976
+ observer.observe(candidate);
977
+ }
978
+ });
979
+ }
980
+ /**
981
+ * Whether a droppable element is currently disabled (reflected via the
982
+ * `data-droppable-disabled` attribute by `DroppableDirective`). Read live on each
983
+ * hit-test / navigation step so disabled↔enabled transitions during an active drag
984
+ * take effect immediately. Disabled droppables are excluded from all drag-time
985
+ * candidate sets — pointer hit-testing and keyboard cross-list navigation.
986
+ */
987
+ #isDroppableDisabled(el) {
988
+ return el.hasAttribute(this.#DROPPABLE_DISABLED_ATTR);
989
+ }
871
990
  /**
872
991
  * Geometric hit-test: return the last candidate (in document order) whose rect
873
992
  * contains the point. "Last in document order" reproduces the painter's-order
874
993
  * tie-break that `elementFromPoint` provided for free — a nested or later
875
994
  * overlapping droppable is painted on top of an earlier/ancestor one.
995
+ *
996
+ * Currently-disabled candidates are skipped here (not filtered from the snapshot) so
997
+ * that toggling `disabled` mid-drag takes effect on the very next frame. The check is
998
+ * a cheap `hasAttribute` read that does not force layout, keeping the loop hot.
876
999
  */
877
1000
  #hitTest(x, y, candidates, rects) {
878
1001
  let match = null;
879
1002
  for (let i = 0; i < candidates.length; i++) {
1003
+ const candidate = candidates[i];
1004
+ if (this.#isDroppableDisabled(candidate)) {
1005
+ continue;
1006
+ }
880
1007
  const r = rects[i];
881
1008
  if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
882
- match = candidates[i];
1009
+ match = candidate;
883
1010
  }
884
1011
  }
885
1012
  return match;
@@ -1018,12 +1145,15 @@ class PositionCalculatorService {
1018
1145
  * @returns Object with droppable info, or null if none found
1019
1146
  */
1020
1147
  findAdjacentDroppable(currentDroppableId, direction, groupName) {
1021
- // Find all droppables in the same group
1148
+ // Include disabled droppables when establishing left-to-right order and locating the
1149
+ // current container — otherwise a container disabled mid-drag (its own index becomes
1150
+ // -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
1151
+ // as *targets* during the outward scan below instead.
1022
1152
  const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1023
1153
  if (allDroppables.length <= 1) {
1024
1154
  return null;
1025
1155
  }
1026
- // Get bounding rects and IDs, sorted by X position
1156
+ // Get bounding rects, IDs, and disabled state, sorted by X position
1027
1157
  const droppableInfos = [];
1028
1158
  allDroppables.forEach((el) => {
1029
1159
  const htmlEl = el;
@@ -1033,56 +1163,41 @@ class PositionCalculatorService {
1033
1163
  element: htmlEl,
1034
1164
  id,
1035
1165
  rect: htmlEl.getBoundingClientRect(),
1166
+ disabled: this.#isDroppableDisabled(htmlEl),
1036
1167
  });
1037
1168
  }
1038
1169
  });
1039
1170
  // Sort by X position (left to right)
1040
1171
  droppableInfos.sort((a, b) => a.rect.left - b.rect.left);
1041
- // Find current droppable index
1172
+ // Find current droppable index (among all, including a now-disabled current container)
1042
1173
  const currentIndex = droppableInfos.findIndex((d) => d.id === currentDroppableId);
1043
1174
  if (currentIndex === -1) {
1044
1175
  return null;
1045
1176
  }
1046
- // Get the adjacent droppable
1047
- const targetIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1;
1048
- if (targetIndex < 0 || targetIndex >= droppableInfos.length) {
1049
- return null;
1177
+ // Scan outward in the requested direction, skipping disabled droppables, until the
1178
+ // first enabled neighbour (or run off the end of the list).
1179
+ const step = direction === 'left' ? -1 : 1;
1180
+ for (let i = currentIndex + step; i >= 0 && i < droppableInfos.length; i += step) {
1181
+ const target = droppableInfos[i];
1182
+ if (target.disabled) {
1183
+ continue;
1184
+ }
1185
+ return {
1186
+ element: target.element,
1187
+ id: target.id,
1188
+ };
1050
1189
  }
1051
- const target = droppableInfos[targetIndex];
1052
- // Get item count from the target droppable
1053
- const itemCount = this.#getDroppableItemCount(target.element);
1054
- return {
1055
- element: target.element,
1056
- id: target.id,
1057
- itemCount,
1058
- };
1190
+ return null;
1059
1191
  }
1060
1192
  /**
1061
- * Get the total item count in a droppable.
1062
- * Uses spacer height and data attributes if available, otherwise counts DOM elements.
1193
+ * Whether the droppable with the given ID is currently disabled. A droppable that cannot
1194
+ * be found is reported as disabled (treat missing as not a valid target). Used to
1195
+ * revalidate a keyboard drag's active target at drop time, since it may have been disabled
1196
+ * after the drag navigated into it.
1063
1197
  */
1064
- #getDroppableItemCount(droppableElement) {
1065
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
1066
- if (virtualScroll) {
1067
- const configuredHeight = virtualScroll.getAttribute('data-item-height');
1068
- if (!configuredHeight) {
1069
- if (isDevMode()) {
1070
- console.error('[ngx-virtual-dnd] vdnd-virtual-scroll requires data-item-height attribute ' +
1071
- 'for keyboard navigation. Cross-list keyboard drag will not work correctly.');
1072
- }
1073
- // Short-circuit: return 0 to prevent navigation to this droppable
1074
- return 0;
1075
- }
1076
- // Prefer spacer height over scrollHeight for accuracy
1077
- const spacer = virtualScroll.querySelector('.vdnd-virtual-scroll-spacer');
1078
- const totalHeight = spacer
1079
- ? parseFloat(spacer.style.height) || 0
1080
- : virtualScroll.scrollHeight;
1081
- const itemHeight = parseInt(configuredHeight, 10);
1082
- return Math.floor(totalHeight / itemHeight);
1083
- }
1084
- // Fallback for non-virtual scroll: DOM count is valid
1085
- return droppableElement.querySelectorAll(`[${this.#DRAGGABLE_ID_ATTR}]`).length;
1198
+ isDroppableDisabledById(id) {
1199
+ const element = this.getDroppableById(id);
1200
+ return element === null || this.#isDroppableDisabled(element);
1086
1201
  }
1087
1202
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1088
1203
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: PositionCalculatorService, providedIn: 'root' });
@@ -1219,6 +1334,9 @@ const DEFAULT_CONFIG = {
1219
1334
  maxSpeed: 15,
1220
1335
  accelerate: true,
1221
1336
  };
1337
+ const BASE_FRAME_DURATION_MS = 1000 / 60;
1338
+ const MIN_SCROLL_FRAME_SCALE = 1;
1339
+ const MAX_SCROLL_FRAME_SCALE = 6;
1222
1340
  /**
1223
1341
  * Service that handles auto-scrolling when dragging near container edges.
1224
1342
  */
@@ -1228,6 +1346,13 @@ class AutoScrollService {
1228
1346
  #scheduler = inject(DragSchedulerService);
1229
1347
  /** Currently registered scrollable containers */
1230
1348
  #scrollableContainers = new Map();
1349
+ /**
1350
+ * Candidate containers ordered innermost (deepest in the DOM) first, so a
1351
+ * nested scrollable wins over its ancestors under the cursor. Rebuilt lazily
1352
+ * whenever registrations change (containers rarely move mid-drag) and once per
1353
+ * drag (see startMonitoring), not per frame.
1354
+ */
1355
+ #orderedContainers = null;
1231
1356
  /** Bound reference to #participantTick for stable add/remove with the scheduler. */
1232
1357
  #boundParticipantTick = () => this.#participantTick();
1233
1358
  /** Callback to invoke when scrolling occurs (for placeholder recalculation) */
@@ -1237,6 +1362,12 @@ class AutoScrollService {
1237
1362
  /** Last tick cursor position for stationary detection */
1238
1363
  #lastTickCursorX = NaN;
1239
1364
  #lastTickCursorY = NaN;
1365
+ /**
1366
+ * Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
1367
+ * Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
1368
+ * compensating slower frames that would otherwise under-scroll.
1369
+ */
1370
+ #lastScrollTimestamp = 0;
1240
1371
  /** Reusable direction object for tick loop (avoids per-frame allocation) */
1241
1372
  #tickDirection = { x: 0, y: 0 };
1242
1373
  /** Current scroll state */
@@ -1253,12 +1384,61 @@ class AutoScrollService {
1253
1384
  element,
1254
1385
  config: { ...DEFAULT_CONFIG, ...config },
1255
1386
  });
1387
+ this.#orderedContainers = null;
1256
1388
  }
1257
1389
  /**
1258
1390
  * Unregister a scrollable container.
1259
1391
  */
1260
1392
  unregisterContainer(id) {
1261
1393
  this.#scrollableContainers.delete(id);
1394
+ this.#orderedContainers = null;
1395
+ }
1396
+ /**
1397
+ * Build (or reuse) the candidate list ordered innermost-first by DOM depth.
1398
+ *
1399
+ * Sorting by absolute DOM depth (descending) is a *total* order, unlike an
1400
+ * `element.contains()` comparator which returns 0 for unrelated pairs and so
1401
+ * violates transitivity — with ≥3 containers a nested pair separated by an
1402
+ * unrelated one in registration order could end up never compared, leaving the
1403
+ * ancestor ahead of its descendant. Equal-depth (including unrelated) containers
1404
+ * keep registration order via the stable sort, so unrelated lists behave as before.
1405
+ */
1406
+ #getOrderedContainers() {
1407
+ if (this.#orderedContainers) {
1408
+ return this.#orderedContainers;
1409
+ }
1410
+ const entries = [];
1411
+ for (const [id, { element, config }] of this.#scrollableContainers) {
1412
+ // Registration is unconditional (directives can't expose DOM size as a signal, so
1413
+ // gating registration on it goes stale — see #27). Filter by live scroll geometry
1414
+ // here instead: this list is rebuilt per drag (startMonitoring nulls the cache) and
1415
+ // on register/unregister, so a container that grew scrollable after init is picked
1416
+ // up on the next drag, and one that shrank stops occupying a candidate slot.
1417
+ if (!this.#isScrollable(element)) {
1418
+ continue;
1419
+ }
1420
+ entries.push({ id, element, config, depth: this.#domDepth(element) });
1421
+ }
1422
+ // Deepest first; stable sort keeps registration order on ties.
1423
+ entries.sort((a, b) => b.depth - a.depth);
1424
+ this.#orderedContainers = entries;
1425
+ return entries;
1426
+ }
1427
+ /**
1428
+ * Whether the element currently has room to scroll on either axis. Read fresh each
1429
+ * time the candidate list is (re)built — never cached — so it always reflects the
1430
+ * live layout at drag start rather than a stale value captured at registration.
1431
+ */
1432
+ #isScrollable(element) {
1433
+ return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
1434
+ }
1435
+ /** Number of ancestor elements — the element's absolute depth in the DOM tree. */
1436
+ #domDepth(element) {
1437
+ let depth = 0;
1438
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
1439
+ depth++;
1440
+ }
1441
+ return depth;
1262
1442
  }
1263
1443
  /**
1264
1444
  * Start monitoring for auto-scroll.
@@ -1268,6 +1448,10 @@ class AutoScrollService {
1268
1448
  */
1269
1449
  startMonitoring(onScroll) {
1270
1450
  this.#onScrollCallback = onScroll ?? null;
1451
+ // Recompute containment ordering fresh per drag: a registered container may have
1452
+ // been reparented (portal/DOM move) since the cache was built, which register/
1453
+ // unregister alone wouldn't catch.
1454
+ this.#orderedContainers = null;
1271
1455
  this.#scheduler.addParticipant(this.#boundParticipantTick);
1272
1456
  }
1273
1457
  /**
@@ -1281,6 +1465,7 @@ class AutoScrollService {
1281
1465
  this.#cursorOverride = null;
1282
1466
  this.#lastTickCursorX = NaN;
1283
1467
  this.#lastTickCursorY = NaN;
1468
+ this.#lastScrollTimestamp = 0;
1284
1469
  this.#scrollState.containerId = null;
1285
1470
  this.#scrollState.direction.x = 0;
1286
1471
  this.#scrollState.direction.y = 0;
@@ -1317,7 +1502,12 @@ class AutoScrollService {
1317
1502
  this.#lastTickCursorX = cursor.x;
1318
1503
  this.#lastTickCursorY = cursor.y;
1319
1504
  let scrollPerformed = false;
1320
- for (const [id, { element, config }] of this.#scrollableContainers) {
1505
+ // Frame scale is shared across candidates within a tick; computed lazily on
1506
+ // the first scroll attempt so stationary/idle frames don't advance the clock.
1507
+ let frameScale = 0;
1508
+ // Candidates are ordered innermost-first: when the deepest container under the
1509
+ // cursor is exhausted at its boundary, the loop falls through to its ancestors.
1510
+ for (const { id, element, config } of this.#getOrderedContainers()) {
1321
1511
  const rect = element.getBoundingClientRect();
1322
1512
  const isInside = this.#positionCalculator.isInsideContainer(cursor, rect);
1323
1513
  if (!isInside) {
@@ -1351,16 +1541,31 @@ class AutoScrollService {
1351
1541
  const distanceRatio = maxDistance / config.threshold;
1352
1542
  speed = Math.min(config.maxSpeed, Math.max(1, config.maxSpeed * distanceRatio));
1353
1543
  }
1354
- this.#scrollState.containerId = id;
1355
- this.#scrollState.direction.x = direction.x;
1356
- this.#scrollState.direction.y = direction.y;
1357
- this.#scrollState.speed = speed;
1358
- this.#performScroll(element, direction, speed);
1359
- scrollPerformed = true;
1360
- break;
1544
+ if (frameScale === 0) {
1545
+ frameScale = this.#getFrameScale();
1546
+ }
1547
+ // #performScroll reports whether it actually moved the element. Only claim the
1548
+ // tick (and stop descending the candidate list) when a scroll really happened —
1549
+ // an exhausted container must not block its ancestors from scrolling.
1550
+ //
1551
+ // Axis independence is per-container: if this container scrolls on *either* axis
1552
+ // it claims the whole tick, so a corner where the inner container's Y is exhausted
1553
+ // but its X still moves will not additionally apply an ancestor's available Y that
1554
+ // same frame. This matches the issue's "break once a container scrolled" contract;
1555
+ // cross-container axis handoff is intentionally out of scope.
1556
+ const scrolled = this.#performScroll(element, direction, frameScale, speed);
1557
+ if (scrolled) {
1558
+ this.#scrollState.containerId = id;
1559
+ this.#scrollState.direction.x = direction.x;
1560
+ this.#scrollState.direction.y = direction.y;
1561
+ this.#scrollState.speed = speed;
1562
+ scrollPerformed = true;
1563
+ break;
1564
+ }
1361
1565
  }
1362
1566
  }
1363
1567
  if (!scrollPerformed) {
1568
+ this.#lastScrollTimestamp = 0;
1364
1569
  this.#scrollState.containerId = null;
1365
1570
  this.#scrollState.direction.x = 0;
1366
1571
  this.#scrollState.direction.y = 0;
@@ -1369,32 +1574,55 @@ class AutoScrollService {
1369
1574
  }
1370
1575
  /**
1371
1576
  * Perform the actual scroll operation.
1372
- */
1373
- #performScroll(element, direction, speed) {
1374
- const scrollX = direction.x * speed;
1375
- const scrollY = direction.y * speed;
1376
- // Check bounds before scrolling
1577
+ *
1578
+ * Each axis is evaluated independently: a boundary-exhausted axis is skipped
1579
+ * while the other axis still scrolls (e.g. at a corner where vertical is
1580
+ * maxed out but horizontal is available).
1581
+ *
1582
+ * @returns `true` when at least one axis actually scrolled, `false` when the
1583
+ * element was already at its boundary in every requested direction. The
1584
+ * caller uses this to fall through to ancestor containers.
1585
+ */
1586
+ #performScroll(element, direction, frameScale, speed) {
1587
+ const scrollX = direction.x * speed * frameScale;
1588
+ const scrollY = direction.y * speed * frameScale;
1589
+ // Integer bounds used only as a cheap fast path to skip the assignment.
1590
+ // scrollHeight/clientHeight are rounded integers while scrollTop is fractional on
1591
+ // WebKit and any non-integer zoom/DPR, so this max can overstate the true one by
1592
+ // up to ~1px. It must NOT decide whether a scroll happened — the browser clamps the
1593
+ // assignment, so a before/after comparison is the only reliable truth signal.
1594
+ // Without it, an element pinned at its fractional max reports `scrolled = true`,
1595
+ // permanently claims the tick (blocking ancestor fall-through) and fires the callback
1596
+ // every frame against unchanged offsets (the WebKit placeholder-drift family).
1377
1597
  const maxScrollY = element.scrollHeight - element.clientHeight;
1378
1598
  const maxScrollX = element.scrollWidth - element.clientWidth;
1379
- if (direction.y < 0 && element.scrollTop <= 0) {
1380
- return;
1381
- }
1382
- if (direction.y > 0 && element.scrollTop >= maxScrollY) {
1383
- return;
1384
- }
1385
- if (direction.x < 0 && element.scrollLeft <= 0) {
1386
- return;
1387
- }
1388
- if (direction.x > 0 && element.scrollLeft >= maxScrollX) {
1389
- return;
1390
- }
1599
+ let scrolled = false;
1391
1600
  // Use direct property assignment for guaranteed synchronous scroll
1392
1601
  // scrollBy() with 'instant' behavior may have async issues in Safari
1393
1602
  if (scrollY !== 0) {
1394
- element.scrollTop += scrollY;
1603
+ const canScrollUp = direction.y < 0 && element.scrollTop > 0;
1604
+ const canScrollDown = direction.y > 0 && element.scrollTop < maxScrollY;
1605
+ if (canScrollUp || canScrollDown) {
1606
+ const before = element.scrollTop;
1607
+ element.scrollTop += scrollY;
1608
+ if (element.scrollTop !== before) {
1609
+ scrolled = true;
1610
+ }
1611
+ }
1395
1612
  }
1396
1613
  if (scrollX !== 0) {
1397
- element.scrollLeft += scrollX;
1614
+ const canScrollLeft = direction.x < 0 && element.scrollLeft > 0;
1615
+ const canScrollRight = direction.x > 0 && element.scrollLeft < maxScrollX;
1616
+ if (canScrollLeft || canScrollRight) {
1617
+ const before = element.scrollLeft;
1618
+ element.scrollLeft += scrollX;
1619
+ if (element.scrollLeft !== before) {
1620
+ scrolled = true;
1621
+ }
1622
+ }
1623
+ }
1624
+ if (!scrolled) {
1625
+ return false;
1398
1626
  }
1399
1627
  // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1400
1628
  // Delaying via RAF causes cumulative drift during continuous autoscroll
@@ -1402,6 +1630,13 @@ class AutoScrollService {
1402
1630
  // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1403
1631
  // already enters the zone when updating drag state.
1404
1632
  this.#onScrollCallback?.();
1633
+ return true;
1634
+ }
1635
+ #getFrameScale() {
1636
+ const now = performance.now();
1637
+ const elapsedMs = this.#lastScrollTimestamp === 0 ? BASE_FRAME_DURATION_MS : now - this.#lastScrollTimestamp;
1638
+ this.#lastScrollTimestamp = now;
1639
+ return Math.min(Math.max(elapsedMs / BASE_FRAME_DURATION_MS, MIN_SCROLL_FRAME_SCALE), MAX_SCROLL_FRAME_SCALE);
1405
1640
  }
1406
1641
  /**
1407
1642
  * Check if auto-scrolling is currently active.
@@ -3157,6 +3392,18 @@ function createEffectiveGroupSignal(options) {
3157
3392
  });
3158
3393
  }
3159
3394
 
3395
+ function normalizeDropDestinationIndex({ sourceIndex, placeholderIndex, sourceDroppableId, activeDroppableId, }) {
3396
+ if (placeholderIndex === null) {
3397
+ return null;
3398
+ }
3399
+ if (sourceDroppableId !== null &&
3400
+ sourceDroppableId === activeDroppableId &&
3401
+ sourceIndex < placeholderIndex) {
3402
+ return placeholderIndex - 1;
3403
+ }
3404
+ return placeholderIndex;
3405
+ }
3406
+
3160
3407
  /**
3161
3408
  * Marks an element as a valid drop target within the virtual scroll drag-and-drop system.
3162
3409
  *
@@ -3182,6 +3429,7 @@ class DroppableDirective {
3182
3429
  #elementRef = inject((ElementRef));
3183
3430
  #dragState = inject(DragStateService);
3184
3431
  #autoScroll = inject(AutoScrollService);
3432
+ #positionCalculator = inject(PositionCalculatorService);
3185
3433
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
3186
3434
  /** Unique identifier for this droppable */
3187
3435
  vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
@@ -3225,29 +3473,14 @@ class DroppableDirective {
3225
3473
  }
3226
3474
  return this.#dragState.placeholderId();
3227
3475
  }, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
3228
- /** Track previous active state for cache clearing on leave */
3476
+ /** Track previous active state to detect the drag-end transition */
3229
3477
  #wasActive = false;
3230
- /** Cached state for handling drop (since state is cleared before effect fires) */
3231
- #cachedDragState = null;
3232
- /**
3233
- * Check if this element is actually scrollable.
3234
- */
3235
- #isScrollable() {
3236
- const el = this.#elementRef.nativeElement;
3237
- const style = window.getComputedStyle(el);
3238
- const overflowY = style.overflowY;
3239
- const overflowX = style.overflowX;
3240
- // Check if overflow allows scrolling
3241
- const hasScrollableOverflow = overflowY === 'auto' ||
3242
- overflowY === 'scroll' ||
3243
- overflowX === 'auto' ||
3244
- overflowX === 'scroll';
3245
- if (!hasScrollableOverflow) {
3246
- return false;
3247
- }
3248
- // Check if content is larger than container
3249
- return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
3250
- }
3478
+ /**
3479
+ * The terminal drag snapshot this droppable has already processed. `endedDragState`
3480
+ * persists until the next drag starts, so this guards against replaying the same drop
3481
+ * when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
3482
+ */
3483
+ #handledEndedState = null;
3251
3484
  constructor() {
3252
3485
  createAutoScrollRegistration({
3253
3486
  autoScrollService: this.#autoScroll,
@@ -3255,29 +3488,51 @@ class DroppableDirective {
3255
3488
  getId: () => this.vdndDroppable(),
3256
3489
  enabled: () => this.autoScrollEnabled(),
3257
3490
  config: () => this.autoScrollConfig(),
3258
- canRegister: () => Boolean(this.effectiveGroup()) && this.#isScrollable(),
3491
+ // Register whenever a group is resolved; do NOT gate on scrollability. DOM size is
3492
+ // not reactive, so a list that becomes scrollable after init (async data, resize)
3493
+ // would never register. AutoScrollService filters by live scroll geometry per drag.
3494
+ canRegister: () => Boolean(this.effectiveGroup()),
3259
3495
  });
3260
- // React to state changes and handle drop events
3496
+ // Notify the calculator once this droppable is rendered (host data attributes applied).
3497
+ // Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
3498
+ // drag start, so without this a conditionally rendered list would never become a target.
3499
+ afterNextRender(() => {
3500
+ const group = this.effectiveGroup();
3501
+ if (group) {
3502
+ this.#positionCalculator.notifyCandidatesChanged(group);
3503
+ }
3504
+ });
3505
+ // React to state changes and handle drop events.
3506
+ //
3507
+ // Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
3508
+ // placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
3509
+ // into endedDragState() at the drag-end boundary, so this effect never has to poll the
3510
+ // high-frequency cursor/placeholder signals — reading them here would make it re-run and
3511
+ // re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
3261
3512
  effect(() => {
3262
3513
  const active = this.isActive();
3263
3514
  const draggedItem = this.#dragState.draggedItem();
3264
3515
  const isDragging = this.#dragState.isDragging();
3265
- // Cache state while active for use during drop handling
3266
- if (active && isDragging && draggedItem) {
3267
- this.#cachedDragState = this.#dragState.getStateSnapshot();
3268
- }
3269
- // Handle drag end (drop)
3270
- if (!isDragging && this.#wasActive && draggedItem === null) {
3271
- // Check if drag was cancelled (e.g., Escape key)
3272
- if (!this.#dragState.wasCancelled()) {
3273
- // Drag ended normally - this is a drop
3274
- this.#handleDrop();
3516
+ // Handle drag end (drop). This droppable is the release target when it was either
3517
+ // observed active during the drag (#wasActive) OR named as the target in the ended
3518
+ // drag snapshot. The snapshot path covers the pointer-up flush, where the final
3519
+ // position is processed and the state cleared in the same synchronous task, so this
3520
+ // effect never observes isActive() === true mid-drag and #wasActive stays false.
3521
+ const endedState = untracked(() => this.#dragState.endedDragState());
3522
+ const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
3523
+ if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
3524
+ // Consume each terminal snapshot exactly once. endedDragState persists until the
3525
+ // next drag starts and disabled()/other inputs re-run this effect, so without a
3526
+ // per-snapshot guard a later state change would replay the same historical drop.
3527
+ if (endedState !== this.#handledEndedState) {
3528
+ this.#handledEndedState = endedState;
3529
+ // Emit only for an enabled target that ended normally. A target disabled at
3530
+ // release still consumes the snapshot (so re-enabling can't resurrect the drop)
3531
+ // but emits nothing, keeping dragEnd and drop consistent.
3532
+ if (!this.disabled() && !this.#dragState.wasCancelled()) {
3533
+ this.#handleDrop();
3534
+ }
3275
3535
  }
3276
- this.#cachedDragState = null;
3277
- }
3278
- // Clear cached state when leaving without dropping
3279
- if (!active && this.#wasActive && isDragging) {
3280
- this.#cachedDragState = null;
3281
3536
  }
3282
3537
  this.#wasActive = active;
3283
3538
  });
@@ -3289,13 +3544,20 @@ class DroppableDirective {
3289
3544
  }
3290
3545
  // Unregister from auto-scroll
3291
3546
  this.#autoScroll.unregisterContainer(this.vdndDroppable());
3547
+ // If this droppable unmounts mid-drag, tell the calculator so it drops it from the
3548
+ // frozen candidate list (deferred re-query runs once the element has left the DOM).
3549
+ const group = untracked(() => this.effectiveGroup());
3550
+ if (group) {
3551
+ this.#positionCalculator.notifyCandidatesChanged(group);
3552
+ }
3292
3553
  }
3293
3554
  /**
3294
3555
  * Handle a drop on this droppable.
3295
3556
  */
3296
3557
  #handleDrop() {
3297
- // Use cached state since the actual state is cleared before this effect fires
3298
- const state = this.#cachedDragState;
3558
+ // The live state is cleared before this effect fires, so read the terminal snapshot
3559
+ // captured by endDrag()/cancelDrag() immediately before the reset.
3560
+ const state = untracked(() => this.#dragState.endedDragState());
3299
3561
  if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
3300
3562
  return;
3301
3563
  }
@@ -3306,16 +3568,17 @@ class DroppableDirective {
3306
3568
  // be in the DOM after autoscroll (it gets virtualized out of view)
3307
3569
  const sourceIndex = state.sourceIndex ?? this.#getItemIndex(state.draggedItem.draggableId, sourceDroppableId);
3308
3570
  // Use the pre-calculated placeholderIndex if available (more reliable)
3309
- // Fall back to DOM-based calculation if not
3310
- let destinationIndex = state.placeholderIndex !== null && state.placeholderIndex !== undefined
3571
+ // Fall back to DOM-based calculation if not. Then normalize to the same
3572
+ // consumer-facing insertion index emitted by DragEndEvent.
3573
+ const placeholderIndex = state.placeholderIndex !== null && state.placeholderIndex !== undefined
3311
3574
  ? state.placeholderIndex
3312
3575
  : this.#getDestinationIndex(placeholderId);
3313
- // Adjust for same-list reordering: if moving within the same list and
3314
- // the destination is after the source, we need to account for the item
3315
- // being removed from its original position
3316
- if (sourceDroppableId === this.vdndDroppable() && sourceIndex < destinationIndex) {
3317
- destinationIndex--;
3318
- }
3576
+ const destinationIndex = normalizeDropDestinationIndex({
3577
+ sourceIndex,
3578
+ placeholderIndex,
3579
+ sourceDroppableId,
3580
+ activeDroppableId: this.vdndDroppable(),
3581
+ });
3319
3582
  this.drop.emit({
3320
3583
  source: {
3321
3584
  draggableId: state.draggedItem.draggableId,
@@ -3391,7 +3654,7 @@ class DroppableDirective {
3391
3654
  return this.#elementRef.nativeElement.scrollHeight;
3392
3655
  }
3393
3656
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
3394
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DroppableDirective, isStandalone: true, selector: "[vdndDroppable]", inputs: { vdndDroppable: { classPropertyName: "vdndDroppable", publicName: "vdndDroppable", isSignal: true, isRequired: true, transformFunction: null }, vdndDroppableGroup: { classPropertyName: "vdndDroppableGroup", publicName: "vdndDroppableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDroppableData: { classPropertyName: "vdndDroppableData", publicName: "vdndDroppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop" }, host: { properties: { "attr.data-droppable-id": "vdndDroppable()", "attr.data-droppable-group": "effectiveGroup()", "attr.data-constrain-to-container": "constrainToContainer() || null", "attr.aria-dropeffect": "\"move\"", "class.vdnd-droppable": "true", "class.vdnd-droppable-active": "isActive()", "class.vdnd-droppable-disabled": "disabled()" } }, ngImport: i0 });
3657
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DroppableDirective, isStandalone: true, selector: "[vdndDroppable]", inputs: { vdndDroppable: { classPropertyName: "vdndDroppable", publicName: "vdndDroppable", isSignal: true, isRequired: true, transformFunction: null }, vdndDroppableGroup: { classPropertyName: "vdndDroppableGroup", publicName: "vdndDroppableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDroppableData: { classPropertyName: "vdndDroppableData", publicName: "vdndDroppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop" }, host: { properties: { "attr.data-droppable-id": "vdndDroppable()", "attr.data-droppable-group": "effectiveGroup()", "attr.data-droppable-disabled": "disabled() || null", "attr.data-constrain-to-container": "constrainToContainer() || null", "attr.aria-dropeffect": "\"move\"", "class.vdnd-droppable": "true", "class.vdnd-droppable-active": "isActive()", "class.vdnd-droppable-disabled": "disabled()" } }, ngImport: i0 });
3395
3658
  }
3396
3659
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, decorators: [{
3397
3660
  type: Directive,
@@ -3400,6 +3663,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3400
3663
  host: {
3401
3664
  '[attr.data-droppable-id]': 'vdndDroppable()',
3402
3665
  '[attr.data-droppable-group]': 'effectiveGroup()',
3666
+ '[attr.data-droppable-disabled]': 'disabled() || null',
3403
3667
  '[attr.data-constrain-to-container]': 'constrainToContainer() || null',
3404
3668
  '[attr.aria-dropeffect]': '"move"',
3405
3669
  '[class.vdnd-droppable]': 'true',
@@ -4113,36 +4377,46 @@ class KeyboardDragHandler {
4113
4377
  /**
4114
4378
  * Unified key dispatch. Returns true if the key was handled.
4115
4379
  * Used by both host bindings and the document-level listener.
4380
+ *
4381
+ * Handled keys are consumed entirely (preventDefault + stopPropagation): right after pickup
4382
+ * the source element can still be focused because Angular has not applied display:none yet,
4383
+ * and without stopping propagation the same keydown would be processed by both the element's
4384
+ * host binding and the document-level listener — moving the item two positions per press.
4116
4385
  */
4117
4386
  handleKey(event) {
4118
4387
  if (!this.isActive()) {
4119
4388
  return false;
4120
4389
  }
4121
- switch (event.key) {
4390
+ const handled = this.#dispatchKey(event.key);
4391
+ if (handled) {
4392
+ event.preventDefault();
4393
+ event.stopPropagation();
4394
+ }
4395
+ return handled;
4396
+ }
4397
+ /**
4398
+ * Execute the drag action for a key. Returns true if the key maps to an action.
4399
+ */
4400
+ #dispatchKey(key) {
4401
+ switch (key) {
4122
4402
  case ' ': // Space
4123
4403
  case 'Enter':
4124
- event.preventDefault();
4125
4404
  this.complete();
4126
4405
  return true;
4127
4406
  case 'Escape':
4128
4407
  case 'Tab':
4129
- event.preventDefault();
4130
4408
  this.cancel();
4131
4409
  return true;
4132
4410
  case 'ArrowUp':
4133
- event.preventDefault();
4134
4411
  this.#deps.keyboardDrag.moveUp();
4135
4412
  return true;
4136
4413
  case 'ArrowDown':
4137
- event.preventDefault();
4138
4414
  this.#deps.keyboardDrag.moveDown();
4139
4415
  return true;
4140
4416
  case 'ArrowLeft':
4141
- event.preventDefault();
4142
4417
  this.#moveToAdjacentDroppable('left');
4143
4418
  return true;
4144
4419
  case 'ArrowRight':
4145
- event.preventDefault();
4146
4420
  this.#moveToAdjacentDroppable('right');
4147
4421
  return true;
4148
4422
  default:
@@ -4155,7 +4429,21 @@ class KeyboardDragHandler {
4155
4429
  complete() {
4156
4430
  const ctx = this.#deps.getContext();
4157
4431
  const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
4158
- const destinationIndex = this.#deps.dragState.placeholderIndex();
4432
+ const activeDroppableId = this.#deps.dragState.activeDroppableId();
4433
+ // Revalidate the active target: it may have been disabled after the drag navigated
4434
+ // into it. A disabled (or missing) target is not a valid drop — report no destination.
4435
+ // The droppable's own disabled guard suppresses the drop event, keeping dragEnd and
4436
+ // drop consistent (non-cancelled dragEnd with destinationIndex: null, no drop).
4437
+ const hasValidTarget = activeDroppableId !== null &&
4438
+ !this.#deps.positionCalculator.isDroppableDisabledById(activeDroppableId);
4439
+ const destinationIndex = hasValidTarget
4440
+ ? normalizeDropDestinationIndex({
4441
+ sourceIndex,
4442
+ placeholderIndex: this.#deps.dragState.placeholderIndex(),
4443
+ sourceDroppableId: this.#deps.dragState.sourceDroppableId(),
4444
+ activeDroppableId,
4445
+ })
4446
+ : null;
4159
4447
  // Remove document listener
4160
4448
  this.#cleanupDocumentListener();
4161
4449
  // Clear droppable metadata cache from this drag session
@@ -4243,10 +4531,19 @@ class KeyboardDragHandler {
4243
4531
  if (!adjacent) {
4244
4532
  return;
4245
4533
  }
4534
+ // Derive the destination list's item count from DragIndexCalculatorService — the single
4535
+ // source of truth that correctly handles registered strategies, dynamic-height lists, and
4536
+ // vdnd-virtual-content (page-scroll) lists. Arrowing back to the origin lands on the source
4537
+ // list, so compute isSameList rather than assuming a distinct target.
4538
+ const itemCount = this.#deps.dragIndexCalculator.getTotalItemCount({
4539
+ droppableElement: adjacent.element,
4540
+ isSameList: adjacent.id === this.#deps.dragState.sourceDroppableId(),
4541
+ draggedItemHeight: this.#deps.dragState.draggedItem()?.height ?? 0,
4542
+ });
4246
4543
  // Maintain the current target index (clamped to the new list's size)
4247
4544
  const currentTargetIndex = this.#deps.keyboardDrag.targetIndex() ?? 0;
4248
- const targetIndex = Math.min(currentTargetIndex, adjacent.itemCount);
4249
- this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex, adjacent.itemCount);
4545
+ const targetIndex = Math.min(currentTargetIndex, itemCount);
4546
+ this.#deps.keyboardDrag.moveToDroppable(adjacent.id, targetIndex, itemCount);
4250
4547
  }
4251
4548
  /**
4252
4549
  * Document-level keydown listener active during keyboard drag.
@@ -4553,7 +4850,12 @@ class DraggableDirective {
4553
4850
  * Set to 0 for immediate drag (default behavior).
4554
4851
  */
4555
4852
  dragDelay = input(0, ...(ngDevMode ? [{ debugName: "dragDelay" }] : []));
4556
- /** Lock dragging to a single axis ('x' = horizontal only, 'y' = vertical only) */
4853
+ /**
4854
+ * Lock dragging by freezing one axis. The value names the axis that stays
4855
+ * fixed: `'x'` freezes the X coordinate (vertical-only movement), `'y'`
4856
+ * freezes the Y coordinate (horizontal-only movement). `null` allows free
4857
+ * movement. Note: this is the opposite of Angular CDK's `cdkDragLockAxis`.
4858
+ */
4557
4859
  lockAxis = input(null, ...(ngDevMode ? [{ debugName: "lockAxis" }] : []));
4558
4860
  /** Emits when drag starts */
4559
4861
  dragStart = output();
@@ -4732,7 +5034,6 @@ class DraggableDirective {
4732
5034
  const clonedElement = this.#overlayContainer.hasTemplatePreview()
4733
5035
  ? undefined
4734
5036
  : this.#elementClone.cloneElement(element);
4735
- const parentDroppableId = this.#getParentDroppableId();
4736
5037
  // Find droppable and calculate initial placeholder position
4737
5038
  // This fixes the UI glitch by ensuring placeholder is set before the element is hidden
4738
5039
  const groupName = this.#effectiveGroup();
@@ -4741,6 +5042,10 @@ class DraggableDirective {
4741
5042
  this.#pointerHandler.cleanup();
4742
5043
  return;
4743
5044
  }
5045
+ const parentDroppableElement = this.#positionCalculator.getDroppableParent(element, groupName);
5046
+ const parentDroppableId = parentDroppableElement
5047
+ ? this.#positionCalculator.getDroppableId(parentDroppableElement)
5048
+ : null;
4744
5049
  // Snapshot droppable rects for this drag session so subsequent hit-testing is
4745
5050
  // pure geometry (no elementFromPoint layout flush per pointermove).
4746
5051
  this.#positionCalculator.beginDragSession(groupName);
@@ -4756,7 +5061,7 @@ class DraggableDirective {
4756
5061
  : parentDroppableId;
4757
5062
  // Calculate source index BEFORE the element is hidden (display: none)
4758
5063
  // This is critical because getBoundingClientRect() returns all zeros for hidden elements
4759
- const sourceIndex = this.#calculateSourceIndex(element, droppableElement);
5064
+ const sourceIndex = this.#calculateSourceIndex(element, parentDroppableElement);
4760
5065
  let initialPlaceholderId = null;
4761
5066
  let initialPlaceholderIndex = null;
4762
5067
  if (droppableElement) {
@@ -4852,10 +5157,24 @@ class DraggableDirective {
4852
5157
  return strategy.findIndexAtOffset(relativeY);
4853
5158
  }
4854
5159
  }
4855
- // Fallback: Use fixed-height math if no strategy available
4856
- const itemHeight = rect.height || 50;
4857
- const relativeY = rect.top - containerRect.top + scrollTop;
4858
- return Math.round(relativeY / itemHeight);
5160
+ // Preserve the geometry fallback for a virtual container whose strategy is unavailable.
5161
+ if (virtualScroll || virtualContent) {
5162
+ const itemHeight = rect.height || 50;
5163
+ const relativeY = rect.top - containerRect.top + scrollTop;
5164
+ return Math.round(relativeY / itemHeight);
5165
+ }
5166
+ // Non-virtual fallback: derive the logical index from preceding draggable siblings.
5167
+ // Geometry-based division is incorrect when the list has padding, gaps, margins,
5168
+ // or variable-height items.
5169
+ let sourceIndex = 0;
5170
+ let sibling = element.previousElementSibling;
5171
+ while (sibling) {
5172
+ if (sibling.matches('[data-draggable-id]')) {
5173
+ sourceIndex++;
5174
+ }
5175
+ sibling = sibling.previousElementSibling;
5176
+ }
5177
+ return sourceIndex;
4859
5178
  }
4860
5179
  /**
4861
5180
  * Recalculate placeholder position (called during auto-scroll).
@@ -5001,6 +5320,16 @@ class DraggableDirective {
5001
5320
  * End the drag operation.
5002
5321
  */
5003
5322
  #endDrag(cancelled) {
5323
+ // Flush the final pointer position for a real drop. Pointer moves only QUEUE a cursor
5324
+ // for the next RAF tick; on release the scheduler is stopped below and any un-processed
5325
+ // cursor is discarded, so without this the drop would resolve against the last
5326
+ // RAF-PROCESSED position — a release that just entered a different (e.g. newly disabled)
5327
+ // droppable could otherwise land on the previously-targeted one. Guarded on group
5328
+ // presence so it never re-enters #endDrag via #updateDrag's group-lost cancel path.
5329
+ // Cancels skip this: their destination is intentionally null.
5330
+ if (!cancelled && this.#lastRawPosition && this.isDragging() && this.#effectiveGroup()) {
5331
+ this.#updateDrag(this.#lastRawPosition);
5332
+ }
5004
5333
  // Stop the scheduler RAF loop first, then remove the autoscroll participant.
5005
5334
  this.#scheduler.stop();
5006
5335
  this.#autoScroll.stopMonitoring();
@@ -5013,20 +5342,14 @@ class DraggableDirective {
5013
5342
  this.#constraintElement = null;
5014
5343
  this.#lastRawPosition = null;
5015
5344
  const sourceIndex = this.#dragState.sourceIndex() ?? 0;
5016
- const placeholderIndex = this.#dragState.placeholderIndex();
5017
- let destinationIndex = cancelled ? null : placeholderIndex;
5018
- // Keep DragEndEvent semantics consistent with DropEvent.destination.index.
5019
- // During same-list drag, placeholderIndex includes the hidden-item adjustment,
5020
- // but the final insertion index must account for removal of the source item.
5021
- if (!cancelled && placeholderIndex !== null) {
5022
- const sourceDroppableId = this.#dragState.sourceDroppableId();
5023
- const activeDroppableId = this.#dragState.activeDroppableId();
5024
- if (sourceDroppableId !== null && sourceDroppableId === activeDroppableId) {
5025
- if (sourceIndex < placeholderIndex) {
5026
- destinationIndex = placeholderIndex - 1;
5027
- }
5028
- }
5029
- }
5345
+ const destinationIndex = cancelled
5346
+ ? null
5347
+ : normalizeDropDestinationIndex({
5348
+ sourceIndex,
5349
+ placeholderIndex: this.#dragState.placeholderIndex(),
5350
+ sourceDroppableId: this.#dragState.sourceDroppableId(),
5351
+ activeDroppableId: this.#dragState.activeDroppableId(),
5352
+ });
5030
5353
  // Emit drag end event
5031
5354
  this.dragEnd.emit({
5032
5355
  draggableId: this.vdndDraggable(),