| ... | ... | @@ -2,12 +2,22 @@ |
| 2 | 2 | * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a |
| 3 | 3 | * scrolling body that says when the list is loading, could not be read, is empty, or is not all |
| 4 | 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 | 10 | import type { ReactNode } from "react"; |
| 7 | 11 | |
| 8 | 12 | import { Button } from "../ui/button"; |
| 9 | | import { Input } from "../ui/input"; |
| 10 | | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 13 | import { |
| 14 | Combobox, |
| 15 | ComboboxInput, |
| 16 | ComboboxItem, |
| 17 | ComboboxList, |
| 18 | ComboboxPopup, |
| 19 | ComboboxTrigger, |
| 20 | } from "../ui/combobox"; |
| 11 | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | 22 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; |
| 13 | 23 | |
| ... | ... | @@ -78,27 +88,61 @@ export function PullRequestCandidatePicker<T>({ |
| 78 | 88 | ); |
| 79 | 89 | } |
| 80 | 90 | |
| 91 | const keys = candidates.map(candidateKey); |
| 92 | |
| 81 | 93 | 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 |
| 84 | 119 | render={ |
| 85 | 120 | <Button size="icon-xs" variant="ghost" aria-label={label}> |
| 86 | 121 | {icon} |
| 87 | 122 | </Button> |
| 88 | 123 | } |
| 89 | 124 | /> |
| 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> |
| 100 | 144 | </div> |
| 101 | | <div className="max-h-72 overflow-y-auto p-1"> |
| 145 | <ComboboxList className="max-h-72"> |
| 102 | 146 | {isPending ? ( |
| 103 | 147 | <PullRequestPeopleGhost rows={4} /> |
| 104 | 148 | ) : error !== null ? ( |
| ... | ... | @@ -110,18 +154,18 @@ export function PullRequestCandidatePicker<T>({ |
| 110 | 154 | {query.length > 0 ? noMatchLabel : emptyLabel} |
| 111 | 155 | </p> |
| 112 | 156 | ) : ( |
| 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]} |
| 119 | 163 | disabled={disabled} |
| 120 | | onClick={() => onSelect(candidate)} |
| 121 | 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 | 167 | {children(candidate)} |
| 124 | | </MenuItem> |
| 168 | </ComboboxItem> |
| 125 | 169 | )) |
| 126 | 170 | )} |
| 127 | 171 | {truncated ? ( |
| ... | ... | @@ -129,8 +173,8 @@ export function PullRequestCandidatePicker<T>({ |
| 129 | 173 | // list is rather than offering a search that would find nothing further. |
| 130 | 174 | <p className="px-2 py-1.5 text-xs text-muted-foreground">{truncatedLabel}</p> |
| 131 | 175 | ) : null} |
| 132 | | </div> |
| 133 | | </MenuPopup> |
| 134 | | </Menu> |
| 176 | </ComboboxList> |
| 177 | </ComboboxPopup> |
| 178 | </Combobox> |
| 135 | 179 | ); |
| 136 | 180 | } |