How to Simulate Drag Events in Storybook

Sept 21, 2024 · 3 min read

9 views

⚙️ Dev

🛠️ Storybook

I ran into this problem because I have been building an online card game where dragging and dropping cards is the main way a player takes action. I specifically wanted to use Storybook's play function to simulate the UI in various states while the player is dragging a card. Unfortunately, a google search for how to test a drag action in a story comes up relatively empty. There are results from Storybook's documentation but "drag" is only mentioned as an action you can test without any helpful examples of how to actually implement it. This made it seem to me that a drag event exists just like other userEvents such as click or hover.

 

If you've found this post, you've probably already realized it doesn't.

 

In order to add a drag event to a story in Storybook, we have to manually implement drag functionality in a helper function using existing dom action events.

 

If a drag event did already exist, it would just be a combination of other existing events. Think about it, you hover over the element you want to drag, click and hold the element, move your cursor to the destination, and then release in that order.

 

I first created a file called interactionUtils.js in my .storybook folder and imported fireEvent from @testing-library/dom. In theory, other event libraries could work as well, this is just what I used.

 

/* interactionUtils.js */
import { fireEvent } from '@testing-library/dom';

 

We will be using fireEvent's mouse events to actually perform the actions that create a drag, but first we have to setup some helper functions to make using them easier.

 

I created a function called getElementCenter which does what the name suggests. During the drag flow, this will help to determine where the drag should start and end based on the center of the source and destination elements respectively.

 

const getElementCenter = (element) => {
    const {left, top, width, height} = element.getBoundingClientRect()
    return {
        x: left + width / 2,
        y: top + height / 2,
    }
};

 

Next, I setup another helper function to ensure the drag happens at a reasonable speed. For the purposes of UI testing in Storybook, the drag simulation would be pretty useless if it finished instantly.

 

const sleep = (ms) => new Promise(
    (resolve) => setTimeout(resolve, ms)
);

 

Lastly, I implemented the drag function which is what is actually called inside of the story. This function uses the helper functions above to calculate the start and end points of the drag and then drag the target element incrementally according to a desired speed. I setup parameter values steps and duration to make the drag speed easily modifiable for future use cases.

 

export const drag = async (fromElement, toElement, steps, duration) => {
    // points for calculating drag path
    const fromCenter = getElementCenter(fromElement);
    const toCenter = getElementCenter(toElement);
    const step = {
        x: (toCenter.x - fromCenter.x) / steps,
        y: (toCenter.y - fromCenter.y) / steps,
    }
    // current point of the mouse
    const current = {
        clientX: fromCenter.x,
        clientY: fromCenter.y,
    }
    // drag fromElement to toElement
    fireEvent.mouseEnter(fromElement, current);
    fireEvent.mouseOver(fromElement, current);
    fireEvent.mouseMove(fromElement, current);
    fireEvent.mouseDown(fromElement, current);
    for (let i = 0; i < steps; i++) {
        current.clientX += step.x
        current.clientY += step.y
        await sleep(duration / steps)
        fireEvent.mouseMove(fromElement, {
            clientX: fromCenter.x + step.x * i,
            clientY: fromCenter.y + step.y * i
        });
    }
    fireEvent.mouseUp(fromElement, {
        clientX: toElement.x,
        clientY: toElement.y
    });
}

 

That's it! You can call the drag function inside of your story with the appropriate parameters. For example, I wanted to simulate a user dragging a card from their hand to the discard pile in order to discard their card at a reasonable speed. My play function looks like

 

play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    const discard = canvas.getByTestId("discard")
    const card = canvas.getByTestId("hand-2-10")
    await drag(card, discard, 100, 500)
}

 

🍻