HTML Drag and Drop API with Phoenix LiveView

A small example of integrating the HTML Drag and Drop API with Phoenix LiveView via hooks.

published on March 5, 2021

With Phoenix LiveView it is possible to create real-time web UIs. For lots of use-cases, it is not necessary to write your own client-side JavaScript. LiveView supports multiple DOM element bindings for the client-server interaction, like click or key events.

With phx-value-* attributes, we can define what payload should be sent to the server.

<button phx-click="numpad" phx-value-number="1">1</button>
<button phx-click="numpad" phx-value-number="2">2</button>

On the server, we can handle a click on one of the buttons with

def handle_event("numpad", %{"number" => number}, socket)) do
  IO.puts("clicked button with number #{number}")
  {:noreply, socket}
end

In cases where we cannot use the "standard" bindings like phx-click, usually to integrate custom JavaScript, we can use client hooks.

In this article, we will show you how to use such hooks by integrating the HTML Drag and Drop API for a Battleship/SeaBattle game implementation.

You can find the complete code of the game on GitHub. A drag and drop example -
placing ships on the game field for the battleship
game.

The HTML Drag and Drop API

The Drag and Drop API is a nice way to enable applications to use drag-and-drop features in browsers. It works by using the DOM event model and drag events.

Elements (for example a div) can be declared as draggables. Those can be grabbed with a mouse and released on droppables, elements that handle the drop event. For a minimal drag-and-drop use case we have:

  • an element to grab
    • declared as draggable with draggable=true
    • an dragstart handler to do inital work, e.g. saving the elements id for later use
  • another element as drop target
    • implements the ondrop handler, e.g. to add the dragged element to its children
<div id="my-draggable" draggable="true" ondragstart="onDragStart(event)">
  A draggable
</div>
<div id="dropzone-a" ondrop="onDrop(event)">Drop Zone</div>

A drawing showcasing drag and drop. On the left is a container with multiple draggable
objects inside. One of the draggables is dragged into another container positioned on the right,
annotated with "drop zone".

Besides dragstart and drop, there are a couple of other drag events, for example dragenter and dragleave. Those two fire when a dragged item enters (or leaves) a valid drop target. An example usage of those is to dynamically add CSS classes for changing the appearance of the drop zone.

Data Transfer

Like mentioned already we can use a dragstart handler to "save" data for later use, for example in our drop handler. There is a special object to hold the data during a drag and drop operation, DataTransfer. All drag events have access to this object via the dataTransfer property.

Let's implement the event handlers we are using in the above html snippet and use DataTransfer to pass the draggable's id from the dragstart handler to the drop handler.

const onDragStart = (event) => {
  event.dataTransfer.setData('text/plain', event.target.id)
}

const onDrop = (event) => {
  event.preventDefault()
  // get the saved id of the draggable
  const draggableId = event.dataTransfer.getData('text')
  event.target.appendChild(document.getElementById(draggableId))
  event.dataTransfer.clearData()
}

We "save" the data with setData, which also gets the format, which is text in our case. With getData, we can then retrieve the previously set data.

Next, let's see how we can apply those things for our Battleship scenario.

5 Ships, 1 Grid and a Phoenix Hook

Battleship is a strategy type guessing game for two players. Each player has a fleet of 5 ships that have to be placed on a grid with 10 x 10 cells.

This placement of the ships on the grid should be made available via drag and drop. That means that our ships are the draggables and the cells of the grid are the drop zones.

The grid with its 10x10 cells, which are marked as droppables, and the 5 ships, which are marked as
draggables. The ship of size 5 was already put on the grid.

The use-case we have here is similar to the simple example in the previous section. We have draggables, the ships, that we want to move to another location in the DOM, our grid (or rather the cells).

This gives us

  • 5 ships/draggables, each with a specific size and
  • 10x10 drop zones, each with unique x-y-coordinates

In our drop handler we will have to make sure that the drop target is valid for the given ship:

  • ships are not allowed to overlap
  • the whole ship has to fit on the grid for the given cell

Only if those two conditions are met, the ship can be placed on the grid. Validating those conditions we want to do on the server. To achieve this we will add a client hook.

Before looking at how to implement the hook, let us create our grid and ships.

Rendering the Draggables and the DropZones

To render the grid we will use a HTML grid layout with 10 rows and 10 columns. For the ships we will also use a grid, but with only one column and size rows.

./lib/battleship_web/live/components/ship.ex
# ...
@impl true
def render(assigns) do
  ~L"""
  <div>
    <div
      id="<%= @name %>"
      draggable="<%= @draggable %>"
      ondragstart="dragStart(event)"
      class='bg-green-400 cursor-move grid grid-flor-row gap-1 <%= if !@draggable do %>opacity-50<% end %> <%= if @in_grid do %>absolute z-10<% else %>mx-1<% end %>'
      phx-value-x="<%= @x %>"
      phx-value-y="<%= @y %>"
      phx-value-size="<%= @size %>"
      phx-value-direction="<%= @direction %>"
      <%= if @in_grid do %>
        phx-click="toggle_direction"
      <% end %
      >
      <%= for _ <- 1..@size do %>
        <div class="w-8 h-8 border-green-600 border-2"></div>
      <% end %>
    </div>
  </div>
  """
end

We

  • set the id of the target element to the name of the ship, e.g. battleship
  • make it a draggable depending on the @draggable assignment. (This will be false when the game started, and ships cannot be moved anymore.)
  • listen for the dragstart event and handle it with the - yet to be defined - dragStart function
  • set Tailwind classes depending on a few conditionals to render the ships correctly. This has to be done a bit different once the ship is on the grid.
  • set a few phx-* attributes for the ship's size and it's x-y-coordinate on the grid. Those we can use to determine which cells a ship overlaps.
  • use a for loop to render the size cells making up the ship.
window.dragStart = (event) => {
  event.dataTransfer.setData(
    'text/plain',
    JSON.stringify({
      id: event.target.id,
      size: +event.target.getAttribute('phx-value-size'),
      direction: event.target.getAttribute('phx-value-direction'),
    })
  )
}

Like before in our simple example, we use the dragstart event to save some attributes of the draggable. This time not only the elements id, but also the ships size and direction.

NOTE

There is no reason to prefix the attributes (like size and x) with phx-value-* to access it in the dragStart function. We do this because we also use those values in a phx-click handler attached to the ship elements. This handler will be used to allow changing the ship's direction from vertical to horizontal.

./lib/battleship_web/live/components/field.ex
# ...
@impl true
def render(assigns) do
  ~L"""
  <div class="p-2 bg-blue-600 rounded-md shadow-lg">
    <div class="grid grid-cols-10 gap-1">
      <%= for y <- 0..9 do %>
        <%= for x <- 0..9 do %>
          <%= live_component @socket, BattleshipWeb.Components.Cell,
            x: x,
            y: y,
            ship: Map.get(@ships, {x, y}),
            do %>
            <%= live_component @socket, BattleshipWeb.Components.Ship,
              name: @ship.name,
              draggable: not @ready,
              x: x,
              y: y,
              size: @ship.size
            %>
          <% end %>
        <% end %>
      <% end %>
    </div>
  </div>
  """
end

To generate the grid, we render 10x10 cells, here with a Cell LiveComponent. A Cell gets passed the x-y-coordinates, and a ship in case one was placed on that cell. This ship is then rendered as part of the Cell.

./lib/battleship_web/live/components/cell.ex
# ...
 @impl true
def render(assigns) do
  ~L"""
  <div
    class="bg-blue-800 w-8 h-8 relative"
    phx-value-x="<%= @x %>"
    phx-value-y="<%= @y %>"
    <%= if @clickable do %>
      id="cell-<%= @x %>-<%= @y %>"
      phx-click="shoot"
    <% end %>
    <%= if not @game_started do %>
    phx-hook="Drag"
    id="#cell-<%= @x %>-<%= @y %>"
    <% end %>
  >
    <%= if @ship do %>
      <%= render_block(@inner_block, ship: @ship) %>
    <% end %>
  </div>
  """
end

We set phx-value-* attributes for the x and y values. (Again, the naming is not important for our Drag hook, but because we also use those values for the phx-click binding.)

In case the game has not started yet and ships can still be dragged around, we assign our phx-hook named Drag. In this hook we will handle the drop event of the drag and drop operation.

The Client Hook

Custom hooks are added by providing the hook property when instantiating the LiveSocket.

./assets/app.js
const Hooks = {
  Drag: {
    mounted() {},
  }
}
let liveSocket = new LiveSocket("/live", Socket, {
  params: {
    _csrf_token: csrfToken,
  },
  hooks: Hooks,
});

We added our Drag hook with a single method, mounted(). This method is one of multiple life-cycle callback's and the only one we need for our example. The mounted callback is triggered once the target element was added to the DOM and its server LiveView has finished mounting.

In callbacks, we have access to multiple attributes, for example el, a reference to the bound DOM node. There is also pushEvent(event, payload, (reply, ref) => ...) to push events from the client to the server. That is what we will use so that we can implement the ship placement validation in our LiveView.

const Hooks = {
  Drag: {
    mounted() {
      this.el.ondrop = (event) => {
        event.preventDefault()
        const ship = JSON.parse(event.dataTransfer.getData('text/plain'))
        const x = event.target.getAttribute('phx-value-x')
        const y = event.target.getAttribute('phx-value-y')
        if (ship && ship.id && ship.size && x && y) {
          this.pushEvent('add_ship', {
            x: +x,
            y: +y,
            ...ship,
          })
        }
      }
    },
  },
}

In the mounted() callback, we add our handler for the drop event to the drop target, the cell. We read the information of the dropped ship via DataTransfer and the coordinates of the cell, x and y from the phx-value-* attributes. When the values are valid, e.g. not null for some reason, we push them to our LiveView where we will handle the ship placement.

./lib/battleship_web/live/game_live.ex
@impl true
def handle_event("add_ship", %{"x" => x, "y" => y, "id" => id, "size" => size}, socket) do
  # check if ship can be placed on {x, y}

  # if valid: update socket
end

Testing the Hook

Now that we have our Drag-Hook working, we also want to test it. Fortunately, Phoenix LiveView comes with great testing support. It let's you render a LiveView and then render hooks:

test "user can place ship on grid", %{conn: conn} do

  # create game and authenticate `conn`
  # ...

  {:ok, view, _html} = live(conn, "/games/#{game.id}")
  view
  |> render_hook("add_ship", %{
    "x" => 1,
    "y" => 0,
    "id" => "battleship",
    "size" => 4,
  })

  view
  |> element("#cell-1-0 > div > #battleship")
  |> has_element?()
end

After executing our hook, we test if the div with the id battleship was rendered inside the cell div with the coordinates {1, 0}.

Limitations

With the built-in Phoenix LiveView testing we can test our hooks, but only server side. We cannot test that the client JavaScript also works and actually pushes the event to the server correctly (or can we?).

There are other tools that help us to write such tests, for example Wallaby. In this case, however, Wallaby would not help us, since there is no support for the Drag and Drop API. One option would be to reimplement our drag and drop with other mouse events for which there is support in Wallaby already. (We won't explore this further as part of this post.)

Another issue is that the HTML Drag and Drop API does not have very good support on mobile. This could also be solved by rewriting it like described in above blog post.

Conclusion

We explored

  • how to use the HTML Drag and Drop API to move DOM elements, declared as draggable, from one container to another, the drop target.
  • DataTransfer, to pass data across different drag events.
  • how to use Phoenix hooks to integrate our Drag and Drop code in our LiveView application.

We hope you enjoyed the post. We are still pretty new to Elixir, so if you have any suggestions, let us know!