Пошук уроків, статей та іншого контенту
Реалізуєте перетягування елементів і обмін даними за допомогою подій Drag and Drop API.
Drag and Drop API дає змогу реагувати на перетягування елементів мишею або трекпадом:
перетягувати елементи всередині сторінки;
обмінюватися даними між джерелом і цільовою областю;
приймати файли, перетягнуті з файлового менеджера;
визначати дозволену операцію: переміщення, копіювання або створення посилання.
API базується на подіях і об’єкті DataTransfer. Джерело перетягування записує дані в dragstart, а цільова область читає їх у drop.
Для HTML-елемента, який можна перетягувати, потрібно встановити атрибут:
<div draggable="true">Перетягніть мене</div>Основні події:
dragstart — перетягування почалося.
drag — елемент переміщується.
dragenter — курсор увійшов у цільову область.
dragover — курсор рухається всередині цільової області.
dragleave — курсор залишив цільову область.
drop — елемент відпустили.
dragend — перетягування завершилося незалежно від результату.
Найважливіша деталь: щоб область приймала перетягування, у її обробнику dragover потрібно викликати event.preventDefault().
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
});Без цього браузер зазвичай не викличе drop.
DataTransferДані передаються через event.dataTransfer.
Записувати дані потрібно в dragstart:
source.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', source.dataset.id);
event.dataTransfer.effectAllowed = 'move';
});Метод setData() приймає:
MIME-тип або довільний тип даних;
значення у вигляді рядка.
Можна передавати кілька форматів одночасно:
event.dataTransfer.setData('text/plain', taskId);
event.dataTransfer.setData(
'application/x-task+json',
JSON.stringify(task)
);text/plain корисний як запасний формат. Наприклад, деякі браузери або зовнішні програми очікують саме його.
Читати дані слід у drop:
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
const taskId = event.dataTransfer.getData('text/plain');
const rawTask = event.dataTransfer.getData('application/x-task+json');
console.log(taskId, rawTask);
});Вміст DataTransfer потрібно перевіряти. Дані можуть надійти не лише з вашого JavaScript-коду, а й із зовнішньої програми або іншої вкладки.
effectAllowed і dropEffectЦі властивості описують тип операції.
На джерелі задається набір дозволених операцій:
event.dataTransfer.effectAllowed = 'copyMove';Можливі значення:
none;
copy;
move;
link;
copyMove;
copyLink;
linkMove;
all.
На цільовій області можна вибрати конкретний ефект:
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
});dropEffect впливає на курсор і повідомляє користувачеві, що станеться після відпускання елемента. Це не виконує переміщення автоматично — DOM потрібно змінити самостійно.
У прикладі нижче:
картки завдань можна перетягувати між колонками;
у DataTransfer передається JSON;
для сумісності додатково передається ідентифікатор у text/plain;
цільові колонки підсвічуються;
враховується вкладеність елементів під час dragenter і dragleave;
додано базову клавіатурну альтернативу;
дані перевіряються перед обробкою.
Збережіть код у файлі index.html і відкрийте його в браузері.
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drag and Drop дошка</title>
<style>
:root {
font-family: system-ui, sans-serif;
color: #172033;
background: #f3f5f9;
}
body {
max-width: 1100px;
margin: 0 auto;
padding: 32px 16px;
}
h1 {
margin-top: 0;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.column {
min-height: 260px;
padding: 16px;
border: 2px dashed #b8c1d1;
border-radius: 12px;
background: #ffffff;
transition: border-color 150ms, background 150ms;
}
.column.is-over {
border-color: #3867d6;
background: #edf3ff;
}
.column h2 {
margin: 0 0 12px;
font-size: 1.1rem;
}
.task-list {
display: grid;
gap: 10px;
min-height: 180px;
}
.task {
display: block;
width: 100%;
padding: 12px;
border: 1px solid #d8deea;
border-radius: 8px;
background: #fff;
color: inherit;
text-align: left;
cursor: grab;
box-shadow: 0 2px 5px rgb(23 32 51 / 8%);
}
.task:hover,
.task:focus-visible {
border-color: #3867d6;
outline: 3px solid rgb(56 103 214 / 25%);
outline-offset: 2px;
}
.task:active {
cursor: grabbing;
}
.task.is-dragging {
opacity: 0.45;
}
.task-title {
display: block;
font-weight: 700;
}
.task-description {
display: block;
margin-top: 4px;
color: #5e687b;
font-size: 0.9rem;
}
.hint {
margin-top: 20px;
color: #5e687b;
}
.sr-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 700px) {
.board {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<h1>Дошка завдань</h1>
<div class="board">
<section
class="column"
data-status="backlog"
aria-labelledby="backlog-title"
>
<h2 id="backlog-title">Заплановано</h2>
<div class="task-list" role="list">
<div
class="task"
role="button"
tabindex="0"
draggable="true"
data-id="task-1"
data-title="Дослідити API"
aria-pressed="false"
>
<span class="task-title">Дослідити API</span>
<span class="task-description">Порівняти доступні варіанти</span>
</div>
<div
class="task"
role="button"
tabindex="0"
draggable="true"
data-id="task-2"
data-title="Підготувати макет"
aria-pressed="false"
>
<span class="task-title">Підготувати макет</span>
<span class="task-description">Створити структуру сторінки</span>
</div>
</div>
</section>
<section
class="column"
data-status="progress"
aria-labelledby="progress-title"
>
<h2 id="progress-title">У роботі</h2>
<div class="task-list" role="list">
<div
class="task"
role="button"
tabindex="0"
draggable="true"
data-id="task-3"
data-title="Написати тести"
aria-pressed="false"
>
<span class="task-title">Написати тести</span>
<span class="task-description">Перевірити основні сценарії</span>
</div>
</div>
</section>
<section
class="column"
data-status="done"
aria-labelledby="done-title"
>
<h2 id="done-title">Готово</h2>
<div class="task-list" role="list"></div>
</section>
</div>
<p class="hint">
Перетягніть картку мишею або виберіть її клавішею Space і переміщуйте
клавішами ← та →.
</p>
<p class="sr-status" aria-live="polite"></p>
<script>
const columns = [...document.querySelectorAll('.column')];
const tasks = [...document.querySelectorAll('.task')];
const statusOutput = document.querySelector('.sr-status');
const taskDataType = 'application/x-task+json';
// Лічильник потрібен, бо dragenter/dragleave спрацьовують і для дочірніх елементів.
const dragDepth = new Map(columns.map((column) => [column, 0]));
function resetColumnState() {
for (const column of columns) {
dragDepth.set(column, 0);
column.classList.remove('is-over');
}
}
function getColumnName(column) {
return column.querySelector('h2').textContent.trim();
}
function moveTask(task, targetColumn) {
const taskList = targetColumn.querySelector('.task-list');
taskList.append(task);
const taskTitle = task.dataset.title;
const columnName = getColumnName(targetColumn);
statusOutput.textContent =
`Завдання «${taskTitle}» переміщено до колонки «${columnName}».`;
}
for (const task of tasks) {
task.addEventListener('dragstart', (event) => {
const payload = {
id: task.dataset.id,
title: task.dataset.title,
sourceStatus: task.closest('.column').dataset.status
};
// Передаємо структуровані дані для нашого застосунку.
event.dataTransfer.setData(taskDataType, JSON.stringify(payload));
// Текстовий формат є запасним варіантом для інших споживачів даних.
event.dataTransfer.setData('text/plain', payload.id);
event.dataTransfer.effectAllowed = 'move';
task.classList.add('is-dragging');
});
task.addEventListener('dragend', () => {
task.classList.remove('is-dragging');
resetColumnState();
});
// Клавіатурна альтернатива для пристроїв, де native DnD недоступний.
task.addEventListener('keydown', (event) => {
if (event.key === ' ') {
event.preventDefault();
const isGrabbed = task.getAttribute('aria-pressed') === 'true';
task.setAttribute('aria-pressed', String(!isGrabbed));
statusOutput.textContent = isGrabbed
? `Завдання «${task.dataset.title}» відпущено.`
: `Завдання «${task.dataset.title}» вибрано. Використовуйте ← або →.`;
}
if (
(event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
task.getAttribute('aria-pressed') === 'true'
) {
event.preventDefault();
const currentColumn = task.closest('.column');
const currentIndex = columns.indexOf(currentColumn);
const direction = event.key === 'ArrowRight' ? 1 : -1;
const nextIndex = currentIndex + direction;
if (columns[nextIndex]) {
moveTask(task, columns[nextIndex]);
task.focus();
}
}
});
}
for (const column of columns) {
column.addEventListener('dragenter', (event) => {
event.preventDefault();
const depth = dragDepth.get(column) + 1;
dragDepth.set(column, depth);
column.classList.add('is-over');
});
column.addEventListener('dragover', (event) => {
// Без preventDefault браузер не дозволить виконати drop.
event.preventDefault();
// Джерело дозволило переміщення, тому показуємо відповідний ефект.
event.dataTransfer.dropEffect = 'move';
});
column.addEventListener('dragleave', () => {
const depth = Math.max(0, dragDepth.get(column) - 1);
dragDepth.set(column, depth);
if (depth === 0) {
column.classList.remove('is-over');
}
});
column.addEventListener('drop', (event) => {
event.preventDefault();
const rawPayload = event.dataTransfer.getData(taskDataType);
const fallbackId = event.dataTransfer.getData('text/plain');
let payload = null;
try {
payload = rawPayload ? JSON.parse(rawPayload) : null;
} catch {
// Некоректний JSON не повинен ламати обробку drop.
payload = null;
}
const taskId = payload?.id || fallbackId;
// Не довіряємо зовнішнім даним і шукаємо реальний елемент у DOM.
const task = [...document.querySelectorAll('.task')]
.find((item) => item.dataset.id === taskId);
if (!task) {
resetColumnState();
return;
}
moveTask(task, column);
task.setAttribute('aria-pressed', 'false');
resetColumnState();
});
}
</script>
</body>
</html>dragenter і dragleave можуть спрацьовувати не лише для самої цільової області, а й для її дочірніх елементів.
Наприклад, якщо колонка містить заголовок і картки, під час руху курсора між ними можуть відбуватися такі події:
dragenter для колонки;
dragenter для заголовка;
dragleave для заголовка;
dragenter для картки;
dragleave для картки;
dragleave для колонки.
Якщо просто додавати і прибирати CSS-клас у цих подіях, підсвічування може зникати, хоча курсор усе ще перебуває в колонці.
Для розв’язання проблеми використовують:
лічильник входів і виходів;
перевірку event.relatedTarget;
окремий елемент-обгортку, що покриває всю зону.
У прикладі використано лічильник dragDepth.
Браузер може передати файли з операційної системи через dataTransfer.files.
const fileZone = document.querySelector('#file-zone');
fileZone.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
fileZone.addEventListener('drop', (event) => {
event.preventDefault();
const files = [...event.dataTransfer.files];
for (const file of files) {
if (!file.type.startsWith('image/')) {
continue;
}
console.log(file.name, file.size, file.type);
}
});Об’єкт File містить:
name — ім’я файлу;
size — розмір у байтах;
type — MIME-тип;
lastModified — час останньої зміни.
Ніколи не покладайтеся лише на розширення файлу. Перевіряйте тип і розмір на клієнті для зручності користувача, але остаточну перевірку потрібно виконувати також на сервері.
Дані можуть надійти з різних джерел:
іншого елемента цієї сторінки;
іншої вкладки браузера;
файлового менеджера;
іншої програми;
поштового клієнта.
Тому в drop не можна безумовно очікувати власний JSON-формат:
const types = event.dataTransfer.types;
if (types.includes('application/x-task+json')) {
// Дані з нашого застосунку.
} else if (event.dataTransfer.files.length > 0) {
// Перетягнуті файли.
} else {
// Інший або непідтримуваний формат.
}Також не варто вставляти отриманий текст через innerHTML. Якщо значення походить із DataTransfer, використовуйте textContent або перевіряйте й очищуйте HTML за допомогою спеціалізованого підходу.
Native Drag and Drop API історично орієнтований переважно на вказівні пристрої. Перетягування мишею не є достатньою альтернативою для:
користувачів клавіатури;
програм читання з екрана;
мобільних пристроїв і сенсорних екранів.
Для доступного інтерфейсу потрібно передбачити альтернативну дію:
кнопки «Перемістити»;
меню вибору цільової колонки;
клавіатурний режим вибору й переміщення;
зрозумілі повідомлення через aria-live.
У прикладі картка отримує фокус через tabindex="0", клавіша Space вибирає її, а стрілки переміщують між колонками. Атрибут aria-pressed повідомляє стан вибраної картки.
Для складніших компонентів варто також:
показувати видимий фокус;
не покладатися лише на колір підсвічування;
повідомляти результат операції текстом;
дозволяти скасувати дію;
тестувати сценарій без миші.
Поведінка HTML Drag and Drop API на сенсорних пристроях відрізняється між браузерами. На частині мобільних платформ події dragstart, dragover і drop не забезпечують повноцінний досвід.
Якщо потрібне однакове перетягування на комп’ютерах і телефонах, зазвичай реалізують власний механізм на основі:
pointerdown;
pointermove;
pointerup;
setPointerCapture();
тимчасового візуального клона елемента.
Такий підхід складніший: потрібно самостійно визначати цільову область, координати, автопрокручування, скасування та доступність. Native Drag and Drop краще використовувати там, де достатньо підтримки десктопних браузерів або де передбачено окрему альтернативу.
preventDefault() у dragoverdropZone.addEventListener('dragover', (event) => {
event.preventDefault();
});Без цього drop найчастіше не відбудеться.
Не покладайтеся на getData() у dragover. Отримання даних потрібно виконувати в drop.
setData() приймає рядок, тому об’єкт потрібно серіалізувати:
event.dataTransfer.setData(
'application/json',
JSON.stringify({ id: 10, title: 'Завдання' })
);На іншому боці:
const value = JSON.parse(
event.dataTransfer.getData('application/json')
);JSON потрібно обробляти в try...catch, оскільки вхідні дані можуть бути пошкодженими.
dragoverdragover спрацьовує дуже часто. Не переміщуйте елемент і не виконуйте важкі операції на кожен виклик цієї події. У dragover зазвичай достатньо:
викликати preventDefault();
встановити dropEffect;
оновити мінімальний стан підсвічування.
Фактичну зміну DOM виконуйте в drop.
dragleave без урахування дочірніх елементівЧерез це цільова зона може миготіти або передчасно втрачати клас підсвічування. Використовуйте лічильник або перевіряйте межі області.
Ідентифікатор із DataTransfer — це лише вхідні дані. Перед зміною DOM перевірте:
чи має значення очікуваний формат;
чи існує відповідний елемент;
чи дозволено переміщувати його до цієї зони;
чи не належить він іншому користувачеві або контексту.
dragendЯкщо перетягування скасували або завершили за межами прийнятної області, drop може не викликатися. Очищуйте тимчасові CSS-класи та інший стан у dragend.
Для запуску перетягування елементу потрібен draggable="true".
Дані записують у dragstart через dataTransfer.setData().
Дані читають у drop через dataTransfer.getData().
Для дозволу операції drop потрібно викликати preventDefault() у dragover.
effectAllowed задає можливі операції джерела, а dropEffect — операцію для конкретної цілі.
dragenter і dragleave можуть спрацьовувати для дочірніх елементів, тому часто потрібен лічильник входів.
Дані з DataTransfer потрібно перевіряти, навіть якщо вони мають власний MIME-тип.
Для файлів використовуйте dataTransfer.files.
Native Drag and Drop не замінює клавіатурну та сенсорну альтернативи.
Стан перетягування, CSS-класи й тимчасові об’єкти потрібно очищувати в dragend.