Skip to main content

lib/modal_stack.ex

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