Modals With <.portal/> in Phoenix LiveView
The deal with modals is you usually want them to live on the first floor inside <body>. In most cases, not all (we'll talk about one). Reasons for that being a risk of modal getting clipped out of view due to setup of parent elements, e.g. overflow: hidden; position: relative; transform: <...>. Even though position: fixed is the remedy for the most part, but hey, there's this transform dude...
I will not cover strategies for showing, hiding a modal, full semantics or accessibility but rather focus on LiveView's built-in <.portal />:
# core_components.ex
attr :id, :string, required: true
slot :inner_block, required: true
def modal(assigns) do
~H"""
<.portal id={"#{@id}-portal"} target="body">
<dialog>
{@inner_block}
</dialog>
</.portal>
"""
end# live/home_live.ex
def render(assigns) do
~H"""
<Layouts.app>
<div class="very">
<div class="deeply">
<div class="nested">
<div style="
width: 10em;
height: 6em;
overflow: hidden;
position: relative;
transform: translateZ(0);
">
<button type="button" phx-click="show_modal">
Show modal
</button>
<.modal :if={@show_modal} id="hello-modal">
<p>Hello!</p>
</.modal>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
endThanks to portal the dialog element is inserted right before </body>:
<dialog phx-id="123">...</dialog>
</body>
</html>Most importantly you still get all the LiveView dynamic parts functioning despite the fact that from server side the dialog element comes wrapped in <template> and only then it is appended to DOM's body:
<template>
<dialog>...</dialog>
</template><.modal :if={@show_modal} id="form-modal">
<.form phx-submit="submit_form">
<p>Submitted email: {@email}</p>
<input type="email" name="email" />
</.form>
</.modal>Another use case of portal is for nesting forms which is not semantic and any child forms get stripped by browsers, meaning they won't work anyways, unless we do this:
<div class="post">
<.form>
<h2>Post form</h2>
...
<.modal :if={@show_modal} id="gallery-modal">
<.form>
<h2>Images form</h2>
...
</.form>
</.modal>
</.form>
</div>Result:
<body>
<div class="post">
<form>
<h2>Post form</h2>
...
</form>
</div>
<dialog>
<form>
<h2>Images form</h2>
...
</form>
</dialog>
</body>When You Don't Need Portal
You have a huge form with many inputs and want to offload the less important ones into modals for the sake of better UX. Now portal would do you a disservice putting these inputs outside the form element. However, making portal optional is a nice workaround for situations like this:
# core_components.ex
attr :id, :string, required: true
attr :portal, :boolean, default: true
slot :inner_block, required: true
def modal(assigns) do
~H"""
<.portal :if={@portal} id={"#{@id}-portal"} target="body">
<.do_modal {assigns}>
{render_slot(@inner_block)}
</.do_modal>
</.portal>
<.do_modal :if={!@portal} {assigns}>
{render_slot(@inner_block)}
</.do_modal>
"""
end
def do_modal(assigns) do
~H"""
<dialog>
{@inner_block}
</dialog>
"""
end# live/home_live.ex
<.form>
<input type="text" name="firstname" />
<input type="text" name="lastname" />
<button type="button" phx-click="show_email_modal">
Change email
</button>
<.modal :if={@show_email_modal} id="email-modal" portal={false}>
<input type="email" name="email" />
</.modal>
</.form>Result:
<form>
<input type="text" name="firstname" />
<input type="text" name="lastname" />
<button type="button" phx-click="show_email_modal">
Change email
</button>
<dialog>
<input type="email" name="email" />
</dialog>
</form>May Elixir prolong your life!