DEV Community

Cover image for Modern Modal For Phoenix Liveview
Mykolas Mankevicius
Mykolas Mankevicius

Posted on

Modern Modal For Phoenix Liveview

I'll go through the step-by-step process of creating a modern modal component using the <dialog> html element. Here's what the end result is going to look like:

TL;DR;

If you just want the code see the git repo

For the sake of readability and simplicity, I've separated the dialog into its own file FutureLiveviewModalWeb.Modal.
I'd advise against that and would just drop into the core_components.ex when working on an actual product.

The main branch on the repo will have the finished result. If you want the step-by-step versions see the individual Step - XX Branches.

I've cleaned up the layouts to not have anything for the sake of simplicity.

In the beginning there was a headless_modal

Branch: Step-01---making-a-headless-modal

For components like these, I start off simple stupid. I call them headless in the trend of HeadlessUI from Tailwind.

Why? Because I believe in the principle of composability. So this:

<.modal>
  <div class="content">
    Hello World!
  </div>
</.modal>
Enter fullscreen mode Exit fullscreen mode

Rather than this:

<.modal title="Hello World!" />
Enter fullscreen mode Exit fullscreen mode

Because you can still achieve the second one using the first approach. But you can't customise or change the second one without adding more props or more slots.

And trust me there will always be an edge case, that you've not thought about.

Hence first comes the headless component which simply encompasses the control logic. Then on top of that, you can build more components, like <.modal_small> or <.modal_fullscreen> and so forth.

Bearing that in mind let's start with our modal.

defmodule FutureLiveviewModalWeb.Modal do
  use Phoenix.Component

  def headless_modal(assigns) do
    ~H"""
    <dialog id="modal">
        <h1>Hello World!</h1>
    </dialog>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

Note the <dialog> html element, that is the magical thing. Which:

  • Moves the element to the top level, no more playing around with z-indexes or weird layering issues.
  • Keyboard focus trap. You don't need to add any logic to keep the keyboard focused on the content.
  • Focus tracking, on opening it will focus on the first focusable element or the first autofocus on closing it will return the focus to the button that opened it.
  • Light dismiss, you can press the Esc key to close the modal.
  • Makes all the other elements inert. Which I still don't understand fully, but it sounds cool.
  • You get a stylable backdrop through the dialog::backdrop selector.

So you get a ton of features out of the box. But some features are missing, which we will implement.

But first things first, how do you open it?

Simple in our /modal_live/index.ex we add this button:

<.button onclick="modal.showModal()">See Modals</.button>
Enter fullscreen mode Exit fullscreen mode

;) And voila our headless modal is done! Well, at least the first step :D

Then there was control

Ok, the first modal is a huge success we achieved a lot with just a few lines of code. But obviously, that's not production-ready. Let's add a little more control to it.

First, we need to accept an id and a default slot so that we can actually have a way to open the custom modal and have our own content show up inside of it.

So let's update FutureLiveviewModalWeb.Modal to add those:

attr :id, :string, required: true
slot :inner_block, required: true

def headless_modal(assigns) do
  ~H"""
  <dialog id={@id}>
    <%= render_slot(@inner_block) %>
  </dialog>
  """
end
Enter fullscreen mode Exit fullscreen mode

Ok at this point we could actually use it like:

<.button onclick="modal.showModal()">See Modals</.button>
<Modal.headless_modal id="modal">
  <h1>Hello World!</h1>
  <.button onclick="modal.close()">Close</.button>
</Modal.headless_modal>
Enter fullscreen mode Exit fullscreen mode

And this is perfectly valid. But maybe a bit verbose. We can do better, but we will need JavaScript.

Here's what we'll end up with:

<.button phx-click={Modal.show("modal")} >See Modal</.button>
<Modal.headless_modal id="modal" :let="close">
  <h1>Hello World!</h1>
  <.button phx-click={close}>Close</.button>
</Modal.headless_modal>
Enter fullscreen mode Exit fullscreen mode

Let's add two functions to our FutureLiveviewModalWeb.Modal module

def show(id) do
  JS.dispatch("show-dialog-modal", to: "##{id}")
end

def hide(id) do
  JS.dispatch("hide-dialog-modal", to: "##{id}")
end
Enter fullscreen mode Exit fullscreen mode

We also need the [show/hide]-dialog-modal defined in our javascript.

Open the assets/js/app.js file and add the following lines before the liveSocket.connect() line

window.addEventListener("show-dialog-modal", event => event.target?.showModal())
window.addEventListener("hide-dialog-modal", event => event.target?.close())
Enter fullscreen mode Exit fullscreen mode

Let's update the headless_modal function to provide an easy way to close itself.

<dialog id={@id} phx-remove={hide(@id)}>
  <%= render_slot(@inner_block, hide(@id)) %>
</dialog>
Enter fullscreen mode Exit fullscreen mode

Ok now update the modals_live/index.ex to use this functionality:

<.button phx-click={Modal.show("modal")}>See Modal</.button>
<Modal.headless_modal :let={close} id="modal">
  <h1>Hello World!</h1>
  <.button phx-click={close}>Close</.button>
</Modal.headless_modal>
Enter fullscreen mode Exit fullscreen mode

Ok now from here we have a nice somewhat headless modal with a lot of features out of the box.

Bringing it up to par with CoreComponents.modal

We're missing a few nice features from CoreComponents.modal:

  • Having the modal open by default
  • Closing the modal on click outside.
  • on_cancel

We're also missing:

  • Styling
  • Animations

But that will come after.

First, let's clean up a little in the FutureLiveviewModalWeb.Modal module, let's rename the hide to close and add a cancel function.

# This was `hide` before
def close(id) do
  JS.dispatch("hide-dialog-modal", to: "##{id}")
end

# We want to allow cancelling from other places as well
def cancel(id) do
  JS.exec("data-cancel", to: "##{id}")
end
Enter fullscreen mode Exit fullscreen mode

Ok now let's adjust our headless_modal component and add three new attributes, copy-pasted from the CoreComponents.modal

attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
attr :class, :any, default: nil
Enter fullscreen mode Exit fullscreen mode

Next, we add a bit more code to the actual html markup:

def headless_modal(assigns) do
  ~H"""
  <dialog
    id={@id}
    phx-mounted={@show && show(@id)}
    phx-remove={close(@id)}
    data-cancel={JS.exec(@on_cancel, "phx-remove")}
    phx-window-keydown={cancel(@id)}
    phx-key="escape"
    class={@class}
  >
    <%!-- We pass in both the options for our component consumers --%>
    <%= render_slot(@inner_block, %{close: close(@id), cancel: cancel(@id)}) %>
  </dialog>
  """
end
Enter fullscreen mode Exit fullscreen mode

It's basically a copy-paste of the code in CoreComponents.modal but with a few changes. All of the logic now lies on the <dialog> element.

Ok let's test this out:

In the assets/js/app.js add the following code, anywhere:

// This is just a simple test, remove it in the actual production code.
window.addEventListener("test-on-cancel", _ => console.log("The modal was cancelled!"))
Enter fullscreen mode Exit fullscreen mode

Next in the lib/future_liveview_modal_web/live/modal_live/index.ex file let's add an on_close attribute on our modal, and change the :let to consume the new way we provide the close function

<Modal.headless_modal :let={%{close: close}} id="modal" on_cancel={JS.dispatch("test-on-cancel")}>
Enter fullscreen mode Exit fullscreen mode

This works almost like the modal but we don't have the click outside to cancel the modal.

Click outside tracking

This is very javascript involved, but I'll try and explain everything along the way:

First, we want to be able to remove event listeners that we add on an element. To do that we'll create a little abstraction around the eventListeners.

Create a file assets/vendor/eventListeners.js and paste the following code there, follow the comments to see what we're doing, but it's pretty self-explanatory:

const elementEventListeners = new Map()

export const addEventListener = (element, event, callback, opts = undefined) => {
  // Get the listeners for the element or create a new Map
  const listeners = elementEventListeners.get(element) || new Map()

  // Get the existing listeners for the event or create an empty array
  const existingListeners = listeners.has(event) ? listeners.get(event) : []

  // Add the new callback to the existing listeners
  listeners.set(event, [...existingListeners, callback])

  // Set the new listeners for the element
  elementEventListeners.set(element, listeners)

  // Add the actual event listener to the element
  element.addEventListener(event, callback, opts)
}

// removes all event listeners for the given element and event
export const removeAllEventListeners = (element, event) => {
  const listeners = elementEventListeners.get(element)
  if (!listeners) return

  const callbacks = listeners.get(event)
  if (!callbacks) return

  callbacks.forEach(callback => element.removeEventListener(event, callback))
  listeners.delete(event)
}
Enter fullscreen mode Exit fullscreen mode

With that out of the way the code for the dialog event listeners will be more complex so let's extract it to a new file assets/vendor/dialog.js. Again follow the comments to grasp what is going on.

import { addEventListener, removeAllEventListeners } from "./eventListeners"

// Checks if the click event is inside the dialog BoundingClientRect
const clickIsInDialog = (dialog, event) => {
  const { left, top, width, height } = dialog.getBoundingClientRect();
  const { clientX: x, clientY: y } = event;

  return (top <= y && y <= top + height && left <= x && x <= left + width);
}

// Calls the cancel callback if it exists
const maybeCallCancel = (dialog) => {
  const cancel = dialog.getAttribute('data-cancel')

  if (cancel) {
    // we will add this command in the app.js
    window.execJS(dialog, cancel)
  }
}

// Closes the dialog if the click is outside of it
const maybeCloseDialog = (event) => {
  const dialog = event.target
  // Prevent the dialog from closing when clicking on elements inside the dialog
  if (dialog?.nodeName !== "DIALOG") {
    return;
  }

  // Prevent the dialog from closing when clicking within the dialog boundaries
  if (clickIsInDialog(dialog, event)) {
    return;
  }


  dialog.close();
  maybeCallCancel(dialog);
}

export const setupDialogEvents = () => {
  // We move this from app.js to here to keep all the logic in one place
  window.addEventListener("hide-dialog-modal", event => event.target?.close())

  // We move this from app.js and update it to use the new event listener functions
  window.addEventListener("show-dialog-modal", event => {
    if (!event.target || !(event.target instanceof HTMLDialogElement)) {
      return;
    }

    event.target.showModal()

    // We add the ligth dismiss on click outside of the dialog
    addEventListener(event.target, "click", maybeCloseDialog, false)

    // Let's not forget to clean up the event listeners when the dialog is closed
    addEventListener(event.target, "close", () => {
      removeAllEventListeners(event.target, "click")
      removeAllEventListeners(event.target, "close")
    }, false)
  })
}
Enter fullscreen mode Exit fullscreen mode

Ok let's now update app.js we remove the old listeners and add these lines:

import { setupDialogEvents } from "../vendor/dialog"

setupDialogEvents()

window.execJS = (el, cmd, eventType = undefined) => {
  if (!window.liveSocket || typeof window.liveSocket.execJS !== 'function') return

  window.liveSocket.execJS(el, cmd, eventType)
}
Enter fullscreen mode Exit fullscreen mode

note the window.execJS is a connector to the Phoenix.LiveView.JS.exec client code.

Ok now we have the fully capable modal without any styles ready to go. "Without any styles" <dialog> has browser default styles, but we will override them next.

Adding some styles

So the headless_modal is done now. But in reality, you never want to expose it willy-nilly. It's better to create some variations with some default styles and animations. So let's use composability and create a modal component.

In the lib/future_liveview_modal_web/components/modal.ex file add a new component:

attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true

def modal(assigns) do
  ~H"""
  <.headless_modal
    :let={actions}
    id={@id}
    show={@show}
    on_cancel={@on_cancel}
    class="modal-animation p-8 rounded-xl bg-white"
  >
    <%= render_slot(@inner_block, actions) %>
  </.headless_modal>
  """
end
Enter fullscreen mode Exit fullscreen mode

add this to your CSS to find out more about this have a look at a video Animate from display none by Kevin Powell :

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

/* This file is for your main application CSS */
@layer base {
  html:has(dialog[open]) {
    overflow: hidden;
  }

  /* Marko Dialog */

  .modal-animation {
    --animation-speed: 500ms;

    max-width: 100vw;
    max-height: 100vh;
    overflow: auto;
    overscroll-behavior: contain;
    overscroll-behavior-block: contain;
    overscroll-behavior-inline: contain;

    &::backdrop {
      opacity: 0;
    }

    &[open] {
      @starting-style {
        transform: translateY(100%);
        opacity: 0;
      }

      transform: translateY(0);
      opacity: 1;
    }

    box-shadow: 0 0 0 300vw rgb(3 3 3 / 80%);
    transform: translateY(100%);
    opacity: 0;
    transition:
      transform var(--animation-speed),
      opacity var(--animation-speed),
      display var(--animation-speed) allow-discrete;
  }
}
Enter fullscreen mode Exit fullscreen mode

and update lib/future_liveview_modal_web/live/modal_live/index.ex file to use the new modal:

<.button phx-click={Modal.show("modal")}>See Modal</.button>
<Modal.modal
  :let={%{close: close, cancel: cancel}}
  id="modal"
  on_cancel={JS.dispatch("test-on-cancel")}
>
  <div class="space-y-4">
    <h1>Hello World!</h1>
    <.button phx-click={cancel}>Cancel</.button>
    <.button phx-click={close}>Close</.button>
  </div>
</Modal.modal>
Enter fullscreen mode Exit fullscreen mode

And voila you have a styled modal with animations from/to display none.

You could make the modal more styled with props for the title, description or whatever your design system needs.

The headless_modal component handles all the logic and javascript. You can go forth and simply use that in custom components and style them in whatever way looks best for you.

References:

There are more considerations so here are some links.

Building a dialog component by Adam Argyle

Animate from display none by Kevin Powell

Top comments (0)