ngx-virtual-dnd 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, afterNextRender, ElementRef, Injector, untracked, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
- import { NgTemplateOutlet } from '@angular/common';
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
+ import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
6
6
  * Initial state for the drag state service.
@@ -21,7 +21,10 @@ const INITIAL_DRAG_STATE = {
21
21
  keyboardTargetIndex: null,
22
22
  };
23
23
  /**
24
- * Placeholder ID used when dropping at the end of a list.
24
+ * The value of every `placeholderId` the library sets. Positions are reported as indexes
25
+ * (`placeholderIndex`, `DropDestination.index`).
26
+ * @deprecated Goes with the deprecated `placeholderId` fields. Use the indexes. Will be removed in
27
+ * the next major version.
25
28
  */
26
29
  const END_OF_LIST = 'END_OF_LIST';
27
30
 
@@ -72,9 +75,16 @@ class FixedHeightStrategy {
72
75
  // No-op for fixed height strategy
73
76
  }
74
77
  setItemKeys(keys) {
75
- const prevCount = this.#itemCount;
76
- this.#itemCount = keys.length;
77
- 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;
78
88
  this.#version.update((v) => v + 1);
79
89
  }
80
90
  }
@@ -105,8 +115,15 @@ class FixedHeightStrategy {
105
115
  * Cache for measured item heights in dynamic virtual scrolling.
106
116
  *
107
117
  * Tracks heights by trackBy key (survives reordering). Maintains a cumulative
108
- * offset prefix-sum array, lazily rebuilt on height change. Binary search
109
- * 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.
110
127
  *
111
128
  * This is a plain class (not a service) — one instance per virtual scroll.
112
129
  */
@@ -115,12 +132,21 @@ class HeightCache {
115
132
  #estimatedHeight;
116
133
  /** Map from trackBy key to measured height */
117
134
  #heightsByKey = new Map();
118
- /** 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
+ */
119
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;
120
144
  /** Prefix sums: offsets[i] = cumulative height of items 0..i-1 */
121
145
  #offsets = [];
122
- /** Whether offsets need to be rebuilt */
123
- #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;
124
150
  /** Index excluded during same-list drag (-1 = none) */
125
151
  #excludedIndex = -1;
126
152
  constructor(estimatedHeight) {
@@ -139,21 +165,31 @@ class HeightCache {
139
165
  * This must be called whenever items change order or the array changes.
140
166
  */
141
167
  setKeys(keys) {
142
- const sameOrder = keys.length === this.#keys.length &&
143
- keys.every((key, index) => Object.is(key, this.#keys[index]));
144
- const nextKeySet = new Set(keys);
145
- let pruned = false;
146
- for (const key of this.#heightsByKey.keys()) {
147
- if (!nextKeySet.has(key)) {
148
- this.#heightsByKey.delete(key);
149
- pruned = true;
150
- }
151
- }
152
- if (sameOrder && !pruned) {
153
- return false;
154
- }
155
- this.#keys = keys;
156
- 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;
157
193
  return true;
158
194
  }
159
195
  /**
@@ -165,7 +201,21 @@ class HeightCache {
165
201
  if (existing === height)
166
202
  return false;
167
203
  this.#heightsByKey.set(key, height);
168
- 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
+ }
169
219
  return true;
170
220
  }
171
221
  /**
@@ -183,18 +233,18 @@ class HeightCache {
183
233
  * Accounts for the excluded index during same-list drag.
184
234
  */
185
235
  getOffset(index) {
186
- this.#rebuildIfDirty();
236
+ const count = this.#keys.length;
187
237
  if (this.#excludedIndex < 0) {
188
238
  // No exclusion — direct lookup
189
- return index < this.#offsets.length ? this.#offsets[index] : this.#getTotalHeightRaw();
239
+ return index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
190
240
  }
191
241
  // With exclusion: subtract the excluded item's height from offsets
192
242
  // at or after the excluded index
193
243
  if (index <= this.#excludedIndex) {
194
- return this.#offsets[index] ?? 0;
244
+ return this.#offsetAt(index) ?? 0;
195
245
  }
196
246
  // index > excludedIndex: offset = raw offset for (index) minus excluded item's height
197
- const rawOffset = index < this.#offsets.length ? this.#offsets[index] : this.#getTotalHeightRaw();
247
+ const rawOffset = index < count ? this.#offsetAt(index) : this.#getExactTotalHeight();
198
248
  const excludedHeight = this.getHeight(this.#excludedIndex);
199
249
  return rawOffset - excludedHeight;
200
250
  }
@@ -205,7 +255,6 @@ class HeightCache {
205
255
  * Uses O(1) prefix-sum lookup when possible.
206
256
  */
207
257
  getTotalHeight(itemCount) {
208
- this.#rebuildIfDirty();
209
258
  if (itemCount <= 0)
210
259
  return 0;
211
260
  const keyCount = this.#keys.length;
@@ -216,20 +265,24 @@ class HeightCache {
216
265
  return this.#getTotalHeightRaw() + (itemCount - keyCount) * this.#estimatedHeight;
217
266
  }
218
267
  // Partial: prefix sum up to itemCount
219
- return this.#offsets[itemCount - 1] + this.getHeight(itemCount - 1);
268
+ return this.#offsetAt(itemCount - 1) + this.getHeight(itemCount - 1);
220
269
  }
221
270
  /**
222
271
  * Find the first visible index for a given scrollTop.
223
272
  * Uses binary search on the prefix-sum offsets (excluding the excluded item).
224
273
  */
225
274
  findFirstVisibleIndex(scrollTop) {
226
- this.#rebuildIfDirty();
227
275
  const count = this.#keys.length;
228
276
  if (count === 0)
229
277
  return 0;
230
- // 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.
231
284
  let lo = 0;
232
- let hi = count - 1;
285
+ let hi = Math.min(count, this.#validCount) - 1;
233
286
  let result = 0;
234
287
  while (lo <= hi) {
235
288
  const mid = (lo + hi) >>> 1;
@@ -275,7 +328,6 @@ class HeightCache {
275
328
  * Used for drag index calculations with variable heights.
276
329
  */
277
330
  findIndexAtOffset(offset) {
278
- this.#rebuildIfDirty();
279
331
  const count = this.#keys.length;
280
332
  if (count === 0)
281
333
  return 0;
@@ -292,7 +344,7 @@ class HeightCache {
292
344
  if (excludedIndex < 0 || excludedIndex >= count) {
293
345
  return this.#findFirstIndexWithBottomPastOffset(offset, 0, count - 1);
294
346
  }
295
- const excludedOffset = this.#offsets[excludedIndex];
347
+ const excludedOffset = this.#offsetAt(excludedIndex);
296
348
  if (offset < excludedOffset && excludedIndex > 0) {
297
349
  return this.#findFirstIndexWithBottomPastOffset(offset, 0, excludedIndex - 1);
298
350
  }
@@ -313,6 +365,17 @@ class HeightCache {
313
365
  this.#excludedIndex = nextIndex;
314
366
  return true;
315
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
+ }
316
379
  /**
317
380
  * Find first logical index in [start, end] whose bottom edge is past offset.
318
381
  * Returns one-past-end when no match is found.
@@ -321,8 +384,15 @@ class HeightCache {
321
384
  if (start > end) {
322
385
  return start;
323
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
+ }
324
394
  let lo = start;
325
- let hi = end;
395
+ let hi = Math.min(end, lastValid);
326
396
  let result = end + 1;
327
397
  while (lo <= hi) {
328
398
  const mid = (lo + hi) >>> 1;
@@ -337,27 +407,64 @@ class HeightCache {
337
407
  }
338
408
  return result;
339
409
  }
340
- /** 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
+ */
341
414
  #getTotalHeightRaw() {
342
- this.#rebuildIfDirty();
343
- const len = this.#offsets.length;
344
- if (len === 0)
415
+ if (this.#keys.length === 0)
345
416
  return 0;
346
- return this.#offsets[len - 1] + this.getHeight(len - 1);
417
+ return this.#totalHeight ?? this.#getExactTotalHeight();
347
418
  }
348
- /** Rebuild prefix-sum array if dirty */
349
- #rebuildIfDirty() {
350
- if (!this.#dirty)
351
- return;
352
- 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() {
353
424
  const count = this.#keys.length;
354
- if (this.#offsets.length !== count) {
355
- 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;
356
454
  }
357
- let cumulative = 0;
358
- 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) {
359
457
  this.#offsets[i] = cumulative;
360
- 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);
361
468
  }
362
469
  }
363
470
  }
@@ -451,6 +558,14 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
451
558
  * a viewport component that handles wrapper-based positioning.
452
559
  */
453
560
  const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
561
+ /**
562
+ * Internal, not exported from the package. Provided by vdnd-virtual-viewport next to
563
+ * VDND_VIRTUAL_VIEWPORT: its rows start contentOffset px down its scroll area while its
564
+ * scrollTop() is the raw position, so VirtualForDirective subtracts the offset when the viewport
565
+ * it injects is this one. A token instead of an instanceof check keeps the viewport component out
566
+ * of apps that don't use it.
567
+ */
568
+ const VDND_OFFSET_ROWS_VIEWPORT = new InjectionToken('VDND_OFFSET_ROWS_VIEWPORT');
454
569
 
455
570
  /** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
456
571
  const DEFAULT_SHIFT_DURATION = 200;
@@ -476,6 +591,168 @@ const DEFAULT_DROP_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
476
591
  */
477
592
  const VDND_ANIMATION_CONFIG = new InjectionToken('VDND_ANIMATION_CONFIG');
478
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
+
479
756
  const INITIAL_CORE_STATE = {
480
757
  isDragging: false,
481
758
  draggedItem: null,
@@ -498,6 +775,7 @@ const INITIAL_CORE_STATE = {
498
775
  * draggedItem, etc.).
499
776
  */
500
777
  class DragStateService {
778
+ #droppableRegistry = inject(DroppableRegistryService);
501
779
  /** Core state signal — contains all rarely-changing properties */
502
780
  #state = signal(INITIAL_CORE_STATE, ...(ngDevMode ? [{ debugName: "#state" }] : []));
503
781
  /** High-frequency cursor position — updated at 60fps during pointer drag */
@@ -506,7 +784,7 @@ class DragStateService {
506
784
  #keyboardTargetIndex = signal(null, ...(ngDevMode ? [{ debugName: "#keyboardTargetIndex" }] : []));
507
785
  /** ID of the droppable currently being hovered over — changes on droppable crossings */
508
786
  #activeDroppableId = signal(null, ...(ngDevMode ? [{ debugName: "#activeDroppableId" }] : []));
509
- /** ID of the item the placeholder should appear before */
787
+ /** `END_OF_LIST` while a droppable is targeted, otherwise null; see placeholderIndex */
510
788
  #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
511
789
  /** Index where the placeholder should be inserted — updated on each placeholder move */
512
790
  #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
@@ -514,7 +792,7 @@ class DragStateService {
514
792
  #endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
515
793
  /** Flag indicating if the last drag was cancelled (not dropped) */
516
794
  #wasCancelled = signal(false, ...(ngDevMode ? [{ debugName: "#wasCancelled" }] : []));
517
- /** Whether the last drag was cancelled (for droppable to check before emitting drop) */
795
+ /** Whether the last drag was cancelled (a cancelled drag delivers no drop) */
518
796
  wasCancelled = this.#wasCancelled.asReadonly();
519
797
  /** Snapshot captured synchronously before the last drag reset. */
520
798
  endedDragState = this.#endedDragState.asReadonly();
@@ -530,7 +808,11 @@ class DragStateService {
530
808
  sourceIndex = computed(() => this.#state().sourceIndex, ...(ngDevMode ? [{ debugName: "sourceIndex" }] : []));
531
809
  /** ID of the droppable currently being hovered over */
532
810
  activeDroppableId = this.#activeDroppableId.asReadonly();
533
- /** ID of the item the placeholder should appear before */
811
+ /**
812
+ * `END_OF_LIST` while a droppable is targeted, otherwise null.
813
+ * @deprecated It never identifies an item. Use `placeholderIndex`. Will be removed in the next
814
+ * major version.
815
+ */
534
816
  placeholderId = this.#placeholderId.asReadonly();
535
817
  /** Index where the placeholder should be inserted */
536
818
  placeholderIndex = this.#placeholderIndex.asReadonly();
@@ -572,6 +854,7 @@ class DragStateService {
572
854
  }
573
855
  /**
574
856
  * Start a drag operation.
857
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
575
858
  */
576
859
  startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
577
860
  // Reset terminal drag metadata at start of new drag
@@ -595,6 +878,7 @@ class DragStateService {
595
878
  }
596
879
  /**
597
880
  * Update the drag position and targets.
881
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
598
882
  */
599
883
  updateDragPosition(update) {
600
884
  if (!this.#state().isDragging) {
@@ -619,7 +903,9 @@ class DragStateService {
619
903
  this.#activeDroppableId.set(droppableId);
620
904
  }
621
905
  /**
622
- * Update just the placeholder position.
906
+ * Update just the placeholder ID.
907
+ * @deprecated Sets only the deprecated `placeholderId`. Will be removed in the next major
908
+ * version.
623
909
  */
624
910
  setPlaceholder(placeholderId) {
625
911
  if (!this.#state().isDragging) {
@@ -633,6 +919,7 @@ class DragStateService {
633
919
  * Used by the autoscroll scroll-only fast path: when a scroll fires, the cursor has not
634
920
  * moved, so only the placeholder index needs recalculation. Skipping the cursor and
635
921
  * active-droppable writes avoids spurious re-evaluation of computeds that read those signals.
922
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
636
923
  */
637
924
  updateScrollOnlyPlaceholder(placeholderId, placeholderIndex) {
638
925
  if (!this.#state().isDragging) {
@@ -642,18 +929,34 @@ class DragStateService {
642
929
  this.#placeholderIndex.set(placeholderIndex);
643
930
  }
644
931
  /**
645
- * End the drag operation and reset state (normal drop).
932
+ * End the drag operation and reset state (normal drop), then deliver the drop: the target
933
+ * droppable (`activeDroppableId`) emits `drop` before this returns.
646
934
  */
647
935
  endDrag() {
648
- this.#endedDragState.set(this.getStateSnapshot());
936
+ // Only a drag in progress ends. Again (e.g. from a drop handler) it would overwrite the
937
+ // ended drag, which the drop animation reads, with an empty one.
938
+ if (!this.#state().isDragging) {
939
+ return;
940
+ }
941
+ const endedState = this.getStateSnapshot();
942
+ this.#endedDragState.set(endedState);
649
943
  this.#wasCancelled.set(false);
650
944
  this.#resetHighFrequencySignals();
651
945
  this.#state.set(INITIAL_CORE_STATE);
946
+ // After the reset, so the drop handler sees the drag as over. Delivered here rather than
947
+ // from a droppable effect on the next change detection: by then the target may already
948
+ // be destroyed or re-created (a drop zone rendered only while dragging, a list replaced
949
+ // by a (dragEnd) handler), and every other droppable would have to tell a new drop from
950
+ // an old one.
951
+ this.#droppableRegistry.deliverDrop(endedState);
652
952
  }
653
953
  /**
654
954
  * Cancel the drag operation (escape key, disabled, etc.).
655
955
  */
656
956
  cancelDrag() {
957
+ if (!this.#state().isDragging) {
958
+ return;
959
+ }
657
960
  this.#endedDragState.set(this.getStateSnapshot());
658
961
  this.#wasCancelled.set(true);
659
962
  this.#resetHighFrequencySignals();
@@ -740,32 +1043,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
740
1043
  }]
741
1044
  }], ctorParameters: () => [] });
742
1045
 
743
- /**
744
- * Query by an exact attribute value without interpolating consumer data into a CSS selector.
745
- *
746
- * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
747
- * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
748
- * quotes, brackets, backslashes, or other selector-sensitive characters.
749
- */
750
- function queryByAttribute(root, attributeName, attributeValue) {
751
- const candidates = root.querySelectorAll(`[${attributeName}]`);
752
- for (const candidate of candidates) {
753
- if (candidate.getAttribute(attributeName) === attributeValue) {
754
- return candidate;
755
- }
756
- }
757
- return null;
1046
+ /** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
1047
+ function isPageRoot(element) {
1048
+ return element.tagName === 'BODY' || element.tagName === 'HTML';
758
1049
  }
759
- function queryAllByAttribute(root, attributeName, attributeValue) {
760
- return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
761
- }
762
-
763
1050
  /**
764
1051
  * Service for calculating drop positions and finding elements at cursor positions.
765
1052
  * This is the core algorithm that makes virtual scroll + drag-and-drop work together.
766
1053
  */
767
1054
  class PositionCalculatorService {
768
1055
  #ngZone = inject(NgZone);
1056
+ #registry = inject(DroppableRegistryService);
769
1057
  /** Data attribute used to identify droppable elements */
770
1058
  #DROPPABLE_ID_ATTR = 'data-droppable-id';
771
1059
  /** Data attribute used to identify droppable groups */
@@ -774,12 +1062,19 @@ class PositionCalculatorService {
774
1062
  #DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
775
1063
  /** Data attribute used to identify draggable elements */
776
1064
  #DRAGGABLE_ID_ATTR = 'data-draggable-id';
777
- /** Maximum DOM levels to traverse when looking for parent elements */
778
- #MAX_PARENT_TRAVERSAL = 15;
779
1065
  /** Reusable result object for getNearEdge (avoids per-frame allocation) */
780
1066
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
781
1067
  /** Active drag session rect snapshot, or null when no drag is in progress. */
782
1068
  #session = null;
1069
+ constructor() {
1070
+ // A droppable (un)registering mid-drag makes the next hit-test re-read the candidates.
1071
+ // Removed on destroy in case the service is provided below root.
1072
+ const removeListener = this.#registry.onChange((group) => this.notifyCandidatesChanged(group));
1073
+ inject(DestroyRef).onDestroy(() => {
1074
+ removeListener();
1075
+ this.endDragSession();
1076
+ });
1077
+ }
783
1078
  /**
784
1079
  * Begin a drag session: snapshot the candidate droppables for `groupName` and
785
1080
  * their rects once, then watch for viewport changes (scroll/resize) that would
@@ -799,7 +1094,7 @@ class PositionCalculatorService {
799
1094
  this.#session = {
800
1095
  groupName,
801
1096
  candidates,
802
- rects: candidates.map((el) => this.#measureRect(el)),
1097
+ rects: this.#measureRects(candidates, []),
803
1098
  dirty: false,
804
1099
  candidatesStale: false,
805
1100
  onViewportChange,
@@ -845,10 +1140,11 @@ class PositionCalculatorService {
845
1140
  }
846
1141
  }
847
1142
  /**
848
- * Re-query the candidate droppable *list* for the active session and re-read their
849
- * rects. Unlike {@link invalidateDroppableRects} (which only refreshes rects), this
850
- * picks up droppables added or removed since the snapshot was captured at drag start —
851
- * the frozen candidate list is otherwise blind to mid-drag mounts/unmounts.
1143
+ * Re-read the candidate droppable *list* for the active session from the registry and
1144
+ * re-read their rects. Unlike {@link invalidateDroppableRects} (which only refreshes
1145
+ * rects), this picks up droppables added or removed since the snapshot was captured at
1146
+ * drag start. Registration changes already trigger it on the next hit-test; this is the
1147
+ * manual escape hatch.
852
1148
  *
853
1149
  * Safe to call when no session is active (no-op).
854
1150
  */
@@ -858,19 +1154,18 @@ class PositionCalculatorService {
858
1154
  return;
859
1155
  }
860
1156
  session.candidates = this.#queryDroppables(session.groupName);
861
- session.rects = session.candidates.map((el) => this.#measureRect(el));
1157
+ session.rects = this.#measureRects(session.candidates, []);
862
1158
  session.dirty = false;
863
1159
  session.candidatesStale = false;
864
1160
  this.#observeCandidates(session);
865
1161
  }
866
1162
  /**
867
1163
  * Signal that the set of droppables for `groupName` may have changed (a droppable
868
- * registered or unregistered mid-drag). Defers the actual re-query to the next
869
- * hit-test — where a removed element is already gone from the DOM and a newly added
870
- * one has its data attributes applied — keeping this notification path cheap.
1164
+ * registered or unregistered mid-drag). Defers the re-read to the next hit-test, keeping
1165
+ * this notification path cheap.
871
1166
  *
872
- * Called by {@link DroppableDirective} lifecycle hooks. No-op when the active session
873
- * belongs to a different group or no session is active.
1167
+ * Called for every droppable registry change. No-op when the active session belongs to
1168
+ * a different group or no session is active.
874
1169
  */
875
1170
  notifyCandidatesChanged(groupName) {
876
1171
  if (this.#session && this.#session.groupName === groupName) {
@@ -900,23 +1195,20 @@ class PositionCalculatorService {
900
1195
  this.refreshCandidates();
901
1196
  }
902
1197
  else if (session.dirty) {
903
- for (let i = 0; i < session.candidates.length; i++) {
904
- session.rects[i] = this.#measureRect(session.candidates[i]);
905
- }
1198
+ this.#measureRects(session.candidates, session.rects);
906
1199
  session.dirty = false;
907
1200
  }
908
1201
  return this.#hitTest(x, y, session.candidates, session.rects);
909
1202
  }
910
1203
  // No active session: one-shot geometric query (still avoids elementFromPoint).
911
1204
  const candidates = this.#queryDroppables(groupName);
912
- const rects = candidates.map((el) => this.#measureRect(el));
913
- return this.#hitTest(x, y, candidates, rects);
1205
+ return this.#hitTest(x, y, candidates, this.#measureRects(candidates, []));
914
1206
  }
915
1207
  /**
916
1208
  * Look up a droppable element by its ID.
917
1209
  *
918
- * When a drag session is active, searches the cached candidate list (O(n), avoids a DOM
919
- * query). Falls back to `document.querySelector` when no session is active.
1210
+ * When a drag session is active, searches the cached candidate list. Otherwise looks the
1211
+ * ID up in the droppable registry. Neither queries the document.
920
1212
  *
921
1213
  * Intended for the autoscroll scroll-only fast path, where the active droppable is already
922
1214
  * known and only the placeholder index needs recalculation.
@@ -930,13 +1222,10 @@ class PositionCalculatorService {
930
1222
  }
931
1223
  return null;
932
1224
  }
933
- if (typeof document === 'undefined') {
934
- return null;
935
- }
936
- return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
1225
+ return this.#registry.getById(id);
937
1226
  }
938
1227
  /**
939
- * Query all droppable elements belonging to a group, in document order.
1228
+ * All rendered droppables belonging to a group, in document order, from the registry.
940
1229
  *
941
1230
  * All group members are included — the disabled check happens per-frame in
942
1231
  * {@link #hitTest} rather than here, because a droppable can be disabled or
@@ -944,30 +1233,46 @@ class PositionCalculatorService {
944
1233
  * `(dragStart)` handler, which fires AFTER the candidate snapshot is captured).
945
1234
  */
946
1235
  #queryDroppables(groupName) {
947
- if (typeof document === 'undefined') {
948
- return [];
949
- }
950
- return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1236
+ return this.#registry.getGroup(groupName);
951
1237
  }
952
1238
  /**
953
- * Measure a candidate's hit-test rect, clipped to its nearest `.vdnd-scrollable`
954
- * ancestor. Without clipping a droppable scrolled mostly out of a clipping container
955
- * still hit-tests over its full unclipped rect (issue #23 case 3). The intersection is
956
- * built as a plain DOMRect; an empty intersection yields a negative width/height so the
957
- * `#hitTest` bounds check can never match it.
1239
+ * The ancestor that clips a candidate's hit-test rect: its nearest `.vdnd-scrollable`
1240
+ * ancestor, or null when it has none or is one itself. Without clipping a droppable scrolled
1241
+ * mostly out of a clipping container still hit-tests over its full unclipped rect (issue #23
1242
+ * case 3).
958
1243
  */
959
- #measureRect(el) {
960
- const rect = el.getBoundingClientRect();
1244
+ #clipOf(el) {
961
1245
  const scrollable = el.closest('.vdnd-scrollable');
962
- if (!scrollable || scrollable === el) {
963
- return rect;
1246
+ return scrollable === el ? null : scrollable;
1247
+ }
1248
+ /**
1249
+ * Measure the candidates' hit-test rects into `rects`, each clipped to its clip ancestor (see
1250
+ * `#clipOf`). Consecutive candidates in one scroller (lists side by side in it) read its rect
1251
+ * once. The ancestor is looked up on each pass: a list moved to another scroller mid-drag keeps
1252
+ * its registration. The intersection is built as a plain DOMRect; an empty intersection yields
1253
+ * a negative width/height so the `#hitTest` bounds check can never match it.
1254
+ */
1255
+ #measureRects(candidates, rects) {
1256
+ let lastClip = null;
1257
+ let lastClipRect = null;
1258
+ for (let i = 0; i < candidates.length; i++) {
1259
+ const rect = candidates[i].getBoundingClientRect();
1260
+ const clip = this.#clipOf(candidates[i]);
1261
+ if (!clip) {
1262
+ rects[i] = rect;
1263
+ continue;
1264
+ }
1265
+ if (clip !== lastClip || !lastClipRect) {
1266
+ lastClip = clip;
1267
+ lastClipRect = clip.getBoundingClientRect();
1268
+ }
1269
+ const top = Math.max(rect.top, lastClipRect.top);
1270
+ const left = Math.max(rect.left, lastClipRect.left);
1271
+ const right = Math.min(rect.right, lastClipRect.right);
1272
+ const bottom = Math.min(rect.bottom, lastClipRect.bottom);
1273
+ rects[i] = new DOMRect(left, top, right - left, bottom - top);
964
1274
  }
965
- const clip = scrollable.getBoundingClientRect();
966
- const top = Math.max(rect.top, clip.top);
967
- const left = Math.max(rect.left, clip.left);
968
- const right = Math.min(rect.right, clip.right);
969
- const bottom = Math.min(rect.bottom, clip.bottom);
970
- return new DOMRect(left, top, right - left, bottom - top);
1275
+ return rects;
971
1276
  }
972
1277
  /**
973
1278
  * Create a ResizeObserver (run outside Angular per CLAUDE.md) that marks the session's
@@ -1071,15 +1376,19 @@ class PositionCalculatorService {
1071
1376
  * @returns The droppable parent element, or null if none found
1072
1377
  */
1073
1378
  getDroppableParent(element, groupName) {
1074
- let current = element;
1075
- let count = 0;
1076
- while (current && current.tagName !== 'BODY' && count < this.#MAX_PARENT_TRAVERSAL) {
1077
- const foundGroup = current.getAttribute(this.#DROPPABLE_GROUP_ATTR);
1078
- if (foundGroup && foundGroup === groupName) {
1379
+ // An empty group name never matches (an empty attribute value is no group)
1380
+ if (!groupName) {
1381
+ return null;
1382
+ }
1383
+ // Match on the attribute's presence and compare the value here, so the group name needs no
1384
+ // selector escaping. Nearer droppables of other groups are skipped.
1385
+ const selector = `[${this.#DROPPABLE_GROUP_ATTR}]`;
1386
+ let current = element.closest(selector);
1387
+ while (current && !isPageRoot(current)) {
1388
+ if (current.getAttribute(this.#DROPPABLE_GROUP_ATTR) === groupName) {
1079
1389
  return current;
1080
1390
  }
1081
- current = current.parentElement;
1082
- count++;
1391
+ current = current.parentElement?.closest(selector) ?? null;
1083
1392
  }
1084
1393
  return null;
1085
1394
  }
@@ -1090,17 +1399,9 @@ class PositionCalculatorService {
1090
1399
  * @returns The draggable parent element, or null if none found
1091
1400
  */
1092
1401
  getDraggableParent(element) {
1093
- let current = element;
1094
- let count = 0;
1095
- while (current && current.tagName !== 'BODY' && count < this.#MAX_PARENT_TRAVERSAL) {
1096
- const draggableId = current.getAttribute(this.#DRAGGABLE_ID_ATTR);
1097
- if (draggableId) {
1098
- return current;
1099
- }
1100
- current = current.parentElement;
1101
- count++;
1102
- }
1103
- return null;
1402
+ const selector = `[${this.#DRAGGABLE_ID_ATTR}]:not([${this.#DRAGGABLE_ID_ATTR}=""])`;
1403
+ const draggable = element.closest(selector);
1404
+ return draggable && !isPageRoot(draggable) ? draggable : null;
1104
1405
  }
1105
1406
  /**
1106
1407
  * Get the draggable ID from an element.
@@ -1173,21 +1474,20 @@ class PositionCalculatorService {
1173
1474
  // current container — otherwise a container disabled mid-drag (its own index becomes
1174
1475
  // -1) would trap the drag with no reachable neighbour. Disabled droppables are skipped
1175
1476
  // as *targets* during the outward scan below instead.
1176
- const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1477
+ const allDroppables = this.#registry.getGroup(groupName);
1177
1478
  if (allDroppables.length <= 1) {
1178
1479
  return null;
1179
1480
  }
1180
1481
  // Get bounding rects, IDs, and disabled state, sorted by X position
1181
1482
  const droppableInfos = [];
1182
1483
  allDroppables.forEach((el) => {
1183
- const htmlEl = el;
1184
- const id = htmlEl.getAttribute(this.#DROPPABLE_ID_ATTR);
1484
+ const id = el.getAttribute(this.#DROPPABLE_ID_ATTR);
1185
1485
  if (id) {
1186
1486
  droppableInfos.push({
1187
- element: htmlEl,
1487
+ element: el,
1188
1488
  id,
1189
- rect: htmlEl.getBoundingClientRect(),
1190
- disabled: this.#isDroppableDisabled(htmlEl),
1489
+ rect: el.getBoundingClientRect(),
1490
+ disabled: this.#isDroppableDisabled(el),
1191
1491
  });
1192
1492
  }
1193
1493
  });
@@ -1231,7 +1531,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1231
1531
  args: [{
1232
1532
  providedIn: 'root',
1233
1533
  }]
1234
- }] });
1534
+ }], ctorParameters: () => [] });
1235
1535
 
1236
1536
  /**
1237
1537
  * Single RAF loop that coordinates all drag-frame work in ordered phases:
@@ -1244,6 +1544,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1244
1544
  * latest cursor and a `cursorDirty` flag. Callers skip their hit-test when
1245
1545
  * the flag is false, so autoscroll-only frames (cursor stationary) incur
1246
1546
  * zero hit-test work.
1547
+ * 3. Frame writer phase (WRITE): registered writers apply the state computed this
1548
+ * frame to the DOM. DragPreviewComponent writes the preview transform here, so
1549
+ * the preview moves in the frame that processed the pointer instead of waiting
1550
+ * for a change detection tick, which Angular schedules after this frame.
1247
1551
  *
1248
1552
  * PointerDragHandler calls `queueCursorUpdate()` instead of managing its own
1249
1553
  * RAF per pointermove. This coalesces all moves within a frame into one update
@@ -1259,6 +1563,8 @@ class DragSchedulerService {
1259
1563
  #cursorDirty = false;
1260
1564
  /** Tick participants called before the main compute phase each frame. */
1261
1565
  #participants = [];
1566
+ /** Frame writers called after the main compute phase each frame. */
1567
+ #frameWriters = [];
1262
1568
  /**
1263
1569
  * Start the scheduler RAF loop.
1264
1570
  * No-op if already running.
@@ -1315,6 +1621,26 @@ class DragSchedulerService {
1315
1621
  this.#participants.splice(idx, 1);
1316
1622
  }
1317
1623
  }
1624
+ /**
1625
+ * Register a function to be called at the end of each frame, after the main tick.
1626
+ * DragPreviewComponent registers here to move the preview within the same frame.
1627
+ * No-op if the same function reference is already registered.
1628
+ */
1629
+ addFrameWriter(fn) {
1630
+ if (!this.#frameWriters.includes(fn)) {
1631
+ this.#frameWriters.push(fn);
1632
+ }
1633
+ }
1634
+ /**
1635
+ * Unregister a previously added frame writer.
1636
+ * No-op if the function was not registered.
1637
+ */
1638
+ removeFrameWriter(fn) {
1639
+ const idx = this.#frameWriters.indexOf(fn);
1640
+ if (idx !== -1) {
1641
+ this.#frameWriters.splice(idx, 1);
1642
+ }
1643
+ }
1318
1644
  #scheduleNextTick() {
1319
1645
  this.#rafId = requestAnimationFrame(() => this.#tick());
1320
1646
  }
@@ -1334,9 +1660,23 @@ class DragSchedulerService {
1334
1660
  for (const participant of this.#participants) {
1335
1661
  participant();
1336
1662
  }
1663
+ // A participant can end the drag (the autoscroll callback may cancel it), which stops
1664
+ // the scheduler and clears onTick.
1665
+ const onTick = this.#onTick;
1666
+ if (!onTick) {
1667
+ return;
1668
+ }
1337
1669
  // Phase 2 — main compute (cursor-based hit-test + signal write)
1338
- this.#onTick(this.#pendingCursor, cursorDirty);
1339
- // Reschedule only if still active (stop() may have been called by onTick).
1670
+ onTick(this.#pendingCursor, cursorDirty);
1671
+ // stop() may have been called by onTick (the drag ended): nothing left to write.
1672
+ if (this.#onTick === null) {
1673
+ return;
1674
+ }
1675
+ // Phase 3 — frame writers (apply this frame's state to the DOM)
1676
+ for (const writer of this.#frameWriters) {
1677
+ writer();
1678
+ }
1679
+ // Reschedule only if still active (a writer is not expected to stop it, but may).
1340
1680
  if (this.#onTick !== null) {
1341
1681
  this.#scheduleNextTick();
1342
1682
  }
@@ -1387,6 +1727,14 @@ class AutoScrollService {
1387
1727
  /** Last tick cursor position for stationary detection */
1388
1728
  #lastTickCursorX = NaN;
1389
1729
  #lastTickCursorY = NaN;
1730
+ /**
1731
+ * The containers the last tick that ran asked to scroll but had no room left: the first
1732
+ * #exhaustedCount entries (the objects are reused from tick to tick). A resting cursor skips the
1733
+ * tick only while they stay as they were on the axes it asked for: content that grows (rows
1734
+ * measured taller, items added), a resize or a scroll can give one room, and the scroll resumes.
1735
+ */
1736
+ #exhausted = [];
1737
+ #exhaustedCount = 0;
1390
1738
  /**
1391
1739
  * Last timestamp used to scale per-frame autoscroll distance by elapsed frame time.
1392
1740
  * Scale is intentionally floored at 1 to preserve the existing feel on 60fps+ displays while
@@ -1490,6 +1838,8 @@ class AutoScrollService {
1490
1838
  this.#cursorOverride = null;
1491
1839
  this.#lastTickCursorX = NaN;
1492
1840
  this.#lastTickCursorY = NaN;
1841
+ this.#exhausted.length = 0;
1842
+ this.#exhaustedCount = 0;
1493
1843
  this.#lastScrollTimestamp = 0;
1494
1844
  this.#scrollState.containerId = null;
1495
1845
  this.#scrollState.direction.x = 0;
@@ -1518,14 +1868,17 @@ class AutoScrollService {
1518
1868
  if (!cursor) {
1519
1869
  return;
1520
1870
  }
1521
- // Skip the container iteration when cursor hasn't moved and scroll is idle.
1871
+ // Skip the container iteration when the cursor hasn't moved and scroll is idle, unless a
1872
+ // container it asked to scroll without room has changed since (see #exhausted).
1522
1873
  if (cursor.x === this.#lastTickCursorX &&
1523
1874
  cursor.y === this.#lastTickCursorY &&
1524
- !this.isScrolling()) {
1875
+ !this.isScrolling() &&
1876
+ !this.#exhaustedChanged()) {
1525
1877
  return;
1526
1878
  }
1527
1879
  this.#lastTickCursorX = cursor.x;
1528
1880
  this.#lastTickCursorY = cursor.y;
1881
+ this.#exhaustedCount = 0;
1529
1882
  let scrollPerformed = false;
1530
1883
  // Frame scale is shared across candidates within a tick; computed lazily on
1531
1884
  // the first scroll attempt so stationary/idle frames don't advance the clock.
@@ -1585,8 +1938,19 @@ class AutoScrollService {
1585
1938
  this.#scrollState.direction.y = direction.y;
1586
1939
  this.#scrollState.speed = speed;
1587
1940
  scrollPerformed = true;
1941
+ // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1942
+ // Delaying via RAF causes cumulative drift during continuous autoscroll
1943
+ // because multiple scrolls happen before each delayed callback runs.
1944
+ // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1945
+ // updates signals and plain fields, and enters the zone itself to emit dragEnd if it ends
1946
+ // the drag.
1947
+ // It runs after the scroll state is recorded: it may end the drag, and the reset of
1948
+ // stopMonitoring() must not be overwritten.
1949
+ this.#onScrollCallback?.();
1588
1950
  break;
1589
1951
  }
1952
+ // No room in any requested direction: a resting cursor retries it once it changes
1953
+ this.#recordExhausted(element, direction);
1590
1954
  }
1591
1955
  }
1592
1956
  if (!scrollPerformed) {
@@ -1646,16 +2010,46 @@ class AutoScrollService {
1646
2010
  }
1647
2011
  }
1648
2012
  }
1649
- if (!scrolled) {
1650
- return false;
2013
+ return scrolled;
2014
+ }
2015
+ /** Record a container that had no room in `direction` (see #exhausted). */
2016
+ #recordExhausted(element, direction) {
2017
+ const exhausted = (this.#exhausted[this.#exhaustedCount] ??= {
2018
+ element,
2019
+ vertical: false,
2020
+ horizontal: false,
2021
+ scrollTop: 0,
2022
+ scrollLeft: 0,
2023
+ maxScrollTop: 0,
2024
+ maxScrollLeft: 0,
2025
+ });
2026
+ exhausted.element = element;
2027
+ exhausted.vertical = direction.y !== 0;
2028
+ exhausted.horizontal = direction.x !== 0;
2029
+ exhausted.scrollTop = element.scrollTop;
2030
+ exhausted.scrollLeft = element.scrollLeft;
2031
+ exhausted.maxScrollTop = element.scrollHeight - element.clientHeight;
2032
+ exhausted.maxScrollLeft = element.scrollWidth - element.clientWidth;
2033
+ this.#exhaustedCount++;
2034
+ }
2035
+ /**
2036
+ * Whether a container in #exhausted has scrolled or changed size on an axis it was asked to
2037
+ * scroll, so it may have room now.
2038
+ */
2039
+ #exhaustedChanged() {
2040
+ for (let i = 0; i < this.#exhaustedCount; i++) {
2041
+ const exhausted = this.#exhausted[i];
2042
+ const { element } = exhausted;
2043
+ if ((exhausted.vertical &&
2044
+ (element.scrollTop !== exhausted.scrollTop ||
2045
+ element.scrollHeight - element.clientHeight !== exhausted.maxScrollTop)) ||
2046
+ (exhausted.horizontal &&
2047
+ (element.scrollLeft !== exhausted.scrollLeft ||
2048
+ element.scrollWidth - element.clientWidth !== exhausted.maxScrollLeft))) {
2049
+ return true;
2050
+ }
1651
2051
  }
1652
- // Notify callback IMMEDIATELY in the same frame (no RAF delay)
1653
- // Delaying via RAF causes cumulative drift during continuous autoscroll
1654
- // because multiple scrolls happen before each delayed callback runs.
1655
- // Note: No ngZone.run() needed here - the callback (DraggableDirective.#recalculatePlaceholder)
1656
- // already enters the zone when updating drag state.
1657
- this.#onScrollCallback?.();
1658
- return true;
2052
+ return false;
1659
2053
  }
1660
2054
  #getFrameScale() {
1661
2055
  const now = performance.now();
@@ -1696,20 +2090,19 @@ class ElementCloneService {
1696
2090
  * These are the visual properties that affect appearance. Keeping them
1697
2091
  * kebab-case avoids a camelCase→kebab conversion on every property of every
1698
2092
  * node during the recursive clone walk (a drag-start hot path).
2093
+ *
2094
+ * A shorthand carries more than the longhands listed with it (`background-size`,
2095
+ * `font-style`, …), so it is copied first, and the longhands only when the engine can't
2096
+ * serialize the shorthand (it gives '' for `font` with `font-variant-ligatures: none`, for
2097
+ * example). Copying both would read and write the same values twice.
1699
2098
  */
1700
- #stylePropsToCopy = [
1701
- 'background',
1702
- 'background-color',
1703
- 'background-image',
2099
+ #stylesToCopy = [
2100
+ ['background', ['background-color', 'background-image']],
1704
2101
  'border',
1705
2102
  'border-radius',
1706
2103
  'box-shadow',
1707
2104
  'color',
1708
- 'font',
1709
- 'font-family',
1710
- 'font-size',
1711
- 'font-weight',
1712
- 'line-height',
2105
+ ['font', ['font-family', 'font-size', 'font-weight', 'line-height']],
1713
2106
  'padding',
1714
2107
  'margin',
1715
2108
  'display',
@@ -1751,10 +2144,22 @@ class ElementCloneService {
1751
2144
  #applyComputedStyles(source, target) {
1752
2145
  const computed = window.getComputedStyle(source);
1753
2146
  // Copy essential visual properties (keys are already kebab-case)
1754
- for (const prop of this.#stylePropsToCopy) {
1755
- const value = computed.getPropertyValue(prop);
1756
- if (value) {
1757
- target.style.setProperty(prop, value);
2147
+ for (const style of this.#stylesToCopy) {
2148
+ if (typeof style === 'string') {
2149
+ this.#copyStyle(computed, target, style);
2150
+ continue;
2151
+ }
2152
+ // The longhands only when the shorthand was not copied: the engine gave no value, or the
2153
+ // clone rejected it. A shorthand sets all its longhands, so reading one back tells (the
2154
+ // shorthand itself would be serialized again to be read). The clone may carry that
2155
+ // longhand inline from the source, so it goes first.
2156
+ const [shorthand, longhands] = style;
2157
+ target.style.removeProperty(longhands[0]);
2158
+ if (!this.#copyStyle(computed, target, shorthand) ||
2159
+ !target.style.getPropertyValue(longhands[0])) {
2160
+ for (const longhand of longhands) {
2161
+ this.#copyStyle(computed, target, longhand);
2162
+ }
1758
2163
  }
1759
2164
  }
1760
2165
  // Disable animations and transitions on clone
@@ -1771,6 +2176,18 @@ class ElementCloneService {
1771
2176
  }
1772
2177
  }
1773
2178
  }
2179
+ /**
2180
+ * Copy one computed property to the target's inline style. Returns whether the engine gave a
2181
+ * value for it.
2182
+ */
2183
+ #copyStyle(computed, target, property) {
2184
+ const value = computed.getPropertyValue(property);
2185
+ if (!value) {
2186
+ return false;
2187
+ }
2188
+ target.style.setProperty(property, value);
2189
+ return true;
2190
+ }
1774
2191
  /**
1775
2192
  * Handle special elements that require extra processing.
1776
2193
  */
@@ -1868,6 +2285,11 @@ class ElementCloneService {
1868
2285
  el.setAttribute('disabled', 'true');
1869
2286
  }
1870
2287
  });
2288
+ // A checked radio joins its group as soon as the clone is connected, which unchecks the
2289
+ // radio in the source item. Without a name the cloned radios belong to no group.
2290
+ clone.querySelectorAll('input[type="radio"][name]').forEach((radio) => {
2291
+ radio.removeAttribute('name');
2292
+ });
1871
2293
  // Remove focus styling classes that might interfere
1872
2294
  clone.classList.remove('vdnd-draggable-dragging');
1873
2295
  clone.classList.remove('vdnd-draggable-disabled');
@@ -2118,14 +2540,19 @@ class DragIndexCalculatorService {
2118
2540
  const cached = this.#droppableCache.get(droppableElement);
2119
2541
  if (cached)
2120
2542
  return cached;
2543
+ const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
2121
2544
  const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
2122
- const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
2123
- ? droppableElement
2124
- : droppableElement.closest('vdnd-virtual-content'));
2545
+ const virtualContentElement = this.#findVirtualContent(droppableElement);
2125
2546
  let containerType;
2126
2547
  let scrollContainer;
2127
2548
  let scrollableParent = null;
2128
- if (virtualScrollElement) {
2549
+ if (isViewport) {
2550
+ // The viewport scrolls its own rows. Checked first so a list nested in a row is not
2551
+ // mistaken for this one.
2552
+ containerType = 'viewport';
2553
+ scrollContainer = droppableElement;
2554
+ }
2555
+ else if (virtualScrollElement) {
2129
2556
  containerType = 'virtualScroll';
2130
2557
  scrollContainer = virtualScrollElement;
2131
2558
  }
@@ -2162,31 +2589,21 @@ class DragIndexCalculatorService {
2162
2589
  return entry;
2163
2590
  }
2164
2591
  getTotalItemCount(args) {
2165
- return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
2592
+ return this.#getTotalItemCount(args.droppableElement, args.draggedItemHeight);
2593
+ }
2594
+ /**
2595
+ * Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
2596
+ * measurement places the placeholder and finds the dragged item's source index.
2597
+ */
2598
+ getScrollGeometry(droppableElement, draggedItemHeight) {
2599
+ return this.#getScrollGeometry(this.#resolveDroppable(droppableElement, draggedItemHeight));
2166
2600
  }
2167
2601
  calculatePlaceholderIndex(args) {
2168
2602
  const { droppableElement, position, grabOffset, draggedItemHeight, sourceDroppableId, sourceIndex, } = args;
2169
2603
  // Resolve cached metadata (DOM queries run once per droppable per drag session)
2170
2604
  const cache = this.#resolveDroppable(droppableElement, draggedItemHeight);
2171
2605
  // Live reads: getBoundingClientRect() and scrollTop change during scroll
2172
- let currentScrollTop;
2173
- let rect;
2174
- if (cache.containerType === 'virtualScroll') {
2175
- rect = cache.scrollContainer.getBoundingClientRect();
2176
- currentScrollTop = cache.scrollContainer.scrollTop;
2177
- }
2178
- else if (cache.containerType === 'virtualContent' && cache.scrollableParent) {
2179
- rect = cache.scrollContainer.getBoundingClientRect();
2180
- const contentOffsetAttr = cache.virtualContentElement.getAttribute('data-content-offset');
2181
- const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
2182
- const offsetValue = Number.isFinite(contentOffset) ? contentOffset : 0;
2183
- currentScrollTop = cache.scrollContainer.scrollTop - offsetValue;
2184
- }
2185
- else {
2186
- rect = cache.scrollContainer.getBoundingClientRect();
2187
- currentScrollTop =
2188
- cache.containerType === 'virtualContent' ? 0 : cache.scrollContainer.scrollTop;
2189
- }
2606
+ const { rect, scrollTop: currentScrollTop } = this.#getScrollGeometry(cache);
2190
2607
  const { strategy, itemHeight, droppableId: currentDroppableId, isConstrainedToContainer, } = cache;
2191
2608
  // Calculate preview center position mathematically
2192
2609
  // The preview is positioned at: cursorPosition - grabOffset (see drag-preview.component.ts)
@@ -2244,7 +2661,7 @@ class DragIndexCalculatorService {
2244
2661
  totalItems = Math.max(0, cachedItemCount);
2245
2662
  }
2246
2663
  else {
2247
- totalItems = this.#getTotalItemCount(droppableElement, isSameList, draggedItemHeight);
2664
+ totalItems = this.#getTotalItemCount(droppableElement, draggedItemHeight, cache);
2248
2665
  }
2249
2666
  // Edge detection: allow dropping at the END of the list when cursor is near bottom edge.
2250
2667
  // Due to max scroll limits, the math alone can't reach totalItems when the list is longer
@@ -2274,7 +2691,41 @@ class DragIndexCalculatorService {
2274
2691
  placeholderIndex = Math.max(0, Math.min(placeholderIndex, totalItems));
2275
2692
  return { index: placeholderIndex, placeholderId: END_OF_LIST };
2276
2693
  }
2277
- #getTotalItemCount(droppableElement, isSameList, draggedItemHeight) {
2694
+ #getScrollGeometry(cache) {
2695
+ const { containerType, scrollContainer } = cache;
2696
+ const rect = scrollContainer.getBoundingClientRect();
2697
+ const isVirtual = containerType === 'virtualScroll' || containerType === 'virtualContent';
2698
+ switch (containerType) {
2699
+ case 'viewport':
2700
+ return {
2701
+ rect,
2702
+ scrollTop: scrollContainer.scrollTop - this.#readContentOffset(scrollContainer),
2703
+ isVirtual,
2704
+ };
2705
+ case 'virtualContent':
2706
+ // Without a vdndScrollable parent the content element itself is measured, unscrolled
2707
+ return cache.scrollableParent
2708
+ ? {
2709
+ rect,
2710
+ scrollTop: scrollContainer.scrollTop - this.#readContentOffset(cache.virtualContentElement),
2711
+ isVirtual,
2712
+ }
2713
+ : { rect, scrollTop: 0, isVirtual };
2714
+ default:
2715
+ return { rect, scrollTop: scrollContainer.scrollTop, isVirtual };
2716
+ }
2717
+ }
2718
+ /** The `data-content-offset` (px reserved above the rows) of a virtual container, or 0. */
2719
+ #readContentOffset(element) {
2720
+ const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
2721
+ return Number.isFinite(offset) ? offset : 0;
2722
+ }
2723
+ /**
2724
+ * The number of items in a droppable. With `cache`, the drag's resolved metadata, the virtual
2725
+ * containers it knows are not looked up in the DOM again: this runs on every frame of a drag
2726
+ * over a list without a registered strategy.
2727
+ */
2728
+ #getTotalItemCount(droppableElement, draggedItemHeight, cache) {
2278
2729
  // Check if a registered strategy exists
2279
2730
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
2280
2731
  const strategy = droppableId ? this.#strategies.get(droppableId) : null;
@@ -2283,7 +2734,9 @@ class DragIndexCalculatorService {
2283
2734
  return Math.max(0, strategyItemCount);
2284
2735
  }
2285
2736
  // Check for embedded virtual scroll component
2286
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
2737
+ const virtualScroll = cache
2738
+ ? cache.virtualScrollElement
2739
+ : droppableElement.querySelector('vdnd-virtual-scroll');
2287
2740
  if (virtualScroll) {
2288
2741
  // Use data-total-items attribute if available (always the true N)
2289
2742
  const totalItemsAttr = virtualScroll.getAttribute('data-total-items');
@@ -2320,9 +2773,9 @@ class DragIndexCalculatorService {
2320
2773
  return count;
2321
2774
  }
2322
2775
  // Check for page-level scroll (vdnd-virtual-content)
2323
- const virtualContent = droppableElement.matches('vdnd-virtual-content')
2324
- ? droppableElement
2325
- : droppableElement.closest('vdnd-virtual-content');
2776
+ const virtualContent = cache
2777
+ ? cache.virtualContentElement
2778
+ : this.#findVirtualContent(droppableElement);
2326
2779
  if (virtualContent) {
2327
2780
  // Prefer explicit total items attribute (works for both fixed and dynamic heights)
2328
2781
  const totalItemsAttr = virtualContent.getAttribute('data-total-items');
@@ -2349,6 +2802,12 @@ class DragIndexCalculatorService {
2349
2802
  const items = droppableElement.querySelectorAll('[data-draggable-id]');
2350
2803
  return items.length;
2351
2804
  }
2805
+ /** The `vdnd-virtual-content` the droppable is or is inside (page-level scroll), if any. */
2806
+ #findVirtualContent(droppableElement) {
2807
+ return droppableElement.matches('vdnd-virtual-content')
2808
+ ? droppableElement
2809
+ : droppableElement.closest('vdnd-virtual-content');
2810
+ }
2352
2811
  #getDraggedItemHeightFallback(height, fallback) {
2353
2812
  return Number.isFinite(height) && height > 0 ? height : fallback;
2354
2813
  }
@@ -2401,10 +2860,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
2401
2860
  }, template: ``, styles: [":host{display:block;pointer-events:none}\n"] }]
2402
2861
  }], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }] } });
2403
2862
 
2863
+ /** A layout value, or 0 where there is no layout (server rendering leaves them undefined). */
2864
+ function layoutValue(value) {
2865
+ return Number.isFinite(value) ? value : 0;
2866
+ }
2404
2867
  function bindRafThrottledScrollTopSignal(options) {
2405
2868
  const { element, ngZone, scrollTop, thresholdPx = 5, onCommit } = options;
2406
2869
  let pendingRaf = null;
2407
- let lastCommittedScrollTop = element.scrollTop;
2870
+ let lastCommittedScrollTop = layoutValue(element.scrollTop);
2408
2871
  const onScroll = () => {
2409
2872
  if (pendingRaf !== null) {
2410
2873
  return;
@@ -2438,7 +2901,7 @@ function bindRafThrottledScrollTopSignal(options) {
2438
2901
  function bindResizeObserverHeightSignal(options) {
2439
2902
  const { element, ngZone, height, minDeltaPx = 1 } = options;
2440
2903
  if (typeof ResizeObserver === 'undefined') {
2441
- height.set(element.clientHeight);
2904
+ height.set(layoutValue(element.clientHeight));
2442
2905
  return () => undefined;
2443
2906
  }
2444
2907
  let observer = null;
@@ -2453,7 +2916,7 @@ function bindResizeObserverHeightSignal(options) {
2453
2916
  });
2454
2917
  observer.observe(element);
2455
2918
  });
2456
- height.set(element.clientHeight);
2919
+ height.set(layoutValue(element.clientHeight));
2457
2920
  return () => {
2458
2921
  observer?.disconnect();
2459
2922
  observer = null;
@@ -2488,6 +2951,66 @@ function createAutoScrollRegistration(options) {
2488
2951
  destroyRef.onDestroy(unregisterCurrent);
2489
2952
  }
2490
2953
 
2954
+ /**
2955
+ * Give a list's strategy its items: `count` of them, with the track keys `keys` returns (the
2956
+ * list's own array, which it goes on reading).
2957
+ *
2958
+ * - FixedHeightStrategy's `setItemKeys` only counts the keys: it gets the count, and the keys are
2959
+ * never computed.
2960
+ * - DynamicHeightStrategy's copies the keys: it gets the list's array.
2961
+ * - Any other `setItemKeys` (a strategy of the consumer's own, or a subclass that overrides it)
2962
+ * gets a copy, which it may keep and change.
2963
+ * @internal
2964
+ */
2965
+ function setStrategyItems(strategy, count, keys) {
2966
+ if (strategy instanceof FixedHeightStrategy &&
2967
+ strategy.setItemKeys === FixedHeightStrategy.prototype.setItemKeys) {
2968
+ strategy.setItemCount(count);
2969
+ }
2970
+ else if (strategy instanceof DynamicHeightStrategy &&
2971
+ strategy.setItemKeys === DynamicHeightStrategy.prototype.setItemKeys) {
2972
+ strategy.setItemKeys(keys());
2973
+ }
2974
+ else {
2975
+ strategy.setItemKeys(keys().slice());
2976
+ }
2977
+ }
2978
+
2979
+ /**
2980
+ * Query by an exact attribute value without interpolating consumer data into a CSS selector.
2981
+ *
2982
+ * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
2983
+ * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
2984
+ * quotes, brackets, backslashes, or other selector-sensitive characters.
2985
+ */
2986
+ function queryByAttribute(root, attributeName, attributeValue) {
2987
+ const candidates = root.querySelectorAll(`[${attributeName}]`);
2988
+ for (const candidate of candidates) {
2989
+ if (candidate.getAttribute(attributeName) === attributeValue) {
2990
+ return candidate;
2991
+ }
2992
+ }
2993
+ return null;
2994
+ }
2995
+ /**
2996
+ * The elements carrying an attribute, by its value (the first in document order when several
2997
+ * share one, as `queryByAttribute` finds). One query for many lookups, where `queryByAttribute`
2998
+ * scans every candidate on each call.
2999
+ */
3000
+ function mapByAttribute(root, attributeName) {
3001
+ const elements = new Map();
3002
+ for (const candidate of root.querySelectorAll(`[${attributeName}]`)) {
3003
+ const value = candidate.getAttribute(attributeName);
3004
+ if (!elements.has(value)) {
3005
+ elements.set(value, candidate);
3006
+ }
3007
+ }
3008
+ return elements;
3009
+ }
3010
+ function queryAllByAttribute(root, attributeName, attributeValue) {
3011
+ return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
3012
+ }
3013
+
2491
3014
  /** Layout changes smaller than this are ignored (sub-pixel rounding noise). */
2492
3015
  const MIN_SHIFT_PX = 0.5;
2493
3016
  /**
@@ -2671,12 +3194,214 @@ class ShiftAnimator {
2671
3194
  }
2672
3195
  }
2673
3196
 
3197
+ /** The elements drop animations hide, with the animation hiding each */
3198
+ const hiddenTargets = new Map();
2674
3199
  /**
2675
- * A virtual scroll container that only renders visible items.
2676
- *
2677
- * Key features:
2678
- * - Only renders items within the visible viewport plus an overscan buffer
2679
- * - Supports "sticky" items that are always rendered (used for dragged items)
3200
+ * Show again an element a drop animation hides, if one of these nodes holds it. A virtual list
3201
+ * calls it for the row it pools: that row's element renders another item next.
3202
+ * @internal
3203
+ */
3204
+ function revealDropTargetIn(nodes) {
3205
+ for (const [element, animation] of hiddenTargets) {
3206
+ if (nodes.some((node) => node.contains(element))) {
3207
+ hiddenTargets.delete(element);
3208
+ animation.cancel();
3209
+ }
3210
+ }
3211
+ }
3212
+ /**
3213
+ * Plays the drop ("settle") animation: the drag preview glides from where it was released
3214
+ * into the dropped item's final position, while that item stays invisible underneath.
3215
+ *
3216
+ * Purely visual. The drag state and the drop events are already final when it starts, so it
3217
+ * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3218
+ * of view) the ghost fades out where it was released.
3219
+ * @internal
3220
+ */
3221
+ class DropAnimator {
3222
+ #config;
3223
+ #ghostAnimation = null;
3224
+ #hidden = null;
3225
+ /** The reduced-motion media query, created on first use (null without `matchMedia`) */
3226
+ #reducedMotionQuery;
3227
+ constructor(config) {
3228
+ this.#config = config;
3229
+ }
3230
+ /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3231
+ isEnabled() {
3232
+ if (this.#duration() <= 0)
3233
+ return false;
3234
+ if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3235
+ return false;
3236
+ }
3237
+ return !this.#prefersReducedMotion();
3238
+ }
3239
+ /**
3240
+ * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3241
+ * `onDone` is not called when the animation is cancelled.
3242
+ */
3243
+ play(ghost, target, onDone) {
3244
+ this.cancel();
3245
+ const timing = {
3246
+ duration: this.#duration(),
3247
+ easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3248
+ fill: 'forwards',
3249
+ };
3250
+ let ghostAnimation;
3251
+ if (target) {
3252
+ const from = ghost.getBoundingClientRect();
3253
+ const dx = target.rect.left - from.left;
3254
+ const dy = target.rect.top - from.top;
3255
+ // Travel on top of the preview's own position transform (width/height must not be
3256
+ // composited, so the base transform is repeated instead of using composite: 'add').
3257
+ const base = ghost.style.transform;
3258
+ ghostAnimation = ghost.animate([
3259
+ {
3260
+ transform: `${base} translate(0px, 0px)`,
3261
+ width: `${from.width}px`,
3262
+ height: `${from.height}px`,
3263
+ },
3264
+ {
3265
+ transform: `${base} translate(${dx}px, ${dy}px)`,
3266
+ width: `${target.rect.width}px`,
3267
+ height: `${target.rect.height}px`,
3268
+ },
3269
+ ], timing);
3270
+ // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3271
+ const hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3272
+ duration: timing.duration,
3273
+ });
3274
+ this.#hidden = { element: target.element, animation: hideAnimation };
3275
+ hiddenTargets.set(target.element, hideAnimation);
3276
+ }
3277
+ else {
3278
+ ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3279
+ }
3280
+ this.#ghostAnimation = ghostAnimation;
3281
+ ghostAnimation.onfinish = () => {
3282
+ if (this.#ghostAnimation !== ghostAnimation)
3283
+ return;
3284
+ this.cancel();
3285
+ onDone();
3286
+ };
3287
+ }
3288
+ /** Stop the running animation immediately and reveal the real item. */
3289
+ cancel() {
3290
+ const ghostAnimation = this.#ghostAnimation;
3291
+ const hidden = this.#hidden;
3292
+ this.#ghostAnimation = null;
3293
+ this.#hidden = null;
3294
+ ghostAnimation?.cancel();
3295
+ if (hidden) {
3296
+ if (hiddenTargets.get(hidden.element) === hidden.animation) {
3297
+ hiddenTargets.delete(hidden.element);
3298
+ }
3299
+ hidden.animation.cancel();
3300
+ }
3301
+ }
3302
+ #duration() {
3303
+ return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
3304
+ }
3305
+ /** One media query for every drop: it is live, so it follows a change of the preference. */
3306
+ #prefersReducedMotion() {
3307
+ if (this.#reducedMotionQuery === undefined) {
3308
+ this.#reducedMotionQuery =
3309
+ typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
3310
+ }
3311
+ return this.#reducedMotionQuery?.matches ?? false;
3312
+ }
3313
+ }
3314
+ /**
3315
+ * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3316
+ * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3317
+ * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3318
+ *
3319
+ * `getDroppable` resolves a droppable ID to its element (the droppable registry lookup).
3320
+ */
3321
+ function findDropTarget(draggableId, droppableIds, getDroppable) {
3322
+ for (const droppableId of droppableIds) {
3323
+ if (!droppableId)
3324
+ continue;
3325
+ const droppable = getDroppable(droppableId);
3326
+ const element = droppable
3327
+ ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3328
+ : null;
3329
+ if (!droppable || !element)
3330
+ continue;
3331
+ const rect = element.getBoundingClientRect();
3332
+ // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3333
+ if (rect.width === 0 && rect.height === 0)
3334
+ return null;
3335
+ const bounds = droppable.getBoundingClientRect();
3336
+ const visible = rect.bottom > bounds.top &&
3337
+ rect.top < bounds.bottom &&
3338
+ rect.right > bounds.left &&
3339
+ rect.left < bounds.right;
3340
+ return visible ? { element, rect } : null;
3341
+ }
3342
+ return null;
3343
+ }
3344
+
3345
+ /**
3346
+ * How many views of rows that left a list stay pooled for rows that come in (recycleRows). A
3347
+ * scroll step reuses the views it pools right away; the pool keeps the rest, for example when
3348
+ * fewer rows render after the list shrinks.
3349
+ */
3350
+ const MAX_POOLED_ROWS = 10;
3351
+ /** `Node.DOCUMENT_POSITION_FOLLOWING`, without the `Node` global (servers have none) */
3352
+ const DOCUMENT_POSITION_FOLLOWING = 4;
3353
+ /**
3354
+ * The node that comes first in the DOM. A view's `rootNodes` list a control flow block's anchor
3355
+ * before the nodes rendered in it, which the DOM puts before the anchor.
3356
+ */
3357
+ function firstInDocumentOrder(nodes) {
3358
+ let first = null;
3359
+ for (const node of nodes) {
3360
+ if (first === null || node.compareDocumentPosition(first) & DOCUMENT_POSITION_FOLLOWING) {
3361
+ first = node;
3362
+ }
3363
+ }
3364
+ return first;
3365
+ }
3366
+ /**
3367
+ * Which values belong to a longest increasing subsequence of `values` (negative values never
3368
+ * do). For rows: the views that can stay where they are while the others move around them, as
3369
+ * few as possible. A moved row loses focus and restarts its CSS transitions.
3370
+ */
3371
+ function longestIncreasingRun(values) {
3372
+ // tails[k]: the index of the smallest value that ends an increasing run of length k + 1
3373
+ const tails = [];
3374
+ const previous = new Array(values.length).fill(-1);
3375
+ for (let i = 0; i < values.length; i++) {
3376
+ const value = values[i];
3377
+ if (value < 0)
3378
+ continue;
3379
+ let low = 0;
3380
+ let high = tails.length;
3381
+ while (low < high) {
3382
+ const middle = (low + high) >> 1;
3383
+ if (values[tails[middle]] < value) {
3384
+ low = middle + 1;
3385
+ }
3386
+ else {
3387
+ high = middle;
3388
+ }
3389
+ }
3390
+ previous[i] = low > 0 ? tails[low - 1] : -1;
3391
+ tails[low] = i;
3392
+ }
3393
+ const inRun = new Array(values.length).fill(false);
3394
+ for (let i = tails.length > 0 ? tails[tails.length - 1] : -1; i >= 0; i = previous[i]) {
3395
+ inRun[i] = true;
3396
+ }
3397
+ return inRun;
3398
+ }
3399
+ /**
3400
+ * A virtual scroll container that only renders visible items.
3401
+ *
3402
+ * Key features:
3403
+ * - Only renders items within the visible viewport plus an overscan buffer
3404
+ * - Supports "sticky" items that are always rendered (used for dragged items)
2680
3405
  * - Uses spacer divs to maintain correct scroll height
2681
3406
  * - Integrates with the drag-and-drop system
2682
3407
  * - Automatic height detection via ResizeObserver when containerHeight is not provided
@@ -2716,11 +3441,13 @@ class VirtualScrollContainerComponent {
2716
3441
  #keyboardDrag = inject(KeyboardDragService);
2717
3442
  #ngZone = inject(NgZone);
2718
3443
  #injector = inject(Injector);
3444
+ #environmentInjector = inject(EnvironmentInjector);
3445
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
2719
3446
  /** Cleanup function for scroll listener */
2720
3447
  #scrollCleanup = null;
2721
3448
  #resizeCleanup = null;
2722
- /** ResizeObserver for dynamic height measurement */
2723
- #itemResizeObserver = null;
3449
+ /** ResizeObserver for dynamic height measurement (only in dynamic height mode) */
3450
+ #itemResizeObserver = signal(null, ...(ngDevMode ? [{ debugName: "#itemResizeObserver" }] : []));
2724
3451
  /** Map from observed HTMLElement to its trackBy key */
2725
3452
  #observedElements = new WeakMap();
2726
3453
  /** Stable mapping from rendered key to observed element for unobserve cleanup */
@@ -2729,6 +3456,38 @@ class VirtualScrollContainerComponent {
2729
3456
  #measuredHeight = signal(0, ...(ngDevMode ? [{ debugName: "#measuredHeight" }] : []));
2730
3457
  /** Slides items displaced by the placeholder (only when VDND_ANIMATION_CONFIG is provided) */
2731
3458
  #shiftAnimator = this.#createShiftAnimator();
3459
+ /** The template's spacer (cannot use ES private with viewChild) */
3460
+ spacer = viewChild('spacer', ...(ngDevMode ? [{ debugName: "spacer" }] : []));
3461
+ /** The template's content wrapper (cannot use ES private with viewChild) */
3462
+ contentWrapper = viewChild('contentWrapper', ...(ngDevMode ? [{ debugName: "contentWrapper" }] : []));
3463
+ /** Where #renderRows inserts the rows (cannot use ES private with viewChild) */
3464
+ rowOutlet = viewChild('rows', { ...(ngDevMode ? { debugName: "rowOutlet" } : {}), read: ViewContainerRef });
3465
+ /** Rendered rows, in DOM order */
3466
+ #rows = [];
3467
+ /** The entries the rendered rows show */
3468
+ #rowEntries = null;
3469
+ /** The template the rendered rows were created from */
3470
+ #rowTemplate = null;
3471
+ /** The track function that gave the rendered rows their keys */
3472
+ #rowTrackBy = null;
3473
+ /** Views of rows that left, detached, to render rows that come in (recycleRows) */
3474
+ #rowPool = [];
3475
+ /**
3476
+ * Set while this component's view is about to be checked in the current change detection pass:
3477
+ * from an input change (ngOnChanges, which runs before its effects; the first render included)
3478
+ * until the check (ngAfterViewChecked). That check renders every row.
3479
+ */
3480
+ #viewCheckPending = false;
3481
+ /** The placeholder, created on its first render and reused */
3482
+ #placeholder = null;
3483
+ /** Height last set on the placeholder */
3484
+ #placeholderHeightSet = null;
3485
+ /** Content offset last written to the content wrapper */
3486
+ #contentTransformSet = null;
3487
+ /** Whether duplicate track keys were reported (once per list) */
3488
+ #warnedDuplicateKeys = false;
3489
+ /** The row keys last checked for duplicates (checked again when the rows change) */
3490
+ #rowKeysChecked = null;
2732
3491
  /** Template for rendering each item - passed as input instead of content child for reliability */
2733
3492
  itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
2734
3493
  /** Unique ID for this scroll container (used for auto-scroll registration) */
@@ -2775,6 +3534,13 @@ class VirtualScrollContainerComponent {
2775
3534
  * Required for placeholder positioning.
2776
3535
  */
2777
3536
  droppableId = input(...(ngDevMode ? [undefined, { debugName: "droppableId" }] : []));
3537
+ /**
3538
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
3539
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
3540
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
3541
+ * @default false
3542
+ */
3543
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
2778
3544
  /**
2779
3545
  * Whether to automatically add the dragged item to the sticky list.
2780
3546
  * This ensures the dragged item remains visible during virtual scrolling.
@@ -2871,22 +3637,50 @@ class VirtualScrollContainerComponent {
2871
3637
  draggedItemId = computed(() => {
2872
3638
  return this.#dragState.draggedItem()?.draggableId ?? null;
2873
3639
  }, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
2874
- /** Map of item IDs to their indices - rebuilt only when items() changes (O(n) once, then O(1) lookups) */
2875
- #itemIndexMap = computed(() => {
2876
- const items = this.items();
3640
+ /**
3641
+ * Every item's ID, in order. Computed once per items change, and only when something reads it:
3642
+ * a dynamic-height strategy, a drag, or a sticky item outside the rendered rows. A fixed-height
3643
+ * strategy needs only the item count.
3644
+ */
3645
+ #itemIds = computed(() => {
2877
3646
  const idFn = this.itemIdFn();
3647
+ return this.items().map((item) => idFn(item));
3648
+ }, ...(ngDevMode ? [{ debugName: "#itemIds" }] : []));
3649
+ /**
3650
+ * Index of each item ID (the last, when several share one), for the sticky items outside the
3651
+ * rendered rows. Built on first use, so an items change (a drop) builds none.
3652
+ */
3653
+ #itemIndexMap = computed(() => {
3654
+ const ids = this.#itemIds();
2878
3655
  const map = new Map();
2879
- for (let i = 0; i < items.length; i++) {
2880
- map.set(idFn(items[i]), i);
3656
+ for (let i = 0; i < ids.length; i++) {
3657
+ map.set(ids[i], i);
2881
3658
  }
2882
3659
  return map;
2883
3660
  }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
2884
- /** The index of the currently dragged item in the items array (-1 if not found or not dragging) */
3661
+ /**
3662
+ * The index of the currently dragged item in the items array (-1 if not found or not dragging).
3663
+ * Found without the index map, which only this lookup would need on a drag: a drag that started
3664
+ * in another list has no item here (IDs are unique across lists), the index where the drag found
3665
+ * its item is checked next, and only then every item ID is searched.
3666
+ */
2885
3667
  #draggedItemIndex = computed(() => {
2886
3668
  const draggedId = this.draggedItemId();
2887
3669
  if (!draggedId)
2888
3670
  return -1;
2889
- return this.#itemIndexMap().get(draggedId) ?? -1;
3671
+ const droppableId = this.droppableId();
3672
+ const sourceDroppableId = this.#dragState.sourceDroppableId();
3673
+ if (droppableId && sourceDroppableId && droppableId !== sourceDroppableId)
3674
+ return -1;
3675
+ const sourceIndex = this.#dragState.sourceIndex();
3676
+ const items = this.items();
3677
+ if (sourceIndex !== null &&
3678
+ sourceIndex >= 0 &&
3679
+ sourceIndex < items.length &&
3680
+ this.itemIdFn()(items[sourceIndex]) === draggedId) {
3681
+ return sourceIndex;
3682
+ }
3683
+ return this.#itemIds().lastIndexOf(draggedId);
2890
3684
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
2891
3685
  /** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
2892
3686
  #stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
@@ -2911,31 +3705,20 @@ class VirtualScrollContainerComponent {
2911
3705
  }
2912
3706
  return this.itemHeight();
2913
3707
  }, ...(ngDevMode ? [{ debugName: "placeholderHeight" }] : []));
2914
- /** Items to render, including sticky items and placeholder */
2915
- renderedItems = computed(() => {
3708
+ /**
3709
+ * Rows to render: the items in range, then the sticky items outside it. Independent of the
3710
+ * placeholder, so a placeholder move leaves the rows as they are.
3711
+ */
3712
+ #renderedRows = computed(() => {
2916
3713
  const items = this.items();
2917
3714
  const { start, end } = this.#renderRange();
2918
3715
  const stickyIds = this.#stickyIdsSet();
2919
3716
  const idFn = this.itemIdFn();
2920
- const itemIndexMap = this.#itemIndexMap();
2921
3717
  const draggedId = this.draggedItemId();
2922
- const placeholderIdx = this.placeholderIndex();
2923
3718
  const result = [];
2924
3719
  const renderedIds = new Set();
2925
- let hasPlaceholder = false;
2926
- // Add all items in the visible range, inserting placeholder at correct position
3720
+ // Add all items in the visible range
2927
3721
  for (let i = start; i <= end && i < items.length; i++) {
2928
- // Insert placeholder before item at placeholderIndex
2929
- if (placeholderIdx === i && !hasPlaceholder) {
2930
- result.push({
2931
- type: 'placeholder',
2932
- data: null,
2933
- index: placeholderIdx,
2934
- isSticky: false,
2935
- isDragging: false,
2936
- });
2937
- hasPlaceholder = true;
2938
- }
2939
3722
  const item = items[i];
2940
3723
  const id = idFn(item);
2941
3724
  result.push({
@@ -2947,24 +3730,14 @@ class VirtualScrollContainerComponent {
2947
3730
  });
2948
3731
  renderedIds.add(id);
2949
3732
  }
2950
- // If placeholder is at the end (after all items), add it
2951
- if (placeholderIdx >= items.length && placeholderIdx >= 0 && !hasPlaceholder) {
2952
- result.push({
2953
- type: 'placeholder',
2954
- data: null,
2955
- index: placeholderIdx,
2956
- isSticky: false,
2957
- isDragging: false,
2958
- });
2959
- hasPlaceholder = true;
2960
- }
2961
3733
  // Add any sticky items that aren't already rendered
2962
3734
  const missingStickyIndices = [];
2963
3735
  for (const id of stickyIds) {
2964
3736
  if (renderedIds.has(id))
2965
3737
  continue;
2966
- const index = itemIndexMap.get(id);
2967
- if (index === undefined)
3738
+ // The drag knows its item's index; the index map serves the other sticky items
3739
+ const index = id === draggedId ? this.#draggedItemIndex() : (this.#itemIndexMap().get(id) ?? -1);
3740
+ if (index < 0)
2968
3741
  continue;
2969
3742
  missingStickyIndices.push({ id, index });
2970
3743
  }
@@ -2984,6 +3757,45 @@ class VirtualScrollContainerComponent {
2984
3757
  });
2985
3758
  }
2986
3759
  return result;
3760
+ }, ...(ngDevMode ? [{ debugName: "#renderedRows" }] : []));
3761
+ /** Track keys of #renderedRows, in order */
3762
+ #rowKeys = computed(() => this.#renderedRows().map((entry, i) => this.trackEntry(i, entry)), ...(ngDevMode ? [{ debugName: "#rowKeys" }] : []));
3763
+ /**
3764
+ * Where the placeholder renders among the rows: before the row at this position (after the
3765
+ * rows in range when it equals their count), or -1 when it is not rendered. It goes before the
3766
+ * item at the placeholder index when that item is in range, and after the rows in range when
3767
+ * the index is past the last item.
3768
+ */
3769
+ #placeholderSlot = computed(() => {
3770
+ const placeholderIndex = this.placeholderIndex();
3771
+ if (placeholderIndex < 0)
3772
+ return -1;
3773
+ const itemCount = this.items().length;
3774
+ const { start, end } = this.#renderRange();
3775
+ const lastInRange = Math.min(end, itemCount - 1);
3776
+ if (placeholderIndex >= start && placeholderIndex <= lastInRange) {
3777
+ return placeholderIndex - start;
3778
+ }
3779
+ if (placeholderIndex >= itemCount) {
3780
+ return Math.max(0, lastInRange - start + 1);
3781
+ }
3782
+ return -1;
3783
+ }, ...(ngDevMode ? [{ debugName: "#placeholderSlot" }] : []));
3784
+ /** Items to render, including sticky items and placeholder */
3785
+ renderedItems = computed(() => {
3786
+ const rows = this.#renderedRows();
3787
+ const slot = this.#placeholderSlot();
3788
+ const result = [...rows];
3789
+ if (slot >= 0) {
3790
+ result.splice(slot, 0, {
3791
+ type: 'placeholder',
3792
+ data: null,
3793
+ index: this.placeholderIndex(),
3794
+ isSticky: false,
3795
+ isDragging: false,
3796
+ });
3797
+ }
3798
+ return result;
2987
3799
  }, ...(ngDevMode ? [{ debugName: "renderedItems" }] : []));
2988
3800
  /** Generated ID for auto-scroll registration */
2989
3801
  #generatedScrollId = `vdnd-scroll-${Math.random().toString(36).slice(2, 9)}`;
@@ -2999,12 +3811,10 @@ class VirtualScrollContainerComponent {
2999
3811
  enabled: () => this.autoScrollEnabled(),
3000
3812
  config: () => this.autoScrollConfig(),
3001
3813
  });
3002
- // Keep strategy item keys in sync
3814
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
3815
+ // spares computing every item's ID on each items change (each drop).
3003
3816
  effect(() => {
3004
- const items = this.items();
3005
- const idFn = this.itemIdFn();
3006
- const keys = items.map((item) => idFn(item));
3007
- this.#strategy().setItemKeys(keys);
3817
+ setStrategyItems(this.#strategy(), this.items().length, () => this.#itemIds());
3008
3818
  });
3009
3819
  // Register strategy with drag index calculator for accurate drag calculations
3010
3820
  effect((onCleanup) => {
@@ -3021,16 +3831,6 @@ class VirtualScrollContainerComponent {
3021
3831
  const draggedIndex = this.#draggedItemIndex();
3022
3832
  strategy.setExcludedIndex(draggedIndex >= 0 ? draggedIndex : null);
3023
3833
  });
3024
- // Snapshot item positions before the template re-renders the placeholder move.
3025
- // Component effects run before the component's own template is refreshed.
3026
- if (this.#shiftAnimator) {
3027
- const animator = this.#shiftAnimator;
3028
- effect(() => {
3029
- const isDragging = this.#dragState.isDragging();
3030
- const placeholderIndex = this.placeholderIndex();
3031
- untracked(() => animator.beforeUpdate(isDragging, placeholderIndex));
3032
- });
3033
- }
3034
3834
  // Keyboard drag autoscroll: keep the placeholder visible. Arrow keys call
3035
3835
  // #revealPlaceholder synchronously (through KeyboardDragService) so a drop right after a
3036
3836
  // move never lands outside the rendered range; this effect re-runs it when item
@@ -3096,12 +3896,37 @@ class VirtualScrollContainerComponent {
3096
3896
  }
3097
3897
  }
3098
3898
  });
3899
+ effect(() => {
3900
+ const spacer = this.spacer()?.nativeElement;
3901
+ if (spacer) {
3902
+ spacer.style.height = `${this.totalHeight()}px`;
3903
+ }
3904
+ });
3905
+ // Last, so it renders what the effects above scrolled to. Component effects run before the
3906
+ // component's own view is checked, which has nothing to update: the template has no bindings.
3907
+ effect(() => this.#render());
3908
+ }
3909
+ ngOnChanges() {
3910
+ this.#viewCheckPending = true;
3099
3911
  }
3100
3912
  ngOnInit() {
3101
- // Set up ResizeObserver for dynamic height measurement
3102
- if (this.dynamicItemHeight()) {
3103
- this.#setupItemResizeObserver();
3104
- }
3913
+ // Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
3914
+ // strategy is replaced when it (or itemHeight) does, so this creates, replaces or removes the
3915
+ // observer. #observeRenderedItems observes every rendered row with each new observer, so a
3916
+ // new strategy gets their heights.
3917
+ effect((onCleanup) => {
3918
+ // No ResizeObserver during server rendering
3919
+ if (!this.dynamicItemHeight() || !this.#isBrowser)
3920
+ return;
3921
+ this.#strategy();
3922
+ const observer = untracked(() => this.#createItemResizeObserver());
3923
+ this.#itemResizeObserver.set(observer);
3924
+ onCleanup(() => {
3925
+ observer.disconnect();
3926
+ this.#observedElementByKey.clear();
3927
+ this.#itemResizeObserver.set(null);
3928
+ });
3929
+ }, { injector: this.#injector });
3105
3930
  }
3106
3931
  ngAfterViewInit() {
3107
3932
  const element = this.#elementRef.nativeElement;
@@ -3120,9 +3945,10 @@ class VirtualScrollContainerComponent {
3120
3945
  thresholdPx: 5,
3121
3946
  });
3122
3947
  // Observe rendered items for dynamic height measurement
3123
- if (this.dynamicItemHeight()) {
3124
- this.#observeRenderedItems();
3125
- }
3948
+ this.#observeRenderedItems();
3949
+ }
3950
+ ngAfterViewChecked() {
3951
+ this.#viewCheckPending = false;
3126
3952
  }
3127
3953
  /**
3128
3954
  * Scroll so the placeholder is fully visible during a keyboard drag into this list.
@@ -3168,15 +3994,295 @@ class VirtualScrollContainerComponent {
3168
3994
  this.#scrollTop.set(newScrollTop);
3169
3995
  }
3170
3996
  }
3997
+ /**
3998
+ * Render the content offset, the rows and the placeholder. From an effect, not the template:
3999
+ * Angular checks every row view in this component's view each time a signal its template reads
4000
+ * changes, so reading the placeholder there re-rendered every row on each placeholder move.
4001
+ * Here a placeholder move only moves the placeholder, and a row renders when its context does.
4002
+ */
4003
+ #render() {
4004
+ // Snapshot the rendered positions before anything moves, so displaced rows can slide. First,
4005
+ // as a subclass that renders its own template relies on it too.
4006
+ const isDragging = this.#dragState.isDragging();
4007
+ const placeholderIndex = this.placeholderIndex();
4008
+ untracked(() => this.#shiftAnimator?.beforeUpdate(isDragging, placeholderIndex));
4009
+ const outlet = this.rowOutlet();
4010
+ const wrapper = this.contentWrapper()?.nativeElement;
4011
+ // Not this component's template (a subclass's own): it renders renderedItems() itself
4012
+ if (!outlet || !wrapper)
4013
+ return;
4014
+ const template = this.itemTemplate() ?? null;
4015
+ const entries = this.#renderedRows();
4016
+ const keys = this.#rowKeys();
4017
+ const trackBy = this.effectiveTrackByFn();
4018
+ const recycle = this.recycleRows();
4019
+ const transform = this.contentTransform();
4020
+ const slot = this.#placeholderSlot();
4021
+ const placeholderHeight = slot >= 0 ? this.placeholderHeight() : null;
4022
+ // Untracked: the signals the rows' templates read must not become this effect's
4023
+ untracked(() => {
4024
+ if (transform !== this.#contentTransformSet) {
4025
+ wrapper.style.transform = transform;
4026
+ this.#contentTransformSet = transform;
4027
+ }
4028
+ this.#renderRows(outlet, template, entries, keys, trackBy, recycle);
4029
+ this.#renderPlaceholder(outlet, slot, placeholderHeight);
4030
+ });
4031
+ }
4032
+ /**
4033
+ * Give each entry a view of the item template, in order. As with `@for`, a row keeps its view
4034
+ * (and the components in it) while its track key stays rendered, and only the rows out of order
4035
+ * move. The view of a row that leaves is destroyed, or with `recycle` pooled and given to a row
4036
+ * that comes in. Only new, pooled and changed views are checked, with `detectChanges()`:
4037
+ * `markForCheck()` would mark every ancestor view up to the root too.
4038
+ */
4039
+ #renderRows(outlet, template, entries, keys, trackBy, recycle) {
4040
+ if (!recycle) {
4041
+ this.#destroyRowPool();
4042
+ }
4043
+ // Nothing to do, as on a placeholder move
4044
+ if (entries === this.#rowEntries &&
4045
+ template === this.#rowTemplate &&
4046
+ trackBy === this.#rowTrackBy) {
4047
+ return;
4048
+ }
4049
+ // A new template renders every row anew; without one there is no row (as ngTemplateOutlet)
4050
+ if (template !== this.#rowTemplate) {
4051
+ for (const row of this.#rows) {
4052
+ row.view.destroy();
4053
+ }
4054
+ this.#rows = [];
4055
+ this.#destroyRowPool();
4056
+ this.#rowTemplate = template;
4057
+ }
4058
+ if (!template)
4059
+ return;
4060
+ // A new track function gives the rendered rows new keys; they keep their views (as with @for)
4061
+ if (trackBy !== this.#rowTrackBy) {
4062
+ for (const row of this.#rows) {
4063
+ const { $implicit, index } = row.view.context;
4064
+ row.key = this.trackEntry(index, { type: 'item', data: $implicit, index });
4065
+ }
4066
+ this.#rowTrackBy = trackBy;
4067
+ }
4068
+ // The last render's views by key, in order: an entry takes the first view left for its key
4069
+ const previous = new Map();
4070
+ for (const row of this.#rows) {
4071
+ if (row.view.destroyed)
4072
+ continue;
4073
+ const views = previous.get(row.key);
4074
+ if (views) {
4075
+ views.push(row);
4076
+ }
4077
+ else {
4078
+ previous.set(row.key, [row]);
4079
+ }
4080
+ }
4081
+ const kept = keys.map((key) => previous.get(key)?.shift());
4082
+ this.#warnDuplicateKeys(keys);
4083
+ // Remove the rows that left, then keep in place the longest run of kept views that is
4084
+ // already in order (their positions in the outlet, which now holds only kept views)
4085
+ const dragged = this.#dragState.draggedItem();
4086
+ for (const views of previous.values()) {
4087
+ for (const row of views) {
4088
+ // The dragged item's view is destroyed, as without recycling: that cancels the drag
4089
+ // instead of handing it to another item
4090
+ if (recycle && !this.#rendersDraggedItem(row.view, dragged)) {
4091
+ this.#poolRow(outlet, row.view);
4092
+ }
4093
+ else {
4094
+ row.view.destroy();
4095
+ }
4096
+ }
4097
+ }
4098
+ const positions = new Map();
4099
+ for (let i = 0; i < outlet.length; i++) {
4100
+ positions.set(outlet.get(i), i);
4101
+ }
4102
+ const inPlace = longestIncreasingRun(kept.map((row) => (row ? positions.get(row.view) : -1)));
4103
+ // From the last row up, so each row goes right before the next one, already in place
4104
+ const rows = new Array(entries.length);
4105
+ const toCheck = [];
4106
+ let next = null;
4107
+ for (let i = entries.length - 1; i >= 0; i--) {
4108
+ const entry = entries[i];
4109
+ let row = kept[i];
4110
+ if (row) {
4111
+ if (!inPlace[i]) {
4112
+ this.#moveBefore(outlet, row.view, next);
4113
+ }
4114
+ if (this.#updateContext(row.view.context, entry)) {
4115
+ toCheck.push(row.view);
4116
+ }
4117
+ }
4118
+ else {
4119
+ const index = next ? outlet.indexOf(next) : outlet.length;
4120
+ // A pooled view is checked whatever its context: nothing checked it while detached
4121
+ let view = this.#rowPool.pop();
4122
+ if (view) {
4123
+ this.#updateContext(view.context, entry);
4124
+ outlet.insert(view, index);
4125
+ }
4126
+ else {
4127
+ const context = {
4128
+ $implicit: entry.data,
4129
+ index: entry.index,
4130
+ isSticky: entry.isSticky,
4131
+ };
4132
+ view = outlet.createEmbeddedView(template, context, index);
4133
+ }
4134
+ row = { key: keys[i], view };
4135
+ toCheck.push(view);
4136
+ }
4137
+ rows[i] = row;
4138
+ next = row.view;
4139
+ }
4140
+ this.#rows = rows;
4141
+ while (this.#rowPool.length > MAX_POOLED_ROWS) {
4142
+ this.#rowPool.pop().destroy();
4143
+ }
4144
+ // When this component's own view is checked later in this pass, that check renders every row
4145
+ if (!this.#viewCheckPending) {
4146
+ // Top to bottom (toCheck lists them bottom up)
4147
+ for (let i = toCheck.length - 1; i >= 0; i--) {
4148
+ toCheck[i].detectChanges();
4149
+ }
4150
+ }
4151
+ this.#rowEntries = entries;
4152
+ }
4153
+ /**
4154
+ * Whether a row's view renders the dragged item: its item has the dragged ID, or the view holds
4155
+ * the dragged element (its draggable ID need not be the item's ID).
4156
+ */
4157
+ #rendersDraggedItem(view, dragged) {
4158
+ if (!dragged)
4159
+ return false;
4160
+ return (this.itemIdFn()(view.context.$implicit) === dragged.draggableId ||
4161
+ view.rootNodes.some((node) => node.contains(dragged.element)));
4162
+ }
4163
+ /**
4164
+ * Take a row's view out of the outlet and pool it. It keeps its element, so its shift
4165
+ * animation is cancelled (the item it renders next must not slide from this one's position),
4166
+ * and a drop animation that hides it shows it again.
4167
+ */
4168
+ #poolRow(outlet, view) {
4169
+ outlet.detach(outlet.indexOf(view));
4170
+ const nodes = view.rootNodes;
4171
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
4172
+ // the animation ends: in the row of another item by then, if the view were reused
4173
+ if (nodes.some((node) => node.parentNode !== null)) {
4174
+ view.destroy();
4175
+ return;
4176
+ }
4177
+ for (const node of nodes) {
4178
+ if (node instanceof HTMLElement) {
4179
+ this.#shiftAnimator?.cancel(node);
4180
+ }
4181
+ }
4182
+ revealDropTargetIn(nodes);
4183
+ this.#rowPool.push(view);
4184
+ }
4185
+ #destroyRowPool() {
4186
+ for (const view of this.#rowPool) {
4187
+ view.destroy();
4188
+ }
4189
+ this.#rowPool = [];
4190
+ }
4191
+ /** Move a row's view right before `next` (last without one), unless it is there already. */
4192
+ #moveBefore(outlet, view, next) {
4193
+ const target = next ? outlet.indexOf(next) : outlet.length;
4194
+ const current = outlet.indexOf(view);
4195
+ if (current === target - 1)
4196
+ return;
4197
+ // move() takes the view out before inserting it, which shifts the views after it
4198
+ outlet.move(view, current < target ? target - 1 : target);
4199
+ }
4200
+ /** Copy an entry into a row's context. Returns whether the context changed. */
4201
+ #updateContext(context, entry) {
4202
+ if (Object.is(context.$implicit, entry.data) &&
4203
+ context.index === entry.index &&
4204
+ context.isSticky === entry.isSticky) {
4205
+ return false;
4206
+ }
4207
+ context.$implicit = entry.data;
4208
+ context.index = entry.index;
4209
+ context.isSticky = entry.isSticky;
4210
+ return true;
4211
+ }
4212
+ /** Items sharing a track key still render (a view each), but drag and drop needs unique IDs. */
4213
+ #warnDuplicateKeys(keys) {
4214
+ if (this.#warnedDuplicateKeys || keys === this.#rowKeysChecked || !isDevMode())
4215
+ return;
4216
+ this.#rowKeysChecked = keys;
4217
+ const seen = new Set();
4218
+ for (const key of keys) {
4219
+ if (seen.has(key)) {
4220
+ this.#warnedDuplicateKeys = true;
4221
+ console.warn(`[ngx-virtual-dnd] vdnd-virtual-scroll rendered two items with the track key ` +
4222
+ `${String(key)}. Give every item a unique ID (itemIdFn, or trackByFn).`);
4223
+ return;
4224
+ }
4225
+ seen.add(key);
4226
+ }
4227
+ }
4228
+ /**
4229
+ * Put the placeholder before the row at `slot` (see #placeholderSlot), or take it out of the
4230
+ * DOM. It is a DragPlaceholderComponent of its own, outside this view: moving it or setting its
4231
+ * height checks no row.
4232
+ */
4233
+ #renderPlaceholder(outlet, slot, height) {
4234
+ if (slot < 0 || height === null) {
4235
+ this.#placeholderElement()?.remove();
4236
+ return;
4237
+ }
4238
+ this.#placeholder ??= createComponent(DragPlaceholderComponent, {
4239
+ environmentInjector: this.#environmentInjector,
4240
+ });
4241
+ if (height !== this.#placeholderHeightSet) {
4242
+ this.#placeholder.setInput('itemHeight', height);
4243
+ this.#placeholder.changeDetectorRef.detectChanges();
4244
+ this.#placeholderHeightSet = height;
4245
+ }
4246
+ // Rows are inserted before the outlet's anchor, so the anchor follows the last of them
4247
+ const anchor = outlet.element.nativeElement;
4248
+ const parent = anchor.parentNode;
4249
+ const element = this.#placeholderElement();
4250
+ if (!parent || !element)
4251
+ return;
4252
+ const before = this.#firstNodeOfRows(slot, parent) ?? anchor;
4253
+ if (element.nextSibling !== before) {
4254
+ parent.insertBefore(element, before);
4255
+ }
4256
+ }
4257
+ #placeholderElement() {
4258
+ return this.#placeholder?.location.nativeElement ?? null;
4259
+ }
4260
+ /** The first node in `parent` of the rows from `index` on (null when none has one). */
4261
+ #firstNodeOfRows(index, parent) {
4262
+ for (let i = index; i < this.#rows.length; i++) {
4263
+ const nodes = this.#rows[i].view.rootNodes.filter((node) => node.parentNode === parent);
4264
+ const node = firstInDocumentOrder(nodes);
4265
+ if (node)
4266
+ return node;
4267
+ }
4268
+ return null;
4269
+ }
3171
4270
  ngOnDestroy() {
3172
4271
  this.#scrollCleanup?.();
3173
4272
  this.#resizeCleanup?.();
4273
+ const itemResizeObserver = this.#itemResizeObserver();
3174
4274
  for (const element of this.#observedElementByKey.values()) {
3175
- this.#itemResizeObserver?.unobserve(element);
4275
+ itemResizeObserver?.unobserve(element);
3176
4276
  }
3177
4277
  this.#observedElementByKey.clear();
3178
- this.#itemResizeObserver?.disconnect();
4278
+ itemResizeObserver?.disconnect();
3179
4279
  this.#shiftAnimator?.cancelAll();
4280
+ // Detached, so not destroyed with this view
4281
+ this.#destroyRowPool();
4282
+ // Created outside this view, so not destroyed with it (and destroy() leaves it in the DOM)
4283
+ this.#placeholderElement()?.remove();
4284
+ this.#placeholder?.destroy();
4285
+ this.#placeholder = null;
3180
4286
  }
3181
4287
  #createShiftAnimator() {
3182
4288
  const config = inject(VDND_ANIMATION_CONFIG, { optional: true });
@@ -3190,10 +4296,25 @@ class VirtualScrollContainerComponent {
3190
4296
  });
3191
4297
  }
3192
4298
  /**
3193
- * Rendered item roots and the placeholder. `@for` never reuses a view for a different
3194
- * track key, so each element is its own stable identity.
4299
+ * Rendered item roots, keyed by the row's track key (a recycled view renders another item, so
4300
+ * its element is no identity), and the placeholder.
3195
4301
  */
3196
4302
  *#shiftAnimationEntries() {
4303
+ if (this.rowOutlet()) {
4304
+ for (const row of this.#rows) {
4305
+ for (const node of row.view.rootNodes) {
4306
+ if (node instanceof HTMLElement) {
4307
+ yield [row.key, node];
4308
+ }
4309
+ }
4310
+ }
4311
+ const placeholder = this.#placeholderElement();
4312
+ if (placeholder?.isConnected) {
4313
+ yield [placeholder, placeholder];
4314
+ }
4315
+ return;
4316
+ }
4317
+ // A subclass's own template (see #render) never recycles: each element is its own identity
3197
4318
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3198
4319
  if (!wrapper)
3199
4320
  return;
@@ -3204,50 +4325,52 @@ class VirtualScrollContainerComponent {
3204
4325
  }
3205
4326
  }
3206
4327
  /**
3207
- * Set up ResizeObserver for measuring individual item heights.
4328
+ * Create the ResizeObserver that measures individual item heights.
3208
4329
  */
3209
- #setupItemResizeObserver() {
3210
- this.#ngZone.runOutsideAngular(() => {
3211
- this.#itemResizeObserver = new ResizeObserver((entries) => {
3212
- const strategy = this.#strategy();
3213
- for (const entry of entries) {
3214
- const element = entry.target;
3215
- const key = this.#observedElements.get(element);
3216
- if (key === undefined)
3217
- continue;
3218
- const height = entry.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
3219
- if (height > 0) {
3220
- strategy.setMeasuredHeight(key, height);
3221
- }
4330
+ #createItemResizeObserver() {
4331
+ return this.#ngZone.runOutsideAngular(() => new ResizeObserver((entries) => {
4332
+ const strategy = this.#strategy();
4333
+ for (const entry of entries) {
4334
+ const element = entry.target;
4335
+ const key = this.#observedElements.get(element);
4336
+ if (key === undefined)
4337
+ continue;
4338
+ const height = entry.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
4339
+ if (height > 0) {
4340
+ strategy.setMeasuredHeight(key, height);
3222
4341
  }
3223
- });
3224
- });
4342
+ }
4343
+ }));
3225
4344
  }
3226
4345
  /**
3227
4346
  * Observe currently rendered items' DOM elements for height changes.
3228
- * Called after view init and on each render cycle in dynamic mode.
4347
+ * Called after view init; re-observes after each render in dynamic mode, and every rendered
4348
+ * item when a new observer is created.
3229
4349
  */
3230
4350
  #observeRenderedItems() {
3231
- if (!this.#itemResizeObserver)
3232
- return;
3233
- // Use an effect to re-observe whenever rendered items change
3234
- effect(() => {
3235
- const rendered = this.renderedItems();
3236
- const idFn = this.itemIdFn();
3237
- const observer = this.#itemResizeObserver;
4351
+ // Re-observe whenever the rows change, once #render has put them in the DOM. The rows, not
4352
+ // renderedItems(): a placeholder move changes no row. A new item template changes no entry
4353
+ // but renders every row anew, as new elements.
4354
+ afterRenderEffect(() => {
4355
+ const observer = this.#itemResizeObserver();
3238
4356
  if (!observer)
3239
4357
  return;
4358
+ const rendered = this.#renderedRows();
4359
+ const idFn = this.itemIdFn();
4360
+ this.itemTemplate();
3240
4361
  // Find the content wrapper and observe item elements
3241
4362
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3242
4363
  if (!wrapper)
3243
4364
  return;
4365
+ // The DOM element of each item, by its data-draggable-id: one query for all rows, where a
4366
+ // query per row scanned the wrapper once for each (quadratic in the rendered rows)
4367
+ const elementsById = mapByAttribute(wrapper, 'data-draggable-id');
3244
4368
  const nextObservedByKey = new Map();
3245
4369
  for (const entry of rendered) {
3246
4370
  if (entry.type !== 'item' || !entry.data)
3247
4371
  continue;
3248
4372
  const key = idFn(entry.data);
3249
- // Find the DOM element for this item by its data-draggable-id.
3250
- const el = queryByAttribute(wrapper, 'data-draggable-id', key);
4373
+ const el = elementsById.get(key);
3251
4374
  if (el) {
3252
4375
  nextObservedByKey.set(key, el);
3253
4376
  }
@@ -3275,8 +4398,11 @@ class VirtualScrollContainerComponent {
3275
4398
  * Scroll to a specific position.
3276
4399
  */
3277
4400
  scrollTo(position) {
3278
- this.#elementRef.nativeElement.scrollTop = position;
3279
- this.#scrollTop.set(position);
4401
+ const element = this.#elementRef.nativeElement;
4402
+ element.scrollTop = position;
4403
+ // Store where the browser actually scrolled. It clamps to the scrollable range, and if that
4404
+ // leaves the element where it was, no scroll event arrives to correct an out-of-range value.
4405
+ this.#scrollTop.set(element.scrollTop);
3280
4406
  }
3281
4407
  /**
3282
4408
  * Scroll to a specific item index.
@@ -3306,183 +4432,33 @@ class VirtualScrollContainerComponent {
3306
4432
  this.scrollTo(newPosition);
3307
4433
  }
3308
4434
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3309
- 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: `
4435
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style.height.px": "containerHeight() ?? null", "attr.data-item-height": "itemHeight()", "attr.data-total-items": "items().length" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "spacer", first: true, predicate: ["spacer"], descendants: true, isSignal: true }, { propertyName: "contentWrapper", first: true, predicate: ["contentWrapper"], descendants: true, isSignal: true }, { propertyName: "rowOutlet", first: true, predicate: ["rows"], descendants: true, read: ViewContainerRef, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
3310
4436
  <!-- Single spacer maintains scroll height -->
3311
- <div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
4437
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
3312
4438
 
3313
4439
  <!-- Content wrapper positioned via GPU-accelerated transform -->
3314
- <div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
3315
- @for (entry of renderedItems(); track trackEntry($index, entry)) {
3316
- @if (entry.type === 'placeholder') {
3317
- <vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
3318
- } @else {
3319
- <ng-container
3320
- *ngTemplateOutlet="
3321
- itemTemplate();
3322
- context: {
3323
- $implicit: entry.data,
3324
- index: entry.index,
3325
- isSticky: entry.isSticky,
3326
- }
3327
- "
3328
- >
3329
- </ng-container>
3330
- }
3331
- }
4440
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4441
+ <ng-container #rows />
3332
4442
  </div>
3333
- `, 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 });
4443
+ `, isInline: true, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3334
4444
  }
3335
4445
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, decorators: [{
3336
4446
  type: Component,
3337
- args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet, DragPlaceholderComponent], host: {
4447
+ args: [{ selector: 'vdnd-virtual-scroll', changeDetection: ChangeDetectionStrategy.OnPush, host: {
3338
4448
  class: 'vdnd-virtual-scroll',
3339
4449
  '[style.height.px]': 'containerHeight() ?? null',
3340
4450
  '[attr.data-item-height]': 'itemHeight()',
3341
4451
  '[attr.data-total-items]': 'items().length',
3342
4452
  }, template: `
3343
4453
  <!-- Single spacer maintains scroll height -->
3344
- <div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
4454
+ <div #spacer class="vdnd-virtual-scroll-spacer"></div>
3345
4455
 
3346
4456
  <!-- Content wrapper positioned via GPU-accelerated transform -->
3347
- <div class="vdnd-virtual-scroll-content-wrapper" [style.transform]="contentTransform()">
3348
- @for (entry of renderedItems(); track trackEntry($index, entry)) {
3349
- @if (entry.type === 'placeholder') {
3350
- <vdnd-drag-placeholder [itemHeight]="placeholderHeight()" />
3351
- } @else {
3352
- <ng-container
3353
- *ngTemplateOutlet="
3354
- itemTemplate();
3355
- context: {
3356
- $implicit: entry.data,
3357
- index: entry.index,
3358
- isSticky: entry.isSticky,
3359
- }
3360
- "
3361
- >
3362
- </ng-container>
3363
- }
3364
- }
4457
+ <div #contentWrapper class="vdnd-virtual-scroll-content-wrapper">
4458
+ <ng-container #rows />
3365
4459
  </div>
3366
4460
  `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-virtual-scroll-spacer{position:absolute;top:0;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-virtual-scroll-content-wrapper{position:absolute;top:0;left:0;right:0;will-change:transform}\n"] }]
3367
- }], 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 }] }] } });
3368
-
3369
- /**
3370
- * Plays the drop ("settle") animation: the drag preview glides from where it was released
3371
- * into the dropped item's final position, while that item stays invisible underneath.
3372
- *
3373
- * Purely visual. The drag state and the drop events are already final when it starts, so it
3374
- * never delays or changes a drop. Without a target (the item is not rendered, or scrolled out
3375
- * of view) the ghost fades out where it was released.
3376
- * @internal
3377
- */
3378
- class DropAnimator {
3379
- #config;
3380
- #ghostAnimation = null;
3381
- #hideAnimation = null;
3382
- constructor(config) {
3383
- this.#config = config;
3384
- }
3385
- /** Whether a drop animation would play right now (duration, WAAPI, reduced motion). */
3386
- isEnabled() {
3387
- if (this.#duration() <= 0)
3388
- return false;
3389
- if (typeof Element === 'undefined' || typeof Element.prototype.animate !== 'function') {
3390
- return false;
3391
- }
3392
- if (typeof matchMedia !== 'function')
3393
- return true;
3394
- return !matchMedia('(prefers-reduced-motion: reduce)').matches;
3395
- }
3396
- /**
3397
- * Animate `ghost` onto `target` (or fade it out without one), then call `onDone`.
3398
- * `onDone` is not called when the animation is cancelled.
3399
- */
3400
- play(ghost, target, onDone) {
3401
- this.cancel();
3402
- const timing = {
3403
- duration: this.#duration(),
3404
- easing: this.#config.dropEasing ?? DEFAULT_DROP_EASING,
3405
- fill: 'forwards',
3406
- };
3407
- let ghostAnimation;
3408
- if (target) {
3409
- const from = ghost.getBoundingClientRect();
3410
- const dx = target.rect.left - from.left;
3411
- const dy = target.rect.top - from.top;
3412
- // Travel on top of the preview's own position transform (width/height must not be
3413
- // composited, so the base transform is repeated instead of using composite: 'add').
3414
- const base = ghost.style.transform;
3415
- ghostAnimation = ghost.animate([
3416
- {
3417
- transform: `${base} translate(0px, 0px)`,
3418
- width: `${from.width}px`,
3419
- height: `${from.height}px`,
3420
- },
3421
- {
3422
- transform: `${base} translate(${dx}px, ${dy}px)`,
3423
- width: `${target.rect.width}px`,
3424
- height: `${target.rect.height}px`,
3425
- },
3426
- ], timing);
3427
- // Keep the real item invisible until the ghost lands on it, so it is never seen twice.
3428
- this.#hideAnimation = target.element.animate([{ opacity: 0 }, { opacity: 0 }], {
3429
- duration: timing.duration,
3430
- });
3431
- }
3432
- else {
3433
- ghostAnimation = ghost.animate([{ opacity: 1 }, { opacity: 0 }], timing);
3434
- }
3435
- this.#ghostAnimation = ghostAnimation;
3436
- ghostAnimation.onfinish = () => {
3437
- if (this.#ghostAnimation !== ghostAnimation)
3438
- return;
3439
- this.cancel();
3440
- onDone();
3441
- };
3442
- }
3443
- /** Stop the running animation immediately and reveal the real item. */
3444
- cancel() {
3445
- const ghostAnimation = this.#ghostAnimation;
3446
- const hideAnimation = this.#hideAnimation;
3447
- this.#ghostAnimation = null;
3448
- this.#hideAnimation = null;
3449
- ghostAnimation?.cancel();
3450
- hideAnimation?.cancel();
3451
- }
3452
- #duration() {
3453
- return this.#config.dropDuration ?? DEFAULT_DROP_DURATION;
3454
- }
3455
- }
3456
- /**
3457
- * Find the rendered element of a dropped item: first in the droppable it was dropped on, then
3458
- * in the one it came from (a drop the consumer rejected or has not committed yet leaves it
3459
- * there). Returns null when it is not rendered, or rendered but scrolled out of its list's view.
3460
- */
3461
- function findDropTarget(draggableId, droppableIds) {
3462
- if (typeof document === 'undefined')
3463
- return null;
3464
- for (const droppableId of droppableIds) {
3465
- if (!droppableId)
3466
- continue;
3467
- const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
3468
- const element = droppable
3469
- ? queryByAttribute(droppable, 'data-draggable-id', draggableId)
3470
- : null;
3471
- if (!droppable || !element)
3472
- continue;
3473
- const rect = element.getBoundingClientRect();
3474
- // Hidden, or virtualized/scrolled out of the list's visible box: nowhere to land.
3475
- if (rect.width === 0 && rect.height === 0)
3476
- return null;
3477
- const bounds = droppable.getBoundingClientRect();
3478
- const visible = rect.bottom > bounds.top &&
3479
- rect.top < bounds.bottom &&
3480
- rect.right > bounds.left &&
3481
- rect.left < bounds.right;
3482
- return visible ? { element, rect } : null;
3483
- }
3484
- return null;
3485
- }
4461
+ }], ctorParameters: () => [], propDecorators: { spacer: [{ type: i0.ViewChild, args: ['spacer', { isSignal: true }] }], contentWrapper: [{ type: i0.ViewChild, args: ['contentWrapper', { isSignal: true }] }], rowOutlet: [{ type: i0.ViewChild, args: ['rows', { ...{ read: ViewContainerRef }, isSignal: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], scrollContainerId: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollContainerId", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], stickyItemIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "stickyItemIds", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoStickyDraggedItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoStickyDraggedItem", required: false }] }] } });
3486
4462
 
3487
4463
  /**
3488
4464
  * Renders a preview of the dragged item that follows the cursor.
@@ -3508,6 +4484,8 @@ class DragPreviewComponent {
3508
4484
  #overlayContainer = inject(OverlayContainerService);
3509
4485
  #elementRef = inject((ElementRef));
3510
4486
  #injector = inject(Injector);
4487
+ #droppableRegistry = inject(DroppableRegistryService);
4488
+ #scheduler = inject(DragSchedulerService);
3511
4489
  #dropAnimator = this.#createDropAnimator();
3512
4490
  /** Optional custom template for the preview */
3513
4491
  previewTemplate = input(...(ngDevMode ? [undefined, { debugName: "previewTemplate" }] : []));
@@ -3525,6 +4503,39 @@ class DragPreviewComponent {
3525
4503
  isSettling = computed(() => this.#settling() !== null, ...(ngDevMode ? [{ debugName: "isSettling" }] : []));
3526
4504
  /** The item shown: the live dragged item, or the dropped one while it settles */
3527
4505
  displayedItem = computed(() => this.dragState.draggedItem() ?? this.#settling()?.item ?? null, ...(ngDevMode ? [{ debugName: "displayedItem" }] : []));
4506
+ /**
4507
+ * Where the preview goes, from everything but the cursor. The template and its effects never
4508
+ * read the cursor: a signal read there would schedule a full application render on every
4509
+ * pointer frame, and that render lands after the frame is painted (one frame late).
4510
+ */
4511
+ #placement = computed(() => {
4512
+ const settling = this.#settling();
4513
+ if (settling && !this.dragState.isDragging()) {
4514
+ return {
4515
+ settled: settling.position,
4516
+ offset: { x: 0, y: 0 },
4517
+ initialPosition: null,
4518
+ lockAxis: null,
4519
+ };
4520
+ }
4521
+ return this.#placementFor({
4522
+ grabOffset: this.dragState.grabOffset(),
4523
+ initialPosition: this.dragState.initialPosition(),
4524
+ lockAxis: this.dragState.lockAxis(),
4525
+ });
4526
+ }, ...(ngDevMode ? [{ debugName: "#placement" }] : []));
4527
+ /**
4528
+ * Moves the preview inside the drag scheduler's frame, right after the hit-test wrote the
4529
+ * cursor (bound for stable add/remove). The render-time write in the constructor covers the
4530
+ * rest: the preview appearing, keyboard drags and the settle.
4531
+ */
4532
+ #writeFramePosition = () => {
4533
+ const preview = this.previewElement()?.nativeElement;
4534
+ if (!preview || this.#settling() || !this.dragState.isDragging()) {
4535
+ return;
4536
+ }
4537
+ this.#place(preview, this.#placement(), this.dragState.cursorPosition());
4538
+ };
3528
4539
  /** Whether this preview's template registration is currently counted (kept balanced). */
3529
4540
  #templateRegistered = false;
3530
4541
  /** The cloned element from drag state (used when no custom template is provided) */
@@ -3535,6 +4546,7 @@ class DragPreviewComponent {
3535
4546
  return this.displayedItem()?.clonedElement ?? null;
3536
4547
  }, ...(ngDevMode ? [{ debugName: "clonedElement" }] : []));
3537
4548
  constructor() {
4549
+ this.#scheduler.addFrameWriter(this.#writeFramePosition);
3538
4550
  // Teleport host element into the body-level overlay container after first render.
3539
4551
  // This escapes any ancestor CSS transforms that would break position: fixed.
3540
4552
  afterNextRender(() => {
@@ -3543,6 +4555,18 @@ class DragPreviewComponent {
3543
4555
  container.appendChild(this.#elementRef.nativeElement);
3544
4556
  }
3545
4557
  });
4558
+ // Place the preview when it appears or anything but the cursor changes (drag start,
4559
+ // keyboard drags, the settle). The cursor is read untracked; pointer moves are written by
4560
+ // #writeFramePosition. Runs before the settle's afterNextRender reads the transform.
4561
+ afterRenderEffect({
4562
+ write: () => {
4563
+ const preview = this.previewElement()?.nativeElement;
4564
+ const placement = this.#placement();
4565
+ if (preview) {
4566
+ this.#place(preview, placement, untracked(this.dragState.cursorPosition));
4567
+ }
4568
+ },
4569
+ });
3546
4570
  // Publish whether this preview renders via a custom template so the drag
3547
4571
  // directives can skip the drag-start element clone when it won't be shown.
3548
4572
  effect(() => {
@@ -3587,6 +4611,7 @@ class DragPreviewComponent {
3587
4611
  });
3588
4612
  }
3589
4613
  ngOnDestroy() {
4614
+ this.#scheduler.removeFrameWriter(this.#writeFramePosition);
3590
4615
  this.#dropAnimator?.cancel();
3591
4616
  if (this.#templateRegistered) {
3592
4617
  this.#overlayContainer.setTemplatePreviewActive(false);
@@ -3594,32 +4619,31 @@ class DragPreviewComponent {
3594
4619
  }
3595
4620
  this.#elementRef.nativeElement.remove();
3596
4621
  }
3597
- /** Whether the preview is visible */
4622
+ /**
4623
+ * Whether the preview is visible. A drag shows it once it has a position: `initialPosition`
4624
+ * is set from the drag-start cursor and, unlike `cursorPosition`, does not change per frame.
4625
+ */
3598
4626
  isVisible = computed(() => {
3599
- return ((this.dragState.isDragging() && this.dragState.cursorPosition() !== null) || this.isSettling());
4627
+ return ((this.dragState.isDragging() && this.dragState.initialPosition() !== null) ||
4628
+ this.isSettling());
3600
4629
  }, ...(ngDevMode ? [{ debugName: "isVisible" }] : []));
3601
- /** Position of the preview */
3602
- position = computed(() => {
3603
- const settling = this.#settling();
3604
- if (settling && !this.dragState.isDragging()) {
3605
- return settling.position;
4630
+ #placementFor(state) {
4631
+ return {
4632
+ settled: null,
4633
+ // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
4634
+ offset: state.grabOffset ?? this.cursorOffset(),
4635
+ initialPosition: state.initialPosition,
4636
+ lockAxis: state.lockAxis,
4637
+ };
4638
+ }
4639
+ #positionAt(placement, cursor) {
4640
+ if (placement.settled) {
4641
+ return placement.settled;
3606
4642
  }
3607
- return this.#positionFor({
3608
- cursorPosition: this.dragState.cursorPosition(),
3609
- grabOffset: this.dragState.grabOffset(),
3610
- initialPosition: this.dragState.initialPosition(),
3611
- lockAxis: this.dragState.lockAxis(),
3612
- });
3613
- }, ...(ngDevMode ? [{ debugName: "position" }] : []));
3614
- #positionFor(state) {
3615
- const cursor = state.cursorPosition;
3616
- const initialPosition = state.initialPosition;
3617
- const lockAxis = state.lockAxis;
3618
4643
  if (!cursor) {
3619
4644
  return { x: 0, y: 0 };
3620
4645
  }
3621
- // Use grab offset if available (preserves grab position), otherwise fall back to cursorOffset input
3622
- const offset = state.grabOffset ?? this.cursorOffset();
4646
+ const { offset, initialPosition, lockAxis } = placement;
3623
4647
  let x = cursor.x - offset.x;
3624
4648
  let y = cursor.y - offset.y;
3625
4649
  // Apply axis locking if configured
@@ -3636,10 +4660,10 @@ class DragPreviewComponent {
3636
4660
  return { x, y };
3637
4661
  }
3638
4662
  /** Transform-based positioning for better performance (avoid layout from left/top). */
3639
- transform = computed(() => {
3640
- const { x, y } = this.position();
3641
- return `translate3d(${x}px, ${y}px, 0)`;
3642
- }, ...(ngDevMode ? [{ debugName: "transform" }] : []));
4663
+ #place(preview, placement, cursor) {
4664
+ const { x, y } = this.#positionAt(placement, cursor);
4665
+ preview.style.transform = `translate3d(${x}px, ${y}px, 0)`;
4666
+ }
3643
4667
  /** Dimensions of the preview */
3644
4668
  dimensions = computed(() => {
3645
4669
  const item = this.displayedItem();
@@ -3675,7 +4699,7 @@ class DragPreviewComponent {
3675
4699
  }
3676
4700
  const settling = {
3677
4701
  item: ended.draggedItem,
3678
- position: this.#positionFor(ended),
4702
+ position: this.#positionAt(this.#placementFor(ended), ended.cursorPosition),
3679
4703
  };
3680
4704
  this.#settling.set(settling);
3681
4705
  // A normal drop lands in the target list; a cancel, a rejected drop or one the consumer
@@ -3692,7 +4716,7 @@ class DragPreviewComponent {
3692
4716
  this.#settling.set(null);
3693
4717
  return;
3694
4718
  }
3695
- const target = findDropTarget(settling.item.draggableId, droppableIds);
4719
+ const target = findDropTarget(settling.item.draggableId, droppableIds, (id) => this.#droppableRegistry.getById(id));
3696
4720
  this.#dropAnimator.play(ghost, target, () => {
3697
4721
  if (this.#settling() === settling) {
3698
4722
  this.#settling.set(null);
@@ -3712,7 +4736,6 @@ class DragPreviewComponent {
3712
4736
  class="vdnd-drag-preview"
3713
4737
  [class.vdnd-drag-preview-dropping]="isSettling()"
3714
4738
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3715
- [style.transform]="transform()"
3716
4739
  [style.width.px]="dimensions().width"
3717
4740
  [style.height.px]="dimensions().height"
3718
4741
  >
@@ -3739,7 +4762,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3739
4762
  class="vdnd-drag-preview"
3740
4763
  [class.vdnd-drag-preview-dropping]="isSettling()"
3741
4764
  [attr.data-testid]="isSettling() ? 'vdnd-drag-preview-dropping' : 'vdnd-drag-preview'"
3742
- [style.transform]="transform()"
3743
4765
  [style.width.px]="dimensions().width"
3744
4766
  [style.height.px]="dimensions().height"
3745
4767
  >
@@ -3940,7 +4962,8 @@ class DroppableDirective {
3940
4962
  #elementRef = inject((ElementRef));
3941
4963
  #dragState = inject(DragStateService);
3942
4964
  #autoScroll = inject(AutoScrollService);
3943
- #positionCalculator = inject(PositionCalculatorService);
4965
+ #registry = inject(DroppableRegistryService);
4966
+ #ngZone = inject(NgZone);
3944
4967
  #parentGroup = inject(VDND_GROUP_TOKEN, { optional: true });
3945
4968
  /** Unique identifier for this droppable */
3946
4969
  vdndDroppable = input.required(...(ngDevMode ? [{ debugName: "vdndDroppable" }] : []));
@@ -3969,7 +4992,7 @@ class DroppableDirective {
3969
4992
  autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
3970
4993
  /** Constrain drag preview and placeholder to container boundaries */
3971
4994
  constrainToContainer = input(false, ...(ngDevMode ? [{ debugName: "constrainToContainer" }] : []));
3972
- /** Emits when an item is dropped on this droppable */
4995
+ /** Emits when an item is dropped on this droppable: at release, right after its `dragEnd` */
3973
4996
  // eslint-disable-next-line @angular-eslint/no-output-native
3974
4997
  drop = output();
3975
4998
  /**
@@ -3982,7 +5005,11 @@ class DroppableDirective {
3982
5005
  const activeId = this.#dragState.activeDroppableId();
3983
5006
  return activeId === this.vdndDroppable() && !this.disabled();
3984
5007
  }, ...(ngDevMode ? [{ debugName: "isActive" }] : []));
3985
- /** The current placeholder ID when this droppable is active */
5008
+ /**
5009
+ * `END_OF_LIST` while this droppable is active, otherwise null.
5010
+ * @deprecated It never identifies an item. For the position, use the `placeholderMove` output
5011
+ * (and `DropEvent.destination.index` on drop). Will be removed in the next major version.
5012
+ */
3986
5013
  placeholderId = computed(() => {
3987
5014
  if (!this.isActive()) {
3988
5015
  return null;
@@ -4010,14 +5037,8 @@ class DroppableDirective {
4010
5037
  #lastPlaceholderIndex = null;
4011
5038
  /** Drag whose initial placeholder position has already been seen by this droppable */
4012
5039
  #placeholderTrackedDrag = null;
4013
- /** Track previous active state to detect the drag-end transition */
4014
- #wasActive = false;
4015
- /**
4016
- * The terminal drag snapshot this droppable has already processed. `endedDragState`
4017
- * persists until the next drag starts, so this guards against replaying the same drop
4018
- * when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
4019
- */
4020
- #handledEndedState = null;
5040
+ /** Whether this droppable has rendered, so its inputs have values */
5041
+ #rendered = false;
4021
5042
  constructor() {
4022
5043
  createAutoScrollRegistration({
4023
5044
  autoScrollService: this.#autoScroll,
@@ -4030,67 +5051,41 @@ class DroppableDirective {
4030
5051
  // would never register. AutoScrollService filters by live scroll geometry per drag.
4031
5052
  canRegister: () => Boolean(this.effectiveGroup()),
4032
5053
  });
4033
- // Notify the calculator once this droppable is rendered (host data attributes applied).
4034
- // Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
4035
- // drag start, so without this a conditionally rendered list would never become a target.
4036
- afterNextRender(() => {
5054
+ afterNextRender(() => (this.#rendered = true));
5055
+ // Register with the droppable registry, and again under the new key when the ID or group
5056
+ // changes. Drag code finds drop targets through the registry instead of querying the
5057
+ // document; a registration during an active drag makes this droppable a target from the
5058
+ // next hit-test. The registry also calls #handleDrop when a drag ends on this droppable.
5059
+ // The cleanup unregisters it, including on destroy.
5060
+ //
5061
+ // Not deferred to afterNextRender: this runs in the change detection that creates the
5062
+ // droppable (inputs are set by then), so afterNextRender hooks of that same render (drop
5063
+ // animation, keyboard focus restore) already find it, and so does a view refreshed only
5064
+ // by its own detectChanges(). Nothing reads the registry before change detection ends.
5065
+ effect((onCleanup) => {
4037
5066
  const group = this.effectiveGroup();
4038
- if (group) {
4039
- this.#positionCalculator.notifyCandidatesChanged(group);
5067
+ if (!group) {
5068
+ return;
4040
5069
  }
5070
+ onCleanup(this.#registry.register(this.#elementRef.nativeElement, this.vdndDroppable(), group, (endedState) => this.#handleDrop(endedState)));
4041
5071
  });
4042
5072
  effect(() => {
4043
5073
  const currentIndex = this.#placeholderInsertionIndex();
4044
5074
  untracked(() => this.#handlePlaceholderMove(currentIndex));
4045
5075
  });
4046
- // React to state changes and handle drop events.
4047
- //
4048
- // Keyed only on the low-frequency fields (isActive/isDragging/draggedItem). The final
4049
- // placeholder/index values #handleDrop needs are captured by DragStateService.endDrag()
4050
- // into endedDragState() at the drag-end boundary, so this effect never has to poll the
4051
- // high-frequency cursor/placeholder signals — reading them here would make it re-run and
4052
- // re-allocate a snapshot on every 60fps frame, defeating the service's signal splitting.
4053
- effect(() => {
4054
- const active = this.isActive();
4055
- const draggedItem = this.#dragState.draggedItem();
4056
- const isDragging = this.#dragState.isDragging();
4057
- // Handle drag end (drop). This droppable is the release target when it was either
4058
- // observed active during the drag (#wasActive) OR named as the target in the ended
4059
- // drag snapshot. The snapshot path covers the pointer-up flush, where the final
4060
- // position is processed and the state cleared in the same synchronous task, so this
4061
- // effect never observes isActive() === true mid-drag and #wasActive stays false.
4062
- const endedState = untracked(() => this.#dragState.endedDragState());
4063
- const endedTargetedThis = endedState?.activeDroppableId === this.vdndDroppable();
4064
- if (!isDragging && draggedItem === null && (this.#wasActive || endedTargetedThis)) {
4065
- // Consume each terminal snapshot exactly once. endedDragState persists until the
4066
- // next drag starts and disabled()/other inputs re-run this effect, so without a
4067
- // per-snapshot guard a later state change would replay the same historical drop.
4068
- if (endedState !== this.#handledEndedState) {
4069
- this.#handledEndedState = endedState;
4070
- // Emit only for an enabled target that ended normally. A target disabled at
4071
- // release still consumes the snapshot (so re-enabling can't resurrect the drop)
4072
- // but emits nothing, keeping dragEnd and drop consistent.
4073
- if (!this.disabled() && !this.#dragState.wasCancelled()) {
4074
- this.#handleDrop();
4075
- }
4076
- }
4077
- }
4078
- this.#wasActive = active;
4079
- });
4080
5076
  }
4081
5077
  ngOnDestroy() {
5078
+ // Destroyed before it rendered: its inputs may have no values yet and it never became the
5079
+ // active drop target. (Not an ngOnInit flag: a subclass with its own ngOnInit would skip
5080
+ // it.) A registry registration, if any, is removed by its effect's cleanup.
5081
+ if (!this.#rendered) {
5082
+ return;
5083
+ }
4082
5084
  // Clean up if this droppable is destroyed while being active
4083
5085
  if (this.isActive()) {
4084
5086
  this.#dragState.setActiveDroppable(null);
4085
5087
  }
4086
- // Unregister from auto-scroll
4087
- this.#autoScroll.unregisterContainer(this.vdndDroppable());
4088
- // If this droppable unmounts mid-drag, tell the calculator so it drops it from the
4089
- // frozen candidate list (deferred re-query runs once the element has left the DOM).
4090
- const group = untracked(() => this.effectiveGroup());
4091
- if (group) {
4092
- this.#positionCalculator.notifyCandidatesChanged(group);
4093
- }
5088
+ // Auto-scroll and the droppable registry unregister through their effects' cleanup.
4094
5089
  }
4095
5090
  /**
4096
5091
  * Emit placeholderMove when the placeholder moved within (or entered) this droppable.
@@ -4124,14 +5119,14 @@ class DroppableDirective {
4124
5119
  });
4125
5120
  }
4126
5121
  /**
4127
- * Handle a drop on this droppable.
5122
+ * Emit `drop` for a drag that ended on this droppable. Called by the registry from
5123
+ * `DragStateService.endDrag()`, with the state captured just before the reset. Returns
5124
+ * whether it emitted.
4128
5125
  */
4129
- #handleDrop() {
4130
- // The live state is cleared before this effect fires, so read the terminal snapshot
4131
- // captured by endDrag()/cancelDrag() immediately before the reset.
4132
- const state = untracked(() => this.#dragState.endedDragState());
4133
- if (!state?.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
4134
- return;
5126
+ #handleDrop(state) {
5127
+ // A target disabled at release is not a valid drop (dragEnd reports no destination).
5128
+ if (this.disabled() || !state.draggedItem || state.activeDroppableId !== this.vdndDroppable()) {
5129
+ return false;
4135
5130
  }
4136
5131
  const sourceDroppableId = state.sourceDroppableId ?? '';
4137
5132
  const placeholderId = state.placeholderId ?? END_OF_LIST;
@@ -4151,7 +5146,7 @@ class DroppableDirective {
4151
5146
  sourceDroppableId,
4152
5147
  activeDroppableId: this.vdndDroppable(),
4153
5148
  });
4154
- this.drop.emit({
5149
+ const event = {
4155
5150
  source: {
4156
5151
  draggableId: state.draggedItem.draggableId,
4157
5152
  droppableId: sourceDroppableId,
@@ -4164,7 +5159,13 @@ class DroppableDirective {
4164
5159
  index: destinationIndex,
4165
5160
  data: this.vdndDroppableData(),
4166
5161
  },
4167
- });
5162
+ };
5163
+ // Delivered from the pointer/keyboard listener, which runs outside Angular's zone. With
5164
+ // zone.js a template listener marks its view dirty without scheduling a render, so emit
5165
+ // inside the zone for the consumer's handler (and async work it starts) to render. A
5166
+ // no-op when zoneless.
5167
+ this.#ngZone.run(() => this.drop.emit(event));
5168
+ return true;
4168
5169
  }
4169
5170
  /**
4170
5171
  * Get the index of an item in a droppable.
@@ -4172,7 +5173,7 @@ class DroppableDirective {
4172
5173
  */
4173
5174
  #getItemIndex(draggableId, droppableId) {
4174
5175
  // Find all draggables in the source droppable
4175
- const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
5176
+ const droppable = this.#registry.getById(droppableId);
4176
5177
  if (!droppable) {
4177
5178
  return 0;
4178
5179
  }
@@ -4245,6 +5246,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4245
5246
  }]
4246
5247
  }], ctorParameters: () => [], propDecorators: { vdndDroppable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppable", required: true }] }], vdndDroppableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableGroup", required: false }] }], vdndDroppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDroppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
4247
5248
 
5249
+ /**
5250
+ * Forwards the inner droppable's `drop` and `placeholderMove` to the sortable list's outputs.
5251
+ *
5252
+ * Not template listeners: Angular marks the listening view and every ancestor up to the root
5253
+ * dirty before running one, so each placeholder move would re-render the consumer's whole
5254
+ * component chain even when nothing listens to `placeholderMove`. A subscription marks nothing;
5255
+ * a consumer's own listener on the sortable list marks only its own view. Subscribed in the
5256
+ * constructor, before the droppable's first change detection can emit; both outputs drop their
5257
+ * listeners when the element is destroyed.
5258
+ */
5259
+ class SortableListOutputsDirective {
5260
+ constructor() {
5261
+ const droppable = inject(DroppableDirective);
5262
+ const list = inject(VirtualSortableListComponent);
5263
+ droppable.drop.subscribe((event) => list.drop.emit(event));
5264
+ droppable.placeholderMove.subscribe((event) => list.placeholderMove.emit(event));
5265
+ }
5266
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
5267
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: SortableListOutputsDirective, isStandalone: true, selector: "[vdndSortableListOutputs]", ngImport: i0 });
5268
+ }
5269
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: SortableListOutputsDirective, decorators: [{
5270
+ type: Directive,
5271
+ args: [{ selector: '[vdndSortableListOutputs]' }]
5272
+ }], ctorParameters: () => [] });
4248
5273
  /**
4249
5274
  * A high-level component that combines droppable, virtual scroll, and placeholder
4250
5275
  * functionality into a single, easy-to-use component.
@@ -4322,6 +5347,13 @@ class VirtualSortableListComponent {
4322
5347
  containerHeight = input(...(ngDevMode ? [undefined, { debugName: "containerHeight" }] : []));
4323
5348
  /** Number of items to render above/below the visible area */
4324
5349
  overscan = input(3, ...(ngDevMode ? [{ debugName: "overscan" }] : []));
5350
+ /**
5351
+ * Reuse the views of rows that scroll out to render the rows that scroll in, instead of
5352
+ * destroying them and creating new ones. Scrolling creates fewer components and elements, but
5353
+ * a row's components, element and DOM state (focus aside) then carry over to other items.
5354
+ * @default false
5355
+ */
5356
+ recycleRows = input(false, ...(ngDevMode ? [{ debugName: "recycleRows" }] : []));
4325
5357
  /** Enable auto-scroll when dragging near edges */
4326
5358
  autoScrollEnabled = input(true, ...(ngDevMode ? [{ debugName: "autoScrollEnabled" }] : []));
4327
5359
  /** Auto-scroll configuration */
@@ -4335,7 +5367,7 @@ class VirtualSortableListComponent {
4335
5367
  /** Emits each time the placeholder moves within this list (every item displacement) */
4336
5368
  placeholderMove = output();
4337
5369
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4338
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
5370
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualSortableListComponent, isStandalone: true, selector: "vdnd-sortable-list", inputs: { droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: true, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableData: { classPropertyName: "droppableData", publicName: "droppableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, recycleRows: { classPropertyName: "recycleRows", publicName: "recycleRows", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, constrainToContainer: { classPropertyName: "constrainToContainer", publicName: "constrainToContainer", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { drop: "drop", placeholderMove: "placeholderMove" }, host: { classAttribute: "vdnd-sortable-list" }, ngImport: i0, template: `
4339
5371
  <div
4340
5372
  class="vdnd-sortable-list-droppable"
4341
5373
  [vdndDroppable]="droppableId()"
@@ -4343,8 +5375,7 @@ class VirtualSortableListComponent {
4343
5375
  [vdndDroppableData]="droppableData()"
4344
5376
  [disabled]="disabled()"
4345
5377
  [constrainToContainer]="constrainToContainer()"
4346
- (drop)="drop.emit($event)"
4347
- (placeholderMove)="placeholderMove.emit($event)"
5378
+ vdndSortableListOutputs
4348
5379
  >
4349
5380
  <vdnd-virtual-scroll
4350
5381
  [items]="items()"
@@ -4357,16 +5388,17 @@ class VirtualSortableListComponent {
4357
5388
  [autoStickyDraggedItem]="true"
4358
5389
  [containerHeight]="containerHeight()"
4359
5390
  [overscan]="overscan()"
5391
+ [recycleRows]="recycleRows()"
4360
5392
  [autoScrollEnabled]="autoScrollEnabled()"
4361
5393
  [autoScrollConfig]="autoScrollConfig()"
4362
5394
  >
4363
5395
  </vdnd-virtual-scroll>
4364
5396
  </div>
4365
- `, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5397
+ `, isInline: true, styles: [":host{display:block}\n"], dependencies: [{ kind: "component", type: VirtualScrollContainerComponent, selector: "vdnd-virtual-scroll", inputs: ["itemTemplate", "scrollContainerId", "autoScrollEnabled", "autoScrollConfig", "items", "itemHeight", "dynamicItemHeight", "containerHeight", "overscan", "stickyItemIds", "itemIdFn", "trackByFn", "droppableId", "recycleRows", "autoStickyDraggedItem"] }, { kind: "directive", type: DroppableDirective, selector: "[vdndDroppable]", inputs: ["vdndDroppable", "vdndDroppableGroup", "vdndDroppableData", "disabled", "autoScrollEnabled", "autoScrollConfig", "constrainToContainer"], outputs: ["drop", "placeholderMove"] }, { kind: "directive", type: SortableListOutputsDirective, selector: "[vdndSortableListOutputs]" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4366
5398
  }
4367
5399
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualSortableListComponent, decorators: [{
4368
5400
  type: Component,
4369
- args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective], host: {
5401
+ args: [{ selector: 'vdnd-sortable-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [VirtualScrollContainerComponent, DroppableDirective, SortableListOutputsDirective], host: {
4370
5402
  class: 'vdnd-sortable-list',
4371
5403
  }, template: `
4372
5404
  <div
@@ -4376,8 +5408,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4376
5408
  [vdndDroppableData]="droppableData()"
4377
5409
  [disabled]="disabled()"
4378
5410
  [constrainToContainer]="constrainToContainer()"
4379
- (drop)="drop.emit($event)"
4380
- (placeholderMove)="placeholderMove.emit($event)"
5411
+ vdndSortableListOutputs
4381
5412
  >
4382
5413
  <vdnd-virtual-scroll
4383
5414
  [items]="items()"
@@ -4390,13 +5421,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4390
5421
  [autoStickyDraggedItem]="true"
4391
5422
  [containerHeight]="containerHeight()"
4392
5423
  [overscan]="overscan()"
5424
+ [recycleRows]="recycleRows()"
4393
5425
  [autoScrollEnabled]="autoScrollEnabled()"
4394
5426
  [autoScrollConfig]="autoScrollConfig()"
4395
5427
  >
4396
5428
  </vdnd-virtual-scroll>
4397
5429
  </div>
4398
5430
  `, styles: [":host{display:block}\n"] }]
4399
- }], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
5431
+ }], propDecorators: { droppableId: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableId", required: true }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: true }] }], itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], itemIdFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemIdFn", required: true }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], trackByFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackByFn", required: false }] }], droppableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "droppableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], containerHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "containerHeight", required: false }] }], overscan: [{ type: i0.Input, args: [{ isSignal: true, alias: "overscan", required: false }] }], recycleRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "recycleRows", required: false }] }], autoScrollEnabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollEnabled", required: false }] }], autoScrollConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScrollConfig", required: false }] }], constrainToContainer: [{ type: i0.Input, args: [{ isSignal: true, alias: "constrainToContainer", required: false }] }], drop: [{ type: i0.Output, args: ["drop"] }], placeholderMove: [{ type: i0.Output, args: ["placeholderMove"] }] } });
4400
5432
 
4401
5433
  /**
4402
5434
  * A virtual viewport component that provides efficient wrapper-based positioning
@@ -4557,8 +5589,9 @@ class VirtualViewportComponent {
4557
5589
  });
4558
5590
  }
4559
5591
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4560
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualViewportComponent, isStandalone: true, selector: "vdnd-virtual-viewport", inputs: { itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, contentOffset: { classPropertyName: "contentOffset", publicName: "contentOffset", isSignal: true, isRequired: false, 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 } }, host: { classAttribute: "vdnd-virtual-viewport" }, providers: [
5592
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.0.6", type: VirtualViewportComponent, isStandalone: true, selector: "vdnd-virtual-viewport", inputs: { itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, dynamicItemHeight: { classPropertyName: "dynamicItemHeight", publicName: "dynamicItemHeight", isSignal: true, isRequired: false, transformFunction: null }, contentOffset: { classPropertyName: "contentOffset", publicName: "contentOffset", isSignal: true, isRequired: false, 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 } }, host: { attributes: { "data-virtual-viewport": "" }, properties: { "attr.data-content-offset": "contentOffset()" }, classAttribute: "vdnd-virtual-viewport" }, providers: [
4561
5593
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
5594
+ { provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
4562
5595
  { provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
4563
5596
  ], ngImport: i0, template: `
4564
5597
  <!-- Spacer maintains total scroll height -->
@@ -4582,9 +5615,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4582
5615
  type: Component,
4583
5616
  args: [{ selector: 'vdnd-virtual-viewport', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
4584
5617
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
5618
+ { provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
4585
5619
  { provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
4586
5620
  ], host: {
4587
5621
  class: 'vdnd-virtual-viewport',
5622
+ // Read by the drag index calculator: this element scrolls its own rows, which start
5623
+ // data-content-offset px below the top of the scroll area
5624
+ 'data-virtual-viewport': '',
5625
+ '[attr.data-content-offset]': 'contentOffset()',
4588
5626
  }, template: `
4589
5627
  <!-- Spacer maintains total scroll height -->
4590
5628
  <div
@@ -4679,6 +5717,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4679
5717
  class VirtualContentComponent {
4680
5718
  #elementRef = inject((ElementRef));
4681
5719
  #ngZone = inject(NgZone);
5720
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
4682
5721
  /**
4683
5722
  * The parent scroll container injected via skip-self to get the actual scrollable element.
4684
5723
  * We provide our own VDND_SCROLL_CONTAINER with adjusted values to children.
@@ -4807,6 +5846,10 @@ class VirtualContentComponent {
4807
5846
  this.#measuredHeaderHeight.set(0);
4808
5847
  return;
4809
5848
  }
5849
+ // Server rendering: no layout to measure, and no ResizeObserver
5850
+ if (!this.#isBrowser) {
5851
+ return;
5852
+ }
4810
5853
  const el = dir.elementRef.nativeElement;
4811
5854
  this.#measuredHeaderHeight.set(el.offsetHeight);
4812
5855
  let observer = null;
@@ -4885,15 +5928,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4885
5928
  class KeyboardDragHandler {
4886
5929
  #deps;
4887
5930
  #boundKeyDown = null;
5931
+ /**
5932
+ * The drag this handler started. Keyboard drag state is shared by every draggable, so a key
5933
+ * that reaches another item (one focused by a click during the drag, for example) must not
5934
+ * move, drop or cancel this drag in that item's name.
5935
+ */
5936
+ #ownDrag = null;
4888
5937
  constructor(deps) {
4889
5938
  this.#deps = deps;
4890
5939
  this.#boundKeyDown = this.#onDocumentKeyDown.bind(this);
4891
5940
  }
4892
5941
  /**
4893
- * Whether a keyboard drag is currently active.
5942
+ * Whether a keyboard drag this handler started is active.
4894
5943
  */
4895
5944
  isActive() {
4896
- return this.#deps.keyboardDrag.isActive();
5945
+ return (this.#ownDrag !== null &&
5946
+ this.#deps.keyboardDrag.isActive() &&
5947
+ this.#deps.dragState.draggedItem() === this.#ownDrag);
4897
5948
  }
4898
5949
  /**
4899
5950
  * Start a keyboard drag operation.
@@ -4937,6 +5988,7 @@ class KeyboardDragHandler {
4937
5988
  width: rect.width,
4938
5989
  data: ctx.data,
4939
5990
  }, sourceIndex, totalItemCount, droppableId);
5991
+ this.#ownDrag = this.#deps.dragState.draggedItem();
4940
5992
  // Add document-level keyboard listener (since element is hidden with display:none)
4941
5993
  this.#deps.ngZone.runOutsideAngular(() => {
4942
5994
  document.addEventListener('keydown', this.#boundKeyDown);
@@ -5024,22 +6076,25 @@ class KeyboardDragHandler {
5024
6076
  this.#cleanupDocumentListener();
5025
6077
  // Clear droppable metadata cache from this drag session
5026
6078
  this.#deps.dragIndexCalculator.clearCache();
5027
- // Emit drag end event
5028
- this.#deps.callbacks.onDragEnd({
6079
+ const event = {
5029
6080
  draggableId: ctx.draggableId,
5030
6081
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5031
6082
  cancelled: false,
5032
6083
  data: ctx.data,
5033
6084
  sourceIndex,
5034
6085
  destinationIndex,
5035
- });
6086
+ };
5036
6087
  // The item stays in its source list when there is no valid target
5037
6088
  const fallbackDroppableId = hasValidTarget
5038
6089
  ? activeDroppableId
5039
6090
  : this.#deps.dragState.sourceDroppableId();
5040
- this.#deps.keyboardDrag.completeKeyboardDrag();
5041
- // Restore focus to the moved element after state updates
5042
- this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
6091
+ this.#endInZone(() => {
6092
+ this.#deps.callbacks.onDragEnd(event);
6093
+ // Ends the drag state, which delivers the drop
6094
+ this.#deps.keyboardDrag.completeKeyboardDrag();
6095
+ // Restore focus to the moved element after state updates
6096
+ this.#restoreFocus(ctx.draggableId, fallbackDroppableId);
6097
+ });
5043
6098
  }
5044
6099
  /**
5045
6100
  * Cancel a keyboard drag operation.
@@ -5053,18 +6108,30 @@ class KeyboardDragHandler {
5053
6108
  this.#cleanupDocumentListener();
5054
6109
  // Clear droppable metadata cache from this drag session
5055
6110
  this.#deps.dragIndexCalculator.clearCache();
5056
- // Emit drag end event
5057
- this.#deps.callbacks.onDragEnd({
6111
+ const event = {
5058
6112
  draggableId: ctx.draggableId,
5059
6113
  droppableId: this.#deps.callbacks.getParentDroppableId() ?? '',
5060
6114
  cancelled: true,
5061
6115
  data: ctx.data,
5062
6116
  sourceIndex,
5063
6117
  destinationIndex: null,
6118
+ };
6119
+ this.#endInZone(() => {
6120
+ this.#deps.callbacks.onDragEnd(event);
6121
+ this.#deps.keyboardDrag.cancelKeyboardDrag();
6122
+ // Restore focus to the original element after state updates
6123
+ this.#restoreFocus(ctx.draggableId, sourceDroppableId);
5064
6124
  });
5065
- this.#deps.keyboardDrag.cancelKeyboardDrag();
5066
- // Restore focus to the original element after state updates
5067
- this.#restoreFocus(ctx.draggableId, sourceDroppableId);
6125
+ }
6126
+ /**
6127
+ * Run a drag end (dragEnd, the state reset that delivers drop, the focus restore) in one
6128
+ * entry into Angular's zone. The document listener runs outside it; with zone.js a
6129
+ * template listener marks its view dirty but schedules no render, and each entry renders
6130
+ * when it is left, so this renders the end once, with focus restored in that render. A
6131
+ * no-op when zoneless.
6132
+ */
6133
+ #endInZone(end) {
6134
+ this.#deps.ngZone.run(end);
5068
6135
  }
5069
6136
  /**
5070
6137
  * Teardown — remove any active document listener.
@@ -5092,7 +6159,7 @@ class KeyboardDragHandler {
5092
6159
  }
5093
6160
  else if (fallbackDroppableId) {
5094
6161
  // Fallback: focus the first draggable in the list the item ended up in
5095
- const container = queryByAttribute(document, 'data-droppable-id', fallbackDroppableId);
6162
+ const container = this.#deps.positionCalculator.getDroppableById(fallbackDroppableId);
5096
6163
  const firstDraggable = container?.querySelector('[data-draggable-id]');
5097
6164
  firstDraggable?.focus();
5098
6165
  }
@@ -5132,19 +6199,42 @@ class KeyboardDragHandler {
5132
6199
  * Document-level keydown listener active during keyboard drag.
5133
6200
  */
5134
6201
  #onDocumentKeyDown(event) {
6202
+ // The drag ended without this handler (its state was reset elsewhere): stop listening, or
6203
+ // this listener would also take the keys of the next keyboard drag
6204
+ if (!this.isActive()) {
6205
+ this.#cleanupDocumentListener();
6206
+ return;
6207
+ }
5135
6208
  this.handleKey(event);
5136
6209
  }
5137
6210
  /**
5138
6211
  * Remove the document-level keyboard listener.
5139
6212
  */
5140
6213
  #cleanupDocumentListener() {
5141
- if (this.#boundKeyDown) {
6214
+ this.#ownDrag = null;
6215
+ // Server rendering destroys the directive without a global document, and never added it
6216
+ if (this.#boundKeyDown && typeof document !== 'undefined') {
5142
6217
  document.removeEventListener('keydown', this.#boundKeyDown);
5143
6218
  }
5144
6219
  }
5145
6220
  }
5146
6221
 
5147
- var _a;
6222
+ /**
6223
+ * Elements inside a draggable that keep their own interaction: pressing one never starts a
6224
+ * pointer drag, and Space pressed on one never starts a keyboard drag.
6225
+ */
6226
+ const INTERACTIVE_ELEMENT_SELECTOR = 'button, input, textarea, select, [contenteditable]';
6227
+ /** Class that excludes the element carrying it, and everything inside it, from starting a drag. */
6228
+ const NO_DRAG_CLASS = 'no-drag';
6229
+ /**
6230
+ * The `no-drag` element inside `draggable` (the draggable itself included) that contains
6231
+ * `target`, or null. A `no-drag` ancestor of the draggable does not count.
6232
+ */
6233
+ function findNoDragElement(target, draggable) {
6234
+ const noDrag = target.closest(`.${NO_DRAG_CLASS}`);
6235
+ return noDrag !== null && draggable.contains(noDrag) ? noDrag : null;
6236
+ }
6237
+
5148
6238
  /**
5149
6239
  * Handles pointer-based (mouse + touch) drag operations.
5150
6240
  *
@@ -5154,7 +6244,8 @@ var _a;
5154
6244
  * - Drag delay timer
5155
6245
  * - Forwarding moves to the directive (DragSchedulerService coalesces them per frame)
5156
6246
  * - Document-level listener management
5157
- * - Escape key cancellation during pointer drag
6247
+ * - Escape key cancellation during pointer drag, and cancellation when the window loses focus
6248
+ * or the page is hidden (the release would never arrive) or the system cancels the touch
5158
6249
  */
5159
6250
  class PointerDragHandler {
5160
6251
  #deps;
@@ -5162,10 +6253,18 @@ class PointerDragHandler {
5162
6253
  #startPosition = null;
5163
6254
  /** Whether we're currently tracking a potential drag */
5164
6255
  #isTracking = false;
6256
+ /** The item the tracked press landed on */
6257
+ #pressedId = null;
6258
+ /** Whether the document and window listeners of a press are added */
6259
+ #listening = false;
6260
+ /** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
6261
+ #touchId = null;
5165
6262
  /** Bound event handlers for cleanup */
5166
6263
  #boundPointerMove = null;
5167
6264
  #boundPointerUp = null;
5168
6265
  #boundKeyDown = null;
6266
+ #boundWindowBlur = null;
6267
+ #boundVisibilityChange = null;
5169
6268
  /** Timer ID for drag delay */
5170
6269
  #delayTimerId = null;
5171
6270
  /** Whether the delay has been satisfied (user held long enough) */
@@ -5175,6 +6274,8 @@ class PointerDragHandler {
5175
6274
  this.#boundPointerMove = this.#onPointerMove.bind(this);
5176
6275
  this.#boundPointerUp = this.#onPointerUp.bind(this);
5177
6276
  this.#boundKeyDown = this.#onEscapeKeyDown.bind(this);
6277
+ this.#boundWindowBlur = this.#onFocusLost.bind(this);
6278
+ this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
5178
6279
  }
5179
6280
  /**
5180
6281
  * The position where the user initially pressed down.
@@ -5209,8 +6310,13 @@ class PointerDragHandler {
5209
6310
  }
5210
6311
  // Check for elements that should not trigger drag
5211
6312
  const target = event.target;
5212
- if (target.closest('button, input, textarea, select, [contenteditable]') ||
5213
- target.classList.contains('no-drag')) {
6313
+ if (target.closest(INTERACTIVE_ELEMENT_SELECTOR) ||
6314
+ findNoDragElement(target, ctx.element) !== null) {
6315
+ return;
6316
+ }
6317
+ // Track the finger that pressed this draggable; the others are ignored for the gesture.
6318
+ const touch = 'touches' in event ? PointerDragHandler.#pressingTouch(event, ctx.element) : null;
6319
+ if ('touches' in event && !touch) {
5214
6320
  return;
5215
6321
  }
5216
6322
  const delay = ctx.dragDelay;
@@ -5222,15 +6328,41 @@ class PointerDragHandler {
5222
6328
  event.preventDefault();
5223
6329
  }
5224
6330
  event.stopPropagation();
6331
+ // A second finger on this draggable while the first is still down: keep following the first.
6332
+ if ('touches' in event && touch && this.#isTracking && this.#trackedTouch(event.touches)) {
6333
+ return;
6334
+ }
6335
+ // Only one drag at a time: a second finger or a press during a keyboard drag must not
6336
+ // replace the drag in progress. The press keeps the default handling above, so a mouse press
6337
+ // still doesn't focus this item (which would take the keys of a keyboard drag).
6338
+ if (this.#deps.callbacks.isOtherDragActive()) {
6339
+ return;
6340
+ }
5225
6341
  this.#isTracking = true;
5226
- this.#startPosition = _a.#getPosition(event);
5227
- // Handle drag delay
6342
+ this.#pressedId = ctx.draggableId;
6343
+ if (touch) {
6344
+ this.#touchId = touch.identifier;
6345
+ this.#startPosition = { x: touch.clientX, y: touch.clientY };
6346
+ }
6347
+ else {
6348
+ const mouse = event;
6349
+ this.#startPosition = { x: mouse.clientX, y: mouse.clientY };
6350
+ }
6351
+ // Handle drag delay. A repeated press (e.g. after a release the page never saw) restarts the
6352
+ // delay; the previous timer must not survive, or it fires after the gesture has ended.
6353
+ this.#cancelDelayTimer();
5228
6354
  if (delay > 0) {
5229
- this.#delayReady = false;
5230
6355
  this.#delayTimerId = setTimeout(() => {
6356
+ this.#delayTimerId = null;
6357
+ // Another drag started while this press was held, or the press no longer applies (see
6358
+ // #isStalePress): drop it rather than show it as ready to drag.
6359
+ if (this.#deps.callbacks.isOtherDragActive() ||
6360
+ this.#isStalePress(this.#deps.getContext())) {
6361
+ this.cleanup();
6362
+ return;
6363
+ }
5231
6364
  this.#delayReady = true;
5232
6365
  this.#deps.callbacks.onPendingChange(true); // Emit ready state when delay passes
5233
- this.#delayTimerId = null;
5234
6366
  }, delay);
5235
6367
  }
5236
6368
  else {
@@ -5249,7 +6381,12 @@ class PointerDragHandler {
5249
6381
  }
5250
6382
  // Listen for Escape key on document to cancel drag
5251
6383
  document.addEventListener('keydown', this.#boundKeyDown);
6384
+ // Switching windows or tabs mid-drag means the release never reaches this page: cancel
6385
+ // instead of leaving the drag active until the next press.
6386
+ window.addEventListener('blur', this.#boundWindowBlur);
6387
+ document.addEventListener('visibilitychange', this.#boundVisibilityChange);
5252
6388
  });
6389
+ this.#listening = true;
5253
6390
  }
5254
6391
  /**
5255
6392
  * Remove listeners + cancel timers.
@@ -5257,9 +6394,17 @@ class PointerDragHandler {
5257
6394
  */
5258
6395
  cleanup() {
5259
6396
  this.#isTracking = false;
6397
+ this.#pressedId = null;
5260
6398
  this.#startPosition = null;
6399
+ this.#touchId = null;
5261
6400
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
5262
6401
  this.#cancelDelayTimer();
6402
+ // Nothing to remove unless a press added listeners (most draggables are never pressed; a
6403
+ // virtual list destroys one each time a row scrolls out). Server rendering adds none.
6404
+ if (!this.#listening) {
6405
+ return;
6406
+ }
6407
+ this.#listening = false;
5263
6408
  // Remove event listeners
5264
6409
  if (this.#boundPointerMove) {
5265
6410
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -5273,9 +6418,15 @@ class PointerDragHandler {
5273
6418
  if (this.#boundKeyDown) {
5274
6419
  document.removeEventListener('keydown', this.#boundKeyDown);
5275
6420
  }
6421
+ if (this.#boundWindowBlur) {
6422
+ window.removeEventListener('blur', this.#boundWindowBlur);
6423
+ }
6424
+ if (this.#boundVisibilityChange) {
6425
+ document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
6426
+ }
5276
6427
  }
5277
6428
  /**
5278
- * Full teardown — cleanup + null references.
6429
+ * Teardown when the directive is destroyed: removes listeners and cancels timers.
5279
6430
  */
5280
6431
  destroy() {
5281
6432
  this.cleanup();
@@ -5287,10 +6438,31 @@ class PointerDragHandler {
5287
6438
  if (!this.#isTracking) {
5288
6439
  return;
5289
6440
  }
5290
- const position = _a.#getPosition(event);
5291
- const ctx = this.#deps.getContext();
6441
+ let position;
6442
+ if ('touches' in event) {
6443
+ // `touches` holds every finger's current position, so the tracked finger's is there even
6444
+ // when only another finger moved
6445
+ const touch = this.#trackedTouch(event.touches) ?? this.#trackedTouch(event.changedTouches);
6446
+ if (!touch) {
6447
+ // The tracked finger is gone. While dragging, keep other fingers from scrolling the page.
6448
+ if (this.#deps.callbacks.isDragging()) {
6449
+ event.preventDefault();
6450
+ }
6451
+ return;
6452
+ }
6453
+ position = { x: touch.clientX, y: touch.clientY };
6454
+ }
6455
+ else {
6456
+ position = { x: event.clientX, y: event.clientY };
6457
+ }
5292
6458
  // Check if we've moved past the threshold
5293
6459
  if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
6460
+ // Read only before the drag starts, not on every move of a drag: nothing in it matters then
6461
+ const ctx = this.#deps.getContext();
6462
+ if (this.#isStalePress(ctx)) {
6463
+ this.cleanup();
6464
+ return;
6465
+ }
5294
6466
  const distance = Math.sqrt(Math.pow(position.x - this.#startPosition.x, 2) +
5295
6467
  Math.pow(position.y - this.#startPosition.y, 2));
5296
6468
  if (distance < ctx.dragThreshold) {
@@ -5304,6 +6476,11 @@ class PointerDragHandler {
5304
6476
  this.cleanup();
5305
6477
  return;
5306
6478
  }
6479
+ // Another press won the race and started its drag first: give up this one.
6480
+ if (this.#deps.callbacks.isOtherDragActive()) {
6481
+ this.cleanup();
6482
+ return;
6483
+ }
5307
6484
  // Start the drag - now we can prevent default
5308
6485
  event.preventDefault();
5309
6486
  this.#deps.callbacks.onDragStart(position);
@@ -5327,13 +6504,35 @@ class PointerDragHandler {
5327
6504
  if (!this.#isTracking) {
5328
6505
  return;
5329
6506
  }
6507
+ // Another finger lifted (or was cancelled) while the tracked one is still down. Touches
6508
+ // without an identifier (malformed synthetic events) can't be told apart: any release ends.
6509
+ if ('touches' in event &&
6510
+ typeof this.#touchId === 'number' &&
6511
+ this.#trackedTouch(event.changedTouches) === null &&
6512
+ this.#trackedTouch(event.touches) !== null) {
6513
+ return;
6514
+ }
5330
6515
  // Only prevent default if we were actually dragging
5331
6516
  // Otherwise, allow native touch behavior (like scroll momentum) to complete
5332
- if (this.#deps.callbacks.isDragging()) {
5333
- event.preventDefault();
5334
- this.#deps.callbacks.onDragEnd(false);
6517
+ // The drag end runs consumer handlers (dragEnd, drop); stop tracking even if one throws
6518
+ try {
6519
+ if (this.#deps.callbacks.isDragging()) {
6520
+ event.preventDefault();
6521
+ // The system took the touch (an incoming call, an OS gesture, too many fingers): the
6522
+ // user never released, so this is a cancel, not a drop
6523
+ this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
6524
+ }
6525
+ }
6526
+ finally {
6527
+ this.cleanup();
5335
6528
  }
5336
- this.cleanup();
6529
+ }
6530
+ /**
6531
+ * Whether the tracked press no longer applies to the draggable: it renders another item now, or
6532
+ * it left the page (a virtual list recycled or pooled its row).
6533
+ */
6534
+ #isStalePress(ctx) {
6535
+ return ctx.draggableId !== this.#pressedId || !ctx.element.isConnected;
5337
6536
  }
5338
6537
  /**
5339
6538
  * Cancel the delay timer if active.
@@ -5350,24 +6549,87 @@ class PointerDragHandler {
5350
6549
  */
5351
6550
  #onEscapeKeyDown(event) {
5352
6551
  if (event.key === 'Escape' && this.#deps.callbacks.isDragging()) {
5353
- // No ngZone.run() needed - #endDrag uses signals which work outside zone
5354
- this.#deps.callbacks.onDragEnd(true);
6552
+ try {
6553
+ this.#deps.callbacks.onDragEnd(true);
6554
+ }
6555
+ finally {
6556
+ this.cleanup();
6557
+ }
6558
+ }
6559
+ }
6560
+ /**
6561
+ * Cancel the drag (or drop the pending press) when the window loses focus.
6562
+ */
6563
+ #onFocusLost() {
6564
+ if (!this.#isTracking) {
6565
+ return;
6566
+ }
6567
+ try {
6568
+ if (this.#deps.callbacks.isDragging()) {
6569
+ this.#deps.callbacks.onDragEnd(true);
6570
+ }
6571
+ }
6572
+ finally {
5355
6573
  this.cleanup();
5356
6574
  }
5357
6575
  }
5358
6576
  /**
5359
- * Get position from mouse or touch event.
6577
+ * Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
5360
6578
  */
5361
- static #getPosition(event) {
5362
- if ('touches' in event) {
5363
- const touch = event.touches[0] ?? event.changedTouches[0];
5364
- return { x: touch.clientX, y: touch.clientY };
6579
+ #onVisibilityChange() {
6580
+ if (document.visibilityState === 'hidden') {
6581
+ this.#onFocusLost();
6582
+ }
6583
+ }
6584
+ /**
6585
+ * The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
6586
+ * leave a list undefined, so a missing list counts as empty. Read by index, without copying the
6587
+ * list or creating an iterator: this runs for every touch move.
6588
+ */
6589
+ #trackedTouch(touches) {
6590
+ if (!touches) {
6591
+ return null;
6592
+ }
6593
+ // eslint-disable-next-line @typescript-eslint/prefer-for-of -- for...of creates an iterator
6594
+ for (let i = 0; i < touches.length; i++) {
6595
+ if (touches[i].identifier === this.#touchId) {
6596
+ return touches[i];
6597
+ }
5365
6598
  }
5366
- return { x: event.clientX, y: event.clientY };
6599
+ return null;
6600
+ }
6601
+ /**
6602
+ * The finger that pressed this draggable: of the touches that started with this event, the
6603
+ * one on the draggable (several fingers can land in one touchstart).
6604
+ */
6605
+ static #pressingTouch(event, element) {
6606
+ // Either list can be undefined on synthetic events
6607
+ const started = Array.from(event.changedTouches ?? []);
6608
+ const onElement = started.find((touch) => touch.target instanceof Node && element.contains(touch.target));
6609
+ return onElement ?? started[0] ?? Array.from(event.touches ?? [])[0] ?? null;
5367
6610
  }
5368
6611
  }
5369
- _a = PointerDragHandler;
5370
6612
 
6613
+ /** Key names as Angular's `keydown.<key>` bindings spell them, for the `event.key` values that differ */
6614
+ const KEY_NAMES = {
6615
+ ' ': 'space',
6616
+ Esc: 'escape',
6617
+ '\x1B': 'escape',
6618
+ Up: 'arrowup',
6619
+ Down: 'arrowdown',
6620
+ Left: 'arrowleft',
6621
+ Right: 'arrowright',
6622
+ };
6623
+ /** The keys an item handles */
6624
+ const HANDLED_KEYS = new Set([
6625
+ 'space',
6626
+ 'enter',
6627
+ 'arrowup',
6628
+ 'arrowdown',
6629
+ 'arrowleft',
6630
+ 'arrowright',
6631
+ 'escape',
6632
+ ]);
5371
6633
  /**
5372
6634
  * Makes an element draggable within the virtual scroll drag-and-drop system.
5373
6635
  *
@@ -5444,9 +6706,16 @@ class DraggableDirective {
5444
6706
  dragStart = output();
5445
6707
  /** Emits when drag ends */
5446
6708
  dragEnd = output();
5447
- /** Whether this element is in the "ready to drag" state (delay has passed) */
5448
- #isPending = signal(false, ...(ngDevMode ? [{ debugName: "#isPending" }] : []));
5449
- isPending = this.#isPending.asReadonly();
6709
+ /** The item whose press is ready to drag (its delay has passed) */
6710
+ #pendingId = signal(null, ...(ngDevMode ? [{ debugName: "#pendingId" }] : []));
6711
+ /**
6712
+ * Whether this element is in the "ready to drag" state (delay has passed). Tied to the pressed
6713
+ * item: a recycled row that renders another item is not ready to drag.
6714
+ */
6715
+ isPending = computed(() => {
6716
+ const pendingId = this.#pendingId();
6717
+ return pendingId !== null && pendingId === this.vdndDraggable();
6718
+ }, ...(ngDevMode ? [{ debugName: "isPending" }] : []));
5450
6719
  /** Whether this element is currently being dragged (based on global drag state) */
5451
6720
  isDragging = computed(() => {
5452
6721
  const draggedItem = this.#dragState.draggedItem();
@@ -5454,6 +6723,64 @@ class DraggableDirective {
5454
6723
  }, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
5455
6724
  #keyboardHandler;
5456
6725
  #pointerHandler;
6726
+ /** Set by ngOnInit, which creates the handlers */
6727
+ #initialized = false;
6728
+ /**
6729
+ * Keys pressed on the item: one listener, added outside Angular's zone, instead of a
6730
+ * `(keydown.<key>)` host binding per key, as Angular adds a listener to every row for each.
6731
+ * Like those bindings, it ignores a key pressed with Shift, Ctrl, Alt or Meta, runs the handler
6732
+ * of a key it handles in the zone (so zone.js apps still render after it), prevents the key's
6733
+ * default action when the handler returns false, and reports what the handler throws to the
6734
+ * ErrorHandler. Added in the constructor, so it runs before the element's template listeners,
6735
+ * as host listeners do.
6736
+ */
6737
+ #onKeydown = (event) => {
6738
+ if (!this.#initialized || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
6739
+ return;
6740
+ }
6741
+ const key = event.key ? (KEY_NAMES[event.key] ?? event.key.toLowerCase()) : '';
6742
+ if (!HANDLED_KEYS.has(key)) {
6743
+ return;
6744
+ }
6745
+ this.#ngZone.run(() => {
6746
+ try {
6747
+ if (this.#handleKey(key, event) === false) {
6748
+ event.preventDefault();
6749
+ }
6750
+ }
6751
+ catch (error) {
6752
+ this.#envInjector.get(ErrorHandler).handleError(error);
6753
+ }
6754
+ });
6755
+ };
6756
+ /**
6757
+ * Touch presses on the item. Added programmatically, not as a host binding (which Angular adds
6758
+ * without options, so non-passive): a swipe that starts on an element with a non-passive
6759
+ * touchstart listener can't scroll until the main thread has run it. With a drag delay the press
6760
+ * never cancels the scroll (the page must scroll when the user swipes before the delay passes),
6761
+ * so the listener is passive; without one it prevents the default action and is non-passive.
6762
+ * Like the keydown listener, it runs the handler in the zone (as the mousedown host binding
6763
+ * does) and reports what it throws to the ErrorHandler. Added once the inputs are set, so unlike
6764
+ * a host listener it runs after the element's template listeners.
6765
+ */
6766
+ #onTouchStart = (event) => {
6767
+ if (!this.#initialized) {
6768
+ return;
6769
+ }
6770
+ this.#ngZone.run(() => {
6771
+ try {
6772
+ this.onPointerDown(event, true);
6773
+ }
6774
+ catch (error) {
6775
+ this.#envInjector.get(ErrorHandler).handleError(error);
6776
+ }
6777
+ });
6778
+ };
6779
+ /** Whether the touchstart listener is passive, or null while it isn't added */
6780
+ #touchStartPassive = null;
6781
+ constructor() {
6782
+ this.#ngZone.runOutsideAngular(() => this.#elementRef.nativeElement.addEventListener('keydown', this.#onKeydown));
6783
+ }
5457
6784
  /** Cached constraint flag from source droppable */
5458
6785
  #constrainToContainer = false;
5459
6786
  /** Element whose rect is used for clamping (scroll container or droppable itself) */
@@ -5464,9 +6791,7 @@ class DraggableDirective {
5464
6791
  * Update the pending state and emit the change event.
5465
6792
  */
5466
6793
  #setPending(pending) {
5467
- if (this.#isPending() !== pending) {
5468
- this.#isPending.set(pending);
5469
- }
6794
+ this.#pendingId.set(pending ? this.vdndDraggable() : null);
5470
6795
  }
5471
6796
  /**
5472
6797
  * Find the element to use for container constraint clamping.
@@ -5491,7 +6816,8 @@ class DraggableDirective {
5491
6816
  ngZone: this.#ngZone,
5492
6817
  envInjector: this.#envInjector,
5493
6818
  callbacks: {
5494
- onDragStart: (event) => this.dragStart.emit(event),
6819
+ onDragStart: (event) => this.#ngZone.run(() => this.dragStart.emit(event)),
6820
+ // The keyboard handler ends the drag inside the zone (dragEnd, drop, focus restore)
5495
6821
  onDragEnd: (event) => this.dragEnd.emit(event),
5496
6822
  getParentDroppableId: () => this.#getParentDroppableId(),
5497
6823
  calculateSourceIndex: (el, droppable) => this.#calculateSourceIndex(el, droppable),
@@ -5516,9 +6842,11 @@ class DraggableDirective {
5516
6842
  onDragEnd: (cancelled) => this.#endDrag(cancelled),
5517
6843
  onPendingChange: (pending) => this.#setPending(pending),
5518
6844
  isDragging: () => this.isDragging(),
6845
+ isOtherDragActive: () => this.#dragState.isDragging() && !this.isDragging(),
5519
6846
  },
5520
6847
  getContext: () => ({
5521
6848
  element: this.#elementRef.nativeElement,
6849
+ draggableId: this.vdndDraggable(),
5522
6850
  groupName: this.#effectiveGroup(),
5523
6851
  disabled: this.disabled(),
5524
6852
  dragHandle: this.dragHandle(),
@@ -5526,8 +6854,20 @@ class DraggableDirective {
5526
6854
  dragDelay: this.dragDelay(),
5527
6855
  }),
5528
6856
  });
6857
+ this.#initialized = true;
6858
+ this.#listenForTouchStart();
6859
+ }
6860
+ ngOnChanges() {
6861
+ this.#listenForTouchStart();
5529
6862
  }
5530
6863
  ngOnDestroy() {
6864
+ this.#elementRef.nativeElement.removeEventListener('keydown', this.#onKeydown);
6865
+ this.#elementRef.nativeElement.removeEventListener('touchstart', this.#onTouchStart);
6866
+ // Destroyed before its first change detection: there are no handlers yet, and its inputs
6867
+ // have no values (reading a bound ID would throw).
6868
+ if (!this.#initialized) {
6869
+ return;
6870
+ }
5531
6871
  // If destroyed mid-drag, cancel to avoid stale global state / ongoing RAF loops.
5532
6872
  if (this.isDragging()) {
5533
6873
  this.#endDrag(true);
@@ -5535,6 +6875,35 @@ class DraggableDirective {
5535
6875
  this.#pointerHandler.destroy();
5536
6876
  this.#keyboardHandler.destroy();
5537
6877
  }
6878
+ /**
6879
+ * Add the touchstart listener, or re-add it when the drag delay changes whether it can be
6880
+ * passive. Lifecycle hooks, not an effect: creating a view effect schedules a change detection
6881
+ * that visits every ancestor, and a virtual list creates rows on each scroll step.
6882
+ */
6883
+ #listenForTouchStart() {
6884
+ // The handler cancels the scroll of a touch press only when there is no delay
6885
+ const passive = this.dragDelay() !== 0;
6886
+ if (passive === this.#touchStartPassive) {
6887
+ return;
6888
+ }
6889
+ const element = this.#elementRef.nativeElement;
6890
+ element.removeEventListener('touchstart', this.#onTouchStart);
6891
+ this.#ngZone.runOutsideAngular(() => element.addEventListener('touchstart', this.#onTouchStart, { passive }));
6892
+ this.#touchStartPassive = passive;
6893
+ }
6894
+ /** Run the handler of a key the item handles (see HANDLED_KEYS), returning what it returns. */
6895
+ #handleKey(key, event) {
6896
+ switch (key) {
6897
+ case 'space':
6898
+ return this.onKeyboardActivate(event);
6899
+ case 'enter':
6900
+ return this.onEnterKey(event);
6901
+ case 'escape':
6902
+ return this.onEscape();
6903
+ default:
6904
+ return this.onArrowKey(event);
6905
+ }
6906
+ }
5538
6907
  /**
5539
6908
  * Handle pointer down (mouse or touch).
5540
6909
  */
@@ -5549,20 +6918,49 @@ class DraggableDirective {
5549
6918
  if (this.disabled()) {
5550
6919
  return;
5551
6920
  }
6921
+ // Space on a control inside the item types into it or clicks it, just as a press on
6922
+ // one never starts a pointer drag.
6923
+ if (this.#isFromNestedControl(event)) {
6924
+ return;
6925
+ }
5552
6926
  event.preventDefault();
5553
- event.stopPropagation(); // Prevent document listener from receiving this event
5554
6927
  // If we're in a keyboard drag, Space drops the item
5555
6928
  if (this.#keyboardHandler.isActive()) {
6929
+ event.stopPropagation(); // Prevent document listener from receiving this event
5556
6930
  this.#keyboardHandler.complete();
5557
6931
  return;
5558
6932
  }
5559
- // If we're in a pointer drag, ignore
5560
- if (this.isDragging()) {
6933
+ // Another drag is in progress: don't replace it. The key goes on to the document, where a
6934
+ // keyboard drag of another item drops with it.
6935
+ if (this.#dragState.isDragging()) {
5561
6936
  return;
5562
6937
  }
5563
- // Start keyboard drag
6938
+ // Start keyboard drag. The document listener it adds must not receive this event.
6939
+ event.stopPropagation();
5564
6940
  this.#keyboardHandler.activate();
5565
6941
  }
6942
+ /**
6943
+ * Whether the event comes from a control (or from inside a `no-drag` element) nested inside this
6944
+ * draggable. Neither the draggable itself nor a control around it counts, so a
6945
+ * `<button vdndDraggable>` still picks up with Space, and neither does a control that is the
6946
+ * drag handle, such as a button handle. Controls inside the handle do count.
6947
+ */
6948
+ #isFromNestedControl(event) {
6949
+ const host = this.#elementRef.nativeElement;
6950
+ const target = event.target;
6951
+ if (!(target instanceof Element) || target === host) {
6952
+ return false;
6953
+ }
6954
+ if (findNoDragElement(target, host) !== null) {
6955
+ return true;
6956
+ }
6957
+ const control = target.closest(INTERACTIVE_ELEMENT_SELECTOR);
6958
+ if (control === null || control === host || !host.contains(control)) {
6959
+ return false;
6960
+ }
6961
+ const handleSelector = this.dragHandle();
6962
+ return !handleSelector || !control.matches(handleSelector);
6963
+ }
5566
6964
  /**
5567
6965
  * Handle Enter key (alternative to Space for dropping during keyboard drag).
5568
6966
  */
@@ -5683,13 +7081,16 @@ class DraggableDirective {
5683
7081
  // Start auto-scroll monitoring — registers as a scheduler participant.
5684
7082
  this.#autoScroll.startMonitoring(() => this.#recalculatePlaceholder());
5685
7083
  // Emit drag start event
5686
- this.dragStart.emit({
7084
+ // Pointer listeners run outside Angular's zone. With zone.js a template listener marks
7085
+ // its view dirty but schedules no render, so emit inside the zone (a no-op when zoneless).
7086
+ const event = {
5687
7087
  draggableId: this.vdndDraggable(),
5688
7088
  droppableId: parentDroppableId ?? '',
5689
7089
  data: this.vdndDraggableData(),
5690
7090
  position,
5691
7091
  sourceIndex,
5692
- });
7092
+ };
7093
+ this.#ngZone.run(() => this.dragStart.emit(event));
5693
7094
  }
5694
7095
  /**
5695
7096
  * Calculate the source index of the dragged element BEFORE it's hidden.
@@ -5701,49 +7102,21 @@ class DraggableDirective {
5701
7102
  return 0;
5702
7103
  }
5703
7104
  const rect = element.getBoundingClientRect();
5704
- const virtualScroll = droppableElement.querySelector('vdnd-virtual-scroll');
5705
- const virtualContent = droppableElement.matches('vdnd-virtual-content')
5706
- ? droppableElement
5707
- : droppableElement.closest('vdnd-virtual-content');
5708
- let containerRect;
5709
- let scrollTop;
5710
- if (virtualScroll) {
5711
- containerRect = virtualScroll.getBoundingClientRect();
5712
- scrollTop = virtualScroll.scrollTop;
5713
- }
5714
- else if (virtualContent) {
5715
- // Page-level scroll: use scrollable parent rect + content offset so that
5716
- // relativeY is measured from the start of the virtual items, not the header.
5717
- const scrollableParent = virtualContent.closest('.vdnd-scrollable');
5718
- if (scrollableParent) {
5719
- containerRect = scrollableParent.getBoundingClientRect();
5720
- const contentOffsetAttr = virtualContent.getAttribute('data-content-offset');
5721
- const contentOffset = contentOffsetAttr ? parseFloat(contentOffsetAttr) : 0;
5722
- scrollTop =
5723
- scrollableParent.scrollTop - (Number.isFinite(contentOffset) ? contentOffset : 0);
5724
- }
5725
- else {
5726
- containerRect = virtualContent.getBoundingClientRect();
5727
- scrollTop = 0;
5728
- }
5729
- }
5730
- else {
5731
- containerRect = droppableElement.getBoundingClientRect();
5732
- scrollTop = droppableElement.scrollTop;
5733
- }
7105
+ // The same measurement that places the placeholder: it knows each container's scroll
7106
+ // element and subtracts space reserved above the rows (contentOffset, page headers).
7107
+ const geometry = this.#dragIndexCalculator.getScrollGeometry(droppableElement, rect.height);
7108
+ const relativeY = rect.top - geometry.rect.top + geometry.scrollTop;
5734
7109
  // Try to use registered strategy for accurate offset-based lookup
5735
7110
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
5736
7111
  if (droppableId) {
5737
7112
  const strategy = this.#dragIndexCalculator.getStrategyForDroppable(droppableId);
5738
7113
  if (strategy) {
5739
- const relativeY = rect.top - containerRect.top + scrollTop;
5740
7114
  return strategy.findIndexAtOffset(relativeY);
5741
7115
  }
5742
7116
  }
5743
7117
  // Preserve the geometry fallback for a virtual container whose strategy is unavailable.
5744
- if (virtualScroll || virtualContent) {
7118
+ if (geometry.isVirtual) {
5745
7119
  const itemHeight = rect.height || 50;
5746
- const relativeY = rect.top - containerRect.top + scrollTop;
5747
7120
  return Math.round(relativeY / itemHeight);
5748
7121
  }
5749
7122
  // Non-virtual fallback: derive the logical index from preceding draggable siblings.
@@ -5933,22 +7306,27 @@ class DraggableDirective {
5933
7306
  sourceDroppableId: this.#dragState.sourceDroppableId(),
5934
7307
  activeDroppableId: this.#dragState.activeDroppableId(),
5935
7308
  });
5936
- // Emit drag end event
5937
- this.dragEnd.emit({
7309
+ const event = {
5938
7310
  draggableId: this.vdndDraggable(),
5939
7311
  droppableId: this.#getParentDroppableId() ?? '',
5940
7312
  cancelled,
5941
7313
  data: this.vdndDraggableData(),
5942
7314
  sourceIndex,
5943
7315
  destinationIndex,
7316
+ };
7317
+ // dragEnd, then the state reset that delivers drop, in ONE zone entry: listeners run
7318
+ // outside Angular's zone, and with zone.js each entry renders when it is left, so
7319
+ // separate entries would render the drag end twice. A no-op when zoneless.
7320
+ this.#ngZone.run(() => {
7321
+ this.dragEnd.emit(event);
7322
+ // Clear drag state - this triggers isDragging computed to become false
7323
+ if (cancelled) {
7324
+ this.#dragState.cancelDrag();
7325
+ }
7326
+ else {
7327
+ this.#dragState.endDrag();
7328
+ }
5944
7329
  });
5945
- // Clear drag state - this triggers isDragging computed to become false
5946
- if (cancelled) {
5947
- this.#dragState.cancelDrag();
5948
- }
5949
- else {
5950
- this.#dragState.endDrag();
5951
- }
5952
7330
  }
5953
7331
  /**
5954
7332
  * Get the parent droppable ID.
@@ -5962,7 +7340,7 @@ class DraggableDirective {
5962
7340
  return droppable ? this.#positionCalculator.getDroppableId(droppable) : null;
5963
7341
  }
5964
7342
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
5965
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)", "touchstart": "onPointerDown($event, true)", "keydown.space": "onKeyboardActivate($event)", "keydown.enter": "onEnterKey($event)", "keydown.arrowup": "onArrowKey($event)", "keydown.arrowdown": "onArrowKey($event)", "keydown.arrowleft": "onArrowKey($event)", "keydown.arrowright": "onArrowKey($event)", "keydown.escape": "onEscape()" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, ngImport: i0 });
7343
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: DraggableDirective, isStandalone: true, selector: "[vdndDraggable]", inputs: { vdndDraggable: { classPropertyName: "vdndDraggable", publicName: "vdndDraggable", isSignal: true, isRequired: true, transformFunction: null }, vdndDraggableGroup: { classPropertyName: "vdndDraggableGroup", publicName: "vdndDraggableGroup", isSignal: true, isRequired: false, transformFunction: null }, vdndDraggableData: { classPropertyName: "vdndDraggableData", publicName: "vdndDraggableData", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, dragDelay: { classPropertyName: "dragDelay", publicName: "dragDelay", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dragStart: "dragStart", dragEnd: "dragEnd" }, host: { listeners: { "mousedown": "onPointerDown($event, false)" }, properties: { "attr.data-draggable-id": "vdndDraggable()", "class.vdnd-draggable": "true", "class.vdnd-draggable-dragging": "isDragging()", "class.vdnd-draggable-disabled": "disabled()", "class.vdnd-drag-pending": "isPending()", "style.display": "isDragging() ? \"none\" : null", "attr.aria-grabbed": "isDragging() ? \"true\" : \"false\"", "tabindex": "disabled() ? -1 : 0" } }, usesOnChanges: true, ngImport: i0 });
5966
7344
  }
5967
7345
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: DraggableDirective, decorators: [{
5968
7346
  type: Directive,
@@ -5978,17 +7356,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
5978
7356
  '[attr.aria-grabbed]': 'isDragging() ? "true" : "false"',
5979
7357
  '[tabindex]': 'disabled() ? -1 : 0',
5980
7358
  '(mousedown)': 'onPointerDown($event, false)',
5981
- '(touchstart)': 'onPointerDown($event, true)',
5982
- '(keydown.space)': 'onKeyboardActivate($event)',
5983
- '(keydown.enter)': 'onEnterKey($event)',
5984
- '(keydown.arrowup)': 'onArrowKey($event)',
5985
- '(keydown.arrowdown)': 'onArrowKey($event)',
5986
- '(keydown.arrowleft)': 'onArrowKey($event)',
5987
- '(keydown.arrowright)': 'onArrowKey($event)',
5988
- '(keydown.escape)': 'onEscape()',
5989
7359
  },
5990
7360
  }]
5991
- }], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
7361
+ }], ctorParameters: () => [], propDecorators: { vdndDraggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggable", required: true }] }], vdndDraggableGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableGroup", required: false }] }], vdndDraggableData: [{ type: i0.Input, args: [{ isSignal: true, alias: "vdndDraggableData", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], dragDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDelay", required: false }] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
5992
7362
 
5993
7363
  /**
5994
7364
  * Directive that marks an element as a scrollable container for virtual scrolling.
@@ -6160,6 +7530,7 @@ class VirtualForDirective {
6160
7530
  #scrollContainer = inject(VDND_SCROLL_CONTAINER);
6161
7531
  #injector = inject(Injector);
6162
7532
  #ngZone = inject(NgZone);
7533
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
6163
7534
  #dragState = inject(DragStateService);
6164
7535
  #dragIndexCalculator = inject(DragIndexCalculatorService);
6165
7536
  /**
@@ -6168,6 +7539,8 @@ class VirtualForDirective {
6168
7539
  * so we skip individual absolute positioning.
6169
7540
  */
6170
7541
  #viewport = inject(VDND_VIRTUAL_VIEWPORT, { optional: true });
7542
+ /** A vdnd-virtual-viewport, whose rows start contentOffset px down (see #rowsScrollTop) */
7543
+ #offsetRowsViewport = inject(VDND_OFFSET_ROWS_VIEWPORT, { optional: true });
6171
7544
  /**
6172
7545
  * Optional parent droppable directive.
6173
7546
  * When present, droppableId is inherited automatically.
@@ -6189,7 +7562,7 @@ class VirtualForDirective {
6189
7562
  #placeholderInDom = false;
6190
7563
  /** ResizeObserver for dynamic height measurement */
6191
7564
  #resizeObserver = null;
6192
- /** Map from observed HTMLElement to its trackBy key */
7565
+ /** Map from observed HTMLElement to its trackBy key (reset with each new observer) */
6193
7566
  #observedElements = new WeakMap();
6194
7567
  // ========== Inputs ==========
6195
7568
  /** The array of items to iterate over */
@@ -6266,8 +7639,21 @@ class VirtualForDirective {
6266
7639
  const strategy = this.#strategy();
6267
7640
  // Read version to subscribe to dynamic height changes
6268
7641
  strategy.version();
6269
- return strategy.getFirstVisibleIndex(this.#scrollContainer.scrollTop());
7642
+ return strategy.getFirstVisibleIndex(this.#rowsScrollTop());
6270
7643
  }, ...(ngDevMode ? [{ debugName: "#firstVisibleIndex" }] : []));
7644
+ /**
7645
+ * How far the rows are scrolled. A vdnd-virtual-viewport's rows start contentOffset px down its
7646
+ * scroll area, but its scrollTop() is the raw position (scrollBy() relies on it), so subtract the
7647
+ * offset here. vdnd-virtual-content's scrollTop() already excludes its offset.
7648
+ */
7649
+ #rowsScrollTop() {
7650
+ const scrollTop = this.#scrollContainer.scrollTop();
7651
+ const viewport = this.#viewport;
7652
+ if (viewport === null || viewport !== this.#offsetRowsViewport) {
7653
+ return scrollTop;
7654
+ }
7655
+ return Math.max(0, scrollTop - viewport.contentOffset());
7656
+ }
6271
7657
  /** Number of visible items */
6272
7658
  #visibleCount = computed(() => {
6273
7659
  const height = this.#scrollContainer.containerHeight();
@@ -6286,38 +7672,47 @@ class VirtualForDirective {
6286
7672
  const end = Math.min(total - 1, first + visible + overscan);
6287
7673
  return { start, end };
6288
7674
  }, ...(ngDevMode ? [{ debugName: "#renderRange" }] : []));
6289
- /** Map of trackBy keys to item indices for quick lookup */
6290
- #itemIndexMap = computed(() => {
6291
- const items = this.vdndVirtualForOf();
7675
+ /**
7676
+ * Every item's track key, in order. Computed once per items change, and only when something
7677
+ * reads it: a dynamic-height strategy or a drag. A fixed-height strategy needs only the count.
7678
+ */
7679
+ #itemKeys = computed(() => {
6292
7680
  const trackByFn = this.vdndVirtualForTrackBy();
6293
- const map = new Map();
6294
- for (let i = 0; i < items.length; i++) {
6295
- map.set(trackByFn(i, items[i]), i);
6296
- }
6297
- return map;
6298
- }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
6299
- /** Index of the dragged item in this list (-1 if not present or not dragging) */
7681
+ return this.vdndVirtualForOf().map((item, i) => trackByFn(i, item));
7682
+ }, ...(ngDevMode ? [{ debugName: "#itemKeys" }] : []));
7683
+ /**
7684
+ * Index of the dragged item in this list (-1 if not present or not dragging). The index where
7685
+ * the drag found its item is checked first: it spares a search of every track key.
7686
+ */
6300
7687
  #draggedItemIndex = computed(() => {
6301
7688
  const draggedItem = this.#dragState.draggedItem();
6302
7689
  if (!draggedItem)
6303
7690
  return -1;
6304
- const byId = this.#itemIndexMap().get(draggedItem.draggableId);
6305
- if (byId !== undefined) {
7691
+ const items = this.vdndVirtualForOf();
7692
+ const sourceIndex = this.#dragState.sourceIndex();
7693
+ if (sourceIndex !== null && sourceIndex >= 0 && sourceIndex < items.length) {
7694
+ const item = items[sourceIndex];
7695
+ if (this.vdndVirtualForTrackBy()(sourceIndex, item) === draggedItem.draggableId ||
7696
+ (draggedItem.data !== null && draggedItem.data !== undefined && item === draggedItem.data)) {
7697
+ return sourceIndex;
7698
+ }
7699
+ }
7700
+ // The last item with the key, as an index map filled in order would give
7701
+ const byId = this.#itemKeys().lastIndexOf(draggedItem.draggableId);
7702
+ if (byId >= 0) {
6306
7703
  return byId;
6307
7704
  }
6308
7705
  const data = draggedItem.data;
6309
7706
  if (data !== null && data !== undefined) {
6310
- return this.vdndVirtualForOf().indexOf(data);
7707
+ return items.indexOf(data);
6311
7708
  }
6312
7709
  return -1;
6313
7710
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
6314
7711
  constructor() {
6315
- // Keep strategy item keys in sync
7712
+ // Keep the strategy's items in sync. A fixed-height strategy needs only their count, which
7713
+ // spares computing every item's track key on each items change (each drop).
6316
7714
  effect(() => {
6317
- const items = this.vdndVirtualForOf();
6318
- const trackByFn = this.vdndVirtualForTrackBy();
6319
- const keys = items.map((item, i) => trackByFn(i, item));
6320
- this.#strategy().setItemKeys(keys);
7715
+ setStrategyItems(this.#strategy(), this.vdndVirtualForOf().length, () => this.#itemKeys());
6321
7716
  });
6322
7717
  // Register strategy with drag index calculator for accurate position lookups
6323
7718
  effect((onCleanup) => {
@@ -6333,24 +7728,46 @@ class VirtualForDirective {
6333
7728
  // correctly skip the hidden item — not just findIndexAtOffset.
6334
7729
  effect(() => {
6335
7730
  const strategy = this.#strategy();
6336
- const draggedIndex = this.#draggedItemIndex();
6337
7731
  const droppableId = this.#effectiveDroppableId();
6338
7732
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6339
7733
  const isDragging = this.#dragState.isDragging();
6340
7734
  const isSourceList = isDragging && droppableId !== undefined && droppableId === sourceDroppableId;
6341
- if (isSourceList && draggedIndex >= 0) {
7735
+ // Only the source list looks for the dragged item (in all its keys)
7736
+ const draggedIndex = isSourceList ? this.#draggedItemIndex() : -1;
7737
+ if (draggedIndex >= 0) {
6342
7738
  strategy.setExcludedIndex(draggedIndex);
6343
7739
  }
6344
7740
  else {
6345
7741
  strategy.setExcludedIndex(null);
6346
7742
  }
6347
7743
  });
7744
+ // Measure item heights in dynamic height mode: the directive's own input, or the viewport's
7745
+ // strategy when inherited. Both can change at runtime, and the strategy is replaced when they
7746
+ // do, so this creates, replaces or removes the observer. A new observer reports every
7747
+ // rendered item, so a new strategy gets their heights.
7748
+ effect((onCleanup) => {
7749
+ // Read the strategy first so the effect tracks its replacement even when the input is on
7750
+ const strategy = this.#strategy();
7751
+ const measure = this.vdndVirtualForDynamicItemHeight() || strategy instanceof DynamicHeightStrategy;
7752
+ // No ResizeObserver during server rendering
7753
+ if (!measure || !this.#isBrowser) {
7754
+ return;
7755
+ }
7756
+ untracked(() => this.#startMeasuring());
7757
+ onCleanup(() => this.#stopMeasuring());
7758
+ });
6348
7759
  // React to changes and update views
6349
7760
  effect(() => {
6350
7761
  this.#updateViews();
6351
7762
  });
6352
7763
  }
6353
7764
  ngOnInit() {
7765
+ // Server rendering: there is no ResizeObserver, and elements added outside the template
7766
+ // (spacer, placeholder) would not match the client's DOM during hydration. The browser adds
7767
+ // the spacer on init, so the server markup lacks only the list's scroll height until then.
7768
+ if (!this.#isBrowser) {
7769
+ return;
7770
+ }
6354
7771
  // Only create spacer when NOT inside a viewport component
6355
7772
  // (viewport provides its own spacer and wrapper positioning)
6356
7773
  if (!this.#useViewportPositioning) {
@@ -6358,12 +7775,6 @@ class VirtualForDirective {
6358
7775
  }
6359
7776
  // Create placeholder element for drag operations
6360
7777
  this.#createPlaceholder();
6361
- // Set up ResizeObserver for dynamic height mode.
6362
- // Check both the directive's own input and the viewport's strategy (when inherited).
6363
- if (this.vdndVirtualForDynamicItemHeight() ||
6364
- this.#strategy() instanceof DynamicHeightStrategy) {
6365
- this.#setupResizeObserver();
6366
- }
6367
7778
  }
6368
7779
  ngOnDestroy() {
6369
7780
  this.#viewPool.forEach((view) => view.destroy());
@@ -6432,6 +7843,19 @@ class VirtualForDirective {
6432
7843
  spacer.style.height = `${strategy.getTotalHeight(total)}px`;
6433
7844
  }, { injector: this.#injector });
6434
7845
  }
7846
+ /** Create the ResizeObserver and observe the items already rendered. */
7847
+ #startMeasuring() {
7848
+ this.#setupResizeObserver();
7849
+ for (const [key, view] of this.#activeViews) {
7850
+ this.#observeViewElements(view, key);
7851
+ }
7852
+ }
7853
+ /** Disconnect the ResizeObserver and forget what it observed. */
7854
+ #stopMeasuring() {
7855
+ this.#resizeObserver?.disconnect();
7856
+ this.#resizeObserver = null;
7857
+ this.#observedElements = new WeakMap();
7858
+ }
6435
7859
  /**
6436
7860
  * Set up ResizeObserver for dynamic height measurement.
6437
7861
  */
@@ -6509,14 +7933,12 @@ class VirtualForDirective {
6509
7933
  strategy.version();
6510
7934
  const placeholderIndex = this.#placeholderIndex();
6511
7935
  const showPlaceholder = this.#shouldShowPlaceholder();
6512
- const draggedIndex = this.#draggedItemIndex();
6513
7936
  const droppableId = this.#effectiveDroppableId();
6514
7937
  const sourceDroppableId = this.#dragState.sourceDroppableId();
6515
7938
  const isSourceList = droppableId ? droppableId === sourceDroppableId : true;
6516
- const shouldKeepDragged = this.#dragState.isDragging() &&
6517
- draggedIndex >= 0 &&
6518
- isSourceList &&
6519
- draggedIndex < items.length;
7939
+ // Only the source list keeps the dragged item rendered, so only it looks for the item
7940
+ const draggedIndex = this.#dragState.isDragging() && isSourceList ? this.#draggedItemIndex() : -1;
7941
+ const shouldKeepDragged = draggedIndex >= 0 && draggedIndex < items.length;
6520
7942
  // Snapshot positions before the DOM changes so displaced items can slide
6521
7943
  this.#shiftAnimator?.beforeUpdate(this.#dragState.isDragging(), placeholderIndex);
6522
7944
  // Notify viewport of render start index for wrapper positioning
@@ -6626,14 +8048,8 @@ class VirtualForDirective {
6626
8048
  // Move unused views to pool
6627
8049
  for (const [key, view] of this.#activeViews) {
6628
8050
  if (!neededKeys.has(key)) {
6629
- const index = this.#viewContainer.indexOf(view);
6630
- if (index >= 0) {
6631
- this.#viewContainer.detach(index);
6632
- }
6633
- this.#unobserveViewElements(view);
6634
- this.#cancelShiftAnimation(view);
6635
- this.#viewPool.push(view);
6636
8051
  this.#activeViews.delete(key);
8052
+ this.#poolView(view);
6637
8053
  }
6638
8054
  }
6639
8055
  // Remove placeholder from DOM if not needed
@@ -6645,6 +8061,7 @@ class VirtualForDirective {
6645
8061
  let viewContainerIndex = 0;
6646
8062
  let placeholderDomPosition = -1;
6647
8063
  const renderedKeys = new Set();
8064
+ const changedViews = [];
6648
8065
  for (const entry of itemsToRender) {
6649
8066
  if (entry.type === 'placeholder') {
6650
8067
  placeholderDomPosition = viewContainerIndex;
@@ -6655,7 +8072,7 @@ class VirtualForDirective {
6655
8072
  continue;
6656
8073
  }
6657
8074
  renderedKeys.add(entry.key);
6658
- const view = this.#getOrCreateView(entry.key, entry.context);
8075
+ const view = this.#getOrCreateView(entry.key, entry.context, changedViews);
6659
8076
  // Ensure view is at correct position in ViewContainerRef
6660
8077
  const currentIndex = this.#viewContainer.indexOf(view);
6661
8078
  if (currentIndex !== viewContainerIndex) {
@@ -6680,8 +8097,33 @@ class VirtualForDirective {
6680
8097
  }
6681
8098
  viewContainerIndex++;
6682
8099
  }
8100
+ this.#renderChangedViews(changedViews);
6683
8101
  return placeholderDomPosition;
6684
8102
  }
8103
+ /**
8104
+ * Take a row's view out of the container and pool it. It keeps its element, so its shift
8105
+ * animation is cancelled (the item it renders next must not slide from this one's position),
8106
+ * and a drop animation that hides it shows it again.
8107
+ */
8108
+ #poolView(view) {
8109
+ const index = this.#viewContainer.indexOf(view);
8110
+ if (index >= 0) {
8111
+ this.#viewContainer.detach(index);
8112
+ }
8113
+ this.#unobserveViewElements(view);
8114
+ // A leave animation (`animate.leave`) keeps the row in the DOM, and Angular removes it when
8115
+ // the animation ends: in the row of another item by then, if the view were reused. A row
8116
+ // whose element is dragged (its draggable ID is not its track key) is destroyed too, which
8117
+ // cancels the drag instead of handing it to another item.
8118
+ const draggedElement = this.#dragState.draggedItem()?.element;
8119
+ if (view.rootNodes.some((node) => node.parentNode !== null || (!!draggedElement && node.contains(draggedElement)))) {
8120
+ view.destroy();
8121
+ return;
8122
+ }
8123
+ this.#cancelShiftAnimation(view);
8124
+ revealDropTargetIn(view.rootNodes);
8125
+ this.#viewPool.push(view);
8126
+ }
6685
8127
  /**
6686
8128
  * Clamp target indices for move/insert to avoid out-of-range operations when
6687
8129
  * reconciliation is given invalid duplicate keys or transiently inconsistent view state.
@@ -6707,28 +8149,59 @@ class VirtualForDirective {
6707
8149
  }
6708
8150
  /**
6709
8151
  * Get an existing view or create/recycle one from the pool.
8152
+ * Adds the view to `changedViews` when it needs rendering: new, taken from the pool, or with a
8153
+ * changed context.
6710
8154
  */
6711
- #getOrCreateView(key, context) {
6712
- let view = this.#activeViews.get(key);
8155
+ #getOrCreateView(key, context, changedViews) {
8156
+ const activeView = this.#activeViews.get(key);
8157
+ let view = activeView ?? this.#viewPool.pop();
6713
8158
  if (view) {
6714
- // Update existing view context
6715
- Object.assign(view.context, context);
6716
- view.markForCheck();
8159
+ // A pooled view was detached, so change detection skipped it: render it even when it
8160
+ // comes back with the same context, as what its template shows may have changed since
8161
+ if (this.#updateContext(view.context, context) || !activeView) {
8162
+ changedViews.push(view);
8163
+ }
6717
8164
  }
6718
8165
  else {
6719
- // Try pool first, then create new
6720
- view = this.#viewPool.pop();
6721
- if (view) {
6722
- Object.assign(view.context, context);
6723
- view.markForCheck();
6724
- }
6725
- else {
6726
- view = this.#templateRef.createEmbeddedView(context);
6727
- }
6728
- this.#activeViews.set(key, view);
8166
+ view = this.#templateRef.createEmbeddedView(context);
8167
+ changedViews.push(view);
6729
8168
  }
8169
+ this.#activeViews.set(key, view);
6730
8170
  return view;
6731
8171
  }
8172
+ /** Copy the fields of `next` into `context`. Returns whether any of them changed. */
8173
+ #updateContext(context, next) {
8174
+ if (Object.is(context.$implicit, next.$implicit) &&
8175
+ context.index === next.index &&
8176
+ context.first === next.first &&
8177
+ context.last === next.last &&
8178
+ context.count === next.count) {
8179
+ return false;
8180
+ }
8181
+ context.$implicit = next.$implicit;
8182
+ context.index = next.index;
8183
+ context.first = next.first;
8184
+ context.last = next.last;
8185
+ context.count = next.count;
8186
+ return true;
8187
+ }
8188
+ /**
8189
+ * Render the views whose context changed, and only those. `markForCheck()` would mark every
8190
+ * ancestor view up to the root as well, re-rendering all of them (and every row of the list)
8191
+ * on each scroll step. Untracked, so the rows' signal reads do not become this effect's
8192
+ * dependencies.
8193
+ */
8194
+ #renderChangedViews(changedViews) {
8195
+ if (changedViews.length === 0)
8196
+ return;
8197
+ untracked(() => {
8198
+ for (const view of changedViews) {
8199
+ if (!view.destroyed) {
8200
+ view.detectChanges();
8201
+ }
8202
+ }
8203
+ });
8204
+ }
6732
8205
  /**
6733
8206
  * Apply absolute positioning styles to a view's root nodes.
6734
8207
  */
@@ -6867,9 +8340,9 @@ function moveItem(event, lists) {
6867
8340
  reorderItems(event, sourceList);
6868
8341
  return;
6869
8342
  }
6870
- // Cross-list move
6871
- const sourceItems = sourceList();
6872
- const item = sourceItems[sourceIndex];
8343
+ // Cross-list move. Read untracked, as in reorderItems: an effect that applies the drop must
8344
+ // not depend on the lists it changes.
8345
+ const item = untracked(sourceList)[sourceIndex];
6873
8346
  if (item === undefined) {
6874
8347
  if (isDevMode()) {
6875
8348
  console.warn(`[ngx-virtual-dnd] [moveItem] Could not find item at index ${sourceIndex}`);
@@ -6911,6 +8384,14 @@ function reorderItems(event, list) {
6911
8384
  if (sourceIndex === destIndex) {
6912
8385
  return;
6913
8386
  }
8387
+ // The list may have changed during the drag; never splice in a missing item as undefined.
8388
+ // Read untracked: from an effect, a tracked read would make the effect re-run on this reorder.
8389
+ if (untracked(list)[sourceIndex] === undefined) {
8390
+ if (isDevMode()) {
8391
+ console.warn(`[ngx-virtual-dnd] [reorderItems] Could not find item at index ${sourceIndex}`);
8392
+ }
8393
+ return;
8394
+ }
6914
8395
  list.update((items) => {
6915
8396
  const newItems = [...items];
6916
8397
  const [removed] = newItems.splice(sourceIndex, 1);