tutorial // Jun 10, 2022

How to Build a Drag and Drop UI with SortableJS

How to build a simple drag-and-drop shopping cart UI with a list of items and a cart to drop them into.

How to Build a Drag and Drop UI with SortableJS

Getting Started

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start. Before you do that, we need to install one dependency sortablejs:

Terminal

cd app && npm i sortablejs

After that, you can start up your app:

Terminal

joystick start

After this, your app should be running and we're ready to get started.

Adding a component for store items

To kick things off, we're going to jump ahead a little bit. In our store, our goal will be to have a list of items that can be dragged-and-dropped into a cart. To keep our UI consistent, we want to use the same design for the items in the store as we do in the cart.

To make this easy, let's start by creating a StoreItem component that will display each of our cart items.

/ui/components/storeItem/index.js

import ui from '@joystick.js/ui';

const StoreItem = ui.component({
  css: `
    div {
      position: relative;
      width: 275px;
      border: 1px solid #eee;
      padding: 15px;
      align-self: flex-end;
      background: #fff;
      box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.02);
    }

    div img {
      max-width: 100%;
      height: auto;
      display: block;
    }

    div h2 {
      font-size: 18px;
      margin: 10px 0 0;
    }

    div p {
      font-size: 15px;
      line-height: 21px;
      margin: 5px 0 0 0;
      color: #888;
    }

    div button {
      position: absolute;
      top: 5px;
      right: 5px;
      z-index: 2;
    }
  `,
  events: {
    'click .remove-item': (event, component = {}) => {
      if (component.props.onRemove) {
        component.props.onRemove(component.props.item.id);
      }
    },
  },
  render: ({ props, when }) => {
    return `
      <div data-id="${props.item?.id}">
        ${when(props.onRemove, `<button class="remove-item">X</button>`)}
        <img src="${props.item?.image}" alt="${props.item?.name}" />
        <header>
          <h2>${props.item?.name} &mdash; ${props.item?.price}</h2>
          <p>${props.item?.description}</p>
        </header>
      </div>
    `;
  },
});

export default StoreItem;

Because this component is fairly simple, we've output the entire thing above.

Our goal here is to render a card-style design for each item. To start, down in the render() function of the component above, we return a string of HTML which will represent the card when it's rendered on screen.

First, on the <div></div> tag starting our HTML, we add a data-id attribute set to the value props.item.id. If we look at our render() function definition we can see that we're expecting a value to be passed—an object representing the component instance—that we can destructure with JavaScript.

On that object, we expect a props value which will contain the props or properties passed to our component as an object. On that object, we expect a prop item which will contain the current item we're trying to render (either in the store or in the cart).

Here, the data-id attribute that we're setting to props.item.id will be utilized to identify which item is being added to the cart when it's dragged and dropped in our UI.

Next, we make use of Joystick's when() function (known as a render function) which helps us to conditionally return some HTML based on a value. Here, we're passing props.onRemove as the first argument (what we want to test for "truthiness") and, if it exists, we want to render a <button></button> for removing the item. Because we're going to reuse this component for both our cart and our store items, we want to make the rendering of the remove button conditional as it only applies to items in our cart.

The rest of our HTML is quite simple. Using the same props.item value, we render the image, name, price, and description from that object.

Up above this, in the events object—where we define JavaScript event listeners for our component—we're defining an event listener which listens for a click event on our <button></button>'s class .remove-item. If a click is detected, Joystick will call the function we pass to click .remove-item.

Inside of that function, we check to see if the component has a component.props.onRemove value. If it does we want to call that function, passing in the component.props.item.id, or, the ID of the item we're trying to remove from the cart.

Finally, at the top of our component, to make things look nice, we've added the necessary CSS to give our component a card-style appearance.

Moving on, next, we want to start getting the main Store page wired up. Before we do, real quick we need to modify our routes on the server to render the store page we're going to create next.

Modifying the index route

We need to make a small change to the routes that were automatically added for us as part of our project template when we ran joystick create app above. Opening up the /index.server.js file at the root of the project, we want to change the name of the page that we're passing to res.render() for the index / route:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/store/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

Here, we want to modify the call to res.render() inside of the handler function passed to the "/" route, swapping the ui/pages/index/index.js path for ui/pages/store/index.js.

Note: this change is arbitrary and only for adding context to our work. If you wish, you can leave the original route intact and modify the page at /ui/pages/index/index.js with the code we'll look at below.

Next, let's wire up the page with our store and cart where we'll implement our drag-and-drop UI at that path.

Adding a component for our store

Now for the important stuff. Let's start by creating the component we assumed would exist at /ui/pages/store/index.js:

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';

const items = [
  { id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
  { id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
  { id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];

const Store = ui.component({
  state: {
    cart: [],
  },
  css: `
    .store-items {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      grid-column-gap: 20px;
      list-style: none;
      width: 50%;
      padding: 40px;
      margin: 0;
    }

    .cart {
      display: flex;
      background: #fff;
      border-top: 1px solid #eee;
      position: fixed;
      bottom: 0;
      left: 0;
      right: 0;
      padding: 25px;
      min-height: 150px;
      text-align: center;
      color: #888;
    }

    .cart footer {
      position: absolute;
      bottom: 100%;
      right: 20px;
      padding: 10px;
      border: 1px solid #eee;
      background: #fff;
    }

    .cart footer h2 {
      margin: 0;
    }

    .cart-items {
      width: 100%;
      display: flex;
      position: relative;
      overflow-x: scroll;
    }

    .cart-items > div:not(.placeholder):not(:last-child) {
      margin-right: 20px;
    }

    .cart-items .placeholder {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  `,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  // We'll handle removing the item here.
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: ${/*  We'll handle removing the item here. */}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

Going from the top, first, we import the StoreItem component that we create above. Just beneath this, we create a static list of items as an array of objects, with each object representing one of the items available in our store. For each item, we have an id, an image, a name, a price, and a description.

Just beneath this, we define our component using the ui.component() function provided by the imported ui object from @joystick.js/ui at the top of the page. To it, we pass an options object describing our component. At the top of that, we kick things off by defining a default state value for our component, adding an empty array for cart (this is where our "dropped" items from the store will live).

This will allow us to start using state.cart down in our render() function without any items in it (if we didn't do this, we'd get an error at render time that state.cart was undefined).

Just below this, we've added some css for our store items and our cart. The outcome of this is a horizontal list for our store items and for our a cart, a "bin" fixed to the bottom of the screen where we can drag items.

The key part here is the render() function. Here, we see a repeat of some of the patterns we learned about when building our StoreItem component. Again, in our render(), we return the HTML that we want to render for our component. Focusing on the details, we're leveraging an additional render function in addition to the when() function we learned about earlier: each(). Like the name implies, for each of x items, we want to render some HTML.

Inside <div class="store-items"></div>, we're calling to each() passing the static items list we created at the top of our file as the first argument and for the second, a function for each() to call for each item in our array. This function is expected to return a string of HTML. Here, to get it, we return a call to another render function component() which helps us to render another Joystick component inside of our HTML.

Here, we expect component() to take our StoreItem component (imported at the top of our file) and render it as HTML, passing the object we've passed as the second argument here as its props value. Recall that earlier, we expect props.item to be defined inside of StoreItem—this is how we define it.

Below this, we render out our cart UI, utilizing when() again to say "if our cart doesn't have any items in it, render a placeholder message to guide the user."

After this, we use each() one more time, this time looping over our state.cart value and again, returning a call to component() and passing our StoreItem component to it. Again, we pass item as a prop and in addition to this, we pass the onRemove() function we anticipated inside of StoreItem that will render our "remove" button on our item.

Next, we have two placeholder comments to replace: what to do when onRemove() is called and then, at the bottom of our render(), providing a total for all of the items in our cart.

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import StoreItem from '../../components/storeItem';

const items = [
  { id: 'apple', image: '/apple.png', name: 'Apple', price: 0.75, description: 'The original forbidden fruit.' },
  { id: 'banana', image: '/banana.png', name: 'Banana', price: 1.15, description: 'It\'s long. It\'s yellow.' },
  { id: 'orange', image: '/orange.png', name: 'Orange', price: 0.95, description: 'The only fruit with a color named after it.' },
];

const Store = ui.component({
  state: {
    cart: [],
  },
  methods: {
    getCartTotal: (component = {}) => {
      const total = component?.state?.cart?.reduce((total = 0, item = {}) => {
        return total += item.price;
      }, 0);

      return total?.toFixed(2);
    },
    handleRemoveItem: (itemId = '', component = {}) => {
      component.setState({
        cart: component?.state?.cart?.filter((cartItem) => {
          return cartItem.id !== itemId;
        }),
      });
    },
  },
  css: `...`,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  methods.handleRemoveItem(itemId);
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: ${methods.getCartTotal()}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

Making a slight change here, now, we're calling to methods.handleRemoveItem() passing in the itemId we expect to get back from StoreItem when it calls the onRemove function for an item. Down at the bottom, we've also added a call to methods.getCartTotal().

In a Joystick component, methods are miscellaneous functions that we can call on our component. Up in the methods object we've added, we're defining both of these functions.

For getCartTotal() our goal is to loop over all of the items in state.cart and provide a total for them. Here, to do it, we use a JavaScript reduce function to say "starting from 0, for each item in state.cart, return the current value of total plus the value of the current item's price property.

For each iteration of .reduce() the return value becomes the new value of total which is then passed on to the next item in the array. When it's finished, reduce() will return the final value.

Down in handleRemoveItem(), our goal is to filter out any items our user wants to remove from state.cart. To do it, we call to component.setState() (Joystick automatically passed the component instance as the final argument after any arguments we've passed to a method function), overwriting cart with the result of calling to component.state.filter(). For .filter() we want to only keep the items with an id that does not match the passed itemId (i.e., filter it out of the cart).

With that, we're ready for the drag-and-drop. Let's see how it's wired up and then take our UI for a spin:

/ui/pages/store/index.js

import ui from '@joystick.js/ui';
import Sortable from 'sortablejs';
import StoreItem from '../../components/storeItem';

const items = [...];

const Store = ui.component({
  state: {
    cart: [],
  },
  lifecycle: {
    onMount: (component = {}) => {
      const storeItems = component.DOMNode.querySelector('.store-items');
      const storeCart = component.DOMNode.querySelector('.cart-items');

      component.itemsSortable = Sortable.create(storeItems, {
        group: {
          name: 'store',
          pull: 'clone',
          put: false,
        },
        sort: false,
      });

      component.cartSortable = Sortable.create(storeCart, {
        group: {
          name: 'store',
          pull: true,
          put: true,
        },
        sort: false,
        onAdd: (event) => {
          const target = event?.item?.querySelector('[data-id]');
          const item = items?.find(({ id }) => id === target?.getAttribute('data-id'));

          // NOTE: Remove the DOM node that SortableJS added for us before calling setState() to update
          // our list. This prevents the render from breaking.
          event?.item?.parentNode.removeChild(event.item);

          component.setState({
            cart: [...component.state.cart, {
              ...item,
              id: `${item.id}-${component.state?.cart?.length + 1}`,
            }],
          });
        },
      });
    },
  },
  methods: {...},
  css: `...`,
  render: ({ component, each, when, state, methods }) => {
    return `
      <div class="store">
        <div class="store-items">
          ${each(items, (item) => {
            return component(StoreItem, { item });
          })}
        </div>
        <div class="cart">
          <div class="cart-items">
            ${when(state.cart.length === 0, `
              <div class="placeholder">
                <p>You don't have any items in your cart. Drag and drop items from above to add them to your cart.</p>
              </div>
            `)}
            ${each(state.cart, (item) => {
              return component(StoreItem, {
                item,
                onRemove: (itemId) => {
                  methods.handleRemoveItem(itemId);
                },
              });
            })}
          </div>
          <footer>
            <h2>Total: ${methods.getCartTotal()}</h2>
          </footer>
        </div>
      </div>
    `;
  },
});

export default Store;

Above, we've added an additional property to our component options lifecycle, and on that, we've added a function onMount. Like the name suggests, this function is called by Joystick when our component is initially rendered or mounted in the browser.

For our drag-and-drop, we want to use this because we need to ensure that the elements we want to turn into drag-and-drop lists are actually rendered in the browser—if they're not, our Sortable will have nothing to "attach" its functionality to.

Inside of onMount, we take in the component instance (automatically passed to us by Joystick) and make two calls to component.DOMNode.querySelector(), one for our store-items list and one for our cart-items list.

Here, component.DOMNode is provided by Joystick and contains the actual DOM element representing this component as it's rendered in the browser. This allows us to interact with the raw DOM (as opposed to the Joystick instance or virtual DOM) directly.

Here, we're calling to .querySelector() on that value to say "inside of this component, find us the element with the class name store-items and the element with the class name cart-items. Once we have these, next, we create our Sortable instances for each list (these will add the necessary drag-and-drop functionality) by calling Sortable.create() and passing the element we retrieved from the DOM as either storeItems or storeCart.

For the first Sortable instance—for storeItems—our definition is a bit simpler. Here, we specify the group property which allows us to create a "linked" drag and drop target using a common name (here we're using store). It also allows us to configure the behavior of the drag-and-drop for this list.

In this case, we want to "clone" elements from our shop list when we drag them (as opposed to moving them entirely) and we do not want to allow items to be put back into the list. Additionally, we do not want our list to be sortable (meaning the order can be changed by dragging and dropping).

Beneath this, for our second sortable instance, we follow a similar pattern, however under the group setting, for pull we pass true and for put we pass true (meaning items can be pulled and put into this list via drag-and-drop). Similar to our store items list, we also disable sort.

The important part here is the onAdd() function. This is called by Sortable whenever a new item is added or dropped into a list. Our goal here is to acknowledge the drop event and then add the item that was dropped into our cart on state.

Because Sortable modifies the DOM directly when dragging and dropping, we need to do a little bit of work. Our goal is to only let Joystick render the list of items in our cart into the DOM. To do it, we have to dynamically remove the DOM items that Sortable adds before we update our state so that we don't break the render.

To get there, we take in the DOM event passed to us by sortable and locate the list item we're trying to add to our cart in the DOM. To do it, we call .querySelector() on event.item—the DOM element representing the dropped item in Sortable—and look for an element inside of that with a data-id attribute (the store item).

Once we have this, we do a JavaScript Array.find() on our static items list we defined earlier to see if we can find any objects with an id matching the value of data-id on the dropped element.

If we do, next, like we hinted at above, we remove the DOM element created in our list by Sortable with event?.item?.parentNode.removeChild(event.item). Once this is done, we call to update our component state with component.setState() setting cart equal to an array that spreads (copies) the current contents of component.state.cart and adds in a new object which consists of the found item (we use the JavaScript spread ... operator to "unpack the contents of it onto a new object) and an id which is the id of the item being dropped followed by -${component.state?.cart?.length + 1}.

We do this because the id of items in our cart needs to have some uniqueness to it if and when we drag multiples of the same item into the cart (here we just suffix a number on the end to make it just unique enough).

That's it! Now, when we drag an item from our store list down to our cart, we'll see the item added automatically. We'll also see the total we rendered via methods.getCartTotal() update with the new value.

Wrapping up

In this tutorial, we learned how to wire up a drag-and-drop UI using SortableJS. We learned how to create a page with two separate lists, connecting them together as a group, and learning how to manage the drag-and-drop interaction between them. We also learned how to leverage state inside of a Joystick component to render out items dynamically based on user interaction.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode