Skip to content

Commit 302dadc

Browse files
committed
fix(#1999): refine display of search results
1 parent abfe118 commit 302dadc

File tree

1 file changed

+197
-4
lines changed

1 file changed

+197
-4
lines changed

packages/dev/docs/src/DocSearch.js

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,42 @@
11
import algoliasearch from 'algoliasearch/lite';
2+
import {createRoot} from 'react-dom/client';
23
import docsStyle from './docs.css';
34
import DOMPurify from 'dompurify';
45
import {Item, SearchAutocomplete, Section} from '@react-spectrum/autocomplete';
6+
import Link from '@spectrum-icons/workflow/Link';
7+
import News from '@spectrum-icons/workflow/News';
58
import React, {useRef, useState} from 'react';
6-
import * as ReactDOM from 'react-dom/client';
79
import {Text, VisuallyHidden} from '@adobe/react-spectrum';
810
import {ThemeProvider} from './ThemeSwitcher';
11+
import WebPage from '@spectrum-icons/workflow/WebPage';
912

1013
export default function DocSearch() {
1114
const client = algoliasearch('1V1Q59JVTR', '44a7e2e7508ff185f25ac64c0a675f98');
1215
const searchIndex = client.initIndex('react-spectrum');
1316
const searchOptions = {
1417
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: '…',
1540
highlightPreTag: `<mark class="${docsStyle.docSearchBoxMark}">`,
1641
highlightPostTag: '</mark>',
1742
hitsPerPage: 20
@@ -29,13 +54,125 @@ export default function DocSearch() {
2954
'support': 'Support'
3055
};
3156

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+
3272
const [searchValue, setSearchValue] = useState('');
73+
const [loadingState, setLoadingState] = useState(null);
3374
const [predictions, setPredictions] = useState(null);
3475
const [suggestions, setSuggestions] = useState(null);
3576

3677
let updatePredictions = ({hits}) => {
3778
setPredictions(hits);
79+
80+
const groupedBySection = groupBy(hits, (hit) => sectionTitlePredicate(hit));
3881
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+
/*
39176
hits.forEach(prediction => {
40177
let hierarchy = prediction.hierarchy;
41178
let objectID = prediction.objectID;
@@ -76,18 +213,23 @@ export default function DocSearch() {
76213
</Item>
77214
);
78215
});
216+
*/
79217
let titles = Object.values(sectionTitles);
80218
sections = sections.sort((a, b) => titles.indexOf(a.title) < titles.indexOf(b.title) ? -1 : 1);
81219
let suggestions = sections.map((section, index) => <Section key={`${index}-${section.title}`} title={section.title}>{section.items}</Section>);
82220
setSuggestions(suggestions);
221+
setLoadingState(null);
83222
};
84223

85224
let onInputChange = (query) => {
225+
setSearchValue(query);
86226
if (!query && predictions) {
87227
setPredictions(null);
88228
setSuggestions(null);
229+
setLoadingState(null);
230+
return;
89231
}
90-
setSearchValue(query);
232+
setLoadingState('loading');
91233
searchIndex
92234
.search(
93235
query,
@@ -107,14 +249,16 @@ export default function DocSearch() {
107249
const searchAutocompleteRef = useRef();
108250
const logoFragment = document.createElement('div');
109251
logoFragment.className = docsStyle.docSearchFooter;
110-
const logoRoot = ReactDOM.createRoot(logoFragment);
252+
const logoRoot = createRoot(logoFragment);
111253
logoRoot.render(AlgoliaSearchLogo);
112254

113255
let onOpenChange = (isOpen) => {
114256
if (isOpen) {
115257
requestAnimationFrame(() => {
116258
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) {
118262
listbox.parentElement.insertBefore(logoFragment, listbox.nextElementSibling);
119263
}
120264
});
@@ -131,6 +275,7 @@ export default function DocSearch() {
131275
aria-label="Search"
132276
UNSAFE_className={docsStyle.docSearchBox}
133277
id="algolia-doc-search"
278+
loadingState={loadingState}
134279
value={searchValue}
135280
onInputChange={onInputChange}
136281
onSubmit={onSubmit}
@@ -153,3 +298,51 @@ const AlgoliaSearchLogo = (
153298
</a>
154299
</div>
155300
);
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

Comments
 (0)