144 lines · 5.7 KB · TypeScript
RawDownload
/** * Loading states specific to the pull request surface — the first list, a search under way, * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. * * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — * compositor-safe, but a layer for every bar on screen — and its white highlight over the * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the * container is a single opacity animation however many bars sit under it, and the bars take * their tone from `muted-foreground` at low alpha, which reads on both themes. */import { cn } from "~/lib/utils"; function GhostBar({ className }: { className?: string | undefined }) {  return <div aria-hidden className={cn("h-3 rounded bg-muted-foreground/15", className)} />;} /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */const TITLE_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-2/5", "w-3/5", "w-1/2"];const META_WIDTHS = ["w-2/5", "w-1/3", "w-2/5", "w-1/4", "w-1/3", "w-2/5", "w-1/3"]; /** Rows in the list's own grid — glyph, title over meta, time over diffstat. */export function PullRequestListGhost({  rows = 7,  caption,}: {  rows?: number;  /** Said where the group headers speak, for the states with something to say — a search. */  caption?: string;}) {  return (    <div      role="status"      aria-label={caption ?? "Loading pull requests"}      className="animate-ghost-pulse space-y-0.5"    >      {caption ? (        <p className="px-3 pb-1 text-xs font-medium text-muted-foreground/70">{caption}</p>      ) : null}      {Array.from({ length: rows }, (_, index) => (        <div          key={index}          className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 py-2"        >          <GhostBar className="size-4 rounded-full" />          <div className="min-w-0 space-y-1.5">            <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />            <GhostBar              className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}            />          </div>          <div className="flex flex-col items-end gap-1.5">            <GhostBar className="w-12" />            <GhostBar className="w-16 bg-muted-foreground/10" />          </div>        </div>      ))}    </div>  );} /** The summary's own shape: a title, a byline, the facts rows, the description. */export function PullRequestDetailGhost() {  return (    <div      role="status"      aria-label="Loading pull request"      className="animate-ghost-pulse space-y-6 px-4 py-5"    >      <div className="space-y-2">        <GhostBar className="h-5 w-4/5" />        <GhostBar className="w-2/5 bg-muted-foreground/10" />      </div>      <div className="space-y-3">        {Array.from({ length: 4 }, (_, index) => (          <div key={index} className="flex items-center gap-3">            <GhostBar className="size-3.5 rounded-full" />            <GhostBar className="w-20 bg-muted-foreground/10" />            <GhostBar className={TITLE_WIDTHS[(index + 1) % TITLE_WIDTHS.length]} />          </div>        ))}      </div>      <div className="space-y-2 pt-1">        <GhostBar className="w-full bg-muted-foreground/10" />        <GhostBar className="w-11/12 bg-muted-foreground/10" />        <GhostBar className="w-4/5 bg-muted-foreground/10" />        <GhostBar className="w-2/3 bg-muted-foreground/10" />      </div>    </div>  );} /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) {  return (    <div role="status" aria-label="Loading people" className="animate-ghost-pulse space-y-1 p-1">      {Array.from({ length: rows }, (_, index) => (        <div key={index} className="flex h-7 items-center gap-2 rounded-md px-2">          <GhostBar className="size-4 rounded-full" />          <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />        </div>      ))}    </div>  );} /** The timeline's own shape: dots on the rail, a line and a date to each. */export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {  return (    <div role="status" aria-label="Loading timeline" className="animate-ghost-pulse px-4 py-5">      <div className="relative ml-2 border-l border-border/70 pl-5">        {Array.from({ length: rows }, (_, index) => (          <div key={index} className="relative pb-5">            <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />            <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />            <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" />          </div>        ))}      </div>    </div>  );} /** A compact placeholder for the conversation while the core detail is already readable. */export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {  return (    <div      role="status"      aria-label="Loading pull request conversation"      className="animate-ghost-pulse space-y-4 py-2"    >      {Array.from({ length: rows }, (_, index) => (        <div key={index} className="flex items-start gap-2">          <GhostBar className="size-5 shrink-0 rounded-full" />          <div className="flex-1 space-y-1.5">            <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />            <GhostBar className="w-full bg-muted-foreground/10" />            <GhostBar className="w-3/4 bg-muted-foreground/10" />          </div>        </div>      ))}    </div>  );}