| 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 | */ |
| 9 | import type { ReactNode } from "react"; |
| 10 | |
| 11 | import { Button } from "../ui/button"; |
| 12 | import { |
| 13 | Combobox, |
| 14 | ComboboxSearchInput, |
| 15 | ComboboxItem, |
| 16 | ComboboxList, |
| 17 | ComboboxPopup, |
| 18 | ComboboxTrigger, |
| 19 | } from "../ui/combobox"; |
| 20 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 21 | import { PullRequestPeopleGhost } from "./PullRequestGhosts"; |
| 22 | |
| 23 | export 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 | } |