ngx-virtual-dnd 3.2.1 → 3.4.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 */
@@ -521,11 +788,13 @@ class DragStateService {
521
788
  #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
522
789
  /** Index where the placeholder should be inserted — updated on each placeholder move */
523
790
  #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
791
+ /** Whether the drag in progress is a touch drag (no cursor shows, so no grabbing cursor) */
792
+ #isTouchDrag = signal(false, ...(ngDevMode ? [{ debugName: "#isTouchDrag" }] : []));
524
793
  /** Snapshot captured synchronously immediately before the last drag state reset. */
525
794
  #endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
526
795
  /** Flag indicating if the last drag was cancelled (not dropped) */
527
796
  #wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
528
- /** Whether the last drag was cancelled (for droppable to check before emitting drop) */
797
+ /** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
529
798
  wasCancelled = this.#wasCancelled.asReadonly();
530
799
  /** Snapshot captured synchronously before the last drag reset. */
531
800
  endedDragState = this.#endedDragState.asReadonly();
@@ -562,37 +831,44 @@ class DragStateService {
562
831
  /** Target index during keyboard navigation */
563
832
  keyboardTargetIndex = this.#keyboardTargetIndex.asReadonly();
564
833
  constructor() {
565
- // Inject cursor styles once (for consistent grabbing cursor during drag)
834
+ // Inject the grabbing cursor styles once. They target lists and items only: a rule matching
835
+ // every element (`body.vdnd-dragging *`, or a `*` anywhere right of the body class) restyles
836
+ // the whole document each time the body class changes, at drag start and at drop. Touch drags
837
+ // show no cursor, so they skip it.
566
838
  if (typeof document !== 'undefined') {
567
839
  const styleId = 'vdnd-cursor-styles';
568
840
  if (!document.getElementById(styleId)) {
569
841
  const style = document.createElement('style');
570
842
  style.id = styleId;
571
843
  style.textContent = `
572
- body.vdnd-dragging,
573
- body.vdnd-dragging * {
844
+ body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-droppable,
845
+ body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-draggable {
574
846
  cursor: grabbing !important;
575
847
  }
576
848
  `;
577
849
  document.head.appendChild(style);
578
850
  }
579
851
  }
580
- // Effect to toggle body class during drag
852
+ // Effect to toggle the body classes during drag
581
853
  effect(() => {
582
854
  if (typeof document === 'undefined')
583
855
  return;
584
856
  const isDragging = this.isDragging();
585
- document.body.classList.toggle('vdnd-dragging', isDragging);
857
+ const classList = document.body.classList;
858
+ classList.toggle('vdnd-dragging', isDragging);
859
+ classList.toggle('vdnd-dragging-touch', isDragging && this.#isTouchDrag());
586
860
  });
587
861
  }
588
862
  /**
589
863
  * Start a drag operation.
590
864
  * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
865
+ * `isTouchDrag` marks a drag started by touch, which gets no grabbing cursor.
591
866
  */
592
- startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
867
+ startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition, isTouchDrag) {
593
868
  // Reset terminal drag metadata at start of new drag
594
869
  this.#endedDragState.set(null);
595
870
  this.#wasCancelled.set(false);
871
+ this.#isTouchDrag.set(isTouchDrag ?? false);
596
872
  this.#cursorPosition.set(cursorPosition ?? null);
597
873
  this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
598
874
  this.#activeDroppableId.set(activeDroppableId ?? null);
@@ -662,32 +938,49 @@ class DragStateService {
662
938
  this.#placeholderIndex.set(placeholderIndex);
663
939
  }
664
940
  /**
665
- * End the drag operation and reset state (normal drop).
941
+ * End the drag operation and reset state (normal drop), then deliver the drop: the target
942
+ * droppable (`activeDroppableId`) emits `drop` before this returns.
666
943
  */
667
944
  endDrag() {
668
- this.#endedDragState.set(this.getStateSnapshot());
945
+ // Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
946
+ // ended drag, which the drop animation reads, with an empty one.
947
+ if (!this.#state().isDragging) {
948
+ return;
949
+ }
950
+ const endedState = this.getStateSnapshot();
951
+ this.#endedDragState.set(endedState);
669
952
  this.#wasCancelled.set(false);
670
- this.#resetHighFrequencySignals();
953
+ this.#resetSignalsOutsideCoreState();
671
954
  this.#state.set(INITIAL_CORE_STATE);
955
+ // After the reset, so the drop handler sees the drag as over. Delivered here rather than
956
+ // from a droppable effect on the next change detection: by then the target may already
957
+ // be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
958
+ // by a (dragEnd) handler), and every other droppable would have to tell a new drop from
959
+ // an old one.
960
+ this.#droppableRegistry.deliverDrop(endedState);
672
961
  }
673
962
  /**
674
963
  * Cancel the drag operation (escape key, disabled, etc.).
675
964
  */
676
965
  cancelDrag() {
966
+ if (!this.#state().isDragging) {
967
+ return;
968
+ }
677
969
  this.#endedDragState.set(this.getStateSnapshot());
678
970
  this.#wasCancelled.set(true);
679
- this.#resetHighFrequencySignals();
971
+ this.#resetSignalsOutsideCoreState();
680
972
  this.#state.set(INITIAL_CORE_STATE);
681
973
  }
682
974
  /**
683
- * Reset the dedicated high-frequency signals to their initial null state.
975
+ * Reset the signals kept outside the core state (high-frequency fields, touch flag) to idle.
684
976
  */
685
- #resetHighFrequencySignals() {
977
+ #resetSignalsOutsideCoreState() {
686
978
  this.#cursorPosition.set(null);
687
979
  this.#keyboardTargetIndex.set(null);
688
980
  this.#activeDroppableId.set(null);
689
981
  this.#placeholderId.set(null);
690
982
  this.#placeholderIndex.set(null);
983
+ this.#isTouchDrag.set(false);
691
984
  }
692
985
  /**
693
986
  * Check if a specific droppable is currently active.
@@ -760,26 +1053,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
760
1053
  }]
761
1054
  }], ctorParameters: () => [] });
762
1055
 
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
1056
  /** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
784
1057
  function isPageRoot(element) {
785
1058
  return element.tagName === 'BODY' || element.tagName === 'HTML';
@@ -790,6 +1063,7 @@ function isPageRoot(element) {
790
1063
  */
791
1064
  class PositionCalculatorService {
792
1065
  #ngZone = inject(NgZone);
1066
+ #registry = inject(DroppableRegistryService);
793
1067
  /** Data attribute used to identify droppable elements */
794
1068
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
795
1069
  /** Data attribute used to identify droppable groups */
@@ -802,6 +1076,15 @@ class PositionCalculatorService {
802
1076
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
803
1077
  /** Active drag session rect snapshot, or null when no drag is in progress. */
804
1078
  #session = null;
1079
+ constructor() {
1080
+ // A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
1081
+ // Removed on destroy in case the service is provided below root.
1082
+ const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
1083
+ inject(DestroyRef).onDestroy(() => {
1084
+ removeListener();
1085
+ this.endDragSession();
1086
+ });
1087
+ }
805
1088
  /**
806
1089
  * Begin a drag session: snapshot the candidate droppables for `groupName` and
807
1090
  * their rects once, then watch for viewport changes (scroll/resize) that would
@@ -821,7 +1104,7 @@ class PositionCalculatorService {
821
1104
  this.#session = {
822
1105
  groupName,
823
1106
  candidates,
824
- rects: candidates.map((el) => this.#measureRect(el)),
1107
+ rects: this.#measureRects(candidates, []),
825
1108
  dirty: false,
826
1109
  candidatesStale: false,
827
1110
  onViewportChange,
@@ -867,10 +1150,11 @@ class PositionCalculatorService {
867
1150
  }
868
1151
  }
869
1152
  /**
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.
1153
+ * Re-read the candidate droppable *list* for the active session from the registry and
1154
+ * re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
1155
+ * rects), this picks up droppables added or removed since the snapshot was captured at
1156
+ * drag start. Registration changes already trigger it on the next hit-test; this is the
1157
+ * manual escape hatch.
874
1158
  *
875
1159
  * Safe to call when no session is active (no-op).
876
1160
  */
@@ -880,19 +1164,18 @@ class PositionCalculatorService {
880
1164
  return;
881
1165
  }
882
1166
  session.candidates = this.#queryDroppables(session.groupName);
883
- session.rects = session.candidates.map((el) => this.#measureRect(el));
1167
+ session.rects = this.#measureRects(session.candidates, []);
884
1168
  session.dirty = false;
885
1169
  session.candidatesStale = false;
886
1170
  this.#observeCandidates(session);
887
1171
  }
888
1172
  /**
889
1173
  * 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.
1174
+ * registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
1175
+ * this notification path cheap.
893
1176
  *
894
- * Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
895
- * belongs to a different group or no session is active.
1177
+ * Called for every droppable registry change. No-op when the active session belongs to
1178
+ * a different group or no session is active.
896
1179
  */
897
1180
  notifyCandidatesChanged(groupName) {
898
1181
  if (this.#session && this.#session.groupName === groupName) {
@@ -922,23 +1205,20 @@ class PositionCalculatorService {
922
1205
  this.refreshCandidates();
923
1206
  }
924
1207
  else if (session.dirty) {
925
- for (let i = 0; i < session.candidates.length; i++) {
926
- session.rects[i] = this.#measureRect(session.candidates[i]);
927
- }
1208
+ this.#measureRects(session.candidates, session.rects);
928
1209
  session.dirty = false;
929
1210
  }
930
1211
  return this.#hitTest(x, y, session.candidates, session.rects);
931
1212
  }
932
1213
  // No active session: one-shot geometric query (still avoids elementFromPoint).
933
1214
  const candidates = this.#queryDroppables(groupName);
934
- const rects = candidates.map((el) => this.#measureRect(el));
935
- return this.#hitTest(x, y, candidates, rects);
1215
+ return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
936
1216
  }
937
1217
  /**
938
1218
  * Look up a droppable element by its ID.
939
1219
  *
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.
1220
+ * When a drag session is active, searches the cached candidate list. Otherwise looks the
1221
+ * ID up in the droppable registry. Neither queries the document.
942
1222
  *
943
1223
  * Intended for the autoscroll scroll-only fast path, where the active droppable is already
944
1224
  * known and only the placeholder index needs recalculation.
@@ -952,13 +1232,10 @@ class PositionCalculatorService {
952
1232
  }
953
1233
  return null;
954
1234
  }
955
- if (typeof document === 'undefined') {
956
- return null;
957
- }
958
- return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
1235
+ return this.#registry.getById(id);
959
1236
  }
960
1237
  /**
961
- * Query all droppable elements belonging to a group, in document order.
1238
+ * All rendered droppables belonging to a group, in document order, from the registry.
962
1239
  *
963
1240
  * All group members are included — the disabled check happens per-frame in
964
1241
  * {@link #hitTest} rather than here, because a droppable can be disabled or
@@ -966,30 +1243,46 @@ class PositionCalculatorService {
966
1243
  * `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
967
1244
  */
968
1245
  #queryDroppables(groupName) {
969
- if (typeof document === 'undefined') {
970
- return [];
971
- }
972
- return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1246
+ return this.#registry.getGroup(groupName);
973
1247
  }
974
1248
  /**
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.
1249
+ * The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
1250
+ * ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
1251
+ * mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
1252
+ * case 3).
980
1253
  */
981
- #measureRect(el) {
982
- const rect = el.getBoundingClientRect();
1254
+ #clipOf(el) {
983
1255
  const scrollable = el.closest('.vdnd-scrollable');
984
- if (!scrollable || scrollable === el) {
985
- return rect;
1256
+ return scrollable === el ? null : scrollable;
1257
+ }
1258
+ /**
1259
+ * Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
1260
+ * `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
1261
+ * once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
1262
+ * its registration. The intersection is built as a plain DOMRect; an empty intersection yields
1263
+ * a negative width/height so the `#hitTest` bounds check can never match it.
1264
+ */
1265
+ #measureRects(candidates, rects) {
1266
+ let lastClip = null;
1267
+ let lastClipRect = null;
1268
+ for (let i = 0; i < candidates.length; i++) {
1269
+ const rect = candidates[i].getBoundingClientRect();
1270
+ const clip = this.#clipOf(candidates[i]);
1271
+ if (!clip) {
1272
+ rects[i] = rect;
1273
+ continue;
1274
+ }
1275
+ if (clip !== lastClip || !lastClipRect) {
1276
+ lastClip = clip;
1277
+ lastClipRect = clip.getBoundingClientRect();
1278
+ }
1279
+ const top = Math.max(rect.top, lastClipRect.top);
1280
+ const left = Math.max(rect.left, lastClipRect.left);
1281
+ const right = Math.min(rect.right, lastClipRect.right);
1282
+ const bottom = Math.min(rect.bottom, lastClipRect.bottom);
1283
+ rects[i] = new DOMRect(left, top, right - left, bottom - top);
986
1284
  }
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);
1285
+ return rects;
993
1286
  }
994
1287
  /**
995
1288
  * Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
@@ -1191,21 +1484,20 @@ class PositionCalculatorService {
1191
1484
  // current container — otherwise a container disabled mid-drag (its own index becomes
1192
1485
  // -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
1193
1486
  // as *targets* during the outward scan below instead.
1194
- const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1487
+ const allDroppables = this.#registry.getGroup(groupName);
1195
1488
  if (allDroppables.length <= 1) {
1196
1489
  return null;
1197
1490
  }
1198
1491
  // Get bounding rects, IDs, and disabled state, sorted by X position
1199
1492
  const droppableInfos = [];
1200
1493
  allDroppables.forEach((el) => {
1201
- const htmlEl = el;
1202
- const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
1494
+ const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
1203
1495
  if (id) {
1204
1496
  droppableInfos.push({
1205
- element: htmlEl,
1497
+ element: el,
1206
1498
  id,
1207
- rect: htmlEl.getBoundingClientRect(),
1208
- disabled: this.#isDroppableDisabled(htmlEl),
1499
+ rect: el.getBoundingClientRect(),
1500
+ disabled: this.#isDroppableDisabled(el),
1209
1501
  });
1210
1502
  }
1211
1503
  });
@@ -1249,7 +1541,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1249
1541
  args: [{
1250
1542
  providedIn: 'root',
1251
1543
  }]
1252
- }] });
1544
+ }], ctorParameters: () => [] });
1253
1545
 
1254
1546
  /**
1255
1547
  * Single RAF loop that coordinates all drag-frame work in ordered phases:
@@ -1262,6 +1554,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1262
1554
  * latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
1263
1555
  * the flag is false, so autoscroll-only frames (cursor stationary) incur
1264
1556
  * zero hit-test work.
1557
+ * 3. Frame writer phase (WRITE): registered writers apply the state computed this
1558
+ * frame to the DOM. DragPreviewComponent writes the preview transform here, so
1559
+ * the preview moves in the frame that processed the pointer instead of waiting
1560
+ * for a change detection tick, which Angular schedules after this frame.
1265
1561
  *
1266
1562
  * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1267
1563
  * RAF per pointermove. This coalesces all moves within a frame into one update
@@ -1277,6 +1573,8 @@ class DragSchedulerService {
1277
1573
  #cursorDirty = false;
1278
1574
  /** Tick participants called before the main compute phase each frame. */
1279
1575
  #participants = [];
1576
+ /** Frame writers called after the main compute phase each frame. */
1577
+ #frameWriters = [];
1280
1578
  /**
1281
1579
  * Start the scheduler RAF loop.
1282
1580
  * No-op if already running.
@@ -1333,6 +1631,26 @@ class DragSchedulerService {
1333
1631
  this.#participants.splice(idx, 1);
1334
1632
  }
1335
1633
  }
1634
+ /**
1635
+ * Register a function to be called at the end of each frame, after the main tick.
1636
+ * DragPreviewComponent registers here to move the preview within the same frame.
1637
+ * No-op if the same function reference is already registered.
1638
+ */
1639
+ addFrameWriter(fn) {
1640
+ if (!this.#frameWriters.includes(fn)) {
1641
+ this.#frameWriters.push(fn);
1642
+ }
1643
+ }
1644
+ /**
1645
+ * Unregister a previously added frame writer.
1646
+ * No-op if the function was not registered.
1647
+ */
1648
+ removeFrameWriter(fn) {
1649
+ const idx = this.#frameWriters.indexOf(fn);
1650
+ if (idx !== -1) {
1651
+ this.#frameWriters.splice(idx, 1);
1652
+ }
1653
+ }
1336
1654
  #scheduleNextTick() {
1337
1655
  this.#rafId = requestAnimationFrame(() => this.#tick());
1338
1656
  }
@@ -1360,7 +1678,15 @@ class DragSchedulerService {
1360
1678
  }
1361
1679
  // Phase 2 — main compute (cursor-based hit-test + signal write)
1362
1680
  onTick(this.#pendingCursor, cursorDirty);
1363
- // Reschedule only if still active (stop() may have been called by onTick).
1681
+ // stop() may have been called by onTick (the drag ended): nothing left to write.
1682
+ if (this.#onTick === null) {
1683
+ return;
1684
+ }
1685
+ // Phase 3 — frame writers (apply this frame's state to the DOM)
1686
+ for (const writer of this.#frameWriters) {
1687
+ writer();
1688
+ }
1689
+ // Reschedule only if still active (a writer is not expected to stop it, but may).
1364
1690
  if (this.#onTick !== null) {
1365
1691
  this.#scheduleNextTick();
1366
1692
  }
@@ -1411,6 +1737,14 @@ class AutoScrollService {
1411
1737
  /** Last tick cursor position for stationary detection */
1412
1738
  #lastTickCursorX = NaN;
1413
1739
  #lastTickCursorY = NaN;
1740
+ /**
1741
+ * The containers the last tick that ran asked to scroll but had no room left: the first
1742
+ * #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
1743
+ * tick only while they stay as they were on the axes it asked for: content that grows (rows
1744
+ * measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
1745
+ */
1746
+ #exhausted = [];
1747
+ #exhaustedCount = 0;
1414
1748
  /**
1415
1749
  * Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
1416
1750
  * Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
@@ -1514,6 +1848,8 @@ class AutoScrollService {
1514
1848
  this.#cursorOverride = null;
1515
1849
  this.#lastTickCursorX = NaN;
1516
1850
  this.#lastTickCursorY = NaN;
1851
+ this.#exhausted.length = 0;
1852
+ this.#exhaustedCount = 0;
1517
1853
  this.#lastScrollTimestamp = 0;
1518
1854
  this.#scrollState.containerId = null;
1519
1855
  this.#scrollState.direction.x = 0;
@@ -1542,14 +1878,17 @@ class AutoScrollService {
1542
1878
  if (!cursor) {
1543
1879
  return;
1544
1880
  }
1545
- // Skip the container iteration when cursor hasn't moved and scroll is idle.
1881
+ // Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
1882
+ // container it asked to scroll without room has changed since (see #exhausted).
1546
1883
  if (cursor.x === this.#lastTickCursorX &&
1547
1884
  cursor.y === this.#lastTickCursorY &&
1548
- !this.isScrolling()) {
1885
+ !this.isScrolling() &&
1886
+ !this.#exhaustedChanged()) {
1549
1887
  return;
1550
1888
  }
1551
1889
  this.#lastTickCursorX = cursor.x;
1552
1890
  this.#lastTickCursorY = cursor.y;
1891
+ this.#exhaustedCount = 0;
1553
1892
  let scrollPerformed = false;
1554
1893
  // Frame scale is shared across candidates within a tick; computed lazily on
1555
1894
  // the first scroll attempt so stationary/idle frames don't advance the clock.
@@ -1609,8 +1948,19 @@ class AutoScrollService {
1609
1948
  this.#scrollState.direction.y = direction.y;
1610
1949
  this.#scrollState.speed = speed;
1611
1950
  scrollPerformed = true;
1951
+ // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1952
+ // Delaying via RAF causes cumulative drift during continuous autoscroll
1953
+ // because multiple scrolls happen before each delayed callback runs.
1954
+ // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1955
+ // updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
1956
+ // the drag.
1957
+ // It runs after the scroll state is recorded: it may end the drag, and the reset of
1958
+ // stopMonitoring() must not be overwritten.
1959
+ this.#onScrollCallback?.();
1612
1960
  break;
1613
1961
  }
1962
+ // No room in any requested direction: a resting cursor retries it once it changes
1963
+ this.#recordExhausted(element, direction);
1614
1964
  }
1615
1965
  }
1616
1966
  if (!scrollPerformed) {
@@ -1670,16 +2020,46 @@ class AutoScrollService {
1670
2020
  }
1671
2021
  }
1672
2022
  }
1673
- if (!scrolled) {
1674
- return false;
2023
+ return scrolled;
2024
+ }
2025
+ /** Record a container that had no room in `direction` (see #exhausted). */
2026
+ #recordExhausted(element, direction) {
2027
+ const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
2028
+ element,
2029
+ vertical: false,
2030
+ horizontal: false,
2031
+ scrollTop: 0,
2032
+ scrollLeft: 0,
2033
+ maxScrollTop: 0,
2034
+ maxScrollLeft: 0,
2035
+ });
2036
+ exhausted.element = element;
2037
+ exhausted.vertical = direction.y !== 0;
2038
+ exhausted.horizontal = direction.x !== 0;
2039
+ exhausted.scrollTop = element.scrollTop;
2040
+ exhausted.scrollLeft = element.scrollLeft;
2041
+ exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
2042
+ exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
2043
+ this.#exhaustedCount++;
2044
+ }
2045
+ /**
2046
+ * Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
2047
+ * scroll, so it may have room now.
2048
+ */
2049
+ #exhaustedChanged() {
2050
+ for (let i = 0; i < this.#exhaustedCount; i++) {
2051
+ const exhausted = this.#exhausted[i];
2052
+ const { element } = exhausted;
2053
+ if ((exhausted.vertical &&
2054
+ (element.scrollTop !== exhausted.scrollTop ||
2055
+ element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
2056
+ (exhausted.horizontal &&
2057
+ (element.scrollLeft !== exhausted.scrollLeft ||
2058
+ element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
2059
+ return true;
2060
+ }
1675
2061
  }
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;
2062
+ return false;
1683
2063
  }
1684
2064
  #getFrameScale() {
1685
2065
  const now = performance.now();
@@ -1720,20 +2100,19 @@ class ElementCloneService {
1720
2100
  * These are the visual properties that affect appearance. Keeping them
1721
2101
  * kebab-case avoids a camelCase→kebab conversion on every property of every
1722
2102
  * node during the recursive clone walk (a drag-start hot path).
2103
+ *
2104
+ * A shorthand carries more than the longhands listed with it (`background-size`,
2105
+ * `font-style`, …), so it is copied first, and the longhands only when the engine can't
2106
+ * serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
2107
+ * example). Copying both would read and write the same values twice.
1723
2108
  */
1724
- #stylePropsToCopy = [
1725
- 'background',
1726
- 'background-color',
1727
- 'background-image',
2109
+ #stylesToCopy = [
2110
+ ['background', ['background-color', 'background-image']],
1728
2111
  'border',
1729
2112
  'border-radius',
1730
2113
  'box-shadow',
1731
2114
  'color',
1732
- 'font',
1733
- 'font-family',
1734
- 'font-size',
1735
- 'font-weight',
1736
- 'line-height',
2115
+ ['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
1737
2116
  'padding',
1738
2117
  'margin',
1739
2118
  'display',
@@ -1775,10 +2154,22 @@ class ElementCloneService {
1775
2154
  #applyComputedStyles(source, target) {
1776
2155
  const computed = window.getComputedStyle(source);
1777
2156
  // 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);
2157
+ for (const style of this.#stylesToCopy) {
2158
+ if (typeof style === 'string') {
2159
+ this.#copyStyle(computed, target, style);
2160
+ continue;
2161
+ }
2162
+ // The longhands only when the shorthand was not copied: the engine gave no value, or the
2163
+ // clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
2164
+ // shorthand itself would be serialized again to be read). The clone may carry that
2165
+ // longhand inline from the source, so it goes first.
2166
+ const [shorthand, longhands] = style;
2167
+ target.style.removeProperty(longhands[0]);
2168
+ if (!this.#copyStyle(computed, target, shorthand) ||
2169
+ !target.style.getPropertyValue(longhands[0])) {
2170
+ for (const longhand of longhands) {
2171
+ this.#copyStyle(computed, target, longhand);
2172
+ }
1782
2173
  }
1783
2174
  }
1784
2175
  // Disable animations and transitions on clone
@@ -1795,6 +2186,18 @@ class ElementCloneService {
1795
2186
  }
1796
2187
  }
1797
2188
  }
2189
+ /**
2190
+ * Copy one computed property to the target's inline style. Returns whether the engine gave a
2191
+ * value for it.
2192
+ */
2193
+ #copyStyle(computed, target, property) {
2194
+ const value = computed.getPropertyValue(property);
2195
+ if (!value) {
2196
+ return false;
2197
+ }
2198
+ target.style.setProperty(property, value);
2199
+ return true;
2200
+ }
1798
2201
  /**
1799
2202
  * Handle special elements that require extra processing.
1800
2203
  */
@@ -2149,9 +2552,7 @@ class DragIndexCalculatorService {
2149
2552
  return cached;
2150
2553
  const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
2151
2554
  const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
2152
- const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
2153
- ? droppableElement
2154
- : droppableElement.closest('vdnd-virtual-content'));
2555
+ const virtualContentElement = this.#findVirtualContent(droppableElement);
2155
2556
  let containerType;
2156
2557
  let scrollContainer;
2157
2558
  let scrollableParent = null;
@@ -2198,7 +2599,7 @@ class DragIndexCalculatorService {
2198
2599
  return entry;
2199
2600
  }
2200
2601
  getTotalItemCount(args) {
2201
- return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
2602
+ return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
2202
2603
  }
2203
2604
  /**
2204
2605
  * Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
@@ -2270,7 +2671,7 @@ class DragIndexCalculatorService {
2270
2671
  totalItems = Math.max(0, cachedItemCount);
2271
2672
  }
2272
2673
  else {
2273
- totalItems = this.#getTotalItemCount(droppableElement, isSameList, draggedItemHeight);
2674
+ totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
2274
2675
  }
2275
2676
  // Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
2276
2677
  // Due to max scroll limits, the math alone can't reach totalItems when the list is longer
@@ -2329,7 +2730,12 @@ class DragIndexCalculatorService {
2329
2730
  const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
2330
2731
  return Number.isFinite(offset) ? offset : 0;
2331
2732
  }
2332
- #getTotalItemCount(droppableElement, isSameList, draggedItemHeight) {
2733
+ /**
2734
+ * The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
2735
+ * containers it knows are not looked up in the DOM again: this runs on every frame of a drag
2736
+ * over a list without a registered strategy.
2737
+ */
2738
+ #getTotalItemCount(droppableElement, draggedItemHeight, cache) {
2333
2739
  // Check if a registered strategy exists
2334
2740
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
2335
2741
  const strategy = droppableId ? this.#strategies.get(droppableId) : null;
@@ -2338,7 +2744,9 @@ class DragIndexCalculatorService {
2338
2744
  return Math.max(0, strategyItemCount);
2339
2745
  }
2340
2746
  // Check for embedded virtual scroll component
2341
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
2747
+ const virtualScroll = cache
2748
+ ? cache.virtualScrollElement
2749
+ : droppableElement.querySelector('vdnd-virtual-scroll');
2342
2750
  if (virtualScroll) {
2343
2751
  // Use data-total-items attribute if available (always the true N)
2344
2752
  const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
@@ -2375,9 +2783,9 @@ class DragIndexCalculatorService {
2375
2783
  return count;
2376
2784
  }
2377
2785
  // Check for page-level scroll (vdnd-virtual-content)
2378
- const virtualContent = droppableElement.matches('vdnd-virtual-content')
2379
- ? droppableElement
2380
- : droppableElement.closest('vdnd-virtual-content');
2786
+ const virtualContent = cache
2787
+ ? cache.virtualContentElement
2788
+ : this.#findVirtualContent(droppableElement);
2381
2789
  if (virtualContent) {
2382
2790
  // Prefer explicit total items attribute (works for both fixed and dynamic heights)
2383
2791
  const totalItemsAttr = virtualContent.getAttribute('data-total-items');
@@ -2404,6 +2812,12 @@ class DragIndexCalculatorService {
2404
2812
  const items = droppableElement.querySelectorAll('[data-draggable-id]');
2405
2813
  return items.length;
2406
2814
  }
2815
+ /** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
2816
+ #findVirtualContent(droppableElement) {
2817
+ return droppableElement.matches('vdnd-virtual-content')
2818
+ ? droppableElement
2819
+ : droppableElement.closest('vdnd-virtual-content');
2820
+ }
2407
2821
  #getDraggedItemHeightFallback(height, fallback) {
2408
2822
  return Number.isFinite(height) && height > 0 ? height : fallback;
2409
2823
  }
@@ -2547,6 +2961,66 @@ function createAutoScrollRegistration(options) {
2547
2961
  destroyRef.onDestroy(unregisterCurrent);
2548
2962
  }
2549
2963
 
2964
+ /**
2965
+ * Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
2966
+ * list's own array, which it goes on reading).
2967
+ *
2968
+ * - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
2969
+ * never computed.
2970
+ * - DynamicHeightStrategy's copies the keys: it gets the list's array.
2971
+ * - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
2972
+ * gets a copy, which it may keep and change.
2973
+ * @internal
2974
+ */
2975
+ function setStrategyItems(strategy, count, keys) {
2976
+ if (strategy instanceof FixedHeightStrategy &&
2977
+ strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
2978
+ strategy.setItemCount(count);
2979
+ }
2980
+ else if (strategy instanceof DynamicHeightStrategy &&
2981
+ strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
2982
+ strategy.setItemKeys(keys());
2983
+ }
2984
+ else {
2985
+ strategy.setItemKeys(keys().slice());
2986
+ }
2987
+ }
2988
+
2989
+ /**
2990
+ * Query by an exact attribute value without interpolating consumer data into a CSS selector.
2991
+ *
2992
+ * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
2993
+ * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
2994
+ * quotes, brackets, backslashes, or other selector-sensitive characters.
2995
+ */
2996
+ function queryByAttribute(root, attributeName, attributeValue) {
2997
+ const candidates = root.querySelectorAll(`[${attributeName}]`);
2998
+ for (const candidate of candidates) {
2999
+ if (candidate.getAttribute(attributeName) === attributeValue) {
3000
+ return candidate;
3001
+ }
3002
+ }
3003
+ return null;
3004
+ }
3005
+ /**
3006
+ * The elements carrying an attribute, by its value (the first in document order when several
3007
+ * share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
3008
+ * scans every candidate on each call.
3009
+ */
3010
+ function mapByAttribute(root, attributeName) {
3011
+ const elements = new Map();
3012
+ for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
3013
+ const value = candidate.getAttribute(attributeName);
3014
+ if (!elements.has(value)) {
3015
+ elements.set(value, candidate);
3016
+ }
3017
+ }
3018
+ return elements;
3019
+ }
3020
+ function queryAllByAttribute(root, attributeName, attributeValue) {
3021
+ return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
3022
+ }
3023
+
2550
3024
  /** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
2551
3025
  const MIN_SHIFT_PX = 0.5;
2552
3026
  /**
@@ -2554,8 +3028,15 @@ const MIN_SHIFT_PX = 0.5;
2554
3028
  *
2555
3029
  * Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
2556
3030
  * placeholder moved (or the drag ended), it snapshots every rendered element's visual position, then after
2557
- * the render measures again and plays a compositor-only `transform` animation from the
2558
- * old position to the new one.
3031
+ * the render measures again and plays a `translate` animation from the old position to the
3032
+ * new one.
3033
+ *
3034
+ * The slide animates the individual `translate` property with a replace effect, so it runs
3035
+ * on the compositor (Chromium never composites a `composite: 'add'` keyframe effect) and
3036
+ * composes with any `transform` the consumer sets on the row. `translate` applies before
3037
+ * `transform`, in the parent's coordinate space, where the deltas are measured. A row
3038
+ * that sets its own `translate` gets an additive effect instead, which keeps that value
3039
+ * but is ticked on the main thread.
2559
3040
  *
2560
3041
  * Positions are measured relative to the scroll content, so scrolling (including
2561
3042
  * programmatic scroll in the same tick) never animates. Measurements include any
@@ -2692,36 +3173,51 @@ class ShiftAnimator {
2692
3173
  }
2693
3174
  // Both measurements include the in-flight offset, so the new animation must
2694
3175
  // start from that offset plus the layout delta to continue from the current spot.
2695
- const current = this.#currentOffset(element);
2696
- shifts.push({ element, x: dx + current.x, y: dy + current.y });
3176
+ const active = this.#activeShift(element);
3177
+ const remaining = active ? 1 - active.progress : 0;
3178
+ shifts.push({
3179
+ element,
3180
+ x: dx + (active?.shift.x ?? 0) * remaining,
3181
+ y: dy + (active?.shift.y ?? 0) * remaining,
3182
+ // While a slide applies, computed style includes it: keep the decision it was made with.
3183
+ additive: active?.shift.additive ?? this.#hasOwnTranslate(element),
3184
+ });
2697
3185
  }
2698
3186
  // Write phase
2699
3187
  for (const element of stale) {
2700
3188
  this.cancel(element);
2701
3189
  }
2702
3190
  for (const shift of shifts) {
2703
- this.#start(shift.element, shift.x, shift.y);
3191
+ this.#start(shift);
2704
3192
  }
2705
3193
  }
2706
- #currentOffset(element) {
2707
- const running = this.#running.get(element);
3194
+ /**
3195
+ * Whether the row sets its own `translate` (which a replace effect would hide while it
3196
+ * runs). Only read while no slide runs on the element: the computed value would include it.
3197
+ */
3198
+ #hasOwnTranslate(element) {
3199
+ const translate = getComputedStyle(element).translate;
3200
+ return !!translate && translate !== 'none';
3201
+ }
3202
+ /** The element's running slide and its progress, or null when none is in effect. */
3203
+ #activeShift(element) {
3204
+ const shift = this.#running.get(element);
2708
3205
  // Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
2709
- const progress = running?.animation.effect?.getComputedTiming().progress;
2710
- if (!running || progress === null || progress === undefined) {
2711
- return { x: 0, y: 0 };
3206
+ // It is null once the animation was cancelled or finished (even before `onfinish` ran).
3207
+ const progress = shift?.animation.effect?.getComputedTiming().progress;
3208
+ if (!shift || progress === null || progress === undefined) {
3209
+ return null;
2712
3210
  }
2713
- const remaining = 1 - progress;
2714
- return { x: running.x * remaining, y: running.y * remaining };
3211
+ return { shift, progress };
2715
3212
  }
2716
- #start(element, x, y) {
3213
+ #start({ element, x, y, additive }) {
2717
3214
  this.#running.get(element)?.animation.cancel();
2718
- const animation = element.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0px, 0px)' }], {
3215
+ const animation = element.animate([{ translate: `${x}px ${y}px` }, { translate: '0px 0px' }], {
2719
3216
  duration: this.#duration(),
2720
3217
  easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
2721
- // Layer on top of any transform the consumer already applies to the item.
2722
- composite: 'add',
3218
+ composite: additive ? 'add' : 'replace',
2723
3219
  });
2724
- this.#running.set(element, { animation, x, y });
3220
+ this.#running.set(element, { animation, x, y, additive });
2725
3221
  animation.onfinish = () => {
2726
3222
  if (this.#running.get(element)?.animation === animation) {
2727
3223
  this.#running.delete(element);
@@ -2730,25 +3226,227 @@ class ShiftAnimator {
2730
3226
  }
2731
3227
  }
2732
3228
 
3229
+ /** The elements drop animations hide, with the animation hiding each */
3230
+ const hiddenTargets = new Map();
2733
3231
  /**
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
3232
+ * Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
3233
+ * calls it for the row it pools: that row's element renders another item next.
3234
+ * @internal
3235
+ */
3236
+ function revealDropTargetIn(nodes) {
3237
+ for (const [element, animation] of hiddenTargets) {
3238
+ if (nodes.some((node) => node.contains(element))) {
3239
+ hiddenTargets.delete(element);
3240
+ animation.cancel();
3241
+ }
3242
+ }
3243
+ }
3244
+ /**
3245
+ * Plays the drop ("settle") animation: the drag preview glides from where it was released
3246
+ * into the dropped item's final position, while that item stays invisible underneath.
2743
3247
  *
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">
3248
+ * Purely visual. The drag state and the drop events are already final when it starts, so it
3249
+ * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3250
+ * of view) the ghost fades out where it was released.
3251
+ * @internal
3252
+ */
3253
+ class DropAnimator {
3254
+ #config;
3255
+ #ghostAnimation = null;
3256
+ #hidden = null;
3257
+ /** The reduced-motion media query, created on first use (null without `matchMedia`) */
3258
+ #reducedMotionQuery;
3259
+ constructor(config) {
3260
+ this.#config = config;
3261
+ }
3262
+ /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3263
+ isEnabled() {
3264
+ if (this.#duration() <= 0)
3265
+ return false;
3266
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3267
+ return false;
3268
+ }
3269
+ return !this.#prefersReducedMotion();
3270
+ }
3271
+ /**
3272
+ * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3273
+ * `onDone` is not called when the animation is cancelled.
3274
+ */
3275
+ play(ghost, target, onDone) {
3276
+ this.cancel();
3277
+ const timing = {
3278
+ duration: this.#duration(),
3279
+ easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3280
+ fill: 'forwards',
3281
+ };
3282
+ let ghostAnimation;
3283
+ if (target) {
3284
+ const from = ghost.getBoundingClientRect();
3285
+ const dx = target.rect.left - from.left;
3286
+ const dy = target.rect.top - from.top;
3287
+ // Travel on top of the preview's own position transform (width/height must not be
3288
+ // composited, so the base transform is repeated instead of using composite: 'add').
3289
+ const base = ghost.style.transform;
3290
+ ghostAnimation = ghost.animate([
3291
+ {
3292
+ transform: `${base} translate(0px, 0px)`,
3293
+ width: `${from.width}px`,
3294
+ height: `${from.height}px`,
3295
+ },
3296
+ {
3297
+ transform: `${base} translate(${dx}px, ${dy}px)`,
3298
+ width: `${target.rect.width}px`,
3299
+ height: `${target.rect.height}px`,
3300
+ },
3301
+ ], timing);
3302
+ // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3303
+ const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3304
+ duration: timing.duration,
3305
+ });
3306
+ this.#hidden = { element: target.element, animation: hideAnimation };
3307
+ hiddenTargets.set(target.element, hideAnimation);
3308
+ }
3309
+ else {
3310
+ ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3311
+ }
3312
+ this.#ghostAnimation = ghostAnimation;
3313
+ ghostAnimation.onfinish = () => {
3314
+ if (this.#ghostAnimation !== ghostAnimation)
3315
+ return;
3316
+ this.cancel();
3317
+ onDone();
3318
+ };
3319
+ }
3320
+ /** Stop the running animation immediately and reveal the real item. */
3321
+ cancel() {
3322
+ const ghostAnimation = this.#ghostAnimation;
3323
+ const hidden = this.#hidden;
3324
+ this.#ghostAnimation = null;
3325
+ this.#hidden = null;
3326
+ ghostAnimation?.cancel();
3327
+ if (hidden) {
3328
+ if (hiddenTargets.get(hidden.element) === hidden.animation) {
3329
+ hiddenTargets.delete(hidden.element);
3330
+ }
3331
+ hidden.animation.cancel();
3332
+ }
3333
+ }
3334
+ #duration() {
3335
+ return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
3336
+ }
3337
+ /** One media query for every drop: it is live, so it follows a change of the preference. */
3338
+ #prefersReducedMotion() {
3339
+ if (this.#reducedMotionQuery === undefined) {
3340
+ this.#reducedMotionQuery =
3341
+ typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
3342
+ }
3343
+ return this.#reducedMotionQuery?.matches ?? false;
3344
+ }
3345
+ }
3346
+ /**
3347
+ * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3348
+ * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3349
+ * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3350
+ *
3351
+ * `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
3352
+ */
3353
+ function findDropTarget(draggableId, droppableIds, getDroppable) {
3354
+ for (const droppableId of droppableIds) {
3355
+ if (!droppableId)
3356
+ continue;
3357
+ const droppable = getDroppable(droppableId);
3358
+ const element = droppable
3359
+ ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3360
+ : null;
3361
+ if (!droppable || !element)
3362
+ continue;
3363
+ const rect = element.getBoundingClientRect();
3364
+ // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3365
+ if (rect.width === 0 && rect.height === 0)
3366
+ return null;
3367
+ const bounds = droppable.getBoundingClientRect();
3368
+ const visible = rect.bottom > bounds.top &&
3369
+ rect.top < bounds.bottom &&
3370
+ rect.right > bounds.left &&
3371
+ rect.left < bounds.right;
3372
+ return visible ? { element, rect } : null;
3373
+ }
3374
+ return null;
3375
+ }
3376
+
3377
+ /**
3378
+ * How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
3379
+ * scroll step reuses the views it pools right away; the pool keeps the rest, for example when
3380
+ * fewer rows render after the list shrinks.
3381
+ */
3382
+ const MAX_POOLED_ROWS = 10;
3383
+ /** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
3384
+ const DOCUMENT_POSITION_FOLLOWING = 4;
3385
+ /**
3386
+ * The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
3387
+ * before the nodes rendered in it, which the DOM puts before the anchor.
3388
+ */
3389
+ function firstInDocumentOrder(nodes) {
3390
+ let first = null;
3391
+ for (const node of nodes) {
3392
+ if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
3393
+ first = node;
3394
+ }
3395
+ }
3396
+ return first;
3397
+ }
3398
+ /**
3399
+ * Which values belong to a longest increasing subsequence of `values` (negative values never
3400
+ * do). For rows: the views that can stay where they are while the others move around them, as
3401
+ * few as possible. A moved row loses focus and restarts its CSS transitions.
3402
+ */
3403
+ function longestIncreasingRun(values) {
3404
+ // tails[k]: the index of the smallest value that ends an increasing run of length k + 1
3405
+ const tails = [];
3406
+ const previous = new Array(values.length).fill(-1);
3407
+ for (let i = 0; i < values.length; i++) {
3408
+ const value = values[i];
3409
+ if (value < 0)
3410
+ continue;
3411
+ let low = 0;
3412
+ let high = tails.length;
3413
+ while (low < high) {
3414
+ const middle = (low + high) >> 1;
3415
+ if (values[tails[middle]] < value) {
3416
+ low = middle + 1;
3417
+ }
3418
+ else {
3419
+ high = middle;
3420
+ }
3421
+ }
3422
+ previous[i] = low > 0 ? tails[low - 1] : -1;
3423
+ tails[low] = i;
3424
+ }
3425
+ const inRun = new Array(values.length).fill(false);
3426
+ for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
3427
+ inRun[i] = true;
3428
+ }
3429
+ return inRun;
3430
+ }
3431
+ /**
3432
+ * A virtual scroll container that only renders visible items.
3433
+ *
3434
+ * Key features:
3435
+ * - Only renders items within the visible viewport plus an overscan buffer
3436
+ * - Supports "sticky" items that are always rendered (used for dragged items)
3437
+ * - Uses spacer divs to maintain correct scroll height
3438
+ * - Integrates with the drag-and-drop system
3439
+ * - Automatic height detection via ResizeObserver when containerHeight is not provided
3440
+ * - Supports dynamic item heights via `dynamicItemHeight` input
3441
+ *
3442
+ * @example
3443
+ * ```html
3444
+ * <!-- With fixed height items -->
3445
+ * <vdnd-virtual-scroll
3446
+ * [items]="items()"
3447
+ * [itemHeight]="50"
3448
+ * [containerHeight]="400"
3449
+ * [trackByFn]="trackById">
2752
3450
  * <ng-template let-item let-index="index">
2753
3451
  * <div class="item">{{ item.name }}</div>
2754
3452
  * </ng-template>
@@ -2775,6 +3473,7 @@ class VirtualScrollContainerComponent {
2775
3473
  #keyboardDrag = inject(KeyboardDragService);
2776
3474
  #ngZone = inject(NgZone);
2777
3475
  #injector = inject(Injector);
3476
+ #environmentInjector = inject(EnvironmentInjector);
2778
3477
  #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
2779
3478
  /** Cleanup function for scroll listener */
2780
3479
  #scrollCleanup = null;
@@ -2789,6 +3488,38 @@ class VirtualScrollContainerComponent {
2789
3488
  #measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
2790
3489
  /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
2791
3490
  #shiftAnimator = this.#createShiftAnimator();
3491
+ /** The template's spacer (cannot use ES private with viewChild) */
3492
+ spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
3493
+ /** The template's content wrapper (cannot use ES private with viewChild) */
3494
+ contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
3495
+ /** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
3496
+ rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
3497
+ /** Rendered rows, in DOM order */
3498
+ #rows = [];
3499
+ /** The entries the rendered rows show */
3500
+ #rowEntries = null;
3501
+ /** The template the rendered rows were created from */
3502
+ #rowTemplate = null;
3503
+ /** The track function that gave the rendered rows their keys */
3504
+ #rowTrackBy = null;
3505
+ /** Views of rows that left, detached, to render rows that come in (recycleRows) */
3506
+ #rowPool = [];
3507
+ /**
3508
+ * Set while this component's view is about to be checked in the current change detection pass:
3509
+ * from an input change (ngOnChanges, which runs before its effects; the first render included)
3510
+ * until the check (ngAfterViewChecked). That check renders every row.
3511
+ */
3512
+ #viewCheckPending = false;
3513
+ /** The placeholder, created on its first render and reused */
3514
+ #placeholder = null;
3515
+ /** Height last set on the placeholder */
3516
+ #placeholderHeightSet = null;
3517
+ /** Content offset last written to the content wrapper */
3518
+ #contentTransformSet = null;
3519
+ /** Whether duplicate track keys were reported (once per list) */
3520
+ #warnedDuplicateKeys = false;
3521
+ /** The row keys last checked for duplicates (checked again when the rows change) */
3522
+ #rowKeysChecked = null;
2792
3523
  /** Template for rendering each item - passed as input instead of content child for reliability */
2793
3524
  itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
2794
3525
  /** Unique ID for this scroll container (used for auto-scroll registration) */
@@ -2835,6 +3566,13 @@ class VirtualScrollContainerComponent {
2835
3566
  * Required for placeholder positioning.
2836
3567
  */
2837
3568
  droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
3569
+ /**
3570
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
3571
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
3572
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
3573
+ * @default false
3574
+ */
3575
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
2838
3576
  /**
2839
3577
  * Whether to automatically add the dragged item to the sticky list.
2840
3578
  * This ensures the dragged item remains visible during virtual scrolling.
@@ -2931,22 +3669,50 @@ class VirtualScrollContainerComponent {
2931
3669
  draggedItemId = computed(() => {
2932
3670
  return this.#dragState.draggedItem()?.draggableId ?? null;
2933
3671
  }, ...(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();
3672
+ /**
3673
+ * Every item's ID, in order. Computed once per items change, and only when something reads it:
3674
+ * a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
3675
+ * strategy needs only the item count.
3676
+ */
3677
+ #itemIds = computed(() => {
2937
3678
  const idFn = this.itemIdFn();
3679
+ return this.items().map((item) => idFn(item));
3680
+ }, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
3681
+ /**
3682
+ * Index of each item ID (the last, when several share one), for the sticky items outside the
3683
+ * rendered rows. Built on first use, so an items change (a drop) builds none.
3684
+ */
3685
+ #itemIndexMap = computed(() => {
3686
+ const ids = this.#itemIds();
2938
3687
  const map = new Map();
2939
- for (let i = 0; i < items.length; i++) {
2940
- map.set(idFn(items[i]), i);
3688
+ for (let i = 0; i < ids.length; i++) {
3689
+ map.set(ids[i], i);
2941
3690
  }
2942
3691
  return map;
2943
3692
  }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
2944
- /** The index of the currently dragged item in the items array (-1 if not found or not dragging) */
3693
+ /**
3694
+ * The index of the currently dragged item in the items array (-1 if not found or not dragging).
3695
+ * Found without the index map, which only this lookup would need on a drag: a drag that started
3696
+ * in another list has no item here (IDs are unique across lists), the index where the drag found
3697
+ * its item is checked next, and only then every item ID is searched.
3698
+ */
2945
3699
  #draggedItemIndex = computed(() => {
2946
3700
  const draggedId = this.draggedItemId();
2947
3701
  if (!draggedId)
2948
3702
  return -1;
2949
- return this.#itemIndexMap().get(draggedId) ?? -1;
3703
+ const droppableId = this.droppableId();
3704
+ const sourceDroppableId = this.#dragState.sourceDroppableId();
3705
+ if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
3706
+ return -1;
3707
+ const sourceIndex = this.#dragState.sourceIndex();
3708
+ const items = this.items();
3709
+ if (sourceIndex !== null &&
3710
+ sourceIndex >= 0 &&
3711
+ sourceIndex < items.length &&
3712
+ this.itemIdFn()(items[sourceIndex]) === draggedId) {
3713
+ return sourceIndex;
3714
+ }
3715
+ return this.#itemIds().lastIndexOf(draggedId);
2950
3716
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
2951
3717
  /** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
2952
3718
  #stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
@@ -2971,31 +3737,20 @@ class VirtualScrollContainerComponent {
2971
3737
  }
2972
3738
  return this.itemHeight();
2973
3739
  }, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
2974
- /** Items to render, including sticky items and placeholder */
2975
- renderedItems = computed(() => {
3740
+ /**
3741
+ * Rows to render: the items in range, then the sticky items outside it. Independent of the
3742
+ * placeholder, so a placeholder move leaves the rows as they are.
3743
+ */
3744
+ #renderedRows = computed(() => {
2976
3745
  const items = this.items();
2977
3746
  const { start, end } = this.#renderRange();
2978
3747
  const stickyIds = this.#stickyIdsSet();
2979
3748
  const idFn = this.itemIdFn();
2980
- const itemIndexMap = this.#itemIndexMap();
2981
3749
  const draggedId = this.draggedItemId();
2982
- const placeholderIdx = this.placeholderIndex();
2983
3750
  const result = [];
2984
3751
  const renderedIds = new Set();
2985
- let hasPlaceholder = false;
2986
- // Add all items in the visible range, inserting placeholder at correct position
3752
+ // Add all items in the visible range
2987
3753
  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
3754
  const item = items[i];
3000
3755
  const id = idFn(item);
3001
3756
  result.push({
@@ -3007,24 +3762,14 @@ class VirtualScrollContainerComponent {
3007
3762
  });
3008
3763
  renderedIds.add(id);
3009
3764
  }
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
3765
  // Add any sticky items that aren't already rendered
3022
3766
  const missingStickyIndices = [];
3023
3767
  for (const id of stickyIds) {
3024
3768
  if (renderedIds.has(id))
3025
3769
  continue;
3026
- const index = itemIndexMap.get(id);
3027
- if (index === undefined)
3770
+ // The drag knows its item's index; the index map serves the other sticky items
3771
+ const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
3772
+ if (index < 0)
3028
3773
  continue;
3029
3774
  missingStickyIndices.push({ id, index });
3030
3775
  }
@@ -3044,6 +3789,45 @@ class VirtualScrollContainerComponent {
3044
3789
  });
3045
3790
  }
3046
3791
  return result;
3792
+ }, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
3793
+ /** Track keys of #renderedRows, in order */
3794
+ #rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
3795
+ /**
3796
+ * Where the placeholder renders among the rows: before the row at this position (after the
3797
+ * rows in range when it equals their count), or -1 when it is not rendered. It goes before the
3798
+ * item at the placeholder index when that item is in range, and after the rows in range when
3799
+ * the index is past the last item.
3800
+ */
3801
+ #placeholderSlot = computed(() => {
3802
+ const placeholderIndex = this.placeholderIndex();
3803
+ if (placeholderIndex < 0)
3804
+ return -1;
3805
+ const itemCount = this.items().length;
3806
+ const { start, end } = this.#renderRange();
3807
+ const lastInRange = Math.min(end, itemCount - 1);
3808
+ if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
3809
+ return placeholderIndex - start;
3810
+ }
3811
+ if (placeholderIndex >= itemCount) {
3812
+ return Math.max(0, lastInRange - start + 1);
3813
+ }
3814
+ return -1;
3815
+ }, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
3816
+ /** Items to render, including sticky items and placeholder */
3817
+ renderedItems = computed(() => {
3818
+ const rows = this.#renderedRows();
3819
+ const slot = this.#placeholderSlot();
3820
+ const result = [...rows];
3821
+ if (slot >= 0) {
3822
+ result.splice(slot, 0, {
3823
+ type: 'placeholder',
3824
+ data: null,
3825
+ index: this.placeholderIndex(),
3826
+ isSticky: false,
3827
+ isDragging: false,
3828
+ });
3829
+ }
3830
+ return result;
3047
3831
  }, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
3048
3832
  /** Generated ID for auto-scroll registration */
3049
3833
  #generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
@@ -3059,12 +3843,10 @@ class VirtualScrollContainerComponent {
3059
3843
  enabled: () => this.autoScrollEnabled(),
3060
3844
  config: () => this.autoScrollConfig(),
3061
3845
  });
3062
- // Keep strategy item keys in sync
3846
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
3847
+ // spares computing every item's ID on each items change (each drop).
3063
3848
  effect(() => {
3064
- const items = this.items();
3065
- const idFn = this.itemIdFn();
3066
- const keys = items.map((item) => idFn(item));
3067
- this.#strategy().setItemKeys(keys);
3849
+ setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
3068
3850
  });
3069
3851
  // Register strategy with drag index calculator for accurate drag calculations
3070
3852
  effect((onCleanup) => {
@@ -3081,16 +3863,6 @@ class VirtualScrollContainerComponent {
3081
3863
  const draggedIndex = this.#draggedItemIndex();
3082
3864
  strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
3083
3865
  });
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
3866
  // Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
3095
3867
  // #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
3096
3868
  // move never lands outside the rendered range; this effect re-runs it when item
@@ -3156,6 +3928,18 @@ class VirtualScrollContainerComponent {
3156
3928
  }
3157
3929
  }
3158
3930
  });
3931
+ effect(() => {
3932
+ const spacer = this.spacer()?.nativeElement;
3933
+ if (spacer) {
3934
+ spacer.style.height = `${this.totalHeight()}px`;
3935
+ }
3936
+ });
3937
+ // Last, so it renders what the effects above scrolled to. Component effects run before the
3938
+ // component's own view is checked, which has nothing to update: the template has no bindings.
3939
+ effect(() => this.#render());
3940
+ }
3941
+ ngOnChanges() {
3942
+ this.#viewCheckPending = true;
3159
3943
  }
3160
3944
  ngOnInit() {
3161
3945
  // Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
@@ -3195,6 +3979,9 @@ class VirtualScrollContainerComponent {
3195
3979
  // Observe rendered items for dynamic height measurement
3196
3980
  this.#observeRenderedItems();
3197
3981
  }
3982
+ ngAfterViewChecked() {
3983
+ this.#viewCheckPending = false;
3984
+ }
3198
3985
  /**
3199
3986
  * Scroll so the placeholder is fully visible during a keyboard drag into this list.
3200
3987
  * Reads only signals and strategy offsets, so it works before the placeholder renders.
@@ -3239,6 +4026,279 @@ class VirtualScrollContainerComponent {
3239
4026
  this.#scrollTop.set(newScrollTop);
3240
4027
  }
3241
4028
  }
4029
+ /**
4030
+ * Render the content offset, the rows and the placeholder. From an effect, not the template:
4031
+ * Angular checks every row view in this component's view each time a signal its template reads
4032
+ * changes, so reading the placeholder there re-rendered every row on each placeholder move.
4033
+ * Here a placeholder move only moves the placeholder, and a row renders when its context does.
4034
+ */
4035
+ #render() {
4036
+ // Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
4037
+ // as a subclass that renders its own template relies on it too.
4038
+ const isDragging = this.#dragState.isDragging();
4039
+ const placeholderIndex = this.placeholderIndex();
4040
+ untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
4041
+ const outlet = this.rowOutlet();
4042
+ const wrapper = this.contentWrapper()?.nativeElement;
4043
+ // Not this component's template (a subclass's own): it renders renderedItems() itself
4044
+ if (!outlet || !wrapper)
4045
+ return;
4046
+ const template = this.itemTemplate() ?? null;
4047
+ const entries = this.#renderedRows();
4048
+ const keys = this.#rowKeys();
4049
+ const trackBy = this.effectiveTrackByFn();
4050
+ const recycle = this.recycleRows();
4051
+ const transform = this.contentTransform();
4052
+ const slot = this.#placeholderSlot();
4053
+ const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
4054
+ // Untracked: the signals the rows' templates read must not become this effect's
4055
+ untracked(() => {
4056
+ if (transform !== this.#contentTransformSet) {
4057
+ wrapper.style.transform = transform;
4058
+ this.#contentTransformSet = transform;
4059
+ }
4060
+ this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
4061
+ this.#renderPlaceholder(outlet, slot, placeholderHeight);
4062
+ });
4063
+ }
4064
+ /**
4065
+ * Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
4066
+ * (and the components in it) while its track key stays rendered, and only the rows out of order
4067
+ * move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
4068
+ * that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
4069
+ * `markForCheck()` would mark every ancestor view up to the root too.
4070
+ */
4071
+ #renderRows(outlet, template, entries, keys, trackBy, recycle) {
4072
+ if (!recycle) {
4073
+ this.#destroyRowPool();
4074
+ }
4075
+ // Nothing to do, as on a placeholder move
4076
+ if (entries === this.#rowEntries &&
4077
+ template === this.#rowTemplate &&
4078
+ trackBy === this.#rowTrackBy) {
4079
+ return;
4080
+ }
4081
+ // A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
4082
+ if (template !== this.#rowTemplate) {
4083
+ for (const row of this.#rows) {
4084
+ row.view.destroy();
4085
+ }
4086
+ this.#rows = [];
4087
+ this.#destroyRowPool();
4088
+ this.#rowTemplate = template;
4089
+ }
4090
+ if (!template)
4091
+ return;
4092
+ // A new track function gives the rendered rows new keys; they keep their views (as with @for)
4093
+ if (trackBy !== this.#rowTrackBy) {
4094
+ for (const row of this.#rows) {
4095
+ const { $implicit, index } = row.view.context;
4096
+ row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
4097
+ }
4098
+ this.#rowTrackBy = trackBy;
4099
+ }
4100
+ // The last render's views by key, in order: an entry takes the first view left for its key
4101
+ const previous = new Map();
4102
+ for (const row of this.#rows) {
4103
+ if (row.view.destroyed)
4104
+ continue;
4105
+ const views = previous.get(row.key);
4106
+ if (views) {
4107
+ views.push(row);
4108
+ }
4109
+ else {
4110
+ previous.set(row.key, [row]);
4111
+ }
4112
+ }
4113
+ const kept = keys.map((key) => previous.get(key)?.shift());
4114
+ this.#warnDuplicateKeys(keys);
4115
+ // Remove the rows that left, then keep in place the longest run of kept views that is
4116
+ // already in order (their positions in the outlet, which now holds only kept views)
4117
+ const dragged = this.#dragState.draggedItem();
4118
+ for (const views of previous.values()) {
4119
+ for (const row of views) {
4120
+ // The dragged item's view is destroyed, as without recycling: that cancels the drag
4121
+ // instead of handing it to another item
4122
+ if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
4123
+ this.#poolRow(outlet, row.view);
4124
+ }
4125
+ else {
4126
+ row.view.destroy();
4127
+ }
4128
+ }
4129
+ }
4130
+ const positions = new Map();
4131
+ for (let i = 0; i < outlet.length; i++) {
4132
+ positions.set(outlet.get(i), i);
4133
+ }
4134
+ const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
4135
+ // From the last row up, so each row goes right before the next one, already in place
4136
+ const rows = new Array(entries.length);
4137
+ const toCheck = [];
4138
+ let next = null;
4139
+ for (let i = entries.length - 1; i >= 0; i--) {
4140
+ const entry = entries[i];
4141
+ let row = kept[i];
4142
+ if (row) {
4143
+ if (!inPlace[i]) {
4144
+ this.#moveBefore(outlet, row.view, next);
4145
+ }
4146
+ if (this.#updateContext(row.view.context, entry)) {
4147
+ toCheck.push(row.view);
4148
+ }
4149
+ }
4150
+ else {
4151
+ const index = next ? outlet.indexOf(next) : outlet.length;
4152
+ // A pooled view is checked whatever its context: nothing checked it while detached
4153
+ let view = this.#rowPool.pop();
4154
+ if (view) {
4155
+ this.#updateContext(view.context, entry);
4156
+ outlet.insert(view, index);
4157
+ }
4158
+ else {
4159
+ const context = {
4160
+ $implicit: entry.data,
4161
+ index: entry.index,
4162
+ isSticky: entry.isSticky,
4163
+ };
4164
+ view = outlet.createEmbeddedView(template, context, index);
4165
+ }
4166
+ row = { key: keys[i], view };
4167
+ toCheck.push(view);
4168
+ }
4169
+ rows[i] = row;
4170
+ next = row.view;
4171
+ }
4172
+ this.#rows = rows;
4173
+ while (this.#rowPool.length > MAX_POOLED_ROWS) {
4174
+ this.#rowPool.pop().destroy();
4175
+ }
4176
+ // When this component's own view is checked later in this pass, that check renders every row
4177
+ if (!this.#viewCheckPending) {
4178
+ // Top to bottom (toCheck lists them bottom up)
4179
+ for (let i = toCheck.length - 1; i >= 0; i--) {
4180
+ toCheck[i].detectChanges();
4181
+ }
4182
+ }
4183
+ this.#rowEntries = entries;
4184
+ }
4185
+ /**
4186
+ * Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
4187
+ * the dragged element (its draggable ID need not be the item's ID).
4188
+ */
4189
+ #rendersDraggedItem(view, dragged) {
4190
+ if (!dragged)
4191
+ return false;
4192
+ return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
4193
+ view.rootNodes.some((node) => node.contains(dragged.element)));
4194
+ }
4195
+ /**
4196
+ * Take a row's view out of the outlet and pool it. It keeps its element, so its shift
4197
+ * animation is cancelled (the item it renders next must not slide from this one's position),
4198
+ * and a drop animation that hides it shows it again.
4199
+ */
4200
+ #poolRow(outlet, view) {
4201
+ outlet.detach(outlet.indexOf(view));
4202
+ const nodes = view.rootNodes;
4203
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
4204
+ // the animation ends: in the row of another item by then, if the view were reused
4205
+ if (nodes.some((node) => node.parentNode !== null)) {
4206
+ view.destroy();
4207
+ return;
4208
+ }
4209
+ for (const node of nodes) {
4210
+ if (node instanceof HTMLElement) {
4211
+ this.#shiftAnimator?.cancel(node);
4212
+ }
4213
+ }
4214
+ revealDropTargetIn(nodes);
4215
+ this.#rowPool.push(view);
4216
+ }
4217
+ #destroyRowPool() {
4218
+ for (const view of this.#rowPool) {
4219
+ view.destroy();
4220
+ }
4221
+ this.#rowPool = [];
4222
+ }
4223
+ /** Move a row's view right before `next` (last without one), unless it is there already. */
4224
+ #moveBefore(outlet, view, next) {
4225
+ const target = next ? outlet.indexOf(next) : outlet.length;
4226
+ const current = outlet.indexOf(view);
4227
+ if (current === target - 1)
4228
+ return;
4229
+ // move() takes the view out before inserting it, which shifts the views after it
4230
+ outlet.move(view, current < target ? target - 1 : target);
4231
+ }
4232
+ /** Copy an entry into a row's context. Returns whether the context changed. */
4233
+ #updateContext(context, entry) {
4234
+ if (Object.is(context.$implicit, entry.data) &&
4235
+ context.index === entry.index &&
4236
+ context.isSticky === entry.isSticky) {
4237
+ return false;
4238
+ }
4239
+ context.$implicit = entry.data;
4240
+ context.index = entry.index;
4241
+ context.isSticky = entry.isSticky;
4242
+ return true;
4243
+ }
4244
+ /** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
4245
+ #warnDuplicateKeys(keys) {
4246
+ if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
4247
+ return;
4248
+ this.#rowKeysChecked = keys;
4249
+ const seen = new Set();
4250
+ for (const key of keys) {
4251
+ if (seen.has(key)) {
4252
+ this.#warnedDuplicateKeys = true;
4253
+ console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
4254
+ `${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
4255
+ return;
4256
+ }
4257
+ seen.add(key);
4258
+ }
4259
+ }
4260
+ /**
4261
+ * Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
4262
+ * DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
4263
+ * height checks no row.
4264
+ */
4265
+ #renderPlaceholder(outlet, slot, height) {
4266
+ if (slot < 0 || height === null) {
4267
+ this.#placeholderElement()?.remove();
4268
+ return;
4269
+ }
4270
+ this.#placeholder ??= createComponent(DragPlaceholderComponent, {
4271
+ environmentInjector: this.#environmentInjector,
4272
+ });
4273
+ if (height !== this.#placeholderHeightSet) {
4274
+ this.#placeholder.setInput('itemHeight', height);
4275
+ this.#placeholder.changeDetectorRef.detectChanges();
4276
+ this.#placeholderHeightSet = height;
4277
+ }
4278
+ // Rows are inserted before the outlet's anchor, so the anchor follows the last of them
4279
+ const anchor = outlet.element.nativeElement;
4280
+ const parent = anchor.parentNode;
4281
+ const element = this.#placeholderElement();
4282
+ if (!parent || !element)
4283
+ return;
4284
+ const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
4285
+ if (element.nextSibling !== before) {
4286
+ parent.insertBefore(element, before);
4287
+ }
4288
+ }
4289
+ #placeholderElement() {
4290
+ return this.#placeholder?.location.nativeElement ?? null;
4291
+ }
4292
+ /** The first node in `parent` of the rows from `index` on (null when none has one). */
4293
+ #firstNodeOfRows(index, parent) {
4294
+ for (let i = index; i < this.#rows.length; i++) {
4295
+ const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
4296
+ const node = firstInDocumentOrder(nodes);
4297
+ if (node)
4298
+ return node;
4299
+ }
4300
+ return null;
4301
+ }
3242
4302
  ngOnDestroy() {
3243
4303
  this.#scrollCleanup?.();
3244
4304
  this.#resizeCleanup?.();
@@ -3249,6 +4309,12 @@ class VirtualScrollContainerComponent {
3249
4309
  this.#observedElementByKey.clear();
3250
4310
  itemResizeObserver?.disconnect();
3251
4311
  this.#shiftAnimator?.cancelAll();
4312
+ // Detached, so not destroyed with this view
4313
+ this.#destroyRowPool();
4314
+ // Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
4315
+ this.#placeholderElement()?.remove();
4316
+ this.#placeholder?.destroy();
4317
+ this.#placeholder = null;
3252
4318
  }
3253
4319
  #createShiftAnimator() {
3254
4320
  const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
@@ -3262,10 +4328,25 @@ class VirtualScrollContainerComponent {
3262
4328
  });
3263
4329
  }
3264
4330
  /**
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.
4331
+ * Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
4332
+ * its element is no identity), and the placeholder.
3267
4333
  */
3268
4334
  *#shiftAnimationEntries() {
4335
+ if (this.rowOutlet()) {
4336
+ for (const row of this.#rows) {
4337
+ for (const node of row.view.rootNodes) {
4338
+ if (node instanceof HTMLElement) {
4339
+ yield [row.key, node];
4340
+ }
4341
+ }
4342
+ }
4343
+ const placeholder = this.#placeholderElement();
4344
+ if (placeholder?.isConnected) {
4345
+ yield [placeholder, placeholder];
4346
+ }
4347
+ return;
4348
+ }
4349
+ // A subclass's own template (see #render) never recycles: each element is its own identity
3269
4350
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3270
4351
  if (!wrapper)
3271
4352
  return;
@@ -3299,25 +4380,29 @@ class VirtualScrollContainerComponent {
3299
4380
  * item when a new observer is created.
3300
4381
  */
3301
4382
  #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)
4383
+ // Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
4384
+ // renderedItems(): a placeholder move changes no row. A new item template changes no entry
4385
+ // but renders every row anew, as new elements.
3304
4386
  afterRenderEffect(() => {
3305
4387
  const observer = this.#itemResizeObserver();
3306
4388
  if (!observer)
3307
4389
  return;
3308
- const rendered = this.renderedItems();
4390
+ const rendered = this.#renderedRows();
3309
4391
  const idFn = this.itemIdFn();
4392
+ this.itemTemplate();
3310
4393
  // Find the content wrapper and observe item elements
3311
4394
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3312
4395
  if (!wrapper)
3313
4396
  return;
4397
+ // The DOM element of each item, by its data-draggable-id: one query for all rows, where a
4398
+ // query per row scanned the wrapper once for each (quadratic in the rendered rows)
4399
+ const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
3314
4400
  const nextObservedByKey = new Map();
3315
4401
  for (const entry of rendered) {
3316
4402
  if (entry.type !== 'item' || !entry.data)
3317
4403
  continue;
3318
4404
  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);
4405
+ const el = elementsById.get(key);
3321
4406
  if (el) {
3322
4407
  nextObservedByKey.set(key, el);
3323
4408
  }
@@ -3356,206 +4441,56 @@ class VirtualScrollContainerComponent {
3356
4441
  */
3357
4442
  scrollToIndex(index) {
3358
4443
  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
- };
4444
+ const position = strategy.getOffsetForIndex(index);
4445
+ this.scrollTo(position);
3515
4446
  }
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();
4447
+ /**
4448
+ * Get the current scroll position.
4449
+ */
4450
+ getScrollTop() {
4451
+ return this.#scrollTop();
3524
4452
  }
3525
- #duration() {
3526
- return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
4453
+ /**
4454
+ * Get the total scrollable height.
4455
+ */
4456
+ getScrollHeight() {
4457
+ return this.totalHeight();
3527
4458
  }
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;
4459
+ /**
4460
+ * Scroll by a delta amount.
4461
+ */
4462
+ scrollBy(delta) {
4463
+ const newPosition = Math.max(0, Math.min(this.getScrollTop() + delta, this.getScrollHeight() - this.effectiveHeight()));
4464
+ this.scrollTo(newPosition);
3556
4465
  }
3557
- return null;
4466
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4467
+ 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: `
4468
+ <!-- Single spacer maintains scroll height -->
4469
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
4470
+
4471
+ <!-- Content wrapper positioned via GPU-accelerated transform -->
4472
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4473
+ <ng-container #rows />
4474
+ </div>
4475
+ `, 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
4476
  }
4477
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
4478
+ type: Component,
4479
+ args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
4480
+ class: 'vdnd-virtual-scroll',
4481
+ '[style.height.px]': 'containerHeight() ?? null',
4482
+ '[attr.data-item-height]': 'itemHeight()',
4483
+ '[attr.data-total-items]': 'items().length',
4484
+ }, template: `
4485
+ <!-- Single spacer maintains scroll height -->
4486
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
4487
+
4488
+ <!-- Content wrapper positioned via GPU-accelerated transform -->
4489
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4490
+ <ng-container #rows />
4491
+ </div>
4492
+ `, 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"] }]
4493
+ }], 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
4494
 
3560
4495
  /**
3561
4496
  * Renders a preview of the dragged item that follows the cursor.
@@ -3581,6 +4516,8 @@ class DragPreviewComponent {
3581
4516
  #overlayContainer = inject(OverlayContainerService);
3582
4517
  #elementRef = inject((ElementRef));
3583
4518
  #injector = inject(Injector);
4519
+ #droppableRegistry = inject(DroppableRegistryService);
4520
+ #scheduler = inject(DragSchedulerService);
3584
4521
  #dropAnimator = this.#createDropAnimator();
3585
4522
  /** Optional custom template for the preview */
3586
4523
  previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
@@ -3598,6 +4535,39 @@ class DragPreviewComponent {
3598
4535
  isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
3599
4536
  /** The item shown: the live dragged item, or the dropped one while it settles */
3600
4537
  displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
4538
+ /**
4539
+ * Where the preview goes, from everything but the cursor. The template and its effects never
4540
+ * read the cursor: a signal read there would schedule a full application render on every
4541
+ * pointer frame, and that render lands after the frame is painted (one frame late).
4542
+ */
4543
+ #placement = computed(() => {
4544
+ const settling = this.#settling();
4545
+ if (settling && !this.dragState.isDragging()) {
4546
+ return {
4547
+ settled: settling.position,
4548
+ offset: { x: 0, y: 0 },
4549
+ initialPosition: null,
4550
+ lockAxis: null,
4551
+ };
4552
+ }
4553
+ return this.#placementFor({
4554
+ grabOffset: this.dragState.grabOffset(),
4555
+ initialPosition: this.dragState.initialPosition(),
4556
+ lockAxis: this.dragState.lockAxis(),
4557
+ });
4558
+ }, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
4559
+ /**
4560
+ * Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
4561
+ * cursor (bound for stable add/remove). The render-time write in the constructor covers the
4562
+ * rest: the preview appearing, keyboard drags and the settle.
4563
+ */
4564
+ #writeFramePosition = () => {
4565
+ const preview = this.previewElement()?.nativeElement;
4566
+ if (!preview || this.#settling() || !this.dragState.isDragging()) {
4567
+ return;
4568
+ }
4569
+ this.#place(preview, this.#placement(), this.dragState.cursorPosition());
4570
+ };
3601
4571
  /** Whether this preview's template registration is currently counted (kept balanced). */
3602
4572
  #templateRegistered = false;
3603
4573
  /** The cloned element from drag state (used when no custom template is provided) */
@@ -3608,6 +4578,7 @@ class DragPreviewComponent {
3608
4578
  return this.displayedItem()?.clonedElement ?? null;
3609
4579
  }, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
3610
4580
  constructor() {
4581
+ this.#scheduler.addFrameWriter(this.#writeFramePosition);
3611
4582
  // Teleport host element into the body-level overlay container after first render.
3612
4583
  // This escapes any ancestor CSS transforms that would break position: fixed.
3613
4584
  afterNextRender(() => {
@@ -3616,6 +4587,18 @@ class DragPreviewComponent {
3616
4587
  container.appendChild(this.#elementRef.nativeElement);
3617
4588
  }
3618
4589
  });
4590
+ // Place the preview when it appears or anything but the cursor changes (drag start,
4591
+ // keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
4592
+ // #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
4593
+ afterRenderEffect({
4594
+ write: () => {
4595
+ const preview = this.previewElement()?.nativeElement;
4596
+ const placement = this.#placement();
4597
+ if (preview) {
4598
+ this.#place(preview, placement, untracked(this.dragState.cursorPosition));
4599
+ }
4600
+ },
4601
+ });
3619
4602
  // Publish whether this preview renders via a custom template so the drag
3620
4603
  // directives can skip the drag-start element clone when it won't be shown.
3621
4604
  effect(() => {
@@ -3660,6 +4643,7 @@ class DragPreviewComponent {
3660
4643
  });
3661
4644
  }
3662
4645
  ngOnDestroy() {
4646
+ this.#scheduler.removeFrameWriter(this.#writeFramePosition);
3663
4647
  this.#dropAnimator?.cancel();
3664
4648
  if (this.#templateRegistered) {
3665
4649
  this.#overlayContainer.setTemplatePreviewActive(false);
@@ -3667,32 +4651,31 @@ class DragPreviewComponent {
3667
4651
  }
3668
4652
  this.#elementRef.nativeElement.remove();
3669
4653
  }
3670
- /** Whether the preview is visible */
4654
+ /**
4655
+ * Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
4656
+ * is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
4657
+ */
3671
4658
  isVisible = computed(() => {
3672
- return ((this.dragState.isDragging() && this.dragState.cursorPosition() !== null) || this.isSettling());
4659
+ return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
4660
+ this.isSettling());
3673
4661
  }, ...(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;
4662
+ #placementFor(state) {
4663
+ return {
4664
+ settled: null,
4665
+ // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
4666
+ offset: state.grabOffset ?? this.cursorOffset(),
4667
+ initialPosition: state.initialPosition,
4668
+ lockAxis: state.lockAxis,
4669
+ };
4670
+ }
4671
+ #positionAt(placement, cursor) {
4672
+ if (placement.settled) {
4673
+ return placement.settled;
3679
4674
  }
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
4675
  if (!cursor) {
3692
4676
  return { x: 0, y: 0 };
3693
4677
  }
3694
- // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
3695
- const offset = state.grabOffset ?? this.cursorOffset();
4678
+ const { offset, initialPosition, lockAxis } = placement;
3696
4679
  let x = cursor.x - offset.x;
3697
4680
  let y = cursor.y - offset.y;
3698
4681
  // Apply axis locking if configured
@@ -3709,10 +4692,10 @@ class DragPreviewComponent {
3709
4692
  return { x, y };
3710
4693
  }
3711
4694
  /** 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" }] : []));
4695
+ #place(preview, placement, cursor) {
4696
+ const { x, y } = this.#positionAt(placement, cursor);
4697
+ preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
4698
+ }
3716
4699
  /** Dimensions of the preview */
3717
4700
  dimensions = computed(() => {
3718
4701
  const item = this.displayedItem();
@@ -3748,7 +4731,7 @@ class DragPreviewComponent {
3748
4731
  }
3749
4732
  const settling = {
3750
4733
  item: ended.draggedItem,
3751
- position: this.#positionFor(ended),
4734
+ position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
3752
4735
  };
3753
4736
  this.#settling.set(settling);
3754
4737
  // A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
@@ -3765,7 +4748,7 @@ class DragPreviewComponent {
3765
4748
  this.#settling.set(null);
3766
4749
  return;
3767
4750
  }
3768
- const target = findDropTarget(settling.item.draggableId, droppableIds);
4751
+ const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
3769
4752
  this.#dropAnimator.play(ghost, target, () => {
3770
4753
  if (this.#settling() === settling) {
3771
4754
  this.#settling.set(null);
@@ -3785,7 +4768,6 @@ class DragPreviewComponent {
3785
4768
  class="vdnd-drag-preview"
3786
4769
  [class.vdnd-drag-preview-dropping]="isSettling()"
3787
4770
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3788
- [style.transform]="transform()"
3789
4771
  [style.width.px]="dimensions().width"
3790
4772
  [style.height.px]="dimensions().height"
3791
4773
  >
@@ -3812,7 +4794,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3812
4794
  class="vdnd-drag-preview"
3813
4795
  [class.vdnd-drag-preview-dropping]="isSettling()"
3814
4796
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3815
- [style.transform]="transform()"
3816
4797
  [style.width.px]="dimensions().width"
3817
4798
  [style.height.px]="dimensions().height"
3818
4799
  >
@@ -4013,7 +4994,8 @@ class DroppableDirective {
4013
4994
  #elementRef = inject((ElementRef));
4014
4995
  #dragState = inject(DragStateService);
4015
4996
  #autoScroll = inject(AutoScrollService);
4016
- #positionCalculator = inject(PositionCalculatorService);
4997
+ #registry = inject(DroppableRegistryService);
4998
+ #ngZone = inject(NgZone);
4017
4999
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
4018
5000
  /** Unique identifier for this droppable */
4019
5001
  vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
@@ -4042,7 +5024,7 @@ class DroppableDirective {
4042
5024
  autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
4043
5025
  /** Constrain drag preview and placeholder to container boundaries */
4044
5026
  constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
4045
- /** Emits when an item is dropped on this droppable */
5027
+ /** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
4046
5028
  // eslint-disable-next-line @angular-eslint/no-output-native
4047
5029
  drop = output();
4048
5030
  /**
@@ -4087,14 +5069,6 @@ class DroppableDirective {
4087
5069
  #lastPlaceholderIndex = null;
4088
5070
  /** Drag whose initial placeholder position has already been seen by this droppable */
4089
5071
  #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
5072
  /** Whether this droppable has rendered, so its inputs have values */
4099
5073
  #rendered = false;
4100
5074
  constructor() {
@@ -4109,59 +5083,33 @@ class DroppableDirective {
4109
5083
  // would never register. AutoScrollService filters by live scroll geometry per drag.
4110
5084
  canRegister: () => Boolean(this.effectiveGroup()),
4111
5085
  });
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;
5086
+ afterNextRender(() => (this.#rendered = true));
5087
+ // Register with the droppable registry, and again under the new key when the ID or group
5088
+ // changes. Drag code finds drop targets through the registry instead of querying the
5089
+ // document; a registration during an active drag makes this droppable a target from the
5090
+ // next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
5091
+ // The cleanup unregisters it, including on destroy.
5092
+ //
5093
+ // Not deferred to afterNextRender: this runs in the change detection that creates the
5094
+ // droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
5095
+ // animation, keyboard focus restore) already find it, and so does a view refreshed only
5096
+ // by its own detectChanges(). Nothing reads the registry before change detection ends.
5097
+ effect((onCleanup) => {
4117
5098
  const group = this.effectiveGroup();
4118
- if (group) {
4119
- this.#positionCalculator.notifyCandidatesChanged(group);
5099
+ if (!group) {
5100
+ return;
4120
5101
  }
5102
+ onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
4121
5103
  });
4122
5104
  effect(() => {
4123
5105
  const currentIndex = this.#placeholderInsertionIndex();
4124
5106
  untracked(() => this.#handlePlaceholderMove(currentIndex));
4125
5107
  });
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
5108
  }
4161
5109
  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.)
5110
+ // Destroyed before it rendered: its inputs may have no values yet and it never became the
5111
+ // active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
5112
+ // it.) A registry registration, if any, is removed by its effect's cleanup.
4165
5113
  if (!this.#rendered) {
4166
5114
  return;
4167
5115
  }
@@ -4169,13 +5117,7 @@ class DroppableDirective {
4169
5117
  if (this.isActive()) {
4170
5118
  this.#dragState.setActiveDroppable(null);
4171
5119
  }
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
- }
5120
+ // Auto-scroll and the droppable registry unregister through their effects' cleanup.
4179
5121
  }
4180
5122
  /**
4181
5123
  * Emit placeholderMove when the placeholder moved within (or entered) this droppable.
@@ -4209,14 +5151,14 @@ class DroppableDirective {
4209
5151
  });
4210
5152
  }
4211
5153
  /**
4212
- * Handle a drop on this droppable.
5154
+ * Emit `drop` for a drag that ended on this droppable. Called by the registry from
5155
+ * `DragStateService.endDrag()`, with the state captured just before the reset. Returns
5156
+ * whether it emitted.
4213
5157
  */
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;
5158
+ #handleDrop(state) {
5159
+ // A target disabled at release is not a valid drop (dragEnd reports no destination).
5160
+ if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
5161
+ return false;
4220
5162
  }
4221
5163
  const sourceDroppableId = state.sourceDroppableId ?? '';
4222
5164
  const placeholderId = state.placeholderId ?? END_OF_LIST;
@@ -4236,7 +5178,7 @@ class DroppableDirective {
4236
5178
  sourceDroppableId,
4237
5179
  activeDroppableId: this.vdndDroppable(),
4238
5180
  });
4239
- this.drop.emit({
5181
+ const event = {
4240
5182
  source: {
4241
5183
  draggableId: state.draggedItem.draggableId,
4242
5184
  droppableId: sourceDroppableId,
@@ -4249,7 +5191,13 @@ class DroppableDirective {
4249
5191
  index: destinationIndex,
4250
5192
  data: this.vdndDroppableData(),
4251
5193
  },
4252
- });
5194
+ };
5195
+ // Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
5196
+ // zone.js a template listener marks its view dirty without scheduling a render, so emit
5197
+ // inside the zone for the consumer's handler (and async work it starts) to render. A
5198
+ // no-op when zoneless.
5199
+ this.#ngZone.run(() => this.drop.emit(event));
5200
+ return true;
4253
5201
  }
4254
5202
  /**
4255
5203
  * Get the index of an item in a droppable.
@@ -4257,7 +5205,7 @@ class DroppableDirective {
4257
5205
  */
4258
5206
  #getItemIndex(draggableId, droppableId) {
4259
5207
  // Find all draggables in the source droppable
4260
- const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
5208
+ const droppable = this.#registry.getById(droppableId);
4261
5209
  if (!droppable) {
4262
5210
  return 0;
4263
5211
  }
@@ -4330,6 +5278,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4330
5278
  }]
4331
5279
  }], 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
5280
 
5281
+ /**
5282
+ * Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
5283
+ *
5284
+ * Not template listeners: Angular marks the listening view and every ancestor up to the root
5285
+ * dirty before running one, so each placeholder move would re-render the consumer's whole
5286
+ * component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
5287
+ * a consumer's own listener on the sortable list marks only its own view. Subscribed in the
5288
+ * constructor, before the droppable's first change detection can emit; both outputs drop their
5289
+ * listeners when the element is destroyed.
5290
+ */
5291
+ class SortableListOutputsDirective {
5292
+ constructor() {
5293
+ const droppable = inject(DroppableDirective);
5294
+ const list = inject(VirtualSortableListComponent);
5295
+ droppable.drop.subscribe((event) => list.drop.emit(event));
5296
+ droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
5297
+ }
5298
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
5299
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
5300
+ }
5301
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
5302
+ type: Directive,
5303
+ args: [{ selector: '[vdndSortableListOutputs]' }]
5304
+ }], ctorParameters: () => [] });
4333
5305
  /**
4334
5306
  * A high-level component that combines droppable, virtual scroll, and placeholder
4335
5307
  * functionality into a single, easy-to-use component.
@@ -4407,6 +5379,13 @@ class VirtualSortableListComponent {
4407
5379
  containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
4408
5380
  /** Number of items to render above/below the visible area */
4409
5381
  overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
5382
+ /**
5383
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
5384
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
5385
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
5386
+ * @default false
5387
+ */
5388
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
4410
5389
  /** Enable auto-scroll when dragging near edges */
4411
5390
  autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
4412
5391
  /** Auto-scroll configuration */
@@ -4420,7 +5399,7 @@ class VirtualSortableListComponent {
4420
5399
  /** Emits each time the placeholder moves within this list (every item displacement) */
4421
5400
  placeholderMove = output();
4422
5401
  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: `
5402
+ 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
5403
  <div
4425
5404
  class="vdnd-sortable-list-droppable"
4426
5405
  [vdndDroppable]="droppableId()"
@@ -4428,8 +5407,7 @@ class VirtualSortableListComponent {
4428
5407
  [vdndDroppableData]="droppableData()"
4429
5408
  [disabled]="disabled()"
4430
5409
  [constrainToContainer]="constrainToContainer()"
4431
- (drop)="drop.emit($event)"
4432
- (placeholderMove)="placeholderMove.emit($event)"
5410
+ vdndSortableListOutputs
4433
5411
  >
4434
5412
  <vdnd-virtual-scroll
4435
5413
  [items]="items()"
@@ -4442,16 +5420,17 @@ class VirtualSortableListComponent {
4442
5420
  [autoStickyDraggedItem]="true"
4443
5421
  [containerHeight]="containerHeight()"
4444
5422
  [overscan]="overscan()"
5423
+ [recycleRows]="recycleRows()"
4445
5424
  [autoScrollEnabled]="autoScrollEnabled()"
4446
5425
  [autoScrollConfig]="autoScrollConfig()"
4447
5426
  >
4448
5427
  </vdnd-virtual-scroll>
4449
5428
  </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 });
5429
+ `, 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
5430
  }
4452
5431
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
4453
5432
  type: Component,
4454
- args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
5433
+ args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
4455
5434
  class: 'vdnd-sortable-list',
4456
5435
  }, template: `
4457
5436
  <div
@@ -4461,8 +5440,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4461
5440
  [vdndDroppableData]="droppableData()"
4462
5441
  [disabled]="disabled()"
4463
5442
  [constrainToContainer]="constrainToContainer()"
4464
- (drop)="drop.emit($event)"
4465
- (placeholderMove)="placeholderMove.emit($event)"
5443
+ vdndSortableListOutputs
4466
5444
  >
4467
5445
  <vdnd-virtual-scroll
4468
5446
  [items]="items()"
@@ -4475,13 +5453,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4475
5453
  [autoStickyDraggedItem]="true"
4476
5454
  [containerHeight]="containerHeight()"
4477
5455
  [overscan]="overscan()"
5456
+ [recycleRows]="recycleRows()"
4478
5457
  [autoScrollEnabled]="autoScrollEnabled()"
4479
5458
  [autoScrollConfig]="autoScrollConfig()"
4480
5459
  >
4481
5460
  </vdnd-virtual-scroll>
4482
5461
  </div>
4483
5462
  `, 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"] }] } });
5463
+ }], 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
5464
 
4486
5465
  /**
4487
5466
  * A virtual viewport component that provides efficient wrapper-based positioning
@@ -5129,22 +6108,25 @@ class KeyboardDragHandler {
5129
6108
  this.#cleanupDocumentListener();
5130
6109
  // Clear droppable metadata cache from this drag session
5131
6110
  this.#deps.dragIndexCalculator.clearCache();
5132
- // Emit drag end event
5133
- this.#deps.callbacks.onDragEnd({
6111
+ const event = {
5134
6112
  draggableId: ctx.draggableId,
5135
6113
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5136
6114
  cancelled: false,
5137
6115
  data: ctx.data,
5138
6116
  sourceIndex,
5139
6117
  destinationIndex,
5140
- });
6118
+ };
5141
6119
  // The item stays in its source list when there is no valid target
5142
6120
  const fallbackDroppableId = hasValidTarget
5143
6121
  ? activeDroppableId
5144
6122
  : 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);
6123
+ this.#endInZone(() => {
6124
+ this.#deps.callbacks.onDragEnd(event);
6125
+ // Ends the drag state, which delivers the drop
6126
+ this.#deps.keyboardDrag.completeKeyboardDrag();
6127
+ // Restore focus to the moved element after state updates
6128
+ this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
6129
+ });
5148
6130
  }
5149
6131
  /**
5150
6132
  * Cancel a keyboard drag operation.
@@ -5158,18 +6140,30 @@ class KeyboardDragHandler {
5158
6140
  this.#cleanupDocumentListener();
5159
6141
  // Clear droppable metadata cache from this drag session
5160
6142
  this.#deps.dragIndexCalculator.clearCache();
5161
- // Emit drag end event
5162
- this.#deps.callbacks.onDragEnd({
6143
+ const event = {
5163
6144
  draggableId: ctx.draggableId,
5164
6145
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5165
6146
  cancelled: true,
5166
6147
  data: ctx.data,
5167
6148
  sourceIndex,
5168
6149
  destinationIndex: null,
6150
+ };
6151
+ this.#endInZone(() => {
6152
+ this.#deps.callbacks.onDragEnd(event);
6153
+ this.#deps.keyboardDrag.cancelKeyboardDrag();
6154
+ // Restore focus to the original element after state updates
6155
+ this.#restoreFocus(ctx.draggableId, sourceDroppableId);
5169
6156
  });
5170
- this.#deps.keyboardDrag.cancelKeyboardDrag();
5171
- // Restore focus to the original element after state updates
5172
- this.#restoreFocus(ctx.draggableId, sourceDroppableId);
6157
+ }
6158
+ /**
6159
+ * Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
6160
+ * entry into Angular's zone. The document listener runs outside it; with zone.js a
6161
+ * template listener marks its view dirty but schedules no render, and each entry renders
6162
+ * when it is left, so this renders the end once, with focus restored in that render. A
6163
+ * no-op when zoneless.
6164
+ */
6165
+ #endInZone(end) {
6166
+ this.#deps.ngZone.run(end);
5173
6167
  }
5174
6168
  /**
5175
6169
  * Teardown — remove any active document listener.
@@ -5197,7 +6191,7 @@ class KeyboardDragHandler {
5197
6191
  }
5198
6192
  else if (fallbackDroppableId) {
5199
6193
  // Fallback: focus the first draggable in the list the item ended up in
5200
- const container = queryByAttribute(document, 'data-droppable-id', fallbackDroppableId);
6194
+ const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
5201
6195
  const firstDraggable = container?.querySelector('[data-draggable-id]');
5202
6196
  firstDraggable?.focus();
5203
6197
  }
@@ -5291,8 +6285,14 @@ class PointerDragHandler {
5291
6285
  #startPosition = null;
5292
6286
  /** Whether we're currently tracking a potential drag */
5293
6287
  #isTracking = false;
6288
+ /** The item the tracked press landed on */
6289
+ #pressedId = null;
6290
+ /** Whether the document and window listeners of a press are added */
6291
+ #listening = false;
5294
6292
  /** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
5295
6293
  #touchId = null;
6294
+ /** Whether the tracked press is a touch (which shows no cursor) */
6295
+ #isTouch = false;
5296
6296
  /** Bound event handlers for cleanup */
5297
6297
  #boundPointerMove = null;
5298
6298
  #boundPointerUp = null;
@@ -5318,6 +6318,13 @@ class PointerDragHandler {
5318
6318
  getStartPosition() {
5319
6319
  return this.#startPosition;
5320
6320
  }
6321
+ /**
6322
+ * Whether the tracked press is a touch. Read by the directive when the drag starts: a touch
6323
+ * drag shows no cursor, so it gets no grabbing cursor.
6324
+ */
6325
+ isTouchPress() {
6326
+ return this.#isTouch;
6327
+ }
5321
6328
  /**
5322
6329
  * Handle pointer down (mouse or touch).
5323
6330
  * Called from the directive's host binding.
@@ -5373,6 +6380,8 @@ class PointerDragHandler {
5373
6380
  return;
5374
6381
  }
5375
6382
  this.#isTracking = true;
6383
+ this.#pressedId = ctx.draggableId;
6384
+ this.#isTouch = isTouch;
5376
6385
  if (touch) {
5377
6386
  this.#touchId = touch.identifier;
5378
6387
  this.#startPosition = { x: touch.clientX, y: touch.clientY };
@@ -5387,9 +6396,10 @@ class PointerDragHandler {
5387
6396
  if (delay > 0) {
5388
6397
  this.#delayTimerId = setTimeout(() => {
5389
6398
  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()) {
6399
+ // Another drag started while this press was held, or the press no longer applies (see
6400
+ // #isStalePress): drop it rather than show it as ready to drag.
6401
+ if (this.#deps.callbacks.isOtherDragActive() ||
6402
+ this.#isStalePress(this.#deps.getContext())) {
5393
6403
  this.cleanup();
5394
6404
  return;
5395
6405
  }
@@ -5418,6 +6428,7 @@ class PointerDragHandler {
5418
6428
  window.addEventListener('blur', this.#boundWindowBlur);
5419
6429
  document.addEventListener('visibilitychange', this.#boundVisibilityChange);
5420
6430
  });
6431
+ this.#listening = true;
5421
6432
  }
5422
6433
  /**
5423
6434
  * Remove listeners + cancel timers.
@@ -5425,14 +6436,18 @@ class PointerDragHandler {
5425
6436
  */
5426
6437
  cleanup() {
5427
6438
  this.#isTracking = false;
6439
+ this.#pressedId = null;
5428
6440
  this.#startPosition = null;
5429
6441
  this.#touchId = null;
6442
+ this.#isTouch = false;
5430
6443
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
5431
6444
  this.#cancelDelayTimer();
5432
- // Server rendering destroys the directive without a global document, and never added any
5433
- if (typeof document === 'undefined') {
6445
+ // Nothing to remove unless a press added listeners (most draggables are never pressed; a
6446
+ // virtual list destroys one each time a row scrolls out). Server rendering adds none.
6447
+ if (!this.#listening) {
5434
6448
  return;
5435
6449
  }
6450
+ this.#listening = false;
5436
6451
  // Remove event listeners
5437
6452
  if (this.#boundPointerMove) {
5438
6453
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -5483,9 +6498,14 @@ class PointerDragHandler {
5483
6498
  else {
5484
6499
  position = { x: event.clientX, y: event.clientY };
5485
6500
  }
5486
- const ctx = this.#deps.getContext();
5487
6501
  // Check if we've moved past the threshold
5488
6502
  if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
6503
+ // Read only before the drag starts, not on every move of a drag: nothing in it matters then
6504
+ const ctx = this.#deps.getContext();
6505
+ if (this.#isStalePress(ctx)) {
6506
+ this.cleanup();
6507
+ return;
6508
+ }
5489
6509
  const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
5490
6510
  Math.pow(position.y - this.#startPosition.y, 2));
5491
6511
  if (distance < ctx.dragThreshold) {
@@ -5537,13 +6557,25 @@ class PointerDragHandler {
5537
6557
  }
5538
6558
  // Only prevent default if we were actually dragging
5539
6559
  // 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');
6560
+ // The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
6561
+ try {
6562
+ if (this.#deps.callbacks.isDragging()) {
6563
+ event.preventDefault();
6564
+ // The system took the touch (an incoming call, an OS gesture, too many fingers): the
6565
+ // user never released, so this is a cancel, not a drop
6566
+ this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
6567
+ }
5545
6568
  }
5546
- this.cleanup();
6569
+ finally {
6570
+ this.cleanup();
6571
+ }
6572
+ }
6573
+ /**
6574
+ * Whether the tracked press no longer applies to the draggable: it renders another item now, or
6575
+ * it left the page (a virtual list recycled or pooled its row).
6576
+ */
6577
+ #isStalePress(ctx) {
6578
+ return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
5547
6579
  }
5548
6580
  /**
5549
6581
  * Cancel the delay timer if active.
@@ -5560,9 +6592,12 @@ class PointerDragHandler {
5560
6592
  */
5561
6593
  #onEscapeKeyDown(event) {
5562
6594
  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();
6595
+ try {
6596
+ this.#deps.callbacks.onDragEnd(true);
6597
+ }
6598
+ finally {
6599
+ this.cleanup();
6600
+ }
5566
6601
  }
5567
6602
  }
5568
6603
  /**
@@ -5572,10 +6607,14 @@ class PointerDragHandler {
5572
6607
  if (!this.#isTracking) {
5573
6608
  return;
5574
6609
  }
5575
- if (this.#deps.callbacks.isDragging()) {
5576
- this.#deps.callbacks.onDragEnd(true);
6610
+ try {
6611
+ if (this.#deps.callbacks.isDragging()) {
6612
+ this.#deps.callbacks.onDragEnd(true);
6613
+ }
6614
+ }
6615
+ finally {
6616
+ this.cleanup();
5577
6617
  }
5578
- this.cleanup();
5579
6618
  }
5580
6619
  /**
5581
6620
  * Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
@@ -5587,10 +6626,20 @@ class PointerDragHandler {
5587
6626
  }
5588
6627
  /**
5589
6628
  * 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.
6629
+ * leave a list undefined, so a missing list counts as empty. Read by index, without copying the
6630
+ * list or creating an iterator: this runs for every touch move.
5591
6631
  */
5592
6632
  #trackedTouch(touches) {
5593
- return Array.from(touches ?? []).find((touch) => touch.identifier === this.#touchId) ?? null;
6633
+ if (!touches) {
6634
+ return null;
6635
+ }
6636
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
6637
+ for (let i = 0; i < touches.length; i++) {
6638
+ if (touches[i].identifier === this.#touchId) {
6639
+ return touches[i];
6640
+ }
6641
+ }
6642
+ return null;
5594
6643
  }
5595
6644
  /**
5596
6645
  * The finger that pressed this draggable: of the touches that started with this event, the
@@ -5604,6 +6653,26 @@ class PointerDragHandler {
5604
6653
  }
5605
6654
  }
5606
6655
 
6656
+ /** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
6657
+ const KEY_NAMES = {
6658
+ ' ': 'space',
6659
+ Esc: 'escape',
6660
+ '\x1B': 'escape',
6661
+ Up: 'arrowup',
6662
+ Down: 'arrowdown',
6663
+ Left: 'arrowleft',
6664
+ Right: 'arrowright',
6665
+ };
6666
+ /** The keys an item handles */
6667
+ const HANDLED_KEYS = new Set([
6668
+ 'space',
6669
+ 'enter',
6670
+ 'arrowup',
6671
+ 'arrowdown',
6672
+ 'arrowleft',
6673
+ 'arrowright',
6674
+ 'escape',
6675
+ ]);
5607
6676
  /**
5608
6677
  * Makes an element draggable within the virtual scroll drag-and-drop system.
5609
6678
  *
@@ -5680,9 +6749,16 @@ class DraggableDirective {
5680
6749
  dragStart = output();
5681
6750
  /** Emits when drag ends */
5682
6751
  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();
6752
+ /** The item whose press is ready to drag (its delay has passed) */
6753
+ #pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
6754
+ /**
6755
+ * Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
6756
+ * item: a recycled row that renders another item is not ready to drag.
6757
+ */
6758
+ isPending = computed(() => {
6759
+ const pendingId = this.#pendingId();
6760
+ return pendingId !== null && pendingId === this.vdndDraggable();
6761
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
5686
6762
  /** Whether this element is currently being dragged (based on global drag state) */
5687
6763
  isDragging = computed(() => {
5688
6764
  const draggedItem = this.#dragState.draggedItem();
@@ -5692,6 +6768,62 @@ class DraggableDirective {
5692
6768
  #pointerHandler;
5693
6769
  /** Set by ngOnInit, which creates the handlers */
5694
6770
  #initialized = false;
6771
+ /**
6772
+ * Keys pressed on the item: one listener, added outside Angular's zone, instead of a
6773
+ * `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
6774
+ * Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
6775
+ * of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
6776
+ * default action when the handler returns false, and reports what the handler throws to the
6777
+ * ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
6778
+ * as host listeners do.
6779
+ */
6780
+ #onKeydown = (event) => {
6781
+ if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
6782
+ return;
6783
+ }
6784
+ const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
6785
+ if (!HANDLED_KEYS.has(key)) {
6786
+ return;
6787
+ }
6788
+ this.#ngZone.run(() => {
6789
+ try {
6790
+ if (this.#handleKey(key, event) === false) {
6791
+ event.preventDefault();
6792
+ }
6793
+ }
6794
+ catch (error) {
6795
+ this.#envInjector.get(ErrorHandler).handleError(error);
6796
+ }
6797
+ });
6798
+ };
6799
+ /**
6800
+ * Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
6801
+ * without options, so non-passive): a swipe that starts on an element with a non-passive
6802
+ * touchstart listener can't scroll until the main thread has run it. With a drag delay the press
6803
+ * never cancels the scroll (the page must scroll when the user swipes before the delay passes),
6804
+ * so the listener is passive; without one it prevents the default action and is non-passive.
6805
+ * Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
6806
+ * does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
6807
+ * a host listener it runs after the element's template listeners.
6808
+ */
6809
+ #onTouchStart = (event) => {
6810
+ if (!this.#initialized) {
6811
+ return;
6812
+ }
6813
+ this.#ngZone.run(() => {
6814
+ try {
6815
+ this.onPointerDown(event, true);
6816
+ }
6817
+ catch (error) {
6818
+ this.#envInjector.get(ErrorHandler).handleError(error);
6819
+ }
6820
+ });
6821
+ };
6822
+ /** Whether the touchstart listener is passive, or null while it isn't added */
6823
+ #touchStartPassive = null;
6824
+ constructor() {
6825
+ this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
6826
+ }
5695
6827
  /** Cached constraint flag from source droppable */
5696
6828
  #constrainToContainer = false;
5697
6829
  /** Element whose rect is used for clamping (scroll container or droppable itself) */
@@ -5702,9 +6834,7 @@ class DraggableDirective {
5702
6834
  * Update the pending state and emit the change event.
5703
6835
  */
5704
6836
  #setPending(pending) {
5705
- if (this.#isPending() !== pending) {
5706
- this.#isPending.set(pending);
5707
- }
6837
+ this.#pendingId.set(pending ? this.vdndDraggable() : null);
5708
6838
  }
5709
6839
  /**
5710
6840
  * Find the element to use for container constraint clamping.
@@ -5729,7 +6859,8 @@ class DraggableDirective {
5729
6859
  ngZone: this.#ngZone,
5730
6860
  envInjector: this.#envInjector,
5731
6861
  callbacks: {
5732
- onDragStart: (event) => this.dragStart.emit(event),
6862
+ onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
6863
+ // The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
5733
6864
  onDragEnd: (event) => this.dragEnd.emit(event),
5734
6865
  getParentDroppableId: () => this.#getParentDroppableId(),
5735
6866
  calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
@@ -5758,6 +6889,7 @@ class DraggableDirective {
5758
6889
  },
5759
6890
  getContext: () => ({
5760
6891
  element: this.#elementRef.nativeElement,
6892
+ draggableId: this.vdndDraggable(),
5761
6893
  groupName: this.#effectiveGroup(),
5762
6894
  disabled: this.disabled(),
5763
6895
  dragHandle: this.dragHandle(),
@@ -5766,8 +6898,14 @@ class DraggableDirective {
5766
6898
  }),
5767
6899
  });
5768
6900
  this.#initialized = true;
6901
+ this.#listenForTouchStart();
6902
+ }
6903
+ ngOnChanges() {
6904
+ this.#listenForTouchStart();
5769
6905
  }
5770
6906
  ngOnDestroy() {
6907
+ this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
6908
+ this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
5771
6909
  // Destroyed before its first change detection: there are no handlers yet, and its inputs
5772
6910
  // have no values (reading a bound ID would throw).
5773
6911
  if (!this.#initialized) {
@@ -5780,6 +6918,35 @@ class DraggableDirective {
5780
6918
  this.#pointerHandler.destroy();
5781
6919
  this.#keyboardHandler.destroy();
5782
6920
  }
6921
+ /**
6922
+ * Add the touchstart listener, or re-add it when the drag delay changes whether it can be
6923
+ * passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
6924
+ * that visits every ancestor, and a virtual list creates rows on each scroll step.
6925
+ */
6926
+ #listenForTouchStart() {
6927
+ // The handler cancels the scroll of a touch press only when there is no delay
6928
+ const passive = this.dragDelay() !== 0;
6929
+ if (passive === this.#touchStartPassive) {
6930
+ return;
6931
+ }
6932
+ const element = this.#elementRef.nativeElement;
6933
+ element.removeEventListener('touchstart', this.#onTouchStart);
6934
+ this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
6935
+ this.#touchStartPassive = passive;
6936
+ }
6937
+ /** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
6938
+ #handleKey(key, event) {
6939
+ switch (key) {
6940
+ case 'space':
6941
+ return this.onKeyboardActivate(event);
6942
+ case 'enter':
6943
+ return this.onEnterKey(event);
6944
+ case 'escape':
6945
+ return this.onEscape();
6946
+ default:
6947
+ return this.onArrowKey(event);
6948
+ }
6949
+ }
5783
6950
  /**
5784
6951
  * Handle pointer down (mouse or touch).
5785
6952
  */
@@ -5946,7 +7113,7 @@ class DraggableDirective {
5946
7113
  height: rect.height,
5947
7114
  width: rect.width,
5948
7115
  data: this.vdndDraggableData(),
5949
- }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined);
7116
+ }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined, this.#pointerHandler.isTouchPress());
5950
7117
  // Start the scheduler RAF loop (drives pointer-move updates + autoscroll participant).
5951
7118
  // onTick is called each frame: when cursor is dirty, run full #updateDrag.
5952
7119
  this.#scheduler.start((cursor, cursorDirty) => {
@@ -5957,13 +7124,16 @@ class DraggableDirective {
5957
7124
  // Start auto-scroll monitoring — registers as a scheduler participant.
5958
7125
  this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
5959
7126
  // Emit drag start event
5960
- this.dragStart.emit({
7127
+ // Pointer listeners run outside Angular's zone. With zone.js a template listener marks
7128
+ // its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
7129
+ const event = {
5961
7130
  draggableId: this.vdndDraggable(),
5962
7131
  droppableId: parentDroppableId ?? '',
5963
7132
  data: this.vdndDraggableData(),
5964
7133
  position,
5965
7134
  sourceIndex,
5966
- });
7135
+ };
7136
+ this.#ngZone.run(() => this.dragStart.emit(event));
5967
7137
  }
5968
7138
  /**
5969
7139
  * Calculate the source index of the dragged element BEFORE it's hidden.
@@ -6179,22 +7349,27 @@ class DraggableDirective {
6179
7349
  sourceDroppableId: this.#dragState.sourceDroppableId(),
6180
7350
  activeDroppableId: this.#dragState.activeDroppableId(),
6181
7351
  });
6182
- // Emit drag end event
6183
- this.dragEnd.emit({
7352
+ const event = {
6184
7353
  draggableId: this.vdndDraggable(),
6185
7354
  droppableId: this.#getParentDroppableId() ?? '',
6186
7355
  cancelled,
6187
7356
  data: this.vdndDraggableData(),
6188
7357
  sourceIndex,
6189
7358
  destinationIndex,
7359
+ };
7360
+ // dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
7361
+ // outside Angular's zone, and with zone.js each entry renders when it is left, so
7362
+ // separate entries would render the drag end twice. A no-op when zoneless.
7363
+ this.#ngZone.run(() => {
7364
+ this.dragEnd.emit(event);
7365
+ // Clear drag state - this triggers isDragging computed to become false
7366
+ if (cancelled) {
7367
+ this.#dragState.cancelDrag();
7368
+ }
7369
+ else {
7370
+ this.#dragState.endDrag();
7371
+ }
6190
7372
  });
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
7373
  }
6199
7374
  /**
6200
7375
  * Get the parent droppable ID.
@@ -6208,7 +7383,7 @@ class DraggableDirective {
6208
7383
  return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
6209
7384
  }
6210
7385
  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 });
7386
+ 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
7387
  }
6213
7388
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
6214
7389
  type: Directive,
@@ -6224,17 +7399,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
6224
7399
  '[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
6225
7400
  '[tabindex]': 'disabled() ? -1 : 0',
6226
7401
  '(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
7402
  },
6236
7403
  }]
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"] }] } });
7404
+ }], 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
7405
 
6239
7406
  /**
6240
7407
  * Directive that marks an element as a scrollable container for virtual scrolling.
@@ -6548,38 +7715,47 @@ class VirtualForDirective {
6548
7715
  const end = Math.min(total - 1, first + visible + overscan);
6549
7716
  return { start, end };
6550
7717
  }, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
6551
- /** Map of trackBy keys to item indices for quick lookup */
6552
- #itemIndexMap = computed(() => {
6553
- const items = this.vdndVirtualForOf();
7718
+ /**
7719
+ * Every item's track key, in order. Computed once per items change, and only when something
7720
+ * reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
7721
+ */
7722
+ #itemKeys = computed(() => {
6554
7723
  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) */
7724
+ return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
7725
+ }, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
7726
+ /**
7727
+ * Index of the dragged item in this list (-1 if not present or not dragging). The index where
7728
+ * the drag found its item is checked first: it spares a search of every track key.
7729
+ */
6562
7730
  #draggedItemIndex = computed(() => {
6563
7731
  const draggedItem = this.#dragState.draggedItem();
6564
7732
  if (!draggedItem)
6565
7733
  return -1;
6566
- const byId = this.#itemIndexMap().get(draggedItem.draggableId);
6567
- if (byId !== undefined) {
7734
+ const items = this.vdndVirtualForOf();
7735
+ const sourceIndex = this.#dragState.sourceIndex();
7736
+ if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
7737
+ const item = items[sourceIndex];
7738
+ if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
7739
+ (draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
7740
+ return sourceIndex;
7741
+ }
7742
+ }
7743
+ // The last item with the key, as an index map filled in order would give
7744
+ const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
7745
+ if (byId >= 0) {
6568
7746
  return byId;
6569
7747
  }
6570
7748
  const data = draggedItem.data;
6571
7749
  if (data !== null && data !== undefined) {
6572
- return this.vdndVirtualForOf().indexOf(data);
7750
+ return items.indexOf(data);
6573
7751
  }
6574
7752
  return -1;
6575
7753
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
6576
7754
  constructor() {
6577
- // Keep strategy item keys in sync
7755
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
7756
+ // spares computing every item's track key on each items change (each drop).
6578
7757
  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);
7758
+ setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
6583
7759
  });
6584
7760
  // Register strategy with drag index calculator for accurate position lookups
6585
7761
  effect((onCleanup) => {
@@ -6595,12 +7771,13 @@ class VirtualForDirective {
6595
7771
  // correctly skip the hidden item — not just findIndexAtOffset.
6596
7772
  effect(() => {
6597
7773
  const strategy = this.#strategy();
6598
- const draggedIndex = this.#draggedItemIndex();
6599
7774
  const droppableId = this.#effectiveDroppableId();
6600
7775
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6601
7776
  const isDragging = this.#dragState.isDragging();
6602
7777
  const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
6603
- if (isSourceList && draggedIndex >= 0) {
7778
+ // Only the source list looks for the dragged item (in all its keys)
7779
+ const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
7780
+ if (draggedIndex >= 0) {
6604
7781
  strategy.setExcludedIndex(draggedIndex);
6605
7782
  }
6606
7783
  else {
@@ -6799,14 +7976,12 @@ class VirtualForDirective {
6799
7976
  strategy.version();
6800
7977
  const placeholderIndex = this.#placeholderIndex();
6801
7978
  const showPlaceholder = this.#shouldShowPlaceholder();
6802
- const draggedIndex = this.#draggedItemIndex();
6803
7979
  const droppableId = this.#effectiveDroppableId();
6804
7980
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6805
7981
  const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
6806
- const shouldKeepDragged = this.#dragState.isDragging() &&
6807
- draggedIndex >= 0 &&
6808
- isSourceList &&
6809
- draggedIndex < items.length;
7982
+ // Only the source list keeps the dragged item rendered, so only it looks for the item
7983
+ const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
7984
+ const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
6810
7985
  // Snapshot positions before the DOM changes so displaced items can slide
6811
7986
  this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
6812
7987
  // Notify viewport of render start index for wrapper positioning
@@ -6916,14 +8091,8 @@ class VirtualForDirective {
6916
8091
  // Move unused views to pool
6917
8092
  for (const [key, view] of this.#activeViews) {
6918
8093
  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
8094
  this.#activeViews.delete(key);
8095
+ this.#poolView(view);
6927
8096
  }
6928
8097
  }
6929
8098
  // Remove placeholder from DOM if not needed
@@ -6935,6 +8104,7 @@ class VirtualForDirective {
6935
8104
  let viewContainerIndex = 0;
6936
8105
  let placeholderDomPosition = -1;
6937
8106
  const renderedKeys = new Set();
8107
+ const changedViews = [];
6938
8108
  for (const entry of itemsToRender) {
6939
8109
  if (entry.type === 'placeholder') {
6940
8110
  placeholderDomPosition = viewContainerIndex;
@@ -6945,7 +8115,7 @@ class VirtualForDirective {
6945
8115
  continue;
6946
8116
  }
6947
8117
  renderedKeys.add(entry.key);
6948
- const view = this.#getOrCreateView(entry.key, entry.context);
8118
+ const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
6949
8119
  // Ensure view is at correct position in ViewContainerRef
6950
8120
  const currentIndex = this.#viewContainer.indexOf(view);
6951
8121
  if (currentIndex !== viewContainerIndex) {
@@ -6970,8 +8140,33 @@ class VirtualForDirective {
6970
8140
  }
6971
8141
  viewContainerIndex++;
6972
8142
  }
8143
+ this.#renderChangedViews(changedViews);
6973
8144
  return placeholderDomPosition;
6974
8145
  }
8146
+ /**
8147
+ * Take a row's view out of the container and pool it. It keeps its element, so its shift
8148
+ * animation is cancelled (the item it renders next must not slide from this one's position),
8149
+ * and a drop animation that hides it shows it again.
8150
+ */
8151
+ #poolView(view) {
8152
+ const index = this.#viewContainer.indexOf(view);
8153
+ if (index >= 0) {
8154
+ this.#viewContainer.detach(index);
8155
+ }
8156
+ this.#unobserveViewElements(view);
8157
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
8158
+ // the animation ends: in the row of another item by then, if the view were reused. A row
8159
+ // whose element is dragged (its draggable ID is not its track key) is destroyed too, which
8160
+ // cancels the drag instead of handing it to another item.
8161
+ const draggedElement = this.#dragState.draggedItem()?.element;
8162
+ if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
8163
+ view.destroy();
8164
+ return;
8165
+ }
8166
+ this.#cancelShiftAnimation(view);
8167
+ revealDropTargetIn(view.rootNodes);
8168
+ this.#viewPool.push(view);
8169
+ }
6975
8170
  /**
6976
8171
  * Clamp target indices for move/insert to avoid out-of-range operations when
6977
8172
  * reconciliation is given invalid duplicate keys or transiently inconsistent view state.
@@ -6997,28 +8192,59 @@ class VirtualForDirective {
6997
8192
  }
6998
8193
  /**
6999
8194
  * Get an existing view or create/recycle one from the pool.
8195
+ * Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
8196
+ * changed context.
7000
8197
  */
7001
- #getOrCreateView(key, context) {
7002
- let view = this.#activeViews.get(key);
8198
+ #getOrCreateView(key, context, changedViews) {
8199
+ const activeView = this.#activeViews.get(key);
8200
+ let view = activeView ?? this.#viewPool.pop();
7003
8201
  if (view) {
7004
- // Update existing view context
7005
- Object.assign(view.context, context);
7006
- view.markForCheck();
8202
+ // A pooled view was detached, so change detection skipped it: render it even when it
8203
+ // comes back with the same context, as what its template shows may have changed since
8204
+ if (this.#updateContext(view.context, context) || !activeView) {
8205
+ changedViews.push(view);
8206
+ }
7007
8207
  }
7008
8208
  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);
8209
+ view = this.#templateRef.createEmbeddedView(context);
8210
+ changedViews.push(view);
7019
8211
  }
8212
+ this.#activeViews.set(key, view);
7020
8213
  return view;
7021
8214
  }
8215
+ /** Copy the fields of `next` into `context`. Returns whether any of them changed. */
8216
+ #updateContext(context, next) {
8217
+ if (Object.is(context.$implicit, next.$implicit) &&
8218
+ context.index === next.index &&
8219
+ context.first === next.first &&
8220
+ context.last === next.last &&
8221
+ context.count === next.count) {
8222
+ return false;
8223
+ }
8224
+ context.$implicit = next.$implicit;
8225
+ context.index = next.index;
8226
+ context.first = next.first;
8227
+ context.last = next.last;
8228
+ context.count = next.count;
8229
+ return true;
8230
+ }
8231
+ /**
8232
+ * Render the views whose context changed, and only those. `markForCheck()` would mark every
8233
+ * ancestor view up to the root as well, re-rendering all of them (and every row of the list)
8234
+ * on each scroll step. Untracked, so the rows' signal reads do not become this effect's
8235
+ * dependencies.
8236
+ */
8237
+ #renderChangedViews(changedViews) {
8238
+ if (changedViews.length === 0)
8239
+ return;
8240
+ untracked(() => {
8241
+ for (const view of changedViews) {
8242
+ if (!view.destroyed) {
8243
+ view.detectChanges();
8244
+ }
8245
+ }
8246
+ });
8247
+ }
7022
8248
  /**
7023
8249
  * Apply absolute positioning styles to a view's root nodes.
7024
8250
  */