Skip to content
Source
Sign inSign up

CommandPaletteContent.tsx

127 lines4.0 KB

1import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
2import { type ComponentProps, type ReactNode, type RefObject } from "react";
3​
4import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command";
5import { Kbd, KbdGroup } from "./ui/kbd";
6​
7/**
8 * A view replacing another inside an open dialog mounts with focus on the body,
9 * left there by the removed input. Claim it so typing cannot reach the composer
10 * behind the modal. On first open the dialog's own initial focus handles it.
11 */
12function claimFocusInOpenDialog(input: HTMLInputElement) {
13 const active = input.ownerDocument.activeElement;
14 if (
15 active === null ||
16 active === input.ownerDocument.body ||
17 input.closest('[role="dialog"]')?.contains(active)
18 ) {
19 input.focus();
20 }
22​
23export interface PendingInputSelection {
24 readonly value: string;
25 readonly start: number | null;
26 readonly end: number | null;
27 readonly direction: "forward" | "backward" | "none" | null;
29​
30function restorePendingSelection(
31 input: HTMLInputElement,
32 pendingSelectionRef: RefObject<PendingInputSelection | null> | undefined,
33) {
34 const selection = pendingSelectionRef?.current;
35 if (!pendingSelectionRef || !selection) return;
36 pendingSelectionRef.current = null;
37 if (input.value !== selection.value) return;
38 input.setSelectionRange(selection.start, selection.end, selection.direction ?? undefined);
40​
41type CommandPaletteContentProps = Omit<ComponentProps<typeof Command>, "children"> & {
42 readonly children: ReactNode;
43 readonly escapeLabel?: ReactNode;
44 readonly footerActionLabel?: ReactNode;
45 readonly footerTrailing?: ReactNode;
46 readonly inputAccessory?: ReactNode;
47 readonly inputProps: ComponentProps<typeof CommandInput>;
48 readonly pendingSelectionRef?: RefObject<PendingInputSelection | null>;
49 readonly panelSize?: "list" | "tall-list" | "fill";
50 readonly showBackHint?: boolean;
51 readonly testId?: string;
52};
53​
54export function CommandPaletteContent({
55 children,
56 escapeLabel = "Close",
57 footerActionLabel,
58 footerTrailing,
59 inputAccessory,
60 inputProps,
61 pendingSelectionRef,
62 panelSize = "list",
63 showBackHint,
64 testId,
65 ...commandProps
66}: CommandPaletteContentProps) {
67 return (
68 <div className="contents" data-testid={testId}>
69 <Command {...commandProps}>
70 <div className="relative">
71 <CommandInput
72 autoFocus={false}
73 endAddon={inputAccessory ? undefined : <Kbd>esc</Kbd>}
74 {...inputProps}
75 ref={(input) => {
76 if (!input) return;
77 restorePendingSelection(input, pendingSelectionRef);
78 claimFocusInOpenDialog(input);
79 }}
80 />
81 {inputAccessory}
82 </div>
83 <CommandPanel
84 className={
85 panelSize === "fill"
86 ? "flex min-h-0 flex-1 flex-col"
87 : panelSize === "tall-list"
88 ? "max-h-[min(34rem,76vh)]"
89 : "flex max-h-[min(28rem,70vh)] min-h-0 flex-col"
90 }
91 >
92 {children}
93 </CommandPanel>
94 <CommandFooter className="max-sm:flex-col max-sm:items-start">
95 <div className="flex items-center gap-3 max-sm:flex-wrap max-sm:gap-y-1.5">
96 <KbdGroup>
97 <Kbd>
98 <ArrowUpIcon />
99 </Kbd>
100 <Kbd>
101 <ArrowDownIcon />
102 </Kbd>
103 <span>Navigate</span>
104 </KbdGroup>
105 {footerActionLabel !== undefined ? (
106 <KbdGroup>
107 <Kbd>Enter</Kbd>
108 <span>{footerActionLabel}</span>
109 </KbdGroup>
110 ) : null}
111 {showBackHint ? (
112 <KbdGroup>
113 <Kbd>Backspace</Kbd>
114 <span>Back</span>
115 </KbdGroup>
116 ) : null}
117 <KbdGroup>
118 <Kbd>Esc</Kbd>
119 <span>{escapeLabel}</span>
120 </KbdGroup>
121 </div>
122 {footerTrailing}
123 </CommandFooter>
124 </Command>
125 </div>
126 );