tasks/checklist.ex
Checklist
A checklist whose items complete one by one, with a progress bar and the next item marked.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.tasks_checklist animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
eyebrow |
string | "Handover checklist" |
|
title |
string | "Morning shift, Warehouse B" |
Up to 34 characters. |
items |
list | [%{meta: "06:40", text: "Count the cash float"}, %{meta: "06:52", text: "Sign off open pick lists"}, ...] |
Up to seven `%{text, meta}`. Text holds 32 characters; meta is a short time or name on the right. |
done |
integer | 3 |
How many items are completed, from the top. The one after them is next. |
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/tasks/checklist.ex
defmodule EHVisuals.Tasks.Checklist do
@moduledoc """
A checklist whose items complete one by one, with the progress on top.
"""
use EHVisuals.Composition,
name: "tasks/checklist",
title: "Checklist",
description:
"A checklist whose items complete one by one, with a progress bar and the next item marked."
@items [
%{text: "Count the cash float", meta: "06:40"},
%{text: "Sign off open pick lists", meta: "06:52"},
%{text: "Log damaged pallets", meta: "07:05"},
%{text: "Hand over the scanner keys", meta: "Jan K."},
%{text: "Brief the next shift lead", meta: "Ewa L."}
]
@row_h 36
@rows_y 92
attr(:eyebrow, :string, default: "Handover checklist")
attr(:title, :string, default: "Morning shift, Warehouse B", doc: "Up to 34 characters.")
attr(:items, :list,
default: @items,
doc:
"Up to seven `%{text, meta}`. Text holds 32 characters; meta is a short time or name on the right."
)
attr(:done, :integer,
default: 3,
doc: "How many items are completed, from the top. The one after them is next."
)
attr(:label, :string, default: "A handover checklist with three of five items completed")
frame_attrs()
@spec tasks_checklist(map()) :: Phoenix.LiveView.Rendered.t()
def tasks_checklist(assigns) do
items = Enum.take(assigns.items, 7)
total = length(items)
done = assigns.done |> max(0) |> min(total)
rows =
for {item, index} <- Enum.with_index(items) do
%{
index: index,
y: @rows_y + index * @row_h,
text: Map.get(item, :text, ""),
meta: Map.get(item, :meta, ""),
state:
cond do
index < done -> "done"
index == done -> "next"
true -> "open"
end
}
end
assigns =
assigns
|> assign(:rows, rows)
|> assign(:complete, total > 0 and done == total)
|> assign(:count, "#{done} of #{total}")
|> assign(:fill_w, if(total == 0, do: 0, else: Float.round(300 * done / total, 1)))
|> assign(:height, @rows_y + total * @row_h + 8)
~H"""
<.frame name="tasks/checklist" 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-mono" x="20" y="31" font-size="9">{String.upcase(@eyebrow)}</text>
<text
class={["ehv-mono", if(@complete, do: "ehv-t-lime", else: "ehv-t-2")]}
x="320"
y="31"
font-size="9"
text-anchor="end"
>
{String.upcase(@count)}
</text>
<text class="ehv-t-strong" x="20" y="56" font-size="15">{@title}</text>
</g>
<g class="ehv-a-fade ehv-i-2">
<rect class="ehv-fill-soft" x="20" y="70" width="300" height="4" rx="2" />
<rect
:if={@fill_w > 0}
class="ehv-fill-lime ehv-a-grow-x ehv-i-5"
x="20"
y="70"
width={@fill_w}
height="4"
rx="2"
/>
</g>
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{row.index + 2}"}>
<line
:if={row.index > 0}
class="ehv-line-faint"
x1="20"
x2="320"
y1={row.y + 0.5}
y2={row.y + 0.5}
/>
<g :if={row.state == "done"}>
<rect class="ehv-fill-lime-soft" x="20" y={row.y + 9} width="18" height="18" rx="5.5" />
<path
class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{min(row.index * 2 + 6, 24)}"}
d={"M24.6 #{row.y + 18.2} l3 3 l5.6 -6.4"}
pathLength="1"
/>
</g>
<rect
:if={row.state == "next"}
class="ehv-tasks-checklist__next"
x="20.5"
y={row.y + 9.5}
width="17"
height="17"
rx="5"
/>
<rect
:if={row.state == "open"}
class="ehv-node-soft"
x="20.5"
y={row.y + 9.5}
width="17"
height="17"
rx="5"
/>
<text
class={[
row.state == "done" && "ehv-t-3 ehv-tasks-checklist__struck",
row.state == "next" && "ehv-t-strong",
row.state == "open" && "ehv-t-2"
]}
x="50"
y={row.y + 22.4}
font-size="12.5"
>
{row.text}
</text>
<text
class={[
"ehv-mono",
row.state == "next" && "ehv-t-ink",
row.state == "done" && "ehv-t-4"
]}
x="320"
y={row.y + 21.5}
font-size="8"
text-anchor="end"
>
{String.upcase(row.meta)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
end
assets/css/tasks/checklist.css
/* tasks/checklist */
.ehv-tasks-checklist { --ehv-max-w: 370px; --ehv-step: 120ms; }
/* A completed item is struck through in its own ink. */
.ehv svg .ehv-tasks-checklist__struck { text-decoration: line-through; }
/* The next item: an empty box with an ink ring. On hover it previews the tick. */
.ehv-tasks-checklist__next { fill: var(--ehv-canvas); stroke: var(--ehv-ink); stroke-width: 1.25; }
.ehv--hover .ehv-tasks-checklist__next { transition: stroke 200ms var(--ehv-ease), fill 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-tasks-checklist__next { stroke: var(--ehv-lime); fill: var(--ehv-lime-soft); }