process/approval.ex
Approval
An approval request with its approvers and the outcome, for people-decide moments.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.process_approval animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
eyebrow |
string | "Approval request" |
|
title |
string | "Purchase order PO-2291" |
|
meta |
string | "Requested by the intake agent" |
|
amount |
string | "€18,400" |
|
approvers |
list | [%{name: "Marta Nowak", status: "approved", role: "Team lead"}, %{name: "Jan Kowalski", ...}, ...] |
Up to three `%{name, role, status}`. Status is `approved`, `waiting` or `rejected`. |
outcome |
string | nil |
The footer line. Left empty, it is written from the approver statuses. |
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/process/approval.ex
defmodule EHVisuals.Process.Approval do
@moduledoc """
An approval request: what is asked, who has decided, and the outcome.
"""
use EHVisuals.Composition,
name: "process/approval",
title: "Approval",
description:
"An approval request with its approvers and the outcome, for people-decide moments."
@approvers [
%{name: "Marta Nowak", role: "Team lead", status: "approved"},
%{name: "Jan Kowalski", role: "Finance", status: "approved"},
%{name: "Ewa Lis", role: "Operations", status: "approved"}
]
attr(:eyebrow, :string, default: "Approval request")
attr(:title, :string, default: "Purchase order PO-2291")
attr(:meta, :string, default: "Requested by the intake agent")
attr(:amount, :string, default: "€18,400")
attr(:approvers, :list,
default: @approvers,
doc: "Up to three `%{name, role, status}`. Status is `approved`, `waiting` or `rejected`."
)
attr(:outcome, :string,
default: nil,
doc: "The footer line. Left empty, it is written from the approver statuses."
)
attr(:label, :string, default: "An approval request signed off by three people")
frame_attrs()
@spec process_approval(map()) :: Phoenix.LiveView.Rendered.t()
def process_approval(assigns) do
rows = assigns.approvers |> Enum.take(3) |> Enum.with_index()
statuses = Enum.map(rows, fn {row, _index} -> row.status end)
footer_y = 104 + length(rows) * 38 + 8
assigns =
assigns
|> assign(:rows, rows)
|> assign(:tone, tone(statuses))
|> assign(:footer, assigns.outcome || outcome(statuses))
|> assign(:footer_y, footer_y)
|> assign(:height, footer_y + 30 + 20)
~H"""
<.frame name="process/approval" 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" />
<text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
{String.upcase(@eyebrow)}
</text>
<text class="ehv-t-strong ehv-a-fade ehv-i-2" x="20" y="56" font-size="15">{@title}</text>
<text class="ehv-t-3 ehv-a-fade ehv-i-2" x="20" y="76" font-size="11.5">{@meta}</text>
<text
class="ehv-t-display ehv-a-fade ehv-i-2"
x="320"
y="57"
font-size="19"
text-anchor="end"
>
{@amount}
</text>
<line class="ehv-line-faint" x1="20" x2="320" y1="94.5" y2="94.5" />
<g :for={{row, index} <- @rows} class={"ehv-a-rise ehv-i-#{index + 3}"}>
<circle class="ehv-card-3" cx="32" cy={123 + index * 38} r="12" />
<text
class="ehv-mono ehv-t-2"
x="32"
y={126 + index * 38}
font-size="8"
text-anchor="middle"
>
{initials(row.name)}
</text>
<text x="54" y={121 + index * 38} font-size="12.5">{row.name}</text>
<text class="ehv-mono" x="54" y={134 + index * 38} font-size="8">
{String.upcase(row.role)}
</text>
<g :if={row.status == "approved"}>
<circle class="ehv-fill-lime-soft" cx="308" cy={123 + index * 38} r="10" />
<path
class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{index + 6}"}
d={"M303.5 #{123 + index * 38} l3.2 3.2 l5.6 -6.4"}
pathLength="1"
/>
</g>
<g :if={row.status == "rejected"}>
<circle class="ehv-fill-coral-soft" cx="308" cy={123 + index * 38} r="10" />
<path
class="ehv-line-coral ehv-round"
d={"M304 #{119 + index * 38} l8 8 m0 -8 l-8 8"}
/>
</g>
<g :if={row.status not in ["approved", "rejected"]}>
<circle class="ehv-line ehv-dash" cx="308" cy={123 + index * 38} r="9.5" />
<circle class="ehv-fill-3 ehv-a-pulse" cx="308" cy={123 + index * 38} r="2.5" />
</g>
</g>
<g class="ehv-a-rise ehv-i-8">
<rect
class={"ehv-process-approval__outcome--#{@tone}"}
x="20"
y={@footer_y}
width="300"
height="30"
rx="6"
/>
<text
class={["ehv-mono", "ehv-process-approval__outcome-text--#{@tone}"]}
x="170"
y={@footer_y + 18.5}
font-size="9"
text-anchor="middle"
>
{String.upcase(@footer)}
</text>
</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 tone(statuses) do
cond do
"rejected" in statuses -> "stop"
Enum.all?(statuses, &(&1 == "approved")) -> "go"
true -> "wait"
end
end
defp outcome(statuses) do
case tone(statuses) do
"go" -> "Approved · posted to the ERP"
"stop" -> "Sent back with a question"
"wait" -> "Waiting for #{Enum.count(statuses, &(&1 != "approved"))} of #{length(statuses)}"
end
end
end
assets/css/process/approval.css
/* process/approval */
.ehv-process-approval { --ehv-max-w: 360px; }
.ehv-process-approval__outcome--go { fill: var(--ehv-lime-fill); }
.ehv-process-approval__outcome--wait { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-process-approval__outcome--stop { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }
.ehv svg .ehv-mono.ehv-process-approval__outcome-text--go { fill: var(--ehv-on-lime); }
.ehv svg .ehv-mono.ehv-process-approval__outcome-text--wait { fill: var(--ehv-ink-2); }
.ehv svg .ehv-mono.ehv-process-approval__outcome-text--stop { fill: var(--ehv-coral); }