ngx-virtual-dnd 3.2.1 → 3.3.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, afterNextRender, ElementRef, Injector, PLATFORM_ID, untracked, afterRenderEffect, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
2
+ import { signal, InjectionToken, Injectable, inject, computed, effect, NgZone, DestroyRef, input, ChangeDetectionStrategy, Component, afterNextRender, ElementRef, Injector, EnvironmentInjector, PLATFORM_ID, viewChild, ViewContainerRef, untracked, isDevMode, createComponent, afterRenderEffect, Directive, output, contentChild, ErrorHandler, TemplateRef } from '@angular/core';
3
3
  import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
@@ -75,9 +75,16 @@ class FixedHeightStrategy {
75
75
  // No-op for fixed height strategy
76
76
  }
77
77
  setItemKeys(keys) {
78
- const prevCount = this.#itemCount;
79
- this.#itemCount = keys.length;
80
- if (prevCount !== this.#itemCount) {
78
+ this.setItemCount(keys.length);
79
+ }
80
+ /**
81
+ * Set the item count: all this strategy needs of the keys, so the library's containers skip
82
+ * computing them.
83
+ * @internal
84
+ */
85
+ setItemCount(count) {
86
+ if (this.#itemCount !== count) {
87
+ this.#itemCount = count;
81
88
  this.#version.update((v) => v + 1);
82
89
  }
83
90
  }
@@ -108,8 +115,15 @@ class FixedHeightStrategy {
108
115
  * Cache for measured item heights in dynamic virtual scrolling.
109
116
  *
110
117
  * Tracks heights by trackBy key (survives reordering). Maintains a cumulative
111
- * offset prefix-sum array, lazily rebuilt on height change. Binary search
112
- * O(log N) for scroll-to-index lookups.
118
+ * offset prefix-sum array and binary searches it for scroll-to-index lookups.
119
+ *
120
+ * The prefix sums are brought up to date lazily and only as far as a lookup needs:
121
+ * a height change invalidates the offsets from that item on, and the next lookup
122
+ * recomputes them up to the item it asks about. Measurements land on the rendered
123
+ * rows and lookups ask about the rows in view, so while scrolling a long list each
124
+ * frame recomputes a few rows' offsets instead of the whole list. The total height
125
+ * is kept as a running sum, so the spacer height needs no prefix sums between key
126
+ * changes.
113
127
  *
114
128
  * This is a plain class (not a service) — one instance per virtual scroll.
115
129
  */
@@ -118,12 +132,21 @@ class HeightCache {
118
132
  #estimatedHeight;
119
133
  /** Map from trackBy key to measured height */
120
134
  #heightsByKey = new Map();
121
- /** Current ordered list of trackBy keys (index-aligned with item array) */
135
+ /**
136
+ * Current ordered list of trackBy keys (index-aligned with item array). A copy of the array
137
+ * given to `setKeys`, so a caller that changes its array in place and passes it again is seen.
138
+ */
122
139
  #keys = [];
140
+ /** Index of each key's first occurrence in `#keys` */
141
+ #firstIndexByKey = new Map();
142
+ /** Whether a key occurs more than once in `#keys` (a trackBy misconfiguration) */
143
+ #hasDuplicateKeys = false;
123
144
  /** Prefix sums: offsets[i] = cumulative height of items 0..i-1 */
124
145
  #offsets = [];
125
- /** Whether offsets need to be rebuilt */
126
- #dirty = true;
146
+ /** Number of leading `#offsets` entries that are up to date */
147
+ #validCount = 0;
148
+ /** Sum of the heights of all items in `#keys`, or null until it is next needed */
149
+ #totalHeight = 0;
127
150
  /** Index excluded during same-list drag (-1 = none) */
128
151
  #excludedIndex = -1;
129
152
  constructor(estimatedHeight) {
@@ -142,21 +165,31 @@ class HeightCache {
142
165
  * This must be called whenever items change order or the array changes.
143
166
  */
144
167
  setKeys(keys) {
145
- const sameOrder = keys.length === this.#keys.length &&
146
- keys.every((key, index) => Object.is(key, this.#keys[index]));
147
- const nextKeySet = new Set(keys);
148
- let pruned = false;
149
- for (const key of this.#heightsByKey.keys()) {
150
- if (!nextKeySet.has(key)) {
151
- this.#heightsByKey.delete(key);
152
- pruned = true;
153
- }
154
- }
155
- if (sameOrder && !pruned) {
156
- return false;
157
- }
158
- this.#keys = keys;
159
- this.#dirty = true;
168
+ // The same keys in the same order (an items change that moved no item) leave the offsets and
169
+ // the key index valid: only heights recorded for keys not in the list are forgotten
170
+ if (keys.length === this.#keys.length &&
171
+ keys.every((key, index) => Object.is(key, this.#keys[index]))) {
172
+ return this.#forgetHeightsNotIn(this.#firstIndexByKey);
173
+ }
174
+ // Filled from the end, so each key keeps its first index. It also serves as the set of keys
175
+ // for pruning, so building it costs no extra pass.
176
+ const firstIndexByKey = new Map();
177
+ for (let i = keys.length - 1; i >= 0; i--) {
178
+ firstIndexByKey.set(keys[i], i);
179
+ }
180
+ this.#forgetHeightsNotIn(firstIndexByKey);
181
+ // Offsets before the first key that changed position are still valid
182
+ let firstChanged = 0;
183
+ const commonLength = Math.min(keys.length, this.#keys.length);
184
+ while (firstChanged < commonLength && Object.is(keys[firstChanged], this.#keys[firstChanged])) {
185
+ firstChanged++;
186
+ }
187
+ this.#keys = keys.slice();
188
+ this.#firstIndexByKey = firstIndexByKey;
189
+ this.#hasDuplicateKeys = firstIndexByKey.size < keys.length;
190
+ this.#offsets.length = keys.length;
191
+ this.#validCount = Math.min(this.#validCount, firstChanged);
192
+ this.#totalHeight = null;
160
193
  return true;
161
194
  }
162
195
  /**
@@ -168,7 +201,21 @@ class HeightCache {
168
201
  if (existing === height)
169
202
  return false;
170
203
  this.#heightsByKey.set(key, height);
171
- this.#dirty = true;
204
+ // Only items at or after the key's first position move
205
+ const firstIndex = this.#firstIndexByKey.get(key);
206
+ if (firstIndex !== undefined) {
207
+ this.#validCount = Math.min(this.#validCount, firstIndex);
208
+ const previous = existing ?? this.#estimatedHeight;
209
+ // A key listed twice, or a non-finite height (which a running sum can't take back out),
210
+ // leaves the total to be recomputed from the offsets
211
+ this.#totalHeight =
212
+ this.#totalHeight === null ||
213
+ this.#hasDuplicateKeys ||
214
+ !Number.isFinite(height) ||
215
+ !Number.isFinite(previous)
216
+ ? null
217
+ : this.#totalHeight + height - previous;
218
+ }
172
219
  return true;
173
220
  }
174
221
  /**
@@ -186,18 +233,18 @@ class HeightCache {
186
233
  * Accounts for the excluded index during same-list drag.
187
234
  */
188
235
  getOffset(index) {
189
- this.#rebuildIfDirty();
236
+ const count = this.#keys.length;
190
237
  if (this.#excludedIndex < 0) {
191
238
  // No exclusion — direct lookup
192
- return index < this.#offsets.length ? this.#offsets[index] : this.#getTotalHeightRaw();
239
+ return index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
193
240
  }
194
241
  // With exclusion: subtract the excluded item's height from offsets
195
242
  // at or after the excluded index
196
243
  if (index <= this.#excludedIndex) {
197
- return this.#offsets[index] ?? 0;
244
+ return this.#offsetAt(index) ?? 0;
198
245
  }
199
246
  // index > excludedIndex: offset = raw offset for (index) minus excluded item's height
200
- const rawOffset = index < this.#offsets.length ? this.#offsets[index] : this.#getTotalHeightRaw();
247
+ const rawOffset = index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
201
248
  const excludedHeight = this.getHeight(this.#excludedIndex);
202
249
  return rawOffset - excludedHeight;
203
250
  }
@@ -208,7 +255,6 @@ class HeightCache {
208
255
  * Uses O(1) prefix-sum lookup when possible.
209
256
  */
210
257
  getTotalHeight(itemCount) {
211
- this.#rebuildIfDirty();
212
258
  if (itemCount <= 0)
213
259
  return 0;
214
260
  const keyCount = this.#keys.length;
@@ -219,20 +265,24 @@ class HeightCache {
219
265
  return this.#getTotalHeightRaw() + (itemCount - keyCount) * this.#estimatedHeight;
220
266
  }
221
267
  // Partial: prefix sum up to itemCount
222
- return this.#offsets[itemCount - 1] + this.getHeight(itemCount - 1);
268
+ return this.#offsetAt(itemCount - 1) + this.getHeight(itemCount - 1);
223
269
  }
224
270
  /**
225
271
  * Find the first visible index for a given scrollTop.
226
272
  * Uses binary search on the prefix-sum offsets (excluding the excluded item).
227
273
  */
228
274
  findFirstVisibleIndex(scrollTop) {
229
- this.#rebuildIfDirty();
230
275
  const count = this.#keys.length;
231
276
  if (count === 0)
232
277
  return 0;
233
- // Binary search for the first item whose bottom edge is past scrollTop
278
+ // Offsets after the excluded item sit its height higher than their raw values, so
279
+ // bring the raw offsets up to date just past scrollTop plus that height
280
+ const excludedHeight = this.#excludedIndex >= 0 ? this.getHeight(this.#excludedIndex) : 0;
281
+ this.#extendPastOffset(scrollTop + excludedHeight);
282
+ // Binary search for the last item whose top is at or above scrollTop. Items past the
283
+ // up-to-date offsets start below scrollTop, so the search can stop there.
234
284
  let lo = 0;
235
- let hi = count - 1;
285
+ let hi = Math.min(count, this.#validCount) - 1;
236
286
  let result = 0;
237
287
  while (lo <= hi) {
238
288
  const mid = (lo + hi) >>> 1;
@@ -278,7 +328,6 @@ class HeightCache {
278
328
  * Used for drag index calculations with variable heights.
279
329
  */
280
330
  findIndexAtOffset(offset) {
281
- this.#rebuildIfDirty();
282
331
  const count = this.#keys.length;
283
332
  if (count === 0)
284
333
  return 0;
@@ -295,7 +344,7 @@ class HeightCache {
295
344
  if (excludedIndex < 0 || excludedIndex >= count) {
296
345
  return this.#findFirstIndexWithBottomPastOffset(offset, 0, count - 1);
297
346
  }
298
- const excludedOffset = this.#offsets[excludedIndex];
347
+ const excludedOffset = this.#offsetAt(excludedIndex);
299
348
  if (offset < excludedOffset && excludedIndex > 0) {
300
349
  return this.#findFirstIndexWithBottomPastOffset(offset, 0, excludedIndex - 1);
301
350
  }
@@ -316,6 +365,17 @@ class HeightCache {
316
365
  this.#excludedIndex = nextIndex;
317
366
  return true;
318
367
  }
368
+ /** Forget the heights of keys not in `keys`. Returns whether it forgot any. */
369
+ #forgetHeightsNotIn(keys) {
370
+ let forgot = false;
371
+ for (const key of this.#heightsByKey.keys()) {
372
+ if (!keys.has(key)) {
373
+ this.#heightsByKey.delete(key);
374
+ forgot = true;
375
+ }
376
+ }
377
+ return forgot;
378
+ }
319
379
  /**
320
380
  * Find first logical index in [start, end] whose bottom edge is past offset.
321
381
  * Returns one-past-end when no match is found.
@@ -324,8 +384,15 @@ class HeightCache {
324
384
  if (start > end) {
325
385
  return start;
326
386
  }
387
+ // Items past the up-to-date offsets start below `offset`, so their bottoms are past it
388
+ // too: the answer is at or before the first of them
389
+ this.#extendPastOffset(offset);
390
+ const lastValid = this.#validCount - 1;
391
+ if (start > lastValid) {
392
+ return start;
393
+ }
327
394
  let lo = start;
328
- let hi = end;
395
+ let hi = Math.min(end, lastValid);
329
396
  let result = end + 1;
330
397
  while (lo <= hi) {
331
398
  const mid = (lo + hi) >>> 1;
@@ -340,27 +407,64 @@ class HeightCache {
340
407
  }
341
408
  return result;
342
409
  }
343
- /** Raw total height without exclusion */
410
+ /**
411
+ * Raw total height without exclusion, from the running sum. With fractional heights it can
412
+ * differ from the prefix sums by rounding, so it is only for the spacer height.
413
+ */
344
414
  #getTotalHeightRaw() {
345
- this.#rebuildIfDirty();
346
- const len = this.#offsets.length;
347
- if (len === 0)
415
+ if (this.#keys.length === 0)
348
416
  return 0;
349
- return this.#offsets[len - 1] + this.getHeight(len - 1);
417
+ return this.#totalHeight ?? this.#getExactTotalHeight();
350
418
  }
351
- /** Rebuild prefix-sum array if dirty */
352
- #rebuildIfDirty() {
353
- if (!this.#dirty)
354
- return;
355
- this.#dirty = false;
419
+ /**
420
+ * Raw total height computed like the offsets (the offset one past the last item), so it
421
+ * compares exactly with them. Re-syncs the running sum.
422
+ */
423
+ #getExactTotalHeight() {
356
424
  const count = this.#keys.length;
357
- if (this.#offsets.length !== count) {
358
- this.#offsets = new Array(count);
425
+ if (count === 0)
426
+ return 0;
427
+ this.#extendTo(count, Infinity);
428
+ this.#totalHeight = this.#offsets[count - 1] + this.getHeight(count - 1);
429
+ return this.#totalHeight;
430
+ }
431
+ /** The raw offset of an item, bringing the prefix sums up to date as far as it */
432
+ #offsetAt(index) {
433
+ if (index >= this.#validCount) {
434
+ this.#extendTo(index + 1, Infinity);
435
+ }
436
+ return this.#offsets[index];
437
+ }
438
+ /**
439
+ * Bring the prefix sums up to date until the last up-to-date item starts past `offset`, or
440
+ * to the end of the list.
441
+ */
442
+ #extendPastOffset(offset) {
443
+ this.#extendTo(this.#keys.length, offset);
444
+ }
445
+ /**
446
+ * Bring the first `count` prefix sums up to date, stopping early after the first item that
447
+ * starts past `stopPastOffset`.
448
+ */
449
+ #extendTo(count, stopPastOffset) {
450
+ const end = Math.min(count, this.#keys.length);
451
+ let i = this.#validCount;
452
+ if (i >= end || (i > 0 && this.#offsets[i - 1] > stopPastOffset)) {
453
+ return;
359
454
  }
360
- let cumulative = 0;
361
- for (let i = 0; i < count; i++) {
455
+ let cumulative = i === 0 ? 0 : this.#offsets[i - 1] + this.getHeight(i - 1);
456
+ while (i < end) {
362
457
  this.#offsets[i] = cumulative;
363
- cumulative += this.getHeight(i);
458
+ i++;
459
+ if (cumulative > stopPastOffset) {
460
+ break;
461
+ }
462
+ cumulative += this.getHeight(i - 1);
463
+ }
464
+ this.#validCount = i;
465
+ if (i === this.#keys.length) {
466
+ // Re-sync the running total with the prefix sums, dropping floating-point drift
467
+ this.#totalHeight = this.#offsets[i - 1] + this.getHeight(i - 1);
364
468
  }
365
469
  }
366
470
  }
@@ -487,6 +591,168 @@ const DEFAULT_DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
487
591
  */
488
592
  const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
489
593
 
594
+ /**
595
+ * Registry of the rendered droppables, keyed by group and by ID.
596
+ *
597
+ * `DroppableDirective` registers its element once rendered and unregisters on destroy, so drag
598
+ * code reads the droppables of one group instead of querying the whole document for
599
+ * `data-droppable-*` attributes. That keeps lookups proportional to the group, not the page,
600
+ * and finds droppables a document query cannot reach (inside shadow roots). It also delivers
601
+ * a drop to its target the moment the drag ends (`DragStateService.endDrag`).
602
+ * @internal
603
+ */
604
+ class DroppableRegistryService {
605
+ #byGroup = new Map();
606
+ #byId = new Map();
607
+ #listeners = new Set();
608
+ /**
609
+ * Register a droppable, with the handler that receives drops on it. Returns the function
610
+ * that unregisters it; calling that more than once is a no-op.
611
+ */
612
+ register(element, id, group, onDrop = null) {
613
+ const registration = { element, id, group, onDrop };
614
+ this.#add(this.#byGroup, group, registration);
615
+ if (id) {
616
+ this.#add(this.#byId, id, registration);
617
+ }
618
+ this.#notify(group);
619
+ let registered = true;
620
+ return () => {
621
+ if (!registered) {
622
+ return;
623
+ }
624
+ registered = false;
625
+ this.#remove(this.#byGroup, group, registration);
626
+ if (id) {
627
+ this.#remove(this.#byId, id, registration);
628
+ }
629
+ this.#notify(group);
630
+ };
631
+ }
632
+ /**
633
+ * The group's droppables that are in the document, in document order (the order they are
634
+ * painted in, which hit-testing relies on). Returns a new array.
635
+ *
636
+ * Sorted on every call rather than cached: a list moved in the DOM keeps its registration.
637
+ * Callers read it at drag start and on candidate changes, not per frame.
638
+ */
639
+ getGroup(group) {
640
+ const registrations = this.#byGroup.get(group);
641
+ if (!registrations) {
642
+ return [];
643
+ }
644
+ const elements = [];
645
+ for (const { element } of registrations) {
646
+ if (element.isConnected) {
647
+ elements.push(element);
648
+ }
649
+ }
650
+ return elements.sort(compareElements);
651
+ }
652
+ /**
653
+ * The droppable with this ID that is in the document, or null. If a re-render briefly
654
+ * leaves two with the same ID, returns the first in document order.
655
+ */
656
+ getById(id) {
657
+ return this.#firstById(id)?.element ?? null;
658
+ }
659
+ /**
660
+ * Hand an ended drag to the drop handler of its target (`activeDroppableId`), so a drop
661
+ * reaches exactly one droppable. IDs should be unique; if several connected droppables share
662
+ * it (briefly, while re-rendering), those in the source list's group come first, then
663
+ * document order, and the first that takes the drop gets it. Does nothing when the drag had
664
+ * no target or item, or the target is no longer registered.
665
+ */
666
+ deliverDrop(endedState) {
667
+ const targetId = endedState.activeDroppableId;
668
+ const registrations = targetId ? this.#byId.get(targetId) : undefined;
669
+ if (!registrations || !endedState.draggedItem) {
670
+ return;
671
+ }
672
+ const sourceId = endedState.sourceDroppableId;
673
+ const dragGroup = sourceId ? this.#firstById(sourceId)?.group : undefined;
674
+ const candidates = registrations
675
+ .filter((registration) => registration.onDrop && registration.element.isConnected)
676
+ .sort((a, b) => Number(b.group === dragGroup) - Number(a.group === dragGroup) ||
677
+ compareElements(a.element, b.element));
678
+ for (const { onDrop } of candidates) {
679
+ if (onDrop?.(endedState)) {
680
+ return;
681
+ }
682
+ }
683
+ }
684
+ /**
685
+ * Call `listener` with the group whenever a droppable of that group registers or
686
+ * unregisters. Returns the function that removes the listener.
687
+ */
688
+ onChange(listener) {
689
+ this.#listeners.add(listener);
690
+ return () => this.#listeners.delete(listener);
691
+ }
692
+ /** The registration with this ID that is in the document and first in document order. */
693
+ #firstById(id) {
694
+ const registrations = this.#byId.get(id);
695
+ if (!registrations) {
696
+ return null;
697
+ }
698
+ let match = null;
699
+ for (const registration of registrations) {
700
+ if (registration.element.isConnected &&
701
+ (!match || compareElements(registration.element, match.element) < 0)) {
702
+ match = registration;
703
+ }
704
+ }
705
+ return match;
706
+ }
707
+ #add(map, key, registration) {
708
+ const list = map.get(key);
709
+ if (list) {
710
+ list.push(registration);
711
+ }
712
+ else {
713
+ map.set(key, [registration]);
714
+ }
715
+ }
716
+ #remove(map, key, registration) {
717
+ const list = map.get(key);
718
+ const index = list?.indexOf(registration) ?? -1;
719
+ if (!list || index === -1) {
720
+ return;
721
+ }
722
+ list.splice(index, 1);
723
+ if (list.length === 0) {
724
+ map.delete(key);
725
+ }
726
+ }
727
+ #notify(group) {
728
+ for (const listener of this.#listeners) {
729
+ listener(group);
730
+ }
731
+ }
732
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
733
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, providedIn: 'root' });
734
+ }
735
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DroppableRegistryService, decorators: [{
736
+ type: Injectable,
737
+ args: [{
738
+ providedIn: 'root',
739
+ }]
740
+ }] });
741
+ /**
742
+ * Negative when `a` comes before `b` in the document (an ancestor comes before its
743
+ * descendants). Across shadow roots the order is stable but implementation-defined.
744
+ */
745
+ function compareElements(a, b) {
746
+ if (a === b) {
747
+ return 0;
748
+ }
749
+ const position = a.compareDocumentPosition(b);
750
+ return position & Node.DOCUMENT_POSITION_FOLLOWING ||
751
+ position & Node.DOCUMENT_POSITION_CONTAINED_BY
752
+ ? -1
753
+ : 1;
754
+ }
755
+
490
756
  const INITIAL_CORE_STATE = {
491
757
  isDragging: false,
492
758
  draggedItem: null,
@@ -509,6 +775,7 @@ const INITIAL_CORE_STATE = {
509
775
  * draggedItem, etc.).
510
776
  */
511
777
  class DragStateService {
778
+ #droppableRegistry = inject(DroppableRegistryService);
512
779
  /** Core state signal — contains all rarely-changing properties */
513
780
  #state = signal(INITIAL_CORE_STATE, ...(ngDevMode ? [{ debugName: "#state" }] : []));
514
781
  /** High-frequency cursor position — updated at 60fps during pointer drag */
@@ -525,7 +792,7 @@ class DragStateService {
525
792
  #endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
526
793
  /** Flag indicating if the last drag was cancelled (not dropped) */
527
794
  #wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
528
- /** Whether the last drag was cancelled (for droppable to check before emitting drop) */
795
+ /** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
529
796
  wasCancelled = this.#wasCancelled.asReadonly();
530
797
  /** Snapshot captured synchronously before the last drag reset. */
531
798
  endedDragState = this.#endedDragState.asReadonly();
@@ -662,18 +929,34 @@ class DragStateService {
662
929
  this.#placeholderIndex.set(placeholderIndex);
663
930
  }
664
931
  /**
665
- * End the drag operation and reset state (normal drop).
932
+ * End the drag operation and reset state (normal drop), then deliver the drop: the target
933
+ * droppable (`activeDroppableId`) emits `drop` before this returns.
666
934
  */
667
935
  endDrag() {
668
- this.#endedDragState.set(this.getStateSnapshot());
936
+ // Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
937
+ // ended drag, which the drop animation reads, with an empty one.
938
+ if (!this.#state().isDragging) {
939
+ return;
940
+ }
941
+ const endedState = this.getStateSnapshot();
942
+ this.#endedDragState.set(endedState);
669
943
  this.#wasCancelled.set(false);
670
944
  this.#resetHighFrequencySignals();
671
945
  this.#state.set(INITIAL_CORE_STATE);
946
+ // After the reset, so the drop handler sees the drag as over. Delivered here rather than
947
+ // from a droppable effect on the next change detection: by then the target may already
948
+ // be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
949
+ // by a (dragEnd) handler), and every other droppable would have to tell a new drop from
950
+ // an old one.
951
+ this.#droppableRegistry.deliverDrop(endedState);
672
952
  }
673
953
  /**
674
954
  * Cancel the drag operation (escape key, disabled, etc.).
675
955
  */
676
956
  cancelDrag() {
957
+ if (!this.#state().isDragging) {
958
+ return;
959
+ }
677
960
  this.#endedDragState.set(this.getStateSnapshot());
678
961
  this.#wasCancelled.set(true);
679
962
  this.#resetHighFrequencySignals();
@@ -760,26 +1043,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
760
1043
  }]
761
1044
  }], ctorParameters: () => [] });
762
1045
 
763
- /**
764
- * Query by an exact attribute value without interpolating consumer data into a CSS selector.
765
- *
766
- * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
767
- * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
768
- * quotes, brackets, backslashes, or other selector-sensitive characters.
769
- */
770
- function queryByAttribute(root, attributeName, attributeValue) {
771
- const candidates = root.querySelectorAll(`[${attributeName}]`);
772
- for (const candidate of candidates) {
773
- if (candidate.getAttribute(attributeName) === attributeValue) {
774
- return candidate;
775
- }
776
- }
777
- return null;
778
- }
779
- function queryAllByAttribute(root, attributeName, attributeValue) {
780
- return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
781
- }
782
-
783
1046
  /** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
784
1047
  function isPageRoot(element) {
785
1048
  return element.tagName === 'BODY' || element.tagName === 'HTML';
@@ -790,6 +1053,7 @@ function isPageRoot(element) {
790
1053
  */
791
1054
  class PositionCalculatorService {
792
1055
  #ngZone = inject(NgZone);
1056
+ #registry = inject(DroppableRegistryService);
793
1057
  /** Data attribute used to identify droppable elements */
794
1058
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
795
1059
  /** Data attribute used to identify droppable groups */
@@ -802,6 +1066,15 @@ class PositionCalculatorService {
802
1066
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
803
1067
  /** Active drag session rect snapshot, or null when no drag is in progress. */
804
1068
  #session = null;
1069
+ constructor() {
1070
+ // A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
1071
+ // Removed on destroy in case the service is provided below root.
1072
+ const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
1073
+ inject(DestroyRef).onDestroy(() => {
1074
+ removeListener();
1075
+ this.endDragSession();
1076
+ });
1077
+ }
805
1078
  /**
806
1079
  * Begin a drag session: snapshot the candidate droppables for `groupName` and
807
1080
  * their rects once, then watch for viewport changes (scroll/resize) that would
@@ -821,7 +1094,7 @@ class PositionCalculatorService {
821
1094
  this.#session = {
822
1095
  groupName,
823
1096
  candidates,
824
- rects: candidates.map((el) => this.#measureRect(el)),
1097
+ rects: this.#measureRects(candidates, []),
825
1098
  dirty: false,
826
1099
  candidatesStale: false,
827
1100
  onViewportChange,
@@ -867,10 +1140,11 @@ class PositionCalculatorService {
867
1140
  }
868
1141
  }
869
1142
  /**
870
- * Re-query the candidate droppable *list* for the active session and re-read their
871
- * rects. Unlike {@link invalidateDroppableRects} (which only refreshes rects), this
872
- * picks up droppables added or removed since the snapshot was captured at drag start —
873
- * the frozen candidate list is otherwise blind to mid-drag mounts/unmounts.
1143
+ * Re-read the candidate droppable *list* for the active session from the registry and
1144
+ * re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
1145
+ * rects), this picks up droppables added or removed since the snapshot was captured at
1146
+ * drag start. Registration changes already trigger it on the next hit-test; this is the
1147
+ * manual escape hatch.
874
1148
  *
875
1149
  * Safe to call when no session is active (no-op).
876
1150
  */
@@ -880,19 +1154,18 @@ class PositionCalculatorService {
880
1154
  return;
881
1155
  }
882
1156
  session.candidates = this.#queryDroppables(session.groupName);
883
- session.rects = session.candidates.map((el) => this.#measureRect(el));
1157
+ session.rects = this.#measureRects(session.candidates, []);
884
1158
  session.dirty = false;
885
1159
  session.candidatesStale = false;
886
1160
  this.#observeCandidates(session);
887
1161
  }
888
1162
  /**
889
1163
  * Signal that the set of droppables for `groupName` may have changed (a droppable
890
- * registered or unregistered mid-drag). Defers the actual re-query to the next
891
- * hit-test — where a removed element is already gone from the DOM and a newly added
892
- * one has its data attributes applied — keeping this notification path cheap.
1164
+ * registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
1165
+ * this notification path cheap.
893
1166
  *
894
- * Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
895
- * belongs to a different group or no session is active.
1167
+ * Called for every droppable registry change. No-op when the active session belongs to
1168
+ * a different group or no session is active.
896
1169
  */
897
1170
  notifyCandidatesChanged(groupName) {
898
1171
  if (this.#session && this.#session.groupName === groupName) {
@@ -922,23 +1195,20 @@ class PositionCalculatorService {
922
1195
  this.refreshCandidates();
923
1196
  }
924
1197
  else if (session.dirty) {
925
- for (let i = 0; i < session.candidates.length; i++) {
926
- session.rects[i] = this.#measureRect(session.candidates[i]);
927
- }
1198
+ this.#measureRects(session.candidates, session.rects);
928
1199
  session.dirty = false;
929
1200
  }
930
1201
  return this.#hitTest(x, y, session.candidates, session.rects);
931
1202
  }
932
1203
  // No active session: one-shot geometric query (still avoids elementFromPoint).
933
1204
  const candidates = this.#queryDroppables(groupName);
934
- const rects = candidates.map((el) => this.#measureRect(el));
935
- return this.#hitTest(x, y, candidates, rects);
1205
+ return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
936
1206
  }
937
1207
  /**
938
1208
  * Look up a droppable element by its ID.
939
1209
  *
940
- * When a drag session is active, searches the cached candidate list (O(n), avoids a DOM
941
- * query). Falls back to `document.querySelector` when no session is active.
1210
+ * When a drag session is active, searches the cached candidate list. Otherwise looks the
1211
+ * ID up in the droppable registry. Neither queries the document.
942
1212
  *
943
1213
  * Intended for the autoscroll scroll-only fast path, where the active droppable is already
944
1214
  * known and only the placeholder index needs recalculation.
@@ -952,13 +1222,10 @@ class PositionCalculatorService {
952
1222
  }
953
1223
  return null;
954
1224
  }
955
- if (typeof document === 'undefined') {
956
- return null;
957
- }
958
- return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
1225
+ return this.#registry.getById(id);
959
1226
  }
960
1227
  /**
961
- * Query all droppable elements belonging to a group, in document order.
1228
+ * All rendered droppables belonging to a group, in document order, from the registry.
962
1229
  *
963
1230
  * All group members are included — the disabled check happens per-frame in
964
1231
  * {@link #hitTest} rather than here, because a droppable can be disabled or
@@ -966,30 +1233,46 @@ class PositionCalculatorService {
966
1233
  * `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
967
1234
  */
968
1235
  #queryDroppables(groupName) {
969
- if (typeof document === 'undefined') {
970
- return [];
971
- }
972
- return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1236
+ return this.#registry.getGroup(groupName);
973
1237
  }
974
1238
  /**
975
- * Measure a candidate's hit-test rect, clipped to its nearest `.vdnd-scrollable`
976
- * ancestor. Without clipping a droppable scrolled mostly out of a clipping container
977
- * still hit-tests over its full unclipped rect (issue #23 case 3). The intersection is
978
- * built as a plain DOMRect; an empty intersection yields a negative width/height so the
979
- * `#hitTest` bounds check can never match it.
1239
+ * The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
1240
+ * ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
1241
+ * mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
1242
+ * case 3).
980
1243
  */
981
- #measureRect(el) {
982
- const rect = el.getBoundingClientRect();
1244
+ #clipOf(el) {
983
1245
  const scrollable = el.closest('.vdnd-scrollable');
984
- if (!scrollable || scrollable === el) {
985
- return rect;
1246
+ return scrollable === el ? null : scrollable;
1247
+ }
1248
+ /**
1249
+ * Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
1250
+ * `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
1251
+ * once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
1252
+ * its registration. The intersection is built as a plain DOMRect; an empty intersection yields
1253
+ * a negative width/height so the `#hitTest` bounds check can never match it.
1254
+ */
1255
+ #measureRects(candidates, rects) {
1256
+ let lastClip = null;
1257
+ let lastClipRect = null;
1258
+ for (let i = 0; i < candidates.length; i++) {
1259
+ const rect = candidates[i].getBoundingClientRect();
1260
+ const clip = this.#clipOf(candidates[i]);
1261
+ if (!clip) {
1262
+ rects[i] = rect;
1263
+ continue;
1264
+ }
1265
+ if (clip !== lastClip || !lastClipRect) {
1266
+ lastClip = clip;
1267
+ lastClipRect = clip.getBoundingClientRect();
1268
+ }
1269
+ const top = Math.max(rect.top, lastClipRect.top);
1270
+ const left = Math.max(rect.left, lastClipRect.left);
1271
+ const right = Math.min(rect.right, lastClipRect.right);
1272
+ const bottom = Math.min(rect.bottom, lastClipRect.bottom);
1273
+ rects[i] = new DOMRect(left, top, right - left, bottom - top);
986
1274
  }
987
- const clip = scrollable.getBoundingClientRect();
988
- const top = Math.max(rect.top, clip.top);
989
- const left = Math.max(rect.left, clip.left);
990
- const right = Math.min(rect.right, clip.right);
991
- const bottom = Math.min(rect.bottom, clip.bottom);
992
- return new DOMRect(left, top, right - left, bottom - top);
1275
+ return rects;
993
1276
  }
994
1277
  /**
995
1278
  * Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
@@ -1191,21 +1474,20 @@ class PositionCalculatorService {
1191
1474
  // current container — otherwise a container disabled mid-drag (its own index becomes
1192
1475
  // -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
1193
1476
  // as *targets* during the outward scan below instead.
1194
- const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1477
+ const allDroppables = this.#registry.getGroup(groupName);
1195
1478
  if (allDroppables.length <= 1) {
1196
1479
  return null;
1197
1480
  }
1198
1481
  // Get bounding rects, IDs, and disabled state, sorted by X position
1199
1482
  const droppableInfos = [];
1200
1483
  allDroppables.forEach((el) => {
1201
- const htmlEl = el;
1202
- const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
1484
+ const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
1203
1485
  if (id) {
1204
1486
  droppableInfos.push({
1205
- element: htmlEl,
1487
+ element: el,
1206
1488
  id,
1207
- rect: htmlEl.getBoundingClientRect(),
1208
- disabled: this.#isDroppableDisabled(htmlEl),
1489
+ rect: el.getBoundingClientRect(),
1490
+ disabled: this.#isDroppableDisabled(el),
1209
1491
  });
1210
1492
  }
1211
1493
  });
@@ -1249,7 +1531,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1249
1531
  args: [{
1250
1532
  providedIn: 'root',
1251
1533
  }]
1252
- }] });
1534
+ }], ctorParameters: () => [] });
1253
1535
 
1254
1536
  /**
1255
1537
  * Single RAF loop that coordinates all drag-frame work in ordered phases:
@@ -1262,6 +1544,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1262
1544
  * latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
1263
1545
  * the flag is false, so autoscroll-only frames (cursor stationary) incur
1264
1546
  * zero hit-test work.
1547
+ * 3. Frame writer phase (WRITE): registered writers apply the state computed this
1548
+ * frame to the DOM. DragPreviewComponent writes the preview transform here, so
1549
+ * the preview moves in the frame that processed the pointer instead of waiting
1550
+ * for a change detection tick, which Angular schedules after this frame.
1265
1551
  *
1266
1552
  * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1267
1553
  * RAF per pointermove. This coalesces all moves within a frame into one update
@@ -1277,6 +1563,8 @@ class DragSchedulerService {
1277
1563
  #cursorDirty = false;
1278
1564
  /** Tick participants called before the main compute phase each frame. */
1279
1565
  #participants = [];
1566
+ /** Frame writers called after the main compute phase each frame. */
1567
+ #frameWriters = [];
1280
1568
  /**
1281
1569
  * Start the scheduler RAF loop.
1282
1570
  * No-op if already running.
@@ -1333,6 +1621,26 @@ class DragSchedulerService {
1333
1621
  this.#participants.splice(idx, 1);
1334
1622
  }
1335
1623
  }
1624
+ /**
1625
+ * Register a function to be called at the end of each frame, after the main tick.
1626
+ * DragPreviewComponent registers here to move the preview within the same frame.
1627
+ * No-op if the same function reference is already registered.
1628
+ */
1629
+ addFrameWriter(fn) {
1630
+ if (!this.#frameWriters.includes(fn)) {
1631
+ this.#frameWriters.push(fn);
1632
+ }
1633
+ }
1634
+ /**
1635
+ * Unregister a previously added frame writer.
1636
+ * No-op if the function was not registered.
1637
+ */
1638
+ removeFrameWriter(fn) {
1639
+ const idx = this.#frameWriters.indexOf(fn);
1640
+ if (idx !== -1) {
1641
+ this.#frameWriters.splice(idx, 1);
1642
+ }
1643
+ }
1336
1644
  #scheduleNextTick() {
1337
1645
  this.#rafId = requestAnimationFrame(() => this.#tick());
1338
1646
  }
@@ -1360,7 +1668,15 @@ class DragSchedulerService {
1360
1668
  }
1361
1669
  // Phase 2 — main compute (cursor-based hit-test + signal write)
1362
1670
  onTick(this.#pendingCursor, cursorDirty);
1363
- // Reschedule only if still active (stop() may have been called by onTick).
1671
+ // stop() may have been called by onTick (the drag ended): nothing left to write.
1672
+ if (this.#onTick === null) {
1673
+ return;
1674
+ }
1675
+ // Phase 3 — frame writers (apply this frame's state to the DOM)
1676
+ for (const writer of this.#frameWriters) {
1677
+ writer();
1678
+ }
1679
+ // Reschedule only if still active (a writer is not expected to stop it, but may).
1364
1680
  if (this.#onTick !== null) {
1365
1681
  this.#scheduleNextTick();
1366
1682
  }
@@ -1411,6 +1727,14 @@ class AutoScrollService {
1411
1727
  /** Last tick cursor position for stationary detection */
1412
1728
  #lastTickCursorX = NaN;
1413
1729
  #lastTickCursorY = NaN;
1730
+ /**
1731
+ * The containers the last tick that ran asked to scroll but had no room left: the first
1732
+ * #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
1733
+ * tick only while they stay as they were on the axes it asked for: content that grows (rows
1734
+ * measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
1735
+ */
1736
+ #exhausted = [];
1737
+ #exhaustedCount = 0;
1414
1738
  /**
1415
1739
  * Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
1416
1740
  * Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
@@ -1514,6 +1838,8 @@ class AutoScrollService {
1514
1838
  this.#cursorOverride = null;
1515
1839
  this.#lastTickCursorX = NaN;
1516
1840
  this.#lastTickCursorY = NaN;
1841
+ this.#exhausted.length = 0;
1842
+ this.#exhaustedCount = 0;
1517
1843
  this.#lastScrollTimestamp = 0;
1518
1844
  this.#scrollState.containerId = null;
1519
1845
  this.#scrollState.direction.x = 0;
@@ -1542,14 +1868,17 @@ class AutoScrollService {
1542
1868
  if (!cursor) {
1543
1869
  return;
1544
1870
  }
1545
- // Skip the container iteration when cursor hasn't moved and scroll is idle.
1871
+ // Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
1872
+ // container it asked to scroll without room has changed since (see #exhausted).
1546
1873
  if (cursor.x === this.#lastTickCursorX &&
1547
1874
  cursor.y === this.#lastTickCursorY &&
1548
- !this.isScrolling()) {
1875
+ !this.isScrolling() &&
1876
+ !this.#exhaustedChanged()) {
1549
1877
  return;
1550
1878
  }
1551
1879
  this.#lastTickCursorX = cursor.x;
1552
1880
  this.#lastTickCursorY = cursor.y;
1881
+ this.#exhaustedCount = 0;
1553
1882
  let scrollPerformed = false;
1554
1883
  // Frame scale is shared across candidates within a tick; computed lazily on
1555
1884
  // the first scroll attempt so stationary/idle frames don't advance the clock.
@@ -1609,8 +1938,19 @@ class AutoScrollService {
1609
1938
  this.#scrollState.direction.y = direction.y;
1610
1939
  this.#scrollState.speed = speed;
1611
1940
  scrollPerformed = true;
1941
+ // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1942
+ // Delaying via RAF causes cumulative drift during continuous autoscroll
1943
+ // because multiple scrolls happen before each delayed callback runs.
1944
+ // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1945
+ // updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
1946
+ // the drag.
1947
+ // It runs after the scroll state is recorded: it may end the drag, and the reset of
1948
+ // stopMonitoring() must not be overwritten.
1949
+ this.#onScrollCallback?.();
1612
1950
  break;
1613
1951
  }
1952
+ // No room in any requested direction: a resting cursor retries it once it changes
1953
+ this.#recordExhausted(element, direction);
1614
1954
  }
1615
1955
  }
1616
1956
  if (!scrollPerformed) {
@@ -1670,16 +2010,46 @@ class AutoScrollService {
1670
2010
  }
1671
2011
  }
1672
2012
  }
1673
- if (!scrolled) {
1674
- return false;
2013
+ return scrolled;
2014
+ }
2015
+ /** Record a container that had no room in `direction` (see #exhausted). */
2016
+ #recordExhausted(element, direction) {
2017
+ const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
2018
+ element,
2019
+ vertical: false,
2020
+ horizontal: false,
2021
+ scrollTop: 0,
2022
+ scrollLeft: 0,
2023
+ maxScrollTop: 0,
2024
+ maxScrollLeft: 0,
2025
+ });
2026
+ exhausted.element = element;
2027
+ exhausted.vertical = direction.y !== 0;
2028
+ exhausted.horizontal = direction.x !== 0;
2029
+ exhausted.scrollTop = element.scrollTop;
2030
+ exhausted.scrollLeft = element.scrollLeft;
2031
+ exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
2032
+ exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
2033
+ this.#exhaustedCount++;
2034
+ }
2035
+ /**
2036
+ * Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
2037
+ * scroll, so it may have room now.
2038
+ */
2039
+ #exhaustedChanged() {
2040
+ for (let i = 0; i < this.#exhaustedCount; i++) {
2041
+ const exhausted = this.#exhausted[i];
2042
+ const { element } = exhausted;
2043
+ if ((exhausted.vertical &&
2044
+ (element.scrollTop !== exhausted.scrollTop ||
2045
+ element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
2046
+ (exhausted.horizontal &&
2047
+ (element.scrollLeft !== exhausted.scrollLeft ||
2048
+ element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
2049
+ return true;
2050
+ }
1675
2051
  }
1676
- // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1677
- // Delaying via RAF causes cumulative drift during continuous autoscroll
1678
- // because multiple scrolls happen before each delayed callback runs.
1679
- // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1680
- // already enters the zone when updating drag state.
1681
- this.#onScrollCallback?.();
1682
- return true;
2052
+ return false;
1683
2053
  }
1684
2054
  #getFrameScale() {
1685
2055
  const now = performance.now();
@@ -1720,20 +2090,19 @@ class ElementCloneService {
1720
2090
  * These are the visual properties that affect appearance. Keeping them
1721
2091
  * kebab-case avoids a camelCase→kebab conversion on every property of every
1722
2092
  * node during the recursive clone walk (a drag-start hot path).
2093
+ *
2094
+ * A shorthand carries more than the longhands listed with it (`background-size`,
2095
+ * `font-style`, …), so it is copied first, and the longhands only when the engine can't
2096
+ * serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
2097
+ * example). Copying both would read and write the same values twice.
1723
2098
  */
1724
- #stylePropsToCopy = [
1725
- 'background',
1726
- 'background-color',
1727
- 'background-image',
2099
+ #stylesToCopy = [
2100
+ ['background', ['background-color', 'background-image']],
1728
2101
  'border',
1729
2102
  'border-radius',
1730
2103
  'box-shadow',
1731
2104
  'color',
1732
- 'font',
1733
- 'font-family',
1734
- 'font-size',
1735
- 'font-weight',
1736
- 'line-height',
2105
+ ['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
1737
2106
  'padding',
1738
2107
  'margin',
1739
2108
  'display',
@@ -1775,10 +2144,22 @@ class ElementCloneService {
1775
2144
  #applyComputedStyles(source, target) {
1776
2145
  const computed = window.getComputedStyle(source);
1777
2146
  // Copy essential visual properties (keys are already kebab-case)
1778
- for (const prop of this.#stylePropsToCopy) {
1779
- const value = computed.getPropertyValue(prop);
1780
- if (value) {
1781
- target.style.setProperty(prop, value);
2147
+ for (const style of this.#stylesToCopy) {
2148
+ if (typeof style === 'string') {
2149
+ this.#copyStyle(computed, target, style);
2150
+ continue;
2151
+ }
2152
+ // The longhands only when the shorthand was not copied: the engine gave no value, or the
2153
+ // clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
2154
+ // shorthand itself would be serialized again to be read). The clone may carry that
2155
+ // longhand inline from the source, so it goes first.
2156
+ const [shorthand, longhands] = style;
2157
+ target.style.removeProperty(longhands[0]);
2158
+ if (!this.#copyStyle(computed, target, shorthand) ||
2159
+ !target.style.getPropertyValue(longhands[0])) {
2160
+ for (const longhand of longhands) {
2161
+ this.#copyStyle(computed, target, longhand);
2162
+ }
1782
2163
  }
1783
2164
  }
1784
2165
  // Disable animations and transitions on clone
@@ -1795,6 +2176,18 @@ class ElementCloneService {
1795
2176
  }
1796
2177
  }
1797
2178
  }
2179
+ /**
2180
+ * Copy one computed property to the target's inline style. Returns whether the engine gave a
2181
+ * value for it.
2182
+ */
2183
+ #copyStyle(computed, target, property) {
2184
+ const value = computed.getPropertyValue(property);
2185
+ if (!value) {
2186
+ return false;
2187
+ }
2188
+ target.style.setProperty(property, value);
2189
+ return true;
2190
+ }
1798
2191
  /**
1799
2192
  * Handle special elements that require extra processing.
1800
2193
  */
@@ -2149,9 +2542,7 @@ class DragIndexCalculatorService {
2149
2542
  return cached;
2150
2543
  const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
2151
2544
  const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
2152
- const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
2153
- ? droppableElement
2154
- : droppableElement.closest('vdnd-virtual-content'));
2545
+ const virtualContentElement = this.#findVirtualContent(droppableElement);
2155
2546
  let containerType;
2156
2547
  let scrollContainer;
2157
2548
  let scrollableParent = null;
@@ -2198,7 +2589,7 @@ class DragIndexCalculatorService {
2198
2589
  return entry;
2199
2590
  }
2200
2591
  getTotalItemCount(args) {
2201
- return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
2592
+ return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
2202
2593
  }
2203
2594
  /**
2204
2595
  * Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
@@ -2270,7 +2661,7 @@ class DragIndexCalculatorService {
2270
2661
  totalItems = Math.max(0, cachedItemCount);
2271
2662
  }
2272
2663
  else {
2273
- totalItems = this.#getTotalItemCount(droppableElement, isSameList, draggedItemHeight);
2664
+ totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
2274
2665
  }
2275
2666
  // Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
2276
2667
  // Due to max scroll limits, the math alone can't reach totalItems when the list is longer
@@ -2329,7 +2720,12 @@ class DragIndexCalculatorService {
2329
2720
  const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
2330
2721
  return Number.isFinite(offset) ? offset : 0;
2331
2722
  }
2332
- #getTotalItemCount(droppableElement, isSameList, draggedItemHeight) {
2723
+ /**
2724
+ * The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
2725
+ * containers it knows are not looked up in the DOM again: this runs on every frame of a drag
2726
+ * over a list without a registered strategy.
2727
+ */
2728
+ #getTotalItemCount(droppableElement, draggedItemHeight, cache) {
2333
2729
  // Check if a registered strategy exists
2334
2730
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
2335
2731
  const strategy = droppableId ? this.#strategies.get(droppableId) : null;
@@ -2338,7 +2734,9 @@ class DragIndexCalculatorService {
2338
2734
  return Math.max(0, strategyItemCount);
2339
2735
  }
2340
2736
  // Check for embedded virtual scroll component
2341
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
2737
+ const virtualScroll = cache
2738
+ ? cache.virtualScrollElement
2739
+ : droppableElement.querySelector('vdnd-virtual-scroll');
2342
2740
  if (virtualScroll) {
2343
2741
  // Use data-total-items attribute if available (always the true N)
2344
2742
  const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
@@ -2375,9 +2773,9 @@ class DragIndexCalculatorService {
2375
2773
  return count;
2376
2774
  }
2377
2775
  // Check for page-level scroll (vdnd-virtual-content)
2378
- const virtualContent = droppableElement.matches('vdnd-virtual-content')
2379
- ? droppableElement
2380
- : droppableElement.closest('vdnd-virtual-content');
2776
+ const virtualContent = cache
2777
+ ? cache.virtualContentElement
2778
+ : this.#findVirtualContent(droppableElement);
2381
2779
  if (virtualContent) {
2382
2780
  // Prefer explicit total items attribute (works for both fixed and dynamic heights)
2383
2781
  const totalItemsAttr = virtualContent.getAttribute('data-total-items');
@@ -2404,6 +2802,12 @@ class DragIndexCalculatorService {
2404
2802
  const items = droppableElement.querySelectorAll('[data-draggable-id]');
2405
2803
  return items.length;
2406
2804
  }
2805
+ /** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
2806
+ #findVirtualContent(droppableElement) {
2807
+ return droppableElement.matches('vdnd-virtual-content')
2808
+ ? droppableElement
2809
+ : droppableElement.closest('vdnd-virtual-content');
2810
+ }
2407
2811
  #getDraggedItemHeightFallback(height, fallback) {
2408
2812
  return Number.isFinite(height) && height > 0 ? height : fallback;
2409
2813
  }
@@ -2547,6 +2951,66 @@ function createAutoScrollRegistration(options) {
2547
2951
  destroyRef.onDestroy(unregisterCurrent);
2548
2952
  }
2549
2953
 
2954
+ /**
2955
+ * Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
2956
+ * list's own array, which it goes on reading).
2957
+ *
2958
+ * - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
2959
+ * never computed.
2960
+ * - DynamicHeightStrategy's copies the keys: it gets the list's array.
2961
+ * - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
2962
+ * gets a copy, which it may keep and change.
2963
+ * @internal
2964
+ */
2965
+ function setStrategyItems(strategy, count, keys) {
2966
+ if (strategy instanceof FixedHeightStrategy &&
2967
+ strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
2968
+ strategy.setItemCount(count);
2969
+ }
2970
+ else if (strategy instanceof DynamicHeightStrategy &&
2971
+ strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
2972
+ strategy.setItemKeys(keys());
2973
+ }
2974
+ else {
2975
+ strategy.setItemKeys(keys().slice());
2976
+ }
2977
+ }
2978
+
2979
+ /**
2980
+ * Query by an exact attribute value without interpolating consumer data into a CSS selector.
2981
+ *
2982
+ * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
2983
+ * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
2984
+ * quotes, brackets, backslashes, or other selector-sensitive characters.
2985
+ */
2986
+ function queryByAttribute(root, attributeName, attributeValue) {
2987
+ const candidates = root.querySelectorAll(`[${attributeName}]`);
2988
+ for (const candidate of candidates) {
2989
+ if (candidate.getAttribute(attributeName) === attributeValue) {
2990
+ return candidate;
2991
+ }
2992
+ }
2993
+ return null;
2994
+ }
2995
+ /**
2996
+ * The elements carrying an attribute, by its value (the first in document order when several
2997
+ * share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
2998
+ * scans every candidate on each call.
2999
+ */
3000
+ function mapByAttribute(root, attributeName) {
3001
+ const elements = new Map();
3002
+ for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
3003
+ const value = candidate.getAttribute(attributeName);
3004
+ if (!elements.has(value)) {
3005
+ elements.set(value, candidate);
3006
+ }
3007
+ }
3008
+ return elements;
3009
+ }
3010
+ function queryAllByAttribute(root, attributeName, attributeValue) {
3011
+ return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
3012
+ }
3013
+
2550
3014
  /** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
2551
3015
  const MIN_SHIFT_PX = 0.5;
2552
3016
  /**
@@ -2730,33 +3194,235 @@ class ShiftAnimator {
2730
3194
  }
2731
3195
  }
2732
3196
 
3197
+ /** The elements drop animations hide, with the animation hiding each */
3198
+ const hiddenTargets = new Map();
2733
3199
  /**
2734
- * A virtual scroll container that only renders visible items.
2735
- *
2736
- * Key features:
2737
- * - Only renders items within the visible viewport plus an overscan buffer
2738
- * - Supports "sticky" items that are always rendered (used for dragged items)
2739
- * - Uses spacer divs to maintain correct scroll height
2740
- * - Integrates with the drag-and-drop system
2741
- * - Automatic height detection via ResizeObserver when containerHeight is not provided
2742
- * - Supports dynamic item heights via `dynamicItemHeight` input
2743
- *
2744
- * @example
2745
- * ```html
2746
- * <!-- With fixed height items -->
2747
- * <vdnd-virtual-scroll
2748
- * [items]="items()"
2749
- * [itemHeight]="50"
2750
- * [containerHeight]="400"
2751
- * [trackByFn]="trackById">
2752
- * <ng-template let-item let-index="index">
2753
- * <div class="item">{{ item.name }}</div>
2754
- * </ng-template>
2755
- * </vdnd-virtual-scroll>
3200
+ * Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
3201
+ * calls it for the row it pools: that row's element renders another item next.
3202
+ * @internal
3203
+ */
3204
+ function revealDropTargetIn(nodes) {
3205
+ for (const [element, animation] of hiddenTargets) {
3206
+ if (nodes.some((node) => node.contains(element))) {
3207
+ hiddenTargets.delete(element);
3208
+ animation.cancel();
3209
+ }
3210
+ }
3211
+ }
3212
+ /**
3213
+ * Plays the drop ("settle") animation: the drag preview glides from where it was released
3214
+ * into the dropped item's final position, while that item stays invisible underneath.
2756
3215
  *
2757
- * <!-- With dynamic height items -->
2758
- * <vdnd-virtual-scroll
2759
- * [items]="items()"
3216
+ * Purely visual. The drag state and the drop events are already final when it starts, so it
3217
+ * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3218
+ * of view) the ghost fades out where it was released.
3219
+ * @internal
3220
+ */
3221
+ class DropAnimator {
3222
+ #config;
3223
+ #ghostAnimation = null;
3224
+ #hidden = null;
3225
+ /** The reduced-motion media query, created on first use (null without `matchMedia`) */
3226
+ #reducedMotionQuery;
3227
+ constructor(config) {
3228
+ this.#config = config;
3229
+ }
3230
+ /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3231
+ isEnabled() {
3232
+ if (this.#duration() <= 0)
3233
+ return false;
3234
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3235
+ return false;
3236
+ }
3237
+ return !this.#prefersReducedMotion();
3238
+ }
3239
+ /**
3240
+ * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3241
+ * `onDone` is not called when the animation is cancelled.
3242
+ */
3243
+ play(ghost, target, onDone) {
3244
+ this.cancel();
3245
+ const timing = {
3246
+ duration: this.#duration(),
3247
+ easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3248
+ fill: 'forwards',
3249
+ };
3250
+ let ghostAnimation;
3251
+ if (target) {
3252
+ const from = ghost.getBoundingClientRect();
3253
+ const dx = target.rect.left - from.left;
3254
+ const dy = target.rect.top - from.top;
3255
+ // Travel on top of the preview's own position transform (width/height must not be
3256
+ // composited, so the base transform is repeated instead of using composite: 'add').
3257
+ const base = ghost.style.transform;
3258
+ ghostAnimation = ghost.animate([
3259
+ {
3260
+ transform: `${base} translate(0px, 0px)`,
3261
+ width: `${from.width}px`,
3262
+ height: `${from.height}px`,
3263
+ },
3264
+ {
3265
+ transform: `${base} translate(${dx}px, ${dy}px)`,
3266
+ width: `${target.rect.width}px`,
3267
+ height: `${target.rect.height}px`,
3268
+ },
3269
+ ], timing);
3270
+ // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3271
+ const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3272
+ duration: timing.duration,
3273
+ });
3274
+ this.#hidden = { element: target.element, animation: hideAnimation };
3275
+ hiddenTargets.set(target.element, hideAnimation);
3276
+ }
3277
+ else {
3278
+ ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3279
+ }
3280
+ this.#ghostAnimation = ghostAnimation;
3281
+ ghostAnimation.onfinish = () => {
3282
+ if (this.#ghostAnimation !== ghostAnimation)
3283
+ return;
3284
+ this.cancel();
3285
+ onDone();
3286
+ };
3287
+ }
3288
+ /** Stop the running animation immediately and reveal the real item. */
3289
+ cancel() {
3290
+ const ghostAnimation = this.#ghostAnimation;
3291
+ const hidden = this.#hidden;
3292
+ this.#ghostAnimation = null;
3293
+ this.#hidden = null;
3294
+ ghostAnimation?.cancel();
3295
+ if (hidden) {
3296
+ if (hiddenTargets.get(hidden.element) === hidden.animation) {
3297
+ hiddenTargets.delete(hidden.element);
3298
+ }
3299
+ hidden.animation.cancel();
3300
+ }
3301
+ }
3302
+ #duration() {
3303
+ return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
3304
+ }
3305
+ /** One media query for every drop: it is live, so it follows a change of the preference. */
3306
+ #prefersReducedMotion() {
3307
+ if (this.#reducedMotionQuery === undefined) {
3308
+ this.#reducedMotionQuery =
3309
+ typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
3310
+ }
3311
+ return this.#reducedMotionQuery?.matches ?? false;
3312
+ }
3313
+ }
3314
+ /**
3315
+ * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3316
+ * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3317
+ * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3318
+ *
3319
+ * `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
3320
+ */
3321
+ function findDropTarget(draggableId, droppableIds, getDroppable) {
3322
+ for (const droppableId of droppableIds) {
3323
+ if (!droppableId)
3324
+ continue;
3325
+ const droppable = getDroppable(droppableId);
3326
+ const element = droppable
3327
+ ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3328
+ : null;
3329
+ if (!droppable || !element)
3330
+ continue;
3331
+ const rect = element.getBoundingClientRect();
3332
+ // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3333
+ if (rect.width === 0 && rect.height === 0)
3334
+ return null;
3335
+ const bounds = droppable.getBoundingClientRect();
3336
+ const visible = rect.bottom > bounds.top &&
3337
+ rect.top < bounds.bottom &&
3338
+ rect.right > bounds.left &&
3339
+ rect.left < bounds.right;
3340
+ return visible ? { element, rect } : null;
3341
+ }
3342
+ return null;
3343
+ }
3344
+
3345
+ /**
3346
+ * How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
3347
+ * scroll step reuses the views it pools right away; the pool keeps the rest, for example when
3348
+ * fewer rows render after the list shrinks.
3349
+ */
3350
+ const MAX_POOLED_ROWS = 10;
3351
+ /** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
3352
+ const DOCUMENT_POSITION_FOLLOWING = 4;
3353
+ /**
3354
+ * The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
3355
+ * before the nodes rendered in it, which the DOM puts before the anchor.
3356
+ */
3357
+ function firstInDocumentOrder(nodes) {
3358
+ let first = null;
3359
+ for (const node of nodes) {
3360
+ if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
3361
+ first = node;
3362
+ }
3363
+ }
3364
+ return first;
3365
+ }
3366
+ /**
3367
+ * Which values belong to a longest increasing subsequence of `values` (negative values never
3368
+ * do). For rows: the views that can stay where they are while the others move around them, as
3369
+ * few as possible. A moved row loses focus and restarts its CSS transitions.
3370
+ */
3371
+ function longestIncreasingRun(values) {
3372
+ // tails[k]: the index of the smallest value that ends an increasing run of length k + 1
3373
+ const tails = [];
3374
+ const previous = new Array(values.length).fill(-1);
3375
+ for (let i = 0; i < values.length; i++) {
3376
+ const value = values[i];
3377
+ if (value < 0)
3378
+ continue;
3379
+ let low = 0;
3380
+ let high = tails.length;
3381
+ while (low < high) {
3382
+ const middle = (low + high) >> 1;
3383
+ if (values[tails[middle]] < value) {
3384
+ low = middle + 1;
3385
+ }
3386
+ else {
3387
+ high = middle;
3388
+ }
3389
+ }
3390
+ previous[i] = low > 0 ? tails[low - 1] : -1;
3391
+ tails[low] = i;
3392
+ }
3393
+ const inRun = new Array(values.length).fill(false);
3394
+ for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
3395
+ inRun[i] = true;
3396
+ }
3397
+ return inRun;
3398
+ }
3399
+ /**
3400
+ * A virtual scroll container that only renders visible items.
3401
+ *
3402
+ * Key features:
3403
+ * - Only renders items within the visible viewport plus an overscan buffer
3404
+ * - Supports "sticky" items that are always rendered (used for dragged items)
3405
+ * - Uses spacer divs to maintain correct scroll height
3406
+ * - Integrates with the drag-and-drop system
3407
+ * - Automatic height detection via ResizeObserver when containerHeight is not provided
3408
+ * - Supports dynamic item heights via `dynamicItemHeight` input
3409
+ *
3410
+ * @example
3411
+ * ```html
3412
+ * <!-- With fixed height items -->
3413
+ * <vdnd-virtual-scroll
3414
+ * [items]="items()"
3415
+ * [itemHeight]="50"
3416
+ * [containerHeight]="400"
3417
+ * [trackByFn]="trackById">
3418
+ * <ng-template let-item let-index="index">
3419
+ * <div class="item">{{ item.name }}</div>
3420
+ * </ng-template>
3421
+ * </vdnd-virtual-scroll>
3422
+ *
3423
+ * <!-- With dynamic height items -->
3424
+ * <vdnd-virtual-scroll
3425
+ * [items]="items()"
2760
3426
  * [itemHeight]="50"
2761
3427
  * [dynamicItemHeight]="true"
2762
3428
  * [containerHeight]="400"
@@ -2775,6 +3441,7 @@ class VirtualScrollContainerComponent {
2775
3441
  #keyboardDrag = inject(KeyboardDragService);
2776
3442
  #ngZone = inject(NgZone);
2777
3443
  #injector = inject(Injector);
3444
+ #environmentInjector = inject(EnvironmentInjector);
2778
3445
  #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
2779
3446
  /** Cleanup function for scroll listener */
2780
3447
  #scrollCleanup = null;
@@ -2789,6 +3456,38 @@ class VirtualScrollContainerComponent {
2789
3456
  #measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
2790
3457
  /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
2791
3458
  #shiftAnimator = this.#createShiftAnimator();
3459
+ /** The template's spacer (cannot use ES private with viewChild) */
3460
+ spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
3461
+ /** The template's content wrapper (cannot use ES private with viewChild) */
3462
+ contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
3463
+ /** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
3464
+ rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
3465
+ /** Rendered rows, in DOM order */
3466
+ #rows = [];
3467
+ /** The entries the rendered rows show */
3468
+ #rowEntries = null;
3469
+ /** The template the rendered rows were created from */
3470
+ #rowTemplate = null;
3471
+ /** The track function that gave the rendered rows their keys */
3472
+ #rowTrackBy = null;
3473
+ /** Views of rows that left, detached, to render rows that come in (recycleRows) */
3474
+ #rowPool = [];
3475
+ /**
3476
+ * Set while this component's view is about to be checked in the current change detection pass:
3477
+ * from an input change (ngOnChanges, which runs before its effects; the first render included)
3478
+ * until the check (ngAfterViewChecked). That check renders every row.
3479
+ */
3480
+ #viewCheckPending = false;
3481
+ /** The placeholder, created on its first render and reused */
3482
+ #placeholder = null;
3483
+ /** Height last set on the placeholder */
3484
+ #placeholderHeightSet = null;
3485
+ /** Content offset last written to the content wrapper */
3486
+ #contentTransformSet = null;
3487
+ /** Whether duplicate track keys were reported (once per list) */
3488
+ #warnedDuplicateKeys = false;
3489
+ /** The row keys last checked for duplicates (checked again when the rows change) */
3490
+ #rowKeysChecked = null;
2792
3491
  /** Template for rendering each item - passed as input instead of content child for reliability */
2793
3492
  itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
2794
3493
  /** Unique ID for this scroll container (used for auto-scroll registration) */
@@ -2835,6 +3534,13 @@ class VirtualScrollContainerComponent {
2835
3534
  * Required for placeholder positioning.
2836
3535
  */
2837
3536
  droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
3537
+ /**
3538
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
3539
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
3540
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
3541
+ * @default false
3542
+ */
3543
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
2838
3544
  /**
2839
3545
  * Whether to automatically add the dragged item to the sticky list.
2840
3546
  * This ensures the dragged item remains visible during virtual scrolling.
@@ -2931,22 +3637,50 @@ class VirtualScrollContainerComponent {
2931
3637
  draggedItemId = computed(() => {
2932
3638
  return this.#dragState.draggedItem()?.draggableId ?? null;
2933
3639
  }, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
2934
- /** Map of item IDs to their indices - rebuilt only when items() changes (O(n) once, then O(1) lookups) */
2935
- #itemIndexMap = computed(() => {
2936
- const items = this.items();
3640
+ /**
3641
+ * Every item's ID, in order. Computed once per items change, and only when something reads it:
3642
+ * a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
3643
+ * strategy needs only the item count.
3644
+ */
3645
+ #itemIds = computed(() => {
2937
3646
  const idFn = this.itemIdFn();
3647
+ return this.items().map((item) => idFn(item));
3648
+ }, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
3649
+ /**
3650
+ * Index of each item ID (the last, when several share one), for the sticky items outside the
3651
+ * rendered rows. Built on first use, so an items change (a drop) builds none.
3652
+ */
3653
+ #itemIndexMap = computed(() => {
3654
+ const ids = this.#itemIds();
2938
3655
  const map = new Map();
2939
- for (let i = 0; i < items.length; i++) {
2940
- map.set(idFn(items[i]), i);
3656
+ for (let i = 0; i < ids.length; i++) {
3657
+ map.set(ids[i], i);
2941
3658
  }
2942
3659
  return map;
2943
3660
  }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
2944
- /** The index of the currently dragged item in the items array (-1 if not found or not dragging) */
3661
+ /**
3662
+ * The index of the currently dragged item in the items array (-1 if not found or not dragging).
3663
+ * Found without the index map, which only this lookup would need on a drag: a drag that started
3664
+ * in another list has no item here (IDs are unique across lists), the index where the drag found
3665
+ * its item is checked next, and only then every item ID is searched.
3666
+ */
2945
3667
  #draggedItemIndex = computed(() => {
2946
3668
  const draggedId = this.draggedItemId();
2947
3669
  if (!draggedId)
2948
3670
  return -1;
2949
- return this.#itemIndexMap().get(draggedId) ?? -1;
3671
+ const droppableId = this.droppableId();
3672
+ const sourceDroppableId = this.#dragState.sourceDroppableId();
3673
+ if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
3674
+ return -1;
3675
+ const sourceIndex = this.#dragState.sourceIndex();
3676
+ const items = this.items();
3677
+ if (sourceIndex !== null &&
3678
+ sourceIndex >= 0 &&
3679
+ sourceIndex < items.length &&
3680
+ this.itemIdFn()(items[sourceIndex]) === draggedId) {
3681
+ return sourceIndex;
3682
+ }
3683
+ return this.#itemIds().lastIndexOf(draggedId);
2950
3684
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
2951
3685
  /** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
2952
3686
  #stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
@@ -2971,31 +3705,20 @@ class VirtualScrollContainerComponent {
2971
3705
  }
2972
3706
  return this.itemHeight();
2973
3707
  }, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
2974
- /** Items to render, including sticky items and placeholder */
2975
- renderedItems = computed(() => {
3708
+ /**
3709
+ * Rows to render: the items in range, then the sticky items outside it. Independent of the
3710
+ * placeholder, so a placeholder move leaves the rows as they are.
3711
+ */
3712
+ #renderedRows = computed(() => {
2976
3713
  const items = this.items();
2977
3714
  const { start, end } = this.#renderRange();
2978
3715
  const stickyIds = this.#stickyIdsSet();
2979
3716
  const idFn = this.itemIdFn();
2980
- const itemIndexMap = this.#itemIndexMap();
2981
3717
  const draggedId = this.draggedItemId();
2982
- const placeholderIdx = this.placeholderIndex();
2983
3718
  const result = [];
2984
3719
  const renderedIds = new Set();
2985
- let hasPlaceholder = false;
2986
- // Add all items in the visible range, inserting placeholder at correct position
3720
+ // Add all items in the visible range
2987
3721
  for (let i = start; i <= end && i < items.length; i++) {
2988
- // Insert placeholder before item at placeholderIndex
2989
- if (placeholderIdx === i && !hasPlaceholder) {
2990
- result.push({
2991
- type: 'placeholder',
2992
- data: null,
2993
- index: placeholderIdx,
2994
- isSticky: false,
2995
- isDragging: false,
2996
- });
2997
- hasPlaceholder = true;
2998
- }
2999
3722
  const item = items[i];
3000
3723
  const id = idFn(item);
3001
3724
  result.push({
@@ -3007,24 +3730,14 @@ class VirtualScrollContainerComponent {
3007
3730
  });
3008
3731
  renderedIds.add(id);
3009
3732
  }
3010
- // If placeholder is at the end (after all items), add it
3011
- if (placeholderIdx >= items.length && placeholderIdx >= 0 && !hasPlaceholder) {
3012
- result.push({
3013
- type: 'placeholder',
3014
- data: null,
3015
- index: placeholderIdx,
3016
- isSticky: false,
3017
- isDragging: false,
3018
- });
3019
- hasPlaceholder = true;
3020
- }
3021
3733
  // Add any sticky items that aren't already rendered
3022
3734
  const missingStickyIndices = [];
3023
3735
  for (const id of stickyIds) {
3024
3736
  if (renderedIds.has(id))
3025
3737
  continue;
3026
- const index = itemIndexMap.get(id);
3027
- if (index === undefined)
3738
+ // The drag knows its item's index; the index map serves the other sticky items
3739
+ const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
3740
+ if (index < 0)
3028
3741
  continue;
3029
3742
  missingStickyIndices.push({ id, index });
3030
3743
  }
@@ -3044,6 +3757,45 @@ class VirtualScrollContainerComponent {
3044
3757
  });
3045
3758
  }
3046
3759
  return result;
3760
+ }, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
3761
+ /** Track keys of #renderedRows, in order */
3762
+ #rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
3763
+ /**
3764
+ * Where the placeholder renders among the rows: before the row at this position (after the
3765
+ * rows in range when it equals their count), or -1 when it is not rendered. It goes before the
3766
+ * item at the placeholder index when that item is in range, and after the rows in range when
3767
+ * the index is past the last item.
3768
+ */
3769
+ #placeholderSlot = computed(() => {
3770
+ const placeholderIndex = this.placeholderIndex();
3771
+ if (placeholderIndex < 0)
3772
+ return -1;
3773
+ const itemCount = this.items().length;
3774
+ const { start, end } = this.#renderRange();
3775
+ const lastInRange = Math.min(end, itemCount - 1);
3776
+ if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
3777
+ return placeholderIndex - start;
3778
+ }
3779
+ if (placeholderIndex >= itemCount) {
3780
+ return Math.max(0, lastInRange - start + 1);
3781
+ }
3782
+ return -1;
3783
+ }, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
3784
+ /** Items to render, including sticky items and placeholder */
3785
+ renderedItems = computed(() => {
3786
+ const rows = this.#renderedRows();
3787
+ const slot = this.#placeholderSlot();
3788
+ const result = [...rows];
3789
+ if (slot >= 0) {
3790
+ result.splice(slot, 0, {
3791
+ type: 'placeholder',
3792
+ data: null,
3793
+ index: this.placeholderIndex(),
3794
+ isSticky: false,
3795
+ isDragging: false,
3796
+ });
3797
+ }
3798
+ return result;
3047
3799
  }, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
3048
3800
  /** Generated ID for auto-scroll registration */
3049
3801
  #generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
@@ -3059,12 +3811,10 @@ class VirtualScrollContainerComponent {
3059
3811
  enabled: () => this.autoScrollEnabled(),
3060
3812
  config: () => this.autoScrollConfig(),
3061
3813
  });
3062
- // Keep strategy item keys in sync
3814
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
3815
+ // spares computing every item's ID on each items change (each drop).
3063
3816
  effect(() => {
3064
- const items = this.items();
3065
- const idFn = this.itemIdFn();
3066
- const keys = items.map((item) => idFn(item));
3067
- this.#strategy().setItemKeys(keys);
3817
+ setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
3068
3818
  });
3069
3819
  // Register strategy with drag index calculator for accurate drag calculations
3070
3820
  effect((onCleanup) => {
@@ -3081,16 +3831,6 @@ class VirtualScrollContainerComponent {
3081
3831
  const draggedIndex = this.#draggedItemIndex();
3082
3832
  strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
3083
3833
  });
3084
- // Snapshot item positions before the template re-renders the placeholder move.
3085
- // Component effects run before the component's own template is refreshed.
3086
- if (this.#shiftAnimator) {
3087
- const animator = this.#shiftAnimator;
3088
- effect(() => {
3089
- const isDragging = this.#dragState.isDragging();
3090
- const placeholderIndex = this.placeholderIndex();
3091
- untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
3092
- });
3093
- }
3094
3834
  // Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
3095
3835
  // #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
3096
3836
  // move never lands outside the rendered range; this effect re-runs it when item
@@ -3156,6 +3896,18 @@ class VirtualScrollContainerComponent {
3156
3896
  }
3157
3897
  }
3158
3898
  });
3899
+ effect(() => {
3900
+ const spacer = this.spacer()?.nativeElement;
3901
+ if (spacer) {
3902
+ spacer.style.height = `${this.totalHeight()}px`;
3903
+ }
3904
+ });
3905
+ // Last, so it renders what the effects above scrolled to. Component effects run before the
3906
+ // component's own view is checked, which has nothing to update: the template has no bindings.
3907
+ effect(() => this.#render());
3908
+ }
3909
+ ngOnChanges() {
3910
+ this.#viewCheckPending = true;
3159
3911
  }
3160
3912
  ngOnInit() {
3161
3913
  // Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
@@ -3195,6 +3947,9 @@ class VirtualScrollContainerComponent {
3195
3947
  // Observe rendered items for dynamic height measurement
3196
3948
  this.#observeRenderedItems();
3197
3949
  }
3950
+ ngAfterViewChecked() {
3951
+ this.#viewCheckPending = false;
3952
+ }
3198
3953
  /**
3199
3954
  * Scroll so the placeholder is fully visible during a keyboard drag into this list.
3200
3955
  * Reads only signals and strategy offsets, so it works before the placeholder renders.
@@ -3239,6 +3994,279 @@ class VirtualScrollContainerComponent {
3239
3994
  this.#scrollTop.set(newScrollTop);
3240
3995
  }
3241
3996
  }
3997
+ /**
3998
+ * Render the content offset, the rows and the placeholder. From an effect, not the template:
3999
+ * Angular checks every row view in this component's view each time a signal its template reads
4000
+ * changes, so reading the placeholder there re-rendered every row on each placeholder move.
4001
+ * Here a placeholder move only moves the placeholder, and a row renders when its context does.
4002
+ */
4003
+ #render() {
4004
+ // Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
4005
+ // as a subclass that renders its own template relies on it too.
4006
+ const isDragging = this.#dragState.isDragging();
4007
+ const placeholderIndex = this.placeholderIndex();
4008
+ untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
4009
+ const outlet = this.rowOutlet();
4010
+ const wrapper = this.contentWrapper()?.nativeElement;
4011
+ // Not this component's template (a subclass's own): it renders renderedItems() itself
4012
+ if (!outlet || !wrapper)
4013
+ return;
4014
+ const template = this.itemTemplate() ?? null;
4015
+ const entries = this.#renderedRows();
4016
+ const keys = this.#rowKeys();
4017
+ const trackBy = this.effectiveTrackByFn();
4018
+ const recycle = this.recycleRows();
4019
+ const transform = this.contentTransform();
4020
+ const slot = this.#placeholderSlot();
4021
+ const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
4022
+ // Untracked: the signals the rows' templates read must not become this effect's
4023
+ untracked(() => {
4024
+ if (transform !== this.#contentTransformSet) {
4025
+ wrapper.style.transform = transform;
4026
+ this.#contentTransformSet = transform;
4027
+ }
4028
+ this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
4029
+ this.#renderPlaceholder(outlet, slot, placeholderHeight);
4030
+ });
4031
+ }
4032
+ /**
4033
+ * Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
4034
+ * (and the components in it) while its track key stays rendered, and only the rows out of order
4035
+ * move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
4036
+ * that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
4037
+ * `markForCheck()` would mark every ancestor view up to the root too.
4038
+ */
4039
+ #renderRows(outlet, template, entries, keys, trackBy, recycle) {
4040
+ if (!recycle) {
4041
+ this.#destroyRowPool();
4042
+ }
4043
+ // Nothing to do, as on a placeholder move
4044
+ if (entries === this.#rowEntries &&
4045
+ template === this.#rowTemplate &&
4046
+ trackBy === this.#rowTrackBy) {
4047
+ return;
4048
+ }
4049
+ // A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
4050
+ if (template !== this.#rowTemplate) {
4051
+ for (const row of this.#rows) {
4052
+ row.view.destroy();
4053
+ }
4054
+ this.#rows = [];
4055
+ this.#destroyRowPool();
4056
+ this.#rowTemplate = template;
4057
+ }
4058
+ if (!template)
4059
+ return;
4060
+ // A new track function gives the rendered rows new keys; they keep their views (as with @for)
4061
+ if (trackBy !== this.#rowTrackBy) {
4062
+ for (const row of this.#rows) {
4063
+ const { $implicit, index } = row.view.context;
4064
+ row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
4065
+ }
4066
+ this.#rowTrackBy = trackBy;
4067
+ }
4068
+ // The last render's views by key, in order: an entry takes the first view left for its key
4069
+ const previous = new Map();
4070
+ for (const row of this.#rows) {
4071
+ if (row.view.destroyed)
4072
+ continue;
4073
+ const views = previous.get(row.key);
4074
+ if (views) {
4075
+ views.push(row);
4076
+ }
4077
+ else {
4078
+ previous.set(row.key, [row]);
4079
+ }
4080
+ }
4081
+ const kept = keys.map((key) => previous.get(key)?.shift());
4082
+ this.#warnDuplicateKeys(keys);
4083
+ // Remove the rows that left, then keep in place the longest run of kept views that is
4084
+ // already in order (their positions in the outlet, which now holds only kept views)
4085
+ const dragged = this.#dragState.draggedItem();
4086
+ for (const views of previous.values()) {
4087
+ for (const row of views) {
4088
+ // The dragged item's view is destroyed, as without recycling: that cancels the drag
4089
+ // instead of handing it to another item
4090
+ if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
4091
+ this.#poolRow(outlet, row.view);
4092
+ }
4093
+ else {
4094
+ row.view.destroy();
4095
+ }
4096
+ }
4097
+ }
4098
+ const positions = new Map();
4099
+ for (let i = 0; i < outlet.length; i++) {
4100
+ positions.set(outlet.get(i), i);
4101
+ }
4102
+ const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
4103
+ // From the last row up, so each row goes right before the next one, already in place
4104
+ const rows = new Array(entries.length);
4105
+ const toCheck = [];
4106
+ let next = null;
4107
+ for (let i = entries.length - 1; i >= 0; i--) {
4108
+ const entry = entries[i];
4109
+ let row = kept[i];
4110
+ if (row) {
4111
+ if (!inPlace[i]) {
4112
+ this.#moveBefore(outlet, row.view, next);
4113
+ }
4114
+ if (this.#updateContext(row.view.context, entry)) {
4115
+ toCheck.push(row.view);
4116
+ }
4117
+ }
4118
+ else {
4119
+ const index = next ? outlet.indexOf(next) : outlet.length;
4120
+ // A pooled view is checked whatever its context: nothing checked it while detached
4121
+ let view = this.#rowPool.pop();
4122
+ if (view) {
4123
+ this.#updateContext(view.context, entry);
4124
+ outlet.insert(view, index);
4125
+ }
4126
+ else {
4127
+ const context = {
4128
+ $implicit: entry.data,
4129
+ index: entry.index,
4130
+ isSticky: entry.isSticky,
4131
+ };
4132
+ view = outlet.createEmbeddedView(template, context, index);
4133
+ }
4134
+ row = { key: keys[i], view };
4135
+ toCheck.push(view);
4136
+ }
4137
+ rows[i] = row;
4138
+ next = row.view;
4139
+ }
4140
+ this.#rows = rows;
4141
+ while (this.#rowPool.length > MAX_POOLED_ROWS) {
4142
+ this.#rowPool.pop().destroy();
4143
+ }
4144
+ // When this component's own view is checked later in this pass, that check renders every row
4145
+ if (!this.#viewCheckPending) {
4146
+ // Top to bottom (toCheck lists them bottom up)
4147
+ for (let i = toCheck.length - 1; i >= 0; i--) {
4148
+ toCheck[i].detectChanges();
4149
+ }
4150
+ }
4151
+ this.#rowEntries = entries;
4152
+ }
4153
+ /**
4154
+ * Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
4155
+ * the dragged element (its draggable ID need not be the item's ID).
4156
+ */
4157
+ #rendersDraggedItem(view, dragged) {
4158
+ if (!dragged)
4159
+ return false;
4160
+ return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
4161
+ view.rootNodes.some((node) => node.contains(dragged.element)));
4162
+ }
4163
+ /**
4164
+ * Take a row's view out of the outlet and pool it. It keeps its element, so its shift
4165
+ * animation is cancelled (the item it renders next must not slide from this one's position),
4166
+ * and a drop animation that hides it shows it again.
4167
+ */
4168
+ #poolRow(outlet, view) {
4169
+ outlet.detach(outlet.indexOf(view));
4170
+ const nodes = view.rootNodes;
4171
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
4172
+ // the animation ends: in the row of another item by then, if the view were reused
4173
+ if (nodes.some((node) => node.parentNode !== null)) {
4174
+ view.destroy();
4175
+ return;
4176
+ }
4177
+ for (const node of nodes) {
4178
+ if (node instanceof HTMLElement) {
4179
+ this.#shiftAnimator?.cancel(node);
4180
+ }
4181
+ }
4182
+ revealDropTargetIn(nodes);
4183
+ this.#rowPool.push(view);
4184
+ }
4185
+ #destroyRowPool() {
4186
+ for (const view of this.#rowPool) {
4187
+ view.destroy();
4188
+ }
4189
+ this.#rowPool = [];
4190
+ }
4191
+ /** Move a row's view right before `next` (last without one), unless it is there already. */
4192
+ #moveBefore(outlet, view, next) {
4193
+ const target = next ? outlet.indexOf(next) : outlet.length;
4194
+ const current = outlet.indexOf(view);
4195
+ if (current === target - 1)
4196
+ return;
4197
+ // move() takes the view out before inserting it, which shifts the views after it
4198
+ outlet.move(view, current < target ? target - 1 : target);
4199
+ }
4200
+ /** Copy an entry into a row's context. Returns whether the context changed. */
4201
+ #updateContext(context, entry) {
4202
+ if (Object.is(context.$implicit, entry.data) &&
4203
+ context.index === entry.index &&
4204
+ context.isSticky === entry.isSticky) {
4205
+ return false;
4206
+ }
4207
+ context.$implicit = entry.data;
4208
+ context.index = entry.index;
4209
+ context.isSticky = entry.isSticky;
4210
+ return true;
4211
+ }
4212
+ /** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
4213
+ #warnDuplicateKeys(keys) {
4214
+ if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
4215
+ return;
4216
+ this.#rowKeysChecked = keys;
4217
+ const seen = new Set();
4218
+ for (const key of keys) {
4219
+ if (seen.has(key)) {
4220
+ this.#warnedDuplicateKeys = true;
4221
+ console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
4222
+ `${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
4223
+ return;
4224
+ }
4225
+ seen.add(key);
4226
+ }
4227
+ }
4228
+ /**
4229
+ * Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
4230
+ * DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
4231
+ * height checks no row.
4232
+ */
4233
+ #renderPlaceholder(outlet, slot, height) {
4234
+ if (slot < 0 || height === null) {
4235
+ this.#placeholderElement()?.remove();
4236
+ return;
4237
+ }
4238
+ this.#placeholder ??= createComponent(DragPlaceholderComponent, {
4239
+ environmentInjector: this.#environmentInjector,
4240
+ });
4241
+ if (height !== this.#placeholderHeightSet) {
4242
+ this.#placeholder.setInput('itemHeight', height);
4243
+ this.#placeholder.changeDetectorRef.detectChanges();
4244
+ this.#placeholderHeightSet = height;
4245
+ }
4246
+ // Rows are inserted before the outlet's anchor, so the anchor follows the last of them
4247
+ const anchor = outlet.element.nativeElement;
4248
+ const parent = anchor.parentNode;
4249
+ const element = this.#placeholderElement();
4250
+ if (!parent || !element)
4251
+ return;
4252
+ const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
4253
+ if (element.nextSibling !== before) {
4254
+ parent.insertBefore(element, before);
4255
+ }
4256
+ }
4257
+ #placeholderElement() {
4258
+ return this.#placeholder?.location.nativeElement ?? null;
4259
+ }
4260
+ /** The first node in `parent` of the rows from `index` on (null when none has one). */
4261
+ #firstNodeOfRows(index, parent) {
4262
+ for (let i = index; i < this.#rows.length; i++) {
4263
+ const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
4264
+ const node = firstInDocumentOrder(nodes);
4265
+ if (node)
4266
+ return node;
4267
+ }
4268
+ return null;
4269
+ }
3242
4270
  ngOnDestroy() {
3243
4271
  this.#scrollCleanup?.();
3244
4272
  this.#resizeCleanup?.();
@@ -3249,6 +4277,12 @@ class VirtualScrollContainerComponent {
3249
4277
  this.#observedElementByKey.clear();
3250
4278
  itemResizeObserver?.disconnect();
3251
4279
  this.#shiftAnimator?.cancelAll();
4280
+ // Detached, so not destroyed with this view
4281
+ this.#destroyRowPool();
4282
+ // Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
4283
+ this.#placeholderElement()?.remove();
4284
+ this.#placeholder?.destroy();
4285
+ this.#placeholder = null;
3252
4286
  }
3253
4287
  #createShiftAnimator() {
3254
4288
  const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
@@ -3262,10 +4296,25 @@ class VirtualScrollContainerComponent {
3262
4296
  });
3263
4297
  }
3264
4298
  /**
3265
- * Rendered item roots and the placeholder. `@for` never reuses a view for a different
3266
- * track key, so each element is its own stable identity.
4299
+ * Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
4300
+ * its element is no identity), and the placeholder.
3267
4301
  */
3268
4302
  *#shiftAnimationEntries() {
4303
+ if (this.rowOutlet()) {
4304
+ for (const row of this.#rows) {
4305
+ for (const node of row.view.rootNodes) {
4306
+ if (node instanceof HTMLElement) {
4307
+ yield [row.key, node];
4308
+ }
4309
+ }
4310
+ }
4311
+ const placeholder = this.#placeholderElement();
4312
+ if (placeholder?.isConnected) {
4313
+ yield [placeholder, placeholder];
4314
+ }
4315
+ return;
4316
+ }
4317
+ // A subclass's own template (see #render) never recycles: each element is its own identity
3269
4318
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3270
4319
  if (!wrapper)
3271
4320
  return;
@@ -3299,25 +4348,29 @@ class VirtualScrollContainerComponent {
3299
4348
  * item when a new observer is created.
3300
4349
  */
3301
4350
  #observeRenderedItems() {
3302
- // Re-observe whenever rendered items change, once they are in the DOM (a plain effect runs
3303
- // before the template renders them, so newly rendered rows would be missed)
4351
+ // Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
4352
+ // renderedItems(): a placeholder move changes no row. A new item template changes no entry
4353
+ // but renders every row anew, as new elements.
3304
4354
  afterRenderEffect(() => {
3305
4355
  const observer = this.#itemResizeObserver();
3306
4356
  if (!observer)
3307
4357
  return;
3308
- const rendered = this.renderedItems();
4358
+ const rendered = this.#renderedRows();
3309
4359
  const idFn = this.itemIdFn();
4360
+ this.itemTemplate();
3310
4361
  // Find the content wrapper and observe item elements
3311
4362
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3312
4363
  if (!wrapper)
3313
4364
  return;
4365
+ // The DOM element of each item, by its data-draggable-id: one query for all rows, where a
4366
+ // query per row scanned the wrapper once for each (quadratic in the rendered rows)
4367
+ const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
3314
4368
  const nextObservedByKey = new Map();
3315
4369
  for (const entry of rendered) {
3316
4370
  if (entry.type !== 'item' || !entry.data)
3317
4371
  continue;
3318
4372
  const key = idFn(entry.data);
3319
- // Find the DOM element for this item by its data-draggable-id.
3320
- const el = queryByAttribute(wrapper, 'data-draggable-id', key);
4373
+ const el = elementsById.get(key);
3321
4374
  if (el) {
3322
4375
  nextObservedByKey.set(key, el);
3323
4376
  }
@@ -3353,209 +4406,59 @@ class VirtualScrollContainerComponent {
3353
4406
  }
3354
4407
  /**
3355
4408
  * Scroll to a specific item index.
3356
- */
3357
- scrollToIndex(index) {
3358
- const strategy = this.#strategy();
3359
- const position = strategy.getOffsetForIndex(index);
3360
- this.scrollTo(position);
3361
- }
3362
- /**
3363
- * Get the current scroll position.
3364
- */
3365
- getScrollTop() {
3366
- return this.#scrollTop();
3367
- }
3368
- /**
3369
- * Get the total scrollable height.
3370
- */
3371
- getScrollHeight() {
3372
- return this.totalHeight();
3373
- }
3374
- /**
3375
- * Scroll by a delta amount.
3376
- */
3377
- scrollBy(delta) {
3378
- const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
3379
- this.scrollTo(newPosition);
3380
- }
3381
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3382
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", 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 }, 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 }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, ngImport: i0, template: `
3383
- <!-- Single spacer maintains scroll height -->
3384
- <div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
3385
-
3386
- <!-- Content wrapper positioned via GPU-accelerated transform -->
3387
- <div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
3388
- @for (entry of renderedItems(); track trackEntry($index, entry)) {
3389
- @if (entry.type === 'placeholder') {
3390
- <vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
3391
- } @else {
3392
- <ng-container
3393
- *ngTemplateOutlet="
3394
- itemTemplate();
3395
- context: {
3396
- $implicit: entry.data,
3397
- index: entry.index,
3398
- isSticky: entry.isSticky,
3399
- }
3400
- "
3401
- >
3402
- </ng-container>
3403
- }
3404
- }
3405
- </div>
3406
- `, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: DragPlaceholderComponent, selector: "vdnd-drag-placeholder", inputs: ["itemHeight"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3407
- }
3408
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
3409
- type: Component,
3410
- args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet, DragPlaceholderComponent], host: {
3411
- class: 'vdnd-virtual-scroll',
3412
- '[style.height.px]': 'containerHeight() ?? null',
3413
- '[attr.data-item-height]': 'itemHeight()',
3414
- '[attr.data-total-items]': 'items().length',
3415
- }, template: `
3416
- <!-- Single spacer maintains scroll height -->
3417
- <div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
3418
-
3419
- <!-- Content wrapper positioned via GPU-accelerated transform -->
3420
- <div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
3421
- @for (entry of renderedItems(); track trackEntry($index, entry)) {
3422
- @if (entry.type === 'placeholder') {
3423
- <vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
3424
- } @else {
3425
- <ng-container
3426
- *ngTemplateOutlet="
3427
- itemTemplate();
3428
- context: {
3429
- $implicit: entry.data,
3430
- index: entry.index,
3431
- isSticky: entry.isSticky,
3432
- }
3433
- "
3434
- >
3435
- </ng-container>
3436
- }
3437
- }
3438
- </div>
3439
- `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
3440
- }], ctorParameters: () => [], propDecorators: { itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], 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 }] }], 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 }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
3441
-
3442
- /**
3443
- * Plays the drop ("settle") animation: the drag preview glides from where it was released
3444
- * into the dropped item's final position, while that item stays invisible underneath.
3445
- *
3446
- * Purely visual. The drag state and the drop events are already final when it starts, so it
3447
- * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3448
- * of view) the ghost fades out where it was released.
3449
- * @internal
3450
- */
3451
- class DropAnimator {
3452
- #config;
3453
- #ghostAnimation = null;
3454
- #hideAnimation = null;
3455
- constructor(config) {
3456
- this.#config = config;
3457
- }
3458
- /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3459
- isEnabled() {
3460
- if (this.#duration() <= 0)
3461
- return false;
3462
- if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3463
- return false;
3464
- }
3465
- if (typeof matchMedia !== 'function')
3466
- return true;
3467
- return !matchMedia('(prefers-reduced-motion: reduce)').matches;
3468
- }
3469
- /**
3470
- * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3471
- * `onDone` is not called when the animation is cancelled.
3472
- */
3473
- play(ghost, target, onDone) {
3474
- this.cancel();
3475
- const timing = {
3476
- duration: this.#duration(),
3477
- easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3478
- fill: 'forwards',
3479
- };
3480
- let ghostAnimation;
3481
- if (target) {
3482
- const from = ghost.getBoundingClientRect();
3483
- const dx = target.rect.left - from.left;
3484
- const dy = target.rect.top - from.top;
3485
- // Travel on top of the preview's own position transform (width/height must not be
3486
- // composited, so the base transform is repeated instead of using composite: 'add').
3487
- const base = ghost.style.transform;
3488
- ghostAnimation = ghost.animate([
3489
- {
3490
- transform: `${base} translate(0px, 0px)`,
3491
- width: `${from.width}px`,
3492
- height: `${from.height}px`,
3493
- },
3494
- {
3495
- transform: `${base} translate(${dx}px, ${dy}px)`,
3496
- width: `${target.rect.width}px`,
3497
- height: `${target.rect.height}px`,
3498
- },
3499
- ], timing);
3500
- // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3501
- this.#hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3502
- duration: timing.duration,
3503
- });
3504
- }
3505
- else {
3506
- ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3507
- }
3508
- this.#ghostAnimation = ghostAnimation;
3509
- ghostAnimation.onfinish = () => {
3510
- if (this.#ghostAnimation !== ghostAnimation)
3511
- return;
3512
- this.cancel();
3513
- onDone();
3514
- };
4409
+ */
4410
+ scrollToIndex(index) {
4411
+ const strategy = this.#strategy();
4412
+ const position = strategy.getOffsetForIndex(index);
4413
+ this.scrollTo(position);
3515
4414
  }
3516
- /** Stop the running animation immediately and reveal the real item. */
3517
- cancel() {
3518
- const ghostAnimation = this.#ghostAnimation;
3519
- const hideAnimation = this.#hideAnimation;
3520
- this.#ghostAnimation = null;
3521
- this.#hideAnimation = null;
3522
- ghostAnimation?.cancel();
3523
- hideAnimation?.cancel();
4415
+ /**
4416
+ * Get the current scroll position.
4417
+ */
4418
+ getScrollTop() {
4419
+ return this.#scrollTop();
3524
4420
  }
3525
- #duration() {
3526
- return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
4421
+ /**
4422
+ * Get the total scrollable height.
4423
+ */
4424
+ getScrollHeight() {
4425
+ return this.totalHeight();
3527
4426
  }
3528
- }
3529
- /**
3530
- * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3531
- * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3532
- * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3533
- */
3534
- function findDropTarget(draggableId, droppableIds) {
3535
- if (typeof document === 'undefined')
3536
- return null;
3537
- for (const droppableId of droppableIds) {
3538
- if (!droppableId)
3539
- continue;
3540
- const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
3541
- const element = droppable
3542
- ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3543
- : null;
3544
- if (!droppable || !element)
3545
- continue;
3546
- const rect = element.getBoundingClientRect();
3547
- // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3548
- if (rect.width === 0 && rect.height === 0)
3549
- return null;
3550
- const bounds = droppable.getBoundingClientRect();
3551
- const visible = rect.bottom > bounds.top &&
3552
- rect.top < bounds.bottom &&
3553
- rect.right > bounds.left &&
3554
- rect.left < bounds.right;
3555
- return visible ? { element, rect } : null;
4427
+ /**
4428
+ * Scroll by a delta amount.
4429
+ */
4430
+ scrollBy(delta) {
4431
+ const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
4432
+ this.scrollTo(newPosition);
3556
4433
  }
3557
- return null;
4434
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4435
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", 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 }, 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 }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "spacer", first: true, predicate: ["spacer"], descendants: true, isSignal: true }, { propertyName: "contentWrapper", first: true, predicate: ["contentWrapper"], descendants: true, isSignal: true }, { propertyName: "rowOutlet", first: true, predicate: ["rows"], descendants: true, read: ViewContainerRef, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
4436
+ <!-- Single spacer maintains scroll height -->
4437
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
4438
+
4439
+ <!-- Content wrapper positioned via GPU-accelerated transform -->
4440
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4441
+ <ng-container #rows />
4442
+ </div>
4443
+ `, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3558
4444
  }
4445
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
4446
+ type: Component,
4447
+ args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
4448
+ class: 'vdnd-virtual-scroll',
4449
+ '[style.height.px]': 'containerHeight() ?? null',
4450
+ '[attr.data-item-height]': 'itemHeight()',
4451
+ '[attr.data-total-items]': 'items().length',
4452
+ }, template: `
4453
+ <!-- Single spacer maintains scroll height -->
4454
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
4455
+
4456
+ <!-- Content wrapper positioned via GPU-accelerated transform -->
4457
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4458
+ <ng-container #rows />
4459
+ </div>
4460
+ `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
4461
+ }], ctorParameters: () => [], propDecorators: { spacer: [{ type: i0.ViewChild, args: ['spacer', { isSignal: true }] }], contentWrapper: [{ type: i0.ViewChild, args: ['contentWrapper', { isSignal: true }] }], rowOutlet: [{ type: i0.ViewChild, args: ['rows', { ...{ read: ViewContainerRef }, isSignal: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], 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 }] }], 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 }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
3559
4462
 
3560
4463
  /**
3561
4464
  * Renders a preview of the dragged item that follows the cursor.
@@ -3581,6 +4484,8 @@ class DragPreviewComponent {
3581
4484
  #overlayContainer = inject(OverlayContainerService);
3582
4485
  #elementRef = inject((ElementRef));
3583
4486
  #injector = inject(Injector);
4487
+ #droppableRegistry = inject(DroppableRegistryService);
4488
+ #scheduler = inject(DragSchedulerService);
3584
4489
  #dropAnimator = this.#createDropAnimator();
3585
4490
  /** Optional custom template for the preview */
3586
4491
  previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
@@ -3598,6 +4503,39 @@ class DragPreviewComponent {
3598
4503
  isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
3599
4504
  /** The item shown: the live dragged item, or the dropped one while it settles */
3600
4505
  displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
4506
+ /**
4507
+ * Where the preview goes, from everything but the cursor. The template and its effects never
4508
+ * read the cursor: a signal read there would schedule a full application render on every
4509
+ * pointer frame, and that render lands after the frame is painted (one frame late).
4510
+ */
4511
+ #placement = computed(() => {
4512
+ const settling = this.#settling();
4513
+ if (settling && !this.dragState.isDragging()) {
4514
+ return {
4515
+ settled: settling.position,
4516
+ offset: { x: 0, y: 0 },
4517
+ initialPosition: null,
4518
+ lockAxis: null,
4519
+ };
4520
+ }
4521
+ return this.#placementFor({
4522
+ grabOffset: this.dragState.grabOffset(),
4523
+ initialPosition: this.dragState.initialPosition(),
4524
+ lockAxis: this.dragState.lockAxis(),
4525
+ });
4526
+ }, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
4527
+ /**
4528
+ * Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
4529
+ * cursor (bound for stable add/remove). The render-time write in the constructor covers the
4530
+ * rest: the preview appearing, keyboard drags and the settle.
4531
+ */
4532
+ #writeFramePosition = () => {
4533
+ const preview = this.previewElement()?.nativeElement;
4534
+ if (!preview || this.#settling() || !this.dragState.isDragging()) {
4535
+ return;
4536
+ }
4537
+ this.#place(preview, this.#placement(), this.dragState.cursorPosition());
4538
+ };
3601
4539
  /** Whether this preview's template registration is currently counted (kept balanced). */
3602
4540
  #templateRegistered = false;
3603
4541
  /** The cloned element from drag state (used when no custom template is provided) */
@@ -3608,6 +4546,7 @@ class DragPreviewComponent {
3608
4546
  return this.displayedItem()?.clonedElement ?? null;
3609
4547
  }, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
3610
4548
  constructor() {
4549
+ this.#scheduler.addFrameWriter(this.#writeFramePosition);
3611
4550
  // Teleport host element into the body-level overlay container after first render.
3612
4551
  // This escapes any ancestor CSS transforms that would break position: fixed.
3613
4552
  afterNextRender(() => {
@@ -3616,6 +4555,18 @@ class DragPreviewComponent {
3616
4555
  container.appendChild(this.#elementRef.nativeElement);
3617
4556
  }
3618
4557
  });
4558
+ // Place the preview when it appears or anything but the cursor changes (drag start,
4559
+ // keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
4560
+ // #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
4561
+ afterRenderEffect({
4562
+ write: () => {
4563
+ const preview = this.previewElement()?.nativeElement;
4564
+ const placement = this.#placement();
4565
+ if (preview) {
4566
+ this.#place(preview, placement, untracked(this.dragState.cursorPosition));
4567
+ }
4568
+ },
4569
+ });
3619
4570
  // Publish whether this preview renders via a custom template so the drag
3620
4571
  // directives can skip the drag-start element clone when it won't be shown.
3621
4572
  effect(() => {
@@ -3660,6 +4611,7 @@ class DragPreviewComponent {
3660
4611
  });
3661
4612
  }
3662
4613
  ngOnDestroy() {
4614
+ this.#scheduler.removeFrameWriter(this.#writeFramePosition);
3663
4615
  this.#dropAnimator?.cancel();
3664
4616
  if (this.#templateRegistered) {
3665
4617
  this.#overlayContainer.setTemplatePreviewActive(false);
@@ -3667,32 +4619,31 @@ class DragPreviewComponent {
3667
4619
  }
3668
4620
  this.#elementRef.nativeElement.remove();
3669
4621
  }
3670
- /** Whether the preview is visible */
4622
+ /**
4623
+ * Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
4624
+ * is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
4625
+ */
3671
4626
  isVisible = computed(() => {
3672
- return ((this.dragState.isDragging() && this.dragState.cursorPosition() !== null) || this.isSettling());
4627
+ return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
4628
+ this.isSettling());
3673
4629
  }, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
3674
- /** Position of the preview */
3675
- position = computed(() => {
3676
- const settling = this.#settling();
3677
- if (settling && !this.dragState.isDragging()) {
3678
- return settling.position;
4630
+ #placementFor(state) {
4631
+ return {
4632
+ settled: null,
4633
+ // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
4634
+ offset: state.grabOffset ?? this.cursorOffset(),
4635
+ initialPosition: state.initialPosition,
4636
+ lockAxis: state.lockAxis,
4637
+ };
4638
+ }
4639
+ #positionAt(placement, cursor) {
4640
+ if (placement.settled) {
4641
+ return placement.settled;
3679
4642
  }
3680
- return this.#positionFor({
3681
- cursorPosition: this.dragState.cursorPosition(),
3682
- grabOffset: this.dragState.grabOffset(),
3683
- initialPosition: this.dragState.initialPosition(),
3684
- lockAxis: this.dragState.lockAxis(),
3685
- });
3686
- }, ...(ngDevMode ? [{ debugName: "position" }] : []));
3687
- #positionFor(state) {
3688
- const cursor = state.cursorPosition;
3689
- const initialPosition = state.initialPosition;
3690
- const lockAxis = state.lockAxis;
3691
4643
  if (!cursor) {
3692
4644
  return { x: 0, y: 0 };
3693
4645
  }
3694
- // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
3695
- const offset = state.grabOffset ?? this.cursorOffset();
4646
+ const { offset, initialPosition, lockAxis } = placement;
3696
4647
  let x = cursor.x - offset.x;
3697
4648
  let y = cursor.y - offset.y;
3698
4649
  // Apply axis locking if configured
@@ -3709,10 +4660,10 @@ class DragPreviewComponent {
3709
4660
  return { x, y };
3710
4661
  }
3711
4662
  /** Transform-based positioning for better performance (avoid layout from left/top). */
3712
- transform = computed(() => {
3713
- const { x, y } = this.position();
3714
- return `translate3d(${x}px, ${y}px, 0)`;
3715
- }, ...(ngDevMode ? [{ debugName: "transform" }] : []));
4663
+ #place(preview, placement, cursor) {
4664
+ const { x, y } = this.#positionAt(placement, cursor);
4665
+ preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
4666
+ }
3716
4667
  /** Dimensions of the preview */
3717
4668
  dimensions = computed(() => {
3718
4669
  const item = this.displayedItem();
@@ -3748,7 +4699,7 @@ class DragPreviewComponent {
3748
4699
  }
3749
4700
  const settling = {
3750
4701
  item: ended.draggedItem,
3751
- position: this.#positionFor(ended),
4702
+ position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
3752
4703
  };
3753
4704
  this.#settling.set(settling);
3754
4705
  // A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
@@ -3765,7 +4716,7 @@ class DragPreviewComponent {
3765
4716
  this.#settling.set(null);
3766
4717
  return;
3767
4718
  }
3768
- const target = findDropTarget(settling.item.draggableId, droppableIds);
4719
+ const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
3769
4720
  this.#dropAnimator.play(ghost, target, () => {
3770
4721
  if (this.#settling() === settling) {
3771
4722
  this.#settling.set(null);
@@ -3785,7 +4736,6 @@ class DragPreviewComponent {
3785
4736
  class="vdnd-drag-preview"
3786
4737
  [class.vdnd-drag-preview-dropping]="isSettling()"
3787
4738
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3788
- [style.transform]="transform()"
3789
4739
  [style.width.px]="dimensions().width"
3790
4740
  [style.height.px]="dimensions().height"
3791
4741
  >
@@ -3812,7 +4762,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3812
4762
  class="vdnd-drag-preview"
3813
4763
  [class.vdnd-drag-preview-dropping]="isSettling()"
3814
4764
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3815
- [style.transform]="transform()"
3816
4765
  [style.width.px]="dimensions().width"
3817
4766
  [style.height.px]="dimensions().height"
3818
4767
  >
@@ -4013,7 +4962,8 @@ class DroppableDirective {
4013
4962
  #elementRef = inject((ElementRef));
4014
4963
  #dragState = inject(DragStateService);
4015
4964
  #autoScroll = inject(AutoScrollService);
4016
- #positionCalculator = inject(PositionCalculatorService);
4965
+ #registry = inject(DroppableRegistryService);
4966
+ #ngZone = inject(NgZone);
4017
4967
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
4018
4968
  /** Unique identifier for this droppable */
4019
4969
  vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
@@ -4042,7 +4992,7 @@ class DroppableDirective {
4042
4992
  autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
4043
4993
  /** Constrain drag preview and placeholder to container boundaries */
4044
4994
  constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
4045
- /** Emits when an item is dropped on this droppable */
4995
+ /** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
4046
4996
  // eslint-disable-next-line @angular-eslint/no-output-native
4047
4997
  drop = output();
4048
4998
  /**
@@ -4087,14 +5037,6 @@ class DroppableDirective {
4087
5037
  #lastPlaceholderIndex = null;
4088
5038
  /** Drag whose initial placeholder position has already been seen by this droppable */
4089
5039
  #placeholderTrackedDrag = null;
4090
- /** Track previous active state to detect the drag-end transition */
4091
- #wasActive = false;
4092
- /**
4093
- * The terminal drag snapshot this droppable has already processed. `endedDragState`
4094
- * persists until the next drag starts, so this guards against replaying the same drop
4095
- * when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
4096
- */
4097
- #handledEndedState = null;
4098
5040
  /** Whether this droppable has rendered, so its inputs have values */
4099
5041
  #rendered = false;
4100
5042
  constructor() {
@@ -4109,59 +5051,33 @@ class DroppableDirective {
4109
5051
  // would never register. AutoScrollService filters by live scroll geometry per drag.
4110
5052
  canRegister: () => Boolean(this.effectiveGroup()),
4111
5053
  });
4112
- // Notify the calculator once this droppable is rendered (host data attributes applied).
4113
- // Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
4114
- // drag start, so without this a conditionally rendered list would never become a target.
4115
- afterNextRender(() => {
4116
- this.#rendered = true;
5054
+ afterNextRender(() => (this.#rendered = true));
5055
+ // Register with the droppable registry, and again under the new key when the ID or group
5056
+ // changes. Drag code finds drop targets through the registry instead of querying the
5057
+ // document; a registration during an active drag makes this droppable a target from the
5058
+ // next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
5059
+ // The cleanup unregisters it, including on destroy.
5060
+ //
5061
+ // Not deferred to afterNextRender: this runs in the change detection that creates the
5062
+ // droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
5063
+ // animation, keyboard focus restore) already find it, and so does a view refreshed only
5064
+ // by its own detectChanges(). Nothing reads the registry before change detection ends.
5065
+ effect((onCleanup) => {
4117
5066
  const group = this.effectiveGroup();
4118
- if (group) {
4119
- this.#positionCalculator.notifyCandidatesChanged(group);
5067
+ if (!group) {
5068
+ return;
4120
5069
  }
5070
+ onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
4121
5071
  });
4122
5072
  effect(() => {
4123
5073
  const currentIndex = this.#placeholderInsertionIndex();
4124
5074
  untracked(() => this.#handlePlaceholderMove(currentIndex));
4125
5075
  });
4126
- // React to state changes and handle drop events.
4127
- //
4128
- // Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
4129
- // placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
4130
- // into endedDragState() at the drag-end boundary, so this effect never has to poll the
4131
- // high-frequency cursor/placeholder signals — reading them here would make it re-run and
4132
- // re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
4133
- effect(() => {
4134
- const active = this.isActive();
4135
- const draggedItem = this.#dragState.draggedItem();
4136
- const isDragging = this.#dragState.isDragging();
4137
- // Handle drag end (drop). This droppable is the release target when it was either
4138
- // observed active during the drag (#wasActive) OR named as the target in the ended
4139
- // drag snapshot. The snapshot path covers the pointer-up flush, where the final
4140
- // position is processed and the state cleared in the same synchronous task, so this
4141
- // effect never observes isActive() === true mid-drag and #wasActive stays false.
4142
- const endedState = untracked(() => this.#dragState.endedDragState());
4143
- const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
4144
- if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
4145
- // Consume each terminal snapshot exactly once. endedDragState persists until the
4146
- // next drag starts and disabled()/other inputs re-run this effect, so without a
4147
- // per-snapshot guard a later state change would replay the same historical drop.
4148
- if (endedState !== this.#handledEndedState) {
4149
- this.#handledEndedState = endedState;
4150
- // Emit only for an enabled target that ended normally. A target disabled at
4151
- // release still consumes the snapshot (so re-enabling can't resurrect the drop)
4152
- // but emits nothing, keeping dragEnd and drop consistent.
4153
- if (!this.disabled() && !this.#dragState.wasCancelled()) {
4154
- this.#handleDrop();
4155
- }
4156
- }
4157
- }
4158
- this.#wasActive = active;
4159
- });
4160
5076
  }
4161
5077
  ngOnDestroy() {
4162
- // Destroyed before it rendered: its inputs may have no values yet, and it never became a
4163
- // drop target or a candidate the calculator knows about. (Not an ngOnInit flag: a subclass
4164
- // with its own ngOnInit would skip it.)
5078
+ // Destroyed before it rendered: its inputs may have no values yet and it never became the
5079
+ // active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
5080
+ // it.) A registry registration, if any, is removed by its effect's cleanup.
4165
5081
  if (!this.#rendered) {
4166
5082
  return;
4167
5083
  }
@@ -4169,13 +5085,7 @@ class DroppableDirective {
4169
5085
  if (this.isActive()) {
4170
5086
  this.#dragState.setActiveDroppable(null);
4171
5087
  }
4172
- // Auto-scroll unregisters itself on destroy (see createAutoScrollRegistration).
4173
- // If this droppable unmounts mid-drag, tell the calculator so it drops it from the
4174
- // frozen candidate list (deferred re-query runs once the element has left the DOM).
4175
- const group = untracked(() => this.effectiveGroup());
4176
- if (group) {
4177
- this.#positionCalculator.notifyCandidatesChanged(group);
4178
- }
5088
+ // Auto-scroll and the droppable registry unregister through their effects' cleanup.
4179
5089
  }
4180
5090
  /**
4181
5091
  * Emit placeholderMove when the placeholder moved within (or entered) this droppable.
@@ -4209,14 +5119,14 @@ class DroppableDirective {
4209
5119
  });
4210
5120
  }
4211
5121
  /**
4212
- * Handle a drop on this droppable.
5122
+ * Emit `drop` for a drag that ended on this droppable. Called by the registry from
5123
+ * `DragStateService.endDrag()`, with the state captured just before the reset. Returns
5124
+ * whether it emitted.
4213
5125
  */
4214
- #handleDrop() {
4215
- // The live state is cleared before this effect fires, so read the terminal snapshot
4216
- // captured by endDrag()/cancelDrag() immediately before the reset.
4217
- const state = untracked(() => this.#dragState.endedDragState());
4218
- if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
4219
- return;
5126
+ #handleDrop(state) {
5127
+ // A target disabled at release is not a valid drop (dragEnd reports no destination).
5128
+ if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
5129
+ return false;
4220
5130
  }
4221
5131
  const sourceDroppableId = state.sourceDroppableId ?? '';
4222
5132
  const placeholderId = state.placeholderId ?? END_OF_LIST;
@@ -4236,7 +5146,7 @@ class DroppableDirective {
4236
5146
  sourceDroppableId,
4237
5147
  activeDroppableId: this.vdndDroppable(),
4238
5148
  });
4239
- this.drop.emit({
5149
+ const event = {
4240
5150
  source: {
4241
5151
  draggableId: state.draggedItem.draggableId,
4242
5152
  droppableId: sourceDroppableId,
@@ -4249,7 +5159,13 @@ class DroppableDirective {
4249
5159
  index: destinationIndex,
4250
5160
  data: this.vdndDroppableData(),
4251
5161
  },
4252
- });
5162
+ };
5163
+ // Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
5164
+ // zone.js a template listener marks its view dirty without scheduling a render, so emit
5165
+ // inside the zone for the consumer's handler (and async work it starts) to render. A
5166
+ // no-op when zoneless.
5167
+ this.#ngZone.run(() => this.drop.emit(event));
5168
+ return true;
4253
5169
  }
4254
5170
  /**
4255
5171
  * Get the index of an item in a droppable.
@@ -4257,7 +5173,7 @@ class DroppableDirective {
4257
5173
  */
4258
5174
  #getItemIndex(draggableId, droppableId) {
4259
5175
  // Find all draggables in the source droppable
4260
- const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
5176
+ const droppable = this.#registry.getById(droppableId);
4261
5177
  if (!droppable) {
4262
5178
  return 0;
4263
5179
  }
@@ -4330,6 +5246,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4330
5246
  }]
4331
5247
  }], 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"] }] } });
4332
5248
 
5249
+ /**
5250
+ * Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
5251
+ *
5252
+ * Not template listeners: Angular marks the listening view and every ancestor up to the root
5253
+ * dirty before running one, so each placeholder move would re-render the consumer's whole
5254
+ * component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
5255
+ * a consumer's own listener on the sortable list marks only its own view. Subscribed in the
5256
+ * constructor, before the droppable's first change detection can emit; both outputs drop their
5257
+ * listeners when the element is destroyed.
5258
+ */
5259
+ class SortableListOutputsDirective {
5260
+ constructor() {
5261
+ const droppable = inject(DroppableDirective);
5262
+ const list = inject(VirtualSortableListComponent);
5263
+ droppable.drop.subscribe((event) => list.drop.emit(event));
5264
+ droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
5265
+ }
5266
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
5267
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
5268
+ }
5269
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
5270
+ type: Directive,
5271
+ args: [{ selector: '[vdndSortableListOutputs]' }]
5272
+ }], ctorParameters: () => [] });
4333
5273
  /**
4334
5274
  * A high-level component that combines droppable, virtual scroll, and placeholder
4335
5275
  * functionality into a single, easy-to-use component.
@@ -4407,6 +5347,13 @@ class VirtualSortableListComponent {
4407
5347
  containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
4408
5348
  /** Number of items to render above/below the visible area */
4409
5349
  overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
5350
+ /**
5351
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
5352
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
5353
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
5354
+ * @default false
5355
+ */
5356
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
4410
5357
  /** Enable auto-scroll when dragging near edges */
4411
5358
  autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
4412
5359
  /** Auto-scroll configuration */
@@ -4420,7 +5367,7 @@ class VirtualSortableListComponent {
4420
5367
  /** Emits each time the placeholder moves within this list (every item displacement) */
4421
5368
  placeholderMove = output();
4422
5369
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4423
- 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: `
5370
+ 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 }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", 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: `
4424
5371
  <div
4425
5372
  class="vdnd-sortable-list-droppable"
4426
5373
  [vdndDroppable]="droppableId()"
@@ -4428,8 +5375,7 @@ class VirtualSortableListComponent {
4428
5375
  [vdndDroppableData]="droppableData()"
4429
5376
  [disabled]="disabled()"
4430
5377
  [constrainToContainer]="constrainToContainer()"
4431
- (drop)="drop.emit($event)"
4432
- (placeholderMove)="placeholderMove.emit($event)"
5378
+ vdndSortableListOutputs
4433
5379
  >
4434
5380
  <vdnd-virtual-scroll
4435
5381
  [items]="items()"
@@ -4442,16 +5388,17 @@ class VirtualSortableListComponent {
4442
5388
  [autoStickyDraggedItem]="true"
4443
5389
  [containerHeight]="containerHeight()"
4444
5390
  [overscan]="overscan()"
5391
+ [recycleRows]="recycleRows()"
4445
5392
  [autoScrollEnabled]="autoScrollEnabled()"
4446
5393
  [autoScrollConfig]="autoScrollConfig()"
4447
5394
  >
4448
5395
  </vdnd-virtual-scroll>
4449
5396
  </div>
4450
- `, 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 });
5397
+ `, 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", "recycleRows", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }, { kind: "directive", type: SortableListOutputsDirective, selector: "[vdndSortableListOutputs]" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4451
5398
  }
4452
5399
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
4453
5400
  type: Component,
4454
- args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
5401
+ args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
4455
5402
  class: 'vdnd-sortable-list',
4456
5403
  }, template: `
4457
5404
  <div
@@ -4461,8 +5408,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4461
5408
  [vdndDroppableData]="droppableData()"
4462
5409
  [disabled]="disabled()"
4463
5410
  [constrainToContainer]="constrainToContainer()"
4464
- (drop)="drop.emit($event)"
4465
- (placeholderMove)="placeholderMove.emit($event)"
5411
+ vdndSortableListOutputs
4466
5412
  >
4467
5413
  <vdnd-virtual-scroll
4468
5414
  [items]="items()"
@@ -4475,13 +5421,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4475
5421
  [autoStickyDraggedItem]="true"
4476
5422
  [containerHeight]="containerHeight()"
4477
5423
  [overscan]="overscan()"
5424
+ [recycleRows]="recycleRows()"
4478
5425
  [autoScrollEnabled]="autoScrollEnabled()"
4479
5426
  [autoScrollConfig]="autoScrollConfig()"
4480
5427
  >
4481
5428
  </vdnd-virtual-scroll>
4482
5429
  </div>
4483
5430
  `, styles: [":host{display:block}\n"] }]
4484
- }], 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"] }] } });
5431
+ }], 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 }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", 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"] }] } });
4485
5432
 
4486
5433
  /**
4487
5434
  * A virtual viewport component that provides efficient wrapper-based positioning
@@ -5129,22 +6076,25 @@ class KeyboardDragHandler {
5129
6076
  this.#cleanupDocumentListener();
5130
6077
  // Clear droppable metadata cache from this drag session
5131
6078
  this.#deps.dragIndexCalculator.clearCache();
5132
- // Emit drag end event
5133
- this.#deps.callbacks.onDragEnd({
6079
+ const event = {
5134
6080
  draggableId: ctx.draggableId,
5135
6081
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5136
6082
  cancelled: false,
5137
6083
  data: ctx.data,
5138
6084
  sourceIndex,
5139
6085
  destinationIndex,
5140
- });
6086
+ };
5141
6087
  // The item stays in its source list when there is no valid target
5142
6088
  const fallbackDroppableId = hasValidTarget
5143
6089
  ? activeDroppableId
5144
6090
  : this.#deps.dragState.sourceDroppableId();
5145
- this.#deps.keyboardDrag.completeKeyboardDrag();
5146
- // Restore focus to the moved element after state updates
5147
- this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
6091
+ this.#endInZone(() => {
6092
+ this.#deps.callbacks.onDragEnd(event);
6093
+ // Ends the drag state, which delivers the drop
6094
+ this.#deps.keyboardDrag.completeKeyboardDrag();
6095
+ // Restore focus to the moved element after state updates
6096
+ this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
6097
+ });
5148
6098
  }
5149
6099
  /**
5150
6100
  * Cancel a keyboard drag operation.
@@ -5158,18 +6108,30 @@ class KeyboardDragHandler {
5158
6108
  this.#cleanupDocumentListener();
5159
6109
  // Clear droppable metadata cache from this drag session
5160
6110
  this.#deps.dragIndexCalculator.clearCache();
5161
- // Emit drag end event
5162
- this.#deps.callbacks.onDragEnd({
6111
+ const event = {
5163
6112
  draggableId: ctx.draggableId,
5164
6113
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5165
6114
  cancelled: true,
5166
6115
  data: ctx.data,
5167
6116
  sourceIndex,
5168
6117
  destinationIndex: null,
6118
+ };
6119
+ this.#endInZone(() => {
6120
+ this.#deps.callbacks.onDragEnd(event);
6121
+ this.#deps.keyboardDrag.cancelKeyboardDrag();
6122
+ // Restore focus to the original element after state updates
6123
+ this.#restoreFocus(ctx.draggableId, sourceDroppableId);
5169
6124
  });
5170
- this.#deps.keyboardDrag.cancelKeyboardDrag();
5171
- // Restore focus to the original element after state updates
5172
- this.#restoreFocus(ctx.draggableId, sourceDroppableId);
6125
+ }
6126
+ /**
6127
+ * Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
6128
+ * entry into Angular's zone. The document listener runs outside it; with zone.js a
6129
+ * template listener marks its view dirty but schedules no render, and each entry renders
6130
+ * when it is left, so this renders the end once, with focus restored in that render. A
6131
+ * no-op when zoneless.
6132
+ */
6133
+ #endInZone(end) {
6134
+ this.#deps.ngZone.run(end);
5173
6135
  }
5174
6136
  /**
5175
6137
  * Teardown — remove any active document listener.
@@ -5197,7 +6159,7 @@ class KeyboardDragHandler {
5197
6159
  }
5198
6160
  else if (fallbackDroppableId) {
5199
6161
  // Fallback: focus the first draggable in the list the item ended up in
5200
- const container = queryByAttribute(document, 'data-droppable-id', fallbackDroppableId);
6162
+ const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
5201
6163
  const firstDraggable = container?.querySelector('[data-draggable-id]');
5202
6164
  firstDraggable?.focus();
5203
6165
  }
@@ -5291,6 +6253,10 @@ class PointerDragHandler {
5291
6253
  #startPosition = null;
5292
6254
  /** Whether we're currently tracking a potential drag */
5293
6255
  #isTracking = false;
6256
+ /** The item the tracked press landed on */
6257
+ #pressedId = null;
6258
+ /** Whether the document and window listeners of a press are added */
6259
+ #listening = false;
5294
6260
  /** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
5295
6261
  #touchId = null;
5296
6262
  /** Bound event handlers for cleanup */
@@ -5373,6 +6339,7 @@ class PointerDragHandler {
5373
6339
  return;
5374
6340
  }
5375
6341
  this.#isTracking = true;
6342
+ this.#pressedId = ctx.draggableId;
5376
6343
  if (touch) {
5377
6344
  this.#touchId = touch.identifier;
5378
6345
  this.#startPosition = { x: touch.clientX, y: touch.clientY };
@@ -5387,9 +6354,10 @@ class PointerDragHandler {
5387
6354
  if (delay > 0) {
5388
6355
  this.#delayTimerId = setTimeout(() => {
5389
6356
  this.#delayTimerId = null;
5390
- // Another drag started while this press was held: drop the press rather than show it
5391
- // as ready to drag during that drag.
5392
- if (this.#deps.callbacks.isOtherDragActive()) {
6357
+ // Another drag started while this press was held, or the press no longer applies (see
6358
+ // #isStalePress): drop it rather than show it as ready to drag.
6359
+ if (this.#deps.callbacks.isOtherDragActive() ||
6360
+ this.#isStalePress(this.#deps.getContext())) {
5393
6361
  this.cleanup();
5394
6362
  return;
5395
6363
  }
@@ -5418,6 +6386,7 @@ class PointerDragHandler {
5418
6386
  window.addEventListener('blur', this.#boundWindowBlur);
5419
6387
  document.addEventListener('visibilitychange', this.#boundVisibilityChange);
5420
6388
  });
6389
+ this.#listening = true;
5421
6390
  }
5422
6391
  /**
5423
6392
  * Remove listeners + cancel timers.
@@ -5425,14 +6394,17 @@ class PointerDragHandler {
5425
6394
  */
5426
6395
  cleanup() {
5427
6396
  this.#isTracking = false;
6397
+ this.#pressedId = null;
5428
6398
  this.#startPosition = null;
5429
6399
  this.#touchId = null;
5430
6400
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
5431
6401
  this.#cancelDelayTimer();
5432
- // Server rendering destroys the directive without a global document, and never added any
5433
- if (typeof document === 'undefined') {
6402
+ // Nothing to remove unless a press added listeners (most draggables are never pressed; a
6403
+ // virtual list destroys one each time a row scrolls out). Server rendering adds none.
6404
+ if (!this.#listening) {
5434
6405
  return;
5435
6406
  }
6407
+ this.#listening = false;
5436
6408
  // Remove event listeners
5437
6409
  if (this.#boundPointerMove) {
5438
6410
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -5483,9 +6455,14 @@ class PointerDragHandler {
5483
6455
  else {
5484
6456
  position = { x: event.clientX, y: event.clientY };
5485
6457
  }
5486
- const ctx = this.#deps.getContext();
5487
6458
  // Check if we've moved past the threshold
5488
6459
  if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
6460
+ // Read only before the drag starts, not on every move of a drag: nothing in it matters then
6461
+ const ctx = this.#deps.getContext();
6462
+ if (this.#isStalePress(ctx)) {
6463
+ this.cleanup();
6464
+ return;
6465
+ }
5489
6466
  const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
5490
6467
  Math.pow(position.y - this.#startPosition.y, 2));
5491
6468
  if (distance < ctx.dragThreshold) {
@@ -5537,13 +6514,25 @@ class PointerDragHandler {
5537
6514
  }
5538
6515
  // Only prevent default if we were actually dragging
5539
6516
  // Otherwise, allow native touch behavior (like scroll momentum) to complete
5540
- if (this.#deps.callbacks.isDragging()) {
5541
- event.preventDefault();
5542
- // The system took the touch (an incoming call, an OS gesture, too many fingers): the user
5543
- // never released, so this is a cancel, not a drop
5544
- this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
6517
+ // The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
6518
+ try {
6519
+ if (this.#deps.callbacks.isDragging()) {
6520
+ event.preventDefault();
6521
+ // The system took the touch (an incoming call, an OS gesture, too many fingers): the
6522
+ // user never released, so this is a cancel, not a drop
6523
+ this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
6524
+ }
5545
6525
  }
5546
- this.cleanup();
6526
+ finally {
6527
+ this.cleanup();
6528
+ }
6529
+ }
6530
+ /**
6531
+ * Whether the tracked press no longer applies to the draggable: it renders another item now, or
6532
+ * it left the page (a virtual list recycled or pooled its row).
6533
+ */
6534
+ #isStalePress(ctx) {
6535
+ return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
5547
6536
  }
5548
6537
  /**
5549
6538
  * Cancel the delay timer if active.
@@ -5560,9 +6549,12 @@ class PointerDragHandler {
5560
6549
  */
5561
6550
  #onEscapeKeyDown(event) {
5562
6551
  if (event.key === 'Escape' && this.#deps.callbacks.isDragging()) {
5563
- // No ngZone.run() needed - #endDrag uses signals which work outside zone
5564
- this.#deps.callbacks.onDragEnd(true);
5565
- this.cleanup();
6552
+ try {
6553
+ this.#deps.callbacks.onDragEnd(true);
6554
+ }
6555
+ finally {
6556
+ this.cleanup();
6557
+ }
5566
6558
  }
5567
6559
  }
5568
6560
  /**
@@ -5572,10 +6564,14 @@ class PointerDragHandler {
5572
6564
  if (!this.#isTracking) {
5573
6565
  return;
5574
6566
  }
5575
- if (this.#deps.callbacks.isDragging()) {
5576
- this.#deps.callbacks.onDragEnd(true);
6567
+ try {
6568
+ if (this.#deps.callbacks.isDragging()) {
6569
+ this.#deps.callbacks.onDragEnd(true);
6570
+ }
6571
+ }
6572
+ finally {
6573
+ this.cleanup();
5577
6574
  }
5578
- this.cleanup();
5579
6575
  }
5580
6576
  /**
5581
6577
  * Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
@@ -5587,10 +6583,20 @@ class PointerDragHandler {
5587
6583
  }
5588
6584
  /**
5589
6585
  * The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
5590
- * leave a list undefined, so a missing list counts as empty.
6586
+ * leave a list undefined, so a missing list counts as empty. Read by index, without copying the
6587
+ * list or creating an iterator: this runs for every touch move.
5591
6588
  */
5592
6589
  #trackedTouch(touches) {
5593
- return Array.from(touches ?? []).find((touch) => touch.identifier === this.#touchId) ?? null;
6590
+ if (!touches) {
6591
+ return null;
6592
+ }
6593
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
6594
+ for (let i = 0; i < touches.length; i++) {
6595
+ if (touches[i].identifier === this.#touchId) {
6596
+ return touches[i];
6597
+ }
6598
+ }
6599
+ return null;
5594
6600
  }
5595
6601
  /**
5596
6602
  * The finger that pressed this draggable: of the touches that started with this event, the
@@ -5604,6 +6610,26 @@ class PointerDragHandler {
5604
6610
  }
5605
6611
  }
5606
6612
 
6613
+ /** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
6614
+ const KEY_NAMES = {
6615
+ ' ': 'space',
6616
+ Esc: 'escape',
6617
+ '\x1B': 'escape',
6618
+ Up: 'arrowup',
6619
+ Down: 'arrowdown',
6620
+ Left: 'arrowleft',
6621
+ Right: 'arrowright',
6622
+ };
6623
+ /** The keys an item handles */
6624
+ const HANDLED_KEYS = new Set([
6625
+ 'space',
6626
+ 'enter',
6627
+ 'arrowup',
6628
+ 'arrowdown',
6629
+ 'arrowleft',
6630
+ 'arrowright',
6631
+ 'escape',
6632
+ ]);
5607
6633
  /**
5608
6634
  * Makes an element draggable within the virtual scroll drag-and-drop system.
5609
6635
  *
@@ -5680,9 +6706,16 @@ class DraggableDirective {
5680
6706
  dragStart = output();
5681
6707
  /** Emits when drag ends */
5682
6708
  dragEnd = output();
5683
- /** Whether this element is in the "ready to drag" state (delay has passed) */
5684
- #isPending = signal(false, ...(ngDevMode ? [{ debugName: "#isPending" }] : []));
5685
- isPending = this.#isPending.asReadonly();
6709
+ /** The item whose press is ready to drag (its delay has passed) */
6710
+ #pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
6711
+ /**
6712
+ * Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
6713
+ * item: a recycled row that renders another item is not ready to drag.
6714
+ */
6715
+ isPending = computed(() => {
6716
+ const pendingId = this.#pendingId();
6717
+ return pendingId !== null && pendingId === this.vdndDraggable();
6718
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
5686
6719
  /** Whether this element is currently being dragged (based on global drag state) */
5687
6720
  isDragging = computed(() => {
5688
6721
  const draggedItem = this.#dragState.draggedItem();
@@ -5692,6 +6725,62 @@ class DraggableDirective {
5692
6725
  #pointerHandler;
5693
6726
  /** Set by ngOnInit, which creates the handlers */
5694
6727
  #initialized = false;
6728
+ /**
6729
+ * Keys pressed on the item: one listener, added outside Angular's zone, instead of a
6730
+ * `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
6731
+ * Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
6732
+ * of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
6733
+ * default action when the handler returns false, and reports what the handler throws to the
6734
+ * ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
6735
+ * as host listeners do.
6736
+ */
6737
+ #onKeydown = (event) => {
6738
+ if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
6739
+ return;
6740
+ }
6741
+ const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
6742
+ if (!HANDLED_KEYS.has(key)) {
6743
+ return;
6744
+ }
6745
+ this.#ngZone.run(() => {
6746
+ try {
6747
+ if (this.#handleKey(key, event) === false) {
6748
+ event.preventDefault();
6749
+ }
6750
+ }
6751
+ catch (error) {
6752
+ this.#envInjector.get(ErrorHandler).handleError(error);
6753
+ }
6754
+ });
6755
+ };
6756
+ /**
6757
+ * Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
6758
+ * without options, so non-passive): a swipe that starts on an element with a non-passive
6759
+ * touchstart listener can't scroll until the main thread has run it. With a drag delay the press
6760
+ * never cancels the scroll (the page must scroll when the user swipes before the delay passes),
6761
+ * so the listener is passive; without one it prevents the default action and is non-passive.
6762
+ * Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
6763
+ * does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
6764
+ * a host listener it runs after the element's template listeners.
6765
+ */
6766
+ #onTouchStart = (event) => {
6767
+ if (!this.#initialized) {
6768
+ return;
6769
+ }
6770
+ this.#ngZone.run(() => {
6771
+ try {
6772
+ this.onPointerDown(event, true);
6773
+ }
6774
+ catch (error) {
6775
+ this.#envInjector.get(ErrorHandler).handleError(error);
6776
+ }
6777
+ });
6778
+ };
6779
+ /** Whether the touchstart listener is passive, or null while it isn't added */
6780
+ #touchStartPassive = null;
6781
+ constructor() {
6782
+ this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
6783
+ }
5695
6784
  /** Cached constraint flag from source droppable */
5696
6785
  #constrainToContainer = false;
5697
6786
  /** Element whose rect is used for clamping (scroll container or droppable itself) */
@@ -5702,9 +6791,7 @@ class DraggableDirective {
5702
6791
  * Update the pending state and emit the change event.
5703
6792
  */
5704
6793
  #setPending(pending) {
5705
- if (this.#isPending() !== pending) {
5706
- this.#isPending.set(pending);
5707
- }
6794
+ this.#pendingId.set(pending ? this.vdndDraggable() : null);
5708
6795
  }
5709
6796
  /**
5710
6797
  * Find the element to use for container constraint clamping.
@@ -5729,7 +6816,8 @@ class DraggableDirective {
5729
6816
  ngZone: this.#ngZone,
5730
6817
  envInjector: this.#envInjector,
5731
6818
  callbacks: {
5732
- onDragStart: (event) => this.dragStart.emit(event),
6819
+ onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
6820
+ // The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
5733
6821
  onDragEnd: (event) => this.dragEnd.emit(event),
5734
6822
  getParentDroppableId: () => this.#getParentDroppableId(),
5735
6823
  calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
@@ -5758,6 +6846,7 @@ class DraggableDirective {
5758
6846
  },
5759
6847
  getContext: () => ({
5760
6848
  element: this.#elementRef.nativeElement,
6849
+ draggableId: this.vdndDraggable(),
5761
6850
  groupName: this.#effectiveGroup(),
5762
6851
  disabled: this.disabled(),
5763
6852
  dragHandle: this.dragHandle(),
@@ -5766,8 +6855,14 @@ class DraggableDirective {
5766
6855
  }),
5767
6856
  });
5768
6857
  this.#initialized = true;
6858
+ this.#listenForTouchStart();
6859
+ }
6860
+ ngOnChanges() {
6861
+ this.#listenForTouchStart();
5769
6862
  }
5770
6863
  ngOnDestroy() {
6864
+ this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
6865
+ this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
5771
6866
  // Destroyed before its first change detection: there are no handlers yet, and its inputs
5772
6867
  // have no values (reading a bound ID would throw).
5773
6868
  if (!this.#initialized) {
@@ -5780,6 +6875,35 @@ class DraggableDirective {
5780
6875
  this.#pointerHandler.destroy();
5781
6876
  this.#keyboardHandler.destroy();
5782
6877
  }
6878
+ /**
6879
+ * Add the touchstart listener, or re-add it when the drag delay changes whether it can be
6880
+ * passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
6881
+ * that visits every ancestor, and a virtual list creates rows on each scroll step.
6882
+ */
6883
+ #listenForTouchStart() {
6884
+ // The handler cancels the scroll of a touch press only when there is no delay
6885
+ const passive = this.dragDelay() !== 0;
6886
+ if (passive === this.#touchStartPassive) {
6887
+ return;
6888
+ }
6889
+ const element = this.#elementRef.nativeElement;
6890
+ element.removeEventListener('touchstart', this.#onTouchStart);
6891
+ this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
6892
+ this.#touchStartPassive = passive;
6893
+ }
6894
+ /** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
6895
+ #handleKey(key, event) {
6896
+ switch (key) {
6897
+ case 'space':
6898
+ return this.onKeyboardActivate(event);
6899
+ case 'enter':
6900
+ return this.onEnterKey(event);
6901
+ case 'escape':
6902
+ return this.onEscape();
6903
+ default:
6904
+ return this.onArrowKey(event);
6905
+ }
6906
+ }
5783
6907
  /**
5784
6908
  * Handle pointer down (mouse or touch).
5785
6909
  */
@@ -5957,13 +7081,16 @@ class DraggableDirective {
5957
7081
  // Start auto-scroll monitoring — registers as a scheduler participant.
5958
7082
  this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
5959
7083
  // Emit drag start event
5960
- this.dragStart.emit({
7084
+ // Pointer listeners run outside Angular's zone. With zone.js a template listener marks
7085
+ // its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
7086
+ const event = {
5961
7087
  draggableId: this.vdndDraggable(),
5962
7088
  droppableId: parentDroppableId ?? '',
5963
7089
  data: this.vdndDraggableData(),
5964
7090
  position,
5965
7091
  sourceIndex,
5966
- });
7092
+ };
7093
+ this.#ngZone.run(() => this.dragStart.emit(event));
5967
7094
  }
5968
7095
  /**
5969
7096
  * Calculate the source index of the dragged element BEFORE it's hidden.
@@ -6179,22 +7306,27 @@ class DraggableDirective {
6179
7306
  sourceDroppableId: this.#dragState.sourceDroppableId(),
6180
7307
  activeDroppableId: this.#dragState.activeDroppableId(),
6181
7308
  });
6182
- // Emit drag end event
6183
- this.dragEnd.emit({
7309
+ const event = {
6184
7310
  draggableId: this.vdndDraggable(),
6185
7311
  droppableId: this.#getParentDroppableId() ?? '',
6186
7312
  cancelled,
6187
7313
  data: this.vdndDraggableData(),
6188
7314
  sourceIndex,
6189
7315
  destinationIndex,
7316
+ };
7317
+ // dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
7318
+ // outside Angular's zone, and with zone.js each entry renders when it is left, so
7319
+ // separate entries would render the drag end twice. A no-op when zoneless.
7320
+ this.#ngZone.run(() => {
7321
+ this.dragEnd.emit(event);
7322
+ // Clear drag state - this triggers isDragging computed to become false
7323
+ if (cancelled) {
7324
+ this.#dragState.cancelDrag();
7325
+ }
7326
+ else {
7327
+ this.#dragState.endDrag();
7328
+ }
6190
7329
  });
6191
- // Clear drag state - this triggers isDragging computed to become false
6192
- if (cancelled) {
6193
- this.#dragState.cancelDrag();
6194
- }
6195
- else {
6196
- this.#dragState.endDrag();
6197
- }
6198
7330
  }
6199
7331
  /**
6200
7332
  * Get the parent droppable ID.
@@ -6208,7 +7340,7 @@ class DraggableDirective {
6208
7340
  return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
6209
7341
  }
6210
7342
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
6211
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)", "touchstart": "onPointerDown($event, true)", "keydown.space": "onKeyboardActivate($event)", "keydown.enter": "onEnterKey($event)", "keydown.arrowup": "onArrowKey($event)", "keydown.arrowdown": "onArrowKey($event)", "keydown.arrowleft": "onArrowKey($event)", "keydown.arrowright": "onArrowKey($event)", "keydown.escape": "onEscape()" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, ngImport: i0 });
7343
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, usesOnChanges: true, ngImport: i0 });
6212
7344
  }
6213
7345
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
6214
7346
  type: Directive,
@@ -6224,17 +7356,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
6224
7356
  '[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
6225
7357
  '[tabindex]': 'disabled() ? -1 : 0',
6226
7358
  '(mousedown)': 'onPointerDown($event, false)',
6227
- '(touchstart)': 'onPointerDown($event, true)',
6228
- '(keydown.space)': 'onKeyboardActivate($event)',
6229
- '(keydown.enter)': 'onEnterKey($event)',
6230
- '(keydown.arrowup)': 'onArrowKey($event)',
6231
- '(keydown.arrowdown)': 'onArrowKey($event)',
6232
- '(keydown.arrowleft)': 'onArrowKey($event)',
6233
- '(keydown.arrowright)': 'onArrowKey($event)',
6234
- '(keydown.escape)': 'onEscape()',
6235
7359
  },
6236
7360
  }]
6237
- }], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
7361
+ }], ctorParameters: () => [], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
6238
7362
 
6239
7363
  /**
6240
7364
  * Directive that marks an element as a scrollable container for virtual scrolling.
@@ -6548,38 +7672,47 @@ class VirtualForDirective {
6548
7672
  const end = Math.min(total - 1, first + visible + overscan);
6549
7673
  return { start, end };
6550
7674
  }, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
6551
- /** Map of trackBy keys to item indices for quick lookup */
6552
- #itemIndexMap = computed(() => {
6553
- const items = this.vdndVirtualForOf();
7675
+ /**
7676
+ * Every item's track key, in order. Computed once per items change, and only when something
7677
+ * reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
7678
+ */
7679
+ #itemKeys = computed(() => {
6554
7680
  const trackByFn = this.vdndVirtualForTrackBy();
6555
- const map = new Map();
6556
- for (let i = 0; i < items.length; i++) {
6557
- map.set(trackByFn(i, items[i]), i);
6558
- }
6559
- return map;
6560
- }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
6561
- /** Index of the dragged item in this list (-1 if not present or not dragging) */
7681
+ return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
7682
+ }, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
7683
+ /**
7684
+ * Index of the dragged item in this list (-1 if not present or not dragging). The index where
7685
+ * the drag found its item is checked first: it spares a search of every track key.
7686
+ */
6562
7687
  #draggedItemIndex = computed(() => {
6563
7688
  const draggedItem = this.#dragState.draggedItem();
6564
7689
  if (!draggedItem)
6565
7690
  return -1;
6566
- const byId = this.#itemIndexMap().get(draggedItem.draggableId);
6567
- if (byId !== undefined) {
7691
+ const items = this.vdndVirtualForOf();
7692
+ const sourceIndex = this.#dragState.sourceIndex();
7693
+ if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
7694
+ const item = items[sourceIndex];
7695
+ if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
7696
+ (draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
7697
+ return sourceIndex;
7698
+ }
7699
+ }
7700
+ // The last item with the key, as an index map filled in order would give
7701
+ const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
7702
+ if (byId >= 0) {
6568
7703
  return byId;
6569
7704
  }
6570
7705
  const data = draggedItem.data;
6571
7706
  if (data !== null && data !== undefined) {
6572
- return this.vdndVirtualForOf().indexOf(data);
7707
+ return items.indexOf(data);
6573
7708
  }
6574
7709
  return -1;
6575
7710
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
6576
7711
  constructor() {
6577
- // Keep strategy item keys in sync
7712
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
7713
+ // spares computing every item's track key on each items change (each drop).
6578
7714
  effect(() => {
6579
- const items = this.vdndVirtualForOf();
6580
- const trackByFn = this.vdndVirtualForTrackBy();
6581
- const keys = items.map((item, i) => trackByFn(i, item));
6582
- this.#strategy().setItemKeys(keys);
7715
+ setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
6583
7716
  });
6584
7717
  // Register strategy with drag index calculator for accurate position lookups
6585
7718
  effect((onCleanup) => {
@@ -6595,12 +7728,13 @@ class VirtualForDirective {
6595
7728
  // correctly skip the hidden item — not just findIndexAtOffset.
6596
7729
  effect(() => {
6597
7730
  const strategy = this.#strategy();
6598
- const draggedIndex = this.#draggedItemIndex();
6599
7731
  const droppableId = this.#effectiveDroppableId();
6600
7732
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6601
7733
  const isDragging = this.#dragState.isDragging();
6602
7734
  const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
6603
- if (isSourceList && draggedIndex >= 0) {
7735
+ // Only the source list looks for the dragged item (in all its keys)
7736
+ const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
7737
+ if (draggedIndex >= 0) {
6604
7738
  strategy.setExcludedIndex(draggedIndex);
6605
7739
  }
6606
7740
  else {
@@ -6799,14 +7933,12 @@ class VirtualForDirective {
6799
7933
  strategy.version();
6800
7934
  const placeholderIndex = this.#placeholderIndex();
6801
7935
  const showPlaceholder = this.#shouldShowPlaceholder();
6802
- const draggedIndex = this.#draggedItemIndex();
6803
7936
  const droppableId = this.#effectiveDroppableId();
6804
7937
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6805
7938
  const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
6806
- const shouldKeepDragged = this.#dragState.isDragging() &&
6807
- draggedIndex >= 0 &&
6808
- isSourceList &&
6809
- draggedIndex < items.length;
7939
+ // Only the source list keeps the dragged item rendered, so only it looks for the item
7940
+ const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
7941
+ const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
6810
7942
  // Snapshot positions before the DOM changes so displaced items can slide
6811
7943
  this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
6812
7944
  // Notify viewport of render start index for wrapper positioning
@@ -6916,14 +8048,8 @@ class VirtualForDirective {
6916
8048
  // Move unused views to pool
6917
8049
  for (const [key, view] of this.#activeViews) {
6918
8050
  if (!neededKeys.has(key)) {
6919
- const index = this.#viewContainer.indexOf(view);
6920
- if (index >= 0) {
6921
- this.#viewContainer.detach(index);
6922
- }
6923
- this.#unobserveViewElements(view);
6924
- this.#cancelShiftAnimation(view);
6925
- this.#viewPool.push(view);
6926
8051
  this.#activeViews.delete(key);
8052
+ this.#poolView(view);
6927
8053
  }
6928
8054
  }
6929
8055
  // Remove placeholder from DOM if not needed
@@ -6935,6 +8061,7 @@ class VirtualForDirective {
6935
8061
  let viewContainerIndex = 0;
6936
8062
  let placeholderDomPosition = -1;
6937
8063
  const renderedKeys = new Set();
8064
+ const changedViews = [];
6938
8065
  for (const entry of itemsToRender) {
6939
8066
  if (entry.type === 'placeholder') {
6940
8067
  placeholderDomPosition = viewContainerIndex;
@@ -6945,7 +8072,7 @@ class VirtualForDirective {
6945
8072
  continue;
6946
8073
  }
6947
8074
  renderedKeys.add(entry.key);
6948
- const view = this.#getOrCreateView(entry.key, entry.context);
8075
+ const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
6949
8076
  // Ensure view is at correct position in ViewContainerRef
6950
8077
  const currentIndex = this.#viewContainer.indexOf(view);
6951
8078
  if (currentIndex !== viewContainerIndex) {
@@ -6970,8 +8097,33 @@ class VirtualForDirective {
6970
8097
  }
6971
8098
  viewContainerIndex++;
6972
8099
  }
8100
+ this.#renderChangedViews(changedViews);
6973
8101
  return placeholderDomPosition;
6974
8102
  }
8103
+ /**
8104
+ * Take a row's view out of the container and pool it. It keeps its element, so its shift
8105
+ * animation is cancelled (the item it renders next must not slide from this one's position),
8106
+ * and a drop animation that hides it shows it again.
8107
+ */
8108
+ #poolView(view) {
8109
+ const index = this.#viewContainer.indexOf(view);
8110
+ if (index >= 0) {
8111
+ this.#viewContainer.detach(index);
8112
+ }
8113
+ this.#unobserveViewElements(view);
8114
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
8115
+ // the animation ends: in the row of another item by then, if the view were reused. A row
8116
+ // whose element is dragged (its draggable ID is not its track key) is destroyed too, which
8117
+ // cancels the drag instead of handing it to another item.
8118
+ const draggedElement = this.#dragState.draggedItem()?.element;
8119
+ if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
8120
+ view.destroy();
8121
+ return;
8122
+ }
8123
+ this.#cancelShiftAnimation(view);
8124
+ revealDropTargetIn(view.rootNodes);
8125
+ this.#viewPool.push(view);
8126
+ }
6975
8127
  /**
6976
8128
  * Clamp target indices for move/insert to avoid out-of-range operations when
6977
8129
  * reconciliation is given invalid duplicate keys or transiently inconsistent view state.
@@ -6997,28 +8149,59 @@ class VirtualForDirective {
6997
8149
  }
6998
8150
  /**
6999
8151
  * Get an existing view or create/recycle one from the pool.
8152
+ * Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
8153
+ * changed context.
7000
8154
  */
7001
- #getOrCreateView(key, context) {
7002
- let view = this.#activeViews.get(key);
8155
+ #getOrCreateView(key, context, changedViews) {
8156
+ const activeView = this.#activeViews.get(key);
8157
+ let view = activeView ?? this.#viewPool.pop();
7003
8158
  if (view) {
7004
- // Update existing view context
7005
- Object.assign(view.context, context);
7006
- view.markForCheck();
8159
+ // A pooled view was detached, so change detection skipped it: render it even when it
8160
+ // comes back with the same context, as what its template shows may have changed since
8161
+ if (this.#updateContext(view.context, context) || !activeView) {
8162
+ changedViews.push(view);
8163
+ }
7007
8164
  }
7008
8165
  else {
7009
- // Try pool first, then create new
7010
- view = this.#viewPool.pop();
7011
- if (view) {
7012
- Object.assign(view.context, context);
7013
- view.markForCheck();
7014
- }
7015
- else {
7016
- view = this.#templateRef.createEmbeddedView(context);
7017
- }
7018
- this.#activeViews.set(key, view);
8166
+ view = this.#templateRef.createEmbeddedView(context);
8167
+ changedViews.push(view);
7019
8168
  }
8169
+ this.#activeViews.set(key, view);
7020
8170
  return view;
7021
8171
  }
8172
+ /** Copy the fields of `next` into `context`. Returns whether any of them changed. */
8173
+ #updateContext(context, next) {
8174
+ if (Object.is(context.$implicit, next.$implicit) &&
8175
+ context.index === next.index &&
8176
+ context.first === next.first &&
8177
+ context.last === next.last &&
8178
+ context.count === next.count) {
8179
+ return false;
8180
+ }
8181
+ context.$implicit = next.$implicit;
8182
+ context.index = next.index;
8183
+ context.first = next.first;
8184
+ context.last = next.last;
8185
+ context.count = next.count;
8186
+ return true;
8187
+ }
8188
+ /**
8189
+ * Render the views whose context changed, and only those. `markForCheck()` would mark every
8190
+ * ancestor view up to the root as well, re-rendering all of them (and every row of the list)
8191
+ * on each scroll step. Untracked, so the rows' signal reads do not become this effect's
8192
+ * dependencies.
8193
+ */
8194
+ #renderChangedViews(changedViews) {
8195
+ if (changedViews.length === 0)
8196
+ return;
8197
+ untracked(() => {
8198
+ for (const view of changedViews) {
8199
+ if (!view.destroyed) {
8200
+ view.detectChanges();
8201
+ }
8202
+ }
8203
+ });
8204
+ }
7022
8205
  /**
7023
8206
  * Apply absolute positioning styles to a view's root nodes.
7024
8207
  */