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 @@...@@ -2,12 +2,22 @@
2 * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a2 * 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 all3 * 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.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.
5 */8 */
9import { SearchIcon } from "lucide-react";
6import type { ReactNode } from "react";10import type { ReactNode } from "react";
711
8import { Button } from "../ui/button";12import { Button } from "../ui/button";
9import { Input } from "../ui/input";13import {
10import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";14 Combobox,
15 ComboboxInput,
16 ComboboxItem,
17 ComboboxList,
18 ComboboxPopup,
19 ComboboxTrigger,
20} from "../ui/combobox";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { PullRequestPeopleGhost } from "./PullRequestGhosts";22import { PullRequestPeopleGhost } from "./PullRequestGhosts";
1323
...@@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({...@@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({
78 );88 );
79 }89 }
8090
91 const keys = candidates.map(candidateKey);
92
81 return (93 return (
82 <Menu open={open} onOpenChange={onOpenChange}>94 <Combobox
83 <MenuTrigger95 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
84 render={119 render={
85 <Button size="icon-xs" variant="ghost" aria-label={label}>120 <Button size="icon-xs" variant="ghost" aria-label={label}>
86 {icon}121 {icon}
87 </Button>122 </Button>
88 }123 }
89 />124 />
90 <MenuPopup align="start" side="bottom" className="w-72 p-0">125 <ComboboxPopup align="start" side="bottom" className="w-72">
91 <div className="border-b border-border/60 p-2">126 <div className="shrink-0 px-3 pt-2.5">
92 <Input127 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
93 autoFocus128 <SearchIcon
94 value={query}129 aria-hidden="true"
95 onChange={(event) => onQueryChange(event.currentTarget.value)}130 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
96 placeholder={searchLabel}131 />
97 aria-label={searchLabel}132 <ComboboxInput
98 size="compact"133 aria-label={searchLabel}
99 />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>
100 </div>144 </div>
101 <div className="max-h-72 overflow-y-auto p-1">145 <ComboboxList className="max-h-72">
102 {isPending ? (146 {isPending ? (
103 <PullRequestPeopleGhost rows={4} />147 <PullRequestPeopleGhost rows={4} />
104 ) : error !== null ? (148 ) : error !== null ? (
...@@ -110,18 +154,18 @@ export function PullRequestCandidatePicker<T>({...@@ -110,18 +154,18 @@ export function PullRequestCandidatePicker<T>({
110 {query.length > 0 ? noMatchLabel : emptyLabel}154 {query.length > 0 ? noMatchLabel : emptyLabel}
111 </p>155 </p>
112 ) : (156 ) : (
113 candidates.map((candidate) => (157 candidates.map((candidate, index) => (
114 // Stays open on press: a change is confirmed by the row's own check turning over,158 <ComboboxItem
115 // and a second label or reviewer is usually wanted right after the first.159 key={keys[index]}
116 <MenuItem160 hideIndicator
117 key={candidateKey(candidate)}161 index={index}
118 closeOnClick={false}162 value={keys[index]}
119 disabled={disabled}163 disabled={disabled}
120 onClick={() => onSelect(candidate)}
121 className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs"164 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"
122 >166 >
123 {children(candidate)}167 {children(candidate)}
124 </MenuItem>168 </ComboboxItem>
125 ))169 ))
126 )}170 )}
127 {truncated ? (171 {truncated ? (
...@@ -129,8 +173,8 @@ export function PullRequestCandidatePicker<T>({...@@ -129,8 +173,8 @@ export function PullRequestCandidatePicker<T>({
129 // list is rather than offering a search that would find nothing further.173 // list is rather than offering a search that would find nothing further.
130 <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p>174 <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p>
131 ) : null}175 ) : null}
132 </div>176 </ComboboxList>
133 </MenuPopup>177 </ComboboxPopup>
134 </Menu>178 </Combobox>
135 );179 );
136}180}