| ... | @@ -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 a | 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 | 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. | 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 | */ |
| | 9 | import { SearchIcon } from "lucide-react"; |
| 6 | import type { ReactNode } from "react"; | 10 | import type { ReactNode } from "react"; |
| 7 | | 11 | |
| 8 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 9 | import { Input } from "../ui/input"; | 13 | import { |
| 10 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 14 | Combobox, |
| | 15 | ComboboxInput, |
| | 16 | ComboboxItem, |
| | 17 | ComboboxList, |
| | 18 | ComboboxPopup, |
| | 19 | ComboboxTrigger, |
| | 20 | } from "../ui/combobox"; |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; | 22 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; |
| 13 | | 23 | |
| ... | @@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({ | ... | @@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({ |
| 78 | ); | 88 | ); |
| 79 | } | 89 | } |
| 80 | | 90 | |
| | 91 | const keys = candidates.map(candidateKey); |
| | 92 | |
| 81 | return ( | 93 | return ( |
| 82 | <Menu open={open} onOpenChange={onOpenChange}> | 94 | <Combobox |
| 83 | <MenuTrigger | 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 |
| 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 | <Input | 127 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> |
| 93 | autoFocus | 128 | <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 | <MenuItem | 160 | 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 | } |