Feature

Basic Modal with Alpine JS

Preview

Basic animated modal with Alpine JS. This feature needs a little help from the JS to get the nice animated effect.

NOTE Alpine JS handles the closing of the modal and I set a timeout in javascript to set the close event to the modal component.

setTimeout(() => {
  this.pushEventTo(event.detail.id, "close", {})
}, 300);

That gives the time for the close-animation to run and LiveView will then remove the modal from the DOM.

lib/phoenix_features_web/live/components/modal_simple.ex
defmodule PhoenixFeaturesWeb.Components.ModalSimple do
  use PhoenixFeaturesWeb, :live_component

  alias PhoenixFeaturesWeb.Components.Modal

  def update(assigns, socket) do
    {:ok,
      socket
      |> assign(assigns)
    }
  end
end
<div id="<%= @id %>" style="min-height: 250px;" ><!-- NOTE THE THE COMPONENT NEEDS TO BE TRACKED WITH AN ID -->
  <div class="flex">
    <span class="rounded-md shadow-sm">
      <button phx-click="open" phx-target="#modal-1" type="button" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150">
        Open modal one
      </button>
    </span>
  </div>

  <%= live_component @socket, Modal, id: "modal-1", title: "Basic Modal With Alpine js" do %>
    <p class="text-gray-800">This is an animated modal that uses Tailwind and Alpine js for the transitions.</p>
    <div class="mt-8 text-right">
      <button @click="open = false" type="button" class="inline-flex justify-center rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150">
        Close
      </button>
    </div>
  <% end %>
</div>
defmodule PhoenixFeaturesWeb.Components.Modal do
  use PhoenixFeaturesWeb, :live_component

  @impl true
  def mount(socket) do
    {:ok, assign(socket, state: "CLOSED")}
  end

  @impl true
  def update(assigns, socket) do
    {:ok,
      socket
      |> assign(assigns)
    }
  end

  @impl true
  def handle_event("open", _, socket) do
    {:noreply, assign(socket, :state, "OPEN")}
  end

  @impl true
  def handle_event("close", _, socket) do
    {:noreply, assign(socket, :state, "CLOSED")}
  end
end
<div id="<%= @id %>">
  <%= if @state == "OPEN" do %>
  <div
    phx-hook="InitModal"
    phx-target="#<%= @id %>"
    x-data="{ open: false }"
    x-init="() => {
      setTimeout(() => open = true, 100);
      $watch('open', isOpen => $dispatch('modal-change', { open: isOpen, id: '#<%= @id %>' }))
    }"
    x-show="open"
    @close-modal="setTimeout(() => open = false, 100)"
    class="z-50 fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0"
    >

    <!-- BACKDROP -->
    <div
      x-show="open"
      x-transition:enter="ease-out duration-300"
      x-transition:enter-start="opacity-0"
      x-transition:enter-end="opacity-100"
      x-transition:leave="ease-in duration-200"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      class="fixed inset-0 transition-opacity"
    >
      <div class="absolute inset-0 bg-gray-900 opacity-50"></div>
    </div>

    <!-- MODAL DIALOG -->
    <div
      x-show="open"
      x-transition:enter="ease-out duration-300"
      x-transition:enter-start="opacity-0 mb-2 sm:mb-8 sm:mt-2 sm:scale-95"
      x-transition:enter-end="opacity-100 mb-8 sm:mt-8 sm:scale-100"
      x-transition:leave="ease-in duration-200"
      x-transition:leave-start="opacity-100  mb-8 sm:mt-8  sm:scale-100"
      x-transition:leave-end="opacity-0  mb-2 sm:mb-8 sm:mt-2  sm:scale-95"
      class="relative w-full max-w-lg my-8 mx-auto px-4 sm:px-0 shadow-lg">

      <div @click.away="open = false" class="relative flex flex-col bg-white border border-gray-200 rounded-lg">
        <!-- MODAL HEADER -->
        <div class="flex items-center justify-between p-4 border-b border-gray-200 rounded-t">
          <h5 class="mb-0 text-base font-semibold text-gray-500 uppercase"><%= assigns[:title] %></h5>
          <button type="button" @click="open = false" class="text-gray-400 hover:text-gray-500 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
            &times;
          </button>
        </div>
        <!-- MODAL BODY -->
        <div class="relative flex-auto p-4">
          <%= @inner_content.([]) %>
        </div>
      </div>
    </div>
  </div>
  <% end %>
</div>
export const InitModal = {
  mounted() {
    const handleOpenCloseEvent = event => {
      if (event.detail.open === false) {
        this.el.removeEventListener("modal-change", handleOpenCloseEvent)

        setTimeout(() => {
          this.pushEventTo(event.detail.id, "close", {})
        }, 300);
      }
    }
    this.el.addEventListener("modal-change", handleOpenCloseEvent)
  }
}
import "alpinejs"
import {InitModal} from "./init_modal"

let Hooks = {}
Hooks.InitModal = InitModal

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks,
  params: {_csrf_token: csrfToken},
  dom: {
    onBeforeElUpdated(from, to){
      if(from.__x){ window.Alpine.clone(from.__x, to) }
    }
  }
})
defmodule PhoenixFeaturesWeb.Components.CalendarSimpleTest do
  use PhoenixFeaturesWeb.ConnCase
  import Phoenix.LiveViewTest

  describe "ModalSimple" do
    test "shows the component", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "modal_simple"))

      assert view |> element("#modal_simple") |> has_element?()
    end

    test "click the button triggers the modal", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "modal_simple"))

      refute view |> element("#modal-1") |> render() =~ "Basic Modal"
      assert view |> element("button", "Open modal one") |> render_click()
      assert view |> element("#modal-1") |> render() =~ "Basic Modal"
    end
  end
end