# Accessibility for Drag-and-Drop ## Table of Contents - [Core Principles](#core-principles) - [Keyboard Navigation](#keyboard-navigation) - [Screen Reader Support](#screen-reader-support) - [Alternative UI Patterns](#alternative-ui-patterns) - [ARIA Patterns](#aria-patterns) - [Testing Guidelines](#testing-guidelines) ## Core Principles ### The Accessibility Challenge Drag-and-drop is inherently visual and mouse-centric, creating barriers for: - Keyboard-only users - Screen reader users - Users with motor disabilities - Touch device users with assistive tech ### Universal Design Approach **Always provide:** 1. Full keyboard navigation 2. Clear announcements for screen readers 3. Alternative UI methods (buttons, forms) 4. Visual feedback for all states 5. Sufficient time for interactions ## Keyboard Navigation ### Standard Key Mappings ```tsx // Recommended keyboard scheme for dnd-kit const keyboardCoordinates = { start: ['Space', 'Enter'], // Pick up item cancel: ['Escape'], // Cancel drag end: ['Space', 'Enter'], // Drop item up: ['ArrowUp', 'w', 'W'], // Move up down: ['ArrowDown', 's', 'S'], // Move down left: ['ArrowLeft', 'a', 'A'], // Move left right: ['ArrowRight', 'd', 'D'], // Move right }; ``` ### Implementation with dnd-kit ```tsx import { KeyboardSensor, useSensor } from '@dnd-kit/core'; import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; function AccessibleDragDrop() { const keyboardSensor = useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }); return ( {/* Draggable content */} ); } ``` ### Custom Keyboard Navigation ```tsx // Enhanced keyboard handler with visual feedback function useKeyboardDragDrop(items, onReorder) { const [activeIndex, setActiveIndex] = useState(-1); const [isGrabbed, setIsGrabbed] = useState(false); const handleKeyDown = (e: KeyboardEvent) => { if (!items.length) return; switch (e.key) { case 'Tab': // Allow normal tab navigation when not dragging if (!isGrabbed) return; e.preventDefault(); break; case ' ': case 'Enter': e.preventDefault(); if (activeIndex === -1) { setActiveIndex(0); } else if (!isGrabbed) { // Pick up item setIsGrabbed(true); announceToScreenReader(`Grabbed ${items[activeIndex].label}. Use arrow keys to move.`); } else { // Drop item setIsGrabbed(false); announceToScreenReader(`Dropped ${items[activeIndex].label} at position ${activeIndex + 1}.`); } break; case 'Escape': if (isGrabbed) { e.preventDefault(); setIsGrabbed(false); announceToScreenReader('Drag cancelled.'); } break; case 'ArrowUp': case 'ArrowDown': e.preventDefault(); if (isGrabbed) { const newIndex = e.key === 'ArrowUp' ? Math.max(0, activeIndex - 1) : Math.min(items.length - 1, activeIndex + 1); if (newIndex !== activeIndex) { onReorder(arrayMove(items, activeIndex, newIndex)); setActiveIndex(newIndex); announceToScreenReader(`Moved to position ${newIndex + 1} of ${items.length}.`); } } else { // Navigate without dragging const newIndex = e.key === 'ArrowUp' ? Math.max(0, activeIndex - 1) : Math.min(items.length - 1, activeIndex + 1); setActiveIndex(newIndex); } break; } }; return { activeIndex, isGrabbed, handleKeyDown }; } ``` ## Screen Reader Support ### Live Region Announcements ```tsx // Announcement component for screen readers function DragDropAnnouncements({ children }) { return ( <> {children} {/* Live region for announcements */}
); } // Helper function to announce function announceToScreenReader(message: string) { const element = document.getElementById('dnd-announcements'); if (element) { element.textContent = message; // Clear after announcement setTimeout(() => { element.textContent = ''; }, 1000); } } ``` ### dnd-kit Accessibility Features ```tsx import { Announcements } from '@dnd-kit/core'; // Custom announcement messages const announcements: Announcements = { onDragStart(id) { return `Picked up draggable item ${id}. Press arrow keys to move, space to drop, escape to cancel.`; }, onDragOver(id, overId) { if (overId) { return `Draggable item ${id} is over droppable area ${overId}.`; } return `Draggable item ${id} is no longer over a droppable area.`; }, onDragEnd(id, overId) { if (overId) { return `Draggable item ${id} was dropped over droppable area ${overId}.`; } return `Draggable item ${id} was dropped.`; }, onDragCancel(id) { return `Dragging was cancelled. Draggable item ${id} was dropped.`; }, }; // Use in DndContext {/* Content */} ``` ## Alternative UI Patterns ### Move Buttons Pattern Provide explicit move buttons as an alternative to drag-and-drop. ```tsx function AccessibleListItem({ item, index, onMove, totalItems }) { return (
{item.content}
{/* Alternative controls */}
); } ``` ### Context Menu Pattern Right-click or long-press menu for reordering. ```tsx function ContextMenuReorder({ item, onAction }) { const [menuOpen, setMenuOpen] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); setMenuPosition({ x: e.clientX, y: e.clientY }); setMenuOpen(true); }; return ( <>
{item.content}
{menuOpen && (
)} ); } ``` ## ARIA Patterns ### Essential ARIA Attributes ```tsx // Draggable item
{item.content}
// Drag instructions (hidden but available to screen readers)
Press space or enter to start dragging. Use arrow keys to move the item. Press space or enter again to drop. Press escape to cancel.
``` ### Drop Zone ARIA ```tsx function DropZone({ isActive, canDrop, children }) { return (
{children} Drop zone active. Release to drop here. Drop zone available.
); } ``` ### List Reordering Pattern ```tsx // Accessible sortable list
{items.map((item, index) => (
{/* Item content */}
))}
This is a reorderable list. Press Tab to focus an item, then press Space to grab it. Use arrow keys to move the item. Press Space again to drop it.
``` ## Testing Guidelines ### Manual Testing Checklist ```markdown ## Keyboard Navigation - [ ] Can reach all draggable items with Tab key - [ ] Can initiate drag with Space/Enter - [ ] Can move items with arrow keys - [ ] Can cancel drag with Escape - [ ] Can drop items with Space/Enter - [ ] Focus visible at all times - [ ] No keyboard traps ## Screen Reader Testing - [ ] All items have descriptive labels - [ ] Drag start announced - [ ] Movement announced with position - [ ] Drop location announced - [ ] Instructions available - [ ] Live region updates work ## Alternative UI - [ ] Move buttons functional - [ ] Position selector works - [ ] Context menu accessible - [ ] All alternatives keyboard accessible ``` ### Automated Testing ```tsx // Jest + Testing Library example import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; test('drag and drop is keyboard accessible', async () => { const user = userEvent.setup(); render(); // Focus first item const firstItem = screen.getByRole('button', { name: /item 1/i }); await user.tab(); expect(firstItem).toHaveFocus(); // Start drag await user.keyboard(' '); expect(firstItem).toHaveAttribute('aria-grabbed', 'true'); // Move down await user.keyboard('{ArrowDown}'); // Drop await user.keyboard(' '); expect(firstItem).toHaveAttribute('aria-grabbed', 'false'); // Verify announcement expect(screen.getByRole('status')).toHaveTextContent(/dropped/i); }); ``` ### Screen Reader Testing Tools **Windows:** - NVDA (free, recommended for testing) - JAWS (commercial, widely used) **macOS:** - VoiceOver (built-in, Cmd+F5) **Linux:** - Orca (free, GNOME) **Browser Extensions:** - ChromeVox (Chrome) - Screen Reader Simulator ### Testing Script ```bash #!/bin/bash # accessibility-test.sh echo "🔍 Testing Drag-and-Drop Accessibility" # Run automated tests npm test -- --coverage drag-drop.test # Check ARIA attributes echo "Checking ARIA attributes..." grep -r "aria-" ./src/components/drag-drop/ # Validate keyboard handlers echo "Checking keyboard support..." grep -r "onKeyDown\|handleKey" ./src/components/drag-drop/ # Check for alternative UI echo "Checking alternative UI..." grep -r "button.*move\|Move.*button" ./src/components/drag-drop/ echo "✅ Accessibility check complete" ``` ## Best Practices Summary ### Do's - ✅ Provide full keyboard navigation - ✅ Include clear screen reader announcements - ✅ Offer alternative UI methods - ✅ Test with actual assistive technology - ✅ Document keyboard shortcuts - ✅ Use semantic HTML and ARIA correctly ### Don'ts - ❌ Rely solely on drag-and-drop - ❌ Hide important functionality behind drag - ❌ Forget escape key handling - ❌ Ignore focus management - ❌ Skip screen reader testing - ❌ Use placeholder text as labels ### Quick Implementation Checklist ```tsx // Minimum viable accessible drag-and-drop const AccessibleDragDrop = () => { // ✅ 1. Keyboard sensor const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor) ); // ✅ 2. Announcements const announcements = { onDragStart: (id) => `Picked up ${id}`, onDragEnd: (id, overId) => `Dropped ${id} at ${overId}`, }; // ✅ 3. ARIA attributes const ariaAttributes = { role: 'button', tabIndex: 0, 'aria-roledescription': 'sortable', 'aria-grabbed': isDragging, }; // ✅ 4. Alternative UI const alternativeControls = ( ); // ✅ 5. Instructions const instructions = (
Press space to grab, arrows to move, space to drop
); return ( {instructions} {/* Draggable content with ARIA */} {alternativeControls} ); }; ```