| 1 | import type { PullRequestReviewGuideStop } from "@t3tools/contracts"; |
| 2 | import { CheckIcon } from "lucide-react"; |
| 3 | |
| 4 | import { cn } from "~/lib/utils"; |
| 5 | |
| 6 | import { Badge } from "../../ui/badge"; |
| 7 | import { 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 | */ |
| 13 | export 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. */ |
| 61 | function 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 | |
| 97 | function 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 | } |