Feature

Pretty notifications with LiveView and Alpine

Preview

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
<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>
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)
  }
}
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) }
    }
  }
})