ngx-virtual-dnd 3.1.3 → 3.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import * as i0 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';
2
+ import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, afterNextRender, ElementRef, Injector, untracked, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
3
  import { NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
@@ -452,6 +452,25 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
452
452
  */
453
453
  const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
454
454
 
455
+ /** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
456
+ const DEFAULT_SHIFT_DURATION = 200;
457
+ /** Default shift animation easing used when the config omits `shiftEasing`. */
458
+ const DEFAULT_SHIFT_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
459
+ /**
460
+ * Opt-in animation configuration. When provided, items displaced by the placeholder
461
+ * slide into their new position instead of jumping. Resolved through the element
462
+ * injector, so it can be provided app-wide or per component subtree.
463
+ *
464
+ * The animation is skipped when the user prefers reduced motion. Values are read each
465
+ * time an animation starts, so getters can toggle the animation at runtime.
466
+ *
467
+ * @example
468
+ * ```typescript
469
+ * providers: [{ provide: VDND_ANIMATION_CONFIG, useValue: { shiftDuration: 200 } }]
470
+ * ```
471
+ */
472
+ const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
473
+
455
474
  const INITIAL_CORE_STATE = {
456
475
  isDragging: false,
457
476
  draggedItem: null,
@@ -1863,6 +1882,8 @@ class KeyboardDragService {
1863
1882
  #dragState = inject(DragStateService);
1864
1883
  /** Total item count for the current droppable (set by droppable on keyboard drag start) */
1865
1884
  #totalItemCount = signal(0, ...(ngDevMode ? [{ debugName: "#totalItemCount" }] : []));
1885
+ /** Per-droppable callbacks that scroll the placeholder into view (see `registerRevealer`) */
1886
+ #revealers = new Map();
1866
1887
  /** Whether a keyboard drag is currently active */
1867
1888
  isActive = computed(() => this.#dragState.isKeyboardDrag() && this.#dragState.isDragging(), ...(ngDevMode ? [{ debugName: "isActive" }] : []));
1868
1889
  /** Current target index during keyboard navigation */
@@ -1898,9 +1919,10 @@ class KeyboardDragService {
1898
1919
  if (!this.isActive()) {
1899
1920
  return targetIndex;
1900
1921
  }
1901
- const totalItems = this.#totalItemCount();
1902
- const clampedIndex = Math.max(0, Math.min(targetIndex, totalItems));
1922
+ const maxIndex = this.#maxTargetIndex(this.activeDroppableId());
1923
+ const clampedIndex = Math.max(0, Math.min(targetIndex, maxIndex));
1903
1924
  this.#dragState.setKeyboardTargetIndex(clampedIndex);
1925
+ this.#revealActivePlaceholder();
1904
1926
  return clampedIndex;
1905
1927
  }
1906
1928
  /**
@@ -1928,8 +1950,26 @@ class KeyboardDragService {
1928
1950
  return;
1929
1951
  }
1930
1952
  this.#totalItemCount.set(totalItemCount);
1931
- const clampedIndex = Math.max(0, Math.min(targetIndex, totalItemCount));
1953
+ const clampedIndex = Math.max(0, Math.min(targetIndex, this.#maxTargetIndex(droppableId)));
1932
1954
  this.#dragState.setKeyboardActiveDroppable(droppableId, clampedIndex);
1955
+ this.#revealActivePlaceholder();
1956
+ }
1957
+ /**
1958
+ * Register the callback that scrolls a droppable's placeholder into view. It runs
1959
+ * synchronously on every keyboard move into or within that droppable, so a drop that
1960
+ * follows before the next render still lands inside the rendered range.
1961
+ */
1962
+ registerRevealer(droppableId, reveal) {
1963
+ this.#revealers.set(droppableId, reveal);
1964
+ }
1965
+ /**
1966
+ * Unregister a droppable's reveal callback. Ignored if another callback has since been
1967
+ * registered under the same ID.
1968
+ */
1969
+ unregisterRevealer(droppableId, reveal) {
1970
+ if (this.#revealers.get(droppableId) === reveal) {
1971
+ this.#revealers.delete(droppableId);
1972
+ }
1933
1973
  }
1934
1974
  /**
1935
1975
  * Complete the keyboard drag (Space or Enter to drop).
@@ -1955,6 +1995,22 @@ class KeyboardDragService {
1955
1995
  setTotalItemCount(count) {
1956
1996
  this.#totalItemCount.set(count);
1957
1997
  }
1998
+ #revealActivePlaceholder() {
1999
+ const droppableId = this.activeDroppableId();
2000
+ if (droppableId !== null) {
2001
+ this.#revealers.get(droppableId)?.();
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Last valid target index in the given droppable. Targets use the drop convention (the item's
2006
+ * final index after it leaves its source), so the source list ends at `totalItemCount - 1`,
2007
+ * while any other list can be appended to at `totalItemCount`.
2008
+ */
2009
+ #maxTargetIndex(droppableId) {
2010
+ const totalItems = this.#totalItemCount();
2011
+ const isSourceList = droppableId !== null && droppableId === this.#dragState.sourceDroppableId();
2012
+ return isSourceList ? Math.max(0, totalItems - 1) : totalItems;
2013
+ }
1958
2014
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1959
2015
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, providedIn: 'root' });
1960
2016
  }
@@ -2426,6 +2482,181 @@ function createAutoScrollRegistration(options) {
2426
2482
  destroyRef.onDestroy(unregisterCurrent);
2427
2483
  }
2428
2484
 
2485
+ /** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
2486
+ const MIN_SHIFT_PX = 0.5;
2487
+ /**
2488
+ * FLIP animator for items displaced by the drag placeholder.
2489
+ *
2490
+ * Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
2491
+ * placeholder moved, it snapshots every rendered element's visual position, then after
2492
+ * the render measures again and plays a compositor-only `transform` animation from the
2493
+ * old position to the new one.
2494
+ *
2495
+ * Positions are measured relative to the scroll content, so scrolling (including
2496
+ * programmatic scroll in the same tick) never animates. Measurements include any
2497
+ * in-flight animation, so a new displacement retargets from where the item currently
2498
+ * is instead of stacking or jumping. Elements whose target did not change keep their
2499
+ * running animation untouched.
2500
+ * @internal
2501
+ */
2502
+ class ShiftAnimator {
2503
+ #config;
2504
+ #injector;
2505
+ #getScrollElement;
2506
+ #getEntries;
2507
+ #running = new Map();
2508
+ #snapshot = null;
2509
+ #reducedMotionQuery;
2510
+ #wasDragging = false;
2511
+ #lastPlaceholderIndex = -1;
2512
+ constructor(options) {
2513
+ this.#config = options.config;
2514
+ this.#injector = options.injector;
2515
+ this.#getScrollElement = options.getScrollElement;
2516
+ this.#getEntries = options.getEntries;
2517
+ }
2518
+ /**
2519
+ * Notify the animator of the drag state about to be rendered.
2520
+ * @param placeholderIndex placeholder index in this list, or -1 when not shown here
2521
+ */
2522
+ beforeUpdate(isDragging, placeholderIndex) {
2523
+ const wasDragging = this.#wasDragging;
2524
+ const previousIndex = this.#lastPlaceholderIndex;
2525
+ this.#wasDragging = isDragging;
2526
+ this.#lastPlaceholderIndex = placeholderIndex;
2527
+ if (!isDragging) {
2528
+ // Drop/cancel: the list re-renders with the committed order, so snap everything.
2529
+ this.#snapshot = null;
2530
+ if (wasDragging) {
2531
+ this.cancelAll();
2532
+ }
2533
+ return;
2534
+ }
2535
+ // Drag start renders the placeholder in the hidden item's slot — nothing moves.
2536
+ // A pending snapshot means another update already captured this tick's "before".
2537
+ if (!wasDragging || placeholderIndex === previousIndex || this.#snapshot) {
2538
+ return;
2539
+ }
2540
+ if (!this.#isEnabled()) {
2541
+ return;
2542
+ }
2543
+ this.#snapshot = this.#measure();
2544
+ afterNextRender(() => this.#play(), { injector: this.#injector });
2545
+ }
2546
+ /** Stop the shift animation on an element (e.g. before its view is recycled). */
2547
+ cancel(element) {
2548
+ const running = this.#running.get(element);
2549
+ if (!running)
2550
+ return;
2551
+ this.#running.delete(element);
2552
+ running.animation.cancel();
2553
+ }
2554
+ /** Stop every running shift animation. */
2555
+ cancelAll() {
2556
+ for (const running of this.#running.values()) {
2557
+ running.animation.cancel();
2558
+ }
2559
+ this.#running.clear();
2560
+ }
2561
+ #isEnabled() {
2562
+ if (this.#duration() <= 0)
2563
+ return false;
2564
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
2565
+ return false;
2566
+ }
2567
+ return !this.#prefersReducedMotion();
2568
+ }
2569
+ #duration() {
2570
+ return this.#config.shiftDuration ?? DEFAULT_SHIFT_DURATION;
2571
+ }
2572
+ #prefersReducedMotion() {
2573
+ if (this.#reducedMotionQuery === undefined) {
2574
+ this.#reducedMotionQuery =
2575
+ typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
2576
+ }
2577
+ return this.#reducedMotionQuery?.matches ?? false;
2578
+ }
2579
+ /** Scroll-content origin, so positions are independent of the scroll offset. */
2580
+ #origin() {
2581
+ const scrollElement = this.#getScrollElement();
2582
+ if (!scrollElement)
2583
+ return { x: 0, y: 0 };
2584
+ const rect = scrollElement.getBoundingClientRect();
2585
+ return { x: rect.left - scrollElement.scrollLeft, y: rect.top - scrollElement.scrollTop };
2586
+ }
2587
+ #measure() {
2588
+ const origin = this.#origin();
2589
+ const positions = new Map();
2590
+ for (const [key, element] of this.#getEntries()) {
2591
+ const rect = element.getBoundingClientRect();
2592
+ // Hidden (display: none) elements, such as the dragged item, have no box.
2593
+ if (rect.width === 0 && rect.height === 0)
2594
+ continue;
2595
+ positions.set(element, { key, x: rect.left - origin.x, y: rect.top - origin.y });
2596
+ }
2597
+ return positions;
2598
+ }
2599
+ #play() {
2600
+ const first = this.#snapshot;
2601
+ this.#snapshot = null;
2602
+ if (!first || !this.#wasDragging)
2603
+ return;
2604
+ const last = this.#measure();
2605
+ const stale = [];
2606
+ const shifts = [];
2607
+ // Read phase — no writes until every element is measured.
2608
+ for (const [element, to] of last) {
2609
+ const from = first.get(element);
2610
+ if (!from || !Object.is(from.key, to.key)) {
2611
+ // Newly rendered or recycled for another item: must not animate from a stale offset.
2612
+ stale.push(element);
2613
+ continue;
2614
+ }
2615
+ const dx = from.x - to.x;
2616
+ const dy = from.y - to.y;
2617
+ if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) {
2618
+ continue;
2619
+ }
2620
+ // Both measurements include the in-flight offset, so the new animation must
2621
+ // start from that offset plus the layout delta to continue from the current spot.
2622
+ const current = this.#currentOffset(element);
2623
+ shifts.push({ element, x: dx + current.x, y: dy + current.y });
2624
+ }
2625
+ // Write phase
2626
+ for (const element of stale) {
2627
+ this.cancel(element);
2628
+ }
2629
+ for (const shift of shifts) {
2630
+ this.#start(shift.element, shift.x, shift.y);
2631
+ }
2632
+ }
2633
+ #currentOffset(element) {
2634
+ const running = this.#running.get(element);
2635
+ // Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
2636
+ const progress = running?.animation.effect?.getComputedTiming().progress;
2637
+ if (!running || progress === null || progress === undefined) {
2638
+ return { x: 0, y: 0 };
2639
+ }
2640
+ const remaining = 1 - progress;
2641
+ return { x: running.x * remaining, y: running.y * remaining };
2642
+ }
2643
+ #start(element, x, y) {
2644
+ this.#running.get(element)?.animation.cancel();
2645
+ const animation = element.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0px, 0px)' }], {
2646
+ duration: this.#duration(),
2647
+ easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
2648
+ // Layer on top of any transform the consumer already applies to the item.
2649
+ composite: 'add',
2650
+ });
2651
+ this.#running.set(element, { animation, x, y });
2652
+ animation.onfinish = () => {
2653
+ if (this.#running.get(element)?.animation === animation) {
2654
+ this.#running.delete(element);
2655
+ }
2656
+ };
2657
+ }
2658
+ }
2659
+
2429
2660
  /**
2430
2661
  * A virtual scroll container that only renders visible items.
2431
2662
  *
@@ -2468,6 +2699,7 @@ class VirtualScrollContainerComponent {
2468
2699
  #elementRef = inject((ElementRef));
2469
2700
  #autoScrollService = inject(AutoScrollService);
2470
2701
  #dragIndexCalculator = inject(DragIndexCalculatorService);
2702
+ #keyboardDrag = inject(KeyboardDragService);
2471
2703
  #ngZone = inject(NgZone);
2472
2704
  #injector = inject(Injector);
2473
2705
  /** Cleanup function for scroll listener */
@@ -2481,6 +2713,8 @@ class VirtualScrollContainerComponent {
2481
2713
  #observedElementByKey = new Map();
2482
2714
  /** Measured height from ResizeObserver (used when containerHeight is not provided) */
2483
2715
  #measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
2716
+ /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
2717
+ #shiftAnimator = this.#createShiftAnimator();
2484
2718
  /** Template for rendering each item - passed as input instead of content child for reliability */
2485
2719
  itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
2486
2720
  /** Unique ID for this scroll container (used for auto-scroll registration) */
@@ -2773,43 +3007,29 @@ class VirtualScrollContainerComponent {
2773
3007
  const draggedIndex = this.#draggedItemIndex();
2774
3008
  strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
2775
3009
  });
2776
- // Keyboard drag autoscroll: scroll to keep target index visible
2777
- effect(() => {
2778
- // Only apply when this droppable is active during keyboard drag
2779
- if (!this.#dragState.isKeyboardDrag())
2780
- return;
2781
- const activeDroppable = this.#dragState.activeDroppableId();
2782
- if (activeDroppable !== this.droppableId())
2783
- return;
2784
- const targetIndex = this.#dragState.keyboardTargetIndex();
2785
- if (targetIndex === null)
2786
- return;
2787
- const strategy = this.#strategy();
2788
- strategy.version();
2789
- const height = this.effectiveHeight();
2790
- if (height <= 0)
3010
+ // Snapshot item positions before the template re-renders the placeholder move.
3011
+ // Component effects run before the component's own template is refreshed.
3012
+ if (this.#shiftAnimator) {
3013
+ const animator = this.#shiftAnimator;
3014
+ effect(() => {
3015
+ const isDragging = this.#dragState.isDragging();
3016
+ const placeholderIndex = this.placeholderIndex();
3017
+ untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
3018
+ });
3019
+ }
3020
+ // Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
3021
+ // #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
3022
+ // move never lands outside the rendered range; this effect re-runs it when item
3023
+ // measurements or the viewport height change during the drag.
3024
+ effect((onCleanup) => {
3025
+ const droppableId = this.droppableId();
3026
+ if (!droppableId)
2791
3027
  return;
2792
- const element = this.#elementRef.nativeElement;
2793
- const currentScrollTop = element.scrollTop;
2794
- // Calculate target item position using strategy
2795
- const targetTop = strategy.getOffsetForIndex(targetIndex);
2796
- const targetBottom = targetTop + strategy.getItemHeight(targetIndex);
2797
- // Calculate visible range
2798
- const viewportTop = currentScrollTop;
2799
- const viewportBottom = currentScrollTop + height;
2800
- // Check if target is fully visible
2801
- if (targetTop < viewportTop) {
2802
- // Target is above viewport - scroll up
2803
- element.scrollTop = targetTop;
2804
- this.#scrollTop.set(targetTop);
2805
- }
2806
- else if (targetBottom > viewportBottom) {
2807
- // Target is below viewport - scroll down
2808
- const newScrollTop = targetBottom - height;
2809
- element.scrollTop = newScrollTop;
2810
- this.#scrollTop.set(newScrollTop);
2811
- }
3028
+ const reveal = () => untracked(() => this.#revealPlaceholder());
3029
+ this.#keyboardDrag.registerRevealer(droppableId, reveal);
3030
+ onCleanup(() => this.#keyboardDrag.unregisterRevealer(droppableId, reveal));
2812
3031
  });
3032
+ effect(() => this.#revealPlaceholder());
2813
3033
  // Preserve scroll position when drag ends at bottom of list.
2814
3034
  // Safety net: totalHeight no longer shrinks during drag (getTotalHeight
2815
3035
  // ignores exclusion), but this logic remains as harmless protection
@@ -2890,6 +3110,50 @@ class VirtualScrollContainerComponent {
2890
3110
  this.#observeRenderedItems();
2891
3111
  }
2892
3112
  }
3113
+ /**
3114
+ * Scroll so the placeholder is fully visible during a keyboard drag into this list.
3115
+ * Reads only signals and strategy offsets, so it works before the placeholder renders.
3116
+ */
3117
+ #revealPlaceholder() {
3118
+ // Only apply when this droppable is active during keyboard drag
3119
+ if (!this.#dragState.isKeyboardDrag())
3120
+ return;
3121
+ const activeDroppable = this.#dragState.activeDroppableId();
3122
+ if (activeDroppable !== this.droppableId())
3123
+ return;
3124
+ // The placeholder renders before the item at placeholderIndex. Below the source in the
3125
+ // same list that is keyboardTargetIndex + 1: the dragged item's slot is excluded.
3126
+ if (this.#dragState.keyboardTargetIndex() === null)
3127
+ return;
3128
+ const placeholderIndex = this.placeholderIndex();
3129
+ if (placeholderIndex < 0)
3130
+ return;
3131
+ const strategy = this.#strategy();
3132
+ strategy.version();
3133
+ const height = this.effectiveHeight();
3134
+ if (height <= 0)
3135
+ return;
3136
+ const element = this.#elementRef.nativeElement;
3137
+ const currentScrollTop = element.scrollTop;
3138
+ // Calculate placeholder position using strategy
3139
+ const targetTop = strategy.getOffsetForIndex(placeholderIndex);
3140
+ const targetBottom = targetTop + this.placeholderHeight();
3141
+ // Calculate visible range
3142
+ const viewportTop = currentScrollTop;
3143
+ const viewportBottom = currentScrollTop + height;
3144
+ // Check if target is fully visible
3145
+ if (targetTop < viewportTop) {
3146
+ // Target is above viewport - scroll up
3147
+ element.scrollTop = targetTop;
3148
+ this.#scrollTop.set(targetTop);
3149
+ }
3150
+ else if (targetBottom > viewportBottom) {
3151
+ // Target is below viewport - scroll down
3152
+ const newScrollTop = targetBottom - height;
3153
+ element.scrollTop = newScrollTop;
3154
+ this.#scrollTop.set(newScrollTop);
3155
+ }
3156
+ }
2893
3157
  ngOnDestroy() {
2894
3158
  this.#scrollCleanup?.();
2895
3159
  this.#resizeCleanup?.();
@@ -2898,6 +3162,32 @@ class VirtualScrollContainerComponent {
2898
3162
  }
2899
3163
  this.#observedElementByKey.clear();
2900
3164
  this.#itemResizeObserver?.disconnect();
3165
+ this.#shiftAnimator?.cancelAll();
3166
+ }
3167
+ #createShiftAnimator() {
3168
+ const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
3169
+ if (!config)
3170
+ return null;
3171
+ return new ShiftAnimator({
3172
+ config,
3173
+ injector: this.#injector,
3174
+ getScrollElement: () => this.#elementRef.nativeElement,
3175
+ getEntries: () => this.#shiftAnimationEntries(),
3176
+ });
3177
+ }
3178
+ /**
3179
+ * Rendered item roots and the placeholder. `@for` never reuses a view for a different
3180
+ * track key, so each element is its own stable identity.
3181
+ */
3182
+ *#shiftAnimationEntries() {
3183
+ const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3184
+ if (!wrapper)
3185
+ return;
3186
+ for (const child of Array.from(wrapper.children)) {
3187
+ if (child instanceof HTMLElement) {
3188
+ yield [child, child];
3189
+ }
3190
+ }
2901
3191
  }
2902
3192
  /**
2903
3193
  * Set up ResizeObserver for measuring individual item heights.
@@ -3461,6 +3751,11 @@ class DroppableDirective {
3461
3751
  /** Emits when an item is dropped on this droppable */
3462
3752
  // eslint-disable-next-line @angular-eslint/no-output-native
3463
3753
  drop = output();
3754
+ /**
3755
+ * Emits each time the placeholder moves within this droppable during a drag
3756
+ * (every item displacement) — e.g. to trigger haptic feedback.
3757
+ */
3758
+ placeholderMove = output();
3464
3759
  /** Whether this droppable is currently being targeted */
3465
3760
  isActive = computed(() => {
3466
3761
  const activeId = this.#dragState.activeDroppableId();
@@ -3473,6 +3768,27 @@ class DroppableDirective {
3473
3768
  }
3474
3769
  return this.#dragState.placeholderId();
3475
3770
  }, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
3771
+ /**
3772
+ * The placeholder's insertion index in this droppable (DropEvent convention),
3773
+ * or null when the placeholder is not here.
3774
+ */
3775
+ #placeholderInsertionIndex = computed(() => {
3776
+ if (!this.isActive() || !this.#dragState.isDragging()) {
3777
+ return null;
3778
+ }
3779
+ const sourceIndex = this.#dragState.sourceIndex();
3780
+ return normalizeDropDestinationIndex({
3781
+ sourceIndex: sourceIndex ?? -1,
3782
+ placeholderIndex: this.#dragState.placeholderIndex(),
3783
+ // Without a known source index there is no hidden-source adjustment to undo
3784
+ sourceDroppableId: sourceIndex === null ? null : this.#dragState.sourceDroppableId(),
3785
+ activeDroppableId: this.vdndDroppable(),
3786
+ });
3787
+ }, ...(ngDevMode ? [{ debugName: "#placeholderInsertionIndex" }] : []));
3788
+ /** Last insertion index reported via placeholderMove (null when not here) */
3789
+ #lastPlaceholderIndex = null;
3790
+ /** Drag whose initial placeholder position has already been seen by this droppable */
3791
+ #placeholderTrackedDrag = null;
3476
3792
  /** Track previous active state to detect the drag-end transition */
3477
3793
  #wasActive = false;
3478
3794
  /**
@@ -3502,6 +3818,10 @@ class DroppableDirective {
3502
3818
  this.#positionCalculator.notifyCandidatesChanged(group);
3503
3819
  }
3504
3820
  });
3821
+ effect(() => {
3822
+ const currentIndex = this.#placeholderInsertionIndex();
3823
+ untracked(() => this.#handlePlaceholderMove(currentIndex));
3824
+ });
3505
3825
  // React to state changes and handle drop events.
3506
3826
  //
3507
3827
  // Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
@@ -3551,6 +3871,37 @@ class DroppableDirective {
3551
3871
  this.#positionCalculator.notifyCandidatesChanged(group);
3552
3872
  }
3553
3873
  }
3874
+ /**
3875
+ * Emit placeholderMove when the placeholder moved within (or entered) this droppable.
3876
+ */
3877
+ #handlePlaceholderMove(currentIndex) {
3878
+ let previousIndex = this.#lastPlaceholderIndex;
3879
+ this.#lastPlaceholderIndex = currentIndex;
3880
+ const draggedItem = this.#dragState.draggedItem();
3881
+ if (currentIndex === null || currentIndex === previousIndex || !draggedItem) {
3882
+ return;
3883
+ }
3884
+ // The drag's first placement in the source list starts from the item's own slot:
3885
+ // nothing is displaced when the placeholder simply takes the hidden item's place.
3886
+ const isFirstPlacement = this.#placeholderTrackedDrag !== draggedItem;
3887
+ this.#placeholderTrackedDrag = draggedItem;
3888
+ const sourceDroppableId = this.#dragState.sourceDroppableId();
3889
+ const sourceIndex = this.#dragState.sourceIndex();
3890
+ if (isFirstPlacement && sourceDroppableId === this.vdndDroppable() && sourceIndex !== null) {
3891
+ if (currentIndex === sourceIndex) {
3892
+ return;
3893
+ }
3894
+ previousIndex = sourceIndex;
3895
+ }
3896
+ this.placeholderMove.emit({
3897
+ draggableId: draggedItem.draggableId,
3898
+ sourceDroppableId: sourceDroppableId ?? '',
3899
+ droppableId: this.vdndDroppable(),
3900
+ previousIndex,
3901
+ currentIndex,
3902
+ data: draggedItem.data,
3903
+ });
3904
+ }
3554
3905
  /**
3555
3906
  * Handle a drop on this droppable.
3556
3907
  */
@@ -3654,7 +4005,7 @@ class DroppableDirective {
3654
4005
  return this.#elementRef.nativeElement.scrollHeight;
3655
4006
  }
3656
4007
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
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 });
4008
+ 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", placeholderMove: "placeholderMove" }, 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 });
3658
4009
  }
3659
4010
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, decorators: [{
3660
4011
  type: Directive,
@@ -3671,7 +4022,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3671
4022
  '[class.vdnd-droppable-disabled]': 'disabled()',
3672
4023
  },
3673
4024
  }]
3674
- }], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }] } });
4025
+ }], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
3675
4026
 
3676
4027
  /**
3677
4028
  * A high-level component that combines droppable, virtual scroll, and placeholder
@@ -3760,8 +4111,10 @@ class VirtualSortableListComponent {
3760
4111
  /** Emits when an item is dropped on this list */
3761
4112
  // eslint-disable-next-line @angular-eslint/no-output-native
3762
4113
  drop = output();
4114
+ /** Emits each time the placeholder moves within this list (every item displacement) */
4115
+ placeholderMove = output();
3763
4116
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3764
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", 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: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
4117
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", 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", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
3765
4118
  <div
3766
4119
  class="vdnd-sortable-list-droppable"
3767
4120
  [vdndDroppable]="droppableId()"
@@ -3770,6 +4123,7 @@ class VirtualSortableListComponent {
3770
4123
  [disabled]="disabled()"
3771
4124
  [constrainToContainer]="constrainToContainer()"
3772
4125
  (drop)="drop.emit($event)"
4126
+ (placeholderMove)="placeholderMove.emit($event)"
3773
4127
  >
3774
4128
  <vdnd-virtual-scroll
3775
4129
  [items]="items()"
@@ -3787,7 +4141,7 @@ class VirtualSortableListComponent {
3787
4141
  >
3788
4142
  </vdnd-virtual-scroll>
3789
4143
  </div>
3790
- `, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4144
+ `, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3791
4145
  }
3792
4146
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
3793
4147
  type: Component,
@@ -3802,6 +4156,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3802
4156
  [disabled]="disabled()"
3803
4157
  [constrainToContainer]="constrainToContainer()"
3804
4158
  (drop)="drop.emit($event)"
4159
+ (placeholderMove)="placeholderMove.emit($event)"
3805
4160
  >
3806
4161
  <vdnd-virtual-scroll
3807
4162
  [items]="items()"
@@ -3820,7 +4175,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3820
4175
  </vdnd-virtual-scroll>
3821
4176
  </div>
3822
4177
  `, styles: [":host{display:block}\n"] }]
3823
- }], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", 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 }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }] } });
4178
+ }], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", 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 }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
3824
4179
 
3825
4180
  /**
3826
4181
  * A virtual viewport component that provides efficient wrapper-based positioning
@@ -4457,9 +4812,13 @@ class KeyboardDragHandler {
4457
4812
  sourceIndex,
4458
4813
  destinationIndex,
4459
4814
  });
4815
+ // The item stays in its source list when there is no valid target
4816
+ const fallbackDroppableId = hasValidTarget
4817
+ ? activeDroppableId
4818
+ : this.#deps.dragState.sourceDroppableId();
4460
4819
  this.#deps.keyboardDrag.completeKeyboardDrag();
4461
4820
  // Restore focus to the moved element after state updates
4462
- this.#restoreFocus(ctx.draggableId);
4821
+ this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
4463
4822
  }
4464
4823
  /**
4465
4824
  * Cancel a keyboard drag operation.
@@ -4467,6 +4826,8 @@ class KeyboardDragHandler {
4467
4826
  cancel() {
4468
4827
  const ctx = this.#deps.getContext();
4469
4828
  const sourceIndex = this.#deps.dragState.sourceIndex() ?? 0;
4829
+ // A cancelled item goes back to its source list
4830
+ const sourceDroppableId = this.#deps.dragState.sourceDroppableId();
4470
4831
  // Remove document listener
4471
4832
  this.#cleanupDocumentListener();
4472
4833
  // Clear droppable metadata cache from this drag session
@@ -4482,7 +4843,7 @@ class KeyboardDragHandler {
4482
4843
  });
4483
4844
  this.#deps.keyboardDrag.cancelKeyboardDrag();
4484
4845
  // Restore focus to the original element after state updates
4485
- this.#restoreFocus(ctx.draggableId);
4846
+ this.#restoreFocus(ctx.draggableId, sourceDroppableId);
4486
4847
  }
4487
4848
  /**
4488
4849
  * Teardown — remove any active document listener.
@@ -4497,20 +4858,21 @@ class KeyboardDragHandler {
4497
4858
  *
4498
4859
  * Uses EnvironmentInjector to ensure callback runs even if the directive
4499
4860
  * is destroyed during cross-list moves.
4861
+ *
4862
+ * `fallbackDroppableId` is the list whose first draggable gets focus when the element is not
4863
+ * rendered (for example scrolled out of a virtual list). Callers read it before ending the
4864
+ * drag, which clears the drag state.
4500
4865
  */
4501
- #restoreFocus(draggableId) {
4502
- // Capture the destination droppable BEFORE scheduling afterNextRender
4503
- // (the directive may be destroyed during cross-list moves)
4504
- const destinationDroppableId = this.#deps.dragState.activeDroppableId();
4866
+ #restoreFocus(draggableId, fallbackDroppableId) {
4505
4867
  afterNextRender(() => {
4506
4868
  const element = queryByAttribute(document, 'data-draggable-id', draggableId);
4507
4869
  if (element) {
4508
4870
  element.focus();
4509
4871
  }
4510
- else if (destinationDroppableId) {
4511
- // Fallback: focus the first draggable in the destination container
4512
- const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
4513
- const firstDraggable = destination?.querySelector('[data-draggable-id]');
4872
+ else if (fallbackDroppableId) {
4873
+ // Fallback: focus the first draggable in the list the item ended up in
4874
+ const container = queryByAttribute(document, 'data-droppable-id', fallbackDroppableId);
4875
+ const firstDraggable = container?.querySelector('[data-draggable-id]');
4514
4876
  firstDraggable?.focus();
4515
4877
  }
4516
4878
  }, { injector: this.#deps.envInjector });
@@ -5537,6 +5899,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
5537
5899
  }]
5538
5900
  }], ctorParameters: () => [], propDecorators: { 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 }] }] } });
5539
5901
 
5902
+ /** Render-queue key of the placeholder entry */
5903
+ const PLACEHOLDER_KEY = '__placeholder__';
5540
5904
  /**
5541
5905
  * A structural directive for virtual scrolling within custom scroll containers.
5542
5906
  * Provides maximum flexibility for advanced use cases where the component wrapper
@@ -5588,6 +5952,8 @@ class VirtualForDirective {
5588
5952
  * When present, droppableId is inherited automatically.
5589
5953
  */
5590
5954
  #droppable = inject(DroppableDirective, { optional: true });
5955
+ /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
5956
+ #shiftAnimator = this.#createShiftAnimator();
5591
5957
  /** Whether we're inside a viewport component (use wrapper positioning) */
5592
5958
  #useViewportPositioning = this.#viewport !== null;
5593
5959
  /** Pool of views for reuse */
@@ -5787,6 +6153,31 @@ class VirtualForDirective {
5787
6153
  this.#placeholder?.remove();
5788
6154
  // Clean up ResizeObserver
5789
6155
  this.#resizeObserver?.disconnect();
6156
+ this.#shiftAnimator?.cancelAll();
6157
+ }
6158
+ #createShiftAnimator() {
6159
+ const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
6160
+ if (!config)
6161
+ return null;
6162
+ return new ShiftAnimator({
6163
+ config,
6164
+ injector: this.#injector,
6165
+ getScrollElement: () => this.#scrollContainer.nativeElement,
6166
+ getEntries: () => this.#shiftAnimationEntries(),
6167
+ });
6168
+ }
6169
+ /** Rendered item root elements (keyed by trackBy key) plus the placeholder. */
6170
+ *#shiftAnimationEntries() {
6171
+ for (const [key, view] of this.#activeViews) {
6172
+ for (const node of view.rootNodes) {
6173
+ if (node instanceof HTMLElement) {
6174
+ yield [key, node];
6175
+ }
6176
+ }
6177
+ }
6178
+ if (this.#placeholder && this.#placeholderInDom) {
6179
+ yield [PLACEHOLDER_KEY, this.#placeholder];
6180
+ }
5790
6181
  }
5791
6182
  /**
5792
6183
  * Create the placeholder element for drag operations.
@@ -5860,6 +6251,19 @@ class VirtualForDirective {
5860
6251
  }
5861
6252
  }
5862
6253
  }
6254
+ /**
6255
+ * Stop any shift animation on a view about to be pooled — a recycled view must not
6256
+ * carry an in-flight offset over to the item it renders next.
6257
+ */
6258
+ #cancelShiftAnimation(view) {
6259
+ if (!this.#shiftAnimator)
6260
+ return;
6261
+ for (const node of view.rootNodes) {
6262
+ if (node instanceof HTMLElement) {
6263
+ this.#shiftAnimator.cancel(node);
6264
+ }
6265
+ }
6266
+ }
5863
6267
  /**
5864
6268
  * Stop observing an element's root nodes.
5865
6269
  */
@@ -5892,6 +6296,8 @@ class VirtualForDirective {
5892
6296
  draggedIndex >= 0 &&
5893
6297
  isSourceList &&
5894
6298
  draggedIndex < items.length;
6299
+ // Snapshot positions before the DOM changes so displaced items can slide
6300
+ this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
5895
6301
  // Notify viewport of render start index for wrapper positioning
5896
6302
  this.#notifyViewportRenderStart(start);
5897
6303
  // 1. Build the list of items to render
@@ -5937,7 +6343,7 @@ class VirtualForDirective {
5937
6343
  if (showPlaceholder && placeholderIndex === i && !placeholderInserted) {
5938
6344
  itemsToRender.push({
5939
6345
  type: 'placeholder',
5940
- key: '__placeholder__',
6346
+ key: PLACEHOLDER_KEY,
5941
6347
  context: null,
5942
6348
  visualIndex: placeholderIndex,
5943
6349
  });
@@ -5961,7 +6367,7 @@ class VirtualForDirective {
5961
6367
  if (showPlaceholder && placeholderIndex >= items.length && !placeholderInserted) {
5962
6368
  itemsToRender.push({
5963
6369
  type: 'placeholder',
5964
- key: '__placeholder__',
6370
+ key: PLACEHOLDER_KEY,
5965
6371
  context: null,
5966
6372
  visualIndex: placeholderIndex,
5967
6373
  });
@@ -6004,6 +6410,7 @@ class VirtualForDirective {
6004
6410
  this.#viewContainer.detach(index);
6005
6411
  }
6006
6412
  this.#unobserveViewElements(view);
6413
+ this.#cancelShiftAnimation(view);
6007
6414
  this.#viewPool.push(view);
6008
6415
  this.#activeViews.delete(key);
6009
6416
  }
@@ -6398,5 +6805,5 @@ function removeAt(list, index) {
6398
6805
  * Generated bundle index. Do not edit.
6399
6806
  */
6400
6807
 
6401
- export { AutoScrollService, ContentHeaderDirective, DragPlaceholderComponent, DragPreviewComponent, DragStateService, DraggableDirective, DroppableDirective, DroppableGroupDirective, DynamicHeightStrategy, END_OF_LIST, ElementCloneService, FixedHeightStrategy, INITIAL_DRAG_STATE, KeyboardDragService, OverlayContainerService, PlaceholderComponent, PositionCalculatorService, ScrollableDirective, VDND_GROUP_TOKEN, VDND_SCROLL_CONTAINER, VDND_VIRTUAL_VIEWPORT, VirtualContentComponent, VirtualForDirective, VirtualScrollContainerComponent, VirtualSortableListComponent, VirtualViewportComponent, applyMove, insertAt, isNoOpDrop, moveItem, removeAt, reorderItems };
6808
+ export { AutoScrollService, ContentHeaderDirective, DragPlaceholderComponent, DragPreviewComponent, DragStateService, DraggableDirective, DroppableDirective, DroppableGroupDirective, DynamicHeightStrategy, END_OF_LIST, ElementCloneService, FixedHeightStrategy, INITIAL_DRAG_STATE, KeyboardDragService, OverlayContainerService, PlaceholderComponent, PositionCalculatorService, ScrollableDirective, VDND_ANIMATION_CONFIG, VDND_GROUP_TOKEN, VDND_SCROLL_CONTAINER, VDND_VIRTUAL_VIEWPORT, VirtualContentComponent, VirtualForDirective, VirtualScrollContainerComponent, VirtualSortableListComponent, VirtualViewportComponent, applyMove, insertAt, isNoOpDrop, moveItem, removeAt, reorderItems };
6402
6809
  //# sourceMappingURL=ngx-virtual-dnd.mjs.map