ngx-virtual-dnd 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, computed, effect, Injectable, inject, NgZone, input, ChangeDetectionStrategy, Component, DestroyRef, afterNextRender, ElementRef, Injector, PLATFORM_ID, untracked, afterRenderEffect, viewChild, Directive, isDevMode, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
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
 
@@ -451,6 +454,14 @@ const VDND_SCROLL_CONTAINER = new InjectionToken('VDND_SCROLL_CONTAINER');
451
454
  * a viewport component that handles wrapper-based positioning.
452
455
  */
453
456
  const VDND_VIRTUAL_VIEWPORT = new InjectionToken('VDND_VIRTUAL_VIEWPORT');
457
+ /**
458
+ * Internal, not exported from the package. Provided by vdnd-virtual-viewport next to
459
+ * VDND_VIRTUAL_VIEWPORT: its rows start contentOffset px down its scroll area while its
460
+ * scrollTop() is the raw position, so VirtualForDirective subtracts the offset when the viewport
461
+ * it injects is this one. A token instead of an instanceof check keeps the viewport component out
462
+ * of apps that don't use it.
463
+ */
464
+ const VDND_OFFSET_ROWS_VIEWPORT = new InjectionToken('VDND_OFFSET_ROWS_VIEWPORT');
454
465
 
455
466
  /** Default shift animation duration (ms) used when the config omits `shiftDuration`. */
456
467
  const DEFAULT_SHIFT_DURATION = 200;
@@ -506,7 +517,7 @@ class DragStateService {
506
517
  #keyboardTargetIndex = signal(null, ...(ngDevMode ? [{ debugName: "#keyboardTargetIndex" }] : []));
507
518
  /** ID of the droppable currently being hovered over — changes on droppable crossings */
508
519
  #activeDroppableId = signal(null, ...(ngDevMode ? [{ debugName: "#activeDroppableId" }] : []));
509
- /** ID of the item the placeholder should appear before */
520
+ /** `END_OF_LIST` while a droppable is targeted, otherwise null; see placeholderIndex */
510
521
  #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
511
522
  /** Index where the placeholder should be inserted — updated on each placeholder move */
512
523
  #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
@@ -530,7 +541,11 @@ class DragStateService {
530
541
  sourceIndex = computed(() => this.#state().sourceIndex, ...(ngDevMode ? [{ debugName: "sourceIndex" }] : []));
531
542
  /** ID of the droppable currently being hovered over */
532
543
  activeDroppableId = this.#activeDroppableId.asReadonly();
533
- /** ID of the item the placeholder should appear before */
544
+ /**
545
+ * `END_OF_LIST` while a droppable is targeted, otherwise null.
546
+ * @deprecated It never identifies an item. Use `placeholderIndex`. Will be removed in the next
547
+ * major version.
548
+ */
534
549
  placeholderId = this.#placeholderId.asReadonly();
535
550
  /** Index where the placeholder should be inserted */
536
551
  placeholderIndex = this.#placeholderIndex.asReadonly();
@@ -572,6 +587,7 @@ class DragStateService {
572
587
  }
573
588
  /**
574
589
  * Start a drag operation.
590
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
575
591
  */
576
592
  startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
577
593
  // Reset terminal drag metadata at start of new drag
@@ -595,6 +611,7 @@ class DragStateService {
595
611
  }
596
612
  /**
597
613
  * Update the drag position and targets.
614
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
598
615
  */
599
616
  updateDragPosition(update) {
600
617
  if (!this.#state().isDragging) {
@@ -619,7 +636,9 @@ class DragStateService {
619
636
  this.#activeDroppableId.set(droppableId);
620
637
  }
621
638
  /**
622
- * Update just the placeholder position.
639
+ * Update just the placeholder ID.
640
+ * @deprecated Sets only the deprecated `placeholderId`. Will be removed in the next major
641
+ * version.
623
642
  */
624
643
  setPlaceholder(placeholderId) {
625
644
  if (!this.#state().isDragging) {
@@ -633,6 +652,7 @@ class DragStateService {
633
652
  * Used by the autoscroll scroll-only fast path: when a scroll fires, the cursor has not
634
653
  * moved, so only the placeholder index needs recalculation. Skipping the cursor and
635
654
  * active-droppable writes avoids spurious re-evaluation of computeds that read those signals.
655
+ * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
636
656
  */
637
657
  updateScrollOnlyPlaceholder(placeholderId, placeholderIndex) {
638
658
  if (!this.#state().isDragging) {
@@ -760,6 +780,10 @@ function queryAllByAttribute(root, attributeName, attributeValue) {
760
780
  return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
761
781
  }
762
782
 
783
+ /** The parent lookups stop below `<body>`: it and `<html>` never count as list or item. */
784
+ function isPageRoot(element) {
785
+ return element.tagName === 'BODY' || element.tagName === 'HTML';
786
+ }
763
787
  /**
764
788
  * Service for calculating drop positions and finding elements at cursor positions.
765
789
  * This is the core algorithm that makes virtual scroll + drag-and-drop work together.
@@ -774,8 +798,6 @@ class PositionCalculatorService {
774
798
  #DROPPABLE_DISABLED_ATTR = 'data-droppable-disabled';
775
799
  /** Data attribute used to identify draggable elements */
776
800
  #DRAGGABLE_ID_ATTR = 'data-draggable-id';
777
- /** Maximum DOM levels to traverse when looking for parent elements */
778
- #MAX_PARENT_TRAVERSAL = 15;
779
801
  /** Reusable result object for getNearEdge (avoids per-frame allocation) */
780
802
  #nearEdgeResult = { top: false, bottom: false, left: false, right: false };
781
803
  /** Active drag session rect snapshot, or null when no drag is in progress. */
@@ -1071,15 +1093,19 @@ class PositionCalculatorService {
1071
1093
  * @returns The droppable parent element, or null if none found
1072
1094
  */
1073
1095
  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) {
1096
+ // An empty group name never matches (an empty attribute value is no group)
1097
+ if (!groupName) {
1098
+ return null;
1099
+ }
1100
+ // Match on the attribute's presence and compare the value here, so the group name needs no
1101
+ // selector escaping. Nearer droppables of other groups are skipped.
1102
+ const selector = `[${this.#DROPPABLE_GROUP_ATTR}]`;
1103
+ let current = element.closest(selector);
1104
+ while (current && !isPageRoot(current)) {
1105
+ if (current.getAttribute(this.#DROPPABLE_GROUP_ATTR) === groupName) {
1079
1106
  return current;
1080
1107
  }
1081
- current = current.parentElement;
1082
- count++;
1108
+ current = current.parentElement?.closest(selector) ?? null;
1083
1109
  }
1084
1110
  return null;
1085
1111
  }
@@ -1090,17 +1116,9 @@ class PositionCalculatorService {
1090
1116
  * @returns The draggable parent element, or null if none found
1091
1117
  */
1092
1118
  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;
1119
+ const selector = `[${this.#DRAGGABLE_ID_ATTR}]:not([${this.#DRAGGABLE_ID_ATTR}=""])`;
1120
+ const draggable = element.closest(selector);
1121
+ return draggable && !isPageRoot(draggable) ? draggable : null;
1104
1122
  }
1105
1123
  /**
1106
1124
  * Get the draggable ID from an element.
@@ -1334,8 +1352,14 @@ class DragSchedulerService {
1334
1352
  for (const participant of this.#participants) {
1335
1353
  participant();
1336
1354
  }
1355
+ // A participant can end the drag (the autoscroll callback may cancel it), which stops
1356
+ // the scheduler and clears onTick.
1357
+ const onTick = this.#onTick;
1358
+ if (!onTick) {
1359
+ return;
1360
+ }
1337
1361
  // Phase 2 — main compute (cursor-based hit-test + signal write)
1338
- this.#onTick(this.#pendingCursor, cursorDirty);
1362
+ onTick(this.#pendingCursor, cursorDirty);
1339
1363
  // Reschedule only if still active (stop() may have been called by onTick).
1340
1364
  if (this.#onTick !== null) {
1341
1365
  this.#scheduleNextTick();
@@ -1868,6 +1892,11 @@ class ElementCloneService {
1868
1892
  el.setAttribute('disabled', 'true');
1869
1893
  }
1870
1894
  });
1895
+ // A checked radio joins its group as soon as the clone is connected, which unchecks the
1896
+ // radio in the source item. Without a name the cloned radios belong to no group.
1897
+ clone.querySelectorAll('input[type="radio"][name]').forEach((radio) => {
1898
+ radio.removeAttribute('name');
1899
+ });
1871
1900
  // Remove focus styling classes that might interfere
1872
1901
  clone.classList.remove('vdnd-draggable-dragging');
1873
1902
  clone.classList.remove('vdnd-draggable-disabled');
@@ -2118,6 +2147,7 @@ class DragIndexCalculatorService {
2118
2147
  const cached = this.#droppableCache.get(droppableElement);
2119
2148
  if (cached)
2120
2149
  return cached;
2150
+ const isViewport = droppableElement.hasAttribute('data-virtual-viewport');
2121
2151
  const virtualScrollElement = droppableElement.querySelector('vdnd-virtual-scroll');
2122
2152
  const virtualContentElement = (droppableElement.matches('vdnd-virtual-content')
2123
2153
  ? droppableElement
@@ -2125,7 +2155,13 @@ class DragIndexCalculatorService {
2125
2155
  let containerType;
2126
2156
  let scrollContainer;
2127
2157
  let scrollableParent = null;
2128
- if (virtualScrollElement) {
2158
+ if (isViewport) {
2159
+ // The viewport scrolls its own rows. Checked first so a list nested in a row is not
2160
+ // mistaken for this one.
2161
+ containerType = 'viewport';
2162
+ scrollContainer = droppableElement;
2163
+ }
2164
+ else if (virtualScrollElement) {
2129
2165
  containerType = 'virtualScroll';
2130
2166
  scrollContainer = virtualScrollElement;
2131
2167
  }
@@ -2164,29 +2200,19 @@ class DragIndexCalculatorService {
2164
2200
  getTotalItemCount(args) {
2165
2201
  return this.#getTotalItemCount(args.droppableElement, args.isSameList, args.draggedItemHeight);
2166
2202
  }
2203
+ /**
2204
+ * Live scroll geometry of a droppable's rows (see {@link DroppableScrollGeometry}). The same
2205
+ * measurement places the placeholder and finds the dragged item's source index.
2206
+ */
2207
+ getScrollGeometry(droppableElement, draggedItemHeight) {
2208
+ return this.#getScrollGeometry(this.#resolveDroppable(droppableElement, draggedItemHeight));
2209
+ }
2167
2210
  calculatePlaceholderIndex(args) {
2168
2211
  const { droppableElement, position, grabOffset, draggedItemHeight, sourceDroppableId, sourceIndex, } = args;
2169
2212
  // Resolve cached metadata (DOM queries run once per droppable per drag session)
2170
2213
  const cache = this.#resolveDroppable(droppableElement, draggedItemHeight);
2171
2214
  // 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
- }
2215
+ const { rect, scrollTop: currentScrollTop } = this.#getScrollGeometry(cache);
2190
2216
  const { strategy, itemHeight, droppableId: currentDroppableId, isConstrainedToContainer, } = cache;
2191
2217
  // Calculate preview center position mathematically
2192
2218
  // The preview is positioned at: cursorPosition - grabOffset (see drag-preview.component.ts)
@@ -2274,6 +2300,35 @@ class DragIndexCalculatorService {
2274
2300
  placeholderIndex = Math.max(0, Math.min(placeholderIndex, totalItems));
2275
2301
  return { index: placeholderIndex, placeholderId: END_OF_LIST };
2276
2302
  }
2303
+ #getScrollGeometry(cache) {
2304
+ const { containerType, scrollContainer } = cache;
2305
+ const rect = scrollContainer.getBoundingClientRect();
2306
+ const isVirtual = containerType === 'virtualScroll' || containerType === 'virtualContent';
2307
+ switch (containerType) {
2308
+ case 'viewport':
2309
+ return {
2310
+ rect,
2311
+ scrollTop: scrollContainer.scrollTop - this.#readContentOffset(scrollContainer),
2312
+ isVirtual,
2313
+ };
2314
+ case 'virtualContent':
2315
+ // Without a vdndScrollable parent the content element itself is measured, unscrolled
2316
+ return cache.scrollableParent
2317
+ ? {
2318
+ rect,
2319
+ scrollTop: scrollContainer.scrollTop - this.#readContentOffset(cache.virtualContentElement),
2320
+ isVirtual,
2321
+ }
2322
+ : { rect, scrollTop: 0, isVirtual };
2323
+ default:
2324
+ return { rect, scrollTop: scrollContainer.scrollTop, isVirtual };
2325
+ }
2326
+ }
2327
+ /** The `data-content-offset` (px reserved above the rows) of a virtual container, or 0. */
2328
+ #readContentOffset(element) {
2329
+ const offset = parseFloat(element?.getAttribute('data-content-offset') ?? '');
2330
+ return Number.isFinite(offset) ? offset : 0;
2331
+ }
2277
2332
  #getTotalItemCount(droppableElement, isSameList, draggedItemHeight) {
2278
2333
  // Check if a registered strategy exists
2279
2334
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
@@ -2401,10 +2456,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
2401
2456
  }, template: ``, styles: [":host{display:block;pointer-events:none}\n"] }]
2402
2457
  }], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }] } });
2403
2458
 
2459
+ /** A layout value, or 0 where there is no layout (server rendering leaves them undefined). */
2460
+ function layoutValue(value) {
2461
+ return Number.isFinite(value) ? value : 0;
2462
+ }
2404
2463
  function bindRafThrottledScrollTopSignal(options) {
2405
2464
  const { element, ngZone, scrollTop, thresholdPx = 5, onCommit } = options;
2406
2465
  let pendingRaf = null;
2407
- let lastCommittedScrollTop = element.scrollTop;
2466
+ let lastCommittedScrollTop = layoutValue(element.scrollTop);
2408
2467
  const onScroll = () => {
2409
2468
  if (pendingRaf !== null) {
2410
2469
  return;
@@ -2438,7 +2497,7 @@ function bindRafThrottledScrollTopSignal(options) {
2438
2497
  function bindResizeObserverHeightSignal(options) {
2439
2498
  const { element, ngZone, height, minDeltaPx = 1 } = options;
2440
2499
  if (typeof ResizeObserver === 'undefined') {
2441
- height.set(element.clientHeight);
2500
+ height.set(layoutValue(element.clientHeight));
2442
2501
  return () => undefined;
2443
2502
  }
2444
2503
  let observer = null;
@@ -2453,7 +2512,7 @@ function bindResizeObserverHeightSignal(options) {
2453
2512
  });
2454
2513
  observer.observe(element);
2455
2514
  });
2456
- height.set(element.clientHeight);
2515
+ height.set(layoutValue(element.clientHeight));
2457
2516
  return () => {
2458
2517
  observer?.disconnect();
2459
2518
  observer = null;
@@ -2716,11 +2775,12 @@ class VirtualScrollContainerComponent {
2716
2775
  #keyboardDrag = inject(KeyboardDragService);
2717
2776
  #ngZone = inject(NgZone);
2718
2777
  #injector = inject(Injector);
2778
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
2719
2779
  /** Cleanup function for scroll listener */
2720
2780
  #scrollCleanup = null;
2721
2781
  #resizeCleanup = null;
2722
- /** ResizeObserver for dynamic height measurement */
2723
- #itemResizeObserver = null;
2782
+ /** ResizeObserver for dynamic height measurement (only in dynamic height mode) */
2783
+ #itemResizeObserver = signal(null, ...(ngDevMode ? [{ debugName: "#itemResizeObserver" }] : []));
2724
2784
  /** Map from observed HTMLElement to its trackBy key */
2725
2785
  #observedElements = new WeakMap();
2726
2786
  /** Stable mapping from rendered key to observed element for unobserve cleanup */
@@ -3098,10 +3158,23 @@ class VirtualScrollContainerComponent {
3098
3158
  });
3099
3159
  }
3100
3160
  ngOnInit() {
3101
- // Set up ResizeObserver for dynamic height measurement
3102
- if (this.dynamicItemHeight()) {
3103
- this.#setupItemResizeObserver();
3104
- }
3161
+ // Measure rows in dynamic height mode. dynamicItemHeight can change at runtime, and the
3162
+ // strategy is replaced when it (or itemHeight) does, so this creates, replaces or removes the
3163
+ // observer. #observeRenderedItems observes every rendered row with each new observer, so a
3164
+ // new strategy gets their heights.
3165
+ effect((onCleanup) => {
3166
+ // No ResizeObserver during server rendering
3167
+ if (!this.dynamicItemHeight() || !this.#isBrowser)
3168
+ return;
3169
+ this.#strategy();
3170
+ const observer = untracked(() => this.#createItemResizeObserver());
3171
+ this.#itemResizeObserver.set(observer);
3172
+ onCleanup(() => {
3173
+ observer.disconnect();
3174
+ this.#observedElementByKey.clear();
3175
+ this.#itemResizeObserver.set(null);
3176
+ });
3177
+ }, { injector: this.#injector });
3105
3178
  }
3106
3179
  ngAfterViewInit() {
3107
3180
  const element = this.#elementRef.nativeElement;
@@ -3120,9 +3193,7 @@ class VirtualScrollContainerComponent {
3120
3193
  thresholdPx: 5,
3121
3194
  });
3122
3195
  // Observe rendered items for dynamic height measurement
3123
- if (this.dynamicItemHeight()) {
3124
- this.#observeRenderedItems();
3125
- }
3196
+ this.#observeRenderedItems();
3126
3197
  }
3127
3198
  /**
3128
3199
  * Scroll so the placeholder is fully visible during a keyboard drag into this list.
@@ -3171,11 +3242,12 @@ class VirtualScrollContainerComponent {
3171
3242
  ngOnDestroy() {
3172
3243
  this.#scrollCleanup?.();
3173
3244
  this.#resizeCleanup?.();
3245
+ const itemResizeObserver = this.#itemResizeObserver();
3174
3246
  for (const element of this.#observedElementByKey.values()) {
3175
- this.#itemResizeObserver?.unobserve(element);
3247
+ itemResizeObserver?.unobserve(element);
3176
3248
  }
3177
3249
  this.#observedElementByKey.clear();
3178
- this.#itemResizeObserver?.disconnect();
3250
+ itemResizeObserver?.disconnect();
3179
3251
  this.#shiftAnimator?.cancelAll();
3180
3252
  }
3181
3253
  #createShiftAnimator() {
@@ -3204,39 +3276,37 @@ class VirtualScrollContainerComponent {
3204
3276
  }
3205
3277
  }
3206
3278
  /**
3207
- * Set up ResizeObserver for measuring individual item heights.
3279
+ * Create the ResizeObserver that measures individual item heights.
3208
3280
  */
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
- }
3281
+ #createItemResizeObserver() {
3282
+ return this.#ngZone.runOutsideAngular(() => new ResizeObserver((entries) => {
3283
+ const strategy = this.#strategy();
3284
+ for (const entry of entries) {
3285
+ const element = entry.target;
3286
+ const key = this.#observedElements.get(element);
3287
+ if (key === undefined)
3288
+ continue;
3289
+ const height = entry.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
3290
+ if (height > 0) {
3291
+ strategy.setMeasuredHeight(key, height);
3222
3292
  }
3223
- });
3224
- });
3293
+ }
3294
+ }));
3225
3295
  }
3226
3296
  /**
3227
3297
  * Observe currently rendered items' DOM elements for height changes.
3228
- * Called after view init and on each render cycle in dynamic mode.
3298
+ * Called after view init; re-observes after each render in dynamic mode, and every rendered
3299
+ * item when a new observer is created.
3229
3300
  */
3230
3301
  #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;
3302
+ // Re-observe whenever rendered items change, once they are in the DOM (a plain effect runs
3303
+ // before the template renders them, so newly rendered rows would be missed)
3304
+ afterRenderEffect(() => {
3305
+ const observer = this.#itemResizeObserver();
3238
3306
  if (!observer)
3239
3307
  return;
3308
+ const rendered = this.renderedItems();
3309
+ const idFn = this.itemIdFn();
3240
3310
  // Find the content wrapper and observe item elements
3241
3311
  const wrapper = this.#elementRef.nativeElement.querySelector('.vdnd-virtual-scroll-content-wrapper');
3242
3312
  if (!wrapper)
@@ -3275,8 +3345,11 @@ class VirtualScrollContainerComponent {
3275
3345
  * Scroll to a specific position.
3276
3346
  */
3277
3347
  scrollTo(position) {
3278
- this.#elementRef.nativeElement.scrollTop = position;
3279
- this.#scrollTop.set(position);
3348
+ const element = this.#elementRef.nativeElement;
3349
+ element.scrollTop = position;
3350
+ // Store where the browser actually scrolled. It clamps to the scrollable range, and if that
3351
+ // leaves the element where it was, no scroll event arrives to correct an out-of-range value.
3352
+ this.#scrollTop.set(element.scrollTop);
3280
3353
  }
3281
3354
  /**
3282
3355
  * Scroll to a specific item index.
@@ -3982,7 +4055,11 @@ class DroppableDirective {
3982
4055
  const activeId = this.#dragState.activeDroppableId();
3983
4056
  return activeId === this.vdndDroppable() && !this.disabled();
3984
4057
  }, ...(ngDevMode ? [{ debugName: "isActive" }] : []));
3985
- /** The current placeholder ID when this droppable is active */
4058
+ /**
4059
+ * `END_OF_LIST` while this droppable is active, otherwise null.
4060
+ * @deprecated It never identifies an item. For the position, use the `placeholderMove` output
4061
+ * (and `DropEvent.destination.index` on drop). Will be removed in the next major version.
4062
+ */
3986
4063
  placeholderId = computed(() => {
3987
4064
  if (!this.isActive()) {
3988
4065
  return null;
@@ -4018,6 +4095,8 @@ class DroppableDirective {
4018
4095
  * when the effect re-runs (e.g. `disabled()` toggling) after the drag has ended.
4019
4096
  */
4020
4097
  #handledEndedState = null;
4098
+ /** Whether this droppable has rendered, so its inputs have values */
4099
+ #rendered = false;
4021
4100
  constructor() {
4022
4101
  createAutoScrollRegistration({
4023
4102
  autoScrollService: this.#autoScroll,
@@ -4034,6 +4113,7 @@ class DroppableDirective {
4034
4113
  // Matters when it mounts DURING an active drag — the candidate snapshot was frozen at
4035
4114
  // drag start, so without this a conditionally rendered list would never become a target.
4036
4115
  afterNextRender(() => {
4116
+ this.#rendered = true;
4037
4117
  const group = this.effectiveGroup();
4038
4118
  if (group) {
4039
4119
  this.#positionCalculator.notifyCandidatesChanged(group);
@@ -4079,12 +4159,17 @@ class DroppableDirective {
4079
4159
  });
4080
4160
  }
4081
4161
  ngOnDestroy() {
4162
+ // Destroyed before it rendered: its inputs may have no values yet, and it never became a
4163
+ // drop target or a candidate the calculator knows about. (Not an ngOnInit flag: a subclass
4164
+ // with its own ngOnInit would skip it.)
4165
+ if (!this.#rendered) {
4166
+ return;
4167
+ }
4082
4168
  // Clean up if this droppable is destroyed while being active
4083
4169
  if (this.isActive()) {
4084
4170
  this.#dragState.setActiveDroppable(null);
4085
4171
  }
4086
- // Unregister from auto-scroll
4087
- this.#autoScroll.unregisterContainer(this.vdndDroppable());
4172
+ // Auto-scroll unregisters itself on destroy (see createAutoScrollRegistration).
4088
4173
  // If this droppable unmounts mid-drag, tell the calculator so it drops it from the
4089
4174
  // frozen candidate list (deferred re-query runs once the element has left the DOM).
4090
4175
  const group = untracked(() => this.effectiveGroup());
@@ -4557,8 +4642,9 @@ class VirtualViewportComponent {
4557
4642
  });
4558
4643
  }
4559
4644
  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: [
4645
+ 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
4646
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
4647
+ { provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
4562
4648
  { provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
4563
4649
  ], ngImport: i0, template: `
4564
4650
  <!-- Spacer maintains total scroll height -->
@@ -4582,9 +4668,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4582
4668
  type: Component,
4583
4669
  args: [{ selector: 'vdnd-virtual-viewport', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
4584
4670
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
4671
+ { provide: VDND_OFFSET_ROWS_VIEWPORT, useExisting: VirtualViewportComponent },
4585
4672
  { provide: VDND_SCROLL_CONTAINER, useExisting: VirtualViewportComponent },
4586
4673
  ], host: {
4587
4674
  class: 'vdnd-virtual-viewport',
4675
+ // Read by the drag index calculator: this element scrolls its own rows, which start
4676
+ // data-content-offset px below the top of the scroll area
4677
+ 'data-virtual-viewport': '',
4678
+ '[attr.data-content-offset]': 'contentOffset()',
4588
4679
  }, template: `
4589
4680
  <!-- Spacer maintains total scroll height -->
4590
4681
  <div
@@ -4679,6 +4770,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4679
4770
  class VirtualContentComponent {
4680
4771
  #elementRef = inject((ElementRef));
4681
4772
  #ngZone = inject(NgZone);
4773
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
4682
4774
  /**
4683
4775
  * The parent scroll container injected via skip-self to get the actual scrollable element.
4684
4776
  * We provide our own VDND_SCROLL_CONTAINER with adjusted values to children.
@@ -4807,6 +4899,10 @@ class VirtualContentComponent {
4807
4899
  this.#measuredHeaderHeight.set(0);
4808
4900
  return;
4809
4901
  }
4902
+ // Server rendering: no layout to measure, and no ResizeObserver
4903
+ if (!this.#isBrowser) {
4904
+ return;
4905
+ }
4810
4906
  const el = dir.elementRef.nativeElement;
4811
4907
  this.#measuredHeaderHeight.set(el.offsetHeight);
4812
4908
  let observer = null;
@@ -4885,15 +4981,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
4885
4981
  class KeyboardDragHandler {
4886
4982
  #deps;
4887
4983
  #boundKeyDown = null;
4984
+ /**
4985
+ * The drag this handler started. Keyboard drag state is shared by every draggable, so a key
4986
+ * that reaches another item (one focused by a click during the drag, for example) must not
4987
+ * move, drop or cancel this drag in that item's name.
4988
+ */
4989
+ #ownDrag = null;
4888
4990
  constructor(deps) {
4889
4991
  this.#deps = deps;
4890
4992
  this.#boundKeyDown = this.#onDocumentKeyDown.bind(this);
4891
4993
  }
4892
4994
  /**
4893
- * Whether a keyboard drag is currently active.
4995
+ * Whether a keyboard drag this handler started is active.
4894
4996
  */
4895
4997
  isActive() {
4896
- return this.#deps.keyboardDrag.isActive();
4998
+ return (this.#ownDrag !== null &&
4999
+ this.#deps.keyboardDrag.isActive() &&
5000
+ this.#deps.dragState.draggedItem() === this.#ownDrag);
4897
5001
  }
4898
5002
  /**
4899
5003
  * Start a keyboard drag operation.
@@ -4937,6 +5041,7 @@ class KeyboardDragHandler {
4937
5041
  width: rect.width,
4938
5042
  data: ctx.data,
4939
5043
  }, sourceIndex, totalItemCount, droppableId);
5044
+ this.#ownDrag = this.#deps.dragState.draggedItem();
4940
5045
  // Add document-level keyboard listener (since element is hidden with display:none)
4941
5046
  this.#deps.ngZone.runOutsideAngular(() => {
4942
5047
  document.addEventListener('keydown', this.#boundKeyDown);
@@ -5132,19 +5237,42 @@ class KeyboardDragHandler {
5132
5237
  * Document-level keydown listener active during keyboard drag.
5133
5238
  */
5134
5239
  #onDocumentKeyDown(event) {
5240
+ // The drag ended without this handler (its state was reset elsewhere): stop listening, or
5241
+ // this listener would also take the keys of the next keyboard drag
5242
+ if (!this.isActive()) {
5243
+ this.#cleanupDocumentListener();
5244
+ return;
5245
+ }
5135
5246
  this.handleKey(event);
5136
5247
  }
5137
5248
  /**
5138
5249
  * Remove the document-level keyboard listener.
5139
5250
  */
5140
5251
  #cleanupDocumentListener() {
5141
- if (this.#boundKeyDown) {
5252
+ this.#ownDrag = null;
5253
+ // Server rendering destroys the directive without a global document, and never added it
5254
+ if (this.#boundKeyDown && typeof document !== 'undefined') {
5142
5255
  document.removeEventListener('keydown', this.#boundKeyDown);
5143
5256
  }
5144
5257
  }
5145
5258
  }
5146
5259
 
5147
- var _a;
5260
+ /**
5261
+ * Elements inside a draggable that keep their own interaction: pressing one never starts a
5262
+ * pointer drag, and Space pressed on one never starts a keyboard drag.
5263
+ */
5264
+ const INTERACTIVE_ELEMENT_SELECTOR = 'button, input, textarea, select, [contenteditable]';
5265
+ /** Class that excludes the element carrying it, and everything inside it, from starting a drag. */
5266
+ const NO_DRAG_CLASS = 'no-drag';
5267
+ /**
5268
+ * The `no-drag` element inside `draggable` (the draggable itself included) that contains
5269
+ * `target`, or null. A `no-drag` ancestor of the draggable does not count.
5270
+ */
5271
+ function findNoDragElement(target, draggable) {
5272
+ const noDrag = target.closest(`.${NO_DRAG_CLASS}`);
5273
+ return noDrag !== null && draggable.contains(noDrag) ? noDrag : null;
5274
+ }
5275
+
5148
5276
  /**
5149
5277
  * Handles pointer-based (mouse + touch) drag operations.
5150
5278
  *
@@ -5154,7 +5282,8 @@ var _a;
5154
5282
  * - Drag delay timer
5155
5283
  * - Forwarding moves to the directive (DragSchedulerService coalesces them per frame)
5156
5284
  * - Document-level listener management
5157
- * - Escape key cancellation during pointer drag
5285
+ * - Escape key cancellation during pointer drag, and cancellation when the window loses focus
5286
+ * or the page is hidden (the release would never arrive) or the system cancels the touch
5158
5287
  */
5159
5288
  class PointerDragHandler {
5160
5289
  #deps;
@@ -5162,10 +5291,14 @@ class PointerDragHandler {
5162
5291
  #startPosition = null;
5163
5292
  /** Whether we're currently tracking a potential drag */
5164
5293
  #isTracking = false;
5294
+ /** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
5295
+ #touchId = null;
5165
5296
  /** Bound event handlers for cleanup */
5166
5297
  #boundPointerMove = null;
5167
5298
  #boundPointerUp = null;
5168
5299
  #boundKeyDown = null;
5300
+ #boundWindowBlur = null;
5301
+ #boundVisibilityChange = null;
5169
5302
  /** Timer ID for drag delay */
5170
5303
  #delayTimerId = null;
5171
5304
  /** Whether the delay has been satisfied (user held long enough) */
@@ -5175,6 +5308,8 @@ class PointerDragHandler {
5175
5308
  this.#boundPointerMove = this.#onPointerMove.bind(this);
5176
5309
  this.#boundPointerUp = this.#onPointerUp.bind(this);
5177
5310
  this.#boundKeyDown = this.#onEscapeKeyDown.bind(this);
5311
+ this.#boundWindowBlur = this.#onFocusLost.bind(this);
5312
+ this.#boundVisibilityChange = this.#onVisibilityChange.bind(this);
5178
5313
  }
5179
5314
  /**
5180
5315
  * The position where the user initially pressed down.
@@ -5209,8 +5344,13 @@ class PointerDragHandler {
5209
5344
  }
5210
5345
  // Check for elements that should not trigger drag
5211
5346
  const target = event.target;
5212
- if (target.closest('button, input, textarea, select, [contenteditable]') ||
5213
- target.classList.contains('no-drag')) {
5347
+ if (target.closest(INTERACTIVE_ELEMENT_SELECTOR) ||
5348
+ findNoDragElement(target, ctx.element) !== null) {
5349
+ return;
5350
+ }
5351
+ // Track the finger that pressed this draggable; the others are ignored for the gesture.
5352
+ const touch = 'touches' in event ? PointerDragHandler.#pressingTouch(event, ctx.element) : null;
5353
+ if ('touches' in event && !touch) {
5214
5354
  return;
5215
5355
  }
5216
5356
  const delay = ctx.dragDelay;
@@ -5222,15 +5362,39 @@ class PointerDragHandler {
5222
5362
  event.preventDefault();
5223
5363
  }
5224
5364
  event.stopPropagation();
5365
+ // A second finger on this draggable while the first is still down: keep following the first.
5366
+ if ('touches' in event && touch && this.#isTracking && this.#trackedTouch(event.touches)) {
5367
+ return;
5368
+ }
5369
+ // Only one drag at a time: a second finger or a press during a keyboard drag must not
5370
+ // replace the drag in progress. The press keeps the default handling above, so a mouse press
5371
+ // still doesn't focus this item (which would take the keys of a keyboard drag).
5372
+ if (this.#deps.callbacks.isOtherDragActive()) {
5373
+ return;
5374
+ }
5225
5375
  this.#isTracking = true;
5226
- this.#startPosition = _a.#getPosition(event);
5227
- // Handle drag delay
5376
+ if (touch) {
5377
+ this.#touchId = touch.identifier;
5378
+ this.#startPosition = { x: touch.clientX, y: touch.clientY };
5379
+ }
5380
+ else {
5381
+ const mouse = event;
5382
+ this.#startPosition = { x: mouse.clientX, y: mouse.clientY };
5383
+ }
5384
+ // Handle drag delay. A repeated press (e.g. after a release the page never saw) restarts the
5385
+ // delay; the previous timer must not survive, or it fires after the gesture has ended.
5386
+ this.#cancelDelayTimer();
5228
5387
  if (delay > 0) {
5229
- this.#delayReady = false;
5230
5388
  this.#delayTimerId = setTimeout(() => {
5389
+ this.#delayTimerId = null;
5390
+ // Another drag started while this press was held: drop the press rather than show it
5391
+ // as ready to drag during that drag.
5392
+ if (this.#deps.callbacks.isOtherDragActive()) {
5393
+ this.cleanup();
5394
+ return;
5395
+ }
5231
5396
  this.#delayReady = true;
5232
5397
  this.#deps.callbacks.onPendingChange(true); // Emit ready state when delay passes
5233
- this.#delayTimerId = null;
5234
5398
  }, delay);
5235
5399
  }
5236
5400
  else {
@@ -5249,6 +5413,10 @@ class PointerDragHandler {
5249
5413
  }
5250
5414
  // Listen for Escape key on document to cancel drag
5251
5415
  document.addEventListener('keydown', this.#boundKeyDown);
5416
+ // Switching windows or tabs mid-drag means the release never reaches this page: cancel
5417
+ // instead of leaving the drag active until the next press.
5418
+ window.addEventListener('blur', this.#boundWindowBlur);
5419
+ document.addEventListener('visibilitychange', this.#boundVisibilityChange);
5252
5420
  });
5253
5421
  }
5254
5422
  /**
@@ -5258,8 +5426,13 @@ class PointerDragHandler {
5258
5426
  cleanup() {
5259
5427
  this.#isTracking = false;
5260
5428
  this.#startPosition = null;
5429
+ this.#touchId = null;
5261
5430
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
5262
5431
  this.#cancelDelayTimer();
5432
+ // Server rendering destroys the directive without a global document, and never added any
5433
+ if (typeof document === 'undefined') {
5434
+ return;
5435
+ }
5263
5436
  // Remove event listeners
5264
5437
  if (this.#boundPointerMove) {
5265
5438
  document.removeEventListener('mousemove', this.#boundPointerMove);
@@ -5273,9 +5446,15 @@ class PointerDragHandler {
5273
5446
  if (this.#boundKeyDown) {
5274
5447
  document.removeEventListener('keydown', this.#boundKeyDown);
5275
5448
  }
5449
+ if (this.#boundWindowBlur) {
5450
+ window.removeEventListener('blur', this.#boundWindowBlur);
5451
+ }
5452
+ if (this.#boundVisibilityChange) {
5453
+ document.removeEventListener('visibilitychange', this.#boundVisibilityChange);
5454
+ }
5276
5455
  }
5277
5456
  /**
5278
- * Full teardown — cleanup + null references.
5457
+ * Teardown when the directive is destroyed: removes listeners and cancels timers.
5279
5458
  */
5280
5459
  destroy() {
5281
5460
  this.cleanup();
@@ -5287,7 +5466,23 @@ class PointerDragHandler {
5287
5466
  if (!this.#isTracking) {
5288
5467
  return;
5289
5468
  }
5290
- const position = _a.#getPosition(event);
5469
+ let position;
5470
+ if ('touches' in event) {
5471
+ // `touches` holds every finger's current position, so the tracked finger's is there even
5472
+ // when only another finger moved
5473
+ const touch = this.#trackedTouch(event.touches) ?? this.#trackedTouch(event.changedTouches);
5474
+ if (!touch) {
5475
+ // The tracked finger is gone. While dragging, keep other fingers from scrolling the page.
5476
+ if (this.#deps.callbacks.isDragging()) {
5477
+ event.preventDefault();
5478
+ }
5479
+ return;
5480
+ }
5481
+ position = { x: touch.clientX, y: touch.clientY };
5482
+ }
5483
+ else {
5484
+ position = { x: event.clientX, y: event.clientY };
5485
+ }
5291
5486
  const ctx = this.#deps.getContext();
5292
5487
  // Check if we've moved past the threshold
5293
5488
  if (!this.#deps.callbacks.isDragging() && this.#startPosition) {
@@ -5304,6 +5499,11 @@ class PointerDragHandler {
5304
5499
  this.cleanup();
5305
5500
  return;
5306
5501
  }
5502
+ // Another press won the race and started its drag first: give up this one.
5503
+ if (this.#deps.callbacks.isOtherDragActive()) {
5504
+ this.cleanup();
5505
+ return;
5506
+ }
5307
5507
  // Start the drag - now we can prevent default
5308
5508
  event.preventDefault();
5309
5509
  this.#deps.callbacks.onDragStart(position);
@@ -5327,11 +5527,21 @@ class PointerDragHandler {
5327
5527
  if (!this.#isTracking) {
5328
5528
  return;
5329
5529
  }
5530
+ // Another finger lifted (or was cancelled) while the tracked one is still down. Touches
5531
+ // without an identifier (malformed synthetic events) can't be told apart: any release ends.
5532
+ if ('touches' in event &&
5533
+ typeof this.#touchId === 'number' &&
5534
+ this.#trackedTouch(event.changedTouches) === null &&
5535
+ this.#trackedTouch(event.touches) !== null) {
5536
+ return;
5537
+ }
5330
5538
  // Only prevent default if we were actually dragging
5331
5539
  // Otherwise, allow native touch behavior (like scroll momentum) to complete
5332
5540
  if (this.#deps.callbacks.isDragging()) {
5333
5541
  event.preventDefault();
5334
- this.#deps.callbacks.onDragEnd(false);
5542
+ // The system took the touch (an incoming call, an OS gesture, too many fingers): the user
5543
+ // never released, so this is a cancel, not a drop
5544
+ this.#deps.callbacks.onDragEnd(event.type === 'touchcancel');
5335
5545
  }
5336
5546
  this.cleanup();
5337
5547
  }
@@ -5356,17 +5566,43 @@ class PointerDragHandler {
5356
5566
  }
5357
5567
  }
5358
5568
  /**
5359
- * Get position from mouse or touch event.
5569
+ * Cancel the drag (or drop the pending press) when the window loses focus.
5360
5570
  */
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 };
5571
+ #onFocusLost() {
5572
+ if (!this.#isTracking) {
5573
+ return;
5574
+ }
5575
+ if (this.#deps.callbacks.isDragging()) {
5576
+ this.#deps.callbacks.onDragEnd(true);
5577
+ }
5578
+ this.cleanup();
5579
+ }
5580
+ /**
5581
+ * Treat the page being hidden (tab switch, app switch on mobile) as focus loss.
5582
+ */
5583
+ #onVisibilityChange() {
5584
+ if (document.visibilityState === 'hidden') {
5585
+ this.#onFocusLost();
5365
5586
  }
5366
- return { x: event.clientX, y: event.clientY };
5587
+ }
5588
+ /**
5589
+ * The tracked finger in `touches`, or null. Synthetic touch events (e.g. from test tools) can
5590
+ * leave a list undefined, so a missing list counts as empty.
5591
+ */
5592
+ #trackedTouch(touches) {
5593
+ return Array.from(touches ?? []).find((touch) => touch.identifier === this.#touchId) ?? null;
5594
+ }
5595
+ /**
5596
+ * The finger that pressed this draggable: of the touches that started with this event, the
5597
+ * one on the draggable (several fingers can land in one touchstart).
5598
+ */
5599
+ static #pressingTouch(event, element) {
5600
+ // Either list can be undefined on synthetic events
5601
+ const started = Array.from(event.changedTouches ?? []);
5602
+ const onElement = started.find((touch) => touch.target instanceof Node && element.contains(touch.target));
5603
+ return onElement ?? started[0] ?? Array.from(event.touches ?? [])[0] ?? null;
5367
5604
  }
5368
5605
  }
5369
- _a = PointerDragHandler;
5370
5606
 
5371
5607
  /**
5372
5608
  * Makes an element draggable within the virtual scroll drag-and-drop system.
@@ -5454,6 +5690,8 @@ class DraggableDirective {
5454
5690
  }, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
5455
5691
  #keyboardHandler;
5456
5692
  #pointerHandler;
5693
+ /** Set by ngOnInit, which creates the handlers */
5694
+ #initialized = false;
5457
5695
  /** Cached constraint flag from source droppable */
5458
5696
  #constrainToContainer = false;
5459
5697
  /** Element whose rect is used for clamping (scroll container or droppable itself) */
@@ -5516,6 +5754,7 @@ class DraggableDirective {
5516
5754
  onDragEnd: (cancelled) => this.#endDrag(cancelled),
5517
5755
  onPendingChange: (pending) => this.#setPending(pending),
5518
5756
  isDragging: () => this.isDragging(),
5757
+ isOtherDragActive: () => this.#dragState.isDragging() && !this.isDragging(),
5519
5758
  },
5520
5759
  getContext: () => ({
5521
5760
  element: this.#elementRef.nativeElement,
@@ -5526,8 +5765,14 @@ class DraggableDirective {
5526
5765
  dragDelay: this.dragDelay(),
5527
5766
  }),
5528
5767
  });
5768
+ this.#initialized = true;
5529
5769
  }
5530
5770
  ngOnDestroy() {
5771
+ // Destroyed before its first change detection: there are no handlers yet, and its inputs
5772
+ // have no values (reading a bound ID would throw).
5773
+ if (!this.#initialized) {
5774
+ return;
5775
+ }
5531
5776
  // If destroyed mid-drag, cancel to avoid stale global state / ongoing RAF loops.
5532
5777
  if (this.isDragging()) {
5533
5778
  this.#endDrag(true);
@@ -5549,20 +5794,49 @@ class DraggableDirective {
5549
5794
  if (this.disabled()) {
5550
5795
  return;
5551
5796
  }
5797
+ // Space on a control inside the item types into it or clicks it, just as a press on
5798
+ // one never starts a pointer drag.
5799
+ if (this.#isFromNestedControl(event)) {
5800
+ return;
5801
+ }
5552
5802
  event.preventDefault();
5553
- event.stopPropagation(); // Prevent document listener from receiving this event
5554
5803
  // If we're in a keyboard drag, Space drops the item
5555
5804
  if (this.#keyboardHandler.isActive()) {
5805
+ event.stopPropagation(); // Prevent document listener from receiving this event
5556
5806
  this.#keyboardHandler.complete();
5557
5807
  return;
5558
5808
  }
5559
- // If we're in a pointer drag, ignore
5560
- if (this.isDragging()) {
5809
+ // Another drag is in progress: don't replace it. The key goes on to the document, where a
5810
+ // keyboard drag of another item drops with it.
5811
+ if (this.#dragState.isDragging()) {
5561
5812
  return;
5562
5813
  }
5563
- // Start keyboard drag
5814
+ // Start keyboard drag. The document listener it adds must not receive this event.
5815
+ event.stopPropagation();
5564
5816
  this.#keyboardHandler.activate();
5565
5817
  }
5818
+ /**
5819
+ * Whether the event comes from a control (or from inside a `no-drag` element) nested inside this
5820
+ * draggable. Neither the draggable itself nor a control around it counts, so a
5821
+ * `<button vdndDraggable>` still picks up with Space, and neither does a control that is the
5822
+ * drag handle, such as a button handle. Controls inside the handle do count.
5823
+ */
5824
+ #isFromNestedControl(event) {
5825
+ const host = this.#elementRef.nativeElement;
5826
+ const target = event.target;
5827
+ if (!(target instanceof Element) || target === host) {
5828
+ return false;
5829
+ }
5830
+ if (findNoDragElement(target, host) !== null) {
5831
+ return true;
5832
+ }
5833
+ const control = target.closest(INTERACTIVE_ELEMENT_SELECTOR);
5834
+ if (control === null || control === host || !host.contains(control)) {
5835
+ return false;
5836
+ }
5837
+ const handleSelector = this.dragHandle();
5838
+ return !handleSelector || !control.matches(handleSelector);
5839
+ }
5566
5840
  /**
5567
5841
  * Handle Enter key (alternative to Space for dropping during keyboard drag).
5568
5842
  */
@@ -5701,49 +5975,21 @@ class DraggableDirective {
5701
5975
  return 0;
5702
5976
  }
5703
5977
  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
- }
5978
+ // The same measurement that places the placeholder: it knows each container's scroll
5979
+ // element and subtracts space reserved above the rows (contentOffset, page headers).
5980
+ const geometry = this.#dragIndexCalculator.getScrollGeometry(droppableElement, rect.height);
5981
+ const relativeY = rect.top - geometry.rect.top + geometry.scrollTop;
5734
5982
  // Try to use registered strategy for accurate offset-based lookup
5735
5983
  const droppableId = this.#positionCalculator.getDroppableId(droppableElement);
5736
5984
  if (droppableId) {
5737
5985
  const strategy = this.#dragIndexCalculator.getStrategyForDroppable(droppableId);
5738
5986
  if (strategy) {
5739
- const relativeY = rect.top - containerRect.top + scrollTop;
5740
5987
  return strategy.findIndexAtOffset(relativeY);
5741
5988
  }
5742
5989
  }
5743
5990
  // Preserve the geometry fallback for a virtual container whose strategy is unavailable.
5744
- if (virtualScroll || virtualContent) {
5991
+ if (geometry.isVirtual) {
5745
5992
  const itemHeight = rect.height || 50;
5746
- const relativeY = rect.top - containerRect.top + scrollTop;
5747
5993
  return Math.round(relativeY / itemHeight);
5748
5994
  }
5749
5995
  // Non-virtual fallback: derive the logical index from preceding draggable siblings.
@@ -6160,6 +6406,7 @@ class VirtualForDirective {
6160
6406
  #scrollContainer = inject(VDND_SCROLL_CONTAINER);
6161
6407
  #injector = inject(Injector);
6162
6408
  #ngZone = inject(NgZone);
6409
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
6163
6410
  #dragState = inject(DragStateService);
6164
6411
  #dragIndexCalculator = inject(DragIndexCalculatorService);
6165
6412
  /**
@@ -6168,6 +6415,8 @@ class VirtualForDirective {
6168
6415
  * so we skip individual absolute positioning.
6169
6416
  */
6170
6417
  #viewport = inject(VDND_VIRTUAL_VIEWPORT, { optional: true });
6418
+ /** A vdnd-virtual-viewport, whose rows start contentOffset px down (see #rowsScrollTop) */
6419
+ #offsetRowsViewport = inject(VDND_OFFSET_ROWS_VIEWPORT, { optional: true });
6171
6420
  /**
6172
6421
  * Optional parent droppable directive.
6173
6422
  * When present, droppableId is inherited automatically.
@@ -6189,7 +6438,7 @@ class VirtualForDirective {
6189
6438
  #placeholderInDom = false;
6190
6439
  /** ResizeObserver for dynamic height measurement */
6191
6440
  #resizeObserver = null;
6192
- /** Map from observed HTMLElement to its trackBy key */
6441
+ /** Map from observed HTMLElement to its trackBy key (reset with each new observer) */
6193
6442
  #observedElements = new WeakMap();
6194
6443
  // ========== Inputs ==========
6195
6444
  /** The array of items to iterate over */
@@ -6266,8 +6515,21 @@ class VirtualForDirective {
6266
6515
  const strategy = this.#strategy();
6267
6516
  // Read version to subscribe to dynamic height changes
6268
6517
  strategy.version();
6269
- return strategy.getFirstVisibleIndex(this.#scrollContainer.scrollTop());
6518
+ return strategy.getFirstVisibleIndex(this.#rowsScrollTop());
6270
6519
  }, ...(ngDevMode ? [{ debugName: "#firstVisibleIndex" }] : []));
6520
+ /**
6521
+ * How far the rows are scrolled. A vdnd-virtual-viewport's rows start contentOffset px down its
6522
+ * scroll area, but its scrollTop() is the raw position (scrollBy() relies on it), so subtract the
6523
+ * offset here. vdnd-virtual-content's scrollTop() already excludes its offset.
6524
+ */
6525
+ #rowsScrollTop() {
6526
+ const scrollTop = this.#scrollContainer.scrollTop();
6527
+ const viewport = this.#viewport;
6528
+ if (viewport === null || viewport !== this.#offsetRowsViewport) {
6529
+ return scrollTop;
6530
+ }
6531
+ return Math.max(0, scrollTop - viewport.contentOffset());
6532
+ }
6271
6533
  /** Number of visible items */
6272
6534
  #visibleCount = computed(() => {
6273
6535
  const height = this.#scrollContainer.containerHeight();
@@ -6345,12 +6607,33 @@ class VirtualForDirective {
6345
6607
  strategy.setExcludedIndex(null);
6346
6608
  }
6347
6609
  });
6610
+ // Measure item heights in dynamic height mode: the directive's own input, or the viewport's
6611
+ // strategy when inherited. Both can change at runtime, and the strategy is replaced when they
6612
+ // do, so this creates, replaces or removes the observer. A new observer reports every
6613
+ // rendered item, so a new strategy gets their heights.
6614
+ effect((onCleanup) => {
6615
+ // Read the strategy first so the effect tracks its replacement even when the input is on
6616
+ const strategy = this.#strategy();
6617
+ const measure = this.vdndVirtualForDynamicItemHeight() || strategy instanceof DynamicHeightStrategy;
6618
+ // No ResizeObserver during server rendering
6619
+ if (!measure || !this.#isBrowser) {
6620
+ return;
6621
+ }
6622
+ untracked(() => this.#startMeasuring());
6623
+ onCleanup(() => this.#stopMeasuring());
6624
+ });
6348
6625
  // React to changes and update views
6349
6626
  effect(() => {
6350
6627
  this.#updateViews();
6351
6628
  });
6352
6629
  }
6353
6630
  ngOnInit() {
6631
+ // Server rendering: there is no ResizeObserver, and elements added outside the template
6632
+ // (spacer, placeholder) would not match the client's DOM during hydration. The browser adds
6633
+ // the spacer on init, so the server markup lacks only the list's scroll height until then.
6634
+ if (!this.#isBrowser) {
6635
+ return;
6636
+ }
6354
6637
  // Only create spacer when NOT inside a viewport component
6355
6638
  // (viewport provides its own spacer and wrapper positioning)
6356
6639
  if (!this.#useViewportPositioning) {
@@ -6358,12 +6641,6 @@ class VirtualForDirective {
6358
6641
  }
6359
6642
  // Create placeholder element for drag operations
6360
6643
  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
6644
  }
6368
6645
  ngOnDestroy() {
6369
6646
  this.#viewPool.forEach((view) => view.destroy());
@@ -6432,6 +6709,19 @@ class VirtualForDirective {
6432
6709
  spacer.style.height = `${strategy.getTotalHeight(total)}px`;
6433
6710
  }, { injector: this.#injector });
6434
6711
  }
6712
+ /** Create the ResizeObserver and observe the items already rendered. */
6713
+ #startMeasuring() {
6714
+ this.#setupResizeObserver();
6715
+ for (const [key, view] of this.#activeViews) {
6716
+ this.#observeViewElements(view, key);
6717
+ }
6718
+ }
6719
+ /** Disconnect the ResizeObserver and forget what it observed. */
6720
+ #stopMeasuring() {
6721
+ this.#resizeObserver?.disconnect();
6722
+ this.#resizeObserver = null;
6723
+ this.#observedElements = new WeakMap();
6724
+ }
6435
6725
  /**
6436
6726
  * Set up ResizeObserver for dynamic height measurement.
6437
6727
  */
@@ -6867,9 +7157,9 @@ function moveItem(event, lists) {
6867
7157
  reorderItems(event, sourceList);
6868
7158
  return;
6869
7159
  }
6870
- // Cross-list move
6871
- const sourceItems = sourceList();
6872
- const item = sourceItems[sourceIndex];
7160
+ // Cross-list move. Read untracked, as in reorderItems: an effect that applies the drop must
7161
+ // not depend on the lists it changes.
7162
+ const item = untracked(sourceList)[sourceIndex];
6873
7163
  if (item === undefined) {
6874
7164
  if (isDevMode()) {
6875
7165
  console.warn(`[ngx-virtual-dnd] [moveItem] Could not find item at index ${sourceIndex}`);
@@ -6911,6 +7201,14 @@ function reorderItems(event, list) {
6911
7201
  if (sourceIndex === destIndex) {
6912
7202
  return;
6913
7203
  }
7204
+ // The list may have changed during the drag; never splice in a missing item as undefined.
7205
+ // Read untracked: from an effect, a tracked read would make the effect re-run on this reorder.
7206
+ if (untracked(list)[sourceIndex] === undefined) {
7207
+ if (isDevMode()) {
7208
+ console.warn(`[ngx-virtual-dnd] [reorderItems] Could not find item at index ${sourceIndex}`);
7209
+ }
7210
+ return;
7211
+ }
6914
7212
  list.update((items) => {
6915
7213
  const newItems = [...items];
6916
7214
  const [removed] = newItems.splice(sourceIndex, 1);