1import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react";
3
4import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
5import { Kbd, KbdGroup } from "./ui/kbd";
6
7type 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 */
28export 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}