tasks/kanban.ex
Kanban
A kanban board with three columns and one card lifted, on its way to the next column.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.tasks_kanban animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
columns |
list | [%{name: "To do", cards: [%{id: "OPS-48", title: "Chase Globex", ...}, ...]}, ...] |
Three `%{name, cards}`. A card is `%{id, title, who}`; up to four per column, titles up to 17 characters. |
lifted |
any | %{id: "OPS-45", title: "Post accruals", who: "Jan Kowalski"} |
The card in the air, `%{id, title, who}`, or `nil` for a board at rest. |
from |
integer | 1 |
Index of the column the lifted card leaves, 0 or 1. It moves to the next one. |
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/kanban.ex
defmodule EHVisuals.Tasks.Kanban do
@moduledoc """
A kanban board with three columns of cards, and one card lifted out of its
column on the way to the next one.
"""
use EHVisuals.Composition,
name: "tasks/kanban",
title: "Kanban",
description:
"A kanban board with three columns and one card lifted, on its way to the next column."
@columns [
%{
name: "To do",
cards: [
%{id: "OPS-48", title: "Chase Globex", who: "Ewa Lis"},
%{id: "OPS-51", title: "Count aisle 14", who: "Jan Kowalski"},
%{id: "OPS-52", title: "Carrier renewal", who: "Piotr Zielinski"}
]
},
%{
name: "In progress",
cards: [%{id: "OPS-44", title: "Match PO-2291", who: "Marta Nowak"}]
},
%{
name: "Done",
cards: [
%{id: "OPS-39", title: "Close February", who: "Marta Nowak"},
%{id: "OPS-41", title: "Onboard Acme", who: "Ewa Lis"}
]
}
]
@col_xs [0, 146, 292]
@card_w 120
@card_h 50
@step 58
@cards_y 36
attr(:columns, :list,
default: @columns,
doc:
"Three `%{name, cards}`. A card is `%{id, title, who}`; up to four per column, titles up to 17 characters."
)
attr(:lifted, :any,
default: %{id: "OPS-45", title: "Post accruals", who: "Jan Kowalski"},
doc: "The card in the air, `%{id, title, who}`, or `nil` for a board at rest."
)
attr(:from, :integer,
default: 1,
doc: "Index of the column the lifted card leaves, 0 or 1. It moves to the next one."
)
attr(:label, :string,
default: "A kanban board with a card being moved from In progress to Done"
)
frame_attrs()
@spec tasks_kanban(map()) :: Phoenix.LiveView.Rendered.t()
def tasks_kanban(assigns) do
from = assigns.from |> max(0) |> min(1)
lifted = assigns.lifted
columns =
for {{column, x}, index} <-
@col_xs |> Enum.zip_with(pad(assigns.columns), &{&2, &1}) |> Enum.with_index() do
cards =
for {card, row} <- column |> Map.get(:cards, []) |> Enum.take(4) |> Enum.with_index() do
card(card, x + 8, @cards_y + row * @step, index * 3 + row + 2)
end
slot = lifted != nil and index in [from, from + 1]
%{
index: index,
x: x,
name: Map.get(column, :name, ""),
cards: cards,
count: length(cards) + if(lifted != nil and index == from, do: 1, else: 0),
slots: length(cards) + if(slot, do: 1, else: 0),
slot_y: @cards_y + length(cards) * @step
}
end
rows = columns |> Enum.map(& &1.slots) |> Enum.max() |> max(1)
source = Enum.at(columns, from)
target = Enum.at(columns, from + 1)
flying =
if lifted do
x = target.x - 50
y = target.slot_y - 12
lifted
|> card(x, y, 0)
|> Map.merge(%{cx: x + @card_w / 2, cy: y + @card_h / 2})
end
assigns =
assigns
|> assign(:cols, columns)
|> assign(:source, source)
|> assign(:target, target)
|> assign(:flying, flying)
|> assign(:col_h, @cards_y + rows * @step + 2)
|> assign(:height, @cards_y + rows * @step + 2)
~H"""
<.frame name="tasks/kanban" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 428 #{@height}"}>
<g class="ehv-h-lift">
<g :for={col <- @cols} class={"ehv-a-rise ehv-i-#{col.index}"}>
<rect class="ehv-card" x={col.x + 0.5} y="0.5" width="135" height={@col_h - 1} rx="11" />
<text class="ehv-mono ehv-t-2" x={col.x + 12} y="23" font-size="8.5">
{String.upcase(col.name)}
</text>
<text class="ehv-mono ehv-t-4" x={col.x + 124} y="23" font-size="8.5" text-anchor="end">
{col.count}
</text>
</g>
<g :for={col <- @cols}>
<g :for={card <- col.cards} class={"ehv-a-rise ehv-i-#{min(card.stagger, 24)}"}>
<rect
class="ehv-card-2 ehv-tasks-kanban__card"
x={card.x + 0.5}
y={card.y + 0.5}
width="119"
height="49"
rx="8"
/>
<text
class={[col.index == 2 && "ehv-t-2"]}
x={card.x + 11}
y={card.y + 20}
font-size="11"
>
{card.title}
</text>
<text class="ehv-mono" x={card.x + 11} y={card.y + 38} font-size="7.5">
{String.upcase(card.id)}
</text>
<circle class="ehv-card-3" cx={card.x + 105} cy={card.y + 35} r="8" />
<text
class="ehv-mono ehv-t-2"
x={card.x + 105}
y={card.y + 37.3}
font-size="6"
text-anchor="middle"
>
{card.initials}
</text>
</g>
</g>
<g :if={@flying} class="ehv-a-fade ehv-i-8">
<rect
class="ehv-line ehv-dash"
x={@source.x + 8.5}
y={@source.slot_y + 0.5}
width="119"
height="49"
rx="8"
/>
<rect
class="ehv-tasks-kanban__drop"
x={@target.x + 8.5}
y={@target.slot_y + 0.5}
width="119"
height="49"
rx="8"
/>
</g>
<g :if={@flying} class="ehv-a-pop ehv-i-10">
<g class="ehv-tasks-kanban__carry">
<g class="ehv-tasks-kanban__float">
<g transform={"rotate(-5 #{@flying.cx} #{@flying.cy})"}>
<rect
class="ehv-tasks-kanban__lifted"
x={@flying.x + 0.5}
y={@flying.y + 0.5}
width="119"
height="49"
rx="8"
/>
<text class="ehv-t-strong" x={@flying.x + 11} y={@flying.y + 20} font-size="11">
{@flying.title}
</text>
<text
class="ehv-mono ehv-t-lime"
x={@flying.x + 11}
y={@flying.y + 38}
font-size="7.5"
>
{String.upcase(@flying.id)}
</text>
<circle class="ehv-well" cx={@flying.x + 105} cy={@flying.y + 35} r="8" />
<text
class="ehv-mono ehv-t-2"
x={@flying.x + 105}
y={@flying.y + 37.3}
font-size="6"
text-anchor="middle"
>
{@flying.initials}
</text>
</g>
</g>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Always three columns: missing ones are drawn empty.
defp pad(columns), do: Enum.take(columns ++ List.duplicate(%{name: "", cards: []}, 3), 3)
defp card(card, x, y, stagger) do
%{
x: x,
y: y,
stagger: stagger,
id: Map.get(card, :id, ""),
title: Map.get(card, :title, ""),
initials: initials(Map.get(card, :who, ""))
}
end
defp initials(name) do
name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
end
end
assets/css/tasks/kanban.css
/* tasks/kanban */
.ehv-tasks-kanban { --ehv-max-w: 480px; --ehv-step: 80ms; --ehv-iso-scale: 0.88; }
.ehv--hover .ehv-tasks-kanban__card { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-tasks-kanban__card:hover { stroke: var(--ehv-border-strong); }
/* The card in the air and the place it will land. */
.ehv-tasks-kanban__lifted { fill: var(--ehv-surface-3); stroke: var(--ehv-lime); stroke-width: 1.25; }
.ehv-tasks-kanban__drop { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1; stroke-dasharray: 3 4; }
/* Held, not placed: the card drifts a little, and leans toward its column on hover. */
@keyframes ehv-tasks-kanban-float { 50% { transform: translateY(-3px); } }
.ehv--animated .ehv-tasks-kanban__float {
animation: ehv-tasks-kanban-float 3.6s ease-in-out infinite;
animation-delay: calc(var(--ehv-dur) + 10 * var(--ehv-step));
}
.ehv--hover .ehv-tasks-kanban__carry { transition: transform 300ms var(--ehv-ease); }
.ehv--hover:hover .ehv-tasks-kanban__carry { transform: translate(12px, 4px); }