Modern Modal For Phoenix Liveview
Tags: webdev, elixir, liveview, tailwindcss
Published: 2023-11-17 09:01:47

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:

{% embed https://youtu.be/jmm_RvQk8AA %}

## TL;DR;

If you just want the code see the [git repo](https://github.com/Neophen/future_liveview_modal)

For the sake of readability and simplicity, I've separated the dialog into its own file [FutureLiveviewModalWeb.Modal](url).
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:

```html
<.modal>
  <div class="content">
    Hello World!
  </div>
</.modal>
```
Rather than this:

```html
<.modal title="Hello World!" />
```

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.

```elixir
defmodule FutureLiveviewModalWeb.Modal do
  use Phoenix.Component

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

Note the [`<dialog>`](https://caniuse.com/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:

```html
<.button onclick="modal.showModal()">See Modals</.button>
```

;) 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:

```elixir
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
```

Ok at this point we could actually use it like:
```html
<.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>
```

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:

```html
<.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>
```

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

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

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

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

```js
window.addEventListener("show-dialog-modal", event => event.target?.showModal())
window.addEventListener("hide-dialog-modal", event => event.target?.close())
```

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

```html
<dialog id={@id} phx-remove={hide(@id)}>
  <%= render_slot(@inner_block, hide(@id)) %>
</dialog>
```

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

```html
<.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>
```

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.

```elixir
# 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
```

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

```elixir
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
attr :class, :any, default: nil
```

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

```elixir
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
```

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:

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

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

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

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:

```js
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)
}
```

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.

```js
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)
  })
}
```

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

```js
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)
}
```

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:

```elixir
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
```

add this to your CSS to find out more about this have a look at a video [Animate from display none by Kevin Powell](https://www.youtube.com/watch?v=4prVdA7_6u0) :
```css
@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;
  }
}
```

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

```html
<.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>
```

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](https://web.dev/articles/building/a-dialog-component)

[Animate from display none by Kevin Powell](https://www.youtube.com/watch?v=4prVdA7_6u0)