<todo-app>
<form action="#">
<input-field>
<label for="add-todo">What needs to be done?</label>
<div class="row">
<div class="group auto">
<input id="add-todo" type="text" value="" required>
</div>
</div>
</input-field>
<input-button class="submit">
<button type="submit" class="constructive" disabled>Add Todo</button>
</input-button>
</form>
<ol filter="all"></ol>
<template>
<li>
<input-checkbox class="todo">
<label>
<input type="checkbox" class="visually-hidden" />
<span class="label"><slot></slot></span>
</label>
</input-checkbox>
<input-button class="delete">
<button type="button" class="destructive small">Delete</button>
</input-button>
</li>
</template>
<footer>
<div class="todo-count">
<p class="all-done">Well done, all done!</p>
<p class="remaining">
<span class="count"></span>
<span class="singular">task</span>
<span class="plural">tasks</span>
remaining
</p>
</div>
<input-radiogroup value="all" class="split-button">
<fieldset>
<legend class="visually-hidden">Filter</legend>
<label class="selected">
<input type="radio" class="visually-hidden" name="filter" value="all" checked>
<span>All</span>
</label>
<label>
<input type="radio" class="visually-hidden" name="filter" value="active">
<span>Active</span>
</label>
<label>
<input type="radio" class="visually-hidden" name="filter" value="completed">
<span>Completed</span>
</label>
</fieldset>
</input-radiogroup>
<input-button class="clear-completed">
<button type="button" class="destructive">
<span class="label">Clear Completed</span>
<span class="badge"></span>
</button>
</input-button>
</footer>
</todo-app>
todo-app {
display: flex;
flex-direction: column;
gap: var(--space-l);
container-type: inline-size;
& form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-m);
justify-content: space-between;
}
& ol {
display: flex;
flex-direction: column;
gap: var(--space-m);
list-style: none;
margin: 0;
padding: 0;
& li {
display: flex;
justify-content: space-between;
gap: var(--space-m);
margin: 0;
padding: 0;
}
&[filter="completed"] li:not(:has([checked])) {
display: none;
}
&[filter="active"] li:has([checked]) {
display: none;
}
}
& footer {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
gap: var(--space-m);
margin: 0;
.todo-count {
justify-self: start;
& p {
font-size: var(--font-size-s);
margin: 0;
}
}
.clear-completed {
justify-self: end;
}
}
}
@container (width > 32rem) {
todo-app form {
flex-direction: row;
align-items: flex-end;
}
}
import {
type Component,
type SignalProducer,
component,
first,
on,
selection,
setAttribute,
setProperty,
setText,
} from "../../../";
import type { InputButtonProps } from "../input-button/input-button";
import type { InputCheckboxProps } from "../input-checkbox/input-checkbox";
import type { InputFieldProps } from "../input-field/input-field";
import type { InputRadiogroupProps } from "../input-radiogroup/input-radiogroup";
export type TodoAppProps = {
active: Component<InputCheckboxProps>[];
completed: Component<InputCheckboxProps>[];
};
export default component(
"todo-app",
{
active: ((el) =>
selection<Component<InputCheckboxProps>>(
el,
"input-checkbox:not([checked])",
)) as SignalProducer<HTMLElement, Component<InputCheckboxProps>[]>,
completed: ((el) =>
selection<Component<InputCheckboxProps>>(
el,
"input-checkbox[checked]",
)) as SignalProducer<HTMLElement, Component<InputCheckboxProps>[]>,
},
(el) => {
const input =
el.querySelector<Component<InputFieldProps>>("input-field");
if (!input) throw new Error("No input field found");
const template = el.querySelector("template");
if (!template) throw new Error("No template found");
const list = el.querySelector("ol");
if (!list) throw new Error("No list found");
return [
// Control todo input form
first<TodoAppProps, Component<InputButtonProps>>(
".submit",
setProperty("disabled", () => !input.length),
),
first(
"form",
on("submit", (e: Event) => {
e.preventDefault();
queueMicrotask(() => {
const value = input.value.toString().trim();
if (!value) return;
const li = document.importNode(
template.content,
true,
).firstElementChild;
if (!(li instanceof HTMLLIElement))
throw new Error(
"Invalid template for list item; expected <li>",
);
li
.querySelector("slot")
?.replaceWith(String(input.value));
list.append(li);
input.clear();
});
}),
),
// Control todo list
first(
"ol",
setAttribute(
"filter",
() =>
el.querySelector<Component<InputRadiogroupProps>>(
"input-radiogroup",
)?.value ?? "all",
),
on("click", (e: Event) => {
const target = e.target as HTMLElement;
if (target.localName === "button")
target.closest("li")!.remove();
}),
),
// Update count elements
first(
".count",
setText(() => String(el.active.length)),
),
first<TodoAppProps, HTMLElement>(
".singular",
setProperty("hidden", () => el.active.length > 1),
),
first<TodoAppProps, HTMLElement>(
".plural",
setProperty("hidden", () => el.active.length === 1),
),
first<TodoAppProps, HTMLElement>(
".remaining",
setProperty("hidden", () => !el.active.length),
),
first<TodoAppProps, HTMLElement>(
".all-done",
setProperty("hidden", () => !!el.active.length),
),
// Control clear-completed button
first<TodoAppProps, Component<InputButtonProps>>(
".clear-completed",
setProperty("disabled", () => !el.completed.length),
setProperty("badge", () =>
el.completed.length > 0 ? String(el.completed.length) : "",
),
on("click", () => {
const items = Array.from(el.querySelectorAll("ol li"));
for (let i = items.length - 1; i >= 0; i--) {
const task = items[i].querySelector("input-checkbox");
if (task?.checked) items[i].remove();
}
}),
),
];
},
);