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.
- package/README.md +54 -481
- package/fesm2022/ngx-virtual-dnd.mjs +464 -57
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +2 -2
- package/types/ngx-virtual-dnd.d.ts +78 -4
|
@@ -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,
|
|
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
|
|
1902
|
-
const clampedIndex = Math.max(0, Math.min(targetIndex,
|
|
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,
|
|
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
|
-
//
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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
|
|
2793
|
-
|
|
2794
|
-
|
|
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 (
|
|
4511
|
-
// Fallback: focus the first draggable in the
|
|
4512
|
-
const
|
|
4513
|
-
const firstDraggable =
|
|
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:
|
|
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:
|
|
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
|