LiveView Drag and Drop with SortableJS

LiveView hooks provide a powerful way to seamlessly integrate JavaScript libraries into LiveView applications. In this post, we’ll integrate SortableJS to build a list component with draggable items.

Defining a live_component

We begin by defining a :live_component called ListComponent, along with its two main callbacks, render/1 and update/2:

defmodule ComponentsExamplesWeb.ListComponent do
  use ComponentsExamplesWeb, :live_component

def render(assigns) do
    ~H"""
    <div class="bg-gray-100 py-4 rounded-lg">
      <div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">

</div>
    </div>
    """
  end

def update(assigns, socket) do
    {:ok, assign(socket, assigns)}
  end
end

Next, we add some elements to our component. The component has three main sections: 1) a header with the list title and an input for adding new elements, 2) the list of draggable elements, and 3) a button for clearing the list.

def render(assigns) do
  ~H"""
  <div class="bg-gray-100 py-4 rounded-lg">
    <div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
+      <.header>
+        <%= @list_name %>
+        <.simple_form
+          for={@form}
+          phx-change="validate"
+          phx-submit="save"
+          phx-target={@myself}
+        >
+          <.input field={@form[:name]} type="text" />
+          <:actions>
+            <.button class="align-middle ml-2">
+              <.icon name="hero-plus" />
+            </.button>
+          </:actions>
+        </.simple_form>
+     </.header>
    </div>
  </div>
  """
end

Now the part we’re interested in today: the items list!

def render(assigns) do
  ~H"""
  <div class="bg-gray-100 py-4 rounded-lg">
    <div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
      <.header>
        ...
      </.header>
+      <div id={"#{@id}-items"}>
+        <div
+          :for={item <- @list}
+          id={"#{@id}-#{item.id}"}
+          class="..."
+        >
+          <div class="flex">
+            <button type="button" class="w-10">
+              <.icon
+                name="hero-check-circle"
+                class={[
+                  "w-7 h-7",
+                  if(item.status == :completed, do: "bg-green-600", else: "bg-gray-300")
+                 ]}
+              />
+            </button>
+            <div class="flex-auto block text-sm leading-6 text-zinc-900">
+              <%= item.name %>
+            </div>
+            <button type="button" class="w-10 -mt-1 flex-none">
+             <.icon name="hero-x-mark" />
+           </button>
+         </div>
+       </div>
      </div>
    </div>
  </div>
  """
end

Adding SortableJS to our LiveView app

There is an existing JavaScript library called SortableJS that provides drag-and-drop functionality for elements inside an HTML tag. Let’s add SortableJS to our LiveView application!

  1. Go to the SortableJS source repository and locate the sortable.js file.
  2. Copy the sortable.js file to the /assets/vendor/ directory in your Phoenix project.

Next, we need to import the Sortable library. You can do this by adding the following line at the top of the app.js file:

  import {Socket} from "phoenix"
  import {LiveSocket} from "phoenix_live_view"
  import topbar from "../vendor/topbar"
+ import Sortable from "../vendor/sortable"

Once the Sortable library is imported, we can use it in our component by defining a Hook in the same file:

let Hooks = {}

Hooks.Sortable = {
  mounted(){
    let sorter = new Sortable(this.el, {
      animation: 150,
      delay: 100,
      dragClass: "drag-item",
      ghostClass: "drag-ghost",
      forceFallback: true,
      onEnd: e => {
        let params = {old: e.oldIndex, new: e.newIndex, ...e.item.dataset}
        this.pushEventTo(this.el, "reposition", params)
      }
    })
  }
}

let liveSocket = new LiveSocket("/live",
                 Socket,
                 {params: {_csrf_token: csrfToken}, hooks: Hooks}
               )

Using SortableJS in LiveView components

We add a couple of lines to our component:

def render(assigns) do
  ~H"""
  <div class="bg-gray-100 py-4 rounded-lg">
    <div class="space-y-5 mx-auto max-w-7xl px-4 space-y-4">
      <.header>
        ...
      </.header>
-     <div id={"#{@id}-items"}>
+     <div id={"#{@id}-items"} phx-hook="Sortable" data-list_id={@id}>
        <div
          :for={item <- @list}
          id={"#{@id}-#{item.id}"}
          class="..."
+         data-id={item.id}
        >
          ...
        </div>
      </div>
    </div>
  </div>
  """
end

Formatting draggable list items

Fixing this detail is simple. We’ve already specified the CSS classes to format the drop placeholder and the dragged article. However, instead of defining new CSS classes, we can leverage the ones already defined by Tailwind.

Bonus: multiple lists

What if we want to drag items between different lists? It is an option that Sortable already has, we just have to configure it:

Hooks.Sortable = {
+ let group = this.el.dataset.group
  mounted(){
    let sorter = new Sortable(this.el, {
+     group: group ? group : undefined,
      animation: 150,
      delay: 100,
      dragClass: "drag-item",
      ghostClass: "drag-ghost",
      forceFallback: true,
      onEnd: e => {
        let params = {old: e.oldIndex, new: e.newIndex, ...e.item.dataset}
        this.pushEventTo(this.el, "reposition", params)
      }
    })
  }
}

Discussion

In this post, we’ve learned how to set up SortableJS in our LiveView app and use it in our live components. But here’s the thing - we’re not actually doing anything with the data we send from the client to the server yet. We still need to figure out how to persist our elements and the position changes.