| 1 | import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; |
| 2 | import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; |
| 3 | |
| 4 | import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; |
| 5 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 6 | |
| 7 | type CommandPaletteContentProps = Omit<ComponentProps<typeof Command>, "children"> & { |
| 8 | readonly children: ReactNode; |
| 9 | readonly escapeLabel?: ReactNode; |
| 10 | readonly footerActionLabel?: ReactNode; |
| 11 | readonly footerTrailing?: ReactNode; |
| 12 | readonly inputAccessory?: ReactNode; |
| 13 | readonly inputProps: ComponentProps<typeof CommandInput>; |
| 14 | /** |
| 15 | * How tall the results panel may grow: the palette's list, a taller file list, or the whole |
| 16 | * dialog body (for modes that lay out their own status and empty states). |
| 17 | */ |
| 18 | readonly panelSize?: "list" | "tall-list" | "fill"; |
| 19 | readonly showBackHint?: boolean; |
| 20 | readonly testId?: string; |
| 21 | }; |
| 22 | |
| 23 | /** |
| 24 | * Shared command palette chrome. Palette modes provide their query behavior, |
| 25 | * results, and optional input accessory while retaining one input, panel, and |
| 26 | * keyboard-hint gutter. |
| 27 | */ |
| 28 | export function CommandPaletteContent({ |
| 29 | children, |
| 30 | escapeLabel = "Close", |
| 31 | footerActionLabel, |
| 32 | footerTrailing, |
| 33 | inputAccessory, |
| 34 | inputProps, |
| 35 | panelSize = "list", |
| 36 | showBackHint, |
| 37 | testId, |
| 38 | ...commandProps |
| 39 | }: CommandPaletteContentProps) { |
| 40 | const inputRef = useRef<HTMLInputElement>(null); |
| 41 | |
| 42 | // Direct-open flows replace the initial palette view after the dialog has |
| 43 | // already moved focus. Reclaim it when the replacement input mounts so |
| 44 | // typing cannot continue in the composer behind the modal. |
| 45 | useLayoutEffect(() => { |
| 46 | inputRef.current?.focus(); |
| 47 | }, []); |
| 48 | |
| 49 | return ( |
| 50 | <div className="contents" data-testid={testId}> |
| 51 | <Command {...commandProps}> |
| 52 | <div className="relative"> |
| 53 | <CommandInput {...inputProps} ref={inputRef} /> |
| 54 | {inputAccessory} |
| 55 | </div> |
| 56 | <CommandPanel |
| 57 | className={ |
| 58 | panelSize === "fill" |
| 59 | ? "flex min-h-0 flex-1 flex-col" |
| 60 | : panelSize === "tall-list" |
| 61 | ? "max-h-[min(34rem,76vh)]" |
| 62 | : "max-h-[min(28rem,70vh)]" |
| 63 | } |
| 64 | > |
| 65 | {children} |
| 66 | </CommandPanel> |
| 67 | <CommandFooter className="max-sm:flex-col max-sm:items-start"> |
| 68 | <div className="flex items-center gap-3"> |
| 69 | <KbdGroup> |
| 70 | <Kbd> |
| 71 | <ArrowUpIcon /> |
| 72 | </Kbd> |
| 73 | <Kbd> |
| 74 | <ArrowDownIcon /> |
| 75 | </Kbd> |
| 76 | <span>Navigate</span> |
| 77 | </KbdGroup> |
| 78 | {footerActionLabel !== undefined ? ( |
| 79 | <KbdGroup> |
| 80 | <Kbd>Enter</Kbd> |
| 81 | <span>{footerActionLabel}</span> |
| 82 | </KbdGroup> |
| 83 | ) : null} |
| 84 | {showBackHint ? ( |
| 85 | <KbdGroup> |
| 86 | <Kbd>Backspace</Kbd> |
| 87 | <span>Back</span> |
| 88 | </KbdGroup> |
| 89 | ) : null} |
| 90 | <KbdGroup> |
| 91 | <Kbd>Esc</Kbd> |
| 92 | <span>{escapeLabel}</span> |
| 93 | </KbdGroup> |
| 94 | </div> |
| 95 | {footerTrailing} |
| 96 | </CommandFooter> |
| 97 | </Command> |
| 98 | </div> |
| 99 | ); |
| 100 | } |