Modal dialog is a often used UI element, but in web tech, developers used to spend quite much effort to implement it, before the introduction of CSS frameworks like Bootstrap. It was tricky to position the dialog in the center of the window, to blur the content behind it. It was tricky to let user close the popup by clicking outside it.
It is until the HTML standard introduces the native <dialog>
element. Now developers can create a modal popup with ease, except if their users are using Safari, which often lags behind the modern web features. Put Safari aside, now we see how to create the native modal popup in Lustre framework (Gleam language).
My use case is the post compose form of this blog. The original content is in Markdown. The form has a "Preview" button so that if I click it, a modal popup appears and loads the rendered HTML of that Markdown code.
The native modal popup has its own HTML code, <dialog>
with an open
attribute, you may think that we can just create it with Lustre's html
functions, like:
html.dialog([attribute.attribute("open", "open")], [])
It works, but not useful, because the dialog then is non-modal: It doesn't auto-close by clicking outside or by pressing Esc
key, it doesn't block the interaction of the UI behind. To make it modal, we won't set the "open" attribute manually, but call the HTMLDialogElement.showModal()
method.
First, let define a view function to prepare the markup for this dialog and the button to activate it:
pub const selector_post_body_preview_dialog = "post-body-preview"
pub fn render_post_edit_page(id: String, model: Model) {
let preview_dialog = h.dialog(
[
a.class(selector_post_body_preview_dialog),
a.class(
"p-4 w-screen sm:start-1/2 sm:-translate-x-1/2 sm:top-1/2 sm:-translate-y-1/2 sm:w-120 md:w-220 h-120 rounded-lg",
),
a.attribute("closedby", "any"),
],
[
h.h1([a.class("text-xl text-center")], [h.text("Preview")]),
h.hr([a.class("pb-4")]),
h.iframe([
a.class("w-full h-full"),
]),
],
)
let preview_button = h.button(
[
a.type_("button"),
a.class(
"px-4 py-1.5 text-sm font-medium rounded-md text-gray-600 transition-colors duration-200 sm:text-base dark:hover:bg-gray-800 dark:text-gray-300 hover:bg-gray-100 border border-gray-400 dark:border-gray-700 cursor-pointer",
),
],
[h.text("Preview")],
)
}
Lustre doesn't have any utility for referencing a rendered DOM element, something like Vue's template ref, so we we need to set a static class name for the dialog, in order to find it and call showModal()
on it later.
My real use case has more steps: After user clicks "Preview" button, we grab the content from the editor (<textarea>
element), sending it to an API to get back the HTML-rendered content, injecting to the <iframe>
in the dialog, then show the dialog. But let's start with a simple case first, that is, just show the dialog on click.
We will define a message for button click:
pub type Msg {
UserClickedPreviewButton
}
and attach to the the button:
let preview_button = h.button(
[
ev.on_click(UserClickedPreviewButton),
],
[h.text("Preview")],
)
In the app update()
function, we handle this message to show the dialog. Because we are going to call a JS method, we need to define a FFI function.
In element.ffi.mjs file:
/**
* Show a dialog element using its selector
* @param {string} selector - CSS selector for the dialog element
* @returns {void}
*/
export function showDialog(selector) {
const dialog = document.querySelector(selector)
if (dialog) {
dialog.showModal()
return
}
console.warn(`${selector} is not found!`)
}
In ffi.gleam file:
@external(javascript, "./element.ffi.mjs", "showDialog")
pub fn show_dialog(selector: String) -> Nil
Use it:
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
UserClickedPreviewButton -> {
let whatnext = {
use _dispatch, _root <- effect.after_paint
ffi.show_dialog("." <> selector_post_body_preview_dialog)
}
#(model, whatnext)
}
}
}
The action of opening dialog must be implemented as Effect
because it needs to access the real DOM element. Our view functions are just to define a template for Lustre runtime to build the DOM, so the show_dialog()
won't work there. We also need to use the effect.after_paint
, not effect.from
, to make sure that the <dialog>
is rendered before our show_dialog
is called.
As stated previously, my real use case is more than this. I have to employ more tricks: how to access the content of <textarea>
on button click, how to display the HTML converted from the Markdown, without clashing with the current page CSS style (use <iframe>
with srcdoc
attribute). It is fun but may make this blog post long and not match the title. So I stop here and leave for another article.