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 +629 -0
- package/fesm2022/ngx-virtual-dnd.mjs +145 -111
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +0 -4
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
|