Skip to content
99 linesCodeBlameRaw
1import { Check, ChevronRight, X } from "lucide-react";
2import { useCallback, useSyncExternalStore } from "react";
3import { Link } from "react-router";
4
5import { type ChecklistItem, dismiss, isDismissed, progress } from "../lib/checklist";
6import { cn } from "../lib/cn";
7import { Button } from "./ui/button";
8import { Hint } from "./ui/hint";
9
10const storage = () => (typeof window === "undefined" ? null : window.localStorage);
11const listeners = new Set<() => void>();
12const subscribe = (listener: () => void) => {
13 listeners.add(listener);
14 return () => listeners.delete(listener);
15};
16
17/**
18 * The steps to production, or to a library's first release, for one
19 * project, as `3/6`, until they are all done or someone dismisses it.
20 * Dismissing is per project and remembered by this browser.
21 */
22export function ProductionChecklist({
23 base,
24 items,
25 title = "Get to production",
26}: {
27 base: string;
28 items: ChecklistItem[];
29 /** "Ship a release" for a library. */
30 title?: string;
31}) {
32 const hidden = useSyncExternalStore(
33 subscribe,
34 useCallback(() => isDismissed(storage, base), [base]),
35 () => false,
36 );
37 const { done, total, complete } = progress(items);
38 if (hidden || complete) return null;
39 const next = items.find((item) => !item.done)?.key;
40
41 return (
42 <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface">
43 <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6">
44 <h2 id="checklist-title" className="text-sm font-semibold">
45 {title}
46 </h2>
47 <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted">
48 {done}/{total}
49 </span>
50 <span className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-line sm:block" aria-hidden>
51 <span className="block h-full rounded-full bg-success" style={{ width: `${(done / total) * 100}%` }} />
52 </span>
53 <Hint label="Dismiss for this project">
54 <Button
55 type="button"
56 onClick={() => {
57 dismiss(storage, base);
58 for (const listener of listeners) listener();
59 }} variant="ghost" size="inline" className="ml-auto p-1 text-faint" aria-label="Dismiss the checklist for this project">
60 <X size={14} />
61 </Button>
62 </Hint>
63 </div>
64 <ol className="grid border-t border-line sm:grid-cols-2 lg:grid-cols-3">
65 {items.map((item) => (
66 <li key={item.key} className="border-b border-line last:border-b-0 sm:[&:nth-last-child(-n+2)]:border-b-0 lg:[&:nth-last-child(-n+3)]:border-b-0">
67 <Link
68 to={item.to}
69 className={cn(
70 "group flex h-full items-start gap-3 px-5 py-3 transition-colors hover:bg-raised/50 sm:px-6",
71 item.done && "opacity-70",
72 )}
73 >
74 <span
75 className={cn(
76 "mt-px flex size-4.5 shrink-0 items-center justify-center rounded-full border",
77 item.done ? "border-success bg-success text-bg" : item.key === next ? "border-success" : "border-line-strong",
78 )}
79 aria-hidden
80 >
81 {item.done && <Check size={11} strokeWidth={3} />}
82 </span>
83 <span className="min-w-0 grow">
84 <span className={cn("block text-[0.8125rem] font-medium", item.done && "text-muted line-through decoration-line-strong")}>
85 {item.title}
86 <span className="sr-only">{item.done ? " (done)" : ""}</span>
87 </span>
88 {!item.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{item.detail}</span>}
89 </span>
90 {!item.done && (
91 <ChevronRight size={14} className="mt-0.5 shrink-0 text-faint transition-colors group-hover:text-fg" />
92 )}
93 </Link>
94 </li>
95 ))}
96 </ol>
97 </section>
98 );
99}