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