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.
@@ -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 totalItems = this.#totalItemCount();
1921
- const clampedIndex = Math.max(0, Math.min(targetIndex, totalItems));
1922
+ const maxIndex = this.#maxTargetIndex(this.activeDroppableId());
1923
+ const clampedIndex = Math.max(0, Math.min(targetIndex, maxIndex));
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, totalItemCount));
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: scroll to keep target index visible
2983
- effect(() => {
2984
- // Only apply when this droppable is active during keyboard drag
2985
- if (!this.#dragState.isKeyboardDrag())
2986
- return;
2987
- const activeDroppable = this.#dragState.activeDroppableId();
2988
- if (activeDroppable !== this.droppableId())
2989
- return;
2990
- const targetIndex = this.#dragState.keyboardTargetIndex();
2991
- if (targetIndex === null)
2992
- return;
2993
- const strategy = this.#strategy();
2994
- strategy.version();
2995
- const height = this.effectiveHeight();
2996
- if (height <= 0)
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 element = this.#elementRef.nativeElement;
2999
- const currentScrollTop = element.scrollTop;
3000
- // Calculate target item position using strategy
3001
- const targetTop = strategy.getOffsetForIndex(targetIndex);
3002
- const targetBottom = targetTop + strategy.getItemHeight(targetIndex);
3003
- // Calculate visible range
3004
- const viewportTop = currentScrollTop;
3005
- const viewportBottom = currentScrollTop + height;
3006
- // Check if target is fully visible
3007
- if (targetTop < viewportTop) {
3008
- // Target is above viewport - scroll up
3009
- element.scrollTop = targetTop;
3010
- this.#scrollTop.set(targetTop);
3011
- }
3012
- else if (targetBottom > viewportBottom) {
3013
- // Target is below viewport - scroll down
3014
- const newScrollTop = targetBottom - height;
3015
- element.scrollTop = newScrollTop;
3016
- this.#scrollTop.set(newScrollTop);
3017
- }
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 (destinationDroppableId) {
4808
- // Fallback: focus the first draggable in the destination container
4809
- const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
4810
- const firstDraggable = destination?.querySelector('[data-draggable-id]');
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 });