Saving <dialog /> against LiveSocket repaint

I’m trying to use native dialog tag for modal. It removes much boilerplating for modal functions. Instead of JS.show/1, it uses HTMLDialogElement.showModal method.

Problem is that it doesn’t survive LiveView update. If it has phx-update="ignore", LiveView destroys native modal setup and append it to its markup position, leaving it dangling out of its place.

Is there any possibility to correct this?


Native modal activated by this.showModal()


Modal destroyed by LiveSocket


Modal appended (to an awkward place) by LiveSocket with phx-update="ignore".

Can you show us your code?

The phx-update="ignore" should be on a surrounding html element and not the actual thing you want to not be affected.

So if you had:

<dialog phx-update="ignore">
  ...
</dialog>

change it to something like:

<div phx-update="ignore">
  <dialog>
    ...
  </dialog>
</div>
1 Like

Tried both ways, to a container and to itself. JS.dispatch makes the same result too.

<.button onclick="document.querySelector('#continue-with-email-modal').showModal()">
  Continue with Email
</.button>
<.modal id="continue-with-email-modal">
  <.form for={:account} phx-submit="mail_access" class="space-y-4">
    <.input name="email" placeholder="Your email" />
    <.button phx-disable-with>Send Link</.button>
  </.form>
</.modal>
def handle_event("mail_access", %{"email" => email}, socket) do
  Feder.Auth.mail_access(email)

  {:noreply, put_flash(socket, :info, "Check your email")}
end
# components

  @doc """
  Renders a button.
  """
  attr :rest, :global, include: ~w(disabled form name value)

  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button
      class={[
        theme(),
        text_box(:double),
        tickle(),
        "font-display font-semibold italic text-base cursor-default",
        "phx-submit-loading:opacity-75"
      ]}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

  @doc """
  Renders a modal.

  Open with `#showModal()`.
  """
  attr :id, :string, required: true
  attr :class, :list, default: []

  slot :inner_block, required: true

  # TODO: Make is survive LiveSocket updates.
  def modal(assigns) do
    ~H"""
    <dialog
      id={@id}
      class={["bg-inherit p-0", @class]}
      onclick="if (event.target == this) this.close()"
    >
      <.focus_wrap id={"#{@id}-focus-wrap"}>
        <div class={[
          theme(),
          box(),
          "mx-auto min-w-[16rem] w-1/2 max-w-prose",
          "grid justify-items-center gap-4",
          @class
        ]}>
          <%= render_slot(@inner_block) %>
        </div>
      </.focus_wrap>
    </dialog>
    """
  end

  @doc """
  Renders an input.
  Field association generates ID, name, and value out of schema.
  Otherwise, a name must be given.

  ## Examples

      <.input field={{f, :email}} type="email" />
      <.input name="my-input" errors={["oh no!"]} />
  """
  attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}"
  attr :name, :string
  attr :class, :list, default: []
  attr :rest, :global, include: ~w(autocomplete disabled form max maxlength min minlength
                                   pattern placeholder readonly required size step)
  slot :inner_block

  def input(assigns) do
    with assigns <- maybe_field(assigns) do
    ~H"""
      <input
        id={assigns[:id] || @name}
        name={@name}
        value={assigns[:value]}
        phx-feedback-for={@name}
        class={[theme(), text_box(), "w-full", @class]}
        {@rest}
      />
    """
    end
  end

Why not handle it in the liveview?

<.button phx-click="continue-with-email">
  Continue with Email
</.button>

<%= if @continue_with_email? do %>
  <.modal id="continue-with-email-modal">
    <.form for={:account} phx-submit="mail_access" class="space-y-4">
      <.input name="email" placeholder="Your email" />
      <.button phx-disable-with>Send Link</.button>
    </.form>
  </.modal>
<% end %>

In your LV:

def mount(_params, _session, socket) do
  {:ok, 
    socket
    ...
    |> assign(:continue_with_email?, false)}
end

...

def handle_event("continue-with-email", _unsigned_params, socket) do
  assign(socket, :continue_with_email?, true)
end

Native modal needs to be activated by HTMLDialogElement.showModal method. It loses modal setup otherwise.

Difference here is, Phoenix ships with hardcoded modal component, but instead I’m trying to use native modal dialog.

In app.js:

window.addEventListener("phx:open-dialog", (e) => {
  document.querySelector(e.detail.target_selector).showModal();
});

In your Heex:

<.button phx-click="continue-with-email">
  Continue with Email
</.button>

<div phx-update="ignore">
  <.modal id="continue-with-email-modal">
    <.form for={:account} phx-submit="mail_access" class="space-y-4">
      <.input name="email" placeholder="Your email" />
      <.button phx-disable-with>Send Link</.button>
    </.form>
  </.modal>
</div>

In your LV:

def handle_event("continue-with-email", _unsigned_params, socket) do
  push_event(socket, "open-dialog", %{target_selector: "#continue-with-email-modal"})
end

If you get that working, it still will erase your diag when the socket updates from something else? Just so I can understand the issue more.

Same result, because your code has phx-update="ignore", the modal gets appended to its markup position, instead of just disappearing.

What is the end result? Are you trying to close the modal? make it disappear? or persist it until the user closes it manually?

If it is to close it:

Usage notes

  • <form> elements can close a <dialog> if they have the attribute method="dialog" or if the button used to submit the form has formmethod="dialog" set. In this case, the state of the form controls are saved, not submitted, the <dialog> closes, and the returnValue property gets set to the value of the button that was used to save the form’s state.

So, on Send Link, data is submitted and LV updates, removing the modal by accident.

No. My goal here is modal not disappearing, because I could display error or do some multi-step communication.


@tomkonidas solution after submit


Ideal state after submit (No change on client, basically)

I have not used dialog yet, have looked into it a bit but I would have thought this should work. Is there a repo I can pull down and try stuff out for myself? Or something that reproduces the error in a minimal way.

UPDATE

Thanks for the support!

It turns out you are right. dialog works as expected with phx-update="ignore".

At the end of rabbit-holes, I’ve found out that iterating @flash map was causing all the problem. This is seemingly irrelevant. The flash is not nested, not even close to my dialog element.

I still don’t understand this behaviour.

UPDATE

I take that back. Any DOM update from LiveSocket that precedes a dialog[phx-update=ignore], will break it.

Aside from this bug, I think I shoud be able to save the status of the modal on session. So I will try to work this way.