mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
The mod-key glyph was computed during render via isMacOS() (window.navigator), so the static-export SSR produced 'Ctrl' while the macOS client hydrated '⌘' on the same <span>, tripping React error #418 (hydration text mismatch) on every page (CommandMenu is in the global SiteHeader). Start modKey from the SSR-stable 'Ctrl' and upgrade to the platform glyph in a client-only mount effect, so SSR and first hydration render agree. Verified via CDP: pre-fix decoder showed args[]=text with the exact Ctrl->⌘ text node. Co-authored-by: Hanzo CTO <[email protected]>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { DialogProps } from "@radix-ui/react-dialog"
|
|
import {
|
|
CircleIcon,
|
|
FileIcon,
|
|
LaptopIcon,
|
|
MoonIcon,
|
|
SunIcon,
|
|
} from "@radix-ui/react-icons"
|
|
import { Clock, X } from "lucide-react"
|
|
import { useTheme } from "next-themes"
|
|
|
|
import { docsConfig } from "@/config/docs"
|
|
import { cn } from "@/lib/utils"
|
|
import { useRecentSearches } from "@/hooks/use-recent-searches"
|
|
import { Button } from "@/registry/default/ui/button"
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
CommandShortcut,
|
|
} from "@/registry/default/ui/command"
|
|
|
|
// Helper to detect macOS
|
|
const isMacOS = () =>
|
|
typeof window !== "undefined" &&
|
|
window.navigator.userAgent.toLowerCase().includes("mac")
|
|
|
|
// Keyboard shortcut badge component
|
|
interface KbdBadgeProps {
|
|
keys: string[]
|
|
className?: string
|
|
}
|
|
|
|
function KbdBadge({ keys, className }: KbdBadgeProps) {
|
|
return (
|
|
<div className={cn("flex items-center gap-1", className)}>
|
|
{keys.map((key, index) => (
|
|
<kbd
|
|
key={index}
|
|
className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100"
|
|
>
|
|
{key}
|
|
</kbd>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CommandMenu({ ...props }: DialogProps) {
|
|
const router = useRouter()
|
|
const [open, setOpen] = React.useState(false)
|
|
const [searchQuery, setSearchQuery] = React.useState("")
|
|
const { setTheme } = useTheme()
|
|
const { recentSearches, addRecentSearch, clearRecentSearches } =
|
|
useRecentSearches()
|
|
|
|
// SSR renders without `window`, so the first client (hydration) render MUST
|
|
// also produce "Ctrl" — computing the platform-specific glyph during render
|
|
// makes the hydrated text ("⌘" on macOS) diverge from the server text
|
|
// ("Ctrl") → React #418 (hydration text mismatch). Start from the SSR-stable
|
|
// value and upgrade to the platform glyph after mount (client-only effect).
|
|
const [modKey, setModKey] = React.useState("Ctrl")
|
|
React.useEffect(() => {
|
|
if (isMacOS()) setModKey("⌘")
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
// ⌘K or Ctrl+K to toggle command menu
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
if (
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLSelectElement
|
|
) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
setOpen((open) => !open)
|
|
}
|
|
|
|
// ⌘P or Ctrl+P as alternative (common in many editors)
|
|
if (e.key === "p" && (e.metaKey || e.ctrlKey)) {
|
|
if (
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLSelectElement
|
|
) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
setOpen((open) => !open)
|
|
}
|
|
|
|
// "/" to focus search
|
|
if (e.key === "/") {
|
|
if (
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLSelectElement
|
|
) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
}
|
|
|
|
document.addEventListener("keydown", down)
|
|
return () => document.removeEventListener("keydown", down)
|
|
}, [])
|
|
|
|
const runCommand = React.useCallback(
|
|
(command: () => unknown, title?: string, href?: string) => {
|
|
// Track in recent searches if we have the metadata
|
|
if (title && href) {
|
|
addRecentSearch({
|
|
id: href,
|
|
title,
|
|
href,
|
|
})
|
|
}
|
|
|
|
setOpen(false)
|
|
setSearchQuery("")
|
|
command()
|
|
},
|
|
[addRecentSearch]
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64"
|
|
)}
|
|
onClick={() => setOpen(true)}
|
|
{...props}
|
|
>
|
|
<span className="hidden lg:inline-flex">Search documentation...</span>
|
|
<span className="inline-flex lg:hidden">Search...</span>
|
|
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
|
<span className="text-xs">{modKey}</span>K
|
|
</kbd>
|
|
</Button>
|
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
<CommandInput
|
|
placeholder="Type a command or search..."
|
|
value={searchQuery}
|
|
onValueChange={setSearchQuery}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
|
|
{/* Recent Searches - only show when no search query */}
|
|
{!searchQuery && recentSearches.length > 0 && (
|
|
<>
|
|
<CommandGroup
|
|
heading={
|
|
<div className="flex items-center justify-between">
|
|
<span>Recent</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto p-0 text-xs font-normal text-muted-foreground hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
clearRecentSearches()
|
|
}}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{recentSearches.map((search) => (
|
|
<CommandItem
|
|
key={search.id}
|
|
value={search.title}
|
|
onSelect={() => {
|
|
runCommand(
|
|
() => router.push(search.href),
|
|
search.title,
|
|
search.href
|
|
)
|
|
}}
|
|
className="group"
|
|
>
|
|
<Clock className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
<span className="flex-1">{search.title}</span>
|
|
<CommandShortcut className="opacity-0 group-hover:opacity-100">
|
|
<X className="h-3 w-3" />
|
|
</CommandShortcut>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
<CommandSeparator />
|
|
</>
|
|
)}
|
|
|
|
<CommandGroup heading="Navigation">
|
|
{docsConfig.mainNav
|
|
.filter((navitem) => !navitem.external)
|
|
.map((navItem) => (
|
|
<CommandItem
|
|
key={navItem.href}
|
|
value={navItem.title}
|
|
onSelect={() => {
|
|
runCommand(
|
|
() => router.push(navItem.href as string),
|
|
navItem.title,
|
|
navItem.href as string
|
|
)
|
|
}}
|
|
>
|
|
<FileIcon className="mr-2 h-4 w-4" />
|
|
{navItem.title}
|
|
<CommandShortcut>
|
|
<KbdBadge keys={["G", navItem.title[0].toUpperCase()]} />
|
|
</CommandShortcut>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
|
|
{docsConfig.sidebarNav.map((group, groupIndex) => (
|
|
<CommandGroup key={group.title} heading={group.title}>
|
|
{group.items.map((navItem, itemIndex) => (
|
|
<CommandItem
|
|
key={navItem.href}
|
|
value={navItem.title}
|
|
keywords={[navItem.title, group.title]}
|
|
onSelect={() => {
|
|
runCommand(
|
|
() => router.push(navItem.href as string),
|
|
navItem.title,
|
|
navItem.href as string
|
|
)
|
|
}}
|
|
>
|
|
<div className="mr-2 flex h-4 w-4 items-center justify-center">
|
|
<CircleIcon className="h-3 w-3" />
|
|
</div>
|
|
<span className="flex-1">{navItem.title}</span>
|
|
{itemIndex < 9 && (
|
|
<CommandShortcut>
|
|
<KbdBadge keys={[String(itemIndex + 1)]} />
|
|
</CommandShortcut>
|
|
)}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
|
|
<CommandSeparator />
|
|
|
|
<CommandGroup heading="Theme">
|
|
<CommandItem
|
|
onSelect={() => runCommand(() => setTheme("light"))}
|
|
keywords={["light", "theme", "appearance"]}
|
|
>
|
|
<SunIcon className="mr-2 h-4 w-4" />
|
|
Light
|
|
<CommandShortcut>
|
|
<KbdBadge keys={["T", "L"]} />
|
|
</CommandShortcut>
|
|
</CommandItem>
|
|
<CommandItem
|
|
onSelect={() => runCommand(() => setTheme("dark"))}
|
|
keywords={["dark", "theme", "appearance"]}
|
|
>
|
|
<MoonIcon className="mr-2 h-4 w-4" />
|
|
Dark
|
|
<CommandShortcut>
|
|
<KbdBadge keys={["T", "D"]} />
|
|
</CommandShortcut>
|
|
</CommandItem>
|
|
<CommandItem
|
|
onSelect={() => runCommand(() => setTheme("system"))}
|
|
keywords={["system", "theme", "appearance", "auto"]}
|
|
>
|
|
<LaptopIcon className="mr-2 h-4 w-4" />
|
|
System
|
|
<CommandShortcut>
|
|
<KbdBadge keys={["T", "S"]} />
|
|
</CommandShortcut>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</CommandDialog>
|
|
</>
|
|
)
|
|
}
|