empowered.house ↗

tasks/kanban.ex

Kanban

A kanban board with three columns and one card lifted, on its way to the next column.

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

<div class="h-96 rounded-xl border border-border">
  <.tasks_kanban animated />
</div>
default
isometric
from the first column · custom copy
hover · no glow
light surface
at rest · isometric · fade out · still

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