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 before proceeding with this article.

You can find the code for this article on GitHub.

[Sept. 7, 2020 - Article and code updated to LiveView v0.14.4]

Our Example #

For our example, we will modify Chris McCord’s counter example from his LiveView Example Repo. This is a simple LiveView example with buttons to increment and decrement a counter and another one, called Boom, to crash the counter.

rFsvt1Y7ydRS6QefNPZ89J0xspap_small.png

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.

Screen Shot 2019-11-30 at 5.31.03 PM.png

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:

Screen Shot 2020-09-07 at 12.16.01 PM.png

  1. The user clicks the Boom button, which fires handle_event/3 on the Counter LiveView.
  2. handle_event/3 calls push_patch/2 with the /counter/confirm-boom route.
  3. The push_patch/2 is to the same LiveView, which comes back in on the handle_params/3 function.
  4. 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.
  5. The change in the socket assign causes render/1 to be called, which results in live_component/3 being called and the modal being rendered.
  6. When the user clicks on one of the modal buttons, handle_event/3 is called in the LiveComponent.
  7. handle_event/3 sends a message to self (passing the given button action and param), which comes in on a Counter LiveView handle_info/2 function.
  8. The handle_info/2 function responds to the message and then calls push_patch/2 with the base /counter URL.
  9. The push_patch/2 is again to the same LiveView, which comes back in on the handle_params/3 function.
  10. 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 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.

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.

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:

  1. 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.
  2. A LiveView can push_patch to itself while staying in the same process.
  3. We can implement the handle_params/3 function such that it “routes” to specific handle_params/4 functions to handle each unique URL path.
  4. The path specific handle_params/4 functions set socket assigns that are used in the render/1 function to conditionally determine what to render. (Note, we could also choose to set the socket assigns before calling push_patch/2 to the same effect.)
  5. 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.

Screen Shot 2020-09-07 at 12.14.10 PM.png

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

LiveComponent Examples Repo: https://github.com/pthompson/live_component_examples

LiveComponent Documentation: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html

LiveView Documentation: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html

LiveView GitHub Repo: https://github.com/phoenixframework/phoenix_live_view

LiveView Examples Repo: https://github.com/chrismccord/phoenix_live_view_example

 
110
Kudos
 
110
Kudos

Now read this

Introduction to Phoenix LiveView LiveComponents

LiveComponents are a new feature of Phoenix LiveView, bringing lightweight reusable components into the LiveView ecosystem. [Sept. 7, 2020 - Article and code updated to LiveView v0.14.4] Phoenix LiveView # Phoenix LiveView became... Continue →