Docs/uiv1.0.0-rc.1

Drag and Drop

Overview

UDIOT provides two client-side drag-and-drop utilities: DragDropManager for generic drag-between-zones interactions, and SortableList for in-place reordering of list items. Both work with the browser's native HTML5 Drag and Drop API. The module also exports a dragDropStyles CSS string that you can inject into a <style> tag to get sensible default drag feedback styles.

Key Concepts

  • DragDropManager — attaches drag events to all elements matching draggableSelector and drop events to all elements matching dropZoneSelector. Fires onDragStart, onDragEnd, and onDrop callbacks with the involved HTML elements and the native DragEvent.
  • SortableList — initialises drag-and-drop on all direct children of a container element, allowing the user to reorder items visually. Fires an onReorder callback with the updated element order.
  • dragDropStyles — a CSS string adding .dragging (opacity + grabbing cursor), .drag-over (dashed blue border), and [draggable="true"] (grab cursor) styles.
  • Data transfer — DragDropManager uses e.dataTransfer.setData('text/plain', ...) to serialise dragData (or element.dataset) and e.dataTransfer.getData('text/plain') to retrieve it on drop.
  • destroy() — removes the event listeners, draggable attributes and CSS classes. Calling initialize() again replaces the listeners rather than adding a second set.

API Reference

DragDropOptions

interface DragDropOptions {
  draggableSelector: string;     // CSS selector for draggable elements
  dropZoneSelector: string;      // CSS selector for drop target elements
  onDragStart?: (element: HTMLElement, event: DragEvent) => void;
  onDragEnd?: (element: HTMLElement, event: DragEvent) => void;
  onDrop?: (
    draggedElement: HTMLElement,
    dropZone: HTMLElement,
    event: DragEvent
  ) => void;
  dragImage?: HTMLElement;       // Custom drag image (not used in current implementation)
  dragData?: any;                // Data to serialise into dataTransfer; defaults to element.dataset
}

DragDropManager

class DragDropManager {
  constructor(options: DragDropOptions);

  // Attach all drag/drop event listeners to matching elements
  initialize(): void;

  // Remove draggable attributes and CSS classes (does not remove event listeners)
  destroy(): void;
}

SortableList

class SortableList {
  constructor(
    containerSelector: string,                // CSS selector for the container element
    onReorder?: (items: HTMLElement[]) => void // Called with the new element order after a drop
  );
  // Initialises automatically in the constructor
}

dragDropStyles (constant)

const dragDropStyles: string;
// Contains CSS for: .dragging, .drag-over, [draggable="true"], [draggable="true"]:active

Basic Usage

Generic drag-and-drop between zones

import { DragDropManager, dragDropStyles } from '@tagna/udiot';

// Inject default styles
document.head.insertAdjacentHTML(
  'beforeend',
  `<style>${dragDropStyles}</style>`
);

const manager = new DragDropManager({
  draggableSelector: '.task-card',
  dropZoneSelector: '.column',
  dragData: { type: 'task' },
  onDragStart(element, event) {
    console.log('Started dragging', element.dataset.id);
  },
  onDrop(draggedElement, dropZone, event) {
    dropZone.appendChild(draggedElement.cloneNode(true));
    draggedElement.remove();
    console.log('Dropped into column:', dropZone.dataset.column);
  },
  onDragEnd(element, event) {
    console.log('Drag ended');
  },
});

manager.initialize();

Sortable list

import { SortableList } from '@tagna/udiot';

const sortable = new SortableList('#priority-list', (items) => {
  const newOrder = items.map(el => el.dataset.id);
  console.log('New order:', newOrder);
  // Persist the new order to the server
  fetch('/api/tasks/reorder', {
    method: 'PUT',
    body: JSON.stringify({ order: newOrder }),
    headers: { 'Content-Type': 'application/json' },
  });
});

Corresponding HTML:

<ul id="priority-list">
  <li data-id="task-1">Task One</li>
  <li data-id="task-2">Task Two</li>
  <li data-id="task-3">Task Three</li>
</ul>

Kanban board

import { DragDropManager } from '@tagna/udiot';

const board = new DragDropManager({
  draggableSelector: '[data-card]',
  dropZoneSelector: '[data-column]',
  onDrop(card, column, event) {
    const cardId   = card.dataset.card!;
    const columnId = column.dataset.column!;

    // Optimistic DOM update
    column.querySelector('.column-body')?.appendChild(card);

    // Persist to backend
    fetch(`/api/cards/${cardId}/move`, {
      method: 'PATCH',
      body: JSON.stringify({ column: columnId }),
      headers: { 'Content-Type': 'application/json' },
    });
  },
});

board.initialize();

Tear-down on navigation

// Single-page navigation cleanup
function onRouteChange() {
  board.destroy();
}

Common Patterns

Restrict drop zones by data attribute

const manager = new DragDropManager({
  draggableSelector: '[data-type="image"]',
  dropZoneSelector: '[data-accepts="image"]',
  onDrop(dragged, zone) {
    zone.appendChild(dragged);
  },
});
manager.initialize();

Reading dataTransfer payload on drop

The native DragEvent.dataTransfer object is passed to onDrop. You can read the JSON payload set by DragDropManager:

onDrop(dragged, zone, event) {
  const raw = event.dataTransfer?.getData('text/plain') ?? '{}';
  const data = JSON.parse(raw);
  console.log('Dropped data:', data);
}

Injecting styles in an SSR page

import { dragDropStyles } from '@tagna/udiot';

export default {
  render(): string {
    return `
      <style>${dragDropStyles}</style>
      <div class="board">
        <div class="column" data-column="todo">
          <div class="task-card" data-id="1">Task 1</div>
        </div>
        <div class="column" data-column="done">
          <!-- drop target -->
        </div>
      </div>
    `;
  },
};

Then call manager.initialize() in the client-side hydration entry point after the DOM is ready.

Integration Notes

  • DragDropManager.initialize() queries the DOM at call time using document.querySelectorAll. Elements added to the DOM after initialize() is called are not automatically made draggable. Re-call initialize() (or add listeners manually) after dynamic DOM updates.
  • SortableList constructor throws Error: Container not found: ${selector} if document.querySelector(containerSelector) returns null. Ensure the container element exists in the DOM before constructing a SortableList.
  • SortableList uses insertBefore with live DOM manipulation during dragover events for real-time visual reordering. This means the onReorder callback receives elements in their final order at the time of the drop event.
  • DragDropManager sets e.dataTransfer.effectAllowed = 'copy' on drag start and e.dataTransfer.dropEffect = 'copy' on drag over. This signals a copy operation to the browser. Change these inside onDragStart / the dragover handler if you want a move operation.
  • destroy() removes the listeners initialize() added, the draggable attribute and the .dragging and .drag-over CSS classes.

Pitfalls

  • Both utilities operate on elements selected at initialisation time. If you use a front-end framework that replaces DOM nodes on re-render, previously attached event listeners will be lost. Re-call initialize() after each render cycle.
  • dragData is serialised with JSON.stringify. Non-serialisable values (functions, circular references, DOM nodes) will throw or produce unexpected output. Keep dragData to plain objects.
  • SortableList's getDragAfterElement uses getBoundingClientRect() to determine insertion position. This is a synchronous layout read and may cause layout thrashing if called frequently during dragover. On lists with many items, consider throttling the dragover event.
  • HTML5 drag-and-drop on mobile devices is not natively supported in iOS Safari or Android Chrome without a polyfill. For touch-enabled drag-and-drop, use a dedicated library (e.g. Sortable.js) as the ComponentHydrator delegate.
  • event.dataTransfer can be null on non-DnD events. DragDropManager guards this in the dragstart and drop handlers with if (e.dataTransfer) { ... }, but custom code in callbacks should also guard against it.
From the canonical UDIOT public documentation.Try it in the playground ↗