"use client" import * as React from "react" import { useRouter } from "next/navigation" import { type DialogProps } from "@radix-ui/react-dialog" import { IconArrowRight } from "@tabler/icons-react" import { useDocsSearch } from "fumadocs-core/search/client" import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react" import { type Color, type ColorPalette } from "@/lib/colors" import { trackEvent } from "@/lib/events" import { showMcpDocs } from "@/lib/flags" import { type source } from "@/lib/source" import { cn } from "@/lib/utils" import { useConfig } from "@/hooks/use-config" import { useMutationObserver } from "@/hooks/use-mutation-observer" import { copyToClipboardWithMeta } from "@/components/copy-button" import { Button } from "@/registry/new-york-v4/ui/button" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/registry/new-york-v4/ui/command" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/registry/new-york-v4/ui/dialog" import { Kbd, KbdGroup } from "@/registry/new-york-v4/ui/kbd" import { Separator } from "@/registry/new-york-v4/ui/separator" import { Spinner } from "@/registry/new-york-v4/ui/spinner" export function CommandMenu({ tree, colors, blocks, navItems, ...props }: DialogProps & { tree: typeof source.pageTree colors: ColorPalette[] blocks?: { name: string; description: string; categories: string[] }[] navItems?: { href: string; label: string }[] }) { const router = useRouter() const [config] = useConfig() const [open, setOpen] = React.useState(false) const [selectedType, setSelectedType] = React.useState< "color" | "page" | "component" | "block" | null >(null) const [copyPayload, setCopyPayload] = React.useState("") const { search, setSearch, query } = useDocsSearch({ type: "fetch", }) const packageManager = config.packageManager || "pnpm" // Track search queries with debouncing to avoid excessive tracking. const searchTimeoutRef = React.useRef(undefined) const lastTrackedQueryRef = React.useRef("") const trackSearchQuery = React.useCallback((query: string) => { const trimmedQuery = query.trim() // Only track if the query is different from the last tracked query and has content. if (trimmedQuery && trimmedQuery !== lastTrackedQueryRef.current) { lastTrackedQueryRef.current = trimmedQuery trackEvent({ name: "search_query", properties: { query: trimmedQuery, query_length: trimmedQuery.length, }, }) } }, []) const handleSearchChange = React.useCallback( (value: string) => { // Clear existing timeout. if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current) } // Set new timeout to debounce both search and tracking. searchTimeoutRef.current = setTimeout(() => { setSearch(value) trackSearchQuery(value) }, 500) }, [setSearch, trackSearchQuery] ) // Cleanup timeout on unmount. React.useEffect(() => { return () => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current) } } }, []) const handlePageHighlight = React.useCallback( (isComponent: boolean, item: { url: string; name?: React.ReactNode }) => { if (isComponent) { const componentName = item.url.split("/").pop() setSelectedType("component") setCopyPayload( `${packageManager} dlx shadcn@latest add ${componentName}` ) } else { setSelectedType("page") setCopyPayload("") } }, [packageManager, setSelectedType, setCopyPayload] ) const handleColorHighlight = React.useCallback( (color: Color) => { setSelectedType("color") setCopyPayload(color.className) }, [setSelectedType, setCopyPayload] ) const handleBlockHighlight = React.useCallback( (block: { name: string; description: string; categories: string[] }) => { setSelectedType("block") setCopyPayload(`${packageManager} dlx shadcn@latest add ${block.name}`) }, [setSelectedType, setCopyPayload, packageManager] ) const runCommand = React.useCallback((command: () => unknown) => { setOpen(false) command() }, []) React.useEffect(() => { const down = (e: KeyboardEvent) => { if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || 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((open) => !open) } if (e.key === "c" && (e.metaKey || e.ctrlKey)) { runCommand(() => { if (selectedType === "color") { copyToClipboardWithMeta(copyPayload, { name: "copy_color", properties: { color: copyPayload }, }) } if (selectedType === "block") { copyToClipboardWithMeta(copyPayload, { name: "copy_npm_command", properties: { command: copyPayload, pm: packageManager }, }) } if (selectedType === "page" || selectedType === "component") { copyToClipboardWithMeta(copyPayload, { name: "copy_npm_command", properties: { command: copyPayload, pm: packageManager }, }) } }) } } document.addEventListener("keydown", down) return () => document.removeEventListener("keydown", down) }, [copyPayload, runCommand, selectedType, packageManager]) return ( Search documentation... Search for a command to run... { handleSearchChange(search) const extendValue = value + " " + (keywords?.join(" ") || "") if (extendValue.toLowerCase().includes(search.toLowerCase())) { return 1 } return 0 }} >
{query.isLoading && (
)}
{query.isLoading ? "Searching..." : "No results found."} {navItems && navItems.length > 0 && ( {navItems.map((item) => ( { setSelectedType("page") setCopyPayload("") }} onSelect={() => { runCommand(() => router.push(item.href)) }} > {item.label} ))} )} {tree.children.map((group) => ( {group.type === "folder" && group.children.map((item) => { if (item.type === "page") { const isComponent = item.url.includes("/components/") if (!showMcpDocs && item.url.includes("/mcp")) { return null } return ( handlePageHighlight(isComponent, item) } onSelect={() => { runCommand(() => router.push(item.url)) }} > {isComponent ? (
) : ( )} {item.name} ) } return null })} ))} {colors.map((colorPalette) => ( {colorPalette.colors.map((color) => ( handleColorHighlight(color)} onSelect={() => { runCommand(() => copyToClipboardWithMeta(color.oklch, { name: "copy_color", properties: { color: color.oklch }, }) ) }} >
{color.className} {color.oklch} ))} ))} {blocks?.length ? ( {blocks.map((block) => ( { handleBlockHighlight(block) }} keywords={[ "block", block.name, block.description, ...block.categories, ]} onSelect={() => { runCommand(() => router.push( `/blocks/${block.categories[0]}#${block.name}` ) ) }} > {block.description} {block.name} ))} ) : null}
{" "} {selectedType === "page" || selectedType === "component" ? "Go to Page" : null} {selectedType === "color" ? "Copy OKLCH" : null}
{copyPayload && ( <>
C {copyPayload}
)}
) } function CommandMenuItem({ children, className, onHighlight, ...props }: React.ComponentProps & { onHighlight?: () => void "data-selected"?: string "aria-selected"?: string }) { const ref = React.useRef(null) useMutationObserver(ref, (mutations) => { mutations.forEach((mutation) => { if ( mutation.type === "attributes" && mutation.attributeName === "aria-selected" && ref.current?.getAttribute("aria-selected") === "true" ) { onHighlight?.() } }) }) return ( {children} ) } function CommandMenuKbd({ className, ...props }: React.ComponentProps<"kbd">) { return ( ) } type Query = Awaited>["query"] function SearchResults({ setOpen, query, search, }: { open: boolean setOpen: (open: boolean) => void query: Query search: string }) { const router = useRouter() const uniqueResults = query.data && Array.isArray(query.data) ? query.data.filter( (item, index, self) => !( item.type === "text" && item.content.trim().split(/\s+/).length <= 1 ) && index === self.findIndex((t) => t.content === item.content) ) : [] if (!search.trim()) { return null } if (!query.data || query.data === "empty") { return null } if (query.data && uniqueResults.length === 0) { return null } return ( {uniqueResults.map((item) => { return ( { router.push(item.url) setOpen(false) }} className="data-[selected=true]:border-input data-[selected=true]:bg-input/50 h-9 rounded-md border border-transparent !px-3 font-normal" keywords={[item.content]} value={`${item.content} ${item.type}`} >
{item.content}
) })}
) }