Web applications are becoming increasingly complex. While that’s the case, we must ensure the interface is intuitive and easy to use. By allowing our users to drag and drop elements across the screen, we can simplify many tasks, such as reordering lists or grouping items.
In this article, we learn how to implement the drag-and-drop functionality without third-party libraries by implementing a simple To-Do list. We also learn how to ensure it works as expected by writing End-to-End (E2E) tests with Playwright.
Check out this repository if you want to see the full code from this article.
Creating a To-Do List application
Let’s create a simple application that uses drag-and-drop to manage its state.
Managing a list of strings
To implement our To-Do list, we need to manage two lists of strings. One contains a list of tasks to do, and the other holds the completed tasks. We can simplify it by creating a generic custom hook.
useList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import { useState } from 'react'; export function useList() { const [items, setItems] = useState<string[]>([]); const addItem = (text: string) => { if (items.includes(text)) { return; } setItems([...items, text]); }; const removeItem = (text: string) => { const index = items.indexOf(text); if (index === -1) { return; } const newItems = [...items]; newItems.splice(index, 1); setItems(newItems); }; return { items, addItem, removeItem, }; } |
Above, we have two methods – one for adding items and one for removing them. We can use it twice to handle the state of two different lists.
1 2 3 4 5 6 7 8 9 10 11 |
const { items: toDoItems, removeItem: removeToDoItem, addItem: addToDoItem, } = useList(); const { items: completedItems, removeItem: removeCompletedItem, addItem: addCompletedItem, } = useList(); |
Handling the input for creating new tasks
To be able to add elements to our lists, we have to create an input and let the users interact with it to put the name of the new task.
useNewItemInput.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { ChangeEvent, useState } from 'react'; export function useNewItemInput() { const [newItem, setNewItem] = useState(''); const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => { setNewItem(event.target.value); }; const clearInput = () => { setNewItem(''); }; return { newItem, handleInputChange, clearInput, }; } |
Dragging and dropping
To handle dragging and dropping, we can use a set of event handlers. First, let’s see them in action in a straightforward example.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import { DragEvent } from 'react'; function App() { const onDragStart = () => { console.log('Dragging'); }; const onDrop = () => { console.log('Dropped'); }; const onDragOver = (event: DragEvent) => { event.preventDefault(); }; return ( <div> <div draggable={true} onDragStart={onDragStart}> Drag me </div> <div onDragOver={onDragOver} onDrop={onDrop}> Drop here </div> </div> ); } export default App; |
draggable
By default, the draggable attribute on each element is set to auto, which allows certain elements, such as images, to be dragged. We should set it to true to ensure that our element can be dragged properly.
onDragStart
The onDragStart event occurs when the user starts to drag an element.
onDragOver
By default, browsers prevent items from being dropped on a particular element. To override that, we need to use the onDragOver event, which is fired when an element is dragged over a drop target. Calling event.preventDefault() enables it to receive onDrop events.
onDrop
The onDrop event happens when the user drops the item on the target. This works if we use the onDragOver event properly and call the event.preventDefault() function.
Implementing the drag-and-drop functionalities
Let’s create a custom hook to handle the drag-and-drop functionalities in our To-Do List application.
useDragAndDrop.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
import { DragEvent, useState } from 'react'; interface Props { addCompletedItem: (item: string) => void; addToDoItem: (item: string) => void; removeToDoItem: (item: string) => void; removeCompletedItem: (item: string) => void; } export function useDragAndDrop({ addCompletedItem, addToDoItem, removeToDoItem, removeCompletedItem, }: Props) { const [ currentlyDraggedItem, setCurrentlyDraggedItem ] = useState<string | null>(null); const startDragging = (item: string) => () => { setCurrentlyDraggedItem(item); }; const handleDropOnCompleted = () => { if (currentlyDraggedItem) { addCompletedItem(currentlyDraggedItem); removeToDoItem(currentlyDraggedItem); } }; const handleDropOnToDo = () => { if (currentlyDraggedItem) { addToDoItem(currentlyDraggedItem); removeCompletedItem(currentlyDraggedItem); } }; const handleDragOver = (event: DragEvent) => { event.preventDefault(); }; return { startDragging, handleDropOnCompleted, handleDropOnToDo, handleDragOver, }; } |
Please notice that startDragging is a currying function that returns another function.
We will call the startDragging function when the user starts dragging a particular item on the list. What’s important is that we store which item was dragged. We will need to know it later.
Then, we have two separate functions that handle dropping the item:
-
handleDropOnCompleted that we will call when the user drops an item on the list of completed tasks
- it adds the currently dragged item to the list of completed tasks and removes it from the to-do tasks
-
handleDropOnToDo that we will need to call when the user drops an item on the list of to-do tasks
- it removes the currently dragged item from the list of completed tasks and appends it to the to-do tasks
We also have the handleDragOver function, which we must use to handle the onDragOver event.
Combining all of our custom hooks
We now have custom hooks for every aspect of our application:
- useNewItemInput that handles the state of the input used for creating new tasks
- useList that manages the state of a particular list of tasks
- useDragAndDrop which contains the logic of dragging and dropping tasks on different lists.
Let’s combine all of the above in a single React component.
ToDoList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
import styles from './ToDoList.module.css'; import { List } from './List'; import { useNewItemInput } from './useNewItemInput'; import { useList } from './useList'; import { useDragAndDrop } from './useDragAndDrop'; export const ToDoList = () => { const { handleInputChange, newItem, clearInput } = useNewItemInput(); const { items: toDoItems, removeItem: removeToDoItem, addItem: addToDoItem, } = useList(); const { items: completedItems, removeItem: removeCompletedItem, addItem: addCompletedItem, } = useList(); const handleNewItem = () => { addToDoItem(newItem); clearInput(); }; const { handleDragOver, handleDropOnToDo, handleDropOnCompleted, startDragging, } = useDragAndDrop({ addToDoItem, addCompletedItem, removeCompletedItem, removeToDoItem, }); return ( <div> <div className={styles.inputWrapper}> <input placeholder="Add new item" onChange={handleInputChange} value={newItem} /> <button onClick={handleNewItem}>Add</button> </div> <div className={styles.listWrapper}> <List items={toDoItems} title="To do:" onStartDragging={startDragging} onDragOver={handleDragOver} onDrop={handleDropOnToDo} data-testid="to-do-list" /> <List items={completedItems} title="Completed:" onStartDragging={startDragging} onDragOver={handleDragOver} onDrop={handleDropOnCompleted} data-testid="completed-list" /> </div> </div> ); }; |
In the <List> component, we render all items from a particular list and attach event listeners related to dragging and dropping.
List.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import { FunctionComponent, DragEvent } from 'react'; import styles from './List.module.css'; interface Props { items: string[]; title: string; onStartDragging: (item: string) => () => void; onDragOver: (event: DragEvent) => void; onDrop: () => void; 'data-testid'?: string; } export const List: FunctionComponent<Props> = ({ items, title, onStartDragging, onDragOver, onDrop, 'data-testid': dataTestId, }) => { return ( <div className={styles.list} onDragOver={onDragOver} onDrop={onDrop} data-testid={dataTestId} > <h3>{title}</h3> <div> {items.map((item) => ( <p className={styles.item} key={item} draggable onDragStart={onStartDragging(item)} > {item} </p> ))} </div> </div> ); }; |
Thanks to all of the above, we now have a fully functional application that allows us to create tasks and move them between two lists by dragging and dropping.
Testing with Playwright
Let’s start by creating a straightforward test that adds the item and checks if it appears on the To-Do list.
ToDoList.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import { expect, test } from '@playwright/test'; test.describe('When the user visits the page', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); }); test.describe('and creates a new task', () => { let taskName: string; test.beforeEach(async ({ page }) => { taskName = 'Buy milk'; const input = page.getByPlaceholder('Add new item'); await input.fill(taskName); await page.getByRole('button').click(); }); test('it should add the new task to the to-do list', ({ page }) => { const toDoList = page.getByTestId('to-do-list'); const newTask = toDoList.getByText(taskName); expect(newTask).toBeVisible(); }); }); }); |
Above, we use page.goto('/') to navigate to our website thanks to using the baseURL configuration property and environment variables. If you want to know more, check out JavaScript testing #17. Introduction to End-to-End testing with Playwright
Testing the dragging functionality
To simulate dragging and dropping with Playwright, we need to use the dragTo function.
ToDoList.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import { expect, test } from '@playwright/test'; test.describe('When the user visits the page', () => { // ... test.describe('and creates a new task', () => { // ... test.describe('and drags the task to the completed list', () => { test.beforeEach(async ({ page }) => { const newTask = page.getByText(taskName); const completedList = page.getByTestId('completed-list'); await newTask.dragTo(completedList); }); test('the task should be visible on the completed list', ({ page }) => { const completedList = page.getByTestId('completed-list'); const newTask = completedList.getByText(taskName); expect(newTask).toBeVisible(); }); test('the task should not be visible on the to-do list', ({ page }) => { const completedList = page.getByTestId('to-do-list'); const newTask = completedList.getByText(taskName); expect(newTask).not.toBeVisible(); }); }); }); }); |
When calling the dragTo function, we provide the target – in this case, the list of completed tasks. All that’s left to do is to check if the task was moved from one list to another.
Summary
In this article, we’ve created a simple To-Do List application using React without relying on any third-party libraries. Thanks to that, we’ve better understood how native events work, allowing us to handle dragging and dropping. We also made sure to split the logic of our application into small and easy-to-understand custom React hooks.
In addition, we learned how to use Playwright to write End-to-End tests that simulate dragging and dropping in a browser and ensure our application is working as expected.
I like your series, they are very nice contents