Skip to content
89 linesCodeBlameRaw
1import { type ReactNode, Suspense, lazy, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
3import { cn } from "../lib/cn";
4import { Kbd } from "./ui/kbd";
5import { paletteKeyLabel } from "../lib/shortcut";
6
7/*
8 * The ⌘K palette. The dialog itself, with its search and its dependencies,
9 * loads the first time it is opened, so pages that never open it do not
10 * carry it.
11 */
12const Dialog = lazy(() => import("./command-palette-dialog"));
13
14/**
15 * A page the palette can jump to (`to`), or something it does in place
16 * (`run`, listed under Commands), such as switching the theme.
17 */
18export type PaletteCommand = { label: string; hint?: string; icon: ReactNode } & ({ to: string; run?: undefined } | { run: () => void; to?: undefined });
19
20/** Opens and closes the palette on ⌘K or Ctrl-K, from anywhere on the page. */
21export function usePaletteShortcut(toggle: () => void) {
22 const latest = useRef(toggle);
23 latest.current = toggle;
24 useEffect(() => {
25 const onKey = (event: KeyboardEvent) => {
26 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
27 event.preventDefault();
28 latest.current();
29 }
30 };
31 window.addEventListener("keydown", onKey);
32 return () => window.removeEventListener("keydown", onKey);
33 }, []);
34}
35
36const never = () => () => {};
37
38function platform(): string {
39 const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
40 return nav.userAgentData?.platform || nav.platform || nav.userAgent;
41}
42
43/**
44 * The palette's shortcut as this computer writes it: ⌘K on a Mac, Ctrl K
45 * elsewhere. Not shown on a touch screen, which has no keyboard to press it.
46 */
47export function PaletteKey({ className }: { className?: string }) {
48 // The server cannot know the computer: it says ⌘K, and the browser corrects it.
49 const label = useSyncExternalStore(never, () => paletteKeyLabel(platform()), () => "⌘K");
50 return <Kbd className={cn(className, "pointer-coarse:hidden")}>{label}</Kbd>;
51}
52
53/**
54 * ⌘K: pages and actions from what the page already knows, and, as someone
55 * types, repositories, issues, pull requests and people from search
56 * (command-palette-dialog.tsx).
57 */
58export function CommandPalette(props: {
59 open: boolean;
60 onOpenChange: (open: boolean) => void;
61 commands: PaletteCommand[];
62 /** The repository being looked at, `owner/name`, to offer searching its code. */
63 repo?: string | null;
64}) {
65 const [wanted, setWanted] = useState(props.open);
66 useEffect(() => {
67 if (props.open) setWanted(true);
68 }, [props.open]);
69 // What had focus when it opened, the button or the page, to give it back
70 // on closing: the dialog has no trigger of its own to return to.
71 const opener = useRef<HTMLElement | null>(null);
72 const wasOpen = useRef(false);
73 if (props.open && !wasOpen.current && typeof document !== "undefined") {
74 opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
75 }
76 wasOpen.current = props.open;
77 if (!wanted) return null;
78 return (
79 <Suspense fallback={null}>
80 <Dialog
81 {...props}
82 onCloseAutoFocus={(event) => {
83 event.preventDefault();
84 if (opener.current?.isConnected) opener.current.focus();
85 }}
86 />
87 </Suspense>
88 );
89}