1
1
"use client" ;
2
2
3
3
import React from "react" ;
4
+ import { useDocsSearch } from "fumadocs-core/search/client" ;
5
+ import type { SortedResult } from "fumadocs-core/server" ;
4
6
import {
5
7
ChevronsUpDownIcon ,
6
8
CornerDownLeftIcon ,
7
9
FileTextIcon ,
8
10
HashIcon ,
9
11
SearchIcon ,
10
12
} from "lucide-react" ;
11
- import { useFilter } from "react-aria-components " ;
13
+ import { kekabCaseToTitle } from "@/lib/string " ;
12
14
import { Button } from "@/components/core/button" ;
13
15
import { DialogRoot , Dialog } from "@/components/core/dialog" ;
16
+ import { Loader } from "@/components/core/loader" ;
14
17
import { MenuContent , MenuItem , MenuSection } from "@/components/core/menu" ;
15
18
import { SearchFieldRoot } from "@/components/core/search-field" ;
16
19
import { Command } from "@/registry/core/command_basic" ;
17
20
import { Input , InputRoot } from "@/registry/core/input_basic" ;
18
-
19
- interface Heading {
20
- id : string ;
21
- content : string ;
22
- }
23
-
24
- interface Page {
25
- id : string ;
26
- title : string ;
27
- headings : Heading [ ] ;
28
- url : string ;
29
- }
21
+ import { searchConfig } from "@/config" ;
30
22
31
23
interface SearchCommandClientProps {
32
24
keyboardShortcut ?: boolean ;
33
25
children : React . ReactNode ;
34
- items : {
35
- title : string ;
36
- items : Page [ ] ;
37
- } [ ] ;
38
26
}
39
27
40
28
export function SearchCommandClient ( {
41
29
keyboardShortcut,
42
30
children,
43
- items,
44
31
} : SearchCommandClientProps ) {
45
- const [ inputValue , setInputValue ] = React . useState ( "" ) ;
46
- const filteredItems = React . useMemo (
47
- ( ) => filterResults ( inputValue , items ) ,
48
- [ inputValue , items ]
49
- ) ;
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 ) ;
50
48
51
49
return (
52
50
< SearchCommandDialog keyboardShortcut = { keyboardShortcut } trigger = { children } >
53
- < Command
54
- inputValue = { inputValue }
55
- onInputChange = { setInputValue }
56
- className = "h-72"
57
- >
51
+ < Command inputValue = { search } onInputChange = { setSearch } className = "h-72" >
58
52
< div className = "p-1" >
59
53
< SearchFieldRoot placeholder = "Search" autoFocus className = "w-full" >
60
54
< InputRoot className = "focus-within:ring-1" >
61
- < SearchIcon />
55
+ { query . isLoading ? < Loader /> : < SearchIcon /> }
62
56
< Input />
63
57
</ InputRoot >
64
58
</ SearchFieldRoot >
65
59
</ div >
66
60
< MenuContent className = "h-full overflow-y-scroll py-1" >
67
- { filteredItems . map ( ( category ) => (
68
- < MenuSection key = { category . id } title = { category . title } >
69
- { category . items . map ( ( item ) => (
61
+ { results . map ( ( group ) => (
62
+ < MenuSection key = { group . id } title = { group . name } >
63
+ { group . results . map ( ( item ) => (
70
64
< MenuItem
71
- key = { ` ${ item . href } - ${ item . type } ` }
72
- href = { item . href }
73
- textValue = { item . title }
65
+ key = { item . id }
66
+ href = { item . url }
67
+ textValue = { item . content }
74
68
prefix = { item . type === "page" ? < FileTextIcon /> : undefined }
75
69
className = {
76
70
item . type === "page"
@@ -79,11 +73,11 @@ export function SearchCommandClient({
79
73
}
80
74
>
81
75
{ item . type === "page" ? (
82
- item . title
76
+ item . content
83
77
) : (
84
78
< div className = "[&_svg]:text-fg-muted ml-2 flex items-center gap-3 border-l pl-4 [&_svg]:size-4" >
85
79
< HashIcon />
86
- < p className = "flex-1 truncate py-2" > { item . title } </ p >
80
+ < p className = "flex-1 truncate py-2" > { item . content } </ p >
87
81
</ div >
88
82
) }
89
83
</ MenuItem >
@@ -106,77 +100,26 @@ export function SearchCommandClient({
106
100
) ;
107
101
}
108
102
109
- type FilteredItems = {
110
- title : string ;
111
- href : string ;
112
- type : "page" | "heading" ;
113
- } [ ] ;
114
-
115
- type FilteredResult = {
103
+ type GroupedResults = {
116
104
id : string ;
117
- title : string ;
118
- items : FilteredItems ;
105
+ name : string ;
106
+ results : SortedResult [ ] ;
119
107
} [ ] ;
120
-
121
- const filterResults = (
122
- query : string ,
123
- items : SearchCommandClientProps [ "items" ]
124
- ) : FilteredResult => {
125
- // When no query, return all pages without headings
126
- if ( ! query ) {
127
- return items . map ( ( category ) => ( {
128
- id : category . title ,
129
- title : category . title ,
130
- items : category . items . map ( ( page ) => ( {
131
- title : page . title ,
132
- href : page . url ,
133
- type : "page" ,
134
- } ) ) ,
135
- } ) ) ;
136
- }
137
-
138
- const normalizedQuery = query . toLowerCase ( ) . trim ( ) ;
139
- const results : FilteredResult = [ ] ;
140
-
141
- items . forEach ( ( category ) => {
142
- const matchedItems : FilteredItems = [ ] ;
143
-
144
- category . items . forEach ( ( page ) => {
145
- const isPageMatch = page . title . toLowerCase ( ) . includes ( normalizedQuery ) ;
146
- const matchedHeadings = page . headings . filter ( ( heading ) =>
147
- heading . content . toLowerCase ( ) . includes ( normalizedQuery )
148
- ) ;
149
-
150
- // Add page if title matches or if there are matched headings
151
- if ( isPageMatch || matchedHeadings . length > 0 ) {
152
- matchedItems . push ( {
153
- title : page . title ,
154
- href : page . url ,
155
- type : "page" ,
156
- } ) ;
157
- }
158
-
159
- // Add matched headings after their parent page
160
- matchedHeadings . forEach ( ( heading ) => {
161
- matchedItems . push ( {
162
- title : heading . content ,
163
- href : `${ page . url } #${ heading . id } ` ,
164
- type : "heading" ,
165
- } ) ;
166
- } ) ;
167
- } ) ;
168
-
169
- // Only add categories that have matches
170
- if ( matchedItems . length > 0 ) {
171
- results . push ( {
172
- id : category . title ,
173
- title : category . title ,
174
- items : matchedItems ,
175
- } ) ;
176
- }
177
- } ) ;
178
-
179
- return results ;
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 [ ] ;
112
+ const uniqueCategories = Array . from (
113
+ new Set ( results . map ( ( result ) => result . url . split ( "/" ) [ 2 ] ! ) )
114
+ ) . filter ( Boolean ) ;
115
+
116
+ const groupedResults : GroupedResults = uniqueCategories . map ( ( category ) => ( {
117
+ id : category ,
118
+ name : kekabCaseToTitle ( category ) ,
119
+ results : results . filter ( ( result ) => result . url . split ( "/" ) [ 2 ] === category ) ,
120
+ } ) ) ;
121
+
122
+ return groupedResults ;
180
123
} ;
181
124
182
125
const SearchCommandDialog = ( {
0 commit comments