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 matchingdraggableSelectorand drop events to all elements matchingdropZoneSelector. FiresonDragStart,onDragEnd, andonDropcallbacks with the involved HTML elements and the nativeDragEvent.SortableList— initialises drag-and-drop on all direct children of a container element, allowing the user to reorder items visually. Fires anonReordercallback 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 —
DragDropManagerusese.dataTransfer.setData('text/plain', ...)to serialisedragData(orelement.dataset) ande.dataTransfer.getData('text/plain')to retrieve it on drop. destroy()— removes the event listeners,draggableattributes and CSS classes. Callinginitialize()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 usingdocument.querySelectorAll. Elements added to the DOM afterinitialize()is called are not automatically made draggable. Re-callinitialize()(or add listeners manually) after dynamic DOM updates.SortableListconstructor throwsError: Container not found: ${selector}ifdocument.querySelector(containerSelector)returnsnull. Ensure the container element exists in the DOM before constructing aSortableList.SortableListusesinsertBeforewith live DOM manipulation duringdragoverevents for real-time visual reordering. This means theonReordercallback receives elements in their final order at the time of thedropevent.DragDropManagersetse.dataTransfer.effectAllowed = 'copy'on drag start ande.dataTransfer.dropEffect = 'copy'on drag over. This signals a copy operation to the browser. Change these insideonDragStart/ thedragoverhandler if you want a move operation.destroy()removes the listenersinitialize()added, thedraggableattribute and the.draggingand.drag-overCSS 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. dragDatais serialised withJSON.stringify. Non-serialisable values (functions, circular references, DOM nodes) will throw or produce unexpected output. KeepdragDatato plain objects.SortableList'sgetDragAfterElementusesgetBoundingClientRect()to determine insertion position. This is a synchronous layout read and may cause layout thrashing if called frequently duringdragover. On lists with many items, consider throttling thedragoverevent.- 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 theComponentHydratordelegate. event.dataTransfercan benullon non-DnD events.DragDropManagerguards this in thedragstartanddrophandlers withif (e.dataTransfer) { ... }, but custom code in callbacks should also guard against it.
From the canonical UDIOT public documentation.Try it in the playground ↗