ngx-virtual-dnd 1.1.1 → 1.2.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 +168 -450
- package/fesm2022/ngx-virtual-dnd.mjs +1002 -298
- package/fesm2022/ngx-virtual-dnd.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-virtual-dnd.d.ts +285 -95
package/README.md
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
# ngx-virtual-dnd
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Angular drag-and-drop library optimized for virtual scrolling. Handles thousands of items efficiently by only rendering visible elements.
|
|
4
4
|
|
|
5
5
|
Inspired by [react-virtualized-dnd](https://github.com/forecast-it/react-virtualized-dnd/).
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Virtual Scrolling** - Renders only visible items plus
|
|
10
|
-
- **Smooth Drag & Drop** - 60fps
|
|
11
|
-
- **Cross-List Support** - Drag
|
|
12
|
-
- **Auto-Scroll** -
|
|
13
|
-
- **
|
|
14
|
-
- **Touch Support** - Works with
|
|
15
|
-
- **
|
|
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
|
|
9
|
+
- **Virtual Scrolling** - Renders only visible items plus overscan buffer
|
|
10
|
+
- **Smooth Drag & Drop** - 60fps with RAF throttling
|
|
11
|
+
- **Cross-List Support** - Drag between multiple lists with group filtering
|
|
12
|
+
- **Auto-Scroll** - Scrolls when dragging near container edges
|
|
13
|
+
- **Keyboard Accessible** - Space to grab, arrows to move, Escape to cancel
|
|
14
|
+
- **Touch Support** - Works with mouse and touch
|
|
15
|
+
- **Angular 21+** - Signals, standalone components, modern patterns
|
|
20
16
|
|
|
21
17
|
## Installation
|
|
22
18
|
|
|
@@ -24,18 +20,14 @@ Inspired by [react-virtualized-dnd](https://github.com/forecast-it/react-virtual
|
|
|
24
20
|
npm install ngx-virtual-dnd
|
|
25
21
|
```
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
## Quick Start (Simplified API)
|
|
30
|
-
|
|
31
|
-
The easiest way to get started is with the high-level `VirtualSortableListComponent`:
|
|
23
|
+
## Quick Start
|
|
32
24
|
|
|
33
25
|
```typescript
|
|
34
26
|
import {
|
|
35
|
-
DragPreviewComponent,
|
|
36
|
-
DraggableDirective,
|
|
37
27
|
VirtualSortableListComponent,
|
|
38
28
|
DroppableGroupDirective,
|
|
29
|
+
DraggableDirective,
|
|
30
|
+
DragPreviewComponent,
|
|
39
31
|
PlaceholderComponent,
|
|
40
32
|
DropEvent,
|
|
41
33
|
moveItem,
|
|
@@ -43,17 +35,17 @@ import {
|
|
|
43
35
|
|
|
44
36
|
@Component({
|
|
45
37
|
imports: [
|
|
46
|
-
DragPreviewComponent,
|
|
47
|
-
DraggableDirective,
|
|
48
38
|
VirtualSortableListComponent,
|
|
49
39
|
DroppableGroupDirective,
|
|
40
|
+
DraggableDirective,
|
|
41
|
+
DragPreviewComponent,
|
|
50
42
|
PlaceholderComponent,
|
|
51
43
|
],
|
|
52
44
|
template: `
|
|
53
45
|
<!-- Item template -->
|
|
54
46
|
<ng-template #itemTpl let-item let-isPlaceholder="isPlaceholder">
|
|
55
47
|
@if (isPlaceholder) {
|
|
56
|
-
<vdnd-placeholder [height]="50"
|
|
48
|
+
<vdnd-placeholder [height]="50" />
|
|
57
49
|
} @else {
|
|
58
50
|
<div class="item" vdndDraggable="{{ item.id }}" [vdndDraggableData]="item">
|
|
59
51
|
{{ item.name }}
|
|
@@ -61,7 +53,7 @@ import {
|
|
|
61
53
|
}
|
|
62
54
|
</ng-template>
|
|
63
55
|
|
|
64
|
-
<!--
|
|
56
|
+
<!-- Lists wrapped in a group -->
|
|
65
57
|
<div vdndGroup="my-group">
|
|
66
58
|
<vdnd-sortable-list
|
|
67
59
|
droppableId="list-1"
|
|
@@ -86,16 +78,16 @@ import {
|
|
|
86
78
|
/>
|
|
87
79
|
</div>
|
|
88
80
|
|
|
81
|
+
<!-- Required: renders the dragged item preview -->
|
|
89
82
|
<vdnd-drag-preview />
|
|
90
83
|
`,
|
|
91
84
|
})
|
|
92
85
|
export class MyComponent {
|
|
93
|
-
list1 = signal<Item[]>([]);
|
|
94
|
-
list2 = signal<Item[]>([]);
|
|
86
|
+
list1 = signal<Item[]>([...]);
|
|
87
|
+
list2 = signal<Item[]>([...]);
|
|
95
88
|
|
|
96
89
|
getItemId = (item: Item) => item.id;
|
|
97
90
|
|
|
98
|
-
// One-liner drop handler using moveItem utility!
|
|
99
91
|
onDrop(event: DropEvent): void {
|
|
100
92
|
moveItem(event, {
|
|
101
93
|
'list-1': this.list1,
|
|
@@ -105,66 +97,75 @@ export class MyComponent {
|
|
|
105
97
|
}
|
|
106
98
|
```
|
|
107
99
|
|
|
108
|
-
|
|
100
|
+
**That's it!** `VirtualSortableListComponent` handles placeholder positioning, sticky items during drag, and virtual scroll integration automatically.
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
## API Overview
|
|
111
103
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
DraggableDirective,
|
|
116
|
-
DroppableDirective,
|
|
117
|
-
VirtualScrollContainerComponent,
|
|
118
|
-
PlaceholderComponent,
|
|
119
|
-
DropEvent,
|
|
120
|
-
DragStateService,
|
|
121
|
-
END_OF_LIST,
|
|
122
|
-
} from 'ngx-virtual-dnd';
|
|
104
|
+
The library exports these main pieces (use IDE completion for full details):
|
|
105
|
+
|
|
106
|
+
**Components:**
|
|
123
107
|
|
|
108
|
+
- `VirtualSortableListComponent` - High-level component combining droppable, virtual scroll, and placeholder
|
|
109
|
+
- `VirtualScrollContainerComponent` - Low-level virtual scroll container
|
|
110
|
+
- `VirtualViewportComponent` - Self-contained virtual scroll viewport
|
|
111
|
+
- `VirtualContentComponent` - Virtual content for external scroll containers (page-level scroll)
|
|
112
|
+
- `DragPreviewComponent` - Renders the dragged item preview (required)
|
|
113
|
+
- `PlaceholderComponent` - Drop position indicator
|
|
114
|
+
|
|
115
|
+
**Directives:**
|
|
116
|
+
|
|
117
|
+
- `DraggableDirective` (`vdndDraggable`) - Makes an element draggable
|
|
118
|
+
- `DroppableDirective` (`vdndDroppable`) - Marks a drop target
|
|
119
|
+
- `DroppableGroupDirective` (`vdndGroup`) - Provides group context to children
|
|
120
|
+
- `ScrollableDirective` (`vdndScrollable`) - Marks external scroll container
|
|
121
|
+
- `VirtualForDirective` (`*vdndVirtualFor`) - Structural directive for virtual lists
|
|
122
|
+
|
|
123
|
+
**Services:**
|
|
124
|
+
|
|
125
|
+
- `DragStateService` - Access drag state (isDragging, draggedItem, placeholderIndex, etc.)
|
|
126
|
+
- `AutoScrollService` - Controls edge auto-scrolling
|
|
127
|
+
- `PositionCalculatorService` - Calculates placeholder positions
|
|
128
|
+
|
|
129
|
+
**Utilities:**
|
|
130
|
+
|
|
131
|
+
- `moveItem()` - Move between signal-based lists
|
|
132
|
+
- `reorderItems()` - Reorder within a single list
|
|
133
|
+
- `applyMove()` - Immutable version (returns new arrays)
|
|
134
|
+
- `isNoOpDrop()` - Check if drop would be a no-op
|
|
135
|
+
- `insertAt()` / `removeAt()` - Low-level array helpers
|
|
136
|
+
|
|
137
|
+
## Advanced Usage
|
|
138
|
+
|
|
139
|
+
### Low-Level API
|
|
140
|
+
|
|
141
|
+
For maximum control, use individual components instead of `VirtualSortableListComponent`:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
124
144
|
@Component({
|
|
125
145
|
imports: [
|
|
126
|
-
DragPreviewComponent,
|
|
127
|
-
DraggableDirective,
|
|
128
|
-
DroppableDirective,
|
|
129
146
|
VirtualScrollContainerComponent,
|
|
147
|
+
DroppableDirective,
|
|
148
|
+
DraggableDirective,
|
|
149
|
+
DragPreviewComponent,
|
|
130
150
|
PlaceholderComponent,
|
|
131
151
|
],
|
|
132
152
|
template: `
|
|
133
|
-
<div
|
|
153
|
+
<div vdndDroppable="list-1" vdndDroppableGroup="demo" (drop)="onDrop($event)">
|
|
134
154
|
<vdnd-virtual-scroll
|
|
135
155
|
[items]="itemsWithPlaceholder()"
|
|
136
156
|
[itemHeight]="50"
|
|
137
157
|
[stickyItemIds]="stickyIds()"
|
|
138
158
|
[itemIdFn]="getItemId"
|
|
139
159
|
[trackByFn]="trackById"
|
|
140
|
-
|
|
141
|
-
|
|
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>
|
|
160
|
+
[itemTemplate]="itemTpl"
|
|
161
|
+
/>
|
|
156
162
|
</div>
|
|
157
|
-
|
|
158
163
|
<vdnd-drag-preview />
|
|
159
164
|
`,
|
|
160
165
|
})
|
|
161
166
|
export class ListComponent {
|
|
162
167
|
readonly #dragState = inject(DragStateService);
|
|
163
|
-
|
|
164
|
-
items = signal<Item[]>([
|
|
165
|
-
{ id: '1', name: 'Item 1' },
|
|
166
|
-
{ id: '2', name: 'Item 2' },
|
|
167
|
-
]);
|
|
168
|
+
items = signal<Item[]>([]);
|
|
168
169
|
|
|
169
170
|
// Keep dragged item rendered during scroll
|
|
170
171
|
stickyIds = computed(() => {
|
|
@@ -178,7 +179,7 @@ export class ListComponent {
|
|
|
178
179
|
const activeDroppable = this.#dragState.activeDroppableId();
|
|
179
180
|
const placeholderIndex = this.#dragState.placeholderIndex();
|
|
180
181
|
|
|
181
|
-
if (activeDroppable !== '
|
|
182
|
+
if (activeDroppable !== 'list-1' || placeholderIndex === null) {
|
|
182
183
|
return items;
|
|
183
184
|
}
|
|
184
185
|
|
|
@@ -186,314 +187,85 @@ export class ListComponent {
|
|
|
186
187
|
result.splice(placeholderIndex, 0, { id: 'placeholder', isPlaceholder: true } as any);
|
|
187
188
|
return result;
|
|
188
189
|
});
|
|
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
190
|
}
|
|
396
191
|
```
|
|
397
192
|
|
|
398
|
-
|
|
193
|
+
### Page-Level Scroll
|
|
399
194
|
|
|
400
|
-
|
|
195
|
+
Use `VirtualContentComponent` with `vdndScrollable` for page-level scrolling with headers/footers:
|
|
401
196
|
|
|
402
197
|
```typescript
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
index: number;
|
|
442
|
-
data?: unknown;
|
|
443
|
-
};
|
|
444
|
-
destination: {
|
|
445
|
-
droppableId: string;
|
|
446
|
-
placeholderId: string;
|
|
447
|
-
index: number;
|
|
448
|
-
data?: unknown;
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
```
|
|
198
|
+
@Component({
|
|
199
|
+
imports: [
|
|
200
|
+
ScrollableDirective,
|
|
201
|
+
VirtualContentComponent,
|
|
202
|
+
VirtualForDirective,
|
|
203
|
+
DraggableDirective,
|
|
204
|
+
DroppableDirective,
|
|
205
|
+
DroppableGroupDirective,
|
|
206
|
+
DragPreviewComponent,
|
|
207
|
+
],
|
|
208
|
+
template: `
|
|
209
|
+
<ion-content [scrollY]="false">
|
|
210
|
+
<div class="scroll-container ion-content-scroll-host" vdndScrollable>
|
|
211
|
+
<!-- Header that scrolls away -->
|
|
212
|
+
<div class="header" #header>Welcome!</div>
|
|
213
|
+
|
|
214
|
+
<!-- Virtual list -->
|
|
215
|
+
<div vdndGroup="tasks">
|
|
216
|
+
<vdnd-virtual-content
|
|
217
|
+
[itemHeight]="72"
|
|
218
|
+
[totalItems]="items().length"
|
|
219
|
+
[contentOffset]="headerHeight()"
|
|
220
|
+
[style.height.px]="items().length * 72"
|
|
221
|
+
vdndDroppable="list-1"
|
|
222
|
+
(drop)="onDrop($event)"
|
|
223
|
+
>
|
|
224
|
+
<ng-container
|
|
225
|
+
*vdndVirtualFor="
|
|
226
|
+
let item of items();
|
|
227
|
+
itemHeight: 72;
|
|
228
|
+
trackBy: trackById;
|
|
229
|
+
droppableId: 'list-1'
|
|
230
|
+
"
|
|
231
|
+
>
|
|
232
|
+
<div class="item" [vdndDraggable]="item.id">{{ item.name }}</div>
|
|
233
|
+
</ng-container>
|
|
234
|
+
</vdnd-virtual-content>
|
|
235
|
+
</div>
|
|
452
236
|
|
|
453
|
-
|
|
237
|
+
<!-- Footer -->
|
|
238
|
+
<div class="footer">Load more</div>
|
|
239
|
+
</div>
|
|
240
|
+
</ion-content>
|
|
454
241
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
242
|
+
<vdnd-drag-preview />
|
|
243
|
+
`,
|
|
244
|
+
})
|
|
245
|
+
export class PageComponent {
|
|
246
|
+
items = signal<Item[]>([...]);
|
|
247
|
+
headerHeight = signal(0);
|
|
248
|
+
|
|
249
|
+
// Track header height with ResizeObserver
|
|
250
|
+
constructor() {
|
|
251
|
+
afterNextRender(() => {
|
|
252
|
+
const header = this.header().nativeElement;
|
|
253
|
+
this.headerHeight.set(header.offsetHeight);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
460
256
|
}
|
|
461
257
|
```
|
|
462
258
|
|
|
463
|
-
|
|
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 |
|
|
259
|
+
Key points:
|
|
477
260
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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 |
|
|
261
|
+
- `vdndScrollable` marks the scroll container
|
|
262
|
+
- `VirtualContentComponent` provides wrapper-based positioning
|
|
263
|
+
- `contentOffset` accounts for content above the list (headers)
|
|
264
|
+
- Set explicit height on `vdnd-virtual-content` matching total item height
|
|
487
265
|
|
|
488
266
|
### Screen Reader Announcements
|
|
489
267
|
|
|
490
|
-
The library emits events with position data
|
|
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**
|
|
268
|
+
The library emits events with position data. Implement announcements in your app:
|
|
497
269
|
|
|
498
270
|
```typescript
|
|
499
271
|
@Component({
|
|
@@ -505,125 +277,71 @@ The library emits events with position data (`sourceIndex`, `targetIndex`, `dest
|
|
|
505
277
|
>
|
|
506
278
|
{{ item.name }}
|
|
507
279
|
</div>
|
|
508
|
-
<div aria-live="assertive" class="
|
|
280
|
+
<div aria-live="assertive" class="sr-only">{{ announcement() }}</div>
|
|
509
281
|
`,
|
|
510
282
|
})
|
|
511
283
|
export class MyComponent {
|
|
512
284
|
announcement = signal('');
|
|
513
285
|
|
|
514
|
-
announce(
|
|
515
|
-
this.announcement.set(
|
|
286
|
+
announce(msg: string) {
|
|
287
|
+
this.announcement.set(msg);
|
|
516
288
|
}
|
|
517
289
|
|
|
518
|
-
announceEnd(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
290
|
+
announceEnd(e: DragEndEvent) {
|
|
291
|
+
this.announce(
|
|
292
|
+
e.cancelled
|
|
293
|
+
? `Cancelled. Returned to position ${e.sourceIndex + 1}`
|
|
294
|
+
: `Dropped at position ${e.destinationIndex! + 1}`,
|
|
295
|
+
);
|
|
524
296
|
}
|
|
525
297
|
}
|
|
526
298
|
```
|
|
527
299
|
|
|
528
|
-
|
|
300
|
+
## Keyboard Navigation
|
|
529
301
|
|
|
530
|
-
|
|
302
|
+
| Key | Action |
|
|
303
|
+
| ----------- | --------------------------- |
|
|
304
|
+
| `Tab` | Navigate to draggable items |
|
|
305
|
+
| `Space` | Start/end drag |
|
|
306
|
+
| `Arrow ↑/↓` | Move item up/down |
|
|
307
|
+
| `Arrow ←/→` | Move to adjacent list |
|
|
308
|
+
| `Escape` | Cancel drag |
|
|
531
309
|
|
|
532
|
-
-
|
|
533
|
-
- **Large lists**: Tested with 10,000+ items without performance degradation
|
|
534
|
-
- **Cross-list moves**: Works across multiple virtualized lists
|
|
310
|
+
ARIA attributes (`aria-grabbed`, `aria-dropeffect`, `tabindex`) are managed automatically.
|
|
535
311
|
|
|
536
312
|
## CSS Classes
|
|
537
313
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
|
541
|
-
|
|
|
542
|
-
| `vdnd-draggable`
|
|
543
|
-
| `vdnd-
|
|
544
|
-
| `vdnd-
|
|
545
|
-
| `vdnd-droppable`
|
|
546
|
-
| `vdnd-droppable-
|
|
547
|
-
| `vdnd-droppable-disabled` | Droppable | When disabled |
|
|
314
|
+
| Class | Applied When |
|
|
315
|
+
| ------------------------- | --------------------------- |
|
|
316
|
+
| `vdnd-draggable` | Always on draggable |
|
|
317
|
+
| `vdnd-draggable-dragging` | While being dragged |
|
|
318
|
+
| `vdnd-draggable-disabled` | When disabled |
|
|
319
|
+
| `vdnd-drag-pending` | After delay, ready to drag |
|
|
320
|
+
| `vdnd-droppable` | Always on droppable |
|
|
321
|
+
| `vdnd-droppable-active` | When a draggable is over it |
|
|
322
|
+
| `vdnd-droppable-disabled` | When disabled |
|
|
548
323
|
|
|
549
324
|
## How It Works
|
|
550
325
|
|
|
551
|
-
|
|
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:
|
|
326
|
+
Traditional drag-and-drop libraries query sibling DOM elements via `getBoundingClientRect()`. This fails with virtual scrolling because items outside the viewport aren't rendered.
|
|
558
327
|
|
|
559
|
-
|
|
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
|
|
328
|
+
This library uses **element-under-point detection**: temporarily hide the dragged element, use `document.elementFromPoint()` to find what's at the cursor, walk up to find the droppable, and calculate placeholder position mathematically.
|
|
563
329
|
|
|
564
|
-
|
|
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
|
|
330
|
+
## Browser Support
|
|
571
331
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
/e2e/ # Playwright E2E tests
|
|
576
|
-
/docs/ # Documentation
|
|
577
|
-
```
|
|
332
|
+
- Chrome/Edge (latest)
|
|
333
|
+
- Firefox (latest)
|
|
334
|
+
- Safari (latest)
|
|
578
335
|
|
|
579
336
|
## Development
|
|
580
337
|
|
|
581
338
|
```bash
|
|
582
|
-
#
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
339
|
+
npm start # Dev server (localhost:4200)
|
|
340
|
+
ng build ngx-virtual-dnd # Build library (required after lib edits)
|
|
341
|
+
npm test # Unit tests
|
|
342
|
+
npm run e2e # E2E tests
|
|
605
343
|
```
|
|
606
344
|
|
|
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
345
|
## License
|
|
628
346
|
|
|
629
347
|
MIT
|