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>
Rather than this:
<.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.
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>
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>
;) 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
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>
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>
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
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())
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>
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>
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
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
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
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!"))
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")}>
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)
}
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)
})
}
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)
}
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
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;
}
}
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>
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.