1
1
import algoliasearch from 'algoliasearch/lite' ;
2
+ import { createRoot } from 'react-dom/client' ;
2
3
import docsStyle from './docs.css' ;
3
4
import DOMPurify from 'dompurify' ;
4
5
import { Item , SearchAutocomplete , Section } from '@react-spectrum/autocomplete' ;
6
+ import Link from '@spectrum-icons/workflow/Link' ;
7
+ import News from '@spectrum-icons/workflow/News' ;
5
8
import React , { useRef , useState } from 'react' ;
6
- import * as ReactDOM from 'react-dom/client' ;
7
9
import { Text , VisuallyHidden } from '@adobe/react-spectrum' ;
8
10
import { ThemeProvider } from './ThemeSwitcher' ;
11
+ import WebPage from '@spectrum-icons/workflow/WebPage' ;
9
12
10
13
export default function DocSearch ( ) {
11
14
const client = algoliasearch ( '1V1Q59JVTR' , '44a7e2e7508ff185f25ac64c0a675f98' ) ;
12
15
const searchIndex = client . initIndex ( 'react-spectrum' ) ;
13
16
const searchOptions = {
14
17
distinct : 1 ,
18
+ attributesToRetrieve : [
19
+ 'hierarchy.lvl0' ,
20
+ 'hierarchy.lvl1' ,
21
+ 'hierarchy.lvl2' ,
22
+ 'hierarchy.lvl3' ,
23
+ 'hierarchy.lvl4' ,
24
+ 'hierarchy.lvl5' ,
25
+ 'hierarchy.lvl6' ,
26
+ 'content' ,
27
+ 'type' ,
28
+ 'url'
29
+ ] ,
30
+ attributesToSnippet : [
31
+ 'hierarchy.lvl1:10' ,
32
+ 'hierarchy.lvl2:10' ,
33
+ 'hierarchy.lvl3:10' ,
34
+ 'hierarchy.lvl4:10' ,
35
+ 'hierarchy.lvl5:10' ,
36
+ 'hierarchy.lvl6:10' ,
37
+ 'content:10'
38
+ ] ,
39
+ snippetEllipsisText : '…' ,
15
40
highlightPreTag : `<mark class="${ docsStyle . docSearchBoxMark } ">` ,
16
41
highlightPostTag : '</mark>' ,
17
42
hitsPerPage : 20
@@ -29,13 +54,125 @@ export default function DocSearch() {
29
54
'support' : 'Support'
30
55
} ;
31
56
57
+ function sectionTitlePredicate ( hit ) {
58
+ let sectionTitle ;
59
+ for ( const [ path , title ] of Object . entries ( sectionTitles ) ) {
60
+ let regexp = new RegExp ( '^.+//.+/' + path + '[/.].+$' , 'i' ) ;
61
+ if ( hit . url . match ( regexp ) ) {
62
+ sectionTitle = title ;
63
+ break ;
64
+ }
65
+ }
66
+ if ( ! sectionTitle ) {
67
+ sectionTitle = 'Documentation' ;
68
+ }
69
+ return sectionTitle ;
70
+ }
71
+
32
72
const [ searchValue , setSearchValue ] = useState ( '' ) ;
73
+ const [ loadingState , setLoadingState ] = useState ( null ) ;
33
74
const [ predictions , setPredictions ] = useState ( null ) ;
34
75
const [ suggestions , setSuggestions ] = useState ( null ) ;
35
76
36
77
let updatePredictions = ( { hits} ) => {
37
78
setPredictions ( hits ) ;
79
+
80
+ const groupedBySection = groupBy ( hits , ( hit ) => sectionTitlePredicate ( hit ) ) ;
38
81
let sections = [ ] ;
82
+ for ( const [ title , hits ] of Object . entries ( groupedBySection ) ) {
83
+ const items = Object . values (
84
+ groupBy ( hits , ( hit ) => hit . hierarchy . lvl1 )
85
+ )
86
+ . reverse ( )
87
+ . map (
88
+ groupedHits =>
89
+ groupedHits . map ( ( hit ) => {
90
+ const hierarchy = hit . hierarchy ;
91
+ const objectID = hit . objectID ;
92
+
93
+ return (
94
+ < Item key = { objectID } textValue = { hit . type === 'content' ? hit [ hit . type ] : hierarchy [ hit . type ] } >
95
+ {
96
+ hierarchy [ hit . type ] &&
97
+ hit . type === 'lvl1' && (
98
+ < >
99
+ {
100
+ title === 'Blog' || title === 'Releases' ?
101
+ < News aria-label = "news" /> :
102
+ < WebPage aria-label = "web page" />
103
+ }
104
+ < Text >
105
+ < Snippet
106
+ className = "DocSearch-Hit-title"
107
+ hit = { hit }
108
+ attribute = "hierarchy.lvl1" />
109
+ </ Text >
110
+ { hit . content && (
111
+ < Text slot = "description" >
112
+ < Snippet
113
+ className = "DocSearch-Hit-path"
114
+ hit = { hit }
115
+ attribute = "content" />
116
+ </ Text >
117
+ ) }
118
+ </ >
119
+ )
120
+ }
121
+
122
+ {
123
+ hierarchy [ hit . type ] &&
124
+ (
125
+ hit . type === 'lvl2' ||
126
+ hit . type === 'lvl3' ||
127
+ hit . type === 'lvl4' ||
128
+ hit . type === 'lvl5' ||
129
+ hit . type === 'lvl6'
130
+ ) && (
131
+ < >
132
+ < Link aria-label = "in-page link" />
133
+ < Text >
134
+ < Snippet
135
+ className = "DocSearch-Hit-title"
136
+ hit = { hit }
137
+ attribute = { `hierarchy.${ hit . type } ` } />
138
+ </ Text >
139
+ < Text slot = "description" >
140
+ < Snippet
141
+ className = "DocSearch-Hit-path"
142
+ hit = { hit }
143
+ attribute = "hierarchy.lvl1" />
144
+ </ Text >
145
+ </ >
146
+ )
147
+ }
148
+
149
+ {
150
+ hit . type === 'content' && (
151
+ < >
152
+ < Link aria-label = "in-page link" />
153
+ < Text >
154
+ < Snippet
155
+ className = "DocSearch-Hit-title"
156
+ hit = { hit }
157
+ attribute = "content" />
158
+ </ Text >
159
+ < Text slot = "description" >
160
+ < Snippet
161
+ className = "DocSearch-Hit-path"
162
+ hit = { hit }
163
+ attribute = "hierarchy.lvl1" />
164
+ </ Text >
165
+ </ >
166
+ )
167
+ }
168
+ </ Item >
169
+ ) ;
170
+ }
171
+ ) ) ;
172
+
173
+ sections . push ( { title, items} ) ;
174
+ }
175
+ /*
39
176
hits.forEach(prediction => {
40
177
let hierarchy = prediction.hierarchy;
41
178
let objectID = prediction.objectID;
@@ -76,18 +213,23 @@ export default function DocSearch() {
76
213
</Item>
77
214
);
78
215
});
216
+ */
79
217
let titles = Object . values ( sectionTitles ) ;
80
218
sections = sections . sort ( ( a , b ) => titles . indexOf ( a . title ) < titles . indexOf ( b . title ) ? - 1 : 1 ) ;
81
219
let suggestions = sections . map ( ( section , index ) => < Section key = { `${ index } -${ section . title } ` } title = { section . title } > { section . items } </ Section > ) ;
82
220
setSuggestions ( suggestions ) ;
221
+ setLoadingState ( null ) ;
83
222
} ;
84
223
85
224
let onInputChange = ( query ) => {
225
+ setSearchValue ( query ) ;
86
226
if ( ! query && predictions ) {
87
227
setPredictions ( null ) ;
88
228
setSuggestions ( null ) ;
229
+ setLoadingState ( null ) ;
230
+ return ;
89
231
}
90
- setSearchValue ( query ) ;
232
+ setLoadingState ( 'loading' ) ;
91
233
searchIndex
92
234
. search (
93
235
query ,
@@ -107,14 +249,16 @@ export default function DocSearch() {
107
249
const searchAutocompleteRef = useRef ( ) ;
108
250
const logoFragment = document . createElement ( 'div' ) ;
109
251
logoFragment . className = docsStyle . docSearchFooter ;
110
- const logoRoot = ReactDOM . createRoot ( logoFragment ) ;
252
+ const logoRoot = createRoot ( logoFragment ) ;
111
253
logoRoot . render ( AlgoliaSearchLogo ) ;
112
254
113
255
let onOpenChange = ( isOpen ) => {
114
256
if ( isOpen ) {
115
257
requestAnimationFrame ( ( ) => {
116
258
const listbox = document . querySelector ( '[role="listbox"]' ) ;
117
- if ( listbox . nextElementSibling . innerHTML !== logoFragment . innerHTML ) {
259
+ if ( listbox &&
260
+ listbox . nextElementSibling &&
261
+ listbox . nextElementSibling . innerHTML !== logoFragment . innerHTML ) {
118
262
listbox . parentElement . insertBefore ( logoFragment , listbox . nextElementSibling ) ;
119
263
}
120
264
} ) ;
@@ -131,6 +275,7 @@ export default function DocSearch() {
131
275
aria-label = "Search"
132
276
UNSAFE_className = { docsStyle . docSearchBox }
133
277
id = "algolia-doc-search"
278
+ loadingState = { loadingState }
134
279
value = { searchValue }
135
280
onInputChange = { onInputChange }
136
281
onSubmit = { onSubmit }
@@ -153,3 +298,51 @@ const AlgoliaSearchLogo = (
153
298
</ a >
154
299
</ div >
155
300
) ;
301
+
302
+ function groupBy ( values , predicate = ( value ) => value ) {
303
+ return values . reduce ( ( accumulator , item ) => {
304
+ const key = predicate ( item ) ;
305
+
306
+ if ( ! Object . prototype . hasOwnProperty . call ( accumulator , key ) ) {
307
+ accumulator [ key ] = [ ] ;
308
+ }
309
+
310
+ // We limit each section to show 20 hits maximum.
311
+ // This acts as a frontend alternative to `distinct`.
312
+ if ( accumulator [ key ] . length < 21 ) {
313
+ accumulator [ key ] . push ( item ) ;
314
+ }
315
+
316
+ return accumulator ;
317
+ } , { } ) ;
318
+ }
319
+
320
+ function getPropertyByPath ( object , path ) {
321
+ const parts = path . split ( '.' ) ;
322
+
323
+ return parts . reduce ( ( prev , current ) => {
324
+ if ( prev ?. [ current ] ) {
325
+ return prev [ current ] ;
326
+ }
327
+ return null ;
328
+ } , object ) ;
329
+ }
330
+
331
+ function Snippet ( {
332
+ hit,
333
+ attribute,
334
+ tagName = 'span' ,
335
+ ...rest
336
+ } ) {
337
+ return React . createElement ( tagName , {
338
+ ...rest ,
339
+ dangerouslySetInnerHTML : {
340
+ __html :
341
+ DOMPurify . sanitize (
342
+ getPropertyByPath ( hit , `_snippetResult.${ attribute } .value` ) ||
343
+ getPropertyByPath ( hit , attribute )
344
+ )
345
+ }
346
+ } ) ;
347
+ }
348
+
0 commit comments