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.
Files for the feaure
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
lib/phoenix_features_web/live/components/modal_simple.html.leex
<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>
lib/phoenix_features_web/live/components/modal.ex
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
lib/phoenix_features_web/live/components/modal.html.leex
<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">
×
</button>
</div>
<!-- MODAL BODY -->
<div class="relative flex-auto p-4">
<%= @inner_content.([]) %>
</div>
</div>
</div>
</div>
<% end %>
</div>
assets/js/init_modal.js
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)
}
}
assets/js/app.js
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) }
}
}
})
test/phoenix_features_web/live/demo_live_test.exs
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