Manual Recipes Todo App: Markup and Styles

Todo App: Markup and Styles

elements man recipes/todo-app-markup Read as markdown

The complete markup and stylesheet for the todo app (opens in a new tab). The recipe builds the page piece by piece; this is the whole of it, assembled, so the page looks finished the first time it renders. Copy both files.

The stylesheet is mostly layout. The card, tabs, buttons, inputs and checkbox come styled from @elements/style, and every color, space and duration below is a token, so it follows dark mode and a brand theme with nothing more.

The template

The top of template.ehtml imports what the functions in the recipe use. The functions go between the imports and the markup.

app/pages/home/template.ehtml
import "./style.css"; import { LiveView, flush } from "@elements/app"; import { byPriority, Draft, Edit, Filter, matches, parseFilter, Todo, View } from "./models";
app/pages/home/template.ehtml
<FilterTab ( view: View, filter: Filter, label: string, count: number, )> <button class={["tab", view.filter === filter && "is-active"]} type="button" aria-pressed={view.filter === filter} onclick={() => onFilter(view, filter)}> {label} <span class="todo-tab-count">{count}</span> </button> </FilterTab> <html class="page-todos" ( todos: LiveView<Todo>, view: View, private draft: Draft = { title: "" }, private edit: Edit = { id: "", title: "", }, ) oninit={() => window.addEventListener("popstate", () => onPopState(view))}> <head> <title>Todos</title> </head> <body> <main class="page-shell is-form todo-page"> <h1 class="todo-heading">Todos</h1> <div class="card todo-card"> <form class="todo-composer" onsubmit={() => onAdd(todos, draft)}> <input value={draft.title} placeholder="What needs doing?" aria-label="New todo"> <button class="is-primary">Add</button> </form> <div class="tab-list todo-tabs"> <FilterTab view={view} filter="all" label="All" count={countOf(todos, "all")} /> <FilterTab view={view} filter="active" label="Active" count={countOf(todos, "active")} /> <FilterTab view={view} filter="done" label="Done" count={countOf(todos, "done")} /> </div> <ul class="todo-list" e:if={countOf(todos, view.filter) > 0}> <li e:for={todo of todos.filter((todo) => matches(todo, view.filter)).sort(byPriority)} class={["todo-item", todo.done && "is-done", todo.leaving && "is-leaving"]} onanimationend={() => onLeaveEnd(todos, todo)}> <input type="checkbox" checked={todo.done} aria-label={`Done: ${todo.title}`} onchange={(e) => onToggle(todos, todo, (e.target as HTMLInputElement).checked)}> <input class="todo-text" e:if={edit.id === todo.id} value={edit.title} aria-label="Edit todo" oninsert={(e) => (e.target as HTMLInputElement).select()} onblur={() => onCommit(todos, edit, todo)} onkeydown={(e) => onEditKeyDown(e, edit)} /> <button class="todo-label" e:else type="button" aria-label={`Edit ${todo.title}`} onclick={() => onEdit(edit, todo)}> {todo.title} </button> <button class={["is-icon", "todo-star", todo.starred && "is-starred"]} type="button" aria-pressed={todo.starred} aria-label={todo.starred ? `Unstar ${todo.title}` : `Star ${todo.title}`} onclick={() => onStar(todos, todo)}>★</button> <button class="is-icon todo-remove" type="button" aria-label={`Delete ${todo.title}`} onclick={() => onRemove(todos, todo)}>×</button> </li> </ul> <div e:else class="todo-empty"> <p class="todo-empty-title" e:if={todos.length === 0}>Nothing on the list</p> <p class="todo-empty-title" e:elseif={view.filter === "active"}>All caught up</p> <p class="todo-empty-title" e:else>Nothing finished yet</p> </div> <div class="todo-foot" e:if={countOf(todos, "done") > 0}> <button class="is-ghost is-sm" type="button" onclick={() => onClearDone(todos)}>Clear done</button> </div> </div> </main> </body> </html>

The label is a button, not a span, so it can take focus and be clicked to rename. The stylesheet makes it read as text.

The stylesheet

Save it before the template (start/patterns): the reload that first shows the markup then already has its rules.

app/pages/home/style.css
@import "#app/shared/styles/page.css"; .todo-page { padding-block: var(--space-10); } .todo-heading { margin-block: 0 var(--space-6); } /* The card's own parts would pad every row the same; the list wants its rows edge to edge, so the card is laid out here instead. */ .todo-composer { display: flex; gap: var(--space-2); padding: var(--space-4); } .todo-composer input { flex: 1; min-width: 0; } .todo-tabs { padding-inline: var(--space-4); } .todo-tab-count { margin-inline-start: var(--space-1-5); color: var(--ink-faint); font-variant-numeric: tabular-nums; } .todo-list { margin: 0; padding: 0; list-style: none; } .todo-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-4); border-bottom: 1px solid var(--rule-soft); } .todo-item:last-child { border-bottom: 0; } /* The label is a button so it can be focused and clicked to rename, but it reads as text: no box, left-aligned, wrapping. */ .todo-label { flex: 1; min-width: 0; height: auto; padding: var(--space-1) 0; border: 0; background: none; color: var(--ink); font: inherit; text-align: start; justify-content: flex-start; overflow-wrap: anywhere; } .todo-text { flex: 1; min-width: 0; } .todo-item.is-done .todo-label { color: var(--ink-faint); text-decoration: line-through; } .todo-star, .todo-remove { border-color: transparent; color: var(--ink-faint); } .todo-star.is-starred { color: var(--warning); } .todo-remove:hover { color: var(--danger); } /* The delete waits on this animation's animationend; LEAVE_MS in the template matches --duration-normal as the backstop. */ .todo-item.is-leaving { animation: todo-leave var(--duration-normal) var(--ease-in) forwards; } @keyframes todo-leave { to { opacity: 0; transform: translateX(var(--space-4)); } } @media (prefers-reduced-motion: reduce) { .todo-item.is-leaving { animation-duration: 1ms; } } .todo-empty { padding: var(--space-10) var(--space-4); text-align: center; } .todo-empty-title { margin: 0; color: var(--ink-soft); } .todo-foot { display: flex; justify-content: flex-end; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--rule-soft); }

The leave animation is what ends a delete. onLeaveEnd runs on its animationend, and LEAVE_MS in the template is the backstop timer, set to match --duration-normal. Change one and change the other.