empowered.house ↗

email/inbox.ex

Inbox

An inbox list with a sender, subject, preview, unread dot and time per row, and one row marked as handled by an agent.

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

<div class="h-96 rounded-xl border border-border">
  <.email_inbox animated />
</div>
default
isometric
shared mailbox · custom copy
hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Inbox"
messages list [%{time: "09:41", from: "Marta Nowak", subject: "Month-end close", preview: "Accruals are ready for review", unread: true}, ...] Up to five `%{from, subject, preview, time}`. Add `unread: true` for the dot and `agent: true` for the tag. Keep sender names under 18 characters. Long previews are cut to fit the row.
agent_tag string "Handled by agent" The tag on rows with `agent: true`. Up to 20 characters.
count any nil The line in the header. Left `nil`, it counts the unread messages.
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/email/inbox.ex
inbox.ex
defmodule EHVisuals.Email.Inbox do
  @moduledoc """
  An inbox: a row per message with the sender, the subject and a preview, an
  unread dot and the time. Rows an agent has dealt with carry a tag.
  """

  use EHVisuals.Composition,
    name: "email/inbox",
    title: "Inbox",
    description:
      "An inbox list with a sender, subject, preview, unread dot and time per row, and one row marked as handled by an agent."

  @messages [
    %{
      from: "Marta Nowak",
      subject: "Month-end close",
      preview: "Accruals are ready for review",
      time: "09:41",
      unread: true
    },
    %{
      from: "Northwind",
      subject: "Order PO-2291",
      preview: "Can you confirm the delivery date",
      time: "09:12",
      agent: true
    },
    %{
      from: "Jan Kowalski",
      subject: "Invoice 4471",
      preview: "Approved, please post it today",
      time: "08:47",
      unread: true
    },
    %{
      from: "Acme Tools",
      subject: "Price list 2027",
      preview: "The updated list is attached",
      time: "Tue"
    }
  ]

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

  attr(:messages, :list,
    default: @messages,
    doc:
      "Up to five `%{from, subject, preview, time}`. Add `unread: true` for the dot and `agent: true` for the tag. Keep sender names under 18 characters. Long previews are cut to fit the row."
  )

  attr(:agent_tag, :string,
    default: "Handled by agent",
    doc: "The tag on rows with `agent: true`. Up to 20 characters."
  )

  attr(:count, :any,
    default: nil,
    doc: "The line in the header. Left `nil`, it counts the unread messages."
  )

  attr(:label, :string,
    default: "An inbox with four messages, two unread and one handled by an agent"
  )

  frame_attrs()

  @spec email_inbox(map()) :: Phoenix.LiveView.Rendered.t()
  def email_inbox(assigns) do
    messages = Enum.take(assigns.messages, 5)
    tag_w = Float.round(24 + String.length(assigns.agent_tag) * 4.9, 1)
    unread = Enum.count(messages, &Map.get(&1, :unread, false))

    rows =
      for {message, index} <- Enum.with_index(messages) do
        agent = Map.get(message, :agent, false)
        limit = 54

        %{
          index: index,
          y: 44 + index * 52,
          from: message.from,
          initials: initials(message.from),
          subject: clip(message.subject, limit),
          preview: clip(message.preview, limit - String.length(message.subject) - 3),
          time: message.time,
          unread: Map.get(message, :unread, false),
          agent: agent,
          tag_x: Float.round(352 - String.length(message.time) * 5.5 - tag_w, 1)
        }
      end

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:tag_w, tag_w)
      |> assign(:summary, assigns.count || "#{unread} unread")
      |> assign(:height, 44 + length(rows) * 52 + 6)

    ~H"""
    <.frame name="email/inbox" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 380 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="379" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-t-strong" x="20" y="28" font-size="14">{@title}</text>
            <text class="ehv-mono" x="360" y="27" font-size="8.5" text-anchor="end">
              {String.upcase(@summary)}
            </text>
            <line class="ehv-line-soft" x1="0.5" x2="379.5" y1="43.5" y2="43.5" />
          </g>
          <g
            :for={row <- @rows}
            class={["ehv-email-inbox__row", "ehv-a-rise", "ehv-i-#{row.index + 2}"]}
          >
            <rect class="ehv-email-inbox__bg" x="6" y={row.y + 3} width="368" height="46" rx="8" />
            <line
              :if={row.index > 0}
              class="ehv-line-faint"
              x1="62"
              x2="360"
              y1={row.y + 0.5}
              y2={row.y + 0.5}
            />
            <circle :if={row.unread} class="ehv-fill-ink" cx="14" cy={row.y + 26} r="2.5" />
            <circle class="ehv-card-3" cx="38" cy={row.y + 26} r="12" />
            <text
              class="ehv-mono ehv-t-2"
              x="38"
              y={row.y + 29}
              font-size="7.5"
              text-anchor="middle"
            >
              {row.initials}
            </text>
            <text
              class={if row.unread, do: "ehv-t-strong", else: "ehv-t-2"}
              x="62"
              y={row.y + 21.5}
              font-size="12.5"
            >
              {row.from}
            </text>
            <text
              class={["ehv-mono", row.unread && "ehv-t-2"]}
              x="360"
              y={row.y + 20.5}
              font-size="8"
              text-anchor="end"
            >
              {String.upcase(row.time)}
            </text>
            <text x="62" y={row.y + 39} font-size="11">
              <tspan class={if row.unread, do: "ehv-email-inbox__subject", else: "ehv-t-2"}>
                {row.subject}
              </tspan>
              <tspan :if={row.preview != ""} class="ehv-t-3">· {row.preview}</tspan>
            </text>
            <g :if={row.agent} class={"ehv-a-pop ehv-i-#{row.index + 5}"}>
              <rect
                class="ehv-fill-lime-soft"
                x={row.tag_x}
                y={row.y + 9.5}
                width={@tag_w}
                height="16"
                rx="5"
              />
              <path
                class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{row.index + 7}"}
                d={"M#{row.tag_x + 7} #{row.y + 17.5} l2.4 2.4 l4.2 -4.8"}
                pathLength="1"
              />
              <text
                class="ehv-mono ehv-t-lime"
                x={row.tag_x + @tag_w - 7}
                y={row.y + 20}
                font-size="7"
                text-anchor="end"
              >
                {String.upcase(@agent_tag)}
              </text>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end

  defp clip(_text, max_chars) when max_chars < 4, do: ""

  defp clip(text, max_chars) do
    if String.length(text) > max_chars do
      String.trim_trailing(String.slice(text, 0, max_chars - 1)) <> "…"
    else
      text
    end
  end
end
assets/css/email/inbox.css
inbox.css
/* email/inbox */
.ehv-email-inbox { --ehv-max-w: 420px; }

.ehv svg .ehv-email-inbox__subject { fill: var(--ehv-ink); }

/* A row lights up under the pointer. */
.ehv .ehv-email-inbox__bg { fill: transparent; }
.ehv--hover .ehv-email-inbox__bg { transition: fill 200ms var(--ehv-ease); }
.ehv--hover .ehv-email-inbox__row:hover .ehv-email-inbox__bg { fill: var(--ehv-surface-2); }