ngx-virtual-dnd 3.2.0-alpha.0 → 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 +53 -519
- package/fesm2022/ngx-virtual-dnd.mjs +113 -48
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +2 -2
- package/types/ngx-virtual-dnd.d.ts +11 -0
|
@@ -1882,6 +1882,8 @@ class KeyboardDragService {
|
|
|
1882
1882
|
#dragState = inject(DragStateService);
|
|
1883
1883
|
/** Total item count for the current droppable (set by droppable on keyboard drag start) */
|
|
1884
1884
|
#totalItemCount = signal(0, ...(ngDevMode ? [{ debugName: "#totalItemCount" }] : []));
|
|
1885
|
+
/** Per-droppable callbacks that scroll the placeholder into view (see `registerRevealer`) */
|
|
1886
|
+
#revealers = new Map();
|
|
1885
1887
|
/** Whether a keyboard drag is currently active */
|
|
1886
1888
|
isActive = computed(() => this.#dragState.isKeyboardDrag() && this.#dragState.isDragging(), ...(ngDevMode ? [{ debugName: "isActive" }] : []));
|
|
1887
1889
|
/** Current target index during keyboard navigation */
|
|
@@ -1917,9 +1919,10 @@ class KeyboardDragService {
|
|
|
1917
1919
|
if (!this.isActive()) {
|
|
1918
1920
|
return targetIndex;
|
|
1919
1921
|
}
|
|
1920
|
-
const
|
|
1921
|
-
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));
|
|
1922
1924
|
this.#dragState.setKeyboardTargetIndex(clampedIndex);
|
|
1925
|
+
this.#revealActivePlaceholder();
|
|
1923
1926
|
return clampedIndex;
|
|
1924
1927
|
}
|
|
1925
1928
|
/**
|
|
@@ -1947,8 +1950,26 @@ class KeyboardDragService {
|
|
|
1947
1950
|
return;
|
|
1948
1951
|
}
|
|
1949
1952
|
this.#totalItemCount.set(totalItemCount);
|
|
1950
|
-
const clampedIndex = Math.max(0, Math.min(targetIndex,
|
|
1953
|
+
const clampedIndex = Math.max(0, Math.min(targetIndex, this.#maxTargetIndex(droppableId)));
|
|
1951
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
|
+
}
|
|
1952
1973
|
}
|
|
1953
1974
|
/**
|
|
1954
1975
|
* Complete the keyboard drag (Space or Enter to drop).
|
|
@@ -1974,6 +1995,22 @@ class KeyboardDragService {
|
|
|
1974
1995
|
setTotalItemCount(count) {
|
|
1975
1996
|
this.#totalItemCount.set(count);
|
|
1976
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
|
+
}
|
|
1977
2014
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1978
2015
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: KeyboardDragService, providedIn: 'root' });
|
|
1979
2016
|
}
|
|
@@ -2662,6 +2699,7 @@ class VirtualScrollContainerComponent {
|
|
|
2662
2699
|
#elementRef = inject((ElementRef));
|
|
2663
2700
|
#autoScrollService = inject(AutoScrollService);
|
|
2664
2701
|
#dragIndexCalculator = inject(DragIndexCalculatorService);
|
|
2702
|
+
#keyboardDrag = inject(KeyboardDragService);
|
|
2665
2703
|
#ngZone = inject(NgZone);
|
|
2666
2704
|
#injector = inject(Injector);
|
|
2667
2705
|
/** Cleanup function for scroll listener */
|
|
@@ -2979,43 +3017,19 @@ class VirtualScrollContainerComponent {
|
|
|
2979
3017
|
untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
|
|
2980
3018
|
});
|
|
2981
3019
|
}
|
|
2982
|
-
// Keyboard drag autoscroll:
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
const
|
|
2988
|
-
if (
|
|
2989
|
-
return;
|
|
2990
|
-
const targetIndex = this.#dragState.keyboardTargetIndex();
|
|
2991
|
-
if (targetIndex === null)
|
|
2992
|
-
return;
|
|
2993
|
-
const strategy = this.#strategy();
|
|
2994
|
-
strategy.version();
|
|
2995
|
-
const height = this.effectiveHeight();
|
|
2996
|
-
if (height <= 0)
|
|
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)
|
|
2997
3027
|
return;
|
|
2998
|
-
const
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
const targetTop = strategy.getOffsetForIndex(targetIndex);
|
|
3002
|
-
const targetBottom = targetTop + strategy.getItemHeight(targetIndex);
|
|
3003
|
-
// Calculate visible range
|
|
3004
|
-
const viewportTop = currentScrollTop;
|
|
3005
|
-
const viewportBottom = currentScrollTop + height;
|
|
3006
|
-
// Check if target is fully visible
|
|
3007
|
-
if (targetTop < viewportTop) {
|
|
3008
|
-
// Target is above viewport - scroll up
|
|
3009
|
-
element.scrollTop = targetTop;
|
|
3010
|
-
this.#scrollTop.set(targetTop);
|
|
3011
|
-
}
|
|
3012
|
-
else if (targetBottom > viewportBottom) {
|
|
3013
|
-
// Target is below viewport - scroll down
|
|
3014
|
-
const newScrollTop = targetBottom - height;
|
|
3015
|
-
element.scrollTop = newScrollTop;
|
|
3016
|
-
this.#scrollTop.set(newScrollTop);
|
|
3017
|
-
}
|
|
3028
|
+
const reveal = () => untracked(() => this.#revealPlaceholder());
|
|
3029
|
+
this.#keyboardDrag.registerRevealer(droppableId, reveal);
|
|
3030
|
+
onCleanup(() => this.#keyboardDrag.unregisterRevealer(droppableId, reveal));
|
|
3018
3031
|
});
|
|
3032
|
+
effect(() => this.#revealPlaceholder());
|
|
3019
3033
|
// Preserve scroll position when drag ends at bottom of list.
|
|
3020
3034
|
// Safety net: totalHeight no longer shrinks during drag (getTotalHeight
|
|
3021
3035
|
// ignores exclusion), but this logic remains as harmless protection
|
|
@@ -3096,6 +3110,50 @@ class VirtualScrollContainerComponent {
|
|
|
3096
3110
|
this.#observeRenderedItems();
|
|
3097
3111
|
}
|
|
3098
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
|
+
}
|
|
3099
3157
|
ngOnDestroy() {
|
|
3100
3158
|
this.#scrollCleanup?.();
|
|
3101
3159
|
this.#resizeCleanup?.();
|
|
@@ -4754,9 +4812,13 @@ class KeyboardDragHandler {
|
|
|
4754
4812
|
sourceIndex,
|
|
4755
4813
|
destinationIndex,
|
|
4756
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();
|
|
4757
4819
|
this.#deps.keyboardDrag.completeKeyboardDrag();
|
|
4758
4820
|
// Restore focus to the moved element after state updates
|
|
4759
|
-
this.#restoreFocus(ctx.draggableId);
|
|
4821
|
+
this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
|
|
4760
4822
|
}
|
|
4761
4823
|
/**
|
|
4762
4824
|
* Cancel a keyboard drag operation.
|
|
@@ -4764,6 +4826,8 @@ class KeyboardDragHandler {
|
|
|
4764
4826
|
cancel() {
|
|
4765
4827
|
const ctx = this.#deps.getContext();
|
|
4766
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();
|
|
4767
4831
|
// Remove document listener
|
|
4768
4832
|
this.#cleanupDocumentListener();
|
|
4769
4833
|
// Clear droppable metadata cache from this drag session
|
|
@@ -4779,7 +4843,7 @@ class KeyboardDragHandler {
|
|
|
4779
4843
|
});
|
|
4780
4844
|
this.#deps.keyboardDrag.cancelKeyboardDrag();
|
|
4781
4845
|
// Restore focus to the original element after state updates
|
|
4782
|
-
this.#restoreFocus(ctx.draggableId);
|
|
4846
|
+
this.#restoreFocus(ctx.draggableId, sourceDroppableId);
|
|
4783
4847
|
}
|
|
4784
4848
|
/**
|
|
4785
4849
|
* Teardown — remove any active document listener.
|
|
@@ -4794,20 +4858,21 @@ class KeyboardDragHandler {
|
|
|
4794
4858
|
*
|
|
4795
4859
|
* Uses EnvironmentInjector to ensure callback runs even if the directive
|
|
4796
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.
|
|
4797
4865
|
*/
|
|
4798
|
-
#restoreFocus(draggableId) {
|
|
4799
|
-
// Capture the destination droppable BEFORE scheduling afterNextRender
|
|
4800
|
-
// (the directive may be destroyed during cross-list moves)
|
|
4801
|
-
const destinationDroppableId = this.#deps.dragState.activeDroppableId();
|
|
4866
|
+
#restoreFocus(draggableId, fallbackDroppableId) {
|
|
4802
4867
|
afterNextRender(() => {
|
|
4803
4868
|
const element = queryByAttribute(document, 'data-draggable-id', draggableId);
|
|
4804
4869
|
if (element) {
|
|
4805
4870
|
element.focus();
|
|
4806
4871
|
}
|
|
4807
|
-
else if (
|
|
4808
|
-
// Fallback: focus the first draggable in the
|
|
4809
|
-
const
|
|
4810
|
-
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]');
|
|
4811
4876
|
firstDraggable?.focus();
|
|
4812
4877
|
}
|
|
4813
4878
|
}, { injector: this.#deps.envInjector });
|