Javascript Drag & Drop
Several years back (time reference ASP.NET Ajax was hot off the press), I developed an application that required drag and drop capabilities.
Reference: https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API
Given the prevalence of web applications since my earliest days of web client development, it's wonderful that standards prevailed. One area that is of particular interest to me is the built-in drag and drop functionality.
Basic objects/interfaces used in the native API are: DragEvent
, DataTransfer
, DataTransferItem
and DataTransferItemList
.
I put together a quick class diagram based on the API description in the pages:
The class diagram does not have an exhaustive list of the properties for each object. It just illustrates the relationships between each object.
Initial Setup
As I mentioned, I am aiming to build a drag-and-drop capable table for managing records. As such, I have the following basic HTML configured for a table:
If you'd like to follow along with the code, I recommend the http-server package using node.js - it's a nice and quick way to get a web-server up and running. Boy have things come a far way since I first started web development 15 or so years ago!
http-server: https://www.npmjs.com/package/http-server
To install: npm install http-server -g
For the first portion of the example, I've excluded styling in order to keep the HTML simple and focused on drag-and-drop functionality.
What Makes an Element Draggable?
The API is very simple in this regard. To make an element draggable, use the draggable attribute with a value of "true":
Nice and easy!
In my case, I'd like to use the draggable capabilities on the "drag handle" (the span in the furthest-left cell). To do that, I've added a class of drag-handle
to each of the drag handles, and added the draggable attribute:
<span class="drag-handle" draggable="true">Drag Element</span>
Attempting to drag the element in the Handle column results in the following:
Based on the indicator, there's nowhere to drop it. That's because the implementation is not completed yet. It turns out there are a couple more requirements for implementing the API:
- Adding handling for the
dragstart
event - Setting data for the drag operation, while handling
dragstart
The first case is fairly simple and just uses the addEventListener
method to handle the event. On our current example (I've added an id attribute to each drag handle for right now to demonstrate this):
The new behaviour is demonstrated below:
As demonstrated, the event is being registered for each unique drag handle element. That's one step closer, but the event handler is still not meeting the requirements spelled out in above - adding data for drag operation during the dragstart
handler. Per the main documentation:
The application is free to include any number of data items in a drag operation. Each data item astring
of a particulartype
- typically a MIME type such astext/html
.
The sample code provided in the API docs demonstrates attaching several different types data to the transfer using the setData
function:
The purpose of the setData
function is to associate data to identify what is being dragged. A text application might use the text of the dragged element, while a link would most likely be the target href. The data is consumed during the dragenter
and dragover
events, which will be discussed in detail later on. One quick example for usage is to determine if the data can be dropped onto the current element.
I will develop a more flexible system of determining the right event data to pass in later on, but for the time being, just use a data-row-id attribute on the tr
parent:
As shown here, the setData
function is used to attach a text/plain
MIME data and a mg-dndrow/id
MIME data. The former is based on the API recommendations. The function uses a basic native JavaScript proprety on the DOMElement object (parentElement) to find the the parent tr
and return it for the data extraction.
Drag Effects
The dropEffect
property on the event dataTransfer indicates the feedback to be given to the user during DND operations - basically the desired cursor to display when hovering over a drop target.
The effects are "copy", "move", and "link". Further details are available in the Mozilla operation, but it's set as a string. In the ongoing example:
// Added to the dragstart event handle
ev.dataTransfer.dropEffect = "move";
Drop Zones
Drop zones (where you can drop a draggable element, so a "droppable" element) are identified through their implementation of the dragover
and drop
events. Before delving further into the implementation of a solution, establishing the features is important:
- Dragging rows stays within the table
- Rows are dragged when they are moved.
- The drag handle is way to pick up the row, but it can be dropped onto any row.
- Dropping the row "takes" that row's place.
As mentioned above, the drop requirements are a dragover
handler and the drop
handler. In the working grid, the tr
element implements the events:
In my initial tests, the SPAN and TD are the target elements as the children of the drop target. To rectify that, I used the currentTarget
property on the event target instead. Doing so routed all the events to the TR element as expected.
Finishing the Drop
To finish the handling of the drop, dragend
fires. The dropEffect
proeprty is used to to determine which drop event occurred, with a value of none
indicating that it was cancelled. Users typically cancel by Escape.
The operation is completed once the dragend
is done propagating.
To put this all together for the reordering on the table, we are taking the row (TR) and moving it into the new slot in the table (above the row that it's dropped on). The code for responding to the dragend event looks as follows (before implementing the actual moving of elements).
As shown in the capture, the final screen has the move as the registered operation. When refreshing the page and dropping out of the table, the dropEffect is "none"
:
To complete the exercise, the row must be extracted and re-inserted at the target index (after the row it is dropped on - or initially at least). The DOM objects support an insertBefore
and an append
method. These have some implications in that they generate a few cases for the drag-and-drop table functionality:
- When moving a row up, simple use the
insertBefore
method to re-insert the row. - When moving a row to the end of the table (dropping it on the last row of the table), use the
append
method. - When moving a row down, the row after the drop target must be used to leverage the
insertBefore
method properly. This is why case #2 exists - if it's the last row, there isn't a row that follows.
The event handler code for the drop
event is as follows:
function gridRow_OnDrop(ev) {
ev.preventDefault();
// Need the data now
const receivedData = ev.dataTransfer.getData("mg-dndrow/id");
let dropRow = getParentRowElement(ev.target);
addEventDescription(
ev.currentTarget.tagName,
"ondrop " + dropRow.dataset.rowId + ", new row is " + receivedData
);
// Grabbing the table element itself.
const tableElement = document.getElementById("dragTable");
let rowToMove = null;
let dropIndex = -1;
let removeIndex = -1;
if (tableElement !== undefined && tableElement !== null) {
for (let i = 0; i < tableElement.rows.length; ++i) {
if (tableElement.rows[i].dataset.rowId === receivedData) {
rowToMove = tableElement.rows[i];
removeIndex = i;
}
if (tableElement.rows[i] === dropRow) {
dropIndex = i;
}
// Can terminate now
if (rowToMove !== null && dropIndex >= 0) {
break;
}
}
}
if (rowToMove && dropRow && dropIndex >= 0) {
let p = rowToMove.parentNode;
// Moving it ahead in the table, that's a special case because of the insertBefore API
if (removeIndex < dropIndex) {
// Either the last element, or handle the proper needs.
// The last row is a special case.
if (dropIndex === tableElement.rows.length - 1) {
// No more rows to drop at, we're done, so put it into the end of the table and continue.
rowToMove.parentNode.removeChild(rowToMove);
p.appendChild(rowToMove);
return;
} else {
// Grab the row after it to
dropRow = tableElement.rows[dropIndex + 1];
}
}
// Removing the original one from there.
rowToMove.parentNode.removeChild(rowToMove);
p.insertBefore(rowToMove, dropRow);
}
}
The code is relatively straightforward and it handles all of the cases as mentioned above. The results are shown in each of these captures:
The first case, dragging a row up:
The second case, dragging a row onto the last row:
The third case, dragging a row down and dropping onto a non-last row record:
Conclusion
As this post illustrates, the drag and drop API in Javascript is not terribly complicated to use. A follow-up post will add some refinements such as styling and other feedback, as well as integration into a Vue application (as a component).
The code for this article is available on GitHub: