ngx-virtual-dnd 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,8 +85,10 @@ Every draggable and droppable needs a group (here from `vdndGroup`), even for a
85
85
  ## Documentation
86
86
 
87
87
  - [Getting started](https://gultyayev.github.io/ngx-virtual-dnd/guide/start/introduction.html): introduction, installation, quick start
88
- - [Guides](https://gultyayev.github.io/ngx-virtual-dnd/guide/essentials/core-concepts.html): multiple lists, dynamic heights, page scroll, drag behavior, styling, accessibility
89
- - [API reference](https://gultyayev.github.io/ngx-virtual-dnd/api/components.html): components, directives, events, utilities, services, types
88
+ - [Guides](https://gultyayev.github.io/ngx-virtual-dnd/guide/essentials/core-concepts.html): multiple lists, dynamic heights, page scroll, drag behavior, styling, accessibility, performance
89
+ - [Coming from Angular CDK](https://gultyayev.github.io/ngx-virtual-dnd/guide/start/coming-from-cdk.html): how the CDK drag-and-drop API maps to this one
90
+ - [FAQ](https://gultyayev.github.io/ngx-virtual-dnd/guide/more/faq.html): SSR, zoneless apps, testing and more
91
+ - [API reference](https://gultyayev.github.io/ngx-virtual-dnd/api/overview.html): components, directives, events, utilities, services, types
90
92
 
91
93
  ## AI agent skills
92
94
 
@@ -98,18 +100,9 @@ npx skills add gultyayev/ngx-virtual-dnd
98
100
 
99
101
  It works with Claude Code, Cursor, Windsurf, GitHub Copilot and [40+ other agents](https://skills.sh). The docs are also published as [llms.txt](https://gultyayev.github.io/ngx-virtual-dnd/llms.txt).
100
102
 
101
- ## Development
103
+ ## Contributing
102
104
 
103
- Requires npm 12 (the version pinned in `packageManager`): `npm install -g npm@12`.
104
-
105
- ```bash
106
- npm start # Demo app (localhost:4200)
107
- npm run build:lib # Build the library (required after library edits)
108
- npm run docs:dev # Docs site (localhost:3000); live examples load from the demo on :4200
109
- npm test # Unit tests
110
- npm run e2e # E2E tests
111
- npm run site:build # Full GitHub Pages build: docs at /, demo at /demo/
112
- ```
105
+ See [CONTRIBUTING.md](https://github.com/gultyayev/ngx-virtual-dnd/blob/master/CONTRIBUTING.md) to run the demo, the docs and the tests locally.
113
106
 
114
107
  ## License
115
108
 
@@ -788,6 +788,8 @@ class DragStateService {
788
788
  #placeholderId = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderId" }] : []));
789
789
  /** Index where the placeholder should be inserted — updated on each placeholder move */
790
790
  #placeholderIndex = signal(null, ...(ngDevMode ? [{ debugName: "#placeholderIndex" }] : []));
791
+ /** Whether the drag in progress is a touch drag (no cursor shows, so no grabbing cursor) */
792
+ #isTouchDrag = signal(false, ...(ngDevMode ? [{ debugName: "#isTouchDrag" }] : []));
791
793
  /** Snapshot captured synchronously immediately before the last drag state reset. */
792
794
  #endedDragState = signal(null, ...(ngDevMode ? [{ debugName: "#endedDragState" }] : []));
793
795
  /** Flag indicating if the last drag was cancelled (not dropped) */
@@ -829,37 +831,44 @@ class DragStateService {
829
831
  /** Target index during keyboard navigation */
830
832
  keyboardTargetIndex = this.#keyboardTargetIndex.asReadonly();
831
833
  constructor() {
832
- // Inject cursor styles once (for consistent grabbing cursor during drag)
834
+ // Inject the grabbing cursor styles once. They target lists and items only: a rule matching
835
+ // every element (`body.vdnd-dragging *`, or a `*` anywhere right of the body class) restyles
836
+ // the whole document each time the body class changes, at drag start and at drop. Touch drags
837
+ // show no cursor, so they skip it.
833
838
  if (typeof document !== 'undefined') {
834
839
  const styleId = 'vdnd-cursor-styles';
835
840
  if (!document.getElementById(styleId)) {
836
841
  const style = document.createElement('style');
837
842
  style.id = styleId;
838
843
  style.textContent = `
839
- body.vdnd-dragging,
840
- body.vdnd-dragging * {
844
+ body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-droppable,
845
+ body.vdnd-dragging:not(.vdnd-dragging-touch) .vdnd-draggable {
841
846
  cursor: grabbing !important;
842
847
  }
843
848
  `;
844
849
  document.head.appendChild(style);
845
850
  }
846
851
  }
847
- // Effect to toggle body class during drag
852
+ // Effect to toggle the body classes during drag
848
853
  effect(() => {
849
854
  if (typeof document === 'undefined')
850
855
  return;
851
856
  const isDragging = this.isDragging();
852
- document.body.classList.toggle('vdnd-dragging', isDragging);
857
+ const classList = document.body.classList;
858
+ classList.toggle('vdnd-dragging', isDragging);
859
+ classList.toggle('vdnd-dragging-touch', isDragging && this.#isTouchDrag());
853
860
  });
854
861
  }
855
862
  /**
856
863
  * Start a drag operation.
857
864
  * `placeholderId` is deprecated with the `placeholderId` signal; the library passes `END_OF_LIST`.
865
+ * `isTouchDrag` marks a drag started by touch, which gets no grabbing cursor.
858
866
  */
859
- startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition) {
867
+ startDrag(item, cursorPosition, grabOffset, lockAxis, activeDroppableId, placeholderId, placeholderIndex, sourceIndex, isKeyboardDrag, axisLockPosition, isTouchDrag) {
860
868
  // Reset terminal drag metadata at start of new drag
861
869
  this.#endedDragState.set(null);
862
870
  this.#wasCancelled.set(false);
871
+ this.#isTouchDrag.set(isTouchDrag ?? false);
863
872
  this.#cursorPosition.set(cursorPosition ?? null);
864
873
  this.#keyboardTargetIndex.set(isKeyboardDrag ? (sourceIndex ?? 0) : null);
865
874
  this.#activeDroppableId.set(activeDroppableId ?? null);
@@ -941,7 +950,7 @@ class DragStateService {
941
950
  const endedState = this.getStateSnapshot();
942
951
  this.#endedDragState.set(endedState);
943
952
  this.#wasCancelled.set(false);
944
- this.#resetHighFrequencySignals();
953
+ this.#resetSignalsOutsideCoreState();
945
954
  this.#state.set(INITIAL_CORE_STATE);
946
955
  // After the reset, so the drop handler sees the drag as over. Delivered here rather than
947
956
  // from a droppable effect on the next change detection: by then the target may already
@@ -959,18 +968,19 @@ class DragStateService {
959
968
  }
960
969
  this.#endedDragState.set(this.getStateSnapshot());
961
970
  this.#wasCancelled.set(true);
962
- this.#resetHighFrequencySignals();
971
+ this.#resetSignalsOutsideCoreState();
963
972
  this.#state.set(INITIAL_CORE_STATE);
964
973
  }
965
974
  /**
966
- * Reset the dedicated high-frequency signals to their initial null state.
975
+ * Reset the signals kept outside the core state (high-frequency fields, touch flag) to idle.
967
976
  */
968
- #resetHighFrequencySignals() {
977
+ #resetSignalsOutsideCoreState() {
969
978
  this.#cursorPosition.set(null);
970
979
  this.#keyboardTargetIndex.set(null);
971
980
  this.#activeDroppableId.set(null);
972
981
  this.#placeholderId.set(null);
973
982
  this.#placeholderIndex.set(null);
983
+ this.#isTouchDrag.set(false);
974
984
  }
975
985
  /**
976
986
  * Check if a specific droppable is currently active.
@@ -3018,8 +3028,15 @@ const MIN_SHIFT_PX = 0.5;
3018
3028
  *
3019
3029
  * Call `beforeUpdate()` before the DOM reflects a new drag/placeholder state. When the
3020
3030
  * placeholder moved (or the drag ended), it snapshots every rendered element's visual position, then after
3021
- * the render measures again and plays a compositor-only `transform` animation from the
3022
- * old position to the new one.
3031
+ * the render measures again and plays a `translate` animation from the old position to the
3032
+ * new one.
3033
+ *
3034
+ * The slide animates the individual `translate` property with a replace effect, so it runs
3035
+ * on the compositor (Chromium never composites a `composite: 'add'` keyframe effect) and
3036
+ * composes with any `transform` the consumer sets on the row. `translate` applies before
3037
+ * `transform`, in the parent's coordinate space, where the deltas are measured. A row
3038
+ * that sets its own `translate` gets an additive effect instead, which keeps that value
3039
+ * but is ticked on the main thread.
3023
3040
  *
3024
3041
  * Positions are measured relative to the scroll content, so scrolling (including
3025
3042
  * programmatic scroll in the same tick) never animates. Measurements include any
@@ -3156,36 +3173,51 @@ class ShiftAnimator {
3156
3173
  }
3157
3174
  // Both measurements include the in-flight offset, so the new animation must
3158
3175
  // start from that offset plus the layout delta to continue from the current spot.
3159
- const current = this.#currentOffset(element);
3160
- shifts.push({ element, x: dx + current.x, y: dy + current.y });
3176
+ const active = this.#activeShift(element);
3177
+ const remaining = active ? 1 - active.progress : 0;
3178
+ shifts.push({
3179
+ element,
3180
+ x: dx + (active?.shift.x ?? 0) * remaining,
3181
+ y: dy + (active?.shift.y ?? 0) * remaining,
3182
+ // While a slide applies, computed style includes it: keep the decision it was made with.
3183
+ additive: active?.shift.additive ?? this.#hasOwnTranslate(element),
3184
+ });
3161
3185
  }
3162
3186
  // Write phase
3163
3187
  for (const element of stale) {
3164
3188
  this.cancel(element);
3165
3189
  }
3166
3190
  for (const shift of shifts) {
3167
- this.#start(shift.element, shift.x, shift.y);
3191
+ this.#start(shift);
3168
3192
  }
3169
3193
  }
3170
- #currentOffset(element) {
3171
- const running = this.#running.get(element);
3194
+ /**
3195
+ * Whether the row sets its own `translate` (which a replace effect would hide while it
3196
+ * runs). Only read while no slide runs on the element: the computed value would include it.
3197
+ */
3198
+ #hasOwnTranslate(element) {
3199
+ const translate = getComputedStyle(element).translate;
3200
+ return !!translate && translate !== 'none';
3201
+ }
3202
+ /** The element's running slide and its progress, or null when none is in effect. */
3203
+ #activeShift(element) {
3204
+ const shift = this.#running.get(element);
3172
3205
  // Effect-level easing makes `progress` the eased progress, i.e. the fraction travelled.
3173
- const progress = running?.animation.effect?.getComputedTiming().progress;
3174
- if (!running || progress === null || progress === undefined) {
3175
- return { x: 0, y: 0 };
3206
+ // It is null once the animation was cancelled or finished (even before `onfinish` ran).
3207
+ const progress = shift?.animation.effect?.getComputedTiming().progress;
3208
+ if (!shift || progress === null || progress === undefined) {
3209
+ return null;
3176
3210
  }
3177
- const remaining = 1 - progress;
3178
- return { x: running.x * remaining, y: running.y * remaining };
3211
+ return { shift, progress };
3179
3212
  }
3180
- #start(element, x, y) {
3213
+ #start({ element, x, y, additive }) {
3181
3214
  this.#running.get(element)?.animation.cancel();
3182
- const animation = element.animate([{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0px, 0px)' }], {
3215
+ const animation = element.animate([{ translate: `${x}px ${y}px` }, { translate: '0px 0px' }], {
3183
3216
  duration: this.#duration(),
3184
3217
  easing: this.#config.shiftEasing ?? DEFAULT_SHIFT_EASING,
3185
- // Layer on top of any transform the consumer already applies to the item.
3186
- composite: 'add',
3218
+ composite: additive ? 'add' : 'replace',
3187
3219
  });
3188
- this.#running.set(element, { animation, x, y });
3220
+ this.#running.set(element, { animation, x, y, additive });
3189
3221
  animation.onfinish = () => {
3190
3222
  if (this.#running.get(element)?.animation === animation) {
3191
3223
  this.#running.delete(element);
@@ -6259,6 +6291,8 @@ class PointerDragHandler {
6259
6291
  #listening = false;
6260
6292
  /** Identifier of the finger that pressed, for touch gestures (other fingers are ignored) */
6261
6293
  #touchId = null;
6294
+ /** Whether the tracked press is a touch (which shows no cursor) */
6295
+ #isTouch = false;
6262
6296
  /** Bound event handlers for cleanup */
6263
6297
  #boundPointerMove = null;
6264
6298
  #boundPointerUp = null;
@@ -6284,6 +6318,13 @@ class PointerDragHandler {
6284
6318
  getStartPosition() {
6285
6319
  return this.#startPosition;
6286
6320
  }
6321
+ /**
6322
+ * Whether the tracked press is a touch. Read by the directive when the drag starts: a touch
6323
+ * drag shows no cursor, so it gets no grabbing cursor.
6324
+ */
6325
+ isTouchPress() {
6326
+ return this.#isTouch;
6327
+ }
6287
6328
  /**
6288
6329
  * Handle pointer down (mouse or touch).
6289
6330
  * Called from the directive's host binding.
@@ -6340,6 +6381,7 @@ class PointerDragHandler {
6340
6381
  }
6341
6382
  this.#isTracking = true;
6342
6383
  this.#pressedId = ctx.draggableId;
6384
+ this.#isTouch = isTouch;
6343
6385
  if (touch) {
6344
6386
  this.#touchId = touch.identifier;
6345
6387
  this.#startPosition = { x: touch.clientX, y: touch.clientY };
@@ -6397,6 +6439,7 @@ class PointerDragHandler {
6397
6439
  this.#pressedId = null;
6398
6440
  this.#startPosition = null;
6399
6441
  this.#touchId = null;
6442
+ this.#isTouch = false;
6400
6443
  this.#deps.callbacks.onPendingChange(false); // Clear pending state on cleanup
6401
6444
  this.#cancelDelayTimer();
6402
6445
  // Nothing to remove unless a press added listeners (most draggables are never pressed; a
@@ -7070,7 +7113,7 @@ class DraggableDirective {
7070
7113
  height: rect.height,
7071
7114
  width: rect.width,
7072
7115
  data: this.vdndDraggableData(),
7073
- }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined);
7116
+ }, position, grabOffset, lockAxis, activeDroppableId, initialPlaceholderId, initialPlaceholderIndex, sourceIndex, undefined, lockAxis ? startPos : undefined, this.#pointerHandler.isTouchPress());
7074
7117
  // Start the scheduler RAF loop (drives pointer-move updates + autoscroll participant).
7075
7118
  // onTick is called each frame: when cursor is dirty, run full #updateDrag.
7076
7119
  this.#scheduler.start((cursor, cursorDirty) => {