Впровадження Drag-and-Drop API на веб-сайті
Нативний Drag and Drop API браузера — не найприємніший інтерфейс для роботи. Порядок подій неочевидний, dataTransfer поводиться по-різному в різних браузерах, а dragover потребує попередження за замовчуванням, інакше drop не спрацює. Однак він вбудований у браузер, підтримує перетягування файлів з ОС, працює без бібліотек і не навантажує bundle.
Для складних сортованих списків з підтримкою дотику та анімацій — варто поглянути на @dnd-kit/core. Для завантаження файлів і базового перетягування — нативного API достатньо.
Основні事件
Порядок подій при перетягуванні:
dragstart → dragenter → dragover (кожні ~50ms) → drop → dragend
На елементі-джерелі: dragstart, drag, dragend.
На елементі-цілі: dragenter, dragover, dragleave, drop.
Перетягуваний елемент
function makeDraggable(element: HTMLElement, data: Record<string, string>): void {
element.setAttribute('draggable', 'true')
element.addEventListener('dragstart', (event: DragEvent) => {
if (!event.dataTransfer) return
// Встановлюємо дані для передачі
for (const [type, value] of Object.entries(data)) {
event.dataTransfer.setData(type, value)
}
// Тип операції: copy | move | link
event.dataTransfer.effectAllowed = 'move'
// Користувацький drag-ghost
const ghost = element.cloneNode(true) as HTMLElement
ghost.style.cssText = 'position:absolute;top:-9999px;opacity:0.8'
document.body.appendChild(ghost)
event.dataTransfer.setDragImage(ghost, 0, 0)
setTimeout(() => document.body.removeChild(ghost), 0)
element.classList.add('is-dragging')
})
element.addEventListener('dragend', () => {
element.classList.remove('is-dragging')
})
}
Зона скидання
function makeDropZone(
zone: HTMLElement,
onDrop: (data: string, event: DragEvent) => void,
acceptType = 'text/plain'
): void {
// Без preventDefault() тут drop не спрацює
zone.addEventListener('dragover', (event: DragEvent) => {
if (!event.dataTransfer?.types.includes(acceptType)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
zone.classList.add('drop-zone--active')
})
zone.addEventListener('dragleave', (event: DragEvent) => {
// Перевіримо, що курсор дійсно покинув зону (не увійшов у дочірній елемент)
if (!zone.contains(event.relatedTarget as Node)) {
zone.classList.remove('drop-zone--active')
}
})
zone.addEventListener('drop', (event: DragEvent) => {
event.preventDefault()
zone.classList.remove('drop-zone--active')
const data = event.dataTransfer?.getData(acceptType)
if (data) onDrop(data, event)
})
}
Завантаження файлів через перетягування
function makeFileDropZone(
zone: HTMLElement,
onFiles: (files: FileList) => void,
accept?: string[]
): void {
zone.addEventListener('dragover', (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
zone.classList.add('drop-zone--active')
})
zone.addEventListener('dragleave', (event: DragEvent) => {
if (!zone.contains(event.relatedTarget as Node)) {
zone.classList.remove('drop-zone--active')
}
})
zone.addEventListener('drop', (event: DragEvent) => {
event.preventDefault()
zone.classList.remove('drop-zone--active')
const files = event.dataTransfer?.files
if (!files?.length) return
if (accept) {
const filtered = Array.from(files).filter((f) =>
accept.some((type) =>
type.startsWith('.') ? f.name.endsWith(type) : f.type.startsWith(type.replace('*', ''))
)
)
if (!filtered.length) return
const dt = new DataTransfer()
filtered.forEach((f) => dt.items.add(f))
onFiles(dt.files)
} else {
onFiles(files)
}
})
}
Сортуваний список
Класичний паттерн — перетягування карток для змінення порядку:
interface SortableItem {
id: string
element: HTMLElement
}
class SortableList {
private items: SortableItem[] = []
private draggedId: string | null = null
constructor(
private container: HTMLElement,
private onChange: (ids: string[]) => void
) {}
add(id: string, element: HTMLElement): void {
element.setAttribute('draggable', 'true')
element.dataset.id = id
element.addEventListener('dragstart', (e: DragEvent) => {
this.draggedId = id
e.dataTransfer!.setData('text/plain', id)
e.dataTransfer!.effectAllowed = 'move'
element.classList.add('sortable--dragging')
})
element.addEventListener('dragend', () => {
element.classList.remove('sortable--dragging')
this.draggedId = null
this.container.querySelectorAll('.sortable--over').forEach((el) =>
el.classList.remove('sortable--over')
)
})
element.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault()
if (this.draggedId === id) return
element.classList.add('sortable--over')
// Вставити перетягуваний елемент перед поточним
const draggedEl = this.container.querySelector(`[data-id="${this.draggedId}"]`)
if (draggedEl && draggedEl !== element) {
const rect = element.getBoundingClientRect()
const insertBefore = e.clientY < rect.top + rect.height / 2
element.parentNode?.insertBefore(
draggedEl,
insertBefore ? element : element.nextSibling
)
}
})
element.addEventListener('dragleave', () => {
element.classList.remove('sortable--over')
})
element.addEventListener('drop', (e: DragEvent) => {
e.preventDefault()
element.classList.remove('sortable--over')
// Порядок уже оновлено у dragover, тут повідомляємо зовні
const newOrder = Array.from(
this.container.querySelectorAll('[data-id]')
).map((el) => (el as HTMLElement).dataset.id!)
this.onChange(newOrder)
})
this.items.push({ id, element })
this.container.appendChild(element)
}
}
React Hook для перетягування та скидання
function useDraggable(id: string) {
const [isDragging, setIsDragging] = useState(false)
const dragHandlers = {
draggable: true as const,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', id)
e.dataTransfer.effectAllowed = 'move'
setIsDragging(true)
},
onDragEnd: () => setIsDragging(false),
}
return { isDragging, dragHandlers }
}
function useDroppable(onDrop: (id: string) => void) {
const [isOver, setIsOver] = useState(false)
const dropHandlers = {
onDragOver: (e: React.DragEvent) => {
e.preventDefault()
setIsOver(true)
},
onDragLeave: (e: React.DragEvent) => {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
setIsOver(false)
}
},
onDrop: (e: React.DragEvent) => {
e.preventDefault()
setIsOver(false)
const id = e.dataTransfer.getData('text/plain')
if (id) onDrop(id)
},
}
return { isOver, dropHandlers }
}
Сенсорні пристрої
Нативний DnD на iOS не працює на більшості елементів. Для підтримки дотику потрібен поліфіл (drag-touch) або бібліотека @dnd-kit/core, яка використовує Pointer Events API і працює на всіх пристроях. Вибір залежить від вимог проекту.
Що включено
Впровадження перетягуваних елементів і зон скидання, сортуваний список (якщо потрібно), завантаження файлів через перетягування з фільтруванням за типом, React хуки, CSS стилі для станів перетягування, розв'язання питання підтримки дотику.
Терміни: 1–2 дні залежно від складності сценаріїв та необхідності підтримки дотику.







