1import type { PullRequestReviewGuideStop } from "@t3tools/contracts";
2import { CheckIcon } from "lucide-react";
3
4import { cn } from "~/lib/utils";
5
6import { Badge } from "../../ui/badge";
7import { stopSubline } from "@t3tools/client-runtime/review-guide-tour";
8
9/**
10 * The tour's table of contents: where the reader is and which stops they have read past. A stop
11 * with a call to make is marked; that is the only sign of where to slow down.
12 */
13export function GuideRail({
14 stops,
15 position,
16 seen,
17 onSelect,
18}: {
19 stops: ReadonlyArray<PullRequestReviewGuideStop>;
20 position: number;
21 /** Stops the reader has moved on from. */
22 seen: ReadonlySet<number>;
23 onSelect: (position: number) => void;
24}) {
25 const reviewed = seen.size;
26 const renderStop = (stop: PullRequestReviewGuideStop, index: number) => (
27 <li key={index}>
28 <RailRow
29 active={position === index}
30 onClick={() => onSelect(index)}
31 lead={<StopBullet number={index + 1} seen={seen.has(index)} />}
32 title={stop.title}
33 detail={
34 <>
35 {stop.attention !== null ? (
36 <Badge size="sm" variant="warning">
37 Look closely
38 </Badge>
39 ) : null}
40 <span>{stopSubline(stop)}</span>
41 </>
42 }
43 />
44 </li>
45 );
46
47 return (
48 <nav aria-label="Review tour" className="flex min-h-0 flex-col gap-4">
49 {/* Beside the stop only; narrow, the stop's own header says where the reader is. */}
50 <p className="text-xs text-muted-foreground tabular-nums @max-[56rem]/guide:hidden">
51 {reviewed} of {stops.length} read
52 </p>
53 <div className="flex flex-col gap-4">
54 <ol className="flex flex-col gap-0.5">{stops.map(renderStop)}</ol>
55 </div>
56 </nav>
57 );
58}
59
60/** A semantic row of the tour's list rather than a control styled as a button. */
61function RailRow({
62 active,
63 onClick,
64 lead,
65 title,
66 detail,
67}: {
68 active: boolean;
69 onClick: () => void;
70 lead: React.ReactNode;
71 title: string;
72 detail: React.ReactNode;
73}) {
74 return (
75 <button
76 type="button"
77 aria-current={active ? "step" : undefined}
78 onClick={onClick}
79 className={cn(
80 "flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
81 active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
82 )}
83 >
84 {lead}
85 <span className="flex min-w-0 flex-col gap-0.5">
86 <span className="text-sm text-foreground">{title}</span>
87 {detail !== null ? (
88 <span className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
89 {detail}
90 </span>
91 ) : null}
92 </span>
93 </button>
94 );
95}
96
97function StopBullet({ number, seen }: { number: number; seen: boolean }) {
98 return (
99 <span
100 className={cn(
101 "flex size-5 shrink-0 items-center justify-center rounded-full border text-2xs tabular-nums",
102 seen ? "border-success/40 text-success-foreground" : "border-border text-muted-foreground",
103 )}
104 >
105 {seen ? <CheckIcon aria-label={`Stop ${number}, read`} className="size-3" /> : number}
106 </span>
107 );
108}