Todo List
A todo list kept as one state on the server. Add tasks, tick them off and filter them, while the header and the list renders again only when something they read has changed. Rows slide in and out with no JavaScript of the page's own.
Todo List
Saturday, October 24
My tasks
1 of 3 done
33%
-
Read the Hibiki recipes
-
Build a reactive island
-
Ship it before lunch
# frozen_string_literal: true
# A todo list held entirely in signals: no table, one list per browser tab.
# Actions only write @items or @filter; the two effects morph the header
# summary and the list whenever what they read changes.
class Recipes::Todo::TodoChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Item = Data.define(:id, :title, :done)
FILTERS = %w[all active done].freeze
SEED = [
Item.new(id: 1, title: "Read the Hibiki recipes", done: true),
Item.new(id: 2, title: "Build a reactive island", done: false),
Item.new(id: 3, title: "Ship it before lunch", done: false)
].freeze
def build_graph
@items = Hibiki::State.new(SEED)
@filter = Hibiki::State.new("all")
@next_id = SEED.size + 1
visible = Hibiki::Derived.new do
case @filter.value
when "active" then @items.value.reject(&:done)
when "done" then @items.value.select(&:done)
else @items.value
end
end
Hibiki::Effect.new do
broadcast_morph target: "todo_summary",
partial: "app/recipes/todo/summary",
locals: { items: @items.value }
end
Hibiki::Effect.new do
broadcast_morph target: "todo_list",
partial: "app/recipes/todo/list",
locals: { items: @items.value, visible: visible.value, filter: @filter.value }
end
end
def add(data)
title = data["title"].to_s.strip.first(120)
return if title.empty?
@items.value = @items.value + [ Item.new(id: @next_id, title:, done: false) ]
@next_id += 1
end
def toggle(data)
id = Integer(data["id"])
@items.value = @items.value.map { it.id == id ? it.with(done: !it.done) : it }
end
def remove(data)
id = Integer(data["id"])
@items.value = @items.value.reject { it.id == id }
end
def clear_done = @items.value = @items.value.reject(&:done)
def set_filter(data)
@filter.value = data["filter"] if FILTERS.include?(data["filter"])
end
end
<% items = Recipes::Todo::TodoChannel::SEED %>
<%= island Recipes::Todo::TodoChannel,
class: "mx-auto w-full max-w-md overflow-hidden rounded-3xl bg-base-100 shadow-2xl shadow-fuchsia-500/10 ring-1 ring-base-content/10" do %>
<header class="relative isolate overflow-hidden bg-linear-to-br from-violet-600 via-fuchsia-500 to-orange-400 px-6 pt-6 pb-16 text-white">
<div class="absolute -top-16 -right-10 -z-10 size-48 rounded-full bg-white/15 blur-2xl"></div>
<div class="absolute -bottom-20 -left-12 -z-10 size-56 rounded-full bg-violet-900/30 blur-3xl"></div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-white/70"><%= Date.current.strftime("%A, %B %-d") %></p>
<h2 class="mt-1 text-3xl font-black tracking-tight">My tasks</h2>
<%= render "app/recipes/todo/summary", items: %>
</header>
<%= tag.form class: "relative z-10 -mt-7 mx-4 flex items-center gap-2 rounded-2xl bg-base-100 p-2 shadow-xl ring-1 ring-base-content/10",
**on(:add, event: :submit) do %>
<input name="title" required maxlength="120" autocomplete="off" placeholder="What needs doing?"
aria-label="New task"
class="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-base placeholder:text-base-content/40 focus:ring-0">
<button class="btn btn-circle border-0 bg-linear-to-br from-violet-600 to-fuchsia-500 text-white shadow-lg shadow-fuchsia-500/30 transition hover:scale-105 active:scale-95"
aria-label="Add task">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="size-5"><path d="M12 5v14M5 12h14"/></svg>
</button>
<% end %>
<%= render "app/recipes/todo/list", items:, visible: items, filter: "all" %>
<% end %>
<%# locals: (items:) -%>
<% done = items.count(&:done) %>
<% percent = items.empty? ? 0 : (done * 100 / items.size) %>
<div id="todo_summary" class="mt-5">
<div class="flex items-end justify-between">
<p class="text-sm text-white/80">
<span class="font-semibold text-white"><%= done %></span> of <%= items.size %> done
</p>
<p class="text-4xl font-black tabular-nums leading-none"><%= percent %><span class="text-lg font-bold text-white/70">%</span></p>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-white/25">
<div class="h-full rounded-full bg-white shadow-[0_0_12px_rgba(255,255,255,0.8)] transition-[width] duration-700 ease-out"
style="width: <%= percent %>%"></div>
</div>
</div>
<%# locals: (items:, visible:, filter:) -%>
<%# Morphed by the channel. Every row keeps its own id, so a new row is a real
insertion (it animates in) and a removed one is held until it slides shut. %>
<div id="todo_list" class="px-4 pt-4 pb-2">
<ul id="todo_items">
<% visible.each do |item| %>
<li id="todo_item_<%= item.id %>" data-motion
class="group grid grid-rows-[1fr] transition-[grid-template-rows,opacity,translate] duration-300 ease-out
starting:grid-rows-[0fr] starting:opacity-0 starting:-translate-y-2
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0 data-motion-leaving:translate-x-6
motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex items-center gap-3 rounded-2xl px-2 py-2.5 transition-colors hover:bg-base-200/70">
<%= tag.button type: "button", role: "checkbox", "aria-checked": item.done,
"aria-label": "Mark “#{item.title}” #{item.done ? 'not done' : 'done'}",
class: [ "grid size-6 shrink-0 place-items-center rounded-full border-2 transition-all duration-300",
item.done ? "scale-100 border-transparent bg-linear-to-br from-violet-600 to-fuchsia-500 text-white shadow-md shadow-fuchsia-500/40"
: "border-base-content/25 text-transparent hover:border-fuchsia-500 hover:scale-110" ],
**on(:toggle, with: { id: item.id }) do %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
<% end %>
<span class="<%= class_names("flex-1 truncate transition-all duration-300",
"text-base-content/40 line-through decoration-fuchsia-500/60 decoration-2": item.done) %>">
<%= item.title %>
</span>
<%= tag.button type: "button", "aria-label": "Delete “#{item.title}”",
class: "btn btn-ghost btn-sm btn-circle text-base-content/40 opacity-0 transition group-hover:opacity-100 hover:text-error focus-visible:opacity-100",
**on(:remove, with: { id: item.id }) do %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="size-4"><path d="M6 6l12 12M18 6 6 18"/></svg>
<% end %>
</div>
</div>
</li>
<% end %>
</ul>
<% if visible.empty? %>
<div id="todo_empty" class="py-10 text-center transition duration-500 starting:opacity-0 starting:scale-95">
<p class="text-4xl"><%= items.empty? ? "🌱" : "🎉" %></p>
<p class="mt-2 font-semibold"><%= items.empty? ? "Nothing here yet" : "All clear" %></p>
<p class="text-sm text-base-content/50">
<%= items.empty? ? "Add your first task above." : "No #{filter == 'done' ? 'finished' : 'open'} tasks in this view." %>
</p>
</div>
<% end %>
<footer id="todo_footer" class="mt-2 flex items-center justify-between gap-2 border-t border-base-content/10 pt-3 pb-1 text-sm">
<span class="whitespace-nowrap text-base-content/60 tabular-nums">
<%= items.count { !it.done } %> left
</span>
<div id="todo_filters" class="flex rounded-full bg-base-200 p-1" role="group" aria-label="Filter tasks">
<% Recipes::Todo::TodoChannel::FILTERS.each do |name| %>
<%= tag.button name.capitalize, type: "button", id: "todo_filter_#{name}", "aria-pressed": filter == name,
class: [ "rounded-full px-2.5 py-1 text-xs sm:px-3 font-semibold transition",
filter == name ? "bg-base-100 text-fuchsia-600 shadow-sm" : "text-base-content/60 hover:text-base-content" ],
**on(:set_filter, with: { filter: name }) %>
<% end %>
</div>
<%= tag.button "Clear done", type: "button", id: "todo_clear",
disabled: items.none?(&:done),
class: "whitespace-nowrap text-xs font-semibold text-base-content/50 transition hover:text-error disabled:invisible",
**on(:clear_done) %>
</footer>
</div>
The entire todo list is Ruby in a channel. The list is one state, and two effects each look after their own part of the card. The list is not backed by a database table here, but can easily be adjusted to do so.
How it fits together
The list lives in a channel: the server side of an Action Cable
connection, an object that stays alive while the page is open and answers
messages from it. Inside it, a state holds a value and remembers
who read it. Here @items is a state holding the whole list: an
array of rows, each a small Data object with an id, a title
and whether it is done. A second state, @filter, holds the
name of the filter in use. A derived is a value worked out from
other values, and worked out again when one of them changes. Here
visible is a derived that reads both states and returns the
rows the filter lets through.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. This channel has two. The first reads
@items and renders the header summary. The second reads
@items, visible and @filter and
renders the list. Each sends its HTML to the card with
broadcast_morph, which updates its part of the card in place.
The channel's public methods are its actions, the methods the page may call. Here there are five, and each one writes a state. This is what happens when you tick a row:
-
The button sends
togglewith the row's id. -
togglebuilds a new array in which that row hasdoneflipped, and assigns it to@items. -
visibleread@items, so it is worked out again. -
Both effects read
@items, so both run again: the header shows one more done and the bar fills, and the row shows its tick.
Now press a filter. set_filter writes @filter,
visible is worked out again, and the list effect runs. The
header effect read neither of them, so it stays quiet. We never wrote
down which effect a filter change belongs
to. Whatever an effect read on its last run is what it depends on.
The island on the page
The part of a page that talks to a channel is an island: one
ordinary HTML element with the channel's controls and output inside it.
Here _todo.html.erb is the island. The island
helper marks its root element for TodoChannel and adds the
Turbo stream the effects broadcast on. Inside it, the controls send their
actions with on:
-
The form carries
on(:add, event: :submit), so submitting it sends itstitlefield toadd. The field then clears, ready for the next task. -
Each row's buttons carry
with:, soon(:toggle, with: { id: item.id })tells the channel which row was meant. The filter buttons send their name the same way.
Rows that slide in and out
A row that enters can animate with CSS alone. A row that leaves cannot,
because the swap removes it before any transition could start. The
hibiki-rails/motion module, imported once in
application.js, holds a swap back until any element marked
data-motion that is about to leave has finished its
transition. The transition itself is Tailwind on the row:
starting: for the entrance and
data-motion-leaving: for the exit.
import "hibiki-rails/motion"
Motion is also why the list is sent with broadcast_morph. A
replace would put in every row afresh, and every row would play its
entrance again. A morph keeps the rows whose ids still match, so a new row
is the only insertion, and the header's bar can slide from its old width
to the new one. The
Leave and Enter Transitions
recipe takes motion further.
The actions here only write the list; the two effects take care of the card from there. The Sign Up recipe uses the same arrangement for a form that checks itself as you type.