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 +6 -13
- package/fesm2022/ngx-virtual-dnd.mjs +71 -28
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +2 -1
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
|
-
- [
|
|
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
|
-
##
|
|
103
|
+
## Contributing
|
|
102
104
|
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
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.#
|
|
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.#
|
|
971
|
+
this.#resetSignalsOutsideCoreState();
|
|
963
972
|
this.#state.set(INITIAL_CORE_STATE);
|
|
964
973
|
}
|
|
965
974
|
/**
|
|
966
|
-
* Reset the
|
|
975
|
+
* Reset the signals kept outside the core state (high-frequency fields, touch flag) to idle.
|
|
967
976
|
*/
|
|
968
|
-
#
|
|
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
|
|
3022
|
-
*
|
|
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
|
|
3160
|
-
|
|
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
|
|
3191
|
+
this.#start(shift);
|
|
3168
3192
|
}
|
|
3169
3193
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
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
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
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
|
-
|
|
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([{
|
|
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
|
-
|
|
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) => {
|