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">&#9776;</span>
    <span class="sortable-item__text">Изучить HTML5 DnD API</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="sortable-item__handle">&#9776;</span>
    <span class="sortable-item__text">Сделать sortable list</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="sortable-item__handle">&#9776;</span>
    <span class="sortable-item__text">Добавить dropzone для файлов</span>
  </li>
  <li class="sortable-item" draggable="true">
    <span class="sortable-item__handle">&#9776;</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">&times;</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-событиях

Связанные темы

Ресурсы