9 min read

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:

Partial Class Diagram for API

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:

<table>
        <thead>
          <tr>
            <th>Handle</th>
            <th>ID</th>
            <th>Content</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td><span>Drag Element</span></td>
            <td>1</td>
            <td>Record 1 Description</td>
          </tr>
          <tr>
            <td><span>Drag Element</span></td>
            <td>2</td>
            <td>Record 2 Description</td>
          </tr>
          <tr>
            <td><span>Drag Element</span></td>
            <td>3</td>
            <td>Record 3 Description</td>
          </tr>
          <tr>
            <td><span>Drag Element</span></td>
            <td>4</td>
            <td>Record 4 Description</td>
          </tr>
        </tbody>
      </table>
HTML Starting Point
Initial Drag and Drop Setup

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":

<p id="draggableItem1" draggable="true">This element now draggable.</p>
Draggable Attribute

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:

Initial Draggable Implementation

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):

// In the DOMContentLoaded handler:
// Register drag start handlers for each of them.
const elements = document.getElementsByClassName("drag-handle");
for (let i = 0; i < elements.length; ++i) {
	elements[i].addEventListener("dragstart", dragHandle_OnDragStart);
}

// The handler function:
function dragHandle_OnDragStart(ev) {
	addEventDescription(ev.target.id, "ondragstart");
}
Basic dragstart event handling code

The new behaviour is demonstrated below:

After Adding the dragstart Event Handler

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 a string of a particular type - typically a MIME type such as text/html.

The sample code provided in the API docs demonstrates attaching several different types data to the transfer using the setData function:

function dragstart_handler(ev) {
  // Add different types of drag data
  ev.dataTransfer.setData("text/plain", ev.target.innerText);
  ev.dataTransfer.setData("text/html", ev.target.outerHTML);
  ev.dataTransfer.setData("text/uri-list", ev.target.ownerDocument.location.href);
}
(From Mozilla)

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:

function getParentRowElement(dragHandle) {
  let p = dragHandle.parentElement;
  if (p.tagName == "TD") {
    return getParentRowElement(p);
  } else if (p.tagName == "TR") {
    return p;
  } else {
    // Do something with an unknown element.
    return null;
  }
}

function dragHandle_OnDragStart(ev) {
  addEventDescription(ev.target.id, "ondragstart");
  let parentRow = getParentRowElement(ev.target);
  if (parentRow !== undefined && parentRow !== null) {
    let rowId = parentRow.dataset.rowId;
	// Add the data 
	if (rowId !== undefined && rowId !== null) {
      ev.dataTransfer.setData("text/plain", "" + rowId);
      ev.dataTransfer.setData("mg-dndrow/id", rowId);
    }
    addEventDescription("rowId", rowId);
  } else {
    console.error("Invalid element dragging");
  }
}
Getting a ro 

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:

  1. Dragging rows stays within the table
  2. Rows are dragged when they are moved.
  3. The drag handle is way to pick up the row, but it can be dropped onto any row.
  4. 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:

function gridRow_OnDragOver(ev) {
  ev.preventDefault();

  ev.dataTransfer.dropEffect = "move";

  addEventDescription(ev.currentTarget.tagName, "ondropover");
}

function gridRow_OnDrop(ev) {
  ev.preventDefault();

  // Need the data now
  const receivedData = ev.dataTransfer.getData("mg-dndrow/id");

  addEventDescription(ev.currentTarget.tagName, "ondrop");
}

// From inside the DOMContentLoaded event:
const tableRows = document.getElementsByTagName("tr");
  for (let i = 0; i < tableRows.length; ++i) {
    if (tableRows[i].dataset.rowId) {
      tableRows[i].addEventListener("dragover", gridRow_OnDragOver);
      tableRows[i].addEventListener("drop", gridRow_OnDrop);
    }
  }
Droppable Configuration (Required Event Handling)

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":

Cancel Drag Result

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:

  1. When moving a row up, simple use the insertBefore method to re-insert the row.
  2. When moving a row to the end of the table (dropping it on the last row of the table), use the append method.
  3. 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:

Case 1 - Drag row up and drop

The second case, dragging a row onto the last row:

Case 2 - Drag down and drop onto the last row

The third case, dragging a row down and dropping onto a non-last row record:

Case 3 - drag down and drop onto a row that is not the last row

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:

mathewgrabau/js-dnd-table-sample
Blog post code - JS implementation of a table for drag and drop - mathewgrabau/js-dnd-table-sample