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 <:modal name={:confirm_delete} on_cancel={JS.push("delete_cancelled")}> ...body... 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"""
<.modal :if={@slot} id={@dom_id} close_label={@close_label} on_cancel={@on_cancel}> {render_slot(@slot)}
""" 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"""