ngx-virtual-dnd 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, isDevMode, input, ChangeDetectionStrategy, Component, ElementRef, Injector, afterNextRender, viewChild, Directive, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
2
+ import { signal, InjectionToken, computed, effect, Injectable, inject, NgZone, isDevMode, input, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Injector, afterNextRender, viewChild, Directive, output, contentChild, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
3
  import { NgTemplateOutlet } from '@angular/common';
4
4
 
5
5
  /**
@@ -709,6 +709,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
709
709
  }]
710
710
  }], ctorParameters: () => [] });
711
711
 
712
+ /**
713
+ * Query by an exact attribute value without interpolating consumer data into a CSS selector.
714
+ *
715
+ * CSS attribute selectors need string-specific escaping that differs from `CSS.escape()`.
716
+ * Filtering candidates by attribute presence avoids selector syntax errors for IDs containing
717
+ * quotes, brackets, backslashes, or other selector-sensitive characters.
718
+ */
719
+ function queryByAttribute(root, attributeName, attributeValue) {
720
+ const candidates = root.querySelectorAll(`[${attributeName}]`);
721
+ for (const candidate of candidates) {
722
+ if (candidate.getAttribute(attributeName) === attributeValue) {
723
+ return candidate;
724
+ }
725
+ }
726
+ return null;
727
+ }
728
+ function queryAllByAttribute(root, attributeName, attributeValue) {
729
+ return Array.from(root.querySelectorAll(`[${attributeName}]`)).filter((candidate) => candidate.getAttribute(attributeName) === attributeValue);
730
+ }
731
+
712
732
  /**
713
733
  * Service for calculating drop positions and finding elements at cursor positions.
714
734
  * This is the core algorithm that makes virtual scroll + drag-and-drop work together.
@@ -837,8 +857,7 @@ class PositionCalculatorService {
837
857
  if (typeof document === 'undefined') {
838
858
  return null;
839
859
  }
840
- const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id;
841
- return document.querySelector(`[${this.#DROPPABLE_ID_ATTR}="${escaped}"]`);
860
+ return queryByAttribute(document, this.#DROPPABLE_ID_ATTR, id);
842
861
  }
843
862
  /**
844
863
  * Query all droppable elements belonging to a group, in document order.
@@ -847,8 +866,7 @@ class PositionCalculatorService {
847
866
  if (typeof document === 'undefined') {
848
867
  return [];
849
868
  }
850
- const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(groupName) : groupName;
851
- return Array.from(document.querySelectorAll(`[${this.#DROPPABLE_GROUP_ATTR}="${escaped}"]`));
869
+ return queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
852
870
  }
853
871
  /**
854
872
  * Geometric hit-test: return the last candidate (in document order) whose rect
@@ -1001,7 +1019,7 @@ class PositionCalculatorService {
1001
1019
  */
1002
1020
  findAdjacentDroppable(currentDroppableId, direction, groupName) {
1003
1021
  // Find all droppables in the same group
1004
- const allDroppables = document.querySelectorAll(`[${this.#DROPPABLE_GROUP_ATTR}="${groupName}"]`);
1022
+ const allDroppables = queryAllByAttribute(document, this.#DROPPABLE_GROUP_ATTR, groupName);
1005
1023
  if (allDroppables.length <= 1) {
1006
1024
  return null;
1007
1025
  }
@@ -2145,6 +2163,34 @@ function bindResizeObserverHeightSignal(options) {
2145
2163
  };
2146
2164
  }
2147
2165
 
2166
+ function createAutoScrollRegistration(options) {
2167
+ const destroyRef = inject(DestroyRef);
2168
+ let registeredId = null;
2169
+ const unregisterCurrent = () => {
2170
+ if (registeredId === null) {
2171
+ return;
2172
+ }
2173
+ options.autoScrollService.unregisterContainer(registeredId);
2174
+ registeredId = null;
2175
+ };
2176
+ effect(() => {
2177
+ const enabled = options.enabled();
2178
+ const id = options.getId();
2179
+ const config = options.config();
2180
+ const canRegister = options.canRegister?.() ?? true;
2181
+ if (!enabled || !canRegister) {
2182
+ unregisterCurrent();
2183
+ return;
2184
+ }
2185
+ if (registeredId !== null && registeredId !== id) {
2186
+ unregisterCurrent();
2187
+ }
2188
+ options.autoScrollService.registerContainer(id, options.getElement(), config);
2189
+ registeredId = id;
2190
+ });
2191
+ destroyRef.onDestroy(unregisterCurrent);
2192
+ }
2193
+
2148
2194
  /**
2149
2195
  * A virtual scroll container that only renders visible items.
2150
2196
  *
@@ -2463,6 +2509,13 @@ class VirtualScrollContainerComponent {
2463
2509
  /** Track previous total height to detect if we were at bottom before drag ended */
2464
2510
  #previousTotalHeight = 0;
2465
2511
  constructor() {
2512
+ createAutoScrollRegistration({
2513
+ autoScrollService: this.#autoScrollService,
2514
+ getElement: () => this.#elementRef.nativeElement,
2515
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
2516
+ enabled: () => this.autoScrollEnabled(),
2517
+ config: () => this.autoScrollConfig(),
2518
+ });
2466
2519
  // Keep strategy item keys in sync
2467
2520
  effect(() => {
2468
2521
  const items = this.items();
@@ -2576,11 +2629,6 @@ class VirtualScrollContainerComponent {
2576
2629
  });
2577
2630
  }
2578
2631
  ngOnInit() {
2579
- // Register with auto-scroll service
2580
- if (this.autoScrollEnabled()) {
2581
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
2582
- this.#autoScrollService.registerContainer(id, this.#elementRef.nativeElement, this.autoScrollConfig());
2583
- }
2584
2632
  // Set up ResizeObserver for dynamic height measurement
2585
2633
  if (this.dynamicItemHeight()) {
2586
2634
  this.#setupItemResizeObserver();
@@ -2615,9 +2663,6 @@ class VirtualScrollContainerComponent {
2615
2663
  }
2616
2664
  this.#observedElementByKey.clear();
2617
2665
  this.#itemResizeObserver?.disconnect();
2618
- // Unregister from auto-scroll service
2619
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
2620
- this.#autoScrollService.unregisterContainer(id);
2621
2666
  }
2622
2667
  /**
2623
2668
  * Set up ResizeObserver for measuring individual item heights.
@@ -2662,8 +2707,8 @@ class VirtualScrollContainerComponent {
2662
2707
  if (entry.type !== 'item' || !entry.data)
2663
2708
  continue;
2664
2709
  const key = idFn(entry.data);
2665
- // Find the DOM element for this item by its data-draggable-id
2666
- const el = wrapper.querySelector(`[data-draggable-id="${key}"]`);
2710
+ // Find the DOM element for this item by its data-draggable-id.
2711
+ const el = queryByAttribute(wrapper, 'data-draggable-id', key);
2667
2712
  if (el) {
2668
2713
  nextObservedByKey.set(key, el);
2669
2714
  }
@@ -3184,17 +3229,6 @@ class DroppableDirective {
3184
3229
  #wasActive = false;
3185
3230
  /** Cached state for handling drop (since state is cleared before effect fires) */
3186
3231
  #cachedDragState = null;
3187
- ngOnInit() {
3188
- // Without a group, this droppable can't participate in DnD (fail gracefully).
3189
- if (!this.effectiveGroup()) {
3190
- return;
3191
- }
3192
- // Register with auto-scroll service only if this element is actually scrollable
3193
- // (i.e., has overflow: auto/scroll and content taller than container)
3194
- if (this.autoScrollEnabled() && this.#isScrollable()) {
3195
- this.#autoScroll.registerContainer(this.vdndDroppable(), this.#elementRef.nativeElement, this.autoScrollConfig());
3196
- }
3197
- }
3198
3232
  /**
3199
3233
  * Check if this element is actually scrollable.
3200
3234
  */
@@ -3215,6 +3249,14 @@ class DroppableDirective {
3215
3249
  return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
3216
3250
  }
3217
3251
  constructor() {
3252
+ createAutoScrollRegistration({
3253
+ autoScrollService: this.#autoScroll,
3254
+ getElement: () => this.#elementRef.nativeElement,
3255
+ getId: () => this.vdndDroppable(),
3256
+ enabled: () => this.autoScrollEnabled(),
3257
+ config: () => this.autoScrollConfig(),
3258
+ canRegister: () => Boolean(this.effectiveGroup()) && this.#isScrollable(),
3259
+ });
3218
3260
  // React to state changes and handle drop events
3219
3261
  effect(() => {
3220
3262
  const active = this.isActive();
@@ -3295,7 +3337,7 @@ class DroppableDirective {
3295
3337
  */
3296
3338
  #getItemIndex(draggableId, droppableId) {
3297
3339
  // Find all draggables in the source droppable
3298
- const droppable = document.querySelector(`[data-droppable-id="${droppableId}"]`);
3340
+ const droppable = queryByAttribute(document, 'data-droppable-id', droppableId);
3299
3341
  if (!droppable) {
3300
3342
  return 0;
3301
3343
  }
@@ -3605,6 +3647,7 @@ class VirtualViewportComponent {
3605
3647
  contentTransform = computed(() => {
3606
3648
  const startIndex = this.#renderStartIndex();
3607
3649
  const s = this.#strategy();
3650
+ s.version();
3608
3651
  const offset = s.getOffsetForIndex(startIndex);
3609
3652
  return `translateY(${offset}px)`;
3610
3653
  }, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
@@ -3637,15 +3680,22 @@ class VirtualViewportComponent {
3637
3680
  this.scrollTo({ top: newPosition });
3638
3681
  }
3639
3682
  // ========== Lifecycle ==========
3683
+ constructor() {
3684
+ createAutoScrollRegistration({
3685
+ autoScrollService: this.#autoScrollService,
3686
+ getElement: () => this.nativeElement,
3687
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
3688
+ enabled: () => this.autoScrollEnabled(),
3689
+ config: () => this.autoScrollConfig(),
3690
+ });
3691
+ }
3640
3692
  ngOnInit() {
3641
3693
  this.#setupScrollListener();
3642
3694
  this.#setupResizeObserver();
3643
- this.#registerAutoScroll();
3644
3695
  }
3645
3696
  ngOnDestroy() {
3646
3697
  this.#scrollCleanup?.();
3647
3698
  this.#resizeCleanup?.();
3648
- this.#unregisterAutoScroll();
3649
3699
  }
3650
3700
  // ========== Private Methods ==========
3651
3701
  /** Minimum scroll delta (px) to trigger signal update */
@@ -3666,16 +3716,6 @@ class VirtualViewportComponent {
3666
3716
  minDeltaPx: 1,
3667
3717
  });
3668
3718
  }
3669
- #registerAutoScroll() {
3670
- if (this.autoScrollEnabled()) {
3671
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
3672
- this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
3673
- }
3674
- }
3675
- #unregisterAutoScroll() {
3676
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
3677
- this.#autoScrollService.unregisterContainer(id);
3678
- }
3679
3719
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3680
3720
  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: [
3681
3721
  { provide: VDND_VIRTUAL_VIEWPORT, useExisting: VirtualViewportComponent },
@@ -3722,7 +3762,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3722
3762
  <ng-content></ng-content>
3723
3763
  </div>
3724
3764
  `, styles: [":host{display:block;overflow:auto;position:relative;overflow-anchor:none}.vdnd-viewport-spacer{position:absolute;left:0;width:1px;visibility:hidden;pointer-events:none}.vdnd-viewport-content{position:absolute;left:0;right:0;will-change:transform}\n"] }]
3725
- }], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], contentOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentOffset", required: false }] }], 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 }] }] } });
3765
+ }], ctorParameters: () => [], propDecorators: { itemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemHeight", required: true }] }], dynamicItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicItemHeight", required: false }] }], contentOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "contentOffset", required: false }] }], 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 }] }] } });
3726
3766
 
3727
3767
  /**
3728
3768
  * Marks an element as the header content for a `vdnd-virtual-content` component.
@@ -3875,6 +3915,7 @@ class VirtualContentComponent {
3875
3915
  contentTransform = computed(() => {
3876
3916
  const startIndex = this.#renderStartIndex();
3877
3917
  const s = this.#strategy();
3918
+ s.version();
3878
3919
  const offset = s.getOffsetForIndex(startIndex);
3879
3920
  return `translateY(${offset}px)`;
3880
3921
  }, ...(ngDevMode ? [{ debugName: "contentTransform" }] : []));
@@ -4174,13 +4215,14 @@ class KeyboardDragHandler {
4174
4215
  // (the directive may be destroyed during cross-list moves)
4175
4216
  const destinationDroppableId = this.#deps.dragState.activeDroppableId();
4176
4217
  afterNextRender(() => {
4177
- const element = document.querySelector(`[data-draggable-id="${draggableId}"]`);
4218
+ const element = queryByAttribute(document, 'data-draggable-id', draggableId);
4178
4219
  if (element) {
4179
4220
  element.focus();
4180
4221
  }
4181
4222
  else if (destinationDroppableId) {
4182
4223
  // Fallback: focus the first draggable in the destination container
4183
- const firstDraggable = document.querySelector(`[data-droppable-id="${destinationDroppableId}"] [data-draggable-id]`);
4224
+ const destination = queryByAttribute(document, 'data-droppable-id', destinationDroppableId);
4225
+ const firstDraggable = destination?.querySelector('[data-draggable-id]');
4184
4226
  firstDraggable?.focus();
4185
4227
  }
4186
4228
  }, { injector: this.#deps.envInjector });
@@ -5101,6 +5143,15 @@ class ScrollableDirective {
5101
5143
  /** Auto-scroll configuration */
5102
5144
  autoScrollConfig = input({}, ...(ngDevMode ? [{ debugName: "autoScrollConfig" }] : []));
5103
5145
  // ========== VdndScrollContainer Implementation ==========
5146
+ constructor() {
5147
+ createAutoScrollRegistration({
5148
+ autoScrollService: this.#autoScrollService,
5149
+ getElement: () => this.nativeElement,
5150
+ getId: () => this.scrollContainerId() ?? this.#generatedScrollId,
5151
+ enabled: () => this.autoScrollEnabled(),
5152
+ config: () => this.autoScrollConfig(),
5153
+ });
5154
+ }
5104
5155
  get nativeElement() {
5105
5156
  return this.#elementRef.nativeElement;
5106
5157
  }
@@ -5124,12 +5175,10 @@ class ScrollableDirective {
5124
5175
  ngOnInit() {
5125
5176
  this.#setupScrollListener();
5126
5177
  this.#setupResizeObserver();
5127
- this.#registerAutoScroll();
5128
5178
  }
5129
5179
  ngOnDestroy() {
5130
5180
  this.#scrollCleanup?.();
5131
5181
  this.#resizeCleanup?.();
5132
- this.#unregisterAutoScroll();
5133
5182
  }
5134
5183
  // ========== Private Methods ==========
5135
5184
  /** Minimum scroll delta (px) to trigger signal update */
@@ -5150,16 +5199,6 @@ class ScrollableDirective {
5150
5199
  minDeltaPx: 1,
5151
5200
  });
5152
5201
  }
5153
- #registerAutoScroll() {
5154
- if (this.autoScrollEnabled()) {
5155
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
5156
- this.#autoScrollService.registerContainer(id, this.nativeElement, this.autoScrollConfig());
5157
- }
5158
- }
5159
- #unregisterAutoScroll() {
5160
- const id = this.scrollContainerId() ?? this.#generatedScrollId;
5161
- this.#autoScrollService.unregisterContainer(id);
5162
- }
5163
5202
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ScrollableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
5164
5203
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: ScrollableDirective, isStandalone: true, selector: "[vdndScrollable]", inputs: { 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: { properties: { "style.overflow-anchor": "\"none\"" }, classAttribute: "vdnd-scrollable" }, providers: [{ provide: VDND_SCROLL_CONTAINER, useExisting: ScrollableDirective }], ngImport: i0 });
5165
5204
  }
@@ -5173,7 +5212,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
5173
5212
  '[style.overflow-anchor]': '"none"',
5174
5213
  },
5175
5214
  }]
5176
- }], propDecorators: { 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 }] }] } });
5215
+ }], ctorParameters: () => [], propDecorators: { 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 }] }] } });
5177
5216
 
5178
5217
  /**
5179
5218
  * A structural directive for virtual scrolling within custom scroll containers.