Phoenix is a web framework for the elixir programming language. Starting with the big update of 1.7, function components became the default for Controllers as well as LiveViews. Per default, Phoenix uses Tailwind as it’s css framework. However, our project mindwendel was and is currently using the css framework bootstrap. Initially, we did not plan to use function components and just stick with Phoenix View dependency. According to the team that is entirely possible as Views are not deprecated. However, function components are the default now so I investigated how to adjust our code base to use function components. It took some time, but most changes for the upgrade were not that difficult to implement (but many!). However, I had one frustrating but small problem: Our modals that are leveraging bootstrap were not working correctly! Lets have a look at the code.
When you generate LiveView code with phoenix built in generators, e.g.: mix phx.gen.live Accounts User users name:string age:integer
or when generating a new project all together, you will notice the your_project_web/components/core_components.ex
file. This file includes some small and handy function components. Here is the default modal example, which uses Tailwind:
def modal(assigns) do
~H"""
<div
id={@id}
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden"
>
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
<div
class="fixed inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div class="flex min-h-full items-center justify-center">
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
>
<div class="absolute top-6 right-5">
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label={gettext("close")}
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
There is also code at the end of the file which takes care of opening / closing the modal:
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
time: 300,
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-content")
end
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
Well, this code obviously will not work with bootstrap, as bootstrap uses different class names and also comes with its own modal Javascript API. But how to make this function component work with boostrap?
There are different technical parts needed for the solution:
So lets look at the solution:
Modal Component (core_components.ex
)
def modal(assigns) do
~H"""
<div
id={@id}
phx-hook="Modal"
data-cancel={JS.exec(@on_cancel, "phx-remove")}
phx-remove={hide_modal(@id)}
class="modal"
tabindex="-1"
aria-hidden="true"
aria-labelledby="{@id}-title"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
>
<div class="modal-header">
<h5 class="modal-title"><%= @title %></h5>
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="phx-modal-close btn-close"
aria-label={gettext("close")}
/>
</div>
<div id={"#{@id}-content"} class="modal-body">
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
</div>
</div>
"""
end
The important part is the phx-hook
which registers our custom Javascript Hook for the modal (see below). In addition, phx-remove
will be triggered when clicking the closing buttons (via the data-cancel
handler that is triggered with phx-click
on the button). This function then dispatches an event which is handled within an event listener that is registered inside the hook. This is rather important in this case, as bootstrap renders a gray backdrop layer which is otherwise stuck on the page and completly disrupts interactions on the page. It has to be removed with the boostrap hide function!
In the above code, you will also notice the exec
call that takes JS commands from the defined attribute (in this case data-cancel
) and executes it from the defined element (the modal).
hide_modal function (core_components.ex
)
def hide_modal(js \\ %JS{}, id) do
js |> JS.dispatch("mindwendel:hide-modal", to: "##{id}")
end
The hide_modal
function dispatches the hide modal event, registered within the hook.
Hook (app.js
)
Hooks.Modal = {
mounted() {
const modal = new Modal(this.el, { backdrop: 'static', keyboard: false });
const closeModal = () => modal && modal.hide();
modal.show();
window.addEventListener('mindwendel:hide-modal', closeModal);
}
}
When the hook of the component is mounted, the modal will be intialized and shown. After, the event listener registers the event for the modal to be closed at a later point in time.
It works quite well. There are still a few gotchas:
fade
class for the bootstrap modal animation seems still buggy: The modal fades properly in, but the closing animation is somehow half missing.destroyed
event of the Hook lifecycle, sometimes I was again stuck with the backdrop. The main problem of the backdrop is that it’s rendered somewhere at the bottom of the page. When LiveView gets an update, it only updates the components html and seems to forget this out of reach backdrop.I hope this helps. If you have suggestions how to improve the code, let me know! If you want to see how it works in practice, have a look at the repository: mindwendel on GitHub