330 lines · 12.2 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. * * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on * both themes) and the single `animate-skeleton` pulse, applied once on the container so any * number of bars costs one opacity animation. */import type { PullRequestListEntry } from "@t3tools/contracts";import { ArrowLeftIcon } from "lucide-react"; import { cn } from "~/lib/utils";import { formatRelativeTimeLabel } from "~/timestampFormat"; import { pullRequestLabelColor } from "./pullRequestList.logic";import {  PullRequestActorLabel,  PullRequestDiffStat,  pullRequestChecksStatePresentation,  resolvePullRequestState,} from "./pullRequestPresentation"; 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="motion-safe:animate-skeleton 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={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" />          </div>        </div>      ))}    </div>  );} /** * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) {  const statePresentation = seed    ? resolvePullRequestState({        state: seed.state,        isDraft: seed.isDraft,      })    : null;  const checksPresentation = seed?.checksState    ? pullRequestChecksStatePresentation(seed.checksState)    : null;   return (    <div      role="status"      aria-label="Loading pull request"      className={cn(        "flex h-full min-h-0 flex-col overflow-hidden bg-background",        !seed && "motion-safe:animate-skeleton",      )}    >      <div className="shrink-0 border-b border-border/60">        <div className="flex h-7 items-center justify-between gap-3 px-4">          <div className="flex min-w-0 flex-1 items-center gap-1.5">            {seed && statePresentation ? (              <>                <span className="min-w-0 truncate text-xs font-medium text-muted-foreground">                  {seed.repository}                </span>                <span                  className={cn("shrink-0 text-xs font-medium", statePresentation.toneClassName)}                >                  #{seed.number}                </span>              </>            ) : (              <>                <GhostBar className="w-24" />                <GhostBar className="w-9" />              </>            )}          </div>          <div className="flex shrink-0 items-center gap-1">            <GhostBar className="h-5 w-16 rounded-md" />            <GhostBar className="size-5 rounded-md" />          </div>        </div>         <div className="px-4 pb-4 pt-1">          {seed ? (            <h1 className="truncate text-base font-semibold leading-snug">{seed.title}</h1>          ) : (            <GhostBar className="h-5 w-4/5 max-w-md" />          )}          <div className="mt-2 flex items-center gap-1.5">            {seed ? (              <>                <PullRequestActorLabel                  actor={seed.author}                  className="font-medium"                  tooltip={false}                />                <span className="text-xs text-muted-foreground">                  updated {formatRelativeTimeLabel(seed.updatedAt)}                </span>              </>            ) : (              <>                <GhostBar className="size-4 rounded-full" />                <GhostBar className="w-24" />              </>            )}          </div>          <div className="mt-4 flex min-w-0 items-center gap-2">            {seed ? (              <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">                <code className="min-w-0 max-w-[40%] shrink-0 truncate">{seed.baseBranch}</code>                <ArrowLeftIcon                  aria-label="receives changes from"                  className="size-3.5 shrink-0 opacity-60"                />                <code className="min-w-0 flex-1 truncate">{seed.headBranch}</code>              </span>            ) : (              <>                <GhostBar className="h-6 w-24 rounded-md" />                <GhostBar className="size-3 rounded-full" />                <GhostBar className="h-6 w-32 rounded-md" />              </>            )}            <div className="ml-auto flex shrink-0 items-center gap-2">              <GhostBar className="w-10" />              {seed ? (                <PullRequestDiffStat                  additions={seed.additions}                  deletions={seed.deletions}                  className="font-mono text-xs"                />              ) : (                <GhostBar className="w-20" />              )}            </div>          </div>        </div>         <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2">          <div className="flex items-center gap-1 p-0.5">            <GhostBar className="h-6 w-16 rounded-md" />            <GhostBar className="h-6 w-16 rounded-md" />            <GhostBar className="h-6 w-12 rounded-md" />          </div>          {checksPresentation ? (            <span              className={cn(                "inline-flex items-center gap-1.5 text-xs",                checksPresentation.toneClassName,              )}            >              <checksPresentation.Icon aria-hidden className="size-3.5" />              {checksPresentation.label}            </span>          ) : (            <GhostBar className="w-20" />          )}        </div>      </div>       <div className="min-h-0 flex-1 overflow-hidden">        <section className="px-4 py-3">          <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">            <div className="flex items-center gap-1.5">              <GhostBar className="size-3.5 rounded-full" />              <GhostBar className="w-14" />            </div>            <div className="flex items-center gap-1">              <GhostBar className="size-4 rounded-full" />              <GhostBar className="size-4 rounded-full" />              <GhostBar className="ml-1 size-5 rounded-md" />            </div>          </div>          <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">            <div className="flex items-center gap-1.5">              <GhostBar className="size-3.5 rounded-full" />              <GhostBar className="w-10" />            </div>            <div className="flex items-center gap-1">              {seed ? (                seed.labels.slice(0, 3).map((label) => {                  const color = pullRequestLabelColor(label.color);                  return (                    <span                      key={label.name}                      className="inline-flex h-5 max-w-32 items-center gap-1 truncate rounded-full border border-border/70 bg-muted/40 px-2 text-[10px] text-muted-foreground"                    >                      <span                        aria-hidden                        className="size-2 shrink-0 rounded-full bg-muted-foreground"                        {...(color ? { style: { backgroundColor: color } } : {})}                      />                      <span className="truncate">{label.name}</span>                    </span>                  );                })              ) : (                <>                  <GhostBar className="h-5 w-24 rounded-full" />                  <GhostBar className="h-5 w-20 rounded-full" />                </>              )}            </div>          </div>          <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">            <div className="flex items-center gap-1.5">              <GhostBar className="size-3.5 rounded-full" />              <GhostBar className="w-14" />            </div>            <GhostBar className="w-20" />          </div>        </section>         <section className="border-t border-border/60">          <div className="flex min-h-11 items-center gap-1.5 px-4 py-3">            <GhostBar className="h-4 w-24" />            <GhostBar className="size-3.5 rounded-full" />          </div>          <div className="space-y-2 px-4 pb-4">            <GhostBar className="w-full" />            <GhostBar className="w-11/12" />            <GhostBar className="w-4/5" />            <GhostBar className="w-2/3" />          </div>        </section>      </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="motion-safe:animate-skeleton 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="motion-safe:animate-skeleton 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" />          </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="motion-safe:animate-skeleton 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" />            <GhostBar className="w-3/4" />          </div>        </div>      ))}    </div>  );}