HTML Drag and Drop API with Phoenix LiveView
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.
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 draggable
s. Those can be grabbed with a mouse and released on droppable
s, 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
- declared as draggable with
- another element as drop target
- implements the
ondrop
handler, e.g. to add the dragged element to its children
- implements the
<div id="my-draggable" draggable="true" ondragstart="onDragStart(event)">
A draggable
</div>
<div id="dropzone-a" ondrop="onDrop(event)">Drop Zone</div>
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 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.
# ...
@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 thename
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’ssize
and it’sx-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
andx
) withphx-value-*
to access it in thedragStart
function. We do this because we also use those values in aphx-click
handler attached to the ship elements. This handler will be used to allow changing the ship’s direction from vertical to horizontal.
# ...
@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
.
# ...
@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
.
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.
@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!