/**
* Keeps a list of titled items you can add to, tick off, rename and remove,
* with live counts of how many remain and how many are done, an action that
* drops every completed item, and an optional per-row quantity field.
*
* Embed it as `` and it mutates the host's own
* array: adding, toggling and removing all write through the cell the host
* passed.
*
* Items are addressed by live reference, never by index or by a minted id, so
* a reference a host stashed before an edit still matches afterwards.
*
* @hashtags checklist, todo, list, tasks, packing-list, shopping-list
* @keywords check off, tick, toggle, done, complete, add item, remove item,
* remaining, packing list, grocery list, shopping list, todo list, task list,
* checkbox list, quantities
*/
import {
action,
computed,
Default,
ifElse,
NAME,
pattern,
Stream,
UI,
type VNode,
Writable,
} from "commonfabric";
export interface CheckItem {
title: string;
done: boolean | Default;
quantity: number | Default<1>;
}
export interface CheckListInput {
/** The list itself. A host passes its own cell to share it. */
items?: Writable>;
/** Placeholder in the add-item field. */
placeholder?: Writable>;
/** Shown in place of the rows while the list is empty. */
emptyMessage?: Writable>;
/** Whether each row carries an editable quantity. */
showQuantity?: Writable>;
}
export interface CheckListOutput {
[NAME]: string;
[UI]: VNode;
items: CheckItem[];
remainingCount: number;
completedCount: number;
summary: string;
addItem: Stream<{ title: string; quantity?: number }>;
toggleItem: Stream<{ item: CheckItem }>;
removeItem: Stream<{ item: CheckItem }>;
clearCompleted: Stream;
}
export const CheckList = pattern(
({ items, placeholder, emptyMessage, showQuantity }) => {
const draft = new Writable("");
const addItem = action(
({ title, quantity }: { title: string; quantity?: number }) => {
const trimmed = title.trim();
if (!trimmed) return;
items.push({ title: trimmed, done: false, quantity: quantity ?? 1 });
},
);
// Toggling writes through the element's own cell rather than replacing the
// array slot: a fresh object literal would re-mint the entity identity and
// silently strand every reference taken before the edit.
const toggleItem = action(({ item }: { item: CheckItem }) => {
const index = items.get().findIndex((candidate) =>
Writable.equals(candidate, item)
);
if (index < 0) return;
const cell = items.key(index).key("done");
cell.set(!cell.get());
});
const removeItem = action(({ item }: { item: CheckItem }) => {
// Plain reads of inline rows carry value references; mapped UI rows
// carry the live slot reference.
const index = items.get().findIndex((candidate, index) =>
Writable.equals(items.key(index), item) ||
Writable.equals(candidate, item)
);
if (index < 0) return;
items.set(items.get().filter((_, position) => position !== index));
});
const clearCompleted = action(() => {
items.set(items.get().filter((item) => !item.done));
});
const remainingCount = computed(() =>
items.get().filter((item) => !item.done).length
);
const completedCount = computed(() =>
items.get().filter((item) => item.done).length
);
const isEmpty = computed(() => items.get().length === 0);
const hasCompleted = computed(() => completedCount > 0);
const summary = computed(() =>
items.get()
.map((item) => `${item.done ? "done" : "open"}: ${item.title}`)
.join(", ")
);
const rows = items.map((item: CheckItem) => (
{ifElse(
showQuantity,
,
null,
)}
removeItem.send({ item })}
>
Remove
));
return {
[NAME]: computed(() => `Checklist (${remainingCount} left)`),
[UI]: (
{computed(() => `${remainingCount} left, ${completedCount} done`)}
{ifElse(
hasCompleted,
Clear completed
,
null,
)}
{rows}
{ifElse(
isEmpty,
,
null,
)}
{
addItem.send({ title: draft.get() });
draft.set("");
}}
>
Add
),
items,
remainingCount,
completedCount,
summary,
addItem,
toggleItem,
removeItem,
clearCompleted,
};
},
);
export default CheckList;