authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 15:21:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 15:21:07-07:00
log0869ad648b67a286d7a47af878f1d256d0ff689f
tree8fa2c93d7c3dd848937c461aed11e59f5d2bad21
parent4b8b5d9e0177002c84a6f55837670aa0ef816915
signature Signed by PGP key B5690EEEBB952194

fix(web): let the PR reviewer and label search boxes take keystrokes (#9479)

Co-authored-by: Claude Code <noreply@anthropic.com>

1 files changed, 70 insertions(+), 26 deletions(-)

apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx+70-26
......@@ -2,12 +2,22 @@
22 * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a
33 * scrolling body that says when the list is loading, could not be read, is empty, or is not all
44 * 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.
58 */
9import { SearchIcon } from "lucide-react";
610import type { ReactNode } from "react";
711
812import { Button } from "../ui/button";
9import { Input } from "../ui/input";
10import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
13import {
14 Combobox,
15 ComboboxInput,
16 ComboboxItem,
17 ComboboxList,
18 ComboboxPopup,
19 ComboboxTrigger,
20} from "../ui/combobox";
1121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1222import { PullRequestPeopleGhost } from "./PullRequestGhosts";
1323
......@@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({
7888 );
7989 }
8090
91 const keys = candidates.map(candidateKey);
92
8193 return (
82 <Menu open={open} onOpenChange={onOpenChange}>
83 <MenuTrigger
94 <Combobox
95 items={keys}
96 // The caller narrows the list, so the combobox does no filtering of its own.
97 filteredItems={keys}
98 filter={null}
99 autoHighlight
100 value={null}
101 onValueChange={(key) => {
102 const candidate = candidates.find((entry) => candidateKey(entry) === key);
103 if (candidate) onSelect(candidate);
104 }}
105 open={open}
106 onOpenChange={(nextOpen, details) => {
107 // Stays open on a pick: a change is confirmed by the row's own check turning over, and a
108 // second label or reviewer is usually wanted right after the first. Cancelled rather than
109 // ignored, so the combobox also skips its own close work: freezing the query and returning
110 // focus to the trigger, either of which would take the next keystroke away from the box.
111 if (!nextOpen && details.reason === "item-press") {
112 details.cancel();
113 return;
114 }
115 onOpenChange(nextOpen);
116 }}
117 >
118 <ComboboxTrigger
84119 render={
85120 <Button size="icon-xs" variant="ghost" aria-label={label}>
86121 {icon}
87122 </Button>
88123 }
89124 />
90 <MenuPopup align="start" side="bottom" className="w-72 p-0">
91 <div className="border-b border-border/60 p-2">
92 <Input
93 autoFocus
94 value={query}
95 onChange={(event) => onQueryChange(event.currentTarget.value)}
96 placeholder={searchLabel}
97 aria-label={searchLabel}
98 size="compact"
99 />
125 <ComboboxPopup align="start" side="bottom" className="w-72">
126 <div className="shrink-0 px-3 pt-2.5">
127 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
128 <SearchIcon
129 aria-hidden="true"
130 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
131 />
132 <ComboboxInput
133 aria-label={searchLabel}
134 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
135 inputClassName="rounded-none bg-transparent text-sm"
136 placeholder={searchLabel}
137 showTrigger={false}
138 size="sm"
139 unstyled
140 value={query}
141 onChange={(event) => onQueryChange(event.target.value)}
142 />
143 </div>
100144 </div>
101 <div className="max-h-72 overflow-y-auto p-1">
145 <ComboboxList className="max-h-72">
102146 {isPending ? (
103147 <PullRequestPeopleGhost rows={4} />
104148 ) : error !== null ? (
......@@ -110,18 +154,18 @@ export function PullRequestCandidatePicker<T>({
110154 {query.length > 0 ? noMatchLabel : emptyLabel}
111155 </p>
112156 ) : (
113 candidates.map((candidate) => (
114 // Stays open on press: a change is confirmed by the row's own check turning over,
115 // and a second label or reviewer is usually wanted right after the first.
116 <MenuItem
117 key={candidateKey(candidate)}
118 closeOnClick={false}
157 candidates.map((candidate, index) => (
158 <ComboboxItem
159 key={keys[index]}
160 hideIndicator
161 index={index}
162 value={keys[index]}
119163 disabled={disabled}
120 onClick={() => onSelect(candidate)}
121164 className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs"
165 contentClassName="flex min-w-0 items-center gap-2"
122166 >
123167 {children(candidate)}
124 </MenuItem>
168 </ComboboxItem>
125169 ))
126170 )}
127171 {truncated ? (
......@@ -129,8 +173,8 @@ export function PullRequestCandidatePicker<T>({
129173 // list is rather than offering a search that would find nothing further.
130174 <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p>
131175 ) : null}
132 </div>
133 </MenuPopup>
134 </Menu>
176 </ComboboxList>
177 </ComboboxPopup>
178 </Combobox>
135179 );
136180}