empowered.house ↗

tasks/checklist.ex

Checklist

A checklist whose items complete one by one, with a progress bar and the next item marked.

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

<div class="h-96 rounded-xl border border-border">
  <.tasks_checklist animated />
</div>
default
isometric
all done · custom copy
just started · hover · no glow
light surface
isometric · fade out · still

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