ngx-virtual-dnd 1.1.0 → 1.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.
package/README.md ADDED
@@ -0,0 +1,629 @@
1
+ # ngx-virtual-dnd
2
+
3
+ An Angular drag-and-drop library optimized for virtual scrolling. Handles thousands of items efficiently by only rendering visible elements while maintaining smooth drag operations.
4
+
5
+ Inspired by [react-virtualized-dnd](https://github.com/forecast-it/react-virtualized-dnd/).
6
+
7
+ ## Features
8
+
9
+ - **Virtual Scrolling** - Renders only visible items plus an overscan buffer
10
+ - **Smooth Drag & Drop** - 60fps drag operations with RAF throttling
11
+ - **Cross-List Support** - Drag items between multiple lists with group filtering
12
+ - **Auto-Scroll** - Automatically scrolls when dragging near container edges
13
+ - **Axis Locking** - Constrain dragging to horizontal or vertical axis
14
+ - **Touch Support** - Works with both mouse and touch events
15
+ - **Keyboard Accessible** - Full keyboard support (Space, Arrow keys, Escape)
16
+ - **ARIA Support** - `aria-grabbed` and `aria-dropeffect` attributes
17
+ - **Angular 21+** - Built with signals, standalone components, and modern patterns
18
+ - **Simplified API** - High-level `VirtualSortableListComponent` for quick setup
19
+ - **External Scroll Containers** - `vdndScrollable` directive for custom scroll hosts
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install ngx-virtual-dnd
25
+ ```
26
+
27
+ **Peer Dependencies:** Angular 21+
28
+
29
+ ## Quick Start (Simplified API)
30
+
31
+ The easiest way to get started is with the high-level `VirtualSortableListComponent`:
32
+
33
+ ```typescript
34
+ import {
35
+ DragPreviewComponent,
36
+ DraggableDirective,
37
+ VirtualSortableListComponent,
38
+ DroppableGroupDirective,
39
+ PlaceholderComponent,
40
+ DropEvent,
41
+ moveItem,
42
+ } from 'ngx-virtual-dnd';
43
+
44
+ @Component({
45
+ imports: [
46
+ DragPreviewComponent,
47
+ DraggableDirective,
48
+ VirtualSortableListComponent,
49
+ DroppableGroupDirective,
50
+ PlaceholderComponent,
51
+ ],
52
+ template: `
53
+ <!-- Item template -->
54
+ <ng-template #itemTpl let-item let-isPlaceholder="isPlaceholder">
55
+ @if (isPlaceholder) {
56
+ <vdnd-placeholder [height]="50"></vdnd-placeholder>
57
+ } @else {
58
+ <div class="item" vdndDraggable="{{ item.id }}" [vdndDraggableData]="item">
59
+ {{ item.name }}
60
+ </div>
61
+ }
62
+ </ng-template>
63
+
64
+ <!-- Use vdndGroup to set group for all children -->
65
+ <div vdndGroup="my-group">
66
+ <vdnd-sortable-list
67
+ droppableId="list-1"
68
+ group="my-group"
69
+ [items]="list1()"
70
+ [itemHeight]="50"
71
+ [containerHeight]="400"
72
+ [itemIdFn]="getItemId"
73
+ [itemTemplate]="itemTpl"
74
+ (drop)="onDrop($event)"
75
+ />
76
+
77
+ <vdnd-sortable-list
78
+ droppableId="list-2"
79
+ group="my-group"
80
+ [items]="list2()"
81
+ [itemHeight]="50"
82
+ [containerHeight]="400"
83
+ [itemIdFn]="getItemId"
84
+ [itemTemplate]="itemTpl"
85
+ (drop)="onDrop($event)"
86
+ />
87
+ </div>
88
+
89
+ <vdnd-drag-preview />
90
+ `,
91
+ })
92
+ export class MyComponent {
93
+ list1 = signal<Item[]>([]);
94
+ list2 = signal<Item[]>([]);
95
+
96
+ getItemId = (item: Item) => item.id;
97
+
98
+ // One-liner drop handler using moveItem utility!
99
+ onDrop(event: DropEvent): void {
100
+ moveItem(event, {
101
+ 'list-1': this.list1,
102
+ 'list-2': this.list2,
103
+ });
104
+ }
105
+ }
106
+ ```
107
+
108
+ ## Quick Start (Low-Level API)
109
+
110
+ For more control, use the individual directives and components:
111
+
112
+ ```typescript
113
+ import {
114
+ DragPreviewComponent,
115
+ DraggableDirective,
116
+ DroppableDirective,
117
+ VirtualScrollContainerComponent,
118
+ PlaceholderComponent,
119
+ DropEvent,
120
+ DragStateService,
121
+ END_OF_LIST,
122
+ } from 'ngx-virtual-dnd';
123
+
124
+ @Component({
125
+ imports: [
126
+ DragPreviewComponent,
127
+ DraggableDirective,
128
+ DroppableDirective,
129
+ VirtualScrollContainerComponent,
130
+ PlaceholderComponent,
131
+ ],
132
+ template: `
133
+ <div class="list" vdndDroppable="my-list" vdndDroppableGroup="demo" (drop)="onDrop($event)">
134
+ <vdnd-virtual-scroll
135
+ [items]="itemsWithPlaceholder()"
136
+ [itemHeight]="50"
137
+ [stickyItemIds]="stickyIds()"
138
+ [itemIdFn]="getItemId"
139
+ [trackByFn]="trackById"
140
+ >
141
+ <ng-template let-item let-index="index">
142
+ @if (item.isPlaceholder) {
143
+ <vdnd-placeholder [height]="50" />
144
+ } @else {
145
+ <div
146
+ class="item"
147
+ vdndDraggable="{{ item.id }}"
148
+ vdndDraggableGroup="demo"
149
+ [vdndDraggableData]="item"
150
+ >
151
+ {{ item.name }}
152
+ </div>
153
+ }
154
+ </ng-template>
155
+ </vdnd-virtual-scroll>
156
+ </div>
157
+
158
+ <vdnd-drag-preview />
159
+ `,
160
+ })
161
+ export class ListComponent {
162
+ readonly #dragState = inject(DragStateService);
163
+
164
+ items = signal<Item[]>([
165
+ { id: '1', name: 'Item 1' },
166
+ { id: '2', name: 'Item 2' },
167
+ ]);
168
+
169
+ // Keep dragged item rendered during scroll
170
+ stickyIds = computed(() => {
171
+ const item = this.#dragState.draggedItem();
172
+ return item ? [item.draggableId] : [];
173
+ });
174
+
175
+ // Insert placeholder into list
176
+ itemsWithPlaceholder = computed(() => {
177
+ const items = this.items();
178
+ const activeDroppable = this.#dragState.activeDroppableId();
179
+ const placeholderIndex = this.#dragState.placeholderIndex();
180
+
181
+ if (activeDroppable !== 'my-list' || placeholderIndex === null) {
182
+ return items;
183
+ }
184
+
185
+ const result = [...items];
186
+ result.splice(placeholderIndex, 0, { id: 'placeholder', isPlaceholder: true } as any);
187
+ return result;
188
+ });
189
+
190
+ onDrop(event: DropEvent): void {
191
+ const sourceIndex = event.source.index;
192
+ const destIndex = event.destination.index;
193
+
194
+ this.items.update((items) => {
195
+ const newItems = [...items];
196
+ const [removed] = newItems.splice(sourceIndex, 1);
197
+ newItems.splice(destIndex, 0, removed);
198
+ return newItems;
199
+ });
200
+ }
201
+
202
+ readonly getItemId = (item: Item): string => item.id;
203
+ readonly trackById = (_: number, item: Item): string => item.id;
204
+ }
205
+ ```
206
+
207
+ ## API Reference
208
+
209
+ ### DraggableDirective
210
+
211
+ Makes an element draggable.
212
+
213
+ ```html
214
+ <div
215
+ vdndDraggable="unique-id"
216
+ vdndDraggableGroup="group-name"
217
+ [vdndDraggableData]="data"
218
+ [disabled]="false"
219
+ [dragHandle]=".handle"
220
+ [dragThreshold]="5"
221
+ (dragStart)="onDragStart($event)"
222
+ (dragMove)="onDragMove($event)"
223
+ (dragEnd)="onDragEnd($event)"
224
+ ></div>
225
+ ```
226
+
227
+ | Input | Type | Description |
228
+ | -------------------- | --------- | ------------------------------------------------ |
229
+ | `vdndDraggable` | `string` | Unique identifier for the draggable |
230
+ | `vdndDraggableGroup` | `string` | Group name for restricting drop targets |
231
+ | `vdndDraggableData` | `unknown` | Optional data attached to the draggable |
232
+ | `disabled` | `boolean` | Whether dragging is disabled |
233
+ | `dragHandle` | `string` | CSS selector for drag handle element |
234
+ | `dragThreshold` | `number` | Minimum distance before drag starts (default: 5) |
235
+
236
+ | Output | Type | Description |
237
+ | ----------- | ---------------- | ---------------------------- |
238
+ | `dragStart` | `DragStartEvent` | Emitted when drag starts |
239
+ | `dragMove` | `DragMoveEvent` | Emitted during drag movement |
240
+ | `dragEnd` | `DragEndEvent` | Emitted when drag ends |
241
+
242
+ ### DroppableDirective
243
+
244
+ Marks an element as a valid drop target.
245
+
246
+ ```html
247
+ <div
248
+ vdndDroppable="list-id"
249
+ vdndDroppableGroup="group-name"
250
+ [vdndDroppableData]="data"
251
+ [disabled]="false"
252
+ [autoScrollEnabled]="true"
253
+ [autoScrollConfig]="{ threshold: 50, maxSpeed: 15 }"
254
+ (dragEnter)="onDragEnter($event)"
255
+ (dragLeave)="onDragLeave($event)"
256
+ (dragOver)="onDragOver($event)"
257
+ (drop)="onDrop($event)"
258
+ ></div>
259
+ ```
260
+
261
+ | Input | Type | Description |
262
+ | -------------------- | ------------------ | --------------------------------------------- |
263
+ | `vdndDroppable` | `string` | Unique identifier for the droppable |
264
+ | `vdndDroppableGroup` | `string` | Group name (must match draggable group) |
265
+ | `vdndDroppableData` | `unknown` | Optional data attached to the droppable |
266
+ | `disabled` | `boolean` | Whether dropping is disabled |
267
+ | `autoScrollEnabled` | `boolean` | Enable auto-scroll near edges (default: true) |
268
+ | `autoScrollConfig` | `AutoScrollConfig` | Auto-scroll configuration |
269
+
270
+ | Output | Type | Description |
271
+ | ----------- | ---------------- | ----------------------------------------------- |
272
+ | `dragEnter` | `DragEnterEvent` | Emitted when a draggable enters |
273
+ | `dragLeave` | `DragLeaveEvent` | Emitted when a draggable leaves |
274
+ | `dragOver` | `DragOverEvent` | Emitted while hovering with placeholder updates |
275
+ | `drop` | `DropEvent` | Emitted when an item is dropped |
276
+
277
+ ### VirtualScrollContainerComponent
278
+
279
+ A virtual scroll container that only renders visible items.
280
+
281
+ ```html
282
+ <vdnd-virtual-scroll
283
+ [items]="items()"
284
+ [itemHeight]="50"
285
+ [containerHeight]="400"
286
+ [overscan]="3"
287
+ [stickyItemIds]="stickyIds()"
288
+ [itemIdFn]="getItemId"
289
+ [trackByFn]="trackById"
290
+ [itemTemplate]="itemTpl"
291
+ (visibleRangeChange)="onRangeChange($event)"
292
+ (scrollPositionChange)="onScroll($event)"
293
+ >
294
+ </vdnd-virtual-scroll>
295
+ ```
296
+
297
+ | Input | Type | Description |
298
+ | ----------------- | ------------------------------------ | ------------------------------------------------- |
299
+ | `items` | `T[]` | Array of items to render |
300
+ | `itemHeight` | `number` | Height of each item in pixels |
301
+ | `containerHeight` | `number` | Height of the container in pixels |
302
+ | `overscan` | `number` | Items to render above/below viewport (default: 3) |
303
+ | `stickyItemIds` | `string[]` | IDs of items that should always be rendered |
304
+ | `itemIdFn` | `(item: T) => string` | Function to get unique ID from item |
305
+ | `trackByFn` | `(index: number, item: T) => string` | Track-by function for the loop |
306
+ | `itemTemplate` | `TemplateRef` | Template for rendering each item |
307
+
308
+ ### VirtualSortableListComponent
309
+
310
+ A high-level component that combines droppable, virtual scroll, and placeholder functionality. **Recommended for most use cases.**
311
+
312
+ ```html
313
+ <vdnd-sortable-list
314
+ droppableId="list-1"
315
+ group="my-group"
316
+ [items]="items()"
317
+ [itemHeight]="50"
318
+ [itemIdFn]="getItemId"
319
+ [itemTemplate]="itemTpl"
320
+ [placeholderTemplate]="placeholderTpl"
321
+ (drop)="onDrop($event)"
322
+ >
323
+ </vdnd-sortable-list>
324
+ ```
325
+
326
+ | Input | Type | Description |
327
+ | --------------------- | --------------------- | ---------------------------------------- |
328
+ | `droppableId` | `string` | Unique identifier for this list |
329
+ | `group` | `string` | Drag-and-drop group name |
330
+ | `items` | `T[]` | Array of items to render |
331
+ | `itemHeight` | `number` | Height of each item in pixels |
332
+ | `itemIdFn` | `(item: T) => string` | Function to get unique ID from item |
333
+ | `itemTemplate` | `TemplateRef` | Template for rendering each item |
334
+ | `trackByFn` | `Function` | Optional track-by (defaults to itemIdFn) |
335
+ | `placeholderTemplate` | `TemplateRef` | Optional custom placeholder template |
336
+ | `containerHeight` | `number` | Optional explicit container height |
337
+ | `disabled` | `boolean` | Whether this list is disabled |
338
+
339
+ ### DroppableGroupDirective
340
+
341
+ Provides group context to child draggables and droppables.
342
+
343
+ ```html
344
+ <!-- Without group directive (verbose) -->
345
+ <div vdndDroppable="list-1" vdndDroppableGroup="my-group">
346
+ <div vdndDraggable="item-1" vdndDraggableGroup="my-group">Item 1</div>
347
+ </div>
348
+
349
+ <!-- With group directive (concise) -->
350
+ <div vdndGroup="my-group">
351
+ <div vdndDroppable="list-1">
352
+ <div vdndDraggable="item-1">Item 1</div>
353
+ </div>
354
+ </div>
355
+ ```
356
+
357
+ ### Drop Utilities
358
+
359
+ ```typescript
360
+ import { moveItem, reorderItems, applyMove, isNoOpDrop } from 'ngx-virtual-dnd';
361
+
362
+ // Move items between signal-based lists
363
+ moveItem(event, {
364
+ 'list-1': this.list1,
365
+ 'list-2': this.list2,
366
+ });
367
+
368
+ // Reorder within a single list
369
+ reorderItems(event, this.items);
370
+
371
+ // Immutable version (returns new arrays)
372
+ const updated = applyMove(event, {
373
+ 'list-1': this.list1(),
374
+ 'list-2': this.list2(),
375
+ });
376
+
377
+ // Check if drop is a no-op (same position)
378
+ if (isNoOpDrop(event)) return;
379
+ ```
380
+
381
+ ### DragStateService
382
+
383
+ Central service for accessing drag state. Inject to build custom integrations.
384
+
385
+ ```typescript
386
+ @Injectable({ providedIn: 'root' })
387
+ export class DragStateService {
388
+ readonly isDragging: Signal<boolean>;
389
+ readonly draggedItem: Signal<DraggedItem | null>;
390
+ readonly sourceDroppableId: Signal<string | null>;
391
+ readonly activeDroppableId: Signal<string | null>;
392
+ readonly placeholderId: Signal<string | null>;
393
+ readonly placeholderIndex: Signal<number | null>;
394
+ readonly cursorPosition: Signal<CursorPosition | null>;
395
+ }
396
+ ```
397
+
398
+ ## Event Types
399
+
400
+ ### DragStartEvent
401
+
402
+ ```typescript
403
+ interface DragStartEvent {
404
+ draggableId: string;
405
+ droppableId: string;
406
+ data?: unknown;
407
+ position: { x: number; y: number };
408
+ sourceIndex: number; // 0-indexed position in source list
409
+ }
410
+ ```
411
+
412
+ ### DragMoveEvent
413
+
414
+ ```typescript
415
+ interface DragMoveEvent {
416
+ draggableId: string;
417
+ position: { x: number; y: number };
418
+ targetIndex: number | null; // Current placeholder index (0-indexed)
419
+ }
420
+ ```
421
+
422
+ ### DragEndEvent
423
+
424
+ ```typescript
425
+ interface DragEndEvent {
426
+ draggableId: string;
427
+ position: { x: number; y: number };
428
+ cancelled: boolean;
429
+ sourceIndex: number; // Original position (for announcements)
430
+ destinationIndex: number | null; // Final position (null if cancelled)
431
+ }
432
+ ```
433
+
434
+ ### DropEvent
435
+
436
+ ```typescript
437
+ interface DropEvent {
438
+ source: {
439
+ draggableId: string;
440
+ droppableId: string;
441
+ index: number;
442
+ data?: unknown;
443
+ };
444
+ destination: {
445
+ droppableId: string;
446
+ placeholderId: string;
447
+ index: number;
448
+ data?: unknown;
449
+ };
450
+ }
451
+ ```
452
+
453
+ ### AutoScrollConfig
454
+
455
+ ```typescript
456
+ interface AutoScrollConfig {
457
+ threshold: number; // Distance from edge to start scrolling (default: 50)
458
+ maxSpeed: number; // Maximum scroll speed in px/frame (default: 15)
459
+ accelerate: boolean; // Accelerate based on distance from edge (default: true)
460
+ }
461
+ ```
462
+
463
+ ## Keyboard Accessibility
464
+
465
+ The library provides full keyboard support for drag-and-drop operations, compliant with WCAG 2.5.7 (Dragging Movements) and WCAG 2.1.1 (Keyboard).
466
+
467
+ ### Keyboard Interaction Model
468
+
469
+ | Key | Action |
470
+ | ----------------- | ------------------------------------------------ |
471
+ | `Tab` | Navigate to draggable items |
472
+ | `Space` | Start drag (while focused on draggable item) |
473
+ | `Arrow ↑` / `↓` | Move item up/down in the list |
474
+ | `Arrow ←` / `→` | Move item to adjacent list (cross-list movement) |
475
+ | `Space` / `Enter` | Drop item at current position |
476
+ | `Escape` | Cancel drag and return item to original position |
477
+
478
+ ### ARIA Attributes
479
+
480
+ The library automatically manages these ARIA attributes:
481
+
482
+ | Attribute | Element | Description |
483
+ | ----------------- | --------- | -------------------------------------------- |
484
+ | `aria-grabbed` | Draggable | `true` when being dragged, `false` otherwise |
485
+ | `aria-dropeffect` | Droppable | Set to `move` on drop target containers |
486
+ | `tabindex` | Draggable | Set to `0` for keyboard focusability |
487
+
488
+ ### Screen Reader Announcements
489
+
490
+ The library emits events with position data (`sourceIndex`, `targetIndex`, `destinationIndex`) that consumers can use to build screen reader announcements. This approach:
491
+
492
+ - Avoids i18n complexity (consumers control announcement text)
493
+ - Allows customization of announcement timing and content
494
+ - Works with any screen reader announcement mechanism
495
+
496
+ **Example: Consumer-Side Announcer**
497
+
498
+ ```typescript
499
+ @Component({
500
+ template: `
501
+ <div
502
+ vdndDraggable="item-1"
503
+ (dragStart)="announce('Grabbed ' + item.name)"
504
+ (dragEnd)="announceEnd($event)"
505
+ >
506
+ {{ item.name }}
507
+ </div>
508
+ <div aria-live="assertive" class="visually-hidden">{{ announcement() }}</div>
509
+ `,
510
+ })
511
+ export class MyComponent {
512
+ announcement = signal('');
513
+
514
+ announce(message: string): void {
515
+ this.announcement.set(message);
516
+ }
517
+
518
+ announceEnd(event: DragEndEvent): void {
519
+ if (event.cancelled) {
520
+ this.announce(`Cancelled. Returned to position ${event.sourceIndex + 1}`);
521
+ } else {
522
+ this.announce(`Dropped at position ${event.destinationIndex! + 1}`);
523
+ }
524
+ }
525
+ }
526
+ ```
527
+
528
+ ### Virtual Scroll Integration
529
+
530
+ Keyboard navigation works seamlessly with virtual scrolling:
531
+
532
+ - **Auto-scroll**: When navigating beyond the visible range, the container automatically scrolls
533
+ - **Large lists**: Tested with 10,000+ items without performance degradation
534
+ - **Cross-list moves**: Works across multiple virtualized lists
535
+
536
+ ## CSS Classes
537
+
538
+ The library adds CSS classes for styling drag states:
539
+
540
+ | Class | Element | Applied When |
541
+ | ------------------------- | --------- | --------------------------- |
542
+ | `vdnd-draggable` | Draggable | Always |
543
+ | `vdnd-draggable-dragging` | Draggable | While being dragged |
544
+ | `vdnd-draggable-disabled` | Draggable | When disabled |
545
+ | `vdnd-droppable` | Droppable | Always |
546
+ | `vdnd-droppable-active` | Droppable | When a draggable is over it |
547
+ | `vdnd-droppable-disabled` | Droppable | When disabled |
548
+
549
+ ## How It Works
550
+
551
+ ### Why Virtual Scroll + DnD is Hard
552
+
553
+ Traditional drag-and-drop libraries (like Angular CDK) query sibling DOM elements via `getBoundingClientRect()`. This fails with virtual scrolling because items outside the viewport aren't rendered.
554
+
555
+ ### The Solution
556
+
557
+ This library uses **element-under-point detection** instead of DOM sibling queries:
558
+
559
+ 1. Temporarily hide the dragged element
560
+ 2. Use `document.elementFromPoint()` to find what's at the cursor
561
+ 3. Walk up the DOM to find the droppable/draggable parent
562
+ 4. Calculate placeholder position mathematically
563
+
564
+ This works because only the visible item at the cursor position needs to exist in the DOM.
565
+
566
+ ### Placeholder Index
567
+
568
+ The placeholder index is calculated using the **preview center position**, providing intuitive UX where the placeholder appears where the preview visually is.
569
+
570
+ ## Project Structure
571
+
572
+ ```
573
+ /projects/ngx-virtual-dnd/ # Reusable library (npm: ngx-virtual-dnd)
574
+ /src/ # Demo application
575
+ /e2e/ # Playwright E2E tests
576
+ /docs/ # Documentation
577
+ ```
578
+
579
+ ## Development
580
+
581
+ ```bash
582
+ # Install dependencies
583
+ npm install
584
+
585
+ # Start demo app (http://localhost:4200)
586
+ npm start
587
+
588
+ # Build library (required after editing library files)
589
+ ng build ngx-virtual-dnd
590
+
591
+ # Run unit tests
592
+ npm test
593
+
594
+ # Run E2E tests
595
+ npm run e2e
596
+
597
+ # Run E2E tests with UI
598
+ npm run e2e:ui
599
+
600
+ # Lint
601
+ npm run lint
602
+
603
+ # Storybook
604
+ npm run storybook
605
+ ```
606
+
607
+ ### Library Development
608
+
609
+ The demo app imports from `dist/ngx-virtual-dnd`. After editing any file in `/projects/ngx-virtual-dnd/`:
610
+
611
+ 1. Rebuild the library: `ng build ngx-virtual-dnd`
612
+ 2. Restart the dev server if running
613
+
614
+ ## Documentation
615
+
616
+ - [Architecture Overview](./docs/ARCHITECTURE.md) - How the library works
617
+ - [API Reference](./docs/API.md) - Components, directives, and services
618
+ - [Usage Guide](./docs/USAGE.md) - Detailed examples
619
+ - [Algorithm](./docs/ALGORITHM.md) - Core positioning algorithm
620
+
621
+ ## Browser Support
622
+
623
+ - Chrome/Edge (latest)
624
+ - Firefox (latest)
625
+ - Safari (latest)
626
+
627
+ ## License
628
+
629
+ MIT