Drag and Drop
Зачем нужно
Drag and Drop (DnD) -- паттерн перетаскивания элементов мышью или пальцем. HTML5 DnD API позволяет делать сортируемые списки, загрузку файлов перетаскиванием в dropzone, канбан-доски. Это один из самых интерактивных паттернов в веб-интерфейсах.
Где используется
- Сортировка списков (todo-лист, приоритеты)
- Канбан-доски (Trello, Jira)
- Загрузка файлов перетаскиванием (file upload dropzone)
- Drag-and-drop конструкторы (редакторы форм, сайтов)
- Перемещение элементов между контейнерами
HTML5 Drag and Drop API
События DnD
| Событие | Срабатывает на | Описание |
|---|---|---|
dragstart |
Перетаскиваемый элемент | Начало перетаскивания |
drag |
Перетаскиваемый элемент | Во время перетаскивания |
dragend |
Перетаскиваемый элемент | Конец перетаскивания |
dragenter |
Целевая зона | Элемент вошёл в зону |
dragover |
Целевая зона | Элемент над зоной (нужен preventDefault) |
dragleave |
Целевая зона | Элемент покинул зону |
drop |
Целевая зона | Элемент отпущен в зоне |
Сортируемый список (Sortable List)
HTML
<ul class="sortable-list" id="taskList">
<li class="sortable-item" draggable="true">
<span class="sortable-item__handle">☰</span>
<span class="sortable-item__text">Изучить HTML5 DnD API</span>
</li>
<li class="sortable-item" draggable="true">
<span class="sortable-item__handle">☰</span>
<span class="sortable-item__text">Сделать sortable list</span>
</li>
<li class="sortable-item" draggable="true">
<span class="sortable-item__handle">☰</span>
<span class="sortable-item__text">Добавить dropzone для файлов</span>
</li>
<li class="sortable-item" draggable="true">
<span class="sortable-item__handle">☰</span>
<span class="sortable-item__text">Протестировать на мобильных</span>
</li>
</ul>
CSS
.sortable-list {
list-style: none;
padding: 0;
margin: 0;
max-width: 400px;
}
.sortable-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 8px;
cursor: grab;
transition: box-shadow 0.2s, opacity 0.2s;
user-select: none;
}
.sortable-item:active {
cursor: grabbing;
}
/* Элемент, который перетаскивают */
.sortable-item.dragging {
opacity: 0.4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Индикатор места вставки */
.sortable-item.drag-over {
border-top: 3px solid #3b82f6;
margin-top: -3px;
}
.sortable-item__handle {
color: #999;
font-size: 18px;
cursor: grab;
}
JavaScript
class SortableList {
constructor(listElement) {
this.list = listElement;
this.draggedItem = null;
this.list.addEventListener('dragstart', (e) => this.onDragStart(e));
this.list.addEventListener('dragover', (e) => this.onDragOver(e));
this.list.addEventListener('dragenter', (e) => this.onDragEnter(e));
this.list.addEventListener('dragleave', (e) => this.onDragLeave(e));
this.list.addEventListener('drop', (e) => this.onDrop(e));
this.list.addEventListener('dragend', (e) => this.onDragEnd(e));
}
onDragStart(e) {
this.draggedItem = e.target.closest('.sortable-item');
if (!this.draggedItem) return;
// dataTransfer нужен для Firefox
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
// Отложенное добавление класса (иначе элемент исчезнет сразу)
requestAnimationFrame(() => {
this.draggedItem.classList.add('dragging');
});
}
onDragOver(e) {
e.preventDefault(); // Обязательно! Иначе drop не сработает
e.dataTransfer.dropEffect = 'move';
}
onDragEnter(e) {
const target = e.target.closest('.sortable-item');
if (target && target !== this.draggedItem) {
target.classList.add('drag-over');
}
}
onDragLeave(e) {
const target = e.target.closest('.sortable-item');
if (target) {
target.classList.remove('drag-over');
}
}
onDrop(e) {
e.preventDefault();
const target = e.target.closest('.sortable-item');
if (!target || target === this.draggedItem) return;
target.classList.remove('drag-over');
// Определяем позицию вставки
const items = [...this.list.querySelectorAll('.sortable-item')];
const draggedIndex = items.indexOf(this.draggedItem);
const targetIndex = items.indexOf(target);
if (draggedIndex < targetIndex) {
target.after(this.draggedItem);
} else {
target.before(this.draggedItem);
}
}
onDragEnd(e) {
this.draggedItem?.classList.remove('dragging');
this.list.querySelectorAll('.drag-over').forEach((el) => {
el.classList.remove('drag-over');
});
this.draggedItem = null;
}
// Получить текущий порядок элементов
getOrder {
return [...this.list.querySelectorAll('.sortable-item')].map(
(item) => item.dataset.id
);
}
}
// Инициализация
const sortable = new SortableList(document.getElementById('taskList'));
File Upload Dropzone
HTML
<div class="dropzone" id="fileDropzone">
<svg class="dropzone__icon" width="48" height="48" viewBox="0 0 24 24">
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 14v4a2 2 0 002 2h12a2 2 0 002-2v-4"
fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<p class="dropzone__text">
Перетащите файлы сюда или
<label class="dropzone__browse">
выберите
<input type="file" multiple hidden id="fileInput" />
</label>
</p>
<p class="dropzone__hint">PNG, JPG или PDF до 10 MB</p>
</div>
<ul class="file-list" id="fileList"></ul>
CSS
.dropzone {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 48px 32px;
text-align: center;
transition: border-color 0.2s, background 0.2s;
cursor: pointer;
}
.dropzone.drag-active {
border-color: #3b82f6;
background: #eff6ff;
}
.dropzone__icon {
color: #999;
margin-bottom: 12px;
}
.dropzone__browse {
color: #3b82f6;
cursor: pointer;
text-decoration: underline;
}
.file-list {
list-style: none;
padding: 0;
margin-top: 16px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 6px;
margin-bottom: 4px;
}
JavaScript
const dropzone = document.getElementById('fileDropzone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
// Предотвращаем поведение браузера по умолчанию (открытие файла)
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((event) => {
dropzone.addEventListener(event, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Визуальный отклик при перетаскивании
['dragenter', 'dragover'].forEach((event) => {
dropzone.addEventListener(event, () => {
dropzone.classList.add('drag-active');
});
});
['dragleave', 'drop'].forEach((event) => {
dropzone.addEventListener(event, () => {
dropzone.classList.remove('drag-active');
});
});
// Обработка дропа
dropzone.addEventListener('drop', (e) => {
const files = [...e.dataTransfer.files];
handleFiles(files);
});
// Обработка выбора через input
fileInput.addEventListener('change', () => {
const files = [...fileInput.files];
handleFiles(files);
});
function handleFiles(files) {
const allowed = ['image/png', 'image/jpeg', 'application/pdf'];
const maxSize = 10 * 1024 * 1024; // 10 MB
files.forEach((file) => {
if (!allowed.includes(file.type)) {
alert(`Тип ${file.type} не поддерживается`);
return;
}
if (file.size > maxSize) {
alert(`Файл ${file.name} слишком большой`);
return;
}
const li = document.createElement('li');
li.className = 'file-item';
li.innerHTML = `
<span>${file.name} (${(file.size / 1024).toFixed(1)} KB)</span>
<button class="file-item__remove">×</button>
`;
li.querySelector('.file-item__remove').addEventListener('click', () => li.remove());
fileList.appendChild(li);
});
}
Touch Fallback для мобильных
HTML5 DnD API плохо работает на мобильных. Используем touch events как fallback.
class TouchDragHandler {
constructor(list) {
this.list = list;
this.draggedEl = null;
this.placeholder = null;
list.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false });
list.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
list.addEventListener('touchend', (e) => this.onTouchEnd(e));
}
onTouchStart(e) {
const item = e.target.closest('.sortable-item');
if (!item) return;
this.draggedEl = item;
this.startY = e.touches[0].clientY;
this.itemRect = item.getBoundingClientRect();
// Создаём placeholder
this.placeholder = item.cloneNode(true);
this.placeholder.style.opacity = '0.3';
item.after(this.placeholder);
item.style.position = 'fixed';
item.style.zIndex = '1000';
item.style.width = `${this.itemRect.width}px`;
}
onTouchMove(e) {
if (!this.draggedEl) return;
e.preventDefault();
const y = e.touches[0].clientY;
this.draggedEl.style.top = `${y - this.itemRect.height / 2}px`;
this.draggedEl.style.left = `${this.itemRect.left}px`;
// Найти элемент под пальцем
this.draggedEl.style.pointerEvents = 'none';
const elemBelow = document.elementFromPoint(this.itemRect.left + 10, y);
this.draggedEl.style.pointerEvents = '';
const target = elemBelow?.closest('.sortable-item');
if (target && target !== this.placeholder) {
const rect = target.getBoundingClientRect();
if (y < rect.top + rect.height / 2) {
target.before(this.placeholder);
} else {
target.after(this.placeholder);
}
}
}
onTouchEnd {
if (!this.draggedEl) return;
this.draggedEl.style.position = '';
this.draggedEl.style.zIndex = '';
this.draggedEl.style.width = '';
this.draggedEl.style.top = '';
this.draggedEl.style.left = '';
this.placeholder.replaceWith(this.draggedEl);
this.draggedEl = null;
this.placeholder = null;
}
}
Частые ошибки
| Ошибка | Проблема | Решение |
|---|---|---|
Нет e.preventDefault() в dragover |
drop не срабатывает |
Всегда вызывай preventDefault |
Нет e.dataTransfer.setData |
Не работает в Firefox | Вызови setData('text/plain', '') |
| Нет touch-поддержки | Не работает на мобильных | Добавь touch events |
| Мерцание при перетаскивании | Элемент прыгает | requestAnimationFrame для стилей |
| Дроп файлов открывает их в браузере | Не предотвращён default | preventDefault на всех DnD-событиях |
Связанные темы
- Popup модальное окно -- подтверждение действия после дропа
- Валидация формы в реальном времени -- валидация загруженных файлов
- Ленивая загрузка изображений -- превью загруженных файлов
- Бесконечный скролл -- подгрузка элементов при перетаскивании