DaisyUI modal in phoenix 1.8 - code example

Hi there,

I’ve been testing Phoenix 1.8 a lot lately and noticed the modal component from 1.7 had disappeared. Since we now have DaisyUI which comes with a Modal component, I figured I’d try to use it. It’s not that easy because if you want to do it the cleanest way, you need to use the browser’s showModal() function. I won’t get into too much detail but after several hours of tests here is what I came up with. I hope it can be useful to some of you here!

The component:

  @doc """
  Modal dialog.
  """
  attr :id, :string, required: true
  attr :on_cancel, JS, default: %JS{}
  slot :inner_block, required: true

  def modal(assigns) do
    ~H"""
    <dialog
      id={@id}
      phx-hook="Modal"
      phx-remove={
        JS.remove_attribute("open")
        |> JS.transition({"ease-out duration-200", "opacity-100", "opacity-0"}, time: 0)
      }
      data-cancel={JS.exec(@on_cancel, "phx-remove")}
      class="modal"
    >
      <.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="modal-box"
      >
        <.button phx-click={JS.exec("data-cancel", to: "##{@id}")} class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 tx-lg">✕</.button>
        <%= render_slot(@inner_block) %>
      </.focus_wrap>
    </dialog>
    """
  end

Add a new hook which will trigger showModal():

Hooks.Modal = {
    mounted() {
        this.el.showModal();
    },
}

In your liveview, just add the modal:

  <.modal
    id="delete_modal"
    :if={@live_action in [:delete]}
    on_cancel={JS.patch(~p"/brands")}
  >
    <h3 class="text-xl text-bold">Warning</h3>
    <p class="text-md mt-3">Are you sure you want to delete brand {@brand.name}?</p>
    <div class="modal-action">
      <.button phx-click="delete" phx-value-id={@brand.id}>Delete</.button>
    </div>
  </.modal>

It’s basically a hot-swap solution for Phoenix modal 1.7. Please note that the modal should be opened only with a specific live_action which is defined in the router:

live "/brands/:id/delete", BrandsLive, :delete

This is necessary because we don’t have an easy way to trigger the close javascript command and retrieve data while letting the browser-operated fade out operate properly (or at least I didn’t find one). But it’s also very useful because it allows us to pass parameters to the modal easily using the url.

Anyway, just my two cents! Feel free to suggest improvements or ask questions.

6 Likes

Update: the issue with this implementation is that if you use it with a form for example, the liveview updates will rewrite the html without the open tag on the dialog HTML element, therefore closing the modal.

So here is my final solution: ditch the hook and just include the open tag from the start.

    <dialog
      id={@id}
      phx-remove={
        JS.remove_attribute("open")
        |> JS.transition({"ease-out duration-200", "opacity-100", "opacity-0"}, time: 0)
      }
      data-cancel={JS.exec(@on_cancel, "phx-remove")}
      class="modal"
      open
    >
4 Likes

Only available in LV 1.1+, but alternatively I believe you can use phx-mounted={JS.ignore_attributes([“open”]}).

It’s a shame the modal got removed from the core_components. I wonder if its because of this and they wanted to keep the components simple?

1 Like

core_components only include what phoenix generators use. Modals in generated code eventually got replaced with separate pages, so there was no use for the modal anymore.

2 Likes

Very interesting! Thank you. I checked the doc and the example is actually given on a dialog :slight_smile:

1 Like

Which is a fantastic change, the modal abuse in the templates was a bit much :slight_smile:

It seems like no matter what anyone says people expect the core components to be a full component system. Hopefully some of the various actual component systems from the community become mature enough to be strongly recommended to new users. I believe Ash has one of those component libraries in their generators now (don’t remember which).

3 Likes

Yesterday I was also testing Phoenix 1.8 + the DaisyUI modal and actually gave up on showModal() and such, being confused by conflicts between what I made and the flash component, which I had also earlier modified using JS hooks to make it disappear after a certain period of time.

So, I went with this purely LiveView solution – good or not. Please criticize.

  @doc """
  Renders a modal dialog.

  The `on_confirm` and `on_cancel` attributes specify the events to trigger
  when the user clicks the respective buttons or dismisses the modal (e.g.,
  pressing Escape or clicking outside).

  ## Examples
      <.link phx-click={JS.push("open", value: %{id: item.id, name: item.name})}>
        Delete
      </.link>

      <.modal :if={@modal} on_confirm="confirm" on_cancel="cancel">
        <div>Are you sure you want to delete {@modal.name}?</div>
      </.modal>
  """
  attr :on_confirm, :string, required: true, doc: "the event to trigger on confirmation"
  attr :on_cancel, :string, required: true, doc: "the event to trigger on cancellation"

  slot :inner_block, required: true, doc: "the content of the modal dialog"

  def modal(assigns) do
    ~H"""
    <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div
        class="bg-white p-6 rounded-xl shadow-xl w-full max-w-md"
        phx-click-away={@on_cancel}
        phx-window-keydown={@on_cancel}
        phx-key="escape"
      >
        {render_slot(@inner_block)}
        <div class="flex justify-end gap-2 mt-8">
          <.button phx-click={@on_cancel}>Cancel</.button>
          <.button phx-click={@on_confirm} variant="primary">Confirm</.button>
        </div>
      </div>
    </div>
    """
  end

and

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(...)
     |> assign(:modal, nil)
     |> ...}
  end

  @impl true
  def handle_event("open", %{"id" => id, "name" => name}, socket) do
    {:noreply, assign(socket, :modal, %{:id => id, :name => name})}
  end

  @impl true
  def handle_event("confirm", _, socket) do
    # skipped ...
    
    {:noreply,
     socket
     |> assign(...)
     |> assign(:modal, nil)
     |> ...
  end

  @impl true
  def handle_event("cancel", _, socket) do
    {:noreply, assign(socket, :modal, nil)}
  end
end

Yesterday, I’m afraid I wasn’t clear enough. I wanted to ask if it’s really that bad to go with a purely LiveView solution without using any JavaScript, like what I did above.

It’s fine. If you want you can combine events with local JS commands to get lower latency for the open/close. But there are legitimate circumstances in which you actually want the modal to be server-rendered. As an example, I have a piece of UI which supports an arbitrary number of stateful modals, some of which can even fetch web content and hold it in-process. This is obviously not something that can be implemented with local JS commands (or even React, interestingly).

If the latency is bothering you then you can simply render the modal into the page and toggle it with JS. There are no rules here, it’s just preference.

2 Likes

Thank you, @garrison !

1 Like

This is my implementation:

  @doc """
  Renders a modal.

  ## Examples

      <.modal id="confirm-modal">
        This is a modal.
      </.modal>

  JS commands may be passed to the `:on_cancel` to configure
  the closing/cancel event, for example:

      <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
        This is another modal.
      </.modal>

  """
  attr :id, :string, required: true
  attr :on_cancel, JS, default: nil
  slot :inner_block, required: true

  def modal(assigns) do
    ~H"""
    <.portal id={@id} target="body">
      <dialog
        open
        closedby={if @on_cancel, do: "any", else: "none"}
        phx-mounted={
          JS.ignore_attributes("open")
          |> JS.transition({"ease-in duration-200", "opacity-0", "opacity-100"}, time: 0)
        }
        phx-remove={
          JS.remove_attribute("open")
          |> JS.transition({"ease-out duration-200", "opacity-100", "opacity-0"}, time: 0)
        }
        class="modal"
      >
        <.focus_wrap
          class="modal-box w-11/12 max-w-2xl"
          id={"#{@id}-container"}
          tabindex="0"
          phx-key="escape"
          phx-window-keydown={@on_cancel}
        >
          <form :if={@on_cancel} method="dialog">
            <button
              phx-click={@on_cancel}
              class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
            >
              ✕
            </button>
          </form>
          {render_slot(@inner_block)}
        </.focus_wrap>
        <form :if={@on_cancel} method="dialog" class="modal-backdrop">
          <button phx-click={@on_cancel}>close</button>
        </form>
      </dialog>
    </.portal>
    """
  end

Requires the LiveView > v1.1.5.

4 Likes

I have made a library (before phoenix introduced daisyUI) that creates the liveview components using daisyUI styles. You can check it here, it includes the modal component, which is based on the old phoenix generators. I use dialog as the html element, as recommended on daisy docs. Here is also the storybook if you want to test it out.
You can use the library or just copy the code, there are other components there from old phoenix generators if you are interested.

2 Likes

I am trying to implement dialog using beercss.
My implementation is below -

  attr :class, :string, default: ""
  attr :width, :string, default: "large"
  attr :style, :string, doc: "style to be applied to the dialog", default: ""
  attr :on_cancel, JS, default: %JS{}, doc: "the JS command to execute on cancel"
  def dialog(assigns) do
    ~H"""
    <div id={"#{@id}_overlay"} class="overlay"></div>
    <dialog
      id={@id}
      phx-mounted={@show && show_dialog(@id)}
      phx-remove={hide_dialog(@id)}
      data-cancel={JS.exec(@on_cancel, "phx-remove")}
      class={[@class, "modal surface", "#{@width}-width"]}
      style={@style}
    >
      <div id={"#{@id}-content"}>
        <.focus_wrap id={"#{@id}_fw"}>
          {render_slot(@inner_block)}
        </.focus_wrap>
      </div>
    </dialog>
    """
  end

  def show_dialog(js \\ %JS{}, id) when is_binary(id) do
    js
    |> JS.add_class("active", to: "##{id}_overlay")
    |> JS.add_class("active", to: "##{id}")
    |> JS.focus_first(to: "##{id}")
  end

  def hide_dialog(js \\ %JS{}, id) do
    js
    |> JS.remove_class("active", to: "##{id}_overlay")
    |> JS.remove_class("active", to: "##{id}")
    |> JS.pop_focus()
  end

The dialog is called like so -

<.dialog
  :if={@live_action in [:new, :edit]}
  title={@title}
  id="doctor-modal"
  show
  on_cancel={JS.patch(~p"/admin/doctors")}
>
  <.live_component
    module={SecondOpinionWeb.Admin.DoctorLive.FormComponent}
    id={@doctor.id || :new}
    title={@page_title}
    action={@live_action}
    doctor={@doctor}
    patch={~p"/admin/doctors"}
  />
</.dialog>

When I navigate to :new or :edit routes, the dialog appears. I have a cancel button in the live_component like so -

<.button class=“border” phx-click={JS.exec(“data-cancel”, to: “#doctor-modal”)}>Cancel</.button>

Clicking on the cancel button works fine and I see the dialog disappear with animation.
But when I click the save button on the form, it does a push_patch to the :index route, the dialog disappears abruptly.
It seems that phx-remove doesn’t finish before the dialog is being removed from the DOM.
But whats strange is the cancel button works just fine.

Can someone please help? Thanks.

I got it to work by changing the hide_dialog to below -

def hide_dialog(js \\ %JS{}, id) do
    js
    |> JS.remove_class("active", to: "##{id}_overlay")
    |> JS.remove_class("surface", to: "##{id}", transition: {"a", "active", ""}, time: 0)
    |> JS.pop_focus()
  end

I ended up using a colocated hook - I like how it keeps everything consolidated into one spot in the codebase.

You can show the modal with something like <button onclick="my_modal.showModal()">open modal</button> and <.modal id=”my_modal”>This is the body of the modal</.modal>

  def modal(assigns) do
    ~H"""
    <script :type={Phoenix.LiveView.ColocatedHook} name=".Modal">
      export default {
        mounted() {
          window.addEventListener("myapp:close-modal", (e) => {
            this.el.close()
          });
        }
      }
    </script>
    <dialog id={@id} class="modal" phx-hook=".Modal">
      <div class="modal-box overflow-visible">
        <button
          class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
          phx-click={JS.dispatch("myapp:close-modal")}
        >
          ✕
        </button>
        {render_slot(@inner_block)}
      </div>
      <div class="modal-backdrop">
        <button phx-click={JS.dispatch("myapp:close-modal")}>
          close
        </button>
      </div>
    </dialog>
    """
  end
2 Likes