/** * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a * scrolling body that says when the list is loading, could not be read, is empty, or is not all * of it. The rows and the words are the caller's; the frame is the same either way. */ import type { ReactNode } from "react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestPeopleGhost } from "./PullRequestGhosts"; export function PullRequestCandidatePicker({ icon, label, allowed, disabledReason, open, onOpenChange, query, onQueryChange, searchLabel, isPending, error, candidates, emptyLabel, noMatchLabel, errorLabel, truncated, truncatedLabel, candidateKey, disabled, onSelect, children, }: { icon: ReactNode; /** The trigger's accessible name; the button carries an icon alone. */ label: string; /** False where the host would refuse this account's change. Disabled with the reason rather * than hidden: a control that vanishes teaches nobody why. */ allowed: boolean; disabledReason: string; open: boolean; onOpenChange: (open: boolean) => void; query: string; onQueryChange: (query: string) => void; searchLabel: string; isPending: boolean; error: string | null; /** Already narrowed by the query; the shell only decides which state to show. */ candidates: ReadonlyArray; emptyLabel: string; noMatchLabel: string; /** Leads the host's own message, which follows it in the same sentence. */ errorLabel: string; /** The host has more than the read asked for, so a name missing here may still be askable. */ truncated: boolean; truncatedLabel: string; candidateKey: (candidate: T) => string; /** Every row locks while one change is in flight, so a second press cannot race the first. */ disabled: boolean; onSelect: (candidate: T) => void; children: (candidate: T) => ReactNode; }) { if (!allowed) { return ( {icon} } /> {disabledReason} ); } return ( {icon} } />
onQueryChange(event.currentTarget.value)} placeholder={searchLabel} aria-label={searchLabel} size="compact" />
{isPending ? ( ) : error !== null ? (

{errorLabel} {error}

) : candidates.length === 0 ? (

{query.length > 0 ? noMatchLabel : emptyLabel}

) : ( candidates.map((candidate) => ( // Stays open on press: a change is confirmed by the row's own check turning over, // and a second label or reviewer is usually wanted right after the first. onSelect(candidate)} className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" > {children(candidate)} )) )} {truncated ? ( // Typing filters what arrived; it does not ask the host again, so this says what the // list is rather than offering a search that would find nothing further.

{truncatedLabel}

) : null}
); }