Feature
Pretty notifications with LiveView and Alpine
This feature is about displaying in app notifications with Phoenix LiveView, Tailwind and AlpineJS.
I wanted to support multiple notifications and not just have a single flash message. I also wanted them to be nicely animated. That is why I needed to use a little javascript with Alpine JS.
Note that in a real app, I would set up the component to subscribe to a pubsub event.
lib/phoenix_features_web/live/components/notifications_simple.ex
defmodule PhoenixFeaturesWeb.Components.NotificationsSimple do
use PhoenixFeaturesWeb, :live_component
alias PhoenixFeatures.Notifications
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:notifications, [])
}
end
@impl true
def handle_event("notify", _, socket) do
notification = Notifications.create_notification(%{})
notifications = socket.assigns.notifications ++ [notification]
{:noreply, assign(socket, notifications: notifications)}
end
@impl true
def handle_event("clear", %{"id" => id}, socket) do
notifications =
socket.assigns.notifications
|> Enum.filter(& &1.id == id)
{:noreply, assign(socket, notifications: notifications)}
end
end
lib/phoenix_features_web/live/components/notifications_simple.html.leex
<div id="<%= @id %>">
<div class="z-50 fixed top-0 right-0 w-64 mt-10 mr-6">
<%= for notification <- @notifications do %>
<div
phx-hook="InitNotification"
data-notification-id="<%= notification.id %>"
x-data="{ show: false }"
x-init="() => {
setTimeout(() => show = true, 100);
setTimeout(() => show = false, 2000);
$watch('show', shouldShow => $dispatch('clear-notification', { show: shouldShow, id: '#<%= @id %>' }))
}"
x-show="show"
x-transition:enter="transform ease-out duration-300 transition"
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="alert alert-solid-primary alert-closable shadow-xl mb-4"
role="alert">
<%= notification.content %>
<button @click="show = false" class="btn btn-primary btn-sm alert-close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<% end %>
</div>
<button phx-click="notify" phx-target="#<%= @id %>" class="btn btn-dark">Show Notification</button>
</div>
assets/js/init_notification.js
export const InitNotification = {
mounted() {
const notificationId = this.el.dataset.notificationId
const handleOpenCloseEvent = event => {
if (event.detail.show === false) {
this.el.removeEventListener("clear-notification", handleOpenCloseEvent)
setTimeout(() => {
this.pushEventTo(event.detail.id, "clear", {id: notificationId})
}, 300);
}
}
this.el.addEventListener("clear-notification", handleOpenCloseEvent)
}
}
assets/js/app.js
import "alpinejs"
import {InitNotification} from "./init_notification"
let Hooks = {}
Hooks.InitModal = InitNotification
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) }
}
}
})