Todo list
Add, tick, filter and clear todos. They survive a page reload.
Each todo keeps its done flag in its own signal, so ticking
one updates its row only. Computed values derive the visible list and
the count, and an effect saves the list to localStorage
after every change.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Todo list - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Signal, Computed, application, html, For, Show, effect } = Signy;
const STORAGE_KEY = "signy-todos";
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
} catch {
return [];
}
}
// done is a signal on each item, so ticking one updates its row only
function createTodo({ id, text, done = false }) {
return { id, text, done: new Signal(done) };
}
class TodoApp extends Component {
todos = new Signal(load().map(createTodo));
filter = new Signal("all");
nextId = Math.max(0, ...this.todos.value.map((todo) => todo.id)) + 1;
visible = new Computed(() => {
const filter = this.filter.value;
return this.todos.value.filter((todo) =>
filter === "all" ? true : filter === "done" ? todo.done.value : !todo.done.value,
);
});
remaining = new Computed(() => this.todos.value.filter((todo) => !todo.done.value).length);
setup() {
effect(() => {
const plain = this.todos.value.map((todo) => ({ id: todo.id, text: todo.text, done: todo.done.value }));
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(plain));
} catch {}
});
}
add(form) {
const text = form.elements.text.value.trim();
if (!text) return;
this.todos.set((list) => [...list, createTodo({ id: this.nextId++, text })]);
form.reset();
}
remove(id) {
this.todos.set((list) => list.filter((todo) => todo.id !== id));
}
clearDone() {
this.todos.set((list) => list.filter((todo) => !todo.done.value));
}
filterButton(value, label) {
return html`
<button
class="small"
aria-pressed=${() => String(this.filter.value === value)}
onClick=${() => (this.filter.value = value)}
>${label}</button>
`;
}
render() {
return html`
<article class="card">
<header>
<h1>Todos</h1>
<p>${() => this.remaining.value} left</p>
</header>
<form class="group" onSubmit=${(e) => (e.preventDefault(), this.add(e.currentTarget))}>
<input name="text" placeholder="What needs doing?" aria-label="New todo" autocomplete="off" />
<button class="primary">Add</button>
</form>
<ul>
${For({
each: () => this.visible.value,
keyBy: "id",
fallback: html`<li>Nothing here.</li>`,
children: (todo) => html`
<li>
<label>
<input type="checkbox" checked=${todo.done.peek()}
onChange=${(e) => (todo.done.value = e.currentTarget.checked)} />
${todo.text}
</label>
<button class="small outlined danger" aria-label=${`Delete ${todo.text}`}
onClick=${() => this.remove(todo.id)}>Delete</button>
</li>
`,
})}
</ul>
<footer class="cluster">
${this.filterButton("all", "All")}
${this.filterButton("active", "Active")}
${this.filterButton("done", "Done")}
${Show({
when: () => this.todos.value.length > this.remaining.value,
children: html`<button class="small secondary outlined" onClick=${() => this.clearDone()}>Clear done</button>`,
})}
</footer>
</article>
`;
}
}
application(TodoApp).mount("#app");
</script>
</body>
</html>