5
5
ChevronsUpDownIcon ,
6
6
CornerDownLeftIcon ,
7
7
FileTextIcon ,
8
+ HashIcon ,
8
9
SearchIcon ,
9
10
} from "lucide-react" ;
10
11
import { useFilter } from "react-aria-components" ;
@@ -41,12 +42,15 @@ export function SearchCommandClient({
41
42
children,
42
43
items,
43
44
} : SearchCommandClientProps ) {
44
- const { contains } = useFilter ( { sensitivity : "base" } ) ;
45
45
const [ inputValue , setInputValue ] = React . useState ( "" ) ;
46
+ const filteredItems = React . useMemo (
47
+ ( ) => filterResults ( inputValue , items ) ,
48
+ [ inputValue , items ]
49
+ ) ;
50
+
46
51
return (
47
52
< SearchCommandDialog keyboardShortcut = { keyboardShortcut } trigger = { children } >
48
53
< Command
49
- filter = { contains }
50
54
inputValue = { inputValue }
51
55
onInputChange = { setInputValue }
52
56
className = "h-72"
@@ -59,20 +63,30 @@ export function SearchCommandClient({
59
63
</ InputRoot >
60
64
</ SearchFieldRoot >
61
65
</ div >
62
- < MenuContent items = { items } className = "h-full overflow-y-scroll py-1" >
63
- { items . map ( ( category , categoryIndex ) => (
64
- < MenuSection key = { categoryIndex } title = { category . title } >
65
- { category . items . map ( ( page ) => (
66
- < React . Fragment key = { page . id } >
67
- < MenuItem
68
- href = { page . url }
69
- textValue = { page . title }
70
- prefix = { < FileTextIcon /> }
71
- className = "py-2"
72
- >
73
- { page . title }
74
- </ MenuItem >
75
- </ React . Fragment >
66
+ < 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 ) => (
70
+ < MenuItem
71
+ key = { `${ item . href } -${ item . type } ` }
72
+ href = { item . href }
73
+ textValue = { item . title }
74
+ prefix = { item . type === "page" ? < FileTextIcon /> : undefined }
75
+ className = {
76
+ item . type === "page"
77
+ ? "[&_svg]:text-fg-muted gap-3 py-2"
78
+ : "py-0 pl-2.5"
79
+ }
80
+ >
81
+ { item . type === "page" ? (
82
+ item . title
83
+ ) : (
84
+ < div className = "[&_svg]:text-fg-muted ml-2 flex items-center gap-3 border-l pl-4 [&_svg]:size-4" >
85
+ < HashIcon />
86
+ < p className = "flex-1 truncate py-2" > { item . title } </ p >
87
+ </ div >
88
+ ) }
89
+ </ MenuItem >
76
90
) ) }
77
91
</ MenuSection >
78
92
) ) }
@@ -91,6 +105,80 @@ export function SearchCommandClient({
91
105
</ SearchCommandDialog >
92
106
) ;
93
107
}
108
+
109
+ type FilteredItems = {
110
+ title : string ;
111
+ href : string ;
112
+ type : "page" | "heading" ;
113
+ } [ ] ;
114
+
115
+ type FilteredResult = {
116
+ id : string ;
117
+ title : string ;
118
+ items : FilteredItems ;
119
+ } [ ] ;
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 ;
180
+ } ;
181
+
94
182
const SearchCommandDialog = ( {
95
183
keyboardShortcut = false ,
96
184
trigger,
0 commit comments