defmodule ModalStack do
@moduledoc """
A single, stackable modal container for Phoenix LiveView.
One stack per page (only one modal is visible at a time). State lives in
`socket.assigns.modal_stack` as a `%ModalStack{}` struct; there is no
`live_component` and no hidden process.
## Setup
def mount(_params, _session, socket) do
{:ok, ModalStack.attach(socket)}
end
## Opening / closing (from your event handlers)
ModalStack.push(socket, :confirm_delete)
ModalStack.pop(socket)
ModalStack.clear(socket)
## Rendering
<ModalStack.modal_stack stack={@modal_stack}>
<:modal name={:confirm_delete} on_cancel={JS.push("delete_cancelled")}>
...body...
</:modal>
</ModalStack.modal_stack>
The close button, `Escape`, and click-away automatically pop the stack via a
hook `attach/1` installs — you do not write a close handler. A slot
`on_cancel` (optional) runs as an extra side-effect alongside the pop.
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@assign_key :modal_stack
@pop_event "modal_stack:pop"
defstruct stack: []
@type t :: %__MODULE__{stack: [atom()]}
@doc """
Seeds the stack into assigns and attaches the close-event hook. Call once in
`mount/3`.
"""
def attach(socket) do
socket
|> Phoenix.Component.assign(@assign_key, %__MODULE__{})
|> Phoenix.LiveView.attach_hook(:modal_stack, :handle_event, &__handle_event__/3)
end
@doc false
def __handle_event__(@pop_event, _params, socket), do: {:halt, pop(socket)}
def __handle_event__(_event, _params, socket), do: {:cont, socket}
@doc "Open a modal by name (no-op if already open). Returns the socket."
def push(socket, name) when is_atom(name) do
update(socket, fn stack ->
if name in stack, do: stack, else: stack ++ [name]
end)
end
@doc "Close the topmost modal. Returns the socket."
def pop(socket) do
update(socket, fn
[] -> []
list -> List.delete_at(list, -1)
end)
end
@doc "Close every open modal. Returns the socket."
def clear(socket), do: update(socket, fn _ -> [] end)
defp update(socket, fun) do
current = socket.assigns[@assign_key]
Phoenix.Component.assign(socket, @assign_key, %{current | stack: fun.(current.stack)})
end
# --- Component + chrome ---
attr :stack, __MODULE__, required: true
attr :close_label, :string, default: "close"
slot :modal, required: true do
attr :name, :atom, required: true
attr :on_cancel, JS
end
def modal_stack(assigns) do
top = List.last(assigns.stack.stack)
slot = top && Enum.find(assigns.modal, &(&1.name == top))
assigns =
assigns
|> assign(:slot, slot)
|> assign(:dom_id, top && "modal-stack-#{top}")
|> assign(:on_cancel, cancel_js(slot))
~H"""
<div>
<.modal :if={@slot} id={@dom_id} close_label={@close_label} on_cancel={@on_cancel}>
{render_slot(@slot)}
</.modal>
</div>
"""
end
# The chrome always pushes the built-in pop; a slot on_cancel (if given) runs
# first as a side-effect. No `||` — pattern-match the optional slot attr.
defp cancel_js(nil), do: %JS{}
defp cancel_js(%{on_cancel: %JS{} = js}), do: JS.push(js, @pop_event)
defp cancel_js(_slot), do: JS.push(%JS{}, @pop_event)
attr :id, :string, required: true
attr :close_label, :string, default: "close"
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
# Close button, Escape, and click-away all run `@on_cancel`, which pushes
# "modal_stack:pop" (plus any slot on_cancel side-effect). The hook pops the
# stack; the re-render removes this element, firing `phx-remove`/`hide_modal`
# for the exit animation. All three paths behave identically.
defp modal(assigns) do
~H"""
<div
id={@id}
phx-mounted={show_modal(@id)}
phx-remove={hide_modal(@id)}
class="hidden relative z-50"
>
<div id={"#{@id}-bg"} class="fixed inset-0 transition-opacity bg-zinc-950/70" aria-hidden="true" />
<div
class="overflow-y-auto fixed inset-0"
role="dialog"
aria-modal="true"
tabindex="0"
>
<div class="flex justify-center items-center min-h-full">
<div class="p-4 w-full max-w-3xl sm:p-6 lg:py-8">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={@on_cancel}
phx-key="escape"
phx-click-away={@on_cancel}
class="hidden relative p-6 text-zinc-100 bg-zinc-900 rounded-2xl border border-white/10 shadow-xl transition shadow-black/40 sm:p-10 lg:p-14"
>
<div class="absolute right-5 top-6">
<button
phx-click={@on_cancel}
type="button"
class="flex-none p-3 -m-3 text-zinc-400 transition-colors hover:text-zinc-200"
aria-label={@close_label}
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>
<div id={"#{@id}-content"}>
{render_slot(@inner_block)}
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
# --- JS show/hide helpers (server-emitted, no client JS needed) ---
defp show(js, selector) do
JS.show(js,
to: selector,
transition:
{"transition-all transform ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
defp hide(js, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
defp show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
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
defp hide_modal(js \\ %JS{}, id) when is_binary(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
end