I know, drag and drop is a solved problem. There are several great utilities you can use to easily have a drag and drop list in your application (dragula, react-beautiful-dnd, and react-dnd). These libraries offer APIs that make it easy to meet your needs without worrying about what is happening under the hood. Most of them hide a complex logic to make the drag and drop work properly on several devices, whether touch or not.
However, using external dependencies comes at a cost. In fact, it increases the bundle size of your application, which can affect its overall performance. You also become dependent on the maintenance of the library.
Therefore, before rushing to use a library, you should take a look at your problem and ask yourself if there is a simple way to solve it without relying on a library.
For instance, if your application does not target touch devices and you want to keep your bundle size as lean as possible for an optimal performance, you should consider implementing your own drag and drop component.
This is exactly what this article will guide you through. You will be using the native HTML5 Drag & Drop API to create and test your own drag and drop sortable list:
The final source code & tests can be found here.
In this first iteration, we will just create the view without any drag & drop behavior.
The state is composed of a list of food items. The food items are unique, so we can use them as keys when rendering the list items. The uniqueness of the food items will also be used later in the sorting logic.
The styles are basically just to color the container and the list items( <li> ), set the padding in them, and set a move cursor in the drag div (containing the hamburgerย icon) to make it clear to the user that itโs draggable.
class App extends React.Component {state = {items: ["๐ฐ Cake", "๐ฉ Donut", "๐ Apple", "๐ Pizza"]};render() {return (<div className="App"><main><h3>List of items</h3><ul>{this.state.items.map(item => (<li key={item}><div className="drag"><Hamburger /></div>{item}</li>))}</ul></main></div>);}}
.App {font-family: sans-serif;font-size: 1.5rem;text-align: center;width: 100vw;height: 100vh;display: flex;justify-content: center;align-items: center;}.App main {background-color: #383838;color: #fff;padding: 10px;}.App ul {margin: 0;padding: 0;list-style: none;}.App ul li {background-color: #383838;padding: 10px 20px;position: relative;display: flex;align-items: flex-start;line-height: 1;}.App li .drag {margin-right: 15px;cursor: move;}.App li svg {height: 2rem;color: #fff;}
To make the items draggable, we add the draggable (shorthand for draggable={true}) prop to the drag div.
class App extends React.Component {state = {items: ["๐ฐ Cake", "๐ฉ Donut", "๐ Apple", "๐ Pizza"]};render() {return (<div className="App"><main><h3>List of items</h3><ul>{this.state.items.map(item => (<li key={item}><div className="drag" draggable><Hamburger /></div>{item}</li>))}</ul></main></div>);}}
Although the icon is draggable, itโs not quite yet what we want to achieve. We want the whole item row to be draggable through the icon.
To do so, we need to specify what we want to be draggable in the onDragStart event handler:
class App extends React.Component {state = {items: ["๐ฐ Cake", "๐ฉ Donut", "๐ Apple", "๐ Pizza"]};onDragStart = e => {e.dataTransfer.effectAllowed = "move";e.dataTransfer.setData("text/html", e.target.parentNode);e.dataTransfer.setDragImage(e.target.parentNode, 20, 20);};render() {return (<div className="App"><main><h3>List of items</h3><ul>{this.state.items.map((item, idx) => (<li key={item}><divclassName="drag"draggableonDragStart={this.onDragStart}><Hamburger /></div>{item}</li>))}</ul></main></div>);}}
We set the drag effect to be โmoveโ. This indicates that the visual effect will be moving the item.
e.dataTransfer.setData("text/html", e.parentNode) sets the dragged item to be the parent node of the drag div, being the list item. This is necessary for browsers like Firefox to achieve our desired effect.
e.dataTransfer.setDragImage(e.parentNode, 20, 20) does the same thing as the previous method. This is necessary for Chrome to achieve our desired effect.
Our items are draggable through the drag icon! We want now to make dragging items affect their sorting.
When an item has another item dragged over it, we need to react (no pun intended ๐ ) to that by changing the sorting of the list. If item A is dragged over item B, then item A gets placed after item B.
First, we should store the currently dragged item when the dragging starts (i.e. in the onDragStart event handler).
Then we should set the currently dragged item to null once the dragging is done. This can be done in the onDragEnd event handler, which is called once the user stops dragging.
We should also implement the sorting logic in the onDragOver event handler for each list item, which is called whenever an item has an element dragged over it.
class App extends React.Component {state = {items: ["๐ฐ Cake", "๐ฉ Donut", "๐ Apple", "๐ Pizza"]};onDragStart = (e, index) => {this.draggedItem = this.state.items[index];e.dataTransfer.effectAllowed = "move";e.dataTransfer.setData("text/html", e.target.parentNode);e.dataTransfer.setDragImage(e.target.parentNode, 20, 20);};onDragOver = index => {const draggedOverItem = this.state.items[index];// if the item is dragged over itself, ignoreif (this.draggedItem === draggedOverItem) {return;}// filter out the currently dragged itemlet items = this.state.items.filter(item => item !== this.draggedItem);// add the dragged item after the dragged over itemitems.splice(index, 0, this.draggedItem);this.setState({ items });};onDragEnd = () => {this.draggedIdx = null;};render() {return (<div className="App"><main><h3>List of items</h3><ul>{this.state.items.map((item, idx) => (<li key={item} onDragOver={() => this.onDragOver(idx)}><divclassName="drag"draggableonDragStart={e => this.onDragStart(e, idx)}onDragEnd={this.onDragEnd}><Hamburger /></div><span className="content">{item}</span></li>))}</ul></main></div>);}}
If the dragged-over item is the same as the currently dragged item, nothing changes. Otherwise, we move the currently dragged item from its initial position in the array of items to be right after the dragged-over item.
Here you go, our list items are now draggable and sortable:
Drag and drop is a complex interaction and is hard to evaluate with a simple unit test.
We might think of using mock function calls in a unit test and check that the state of our component changed properly. While this would work, it remains a non-reliable test because it does not assure that the behavior is working as itโs supposed to be.
Our test should be as close as possible to how a user interacts with the drag and drop. Thus, we will write an end-to-end test that simply reproduces the dragging behavior and checks whether the sorting of the list changed properly. One library we can use is cypress.js.
After installing Cypress through npm or yarn, we can simply add a test file dragAndDrop.js to the cypress/integration directory:
describe("Drag and Drop", () => {beforeEach(() => {cy.visit("http://localhost:3000/");});it("should move first item to 3rd position", () => {// whendragItem(1, 3);// thengetListItemValue(1).should("have.text", "๐ฉ Donut");getListItemValue(3).should("have.text", "๐ฐ Cake");});});// helper functionsfunction dragItem(indexToMove, targetIndex) {const dataTranferMock = { setData: () => {}, setDragImage: () => {} };cy.get(`li:nth-child(${indexToMove}) > .drag`).trigger("dragstart", {dataTransfer: dataTranferMock});cy.get(`li:nth-child(${targetIndex})`).trigger("dragover");}function getListItemValue(index) {return cy.get(`li:nth-child(${index}) > .content`);}
The test drags the first item to the third position using the dragItem helper function. It then checks whether the list sorting has changed properly by checking the values of the affected list items.
The dragItem helper function drags the item at index indexToMove to the position at targetIndex . To do so, it triggers a dragstart event on the drag div at indexToMove(with a mock dataTransfer object so that our dragstart event handler doesnโt fail) and a dragover event on the list item as targetIndex .
The getListItemValue helper function gets the content DOM element inside the list item at the given index .
We can then run the test through:
./node_modules/.bin/cypress run
A nice benefit of using Cypress is that it quickly provides a video of the integration test once you run it: