/** * 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. * * The same combobox as the project and branch pickers, and dressed the same, rather than a menu: * a menu's typeahead claims every keypress to jump between rows, which a search box cannot share. */ import { SearchIcon } from "lucide-react"; import type { ReactNode } from "react"; import { Button } from "../ui/button"; import { Combobox, ComboboxInput, ComboboxItem, ComboboxList, ComboboxPopup, ComboboxTrigger, } from "../ui/combobox"; 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} ); } const keys = candidates.map(candidateKey); return ( { const candidate = candidates.find((entry) => candidateKey(entry) === key); if (candidate) onSelect(candidate); }} open={open} onOpenChange={(nextOpen, details) => { // Stays open on a pick: 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. Cancelled rather than // ignored, so the combobox also skips its own close work: freezing the query and returning // focus to the trigger, either of which would take the next keystroke away from the box. if (!nextOpen && details.reason === "item-press") { details.cancel(); return; } onOpenChange(nextOpen); }} > {icon} } />
{isPending ? ( ) : error !== null ? (

{errorLabel} {error}

) : candidates.length === 0 ? (

{query.length > 0 ? noMatchLabel : emptyLabel}

) : ( candidates.map((candidate, index) => ( {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}
); }