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

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,
@@ -2426,6 +2445,181 @@ function createAutoScrollRegistration(options) {
2426
2445
  destroyRef.onDestroy(unregisterCurrent);
2427
2446
  }
2428
2447
 
2448
+ /** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
2449
+ const MIN_SHIFT_PX = 0.5;
2450
+ /**
2451
+ * FLIP animator for items displaced by the drag placeholder.
2452
+ *
2453
+ * 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
2455
+ * the render measures again and plays a compositor-only `transform` animation from the
2456
+ * old position to the new one.
2457
+ *
2458
+ * Positions are measured relative to the scroll content, so scrolling (including
2459
+ * programmatic scroll in the same tick) never animates. Measurements include any
2460
+ * in-flight animation, so a new displacement retargets from where the item currently
2461
+ * is instead of stacking or jumping. Elements whose target did not change keep their
2462
+ * running animation untouched.
2463
+ * @internal
2464
+ */
2465
+ class ShiftAnimator {
2466
+ #config;
2467
+ #injector;
2468
+ #getScrollElement;
2469
+ #getEntries;
2470
+ #running = new Map();
2471
+ #snapshot = null;
2472
+ #reducedMotionQuery;
2473
+ #wasDragging = false;
2474
+ #lastPlaceholderIndex = -1;
2475
+ constructor(options) {
2476
+ this.#config = options.config;
2477
+ this.#injector = options.injector;
2478
+ this.#getScrollElement = options.getScrollElement;
2479
+ this.#getEntries = options.getEntries;
2480
+ }
2481
+ /**
2482
+ * Notify the animator of the drag state about to be rendered.
2483
+ * @param placeholderIndex placeholder index in this list, or -1 when not shown here
2484
+ */
2485
+ beforeUpdate(isDragging, placeholderIndex) {
2486
+ const wasDragging = this.#wasDragging;
2487
+ const previousIndex = this.#lastPlaceholderIndex;
2488
+ this.#wasDragging = isDragging;
2489
+ this.#lastPlaceholderIndex = placeholderIndex;
2490
+ if (!isDragging) {
2491
+ // Drop/cancel: the list re-renders with the committed order, so snap everything.
2492
+ this.#snapshot = null;
2493
+ if (wasDragging) {
2494
+ this.cancelAll();
2495
+ }
2496
+ return;
2497
+ }
2498
+ // Drag start renders the placeholder in the hidden item's slot — nothing moves.
2499
+ // A pending snapshot means another update already captured this tick's "before".
2500
+ if (!wasDragging || placeholderIndex === previousIndex || this.#snapshot) {
2501
+ return;
2502
+ }
2503
+ if (!this.#isEnabled()) {
2504
+ return;
2505
+ }
2506
+ this.#snapshot = this.#measure();
2507
+ afterNextRender(() => this.#play(), { injector: this.#injector });
2508
+ }
2509
+ /** Stop the shift animation on an element (e.g. before its view is recycled). */
2510
+ cancel(element) {
2511
+ const running = this.#running.get(element);
2512
+ if (!running)
2513
+ return;
2514
+ this.#running.delete(element);
2515
+ running.animation.cancel();
2516
+ }
2517
+ /** Stop every running shift animation. */
2518
+ cancelAll() {
2519
+ for (const running of this.#running.values()) {
2520
+ running.animation.cancel();
2521
+ }
2522
+ this.#running.clear();
2523
+ }
2524
+ #isEnabled() {
2525
+ if (this.#duration() <= 0)
2526
+ return false;
2527
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
2528
+ return false;
2529
+ }
2530
+ return !this.#prefersReducedMotion();
2531
+ }
2532
+ #duration() {
2533
+ return this.#config.shiftDuration ?? DEFAULT_SHIFT_DURATION;
2534
+ }
2535
+ #prefersReducedMotion() {
2536
+ if (this.#reducedMotionQuery === undefined) {
2537
+ this.#reducedMotionQuery =
2538
+ typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
2539
+ }
2540
+ return this.#reducedMotionQuery?.matches ?? false;
2541
+ }
2542
+ /** Scroll-content origin, so positions are independent of the scroll offset. */
2543
+ #origin() {
2544
+ const scrollElement = this.#getScrollElement();
2545
+ if (!scrollElement)
2546
+ return { x: 0, y: 0 };
2547
+ const rect = scrollElement.getBoundingClientRect();
2548
+ return { x: rect.left - scrollElement.scrollLeft, y: rect.top - scrollElement.scrollTop };
2549
+ }
2550
+ #measure() {
2551
+ const origin = this.#origin();
2552
+ const positions = new Map();
2553
+ for (const [key, element] of this.#getEntries()) {
2554
+ const rect = element.getBoundingClientRect();
2555
+ // Hidden (display: none) elements, such as the dragged item, have no box.
2556
+ if (rect.width === 0 && rect.height === 0)
2557
+ continue;
2558
+ positions.set(element, { key, x: rect.left - origin.x, y: rect.top - origin.y });
2559
+ }
2560
+ return positions;
2561
+ }
2562
+ #play() {
2563
+ const first = this.#snapshot;
2564
+ this.#snapshot = null;
2565
+ if (!first || !this.#wasDragging)
2566
+ return;
2567
+ const last = this.#measure();
2568
+ const stale = [];
2569
+ const shifts = [];
2570
+ // Read phase — no writes until every element is measured.
2571
+ for (const [element, to] of last) {
2572
+ const from = first.get(element);
2573
+ if (!from || !Object.is(from.key, to.key)) {
2574
+ // Newly rendered or recycled for another item: must not animate from a stale offset.
2575
+ stale.push(element);
2576
+ continue;
2577
+ }
2578
+ const dx = from.x - to.x;
2579
+ const dy = from.y - to.y;
2580
+ if (Math.abs(dx) < MIN_SHIFT_PX && Math.abs(dy) < MIN_SHIFT_PX) {
2581
+ continue;
2582
+ }
2583
+ // Both measurements include the in-flight offset, so the new animation must
2584
+ // start from that offset plus the layout delta to continue from the current spot.
2585
+ const current = this.#currentOffset(element);
2586
+ shifts.push({ element, x: dx + current.x, y: dy + current.y });
2587
+ }
2588
+ // Write phase
2589
+ for (const element of stale) {
2590
+ this.cancel(element);
2591
+ }
2592
+ for (const shift of shifts) {
2593
+ this.#start(shift.element, shift.x, shift.y);
2594
+ }
2595
+ }
2596
+ #currentOffset(element) {
2597
+ const running = this.#running.get(element);
2598
+ // Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
2599
+ const progress = running?.animation.effect?.getComputedTiming().progress;
2600
+ if (!running || progress === null || progress === undefined) {
2601
+ return { x: 0, y: 0 };
2602
+ }
2603
+ const remaining = 1 - progress;
2604
+ return { x: running.x * remaining, y: running.y * remaining };
2605
+ }
2606
+ #start(element, x, y) {
2607
+ this.#running.get(element)?.animation.cancel();
2608
+ const animation = element.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0px, 0px)' }], {
2609
+ duration: this.#duration(),
2610
+ easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
2611
+ // Layer on top of any transform the consumer already applies to the item.
2612
+ composite: 'add',
2613
+ });
2614
+ this.#running.set(element, { animation, x, y });
2615
+ animation.onfinish = () => {
2616
+ if (this.#running.get(element)?.animation === animation) {
2617
+ this.#running.delete(element);
2618
+ }
2619
+ };
2620
+ }
2621
+ }
2622
+
2429
2623
  /**
2430
2624
  * A virtual scroll container that only renders visible items.
2431
2625
  *
@@ -2481,6 +2675,8 @@ class VirtualScrollContainerComponent {
2481
2675
  #observedElementByKey = new Map();
2482
2676
  /** Measured height from ResizeObserver (used when containerHeight is not provided) */
2483
2677
  #measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
2678
+ /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
2679
+ #shiftAnimator = this.#createShiftAnimator();
2484
2680
  /** Template for rendering each item - passed as input instead of content child for reliability */
2485
2681
  itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
2486
2682
  /** Unique ID for this scroll container (used for auto-scroll registration) */
@@ -2773,6 +2969,16 @@ class VirtualScrollContainerComponent {
2773
2969
  const draggedIndex = this.#draggedItemIndex();
2774
2970
  strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
2775
2971
  });
2972
+ // Snapshot item positions before the template re-renders the placeholder move.
2973
+ // Component effects run before the component's own template is refreshed.
2974
+ if (this.#shiftAnimator) {
2975
+ const animator = this.#shiftAnimator;
2976
+ effect(() => {
2977
+ const isDragging = this.#dragState.isDragging();
2978
+ const placeholderIndex = this.placeholderIndex();
2979
+ untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
2980
+ });
2981
+ }
2776
2982
  // Keyboard drag autoscroll: scroll to keep target index visible
2777
2983
  effect(() => {
2778
2984
  // Only apply when this droppable is active during keyboard drag
@@ -2898,6 +3104,32 @@ class VirtualScrollContainerComponent {
2898
3104
  }
2899
3105
  this.#observedElementByKey.clear();
2900
3106
  this.#itemResizeObserver?.disconnect();
3107
+ this.#shiftAnimator?.cancelAll();
3108
+ }
3109
+ #createShiftAnimator() {
3110
+ const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
3111
+ if (!config)
3112
+ return null;
3113
+ return new ShiftAnimator({
3114
+ config,
3115
+ injector: this.#injector,
3116
+ getScrollElement: () => this.#elementRef.nativeElement,
3117
+ getEntries: () => this.#shiftAnimationEntries(),
3118
+ });
3119
+ }
3120
+ /**
3121
+ * Rendered item roots and the placeholder. `@for` never reuses a view for a different
3122
+ * track key, so each element is its own stable identity.
3123
+ */
3124
+ *#shiftAnimationEntries() {
3125
+ const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3126
+ if (!wrapper)
3127
+ return;
3128
+ for (const child of Array.from(wrapper.children)) {
3129
+ if (child instanceof HTMLElement) {
3130
+ yield [child, child];
3131
+ }
3132
+ }
2901
3133
  }
2902
3134
  /**
2903
3135
  * Set up ResizeObserver for measuring individual item heights.
@@ -3461,6 +3693,11 @@ class DroppableDirective {
3461
3693
  /** Emits when an item is dropped on this droppable */
3462
3694
  // eslint-disable-next-line @angular-eslint/no-output-native
3463
3695
  drop = output();
3696
+ /**
3697
+ * Emits each time the placeholder moves within this droppable during a drag
3698
+ * (every item displacement) — e.g. to trigger haptic feedback.
3699
+ */
3700
+ placeholderMove = output();
3464
3701
  /** Whether this droppable is currently being targeted */
3465
3702
  isActive = computed(() => {
3466
3703
  const activeId = this.#dragState.activeDroppableId();
@@ -3473,6 +3710,27 @@ class DroppableDirective {
3473
3710
  }
3474
3711
  return this.#dragState.placeholderId();
3475
3712
  }, ...(ngDevMode ? [{ debugName: "placeholderId" }] : []));
3713
+ /**
3714
+ * The placeholder's insertion index in this droppable (DropEvent convention),
3715
+ * or null when the placeholder is not here.
3716
+ */
3717
+ #placeholderInsertionIndex = computed(() => {
3718
+ if (!this.isActive() || !this.#dragState.isDragging()) {
3719
+ return null;
3720
+ }
3721
+ const sourceIndex = this.#dragState.sourceIndex();
3722
+ return normalizeDropDestinationIndex({
3723
+ sourceIndex: sourceIndex ?? -1,
3724
+ placeholderIndex: this.#dragState.placeholderIndex(),
3725
+ // Without a known source index there is no hidden-source adjustment to undo
3726
+ sourceDroppableId: sourceIndex === null ? null : this.#dragState.sourceDroppableId(),
3727
+ activeDroppableId: this.vdndDroppable(),
3728
+ });
3729
+ }, ...(ngDevMode ? [{ debugName: "#placeholderInsertionIndex" }] : []));
3730
+ /** Last insertion index reported via placeholderMove (null when not here) */
3731
+ #lastPlaceholderIndex = null;
3732
+ /** Drag whose initial placeholder position has already been seen by this droppable */
3733
+ #placeholderTrackedDrag = null;
3476
3734
  /** Track previous active state to detect the drag-end transition */
3477
3735
  #wasActive = false;
3478
3736
  /**
@@ -3502,6 +3760,10 @@ class DroppableDirective {
3502
3760
  this.#positionCalculator.notifyCandidatesChanged(group);
3503
3761
  }
3504
3762
  });
3763
+ effect(() => {
3764
+ const currentIndex = this.#placeholderInsertionIndex();
3765
+ untracked(() => this.#handlePlaceholderMove(currentIndex));
3766
+ });
3505
3767
  // React to state changes and handle drop events.
3506
3768
  //
3507
3769
  // Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
@@ -3551,6 +3813,37 @@ class DroppableDirective {
3551
3813
  this.#positionCalculator.notifyCandidatesChanged(group);
3552
3814
  }
3553
3815
  }
3816
+ /**
3817
+ * Emit placeholderMove when the placeholder moved within (or entered) this droppable.
3818
+ */
3819
+ #handlePlaceholderMove(currentIndex) {
3820
+ let previousIndex = this.#lastPlaceholderIndex;
3821
+ this.#lastPlaceholderIndex = currentIndex;
3822
+ const draggedItem = this.#dragState.draggedItem();
3823
+ if (currentIndex === null || currentIndex === previousIndex || !draggedItem) {
3824
+ return;
3825
+ }
3826
+ // The drag's first placement in the source list starts from the item's own slot:
3827
+ // nothing is displaced when the placeholder simply takes the hidden item's place.
3828
+ const isFirstPlacement = this.#placeholderTrackedDrag !== draggedItem;
3829
+ this.#placeholderTrackedDrag = draggedItem;
3830
+ const sourceDroppableId = this.#dragState.sourceDroppableId();
3831
+ const sourceIndex = this.#dragState.sourceIndex();
3832
+ if (isFirstPlacement && sourceDroppableId === this.vdndDroppable() && sourceIndex !== null) {
3833
+ if (currentIndex === sourceIndex) {
3834
+ return;
3835
+ }
3836
+ previousIndex = sourceIndex;
3837
+ }
3838
+ this.placeholderMove.emit({
3839
+ draggableId: draggedItem.draggableId,
3840
+ sourceDroppableId: sourceDroppableId ?? '',
3841
+ droppableId: this.vdndDroppable(),
3842
+ previousIndex,
3843
+ currentIndex,
3844
+ data: draggedItem.data,
3845
+ });
3846
+ }
3554
3847
  /**
3555
3848
  * Handle a drop on this droppable.
3556
3849
  */
@@ -3654,7 +3947,7 @@ class DroppableDirective {
3654
3947
  return this.#elementRef.nativeElement.scrollHeight;
3655
3948
  }
3656
3949
  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 });
3950
+ 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
3951
  }
3659
3952
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableDirective, decorators: [{
3660
3953
  type: Directive,
@@ -3671,7 +3964,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3671
3964
  '[class.vdnd-droppable-disabled]': 'disabled()',
3672
3965
  },
3673
3966
  }]
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"] }] } });
3967
+ }], 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
3968
 
3676
3969
  /**
3677
3970
  * A high-level component that combines droppable, virtual scroll, and placeholder
@@ -3760,8 +4053,10 @@ class VirtualSortableListComponent {
3760
4053
  /** Emits when an item is dropped on this list */
3761
4054
  // eslint-disable-next-line @angular-eslint/no-output-native
3762
4055
  drop = output();
4056
+ /** Emits each time the placeholder moves within this list (every item displacement) */
4057
+ placeholderMove = output();
3763
4058
  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: `
4059
+ 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
4060
  <div
3766
4061
  class="vdnd-sortable-list-droppable"
3767
4062
  [vdndDroppable]="droppableId()"
@@ -3770,6 +4065,7 @@ class VirtualSortableListComponent {
3770
4065
  [disabled]="disabled()"
3771
4066
  [constrainToContainer]="constrainToContainer()"
3772
4067
  (drop)="drop.emit($event)"
4068
+ (placeholderMove)="placeholderMove.emit($event)"
3773
4069
  >
3774
4070
  <vdnd-virtual-scroll
3775
4071
  [items]="items()"
@@ -3787,7 +4083,7 @@ class VirtualSortableListComponent {
3787
4083
  >
3788
4084
  </vdnd-virtual-scroll>
3789
4085
  </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 });
4086
+ `, 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
4087
  }
3792
4088
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
3793
4089
  type: Component,
@@ -3802,6 +4098,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3802
4098
  [disabled]="disabled()"
3803
4099
  [constrainToContainer]="constrainToContainer()"
3804
4100
  (drop)="drop.emit($event)"
4101
+ (placeholderMove)="placeholderMove.emit($event)"
3805
4102
  >
3806
4103
  <vdnd-virtual-scroll
3807
4104
  [items]="items()"
@@ -3820,7 +4117,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3820
4117
  </vdnd-virtual-scroll>
3821
4118
  </div>
3822
4119
  `, 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"] }] } });
4120
+ }], 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
4121
 
3825
4122
  /**
3826
4123
  * A virtual viewport component that provides efficient wrapper-based positioning
@@ -5537,6 +5834,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
5537
5834
  }]
5538
5835
  }], 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
5836
 
5837
+ /** Render-queue key of the placeholder entry */
5838
+ const PLACEHOLDER_KEY = '__placeholder__';
5540
5839
  /**
5541
5840
  * A structural directive for virtual scrolling within custom scroll containers.
5542
5841
  * Provides maximum flexibility for advanced use cases where the component wrapper
@@ -5588,6 +5887,8 @@ class VirtualForDirective {
5588
5887
  * When present, droppableId is inherited automatically.
5589
5888
  */
5590
5889
  #droppable = inject(DroppableDirective, { optional: true });
5890
+ /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
5891
+ #shiftAnimator = this.#createShiftAnimator();
5591
5892
  /** Whether we're inside a viewport component (use wrapper positioning) */
5592
5893
  #useViewportPositioning = this.#viewport !== null;
5593
5894
  /** Pool of views for reuse */
@@ -5787,6 +6088,31 @@ class VirtualForDirective {
5787
6088
  this.#placeholder?.remove();
5788
6089
  // Clean up ResizeObserver
5789
6090
  this.#resizeObserver?.disconnect();
6091
+ this.#shiftAnimator?.cancelAll();
6092
+ }
6093
+ #createShiftAnimator() {
6094
+ const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
6095
+ if (!config)
6096
+ return null;
6097
+ return new ShiftAnimator({
6098
+ config,
6099
+ injector: this.#injector,
6100
+ getScrollElement: () => this.#scrollContainer.nativeElement,
6101
+ getEntries: () => this.#shiftAnimationEntries(),
6102
+ });
6103
+ }
6104
+ /** Rendered item root elements (keyed by trackBy key) plus the placeholder. */
6105
+ *#shiftAnimationEntries() {
6106
+ for (const [key, view] of this.#activeViews) {
6107
+ for (const node of view.rootNodes) {
6108
+ if (node instanceof HTMLElement) {
6109
+ yield [key, node];
6110
+ }
6111
+ }
6112
+ }
6113
+ if (this.#placeholder && this.#placeholderInDom) {
6114
+ yield [PLACEHOLDER_KEY, this.#placeholder];
6115
+ }
5790
6116
  }
5791
6117
  /**
5792
6118
  * Create the placeholder element for drag operations.
@@ -5860,6 +6186,19 @@ class VirtualForDirective {
5860
6186
  }
5861
6187
  }
5862
6188
  }
6189
+ /**
6190
+ * Stop any shift animation on a view about to be pooled — a recycled view must not
6191
+ * carry an in-flight offset over to the item it renders next.
6192
+ */
6193
+ #cancelShiftAnimation(view) {
6194
+ if (!this.#shiftAnimator)
6195
+ return;
6196
+ for (const node of view.rootNodes) {
6197
+ if (node instanceof HTMLElement) {
6198
+ this.#shiftAnimator.cancel(node);
6199
+ }
6200
+ }
6201
+ }
5863
6202
  /**
5864
6203
  * Stop observing an element's root nodes.
5865
6204
  */
@@ -5892,6 +6231,8 @@ class VirtualForDirective {
5892
6231
  draggedIndex >= 0 &&
5893
6232
  isSourceList &&
5894
6233
  draggedIndex < items.length;
6234
+ // Snapshot positions before the DOM changes so displaced items can slide
6235
+ this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
5895
6236
  // Notify viewport of render start index for wrapper positioning
5896
6237
  this.#notifyViewportRenderStart(start);
5897
6238
  // 1. Build the list of items to render
@@ -5937,7 +6278,7 @@ class VirtualForDirective {
5937
6278
  if (showPlaceholder && placeholderIndex === i && !placeholderInserted) {
5938
6279
  itemsToRender.push({
5939
6280
  type: 'placeholder',
5940
- key: '__placeholder__',
6281
+ key: PLACEHOLDER_KEY,
5941
6282
  context: null,
5942
6283
  visualIndex: placeholderIndex,
5943
6284
  });
@@ -5961,7 +6302,7 @@ class VirtualForDirective {
5961
6302
  if (showPlaceholder && placeholderIndex >= items.length && !placeholderInserted) {
5962
6303
  itemsToRender.push({
5963
6304
  type: 'placeholder',
5964
- key: '__placeholder__',
6305
+ key: PLACEHOLDER_KEY,
5965
6306
  context: null,
5966
6307
  visualIndex: placeholderIndex,
5967
6308
  });
@@ -6004,6 +6345,7 @@ class VirtualForDirective {
6004
6345
  this.#viewContainer.detach(index);
6005
6346
  }
6006
6347
  this.#unobserveViewElements(view);
6348
+ this.#cancelShiftAnimation(view);
6007
6349
  this.#viewPool.push(view);
6008
6350
  this.#activeViews.delete(key);
6009
6351
  }
@@ -6398,5 +6740,5 @@ function removeAt(list, index) {
6398
6740
  * Generated bundle index. Do not edit.
6399
6741
  */
6400
6742
 
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 };
6743
+ 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
6744
  //# sourceMappingURL=ngx-virtual-dnd.mjs.map