ngx-virtual-dnd 3.2.0-alpha.0 → 3.2.0-alpha.2

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.
@@ -456,12 +456,17 @@ const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
456
456
  const DEFAULT_SHIFT_DURATION = 200;
457
457
  /** Default shift animation easing used when the config omits `shiftEasing`. */
458
458
  const DEFAULT_SHIFT_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
459
+ /** Default drop animation duration (ms) used when the config omits `dropDuration`. */
460
+ const DEFAULT_DROP_DURATION = 200;
461
+ /** Default drop animation easing used when the config omits `dropEasing`. */
462
+ const DEFAULT_DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
459
463
  /**
460
464
  * Opt-in animation configuration. When provided, items displaced by the placeholder
461
- * slide into their new position instead of jumping. Resolved through the element
465
+ * slide into their new position instead of jumping, and on drop the drag preview glides
466
+ * into the item's final position. Resolved through the element
462
467
  * injector, so it can be provided app-wide or per component subtree.
463
468
  *
464
- * The animation is skipped when the user prefers reduced motion. Values are read each
469
+ * Animations are skipped when the user prefers reduced motion. Values are read each
465
470
  * time an animation starts, so getters can toggle the animation at runtime.
466
471
  *
467
472
  * @example
@@ -1235,9 +1240,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1235
1240
  * AutoScrollService registers here to perform edge-scroll + immediate
1236
1241
  * placeholder recalculation in the same synchronous frame — preserving
1237
1242
  * the Safari synchronous-callback constraint.
1238
- * 2. Main compute phase (COMPUTE+WRITE): onTick is called with the latest
1239
- * cursor. Only runs when the cursor has changed (cursorDirty=true), so
1240
- * autoscroll-only frames (cursor stationary) incur zero hit-test work.
1243
+ * 2. Main compute phase (COMPUTE+WRITE): onTick is called every frame with the
1244
+ * latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
1245
+ * the flag is false, so autoscroll-only frames (cursor stationary) incur
1246
+ * zero hit-test work.
1241
1247
  *
1242
1248
  * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1243
1249
  * RAF per pointermove. This coalesces all moves within a frame into one update
@@ -1882,6 +1888,8 @@ class KeyboardDragService {
1882
1888
  #dragState = inject(DragStateService);
1883
1889
  /** Total item count for the current droppable (set by droppable on keyboard drag start) */
1884
1890
  #totalItemCount = signal(0, ...(ngDevMode ? [{ debugName: "#totalItemCount" }] : []));
1891
+ /** Per-droppable callbacks that scroll the placeholder into view (see `registerRevealer`) */
1892
+ #revealers = new Map();
1885
1893
  /** Whether a keyboard drag is currently active */
1886
1894
  isActive = computed(() => this.#dragState.isKeyboardDrag() && this.#dragState.isDragging(), ...(ngDevMode ? [{ debugName: "isActive" }] : []));
1887
1895
  /** Current target index during keyboard navigation */
@@ -1917,9 +1925,10 @@ class KeyboardDragService {
1917
1925
  if (!this.isActive()) {
1918
1926
  return targetIndex;
1919
1927
  }
1920
- const totalItems = this.#totalItemCount();
1921
- const clampedIndex = Math.max(0, Math.min(targetIndex, totalItems));
1928
+ const maxIndex = this.#maxTargetIndex(this.activeDroppableId());
1929
+ const clampedIndex = Math.max(0, Math.min(targetIndex, maxIndex));
1922
1930
  this.#dragState.setKeyboardTargetIndex(clampedIndex);
1931
+ this.#revealActivePlaceholder();
1923
1932
  return clampedIndex;
1924
1933
  }
1925
1934
  /**
@@ -1947,8 +1956,26 @@ class KeyboardDragService {
1947
1956
  return;
1948
1957
  }
1949
1958
  this.#totalItemCount.set(totalItemCount);
1950
- const clampedIndex = Math.max(0, Math.min(targetIndex, totalItemCount));
1959
+ const clampedIndex = Math.max(0, Math.min(targetIndex, this.#maxTargetIndex(droppableId)));
1951
1960
  this.#dragState.setKeyboardActiveDroppable(droppableId, clampedIndex);
1961
+ this.#revealActivePlaceholder();
1962
+ }
1963
+ /**
1964
+ * Register the callback that scrolls a droppable's placeholder into view. It runs
1965
+ * synchronously on every keyboard move into or within that droppable, so a drop that
1966
+ * follows before the next render still lands inside the rendered range.
1967
+ */
1968
+ registerRevealer(droppableId, reveal) {
1969
+ this.#revealers.set(droppableId, reveal);
1970
+ }
1971
+ /**
1972
+ * Unregister a droppable's reveal callback. Ignored if another callback has since been
1973
+ * registered under the same ID.
1974
+ */
1975
+ unregisterRevealer(droppableId, reveal) {
1976
+ if (this.#revealers.get(droppableId) === reveal) {
1977
+ this.#revealers.delete(droppableId);
1978
+ }
1952
1979
  }
1953
1980
  /**
1954
1981
  * Complete the keyboard drag (Space or Enter to drop).
@@ -1974,6 +2001,22 @@ class KeyboardDragService {
1974
2001
  setTotalItemCount(count) {
1975
2002
  this.#totalItemCount.set(count);
1976
2003
  }
2004
+ #revealActivePlaceholder() {
2005
+ const droppableId = this.activeDroppableId();
2006
+ if (droppableId !== null) {
2007
+ this.#revealers.get(droppableId)?.();
2008
+ }
2009
+ }
2010
+ /**
2011
+ * Last valid target index in the given droppable. Targets use the drop convention (the item's
2012
+ * final index after it leaves its source), so the source list ends at `totalItemCount - 1`,
2013
+ * while any other list can be appended to at `totalItemCount`.
2014
+ */
2015
+ #maxTargetIndex(droppableId) {
2016
+ const totalItems = this.#totalItemCount();
2017
+ const isSourceList = droppableId !== null && droppableId === this.#dragState.sourceDroppableId();
2018
+ return isSourceList ? Math.max(0, totalItems - 1) : totalItems;
2019
+ }
1977
2020
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1978
2021
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, providedIn: 'root' });
1979
2022
  }
@@ -2451,7 +2494,7 @@ const MIN_SHIFT_PX = 0.5;
2451
2494
  * FLIP animator for items displaced by the drag placeholder.
2452
2495
  *
2453
2496
  * Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
2454
- * placeholder moved, it snapshots every rendered element's visual position, then after
2497
+ * placeholder moved (or the drag ended), it snapshots every rendered element's visual position, then after
2455
2498
  * the render measures again and plays a compositor-only `transform` animation from the
2456
2499
  * old position to the new one.
2457
2500
  *
@@ -2488,11 +2531,19 @@ class ShiftAnimator {
2488
2531
  this.#wasDragging = isDragging;
2489
2532
  this.#lastPlaceholderIndex = placeholderIndex;
2490
2533
  if (!isDragging) {
2491
- // Drop/cancel: the list re-renders with the committed order, so snap everything.
2492
- this.#snapshot = null;
2493
- if (wasDragging) {
2534
+ // Drop/cancel: one last pass slides rows from their drag layout into the committed
2535
+ // order. After a drop the rows usually already sit in their final slots, so in-flight
2536
+ // slides simply finish; after a cancel the displaced rows slide back.
2537
+ // A pending snapshot already holds this tick's "before".
2538
+ if (!wasDragging || this.#snapshot) {
2539
+ return;
2540
+ }
2541
+ if (!this.#isEnabled()) {
2494
2542
  this.cancelAll();
2543
+ return;
2495
2544
  }
2545
+ this.#snapshot = this.#measure();
2546
+ afterNextRender(() => this.#play(), { injector: this.#injector });
2496
2547
  return;
2497
2548
  }
2498
2549
  // Drag start renders the placeholder in the hidden item's slot — nothing moves.
@@ -2562,7 +2613,7 @@ class ShiftAnimator {
2562
2613
  #play() {
2563
2614
  const first = this.#snapshot;
2564
2615
  this.#snapshot = null;
2565
- if (!first || !this.#wasDragging)
2616
+ if (!first)
2566
2617
  return;
2567
2618
  const last = this.#measure();
2568
2619
  const stale = [];
@@ -2662,6 +2713,7 @@ class VirtualScrollContainerComponent {
2662
2713
  #elementRef = inject((ElementRef));
2663
2714
  #autoScrollService = inject(AutoScrollService);
2664
2715
  #dragIndexCalculator = inject(DragIndexCalculatorService);
2716
+ #keyboardDrag = inject(KeyboardDragService);
2665
2717
  #ngZone = inject(NgZone);
2666
2718
  #injector = inject(Injector);
2667
2719
  /** Cleanup function for scroll listener */
@@ -2979,43 +3031,19 @@ class VirtualScrollContainerComponent {
2979
3031
  untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
2980
3032
  });
2981
3033
  }
2982
- // Keyboard drag autoscroll: scroll to keep target index visible
2983
- effect(() => {
2984
- // Only apply when this droppable is active during keyboard drag
2985
- if (!this.#dragState.isKeyboardDrag())
2986
- return;
2987
- const activeDroppable = this.#dragState.activeDroppableId();
2988
- if (activeDroppable !== this.droppableId())
2989
- return;
2990
- const targetIndex = this.#dragState.keyboardTargetIndex();
2991
- if (targetIndex === null)
2992
- return;
2993
- const strategy = this.#strategy();
2994
- strategy.version();
2995
- const height = this.effectiveHeight();
2996
- if (height <= 0)
3034
+ // Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
3035
+ // #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
3036
+ // move never lands outside the rendered range; this effect re-runs it when item
3037
+ // measurements or the viewport height change during the drag.
3038
+ effect((onCleanup) => {
3039
+ const droppableId = this.droppableId();
3040
+ if (!droppableId)
2997
3041
  return;
2998
- const element = this.#elementRef.nativeElement;
2999
- const currentScrollTop = element.scrollTop;
3000
- // Calculate target item position using strategy
3001
- const targetTop = strategy.getOffsetForIndex(targetIndex);
3002
- const targetBottom = targetTop + strategy.getItemHeight(targetIndex);
3003
- // Calculate visible range
3004
- const viewportTop = currentScrollTop;
3005
- const viewportBottom = currentScrollTop + height;
3006
- // Check if target is fully visible
3007
- if (targetTop < viewportTop) {
3008
- // Target is above viewport - scroll up
3009
- element.scrollTop = targetTop;
3010
- this.#scrollTop.set(targetTop);
3011
- }
3012
- else if (targetBottom > viewportBottom) {
3013
- // Target is below viewport - scroll down
3014
- const newScrollTop = targetBottom - height;
3015
- element.scrollTop = newScrollTop;
3016
- this.#scrollTop.set(newScrollTop);
3017
- }
3042
+ const reveal = () => untracked(() => this.#revealPlaceholder());
3043
+ this.#keyboardDrag.registerRevealer(droppableId, reveal);
3044
+ onCleanup(() => this.#keyboardDrag.unregisterRevealer(droppableId, reveal));
3018
3045
  });
3046
+ effect(() => this.#revealPlaceholder());
3019
3047
  // Preserve scroll position when drag ends at bottom of list.
3020
3048
  // Safety net: totalHeight no longer shrinks during drag (getTotalHeight
3021
3049
  // ignores exclusion), but this logic remains as harmless protection
@@ -3096,6 +3124,50 @@ class VirtualScrollContainerComponent {
3096
3124
  this.#observeRenderedItems();
3097
3125
  }
3098
3126
  }
3127
+ /**
3128
+ * Scroll so the placeholder is fully visible during a keyboard drag into this list.
3129
+ * Reads only signals and strategy offsets, so it works before the placeholder renders.
3130
+ */
3131
+ #revealPlaceholder() {
3132
+ // Only apply when this droppable is active during keyboard drag
3133
+ if (!this.#dragState.isKeyboardDrag())
3134
+ return;
3135
+ const activeDroppable = this.#dragState.activeDroppableId();
3136
+ if (activeDroppable !== this.droppableId())
3137
+ return;
3138
+ // The placeholder renders before the item at placeholderIndex. Below the source in the
3139
+ // same list that is keyboardTargetIndex + 1: the dragged item's slot is excluded.
3140
+ if (this.#dragState.keyboardTargetIndex() === null)
3141
+ return;
3142
+ const placeholderIndex = this.placeholderIndex();
3143
+ if (placeholderIndex < 0)
3144
+ return;
3145
+ const strategy = this.#strategy();
3146
+ strategy.version();
3147
+ const height = this.effectiveHeight();
3148
+ if (height <= 0)
3149
+ return;
3150
+ const element = this.#elementRef.nativeElement;
3151
+ const currentScrollTop = element.scrollTop;
3152
+ // Calculate placeholder position using strategy
3153
+ const targetTop = strategy.getOffsetForIndex(placeholderIndex);
3154
+ const targetBottom = targetTop + this.placeholderHeight();
3155
+ // Calculate visible range
3156
+ const viewportTop = currentScrollTop;
3157
+ const viewportBottom = currentScrollTop + height;
3158
+ // Check if target is fully visible
3159
+ if (targetTop < viewportTop) {
3160
+ // Target is above viewport - scroll up
3161
+ element.scrollTop = targetTop;
3162
+ this.#scrollTop.set(targetTop);
3163
+ }
3164
+ else if (targetBottom > viewportBottom) {
3165
+ // Target is below viewport - scroll down
3166
+ const newScrollTop = targetBottom - height;
3167
+ element.scrollTop = newScrollTop;
3168
+ this.#scrollTop.set(newScrollTop);
3169
+ }
3170
+ }
3099
3171
  ngOnDestroy() {
3100
3172
  this.#scrollCleanup?.();
3101
3173
  this.#resizeCleanup?.();
@@ -3294,6 +3366,124 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3294
3366
  `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
3295
3367
  }], ctorParameters: () => [], propDecorators: { itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
3296
3368
 
3369
+ /**
3370
+ * Plays the drop ("settle") animation: the drag preview glides from where it was released
3371
+ * into the dropped item's final position, while that item stays invisible underneath.
3372
+ *
3373
+ * Purely visual. The drag state and the drop events are already final when it starts, so it
3374
+ * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3375
+ * of view) the ghost fades out where it was released.
3376
+ * @internal
3377
+ */
3378
+ class DropAnimator {
3379
+ #config;
3380
+ #ghostAnimation = null;
3381
+ #hideAnimation = null;
3382
+ constructor(config) {
3383
+ this.#config = config;
3384
+ }
3385
+ /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3386
+ isEnabled() {
3387
+ if (this.#duration() <= 0)
3388
+ return false;
3389
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3390
+ return false;
3391
+ }
3392
+ if (typeof matchMedia !== 'function')
3393
+ return true;
3394
+ return !matchMedia('(prefers-reduced-motion: reduce)').matches;
3395
+ }
3396
+ /**
3397
+ * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3398
+ * `onDone` is not called when the animation is cancelled.
3399
+ */
3400
+ play(ghost, target, onDone) {
3401
+ this.cancel();
3402
+ const timing = {
3403
+ duration: this.#duration(),
3404
+ easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3405
+ fill: 'forwards',
3406
+ };
3407
+ let ghostAnimation;
3408
+ if (target) {
3409
+ const from = ghost.getBoundingClientRect();
3410
+ const dx = target.rect.left - from.left;
3411
+ const dy = target.rect.top - from.top;
3412
+ // Travel on top of the preview's own position transform (width/height must not be
3413
+ // composited, so the base transform is repeated instead of using composite: 'add').
3414
+ const base = ghost.style.transform;
3415
+ ghostAnimation = ghost.animate([
3416
+ {
3417
+ transform: `${base} translate(0px, 0px)`,
3418
+ width: `${from.width}px`,
3419
+ height: `${from.height}px`,
3420
+ },
3421
+ {
3422
+ transform: `${base} translate(${dx}px, ${dy}px)`,
3423
+ width: `${target.rect.width}px`,
3424
+ height: `${target.rect.height}px`,
3425
+ },
3426
+ ], timing);
3427
+ // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3428
+ this.#hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3429
+ duration: timing.duration,
3430
+ });
3431
+ }
3432
+ else {
3433
+ ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3434
+ }
3435
+ this.#ghostAnimation = ghostAnimation;
3436
+ ghostAnimation.onfinish = () => {
3437
+ if (this.#ghostAnimation !== ghostAnimation)
3438
+ return;
3439
+ this.cancel();
3440
+ onDone();
3441
+ };
3442
+ }
3443
+ /** Stop the running animation immediately and reveal the real item. */
3444
+ cancel() {
3445
+ const ghostAnimation = this.#ghostAnimation;
3446
+ const hideAnimation = this.#hideAnimation;
3447
+ this.#ghostAnimation = null;
3448
+ this.#hideAnimation = null;
3449
+ ghostAnimation?.cancel();
3450
+ hideAnimation?.cancel();
3451
+ }
3452
+ #duration() {
3453
+ return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
3454
+ }
3455
+ }
3456
+ /**
3457
+ * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3458
+ * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3459
+ * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3460
+ */
3461
+ function findDropTarget(draggableId, droppableIds) {
3462
+ if (typeof document === 'undefined')
3463
+ return null;
3464
+ for (const droppableId of droppableIds) {
3465
+ if (!droppableId)
3466
+ continue;
3467
+ const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
3468
+ const element = droppable
3469
+ ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3470
+ : null;
3471
+ if (!droppable || !element)
3472
+ continue;
3473
+ const rect = element.getBoundingClientRect();
3474
+ // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3475
+ if (rect.width === 0 && rect.height === 0)
3476
+ return null;
3477
+ const bounds = droppable.getBoundingClientRect();
3478
+ const visible = rect.bottom > bounds.top &&
3479
+ rect.top < bounds.bottom &&
3480
+ rect.right > bounds.left &&
3481
+ rect.left < bounds.right;
3482
+ return visible ? { element, rect } : null;
3483
+ }
3484
+ return null;
3485
+ }
3486
+
3297
3487
  /**
3298
3488
  * Renders a preview of the dragged item that follows the cursor.
3299
3489
  *
@@ -3301,6 +3491,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3301
3491
  * so it works correctly even inside ancestors with CSS `transform` (e.g. Ionic pages).
3302
3492
  * It can be placed anywhere in the component tree.
3303
3493
  *
3494
+ * When `VDND_ANIMATION_CONFIG` is provided, the preview stays up briefly after a drop or
3495
+ * cancel and glides into the item's final position.
3496
+ *
3304
3497
  * @example
3305
3498
  * ```html
3306
3499
  * <vdnd-drag-preview>
@@ -3314,12 +3507,24 @@ class DragPreviewComponent {
3314
3507
  dragState = inject(DragStateService);
3315
3508
  #overlayContainer = inject(OverlayContainerService);
3316
3509
  #elementRef = inject((ElementRef));
3510
+ #injector = inject(Injector);
3511
+ #dropAnimator = this.#createDropAnimator();
3317
3512
  /** Optional custom template for the preview */
3318
3513
  previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
3319
3514
  /** Offset from cursor to preview (to avoid cursor being on top of preview) */
3320
3515
  cursorOffset = input({ x: 8, y: 8 }, ...(ngDevMode ? [{ debugName: "cursorOffset" }] : []));
3321
3516
  /** Reference to the clone container element (cannot use ES private with viewChild) */
3322
3517
  cloneContainer = viewChild('cloneContainer', ...(ngDevMode ? [{ debugName: "cloneContainer" }] : []));
3518
+ /** The rendered preview box (cannot use ES private with viewChild) */
3519
+ previewElement = viewChild('preview', ...(ngDevMode ? [{ debugName: "previewElement" }] : []));
3520
+ /** Set while the preview animates into the drop position after the drag ended */
3521
+ #settling = signal(null, ...(ngDevMode ? [{ debugName: "#settling" }] : []));
3522
+ /** Whether the drag was still active on the last state change (detects drag end) */
3523
+ #wasDragging = false;
3524
+ /** Whether the preview is playing the drop animation */
3525
+ isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
3526
+ /** The item shown: the live dragged item, or the dropped one while it settles */
3527
+ displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
3323
3528
  /** Whether this preview's template registration is currently counted (kept balanced). */
3324
3529
  #templateRegistered = false;
3325
3530
  /** The cloned element from drag state (used when no custom template is provided) */
@@ -3327,7 +3532,7 @@ class DragPreviewComponent {
3327
3532
  if (this.previewTemplate()) {
3328
3533
  return null; // Custom template takes precedence
3329
3534
  }
3330
- return this.dragState.draggedItem()?.clonedElement ?? null;
3535
+ return this.displayedItem()?.clonedElement ?? null;
3331
3536
  }, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
3332
3537
  constructor() {
3333
3538
  // Teleport host element into the body-level overlay container after first render.
@@ -3365,8 +3570,24 @@ class DragPreviewComponent {
3365
3570
  container.appendChild(clone);
3366
3571
  }
3367
3572
  });
3573
+ // Drop animation: when a drag ends, keep the preview up and glide it into place.
3574
+ // A new drag cuts any settle short.
3575
+ effect(() => {
3576
+ const isDragging = this.dragState.isDragging();
3577
+ untracked(() => {
3578
+ const wasDragging = this.#wasDragging;
3579
+ this.#wasDragging = isDragging;
3580
+ if (isDragging) {
3581
+ this.#stopSettling();
3582
+ }
3583
+ else if (wasDragging) {
3584
+ this.#startSettling();
3585
+ }
3586
+ });
3587
+ });
3368
3588
  }
3369
3589
  ngOnDestroy() {
3590
+ this.#dropAnimator?.cancel();
3370
3591
  if (this.#templateRegistered) {
3371
3592
  this.#overlayContainer.setTemplatePreviewActive(false);
3372
3593
  this.#templateRegistered = false;
@@ -3375,20 +3596,30 @@ class DragPreviewComponent {
3375
3596
  }
3376
3597
  /** Whether the preview is visible */
3377
3598
  isVisible = computed(() => {
3378
- return this.dragState.isDragging() && this.dragState.cursorPosition() !== null;
3599
+ return ((this.dragState.isDragging() && this.dragState.cursorPosition() !== null) || this.isSettling());
3379
3600
  }, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
3380
3601
  /** Position of the preview */
3381
3602
  position = computed(() => {
3382
- const cursor = this.dragState.cursorPosition();
3383
- const grabOffset = this.dragState.grabOffset();
3384
- const fallbackOffset = this.cursorOffset();
3385
- const initialPosition = this.dragState.initialPosition();
3386
- const lockAxis = this.dragState.lockAxis();
3603
+ const settling = this.#settling();
3604
+ if (settling && !this.dragState.isDragging()) {
3605
+ return settling.position;
3606
+ }
3607
+ return this.#positionFor({
3608
+ cursorPosition: this.dragState.cursorPosition(),
3609
+ grabOffset: this.dragState.grabOffset(),
3610
+ initialPosition: this.dragState.initialPosition(),
3611
+ lockAxis: this.dragState.lockAxis(),
3612
+ });
3613
+ }, ...(ngDevMode ? [{ debugName: "position" }] : []));
3614
+ #positionFor(state) {
3615
+ const cursor = state.cursorPosition;
3616
+ const initialPosition = state.initialPosition;
3617
+ const lockAxis = state.lockAxis;
3387
3618
  if (!cursor) {
3388
3619
  return { x: 0, y: 0 };
3389
3620
  }
3390
3621
  // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
3391
- const offset = grabOffset ?? fallbackOffset;
3622
+ const offset = state.grabOffset ?? this.cursorOffset();
3392
3623
  let x = cursor.x - offset.x;
3393
3624
  let y = cursor.y - offset.y;
3394
3625
  // Apply axis locking if configured
@@ -3403,7 +3634,7 @@ class DragPreviewComponent {
3403
3634
  }
3404
3635
  }
3405
3636
  return { x, y };
3406
- }, ...(ngDevMode ? [{ debugName: "position" }] : []));
3637
+ }
3407
3638
  /** Transform-based positioning for better performance (avoid layout from left/top). */
3408
3639
  transform = computed(() => {
3409
3640
  const { x, y } = this.position();
@@ -3411,7 +3642,7 @@ class DragPreviewComponent {
3411
3642
  }, ...(ngDevMode ? [{ debugName: "transform" }] : []));
3412
3643
  /** Dimensions of the preview */
3413
3644
  dimensions = computed(() => {
3414
- const item = this.dragState.draggedItem();
3645
+ const item = this.displayedItem();
3415
3646
  if (!item) {
3416
3647
  return { width: 100, height: 50 };
3417
3648
  }
@@ -3422,19 +3653,65 @@ class DragPreviewComponent {
3422
3653
  }, ...(ngDevMode ? [{ debugName: "dimensions" }] : []));
3423
3654
  /** Template context */
3424
3655
  templateContext = computed(() => {
3425
- const item = this.dragState.draggedItem();
3656
+ const item = this.displayedItem();
3426
3657
  return {
3427
3658
  $implicit: (item?.data ?? null),
3428
3659
  draggableId: item?.draggableId ?? '',
3429
3660
  droppableId: item?.droppableId ?? '',
3430
3661
  };
3431
3662
  }, ...(ngDevMode ? [{ debugName: "templateContext" }] : []));
3663
+ #createDropAnimator() {
3664
+ const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
3665
+ return config ? new DropAnimator(config) : null;
3666
+ }
3667
+ /**
3668
+ * Keep the ended drag's preview on screen, then (once the consumer's drop handler has
3669
+ * re-rendered the lists) animate it onto the item's final position.
3670
+ */
3671
+ #startSettling() {
3672
+ const ended = this.dragState.endedDragState();
3673
+ if (!this.#dropAnimator?.isEnabled() || !ended?.draggedItem || !ended.cursorPosition) {
3674
+ return;
3675
+ }
3676
+ const settling = {
3677
+ item: ended.draggedItem,
3678
+ position: this.#positionFor(ended),
3679
+ };
3680
+ this.#settling.set(settling);
3681
+ // A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
3682
+ // has not committed yet leaves the item in its source list.
3683
+ const droppableIds = this.dragState.wasCancelled()
3684
+ ? [ended.sourceDroppableId]
3685
+ : [ended.activeDroppableId, ended.sourceDroppableId];
3686
+ afterNextRender(() => {
3687
+ if (this.#settling() !== settling) {
3688
+ return;
3689
+ }
3690
+ const ghost = this.previewElement()?.nativeElement;
3691
+ if (!ghost || !this.#dropAnimator) {
3692
+ this.#settling.set(null);
3693
+ return;
3694
+ }
3695
+ const target = findDropTarget(settling.item.draggableId, droppableIds);
3696
+ this.#dropAnimator.play(ghost, target, () => {
3697
+ if (this.#settling() === settling) {
3698
+ this.#settling.set(null);
3699
+ }
3700
+ });
3701
+ }, { injector: this.#injector });
3702
+ }
3703
+ #stopSettling() {
3704
+ this.#dropAnimator?.cancel();
3705
+ this.#settling.set(null);
3706
+ }
3432
3707
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DragPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3433
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: DragPreviewComponent, isStandalone: true, selector: "vdnd-drag-preview", inputs: { previewTemplate: { classPropertyName: "previewTemplate", publicName: "previewTemplate", isSignal: true, isRequired: false, transformFunction: null }, cursorOffset: { classPropertyName: "cursorOffset", publicName: "cursorOffset", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "cloneContainer", first: true, predicate: ["cloneContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `
3708
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: DragPreviewComponent, isStandalone: true, selector: "vdnd-drag-preview", inputs: { previewTemplate: { classPropertyName: "previewTemplate", publicName: "previewTemplate", isSignal: true, isRequired: false, transformFunction: null }, cursorOffset: { classPropertyName: "cursorOffset", publicName: "cursorOffset", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "cloneContainer", first: true, predicate: ["cloneContainer"], descendants: true, isSignal: true }, { propertyName: "previewElement", first: true, predicate: ["preview"], descendants: true, isSignal: true }], ngImport: i0, template: `
3434
3709
  @if (isVisible()) {
3435
3710
  <div
3711
+ #preview
3436
3712
  class="vdnd-drag-preview"
3437
- data-testid="vdnd-drag-preview"
3713
+ [class.vdnd-drag-preview-dropping]="isSettling()"
3714
+ [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3438
3715
  [style.transform]="transform()"
3439
3716
  [style.width.px]="dimensions().width"
3440
3717
  [style.height.px]="dimensions().height"
@@ -3446,7 +3723,7 @@ class DragPreviewComponent {
3446
3723
  <div class="vdnd-drag-preview-clone" #cloneContainer></div>
3447
3724
  } @else {
3448
3725
  <div class="vdnd-drag-preview-default">
3449
- {{ dragState.draggedItem()?.draggableId }}
3726
+ {{ displayedItem()?.draggableId }}
3450
3727
  </div>
3451
3728
  }
3452
3729
  </div>
@@ -3458,8 +3735,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3458
3735
  args: [{ selector: 'vdnd-drag-preview', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet], template: `
3459
3736
  @if (isVisible()) {
3460
3737
  <div
3738
+ #preview
3461
3739
  class="vdnd-drag-preview"
3462
- data-testid="vdnd-drag-preview"
3740
+ [class.vdnd-drag-preview-dropping]="isSettling()"
3741
+ [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3463
3742
  [style.transform]="transform()"
3464
3743
  [style.width.px]="dimensions().width"
3465
3744
  [style.height.px]="dimensions().height"
@@ -3471,13 +3750,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3471
3750
  <div class="vdnd-drag-preview-clone" #cloneContainer></div>
3472
3751
  } @else {
3473
3752
  <div class="vdnd-drag-preview-default">
3474
- {{ dragState.draggedItem()?.draggableId }}
3753
+ {{ displayedItem()?.draggableId }}
3475
3754
  </div>
3476
3755
  }
3477
3756
  </div>
3478
3757
  }
3479
3758
  `, styles: [".vdnd-drag-preview{box-sizing:border-box;position:fixed;left:0;top:0;will-change:transform;pointer-events:none;z-index:1000}.vdnd-drag-preview-clone{width:100%;height:100%;overflow:hidden}\n"] }]
3480
- }], ctorParameters: () => [], propDecorators: { previewTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewTemplate", required: false }] }], cursorOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "cursorOffset", required: false }] }], cloneContainer: [{ type: i0.ViewChild, args: ['cloneContainer', { isSignal: true }] }] } });
3759
+ }], ctorParameters: () => [], propDecorators: { previewTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewTemplate", required: false }] }], cursorOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "cursorOffset", required: false }] }], cloneContainer: [{ type: i0.ViewChild, args: ['cloneContainer', { isSignal: true }] }], previewElement: [{ type: i0.ViewChild, args: ['preview', { isSignal: true }] }] } });
3481
3760
 
3482
3761
  /**
3483
3762
  * A placeholder component that indicates where a dropped item will be inserted.
@@ -4754,9 +5033,13 @@ class KeyboardDragHandler {
4754
5033
  sourceIndex,
4755
5034
  destinationIndex,
4756
5035
  });
5036
+ // The item stays in its source list when there is no valid target
5037
+ const fallbackDroppableId = hasValidTarget
5038
+ ? activeDroppableId
5039
+ : this.#deps.dragState.sourceDroppableId();
4757
5040
  this.#deps.keyboardDrag.completeKeyboardDrag();
4758
5041
  // Restore focus to the moved element after state updates
4759
- this.#restoreFocus(ctx.draggableId);
5042
+ this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
4760
5043
  }
4761
5044
  /**
4762
5045
  * Cancel a keyboard drag operation.
@@ -4764,6 +5047,8 @@ class KeyboardDragHandler {
4764
5047
  cancel() {
4765
5048
  const ctx = this.#deps.getContext();
4766
5049
  const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
5050
+ // A cancelled item goes back to its source list
5051
+ const sourceDroppableId = this.#deps.dragState.sourceDroppableId();
4767
5052
  // Remove document listener
4768
5053
  this.#cleanupDocumentListener();
4769
5054
  // Clear droppable metadata cache from this drag session
@@ -4779,7 +5064,7 @@ class KeyboardDragHandler {
4779
5064
  });
4780
5065
  this.#deps.keyboardDrag.cancelKeyboardDrag();
4781
5066
  // Restore focus to the original element after state updates
4782
- this.#restoreFocus(ctx.draggableId);
5067
+ this.#restoreFocus(ctx.draggableId, sourceDroppableId);
4783
5068
  }
4784
5069
  /**
4785
5070
  * Teardown — remove any active document listener.
@@ -4794,20 +5079,21 @@ class KeyboardDragHandler {
4794
5079
  *
4795
5080
  * Uses EnvironmentInjector to ensure callback runs even if the directive
4796
5081
  * is destroyed during cross-list moves.
5082
+ *
5083
+ * `fallbackDroppableId` is the list whose first draggable gets focus when the element is not
5084
+ * rendered (for example scrolled out of a virtual list). Callers read it before ending the
5085
+ * drag, which clears the drag state.
4797
5086
  */
4798
- #restoreFocus(draggableId) {
4799
- // Capture the destination droppable BEFORE scheduling afterNextRender
4800
- // (the directive may be destroyed during cross-list moves)
4801
- const destinationDroppableId = this.#deps.dragState.activeDroppableId();
5087
+ #restoreFocus(draggableId, fallbackDroppableId) {
4802
5088
  afterNextRender(() => {
4803
5089
  const element = queryByAttribute(document, 'data-draggable-id', draggableId);
4804
5090
  if (element) {
4805
5091
  element.focus();
4806
5092
  }
4807
- else if (destinationDroppableId) {
4808
- // Fallback: focus the first draggable in the destination container
4809
- const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
4810
- const firstDraggable = destination?.querySelector('[data-draggable-id]');
5093
+ else if (fallbackDroppableId) {
5094
+ // Fallback: focus the first draggable in the list the item ended up in
5095
+ const container = queryByAttribute(document, 'data-droppable-id', fallbackDroppableId);
5096
+ const firstDraggable = container?.querySelector('[data-draggable-id]');
4811
5097
  firstDraggable?.focus();
4812
5098
  }
4813
5099
  }, { injector: this.#deps.envInjector });
@@ -4866,7 +5152,7 @@ var _a;
4866
5152
  * - Pointer down / move / up lifecycle
4867
5153
  * - Drag threshold detection
4868
5154
  * - Drag delay timer
4869
- * - RAF throttling for move updates
5155
+ * - Forwarding moves to the directive (DragSchedulerService coalesces them per frame)
4870
5156
  * - Document-level listener management
4871
5157
  * - Escape key cancellation during pointer drag
4872
5158
  */