empowered.house ↗

notifications/list.ex

Notification list

A list of notifications with a small icon and a timestamp each, and the unread ones marked.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.notifications_list animated />
</div>
default
isometric
all read · custom copy
five items · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Notifications"
items list [%{meta: "Northwind · €18,400", time: "Now", title: "PO-2291 needs your approval", icon: "approval", unread: true}, ...] Up to six `%{icon, title, meta, time, unread}`. Icon is `approval`, `alert`, `agent`, `clock`, `doc` or `message`; `alert` is drawn in coral. Titles up to 32 characters.
badge any nil The pill in the header. Left empty, it counts the unread items.
footer any "Mark all as read" The line at the bottom, or `nil`.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/notifications/list.ex
list.ex
defmodule EHVisuals.Notifications.List do
  @moduledoc """
  A list of notifications, each with a small drawn icon and a timestamp, and
  the unread ones marked.
  """

  use EHVisuals.Composition,
    name: "notifications/list",
    title: "Notification list",
    description:
      "A list of notifications with a small icon and a timestamp each, and the unread ones marked."

  @items [
    %{
      icon: "approval",
      title: "PO-2291 needs your approval",
      meta: "Northwind · €18,400",
      time: "Now",
      unread: true
    },
    %{
      icon: "alert",
      title: "Stock is low in aisle 14",
      meta: "Acme Tools · 40 units left",
      time: "12 min",
      unread: true
    },
    %{
      icon: "agent",
      title: "Invoice run finished",
      meta: "128 posted to the ERP",
      time: "1 h",
      unread: false
    },
    %{
      icon: "clock",
      title: "Month-end close starts Monday",
      meta: "Finance calendar",
      time: "Yesterday",
      unread: false
    }
  ]

  @rows_y 52
  @row_h 46

  attr(:title, :string, default: "Notifications")

  attr(:items, :list,
    default: @items,
    doc:
      "Up to six `%{icon, title, meta, time, unread}`. Icon is `approval`, `alert`, `agent`, `clock`, `doc` or `message`; `alert` is drawn in coral. Titles up to 32 characters."
  )

  attr(:badge, :any,
    default: nil,
    doc: "The pill in the header. Left empty, it counts the unread items."
  )

  attr(:footer, :any, default: "Mark all as read", doc: "The line at the bottom, or `nil`.")

  attr(:label, :string,
    default: "A list of four notifications with icons and timestamps, two of them unread"
  )

  frame_attrs()

  @spec notifications_list(map()) :: Phoenix.LiveView.Rendered.t()
  def notifications_list(assigns) do
    rows =
      for {item, index} <- assigns.items |> Enum.take(6) |> Enum.with_index() do
        %{
          index: index,
          y: @rows_y + index * @row_h,
          icon: Map.get(item, :icon, ""),
          title: Map.get(item, :title, ""),
          meta: Map.get(item, :meta, ""),
          time: Map.get(item, :time, ""),
          unread: Map.get(item, :unread, false)
        }
      end

    unread = Enum.count(rows, & &1.unread)
    badge = assigns.badge || if(unread > 0, do: "#{unread} new")
    footer_y = @rows_y + length(rows) * @row_h

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:pill, badge)
      |> assign(:pill_w, Float.round(16 + String.length(badge || "") * 5.9, 1))
      |> assign(:footer_y, footer_y)
      |> assign(:height, footer_y + if(assigns.footer, do: 38, else: 6))

    ~H"""
    <.frame name="notifications/list" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 340 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-t-strong" x="20" y="34" font-size="14.5">{@title}</text>
            <g :if={@pill}>
              <rect
                class="ehv-fill-lime-soft"
                x={320 - @pill_w}
                y="19"
                width={@pill_w}
                height="20"
                rx="6"
              />
              <text
                class="ehv-mono ehv-t-lime"
                x={320 - @pill_w / 2}
                y="32.5"
                font-size="8.5"
                text-anchor="middle"
              >
                {String.upcase(@pill)}
              </text>
            </g>
          </g>
          <g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 2}"}>
            <line class="ehv-line-faint" x1="20" x2="320" y1={row.y + 0.5} y2={row.y + 0.5} />
            <rect
              class="ehv-notifications-list__row"
              x="8"
              y={row.y + 4}
              width="324"
              height="38"
              rx="8"
            />
            <rect class="ehv-card-3" x="20.5" y={row.y + 10.5} width="25" height="25" rx="8" />
            <g transform={"translate(33 #{row.y + 23})"}>
              <path
                class={[
                  "ehv-round",
                  if(row.icon == "alert", do: "ehv-line-coral", else: "ehv-line-ink")
                ]}
                d={icon(row.icon)}
              />
              <rect
                :if={row.icon == "agent"}
                class="ehv-fill-ink"
                x="-2"
                y="-2"
                width="4"
                height="4"
                rx="1"
              />
            </g>
            <text
              class={[if(row.unread, do: "ehv-t-strong", else: "ehv-t-2")]}
              x="58"
              y={row.y + 20.5}
              font-size="12.5"
            >
              {row.title}
            </text>
            <text class="ehv-mono" x="58" y={row.y + 34} font-size="8">
              {String.upcase(row.meta)}
            </text>
            <text
              class={["ehv-mono", if(row.unread, do: "ehv-t-2", else: "ehv-t-4")]}
              x="320"
              y={row.y + 20}
              font-size="8"
              text-anchor="end"
            >
              {String.upcase(row.time)}
            </text>
            <g :if={row.unread} class={"ehv-a-pop ehv-i-#{row.index + 5}"}>
              <circle class="ehv-fill-lime ehv-a-pulse" cx="316.5" cy={row.y + 31} r="3" />
            </g>
          </g>
          <g :if={@footer} class="ehv-a-fade ehv-i-8">
            <line class="ehv-line-faint" x1="20" x2="320" y1={@footer_y + 0.5} y2={@footer_y + 0.5} />
            <text class="ehv-t-2" x="170" y={@footer_y + 23.5} font-size="11.5" text-anchor="middle">
              {@footer}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Icons are drawn around {0, 0}, inside a 14 by 14 box.
  defp icon("approval"), do: "M-4.6 0.3 l3.1 3.1 l6.1 -6.9"
  defp icon("alert"), do: "M0 -6 L6.6 5.2 H-6.6 Z M0 -1.9 v3.2 M0 3.3 v0.2"

  defp icon("agent"),
    do: "M-3 -6 h6 a3 3 0 0 1 3 3 v6 a3 3 0 0 1 -3 3 h-6 a3 3 0 0 1 -3 -3 v-6 a3 3 0 0 1 3 -3 z"

  defp icon("clock"), do: "M0 -6.2 a6.2 6.2 0 1 0 0.01 0 z M0 -3.2 V0.2 l2.5 1.6"

  defp icon("doc"),
    do: "M-4.6 -6.2 h5.6 l3.6 3.6 v8.8 h-9.2 z M1 -6.2 v3.6 h3.6 M-2 1 h4 M-2 3.6 h4"

  defp icon("message"), do: "M-6.2 -5.2 h12.4 v8.2 h-6.6 l-3 3 v-3 h-2.8 z"
  defp icon(_other), do: "M-3 0 h6 M0 -3 v6"
end
assets/css/notifications/list.css
list.css
/* notifications/list */
.ehv-notifications-list { --ehv-max-w: 370px; --ehv-step: 110ms; }

/* A row has no surface of its own until it is hovered. */
.ehv-notifications-list__row { fill: var(--ehv-surface-2); opacity: 0; }
.ehv--hover .ehv-notifications-list__row { transition: opacity 200ms var(--ehv-ease); }
.ehv--hover .ehv-notifications-list__row:hover { opacity: 1; }