Phoenix 1.7: Using the modal function component with bootstrap instead of Tailwind

Phoenix is a web framework for the elixir programming language. Starting with the big update of 1.7, function components became the default for Controllers as well as LiveViews. Per default, Phoenix uses Tailwind as it’s css framework. However, our project mindwendel was and is currently using the css framework bootstrap. Initially, we did not plan to use function components and just stick with Phoenix View dependency. According to the team that is entirely possible as Views are not deprecated. However, function components are the default now so I investigated how to adjust our code base to use function components. It took some time, but most changes for the upgrade were not that difficult to implement (but many!). However, I had one frustrating but small problem: Our modals that are leveraging bootstrap were not working correctly! Lets have a look at the code.

Function component

When you generate LiveView code with phoenix built in generators, e.g.: mix phx.gen.live Accounts User users name:string age:integer or when generating a new project all together, you will notice the your_project_web/components/core_components.ex file. This file includes some small and handy function components. Here is the default modal example, which uses Tailwind:

  def modal(assigns) do
    ~H"""
    <div
      id={@id}
      phx-mounted={@show && show_modal(@id)}
      phx-remove={hide_modal(@id)}
      data-cancel={JS.exec(@on_cancel, "phx-remove")}
      class="relative z-50 hidden"
    >
      <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
      <div
        class="fixed inset-0 overflow-y-auto"
        aria-labelledby={"#{@id}-title"}
        aria-describedby={"#{@id}-description"}
        role="dialog"
        aria-modal="true"
        tabindex="0"
      >
        <div class="flex min-h-full items-center justify-center">
          <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
            <.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="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
            >
              <div class="absolute top-6 right-5">
                <button
                  phx-click={JS.exec("data-cancel", to: "##{@id}")}
                  type="button"
                  class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
                  aria-label={gettext("close")}
                >
                  <.icon name="hero-x-mark-solid" class="h-5 w-5" />
                </button>
              </div>
              <div id={"#{@id}-content"}>
                <%= render_slot(@inner_block) %>
              </div>
            </.focus_wrap>
          </div>
        </div>
      </div>
    </div>
    """
  end

There is also code at the end of the file which takes care of opening / closing the modal:

  def show_modal(js \\ %JS{}, id) when is_binary(id) do
    js
    |> JS.show(to: "##{id}")
    |> JS.show(
      to: "##{id}-bg",
      time: 300,
      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

  def hide_modal(js \\ %JS{}, 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

Well, this code obviously will not work with bootstrap, as bootstrap uses different class names and also comes with its own modal Javascript API. But how to make this function component work with boostrap?

The solution with Hooks, dispatch and event listeners

There are different technical parts needed for the solution:

So lets look at the solution:

Modal Component (core_components.ex)

  def modal(assigns) do
    ~H"""
    <div
      id={@id}
      phx-hook="Modal"
      data-cancel={JS.exec(@on_cancel, "phx-remove")}
      phx-remove={hide_modal(@id)}
      class="modal"
      tabindex="-1"
      aria-hidden="true"
      aria-labelledby="{@id}-title"
    >
      <div class="modal-dialog modal-lg">
        <div class="modal-content">
          <.focus_wrap
            id={"#{@id}-container"}
            phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
            phx-key="escape"
          >
            <div class="modal-header">
              <h5 class="modal-title"><%= @title %></h5>
              <button
                phx-click={JS.exec("data-cancel", to: "##{@id}")}
                type="button"
                class="phx-modal-close btn-close"
                aria-label={gettext("close")}
              />
            </div>
            <div id={"#{@id}-content"} class="modal-body">
              <%= render_slot(@inner_block) %>
            </div>
          </.focus_wrap>
        </div>
      </div>
    </div>
    """
  end

The important part is the phx-hook which registers our custom Javascript Hook for the modal (see below). In addition, phx-remove will be triggered when clicking the closing buttons (via the data-cancel handler that is triggered with phx-click on the button). This function then dispatches an event which is handled within an event listener that is registered inside the hook. This is rather important in this case, as bootstrap renders a gray backdrop layer which is otherwise stuck on the page and completly disrupts interactions on the page. It has to be removed with the boostrap hide function!

In the above code, you will also notice the exec call that takes JS commands from the defined attribute (in this case data-cancel) and executes it from the defined element (the modal).

hide_modal function (core_components.ex)

  def hide_modal(js \\ %JS{}, id) do
    js |> JS.dispatch("mindwendel:hide-modal", to: "##{id}")
  end

The hide_modal function dispatches the hide modal event, registered within the hook.

Hook (app.js)

Hooks.Modal = {
  mounted() {
    const modal = new Modal(this.el, { backdrop: 'static', keyboard: false });
    const closeModal = () => modal && modal.hide();

    modal.show();

    window.addEventListener('mindwendel:hide-modal', closeModal);
  }
}

When the hook of the component is mounted, the modal will be intialized and shown. After, the event listener registers the event for the modal to be closed at a later point in time.

It works quite well. There are still a few gotchas:

I hope this helps. If you have suggestions how to improve the code, let me know! If you want to see how it works in practice, have a look at the repository: mindwendel on GitHub

Interested in working with us?

Schedule a meeting with us and let us know how we can help improve your GitLab or GitHub setup.
« The use of Content Management Systems for increased development velocity Update: Run a Phoenix 1.7 application on Scalingo using Releases »
B310 Digital GmbH, c/o FLEET7, Fleethörn 7, 24103 Kiel hi@b310.de