1/**
2 * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a
3 * scrolling body that says when the list is loading, could not be read, is empty, or is not all
4 * of it. The rows and the words are the caller's; the frame is the same either way.
5 *
6 * The same combobox as the project and branch pickers, and dressed the same, rather than a menu:
7 * a menu's typeahead claims every keypress to jump between rows, which a search box cannot share.
8 */
9import type { ReactNode } from "react";
10
11import { Button } from "../ui/button";
12import {
13 Combobox,
14 ComboboxSearchInput,
15 ComboboxItem,
16 ComboboxList,
17 ComboboxPopup,
18 ComboboxTrigger,
19} from "../ui/combobox";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { PullRequestPeopleGhost } from "./PullRequestGhosts";
22
23export function PullRequestCandidatePicker<T>({
24 icon,
25 label,
26 allowed,
27 disabledReason,
28 open,
29 onOpenChange,
30 query,
31 onQueryChange,
32 searchLabel,
33 isPending,
34 error,
35 candidates,
36 emptyLabel,
37 noMatchLabel,
38 errorLabel,
39 truncated,
40 truncatedLabel,
41 candidateKey,
42 disabled,
43 onSelect,
44 children,
45}: {
46 icon: ReactNode;
47 /** The trigger's accessible name; the button carries an icon alone. */
48 label: string;
49 /** False where the host would refuse this account's change. Disabled with the reason rather
50 * than hidden: a control that vanishes teaches nobody why. */
51 allowed: boolean;
52 disabledReason: string;
53 open: boolean;
54 onOpenChange: (open: boolean) => void;
55 query: string;
56 onQueryChange: (query: string) => void;
57 searchLabel: string;
58 isPending: boolean;
59 error: string | null;
60 /** Already narrowed by the query; the shell only decides which state to show. */
61 candidates: ReadonlyArray<T>;
62 emptyLabel: string;
63 noMatchLabel: string;
64 /** Leads the host's own message, which follows it in the same sentence. */
65 errorLabel: string;
66 /** The host has more than the read asked for, so a name missing here may still be askable. */
67 truncated: boolean;
68 truncatedLabel: string;
69 candidateKey: (candidate: T) => string;
70 /** Every row locks while one change is in flight, so a second press cannot race the first. */
71 disabled: boolean;
72 onSelect: (candidate: T) => void;
73 children: (candidate: T) => ReactNode;
74}) {
75 if (!allowed) {
76 return (
77 <Tooltip>
78 <TooltipTrigger
79 render={
80 <Button size="icon-xs" variant="ghost" disabled aria-label={label}>
81 {icon}
82 </Button>
83 }
84 />
85 <TooltipPopup side="bottom">{disabledReason}</TooltipPopup>
86 </Tooltip>
87 );
88 }
89
90 const keys = candidates.map(candidateKey);
91
92 return (
93 <Combobox
94 items={keys}
95 // The caller narrows the list, so the combobox does no filtering of its own.
96 filteredItems={keys}
97 filter={null}
98 autoHighlight
99 value={null}
100 onValueChange={(key) => {
101 const candidate = candidates.find((entry) => candidateKey(entry) === key);
102 if (candidate) onSelect(candidate);
103 }}
104 open={open}
105 onOpenChange={(nextOpen, details) => {
106 // Stays open on a pick: a change is confirmed by the row's own check turning over, and a
107 // second label or reviewer is usually wanted right after the first. Cancelled rather than
108 // ignored, so the combobox also skips its own close work: freezing the query and returning
109 // focus to the trigger, either of which would take the next keystroke away from the box.
110 if (!nextOpen && details.reason === "item-press") {
111 details.cancel();
112 return;
113 }
114 onOpenChange(nextOpen);
115 }}
116 >
117 <ComboboxTrigger
118 render={
119 <Button size="icon-xs" variant="ghost" aria-label={label}>
120 {icon}
121 </Button>
122 }
123 />
124 <ComboboxPopup align="start" side="bottom" className="w-72">
125 <ComboboxSearchInput
126 aria-label={searchLabel}
127 placeholder={searchLabel}
128 value={query}
129 onChange={(event) => onQueryChange(event.target.value)}
130 />
131 <ComboboxList className="max-h-72">
132 {isPending ? (
133 <PullRequestPeopleGhost rows={4} />
134 ) : error !== null ? (
135 <p className="p-2 text-xs text-muted-foreground">
136 {errorLabel} {error}
137 </p>
138 ) : candidates.length === 0 ? (
139 <p className="p-2 text-xs text-muted-foreground">
140 {query.length > 0 ? noMatchLabel : emptyLabel}
141 </p>
142 ) : (
143 candidates.map((candidate, index) => (
144 <ComboboxItem
145 key={keys[index]}
146 hideIndicator
147 index={index}
148 value={keys[index]}
149 disabled={disabled}
150 >
151 {children(candidate)}
152 </ComboboxItem>
153 ))
154 )}
155 {truncated ? (
156 // Typing filters what arrived; it does not ask the host again, so this says what the
157 // list is rather than offering a search that would find nothing further.
158 <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p>
159 ) : null}
160 </ComboboxList>
161 </ComboboxPopup>
162 </Combobox>
163 );
164}