|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import React from "react"; |
| 4 | +import { useDocsSearch } from "fumadocs-core/search/client"; |
| 5 | +import type { SortedResult } from "fumadocs-core/server"; |
| 6 | +import { |
| 7 | + ChevronsUpDownIcon, |
| 8 | + CornerDownLeftIcon, |
| 9 | + FileTextIcon, |
| 10 | + HashIcon, |
| 11 | + SearchIcon, |
| 12 | +} from "lucide-react"; |
1 | 13 | import { kekabCaseToTitle } from "@/lib/string";
|
2 |
| -import { source } from "@/app/source"; |
3 |
| -import { SearchCommandClient } from "./search-command-client"; |
4 |
| - |
5 |
| -const additionalPages = [ |
6 |
| - { |
7 |
| - id: "themes", |
8 |
| - title: "Themes", |
9 |
| - headings: [], |
10 |
| - url: "/themes", |
11 |
| - }, |
12 |
| -]; |
| 14 | +import { Button } from "@/components/core/button"; |
| 15 | +import { DialogRoot, Dialog } from "@/components/core/dialog"; |
| 16 | +import { Loader } from "@/components/core/loader"; |
| 17 | +import { MenuContent, MenuItem, MenuSection } from "@/components/core/menu"; |
| 18 | +import { SearchFieldRoot } from "@/components/core/search-field"; |
| 19 | +import { Command } from "@/registry/core/command_basic"; |
| 20 | +import { Input, InputRoot } from "@/registry/core/input_basic"; |
| 21 | +import { searchConfig } from "@/config"; |
| 22 | + |
| 23 | +interface SearchCommandProps { |
| 24 | + keyboardShortcut?: boolean; |
| 25 | + children: React.ReactNode; |
| 26 | +} |
13 | 27 |
|
14 | 28 | export function SearchCommand({
|
15 |
| - children, |
16 | 29 | keyboardShortcut,
|
17 |
| -}: { |
18 |
| - children: React.ReactNode; |
19 |
| - keyboardShortcut?: boolean; |
20 |
| -}) { |
21 |
| - const pages = [ |
22 |
| - ...source.getPages().map((page) => ({ |
23 |
| - id: page.url, |
24 |
| - title: page.data.title, |
25 |
| - headings: page.data.structuredData.headings, |
26 |
| - url: page.url, |
27 |
| - })), |
28 |
| - ...additionalPages, |
29 |
| - ]; |
| 30 | + children, |
| 31 | +}: SearchCommandProps) { |
| 32 | + const { search, setSearch, query } = useDocsSearch({ type: "fetch" }); |
| 33 | + const results = |
| 34 | + search === "" || query.data === "empty" |
| 35 | + ? [ |
| 36 | + { |
| 37 | + id: "suggestions", |
| 38 | + name: "Suggestions", |
| 39 | + results: searchConfig.defaultResults.map((elem) => ({ |
| 40 | + id: elem.href, |
| 41 | + content: elem.name, |
| 42 | + url: elem.href, |
| 43 | + type: "page", |
| 44 | + })), |
| 45 | + }, |
| 46 | + ] |
| 47 | + : groupByCategory(query.data); |
30 | 48 |
|
| 49 | + return ( |
| 50 | + <SearchCommandDialog keyboardShortcut={keyboardShortcut} trigger={children}> |
| 51 | + <Command inputValue={search} onInputChange={setSearch} className="h-72"> |
| 52 | + <div className="p-1"> |
| 53 | + <SearchFieldRoot placeholder="Search" autoFocus className="w-full"> |
| 54 | + <InputRoot className="focus-within:ring-1"> |
| 55 | + {query.isLoading ? <Loader /> : <SearchIcon />} |
| 56 | + <Input /> |
| 57 | + </InputRoot> |
| 58 | + </SearchFieldRoot> |
| 59 | + </div> |
| 60 | + <MenuContent className="h-full overflow-y-scroll py-1"> |
| 61 | + {results.map((group) => ( |
| 62 | + <MenuSection key={group.id} title={group.name}> |
| 63 | + {group.results.map((item) => ( |
| 64 | + <MenuItem |
| 65 | + key={item.id} |
| 66 | + href={item.url} |
| 67 | + textValue={item.content} |
| 68 | + prefix={item.type === "page" ? <FileTextIcon /> : undefined} |
| 69 | + className={ |
| 70 | + item.type === "page" |
| 71 | + ? "[&_svg]:text-fg-muted gap-3 py-2" |
| 72 | + : "py-0 pl-2.5" |
| 73 | + } |
| 74 | + > |
| 75 | + {item.type === "page" ? ( |
| 76 | + item.content |
| 77 | + ) : ( |
| 78 | + <div className="[&_svg]:text-fg-muted ml-2 flex items-center gap-3 border-l pl-4 [&_svg]:size-4"> |
| 79 | + <HashIcon /> |
| 80 | + <p className="flex-1 truncate py-2">{item.content}</p> |
| 81 | + </div> |
| 82 | + )} |
| 83 | + </MenuItem> |
| 84 | + ))} |
| 85 | + </MenuSection> |
| 86 | + ))} |
| 87 | + </MenuContent> |
| 88 | + <div className="text-fg-muted flex items-center justify-end gap-4 rounded-b-[inherit] border-t p-3 text-xs [&_svg]:size-4"> |
| 89 | + <div className="flex items-center gap-1"> |
| 90 | + <ChevronsUpDownIcon /> |
| 91 | + <span>Navigate</span> |
| 92 | + </div> |
| 93 | + <div className="flex items-center gap-1"> |
| 94 | + <CornerDownLeftIcon /> |
| 95 | + <span>Go</span> |
| 96 | + </div> |
| 97 | + </div> |
| 98 | + </Command> |
| 99 | + </SearchCommandDialog> |
| 100 | + ); |
| 101 | +} |
| 102 | + |
| 103 | +type GroupedResults = { |
| 104 | + id: string; |
| 105 | + name: string; |
| 106 | + results: SortedResult[]; |
| 107 | +}[]; |
| 108 | +const groupByCategory = (results?: SortedResult[]): GroupedResults => { |
| 109 | + // We will get the category from the url and group the results by category |
| 110 | + // eg url: /docs/components/buttons/button -> category: components |
| 111 | + if (!results) return []; |
31 | 112 | const uniqueCategories = Array.from(
|
32 |
| - new Set(pages.map((item) => item.url.split("/")[2])) |
| 113 | + new Set(results.map((result) => result.url.split("/")[2]!)) |
33 | 114 | ).filter(Boolean);
|
34 | 115 |
|
35 |
| - const items = uniqueCategories.map((category) => ({ |
36 |
| - // @ts-expect-error TODO fix later |
37 |
| - title: kekabCaseToTitle(category), |
38 |
| - items: pages.filter((item) => item.url.split("/")[2] === category), |
| 116 | + const groupedResults: GroupedResults = uniqueCategories.map((category) => ({ |
| 117 | + id: category, |
| 118 | + name: kekabCaseToTitle(category), |
| 119 | + results: results.filter((result) => result.url.split("/")[2] === category), |
39 | 120 | }));
|
40 | 121 |
|
| 122 | + return groupedResults; |
| 123 | +}; |
| 124 | + |
| 125 | +const SearchCommandDialog = ({ |
| 126 | + keyboardShortcut = false, |
| 127 | + trigger, |
| 128 | + children, |
| 129 | +}: { |
| 130 | + keyboardShortcut?: boolean; |
| 131 | + children: React.ReactNode; |
| 132 | + trigger: React.ReactNode; |
| 133 | +}) => { |
| 134 | + const [isOpen, setIsOpen] = React.useState(false); |
| 135 | + |
| 136 | + React.useEffect(() => { |
| 137 | + if (!keyboardShortcut) return; |
| 138 | + |
| 139 | + const down = (e: KeyboardEvent) => { |
| 140 | + if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") { |
| 141 | + if ( |
| 142 | + (e.target instanceof HTMLElement && e.target.isContentEditable) || |
| 143 | + e.target instanceof HTMLInputElement || |
| 144 | + e.target instanceof HTMLTextAreaElement || |
| 145 | + e.target instanceof HTMLSelectElement |
| 146 | + ) { |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + e.preventDefault(); |
| 151 | + setIsOpen((open) => !open); |
| 152 | + } |
| 153 | + }; |
| 154 | + |
| 155 | + document.addEventListener("keydown", down); |
| 156 | + return () => document.removeEventListener("keydown", down); |
| 157 | + }, [keyboardShortcut]); |
| 158 | + |
41 | 159 | return (
|
42 |
| - <SearchCommandClient keyboardShortcut={keyboardShortcut} items={items}> |
43 |
| - {children} |
44 |
| - </SearchCommandClient> |
| 160 | + <DialogRoot isOpen={isOpen} onOpenChange={setIsOpen}> |
| 161 | + {trigger} |
| 162 | + <Dialog className="p-0!"> |
| 163 | + {children} |
| 164 | + <Button |
| 165 | + slot="close" |
| 166 | + variant="outline" |
| 167 | + shape="rectangle" |
| 168 | + size="sm" |
| 169 | + className="absolute right-2 top-2 h-7 px-2 text-xs font-normal" |
| 170 | + > |
| 171 | + Esc |
| 172 | + </Button> |
| 173 | + </Dialog> |
| 174 | + </DialogRoot> |
45 | 175 | );
|
46 |
| -} |
| 176 | +}; |
0 commit comments