Creating a Modal LiveView LiveComponent
In this article, we will create a reusable and configurable modal dialog that we can use throughout our app. We will implement the modal as a LiveComponent and along the way we will touch on several other important LiveView features including push_patch, browser pushState, and JavaScript hooks.
If you aren’t familiar with LiveView LiveComponents, you might want to read [Introduction to Phoenix LiveView LiveComponents][introduction-article] before proceeding with this article.
You can find the code for this article [on GitHub][github-repo].
[Sept. 7, 2020 - Article and [code][github-repo] updated to LiveView v0.14.4]
Our Example #
For our example, we will modify Chris McCord’s counter example from his [LiveView Example Repo][liveview-examples]. This is a simple LiveView example with buttons to increment and decrement a counter and another one, called Boom, to crash the counter.
We will modify the example to display a modal dialog when the user clicks the Boom button. The modal, rendered on its own URL, will ask the user if they really want to crash the counter. If they agree then the counter will crash; otherwise, if they respond no or hit the back button then the counter will be preserved.
Implementation Overview #
Our modal LiveComponent will be rendered on its own URL using browser pushState. It will overlay the screen with a semi-transparent overlay to prevent the user from interacting with the underlying page and the modal dialog will be in the foreground. This will all be implemented from a single server-side LiveView process.
The modal itself will have a title, some body text, and 2 buttons, all configurable. In response to the modal button clicks, the modal will callback on the hosting LiveView to respond.
The component can be called like this:
<%= live_component(@socket,
ModalLive,
id: "confirm-boom",
title: "Go Boom",
body: "Are you sure you want to crash the counter?",
right_button: "Sure",
right_button_action: "crash",
right_button_param: "boom",
left_button: "Yikes, No!",
left_button_action: "cancel-crash")
%>
A required button action string and optional button parameter are given for each button in the live_component/3
assigns. These are stored in the component and returned to the caller in a message to self when the corresponding button is clicked.
The caller must define message handlers, which take the action and parameter given for each button. For example:
def handle_info({ModalLive,
:button_clicked,
%{action: "crash", param: exception}
},
socket)
Flow of Control #
The flow of control from the click of the Boom button, to the rendering of the LiveComponent modal, to the click of the modal buttons and the
dismissal of the modal goes like this:
- The user clicks the Boom button, which fires
handle_event/3
on the Counter LiveView. handle_event/3
callspush_patch/2
with the /counter/confirm-boom route.- The
push_patch/2
is to the same LiveView, which comes back in on thehandle_params/3
function. handle_params/3
, seeing that we came in on the /counter/confirm-boom URL, sets:show_modal
to true.:show_modal
is a socket assign indicating that the modal should be displayed.- The change in the socket assign causes
render/1
to be called, which results inlive_component/3
being called and the modal being rendered. - When the user clicks on one of the modal buttons,
handle_event/3
is called in the LiveComponent. handle_event/3
sends a message to self (passing the given button action and param), which comes in on a Counter LiveViewhandle_info/2
function.- The
handle_info/2
function responds to the message and then callspush_patch/2
with the base/counter
URL. - The
push_patch/2
is again to the same LiveView, which comes back in on thehandle_params/3
function. handle_params/3
, seeing that we came in on the base /counter URL, sets the:show_modal
socket assign to false, resulting in the Counter LiveView being re-rendered without the modal overlay.
Modal LiveComponent Implementation #
Initializing the Component #
When live_component/3
is called to render the component, mount/1
and update/2
are called in the component. The Modal component’s update/2
function merges the passed in assigns with a set of defaults, giving preference to the passed in assigns.
defmodule DemoWeb.LiveComponent.ModalLive do
use Phoenix.LiveComponent
@defaults %{
left_button: "Cancel",
left_button_action: nil,
left_button_param: nil,
right_button: "OK",
right_button_action: nil,
right_button_param: nil
}
def mount(socket) do
{:ok, socket}
end
def update(%{id: _id} = assigns, socket) do
{:ok, assign(socket, Map.merge(@defaults, assigns))}
end
Rendering the Modal #
The render/1
function is much as you might expect. It renders a semi-transparent background fixed to the top of the screen with the modal centered in the foreground.
def render(assigns) do
~L"\""
<div id="modal-<%= @id %>">
<!-- Modal Background -->
<div class="modal-container"
phx-hook="ScrollLock">
<div class="modal-inner-container">
<div class="modal-card">
<div class="modal-inner-card">
<!-- Title -->
<%= if @title != nil do %>
<div class="modal-title">
<%= @title %>
</div>
<% end %>
<!-- Body -->
<%= if @body != nil do %>
<div class="modal-body">
<%= @body %>
</div>
<% end %>
<!-- Buttons -->
<div class="modal-buttons">
<!-- Left Button -->
<button class="left-button"
type="button"
phx-click="left-button-click"
phx-target="#modal-<%= @id %>">
<div>
<%= @left_button %>
</div>
</button>
<!-- Right Button -->
<button class="right-button"
type="button"
phx-click="right-button-click"
phx-target="#modal-<%= @id %>">
<div>
<%= @right_button %>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
Notice that the button elements have phx-click
and phx-target
attributes, which will trigger handle_event/3
to be called with the associated events on the target component.
The phx-hook
attribute on the modal container div
registers a JavaScript hook with the background container, which we will discuss in the next section.
The example is styled using vanilla CSS. We won’t show all of the CSS here, but for the semi-transparent overlay container and the foreground container.
/* Semi-transparent overlay container */
.modal-container {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
z-index: 998;
background-color: rgba(0, 0, 0, 0.7);
}
/* Foreground container */
.modal-inner-container {
position: relative;
z-index: 999;
}
You can see the rest of the CSS in the [GitHub repo][github-repo] for this article.
Side note: For a real-world project we could provide finer customization of the modal using a utility-first CSS framework like [Tailwind CSS][tailwind-css].
ScrollLock JavaScript Hook #
The phx-hook="ScrollLock"
that you see in the modal container HTML associates a JavaScript hook named “ScrollLock” with the element.
The ScrollLock
hook prevents the background view from being scrolled while the modal is in the foreground. In our example, the background view is the Counter underneath the modal.
This is how the ScrollLock
hook works:
import css from '../css/app.css'
import 'phoenix_html'
import {
Socket
} from 'phoenix'
import {
LiveSocket
} from 'phoenix_live_view'
// Define hooks
const Hooks = {}
Hooks.ScrollLock = {
mounted() {
this.lockScroll()
},
destroyed() {
this.unlockScroll()
},
lockScroll() {
// From https://github.com/excid3/tailwindcss-stimulus-components/blob/master/src/modal.js
// Add right padding to the body so the page doesn't shift when we disable scrolling
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
document.body.style.paddingRight = `${scrollbarWidth}px`
// Save the scroll position
this.scrollPosition = window.pageYOffset || document.body.scrollTop
// Add classes to body to fix its position
document.body.classList.add('fix-position')
// Add negative top position in order for body to stay in place
document.body.style.top = `-${this.scrollPosition}px`
},
unlockScroll() {
// From https://github.com/excid3/tailwindcss-stimulus-components/blob/master/src/modal.js
// Remove tweaks for scrollbar
document.body.style.paddingRight = null
// Remove classes from body to unfix position
document.body.classList.remove('fix-position')
// Restore the scroll position of the body before it got locked
document.documentElement.scrollTop = this.scrollPosition
// Remove the negative top inline style from body
document.body.style.top = null
}
}
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
params: {
_csrf_token: csrfToken
},
hooks: Hooks
});
liveSocket.connect()
The ScrollLock
isn’t essential for the modal to work, but it makes the modal feel more “solid” and in-control of the screen when the user can’t interact with the underlying view in any way.
The hook implements the mounted
and destroyed
functions, which get called, respectively, when the element is added and removed from the DOM. For our example, this corresponds to when the Modal is added and removed. Scrolling of the underlying view is disabled in the mounted
call and re-enabled it in the destroyed
call.
For our example, scrolling of the Counter view will be locked when the modal is shown and unlocked when it is removed.
Credit: The scroll lock JavaScript code above was snagged from Chris Oliver at https://github.com/excid3/tailwindcss-stimulus-components/blob/master/src/modal.js.
Modal Button Event Handlers #
The modal button handle_event/3
functions are defined in the Modal LiveComponent, since this is a stateful component.
Here is the event handler for the right button click. The left button is similar.
def handle_event(
"right-button-click",
_params,
%{
assigns: %{
right_button_action: right_button_action,
right_button_param: right_button_param
}
} = socket
) do
send(
self(),
{__MODULE__,
:button_clicked,
%{action: right_button_action, param: right_button_param}}
)
{:noreply, socket}
end
When a button is clicked, the event handler sends a message to self, passing the button’s action and param assigns that were passed into the component when it was initialized. The structure of the message is a tuple with the modal’s module name, the atom :button_pressed
, and a map containing the action and param.
Counter LiveView Implementation (Using the Modal LiveComponent) #
Our Counter LiveView implementation will be premised on the following:
- The same LiveView can be called with multiple URLs. We will adopt the convention that our secondary URLs, e.g., /counter/confirm-boom, will be subpaths of our primary URL, /counter.
- A LiveView can
push_patch
to itself while staying in the same process. - We can implement the
handle_params/3
function such that it “routes” to specifichandle_params/4
functions to handle each unique URL path. - The path specific
handle_params/4
functions set socket assigns that are used in therender/1
function to conditionally determine what to render. (Note, we could also choose to set the socket assigns before callingpush_patch/2
to the same effect.) - LiveComponents can send messages back to their parent LiveView.
Multiple URLs to the Same LiveView #
Routes #
In order for a LiveView to respond to multiple URLs, we need to setup the routes in router.ex.
For our example, we will specify the /counter and /counter/confirm-boom live routes, which both point to the CounterLive LiveView module.
scope "/", DemoWeb do
pipe_through :browser
live "/counter", CounterLive, :show
live "/counter/confirm-boom", CounterLive, :confirm_boom
When we specify our routes, we can give a live_action atom that will be available in the socket passed into handle_params
, which we will see below. This allows us to use the same LiveView with different routes and differentiate them in our handle_params
according to the live_action.
The Router helpers make our routes available according to the LiveView name and the live_action atom. Our two routes above will be available as:
Routes.counter_path(socket, :show)
and
Routes.counter_path(socket, :confirm_boom)
Side note: By creating our LiveView routes in router.ex, LiveView can navigate between LiveView pages over the same connection, delivering a significant speed advantage over traditional web apps.
handle_params/3 #
The LiveView’s handle_params/3
function gets called during live navigation. It takes the path and query parameters as the first argument, the URL as the second argument, and the socket as the third. If there is a live_action associated with our route, it will be available in the socket assigns.
From our handle_params/3
we will call apply_action/3
to handle each case. The apply_action
is passed the socket, along with the params and :live_action
, and returns the socket.
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
apply_action/3 #
Now we can create apply_action/3
functions to handle each particular URL.
def apply_action(socket, :show, _params) do
assign(socket, show_modal: false)
end
def apply_action(%{assigns: %{show_modal: _}} = socket, :confirm_boom, _params) do
assign(socket, show_modal: true)
end
def apply_action(socket, _live_action, _params) do
push_patch(socket,
to: Routes.counter_path(socket, :show),
replace: true
)
end
The first apply_action/3
function matches on the :show
live_action (the /counter path). All it does is set the :show_modal
socket assign to false
.
The second apply_action/3
function matches on the :confirm_boom
live_action (the /counter/confirm-boom path) and the :show_modal
socket assign. It sets the :show_modal
assign to true
, which will be used in the render/1
function to conditionally render the modal over the main view.
The thirdapply_action/3
function is a catch-all that redirects to the base counter path (live_action of :show
) if the path is unknown or if the :show_modal
socket assign is not set. The :show_modal
assign might not be set if the Counter LiveView wasn’t initialized properly, which can happen if the user comes in on the /counter/confirm-boom URL directly, without going through /counter, or if the counter crashes while on the /counter/confirm-boom URL.
Notice that replace: true
is passed to push_patch/2
. This means that the URL will be replaced on the pushState stack. This is done so that the invalid URL handled by the catch-all will not be preserved in the browsing history.
Rendering the Counter LiveView #
Our render/1
function renders the Counter web page and conditionally, based on the :show_modal
assign, displays the LiveComponent modal.
def render(assigns) do
~L"\""
<div>
<h1>The count is: <span><%= @val %></span></h1>
<button class="alert-danger"
phx-click="go-boom">Boom</button>
<button phx-click="dec">-</button>
<button phx-click="inc">+</button>
</div>
<%= if @show_modal do %>
<%= live_component(@socket,
ModalLive,
id: "confirm-boom",
title: "Go Boom",
body: "Are you sure you want to crash the counter?",
right_button: "Sure",
right_button_action: "crash",
right_button_param: "boom",
left_button: "Yikes, No!",
left_button_action: "cancel-crash")
%>
<% end %>
"""
end
The phx-click="go-boom"
on the Boom button is handled by an event handler, which sets the :show_modal
assign to true, causing the modal to be displayed.
The right_button_action
and left_button_action
assigns passed in the live_component
call will ultimately be sent back to the LiveView and handled by handle_info
functions that set :show_modal
to false, causing the modal to be hidden.
Counter Button Events #
These Counter LiveView handle_event/3
functions respond to the “+”, “-”, and “Boom” button events:
def handle_event("inc", _, socket) do
{:noreply, update(socket, :val, &(&1 + 1))}
end
def handle_event("dec", _, socket) do
{:noreply, update(socket, :val, &(&1 - 1))}
end
def handle_event("go-boom", _, socket) do
{:noreply,
push_patch(
socket,
to: Routes.confirm_boom_live_path(socket, DemoWeb.CounterLive),
replace: true
)}
end
The handle_event/3
implementations for the “+” and “-” buttons just increment and decrement the :val
socket assign.
The handle_event/3
for the Boom button does a push_patch/2
back to self on the confirm_boom_live_path
route with replace: true
.
Handling Messages from the Modal LiveComponent #
As we saw earlier, the Modal LiveComponent sends messages to self in response to the modal button clicks, passing the action
and param
associated with the respective buttons.
The messages are handled by handle_info/2
functions in the parent Counter LiveView.
def handle_info(
{ModalLive,
:button_clicked,
%{action: "crash", param: exception}},
socket
) do
raise(exception)
{:noreply, socket}
end
def handle_info(
{ModalLive,
:button_clicked,
%{action: "cancel-crash"}},
socket
) do
{:noreply,
push_patch(socket,
to: Routes.live_path(socket, DemoWeb.CounterLive),
replace: true
)}
end
When the modal button action is “crash”, the associated handle_info/2
function raises the exception specified by the button param (“boom” in our example.)
Note that because we are on the /counter/confirm-boom URL when the exception is raised, the LiveView will still be on the /counter/confirm-boom URL when the LiveView is restarted, which is not a valid state. This will result in the catch-all handle_params/4
function being called, which recovers by rerouting to /counter.
When the action is “cancel-crash”, the associated handle_info/2
function cancels the modal by doing a push_patch/2
to self on the base /counter URL.
Wrapping Up #
That’s it! If you read this far, the diagram we drew towards the beginning of the article should make more sense now.
We’ve seen that using LiveView LiveComponents, push_patch
to self, handle_params
“routing”, conditional rendering, browser pushState, a smattering of JavaScript hooks, and messages to self allow us to neatly partition functionality and create dynamic server-side web interfaces.
Thanks to Bruce Tate, Moxley Stratton, Ben Munat, and Elijah Kim for their feedback.
Links #
Introduction to Phoenix LiveView LiveComponents:
[http://blog.pthompson.org/liveview-livecomponents-introduction][introduction-article]
LiveComponent Examples Repo: [https://github.com/pthompson/live_component_examples][github-repo]
LiveComponent Documentation: [https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html][livecomponent-docs]
LiveView Documentation: [https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html ][liveview-docs]
LiveView GitHub Repo: [https://github.com/phoenixframework/phoenix_live_view][liveview-github]
LiveView Examples Repo: [https://github.com/chrismccord/phoenix_live_view_example][liveview-examples]
[introduction-article]: http://blog.pthompson.org/liveview-livecomponents-introduction “Introduction to Phoenix LiveView LiveComponents”
[github-repo]: https://github.com/pthompson/live_component_examples “LiveComponent Example Repo”
[liveview-docs]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html “Phoenix LiveView Documentation”
[livecomponent-docs]: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html “Phoenix LiveComponent Documentation”
[liveview-github]: https://github.com/phoenixframework/phoenix_live_view “Phoenix LiveView GitHub Repo”
[liveview-examples]: https://github.com/chrismccord/phoenix_live_view_example “Phoenix LiveView Examples Repo”
[tailwind-css]: https://tailwindcss.com/ “Tailwind CSS”