ux-toolkit 0.1.0 → 0.4.1
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 +113 -7
- package/agents/card-reviewer.md +173 -0
- package/agents/comparison-reviewer.md +143 -0
- package/agents/density-reviewer.md +207 -0
- package/agents/detail-page-reviewer.md +143 -0
- package/agents/editor-reviewer.md +165 -0
- package/agents/form-reviewer.md +156 -0
- package/agents/game-ui-reviewer.md +181 -0
- package/agents/list-page-reviewer.md +132 -0
- package/agents/navigation-reviewer.md +145 -0
- package/agents/panel-reviewer.md +182 -0
- package/agents/replay-reviewer.md +174 -0
- package/agents/settings-reviewer.md +166 -0
- package/agents/ux-auditor.md +145 -45
- package/agents/ux-engineer.md +211 -38
- package/dist/cli.js +172 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -4
- package/dist/index.d.ts +128 -4
- package/dist/index.js +172 -5
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/skills/canvas-grid-patterns/SKILL.md +367 -0
- package/skills/comparison-patterns/SKILL.md +354 -0
- package/skills/data-density-patterns/SKILL.md +493 -0
- package/skills/detail-page-patterns/SKILL.md +522 -0
- package/skills/drag-drop-patterns/SKILL.md +406 -0
- package/skills/editor-workspace-patterns/SKILL.md +552 -0
- package/skills/event-timeline-patterns/SKILL.md +542 -0
- package/skills/form-patterns/SKILL.md +608 -0
- package/skills/info-card-patterns/SKILL.md +531 -0
- package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
- package/skills/list-page-patterns/SKILL.md +351 -0
- package/skills/modal-patterns/SKILL.md +750 -0
- package/skills/navigation-patterns/SKILL.md +476 -0
- package/skills/page-structure-patterns/SKILL.md +271 -0
- package/skills/playback-replay-patterns/SKILL.md +695 -0
- package/skills/react-ux-patterns/SKILL.md +434 -0
- package/skills/split-panel-patterns/SKILL.md +609 -0
- package/skills/status-visualization-patterns/SKILL.md +635 -0
- package/skills/toast-notification-patterns/SKILL.md +207 -0
- package/skills/turn-based-ui-patterns/SKILL.md +506 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: drag-drop-patterns
|
|
3
|
+
description: Drag and drop interactions, visual feedback, drop zones, and reordering patterns
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Drag & Drop Patterns
|
|
8
|
+
|
|
9
|
+
Modern drag-and-drop UI patterns for complex interactions like equipment assignment, panel management, and list reordering. Based on dnd-kit, react-dnd, and WCAG 2.1 accessibility guidelines.
|
|
10
|
+
|
|
11
|
+
## 1. Drag Initiation
|
|
12
|
+
|
|
13
|
+
### Drag Handle Pattern
|
|
14
|
+
```tsx
|
|
15
|
+
interface DraggableItemProps {
|
|
16
|
+
id: string;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function DraggableItem({ id, children }: DraggableItemProps) {
|
|
21
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id });
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div ref={setNodeRef} className={isDragging ? 'opacity-50' : ''}>
|
|
25
|
+
<button {...attributes} {...listeners} className="drag-handle cursor-grab active:cursor-grabbing p-2">
|
|
26
|
+
<GripVertical className="w-4 h-4 text-gray-400" /> {/* ⋮⋮ icon */}
|
|
27
|
+
</button>
|
|
28
|
+
<div>{children}</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Activation Constraints
|
|
35
|
+
```tsx
|
|
36
|
+
const sensors = useSensors(
|
|
37
|
+
useSensor(MouseSensor, {
|
|
38
|
+
activationConstraint: { distance: 10 } // 10px threshold prevents accidental drags
|
|
39
|
+
}),
|
|
40
|
+
useSensor(TouchSensor, {
|
|
41
|
+
activationConstraint: { delay: 250, tolerance: 5 } // Long press for touch
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 2. Drag Preview
|
|
47
|
+
|
|
48
|
+
### Ghost Element with Custom Preview
|
|
49
|
+
```tsx
|
|
50
|
+
function DragOverlayPreview({ item }: { item: Item }) {
|
|
51
|
+
return (
|
|
52
|
+
<DragOverlay>
|
|
53
|
+
{item ? (
|
|
54
|
+
<div className="bg-white border-2 border-blue-500 rounded-lg shadow-lg p-4 opacity-90">
|
|
55
|
+
{item.name}
|
|
56
|
+
{item.count > 1 && <Badge className="ml-2">{item.count}</Badge>}
|
|
57
|
+
</div>
|
|
58
|
+
) : null}
|
|
59
|
+
</DragOverlay>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Cursor & Visual States
|
|
65
|
+
```css
|
|
66
|
+
.dragging { opacity: 0.5; transform: scale(0.95); }
|
|
67
|
+
.drag-handle { cursor: grab; }
|
|
68
|
+
.drag-handle:active { cursor: grabbing; }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 3. Drop Zone Feedback
|
|
72
|
+
|
|
73
|
+
### Valid/Invalid Drop Zones
|
|
74
|
+
```tsx
|
|
75
|
+
function DropZone({ id, accepts }: DropZoneProps) {
|
|
76
|
+
const { isOver, setNodeRef } = useDroppable({ id, data: { accepts } });
|
|
77
|
+
const canDrop = useCanDrop(accepts); // Check if active item matches
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={setNodeRef}
|
|
82
|
+
className={cn(
|
|
83
|
+
'min-h-32 rounded-lg border-2 border-dashed transition-colors',
|
|
84
|
+
isOver && canDrop && 'border-green-500 bg-green-50',
|
|
85
|
+
isOver && !canDrop && 'border-red-500 bg-red-50',
|
|
86
|
+
!isOver && 'border-gray-300'
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Cursor Feedback
|
|
96
|
+
```tsx
|
|
97
|
+
const cursorClass = isOver
|
|
98
|
+
? (canDrop ? 'cursor-copy' : 'cursor-not-allowed')
|
|
99
|
+
: 'cursor-default';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 4. Drop Indicators
|
|
103
|
+
|
|
104
|
+
### Insertion Line Between Items
|
|
105
|
+
```tsx
|
|
106
|
+
function DropIndicator({ active }: { active: boolean }) {
|
|
107
|
+
if (!active) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="relative h-0.5 my-2">
|
|
111
|
+
<div className="absolute inset-0 bg-blue-500" />
|
|
112
|
+
<div className="absolute -left-1 -top-1 w-2 h-2 rounded-full bg-blue-500" />
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function SortableList({ items }: { items: Item[] }) {
|
|
118
|
+
const { over } = useDndContext();
|
|
119
|
+
|
|
120
|
+
return items.map((item, index) => (
|
|
121
|
+
<>
|
|
122
|
+
<DropIndicator active={over?.id === `gap-${index}`} />
|
|
123
|
+
<SortableItem key={item.id} id={item.id} />
|
|
124
|
+
</>
|
|
125
|
+
));
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Placeholder for Empty Zones
|
|
130
|
+
```tsx
|
|
131
|
+
{items.length === 0 && (
|
|
132
|
+
<div className="text-center py-8 text-gray-400">
|
|
133
|
+
Drop items here
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 5. Drag Constraints
|
|
139
|
+
|
|
140
|
+
### Axis Locking
|
|
141
|
+
```tsx
|
|
142
|
+
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
|
143
|
+
...transform,
|
|
144
|
+
x: 0
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
<DndContext modifiers={[restrictToVerticalAxis]}>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Containment & Boundaries
|
|
151
|
+
```tsx
|
|
152
|
+
import { restrictToWindowEdges, restrictToParentElement } from '@dnd-kit/modifiers';
|
|
153
|
+
|
|
154
|
+
<DndContext modifiers={[restrictToWindowEdges]}>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Snap to Grid
|
|
158
|
+
```tsx
|
|
159
|
+
const snapToGrid: Modifier = ({ transform }) => ({
|
|
160
|
+
x: Math.round(transform.x / 20) * 20,
|
|
161
|
+
y: Math.round(transform.y / 20) * 20
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## 6. Multi-Select Drag
|
|
166
|
+
|
|
167
|
+
### Selected Items State
|
|
168
|
+
```tsx
|
|
169
|
+
function MultiDraggable({ id, selected }: { id: string; selected: string[] }) {
|
|
170
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
171
|
+
id,
|
|
172
|
+
data: { selectedIds: selected.includes(id) ? selected : [id] }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const count = selected.length > 1 ? selected.length : null;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div ref={setNodeRef} className={selected.includes(id) ? 'ring-2 ring-blue-500' : ''}>
|
|
179
|
+
{count && <Badge className="absolute -top-2 -right-2">{count}</Badge>}
|
|
180
|
+
<button {...attributes} {...listeners}>Drag</button>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 7. Keyboard Alternative [CRITICAL]
|
|
187
|
+
|
|
188
|
+
### Accessible Reordering
|
|
189
|
+
```tsx
|
|
190
|
+
const keyboardSensor = useSensor(KeyboardSensor, {
|
|
191
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
192
|
+
keyboardCodes: {
|
|
193
|
+
start: ['Space', 'Enter'],
|
|
194
|
+
cancel: ['Escape'],
|
|
195
|
+
end: ['Space', 'Enter'],
|
|
196
|
+
up: ['ArrowUp'],
|
|
197
|
+
down: ['ArrowDown']
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Screen Reader Instructions
|
|
203
|
+
```tsx
|
|
204
|
+
<div
|
|
205
|
+
role="button"
|
|
206
|
+
tabIndex={0}
|
|
207
|
+
aria-describedby="drag-instructions"
|
|
208
|
+
{...attributes}
|
|
209
|
+
{...listeners}
|
|
210
|
+
>
|
|
211
|
+
<span id="drag-instructions" className="sr-only">
|
|
212
|
+
Press Space to pick up, Arrow keys to move, Space to drop, Escape to cancel
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## 8. Cancel & Undo
|
|
218
|
+
|
|
219
|
+
### Escape to Cancel
|
|
220
|
+
```tsx
|
|
221
|
+
function DragContext() {
|
|
222
|
+
const [items, setItems] = useState(initialItems);
|
|
223
|
+
const [snapshot, setSnapshot] = useState<Item[]>([]);
|
|
224
|
+
|
|
225
|
+
const handleDragStart = () => setSnapshot([...items]);
|
|
226
|
+
|
|
227
|
+
const handleDragCancel = () => setItems(snapshot);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<DndContext onDragStart={handleDragStart} onDragCancel={handleDragCancel}>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Undo after Drop
|
|
236
|
+
```tsx
|
|
237
|
+
const [history, setHistory] = useState<Item[][]>([]);
|
|
238
|
+
|
|
239
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
240
|
+
setHistory(prev => [...prev, items]);
|
|
241
|
+
// Apply changes
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const undo = () => {
|
|
245
|
+
if (history.length > 0) {
|
|
246
|
+
setItems(history[history.length - 1]);
|
|
247
|
+
setHistory(prev => prev.slice(0, -1));
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## 9. Cross-Container Drag
|
|
253
|
+
|
|
254
|
+
### Multi-Container Setup
|
|
255
|
+
```tsx
|
|
256
|
+
function MultiContainer() {
|
|
257
|
+
const containers = ['panel-1', 'panel-2', 'panel-3'];
|
|
258
|
+
|
|
259
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
260
|
+
const { active, over } = event;
|
|
261
|
+
if (!over) return;
|
|
262
|
+
|
|
263
|
+
const activeContainer = findContainer(active.id);
|
|
264
|
+
const overContainer = over.id;
|
|
265
|
+
|
|
266
|
+
if (activeContainer !== overContainer) {
|
|
267
|
+
moveItemBetweenContainers(active.id, activeContainer, overContainer);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<DndContext onDragEnd={handleDragEnd}>
|
|
273
|
+
{containers.map(id => (
|
|
274
|
+
<DropZone key={id} id={id} />
|
|
275
|
+
))}
|
|
276
|
+
</DndContext>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## 10. Touch Support
|
|
282
|
+
|
|
283
|
+
### Touch Configuration
|
|
284
|
+
```tsx
|
|
285
|
+
const touchSensor = useSensor(TouchSensor, {
|
|
286
|
+
activationConstraint: {
|
|
287
|
+
delay: 250, // Long press 250ms
|
|
288
|
+
tolerance: 5 // Allow 5px movement during press
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Touch Feedback
|
|
294
|
+
```tsx
|
|
295
|
+
// Add haptic feedback on touch devices
|
|
296
|
+
const handleDragStart = () => {
|
|
297
|
+
if ('vibrate' in navigator) {
|
|
298
|
+
navigator.vibrate(50);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## 11. Code Examples
|
|
304
|
+
|
|
305
|
+
### Complete useDragDrop Hook
|
|
306
|
+
```tsx
|
|
307
|
+
interface UseDragDropOptions {
|
|
308
|
+
items: Item[];
|
|
309
|
+
onReorder: (items: Item[]) => void;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function useDragDrop({ items, onReorder }: UseDragDropOptions) {
|
|
313
|
+
const sensors = useSensors(
|
|
314
|
+
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
|
|
315
|
+
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
|
|
316
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
320
|
+
|
|
321
|
+
const handleDragStart = (event: DragStartEvent) => setActiveId(event.active.id);
|
|
322
|
+
|
|
323
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
324
|
+
const { active, over } = event;
|
|
325
|
+
if (over && active.id !== over.id) {
|
|
326
|
+
const oldIndex = items.findIndex(item => item.id === active.id);
|
|
327
|
+
const newIndex = items.findIndex(item => item.id === over.id);
|
|
328
|
+
onReorder(arrayMove(items, oldIndex, newIndex));
|
|
329
|
+
}
|
|
330
|
+
setActiveId(null);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return { sensors, activeId, handleDragStart, handleDragEnd };
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### TypeScript Interfaces
|
|
338
|
+
```tsx
|
|
339
|
+
interface DragItem {
|
|
340
|
+
id: string;
|
|
341
|
+
type: 'equipment' | 'weapon' | 'module';
|
|
342
|
+
data: unknown;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface DropZoneProps {
|
|
346
|
+
id: string;
|
|
347
|
+
accepts: DragItem['type'][];
|
|
348
|
+
children: React.ReactNode;
|
|
349
|
+
onDrop?: (item: DragItem) => void;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
interface DragEndEvent {
|
|
353
|
+
active: { id: string; data: { current: DragItem } };
|
|
354
|
+
over: { id: string; data: { current: { accepts: string[] } } } | null;
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## 12. Audit Checklist
|
|
359
|
+
|
|
360
|
+
### Drag Initiation
|
|
361
|
+
- [ ] [MAJOR] Drag handle is clearly visible (icon present)
|
|
362
|
+
- [ ] [MAJOR] Activation threshold prevents accidental drags (10px minimum)
|
|
363
|
+
- [ ] [MINOR] Cursor changes to `grab` on hover, `grabbing` on drag
|
|
364
|
+
- [ ] [MAJOR] Touch requires long-press (250ms minimum)
|
|
365
|
+
|
|
366
|
+
### Visual Feedback
|
|
367
|
+
- [ ] [CRITICAL] Drag preview shows what's being dragged
|
|
368
|
+
- [ ] [MAJOR] Original item has reduced opacity during drag (0.5 or less)
|
|
369
|
+
- [ ] [MAJOR] Multi-select shows count badge
|
|
370
|
+
- [ ] [MINOR] Smooth transitions on drag start/end
|
|
371
|
+
|
|
372
|
+
### Drop Zones
|
|
373
|
+
- [ ] [CRITICAL] Valid drop zones highlighted (border/background change)
|
|
374
|
+
- [ ] [CRITICAL] Invalid drop zones show "not-allowed" cursor
|
|
375
|
+
- [ ] [MAJOR] Drop indicator shows insertion point (horizontal line)
|
|
376
|
+
- [ ] [MINOR] Empty zones show placeholder text
|
|
377
|
+
|
|
378
|
+
### Keyboard Accessibility
|
|
379
|
+
- [ ] [CRITICAL] Space/Enter to pick up and drop
|
|
380
|
+
- [ ] [CRITICAL] Arrow keys to move
|
|
381
|
+
- [ ] [CRITICAL] Escape to cancel
|
|
382
|
+
- [ ] [CRITICAL] Screen reader instructions provided (aria-describedby)
|
|
383
|
+
- [ ] [MAJOR] Focus visible during keyboard drag
|
|
384
|
+
|
|
385
|
+
### Cross-Container
|
|
386
|
+
- [ ] [MAJOR] Items can move between containers
|
|
387
|
+
- [ ] [MAJOR] Invalid containers reject drops
|
|
388
|
+
- [ ] [MINOR] Visual feedback when hovering over target container
|
|
389
|
+
|
|
390
|
+
### Touch Support
|
|
391
|
+
- [ ] [MAJOR] Long-press activates drag on touch (250ms)
|
|
392
|
+
- [ ] [MINOR] Haptic feedback on drag start (if supported)
|
|
393
|
+
- [ ] [MAJOR] Touch tolerance prevents scroll conflict (5px)
|
|
394
|
+
|
|
395
|
+
### Cancel & Error Recovery
|
|
396
|
+
- [ ] [MAJOR] Escape cancels drag and restores position
|
|
397
|
+
- [ ] [MINOR] Undo available after drop
|
|
398
|
+
- [ ] [MAJOR] Invalid drops don't corrupt state
|
|
399
|
+
|
|
400
|
+
### Performance
|
|
401
|
+
- [ ] [MINOR] Smooth 60fps drag animation
|
|
402
|
+
- [ ] [MINOR] Large lists use virtualization if >100 items
|
|
403
|
+
- [ ] [MINOR] Preview uses lightweight component
|
|
404
|
+
|
|
405
|
+
**Critical Path**: Keyboard accessibility + valid/invalid drop zones + drag preview
|
|
406
|
+
**Total Severity Score**: 9 Critical, 11 Major, 8 Minor
|