Реализация Drag-and-Drop API на сайте
Нативный Drag and Drop API браузера — не самый приятный в работе интерфейс. Порядок событий неочевидный, dataTransfer ведёт себя по-разному в разных браузерах, а dragover нужно дефолтно предотвращать, иначе drop не сработает. Но он встроен в браузер, поддерживает перетаскивание файлов из OS, работает без библиотек и не нагружает bundle.
Для сложных сортируемых списков с touch-поддержкой и анимациями — лучше смотреть в сторону @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)
})
}
Загрузка файлов через drag
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-хук для drag-and-drop
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 }
}
Touch-устройства
Нативный DnD на iOS не работает на большинстве элементов. Для touch-поддержки нужен полифил (drag-touch) или библиотека @dnd-kit/core, которая использует Pointer Events API и работает на всех устройствах. Выбор зависит от требований проекта.
Что входит в работу
Реализация перетаскиваемых элементов и зон сброса, сортируемый список (если нужно), загрузка файлов через drag с фильтрацией по типу, React-хуки, CSS-стили для drag-состояний, решение вопроса touch-поддержки.
Срок: 1–2 дня в зависимости от сложности сценариев и необходимости touch-поддержки.







