From f63b70b4134bd047b6a61a21433d81ac2399ae9e Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 21 Oct 2025 10:26:29 +0400 Subject: [PATCH] feat: implement search via fumadocs (#8523) * feat: implement search * fix: update message when searching --- apps/v4/app/api/search/route.ts | 5 ++ apps/v4/components/command-menu.tsx | 133 +++++++++++++++++++++++++++- apps/v4/lib/events.ts | 1 + 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/v4/app/api/search/route.ts diff --git a/apps/v4/app/api/search/route.ts b/apps/v4/app/api/search/route.ts new file mode 100644 index 0000000000..3bafe9c5a2 --- /dev/null +++ b/apps/v4/app/api/search/route.ts @@ -0,0 +1,5 @@ +import { createFromSource } from "fumadocs-core/search/server" + +import { source } from "@/lib/source" + +export const { GET } = createFromSource(source) diff --git a/apps/v4/components/command-menu.tsx b/apps/v4/components/command-menu.tsx index 7f73a34e61..934d204bd6 100644 --- a/apps/v4/components/command-menu.tsx +++ b/apps/v4/components/command-menu.tsx @@ -4,9 +4,11 @@ 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 { source } from "@/lib/source" import { cn } from "@/lib/utils" @@ -33,6 +35,7 @@ import { } 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, @@ -54,8 +57,57 @@ export function CommandMenu({ "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) { @@ -171,6 +223,7 @@ export function CommandMenu({ { + handleSearchChange(search) const extendValue = value + " " + (keywords?.join(" ") || "") if (extendValue.toLowerCase().includes(search.toLowerCase())) { return 1 @@ -178,10 +231,17 @@ export function CommandMenu({ return 0 }} > - +
+ + {query.isLoading && ( +
+ +
+ )} +
- No results found. + {query.isLoading ? "Searching..." : "No results found."} {navItems && navItems.length > 0 && ( ) : null} +
@@ -399,3 +465,66 @@ function CommandMenuKbd({ className, ...props }: React.ComponentProps<"kbd">) { /> ) } + +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}
+
+ ) + })} +
+ ) +} diff --git a/apps/v4/lib/events.ts b/apps/v4/lib/events.ts index 2c64366a30..262bf532c2 100644 --- a/apps/v4/lib/events.ts +++ b/apps/v4/lib/events.ts @@ -16,6 +16,7 @@ const eventSchema = z.object({ "copy_chart_data", "copy_color", "set_layout", + "search_query", ]), // declare type AllowedPropertyValues = string | number | boolean | null properties: z