Skip to content

Commit 4c0aa3b

Browse files
feat(docsearch): add recent searches (#40)
1 parent ba6bdde commit 4c0aa3b

13 files changed

Lines changed: 430 additions & 123 deletions

File tree

src/DocSearch.tsx

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ import {
55
} from '@francoischalifour/autocomplete-core';
66
import { getAlgoliaHits } from '@francoischalifour/autocomplete-preset-algolia';
77

8-
import { DocSearchHit, InternalDocSearchHit } from './types';
8+
import {
9+
DocSearchHit,
10+
InternalDocSearchHit,
11+
RecentDocSearchHit,
12+
} from './types';
913
import { createSearchClient, groupBy, noop } from './utils';
1014
import { SearchBox } from './SearchBox';
1115
import { Dropdown } from './Dropdown';
1216
import { Footer } from './Footer';
1317

18+
import { createRecentSearches } from './recent-searches';
19+
1420
interface DocSearchProps {
1521
appId?: string;
1622
apiKey: string;
@@ -42,18 +48,9 @@ export function DocSearch({
4248
appId,
4349
apiKey,
4450
]);
51+
const recentSearches = useRef(createRecentSearches<RecentDocSearchHit>());
4552

46-
const {
47-
getEnvironmentProps,
48-
getRootProps,
49-
getFormProps,
50-
getLabelProps,
51-
getInputProps,
52-
getMenuProps,
53-
getItemProps,
54-
setQuery,
55-
refresh,
56-
} = React.useMemo(
53+
const autocomplete = React.useMemo(
5754
() =>
5855
createAutocomplete<
5956
InternalDocSearchHit,
@@ -127,9 +124,27 @@ export function DocSearch({
127124
});
128125
}
129126

127+
if (!query) {
128+
return [
129+
{
130+
onSelect({ suggestion }) {
131+
recentSearches.current.saveSearch(suggestion);
132+
onClose();
133+
},
134+
getSuggestionUrl({ suggestion }) {
135+
return suggestion.url;
136+
},
137+
getSuggestions() {
138+
return recentSearches.current.getSearches();
139+
},
140+
},
141+
];
142+
}
143+
130144
return Object.values<DocSearchHit[]>(sources).map(items => {
131145
return {
132-
onSelect() {
146+
onSelect({ suggestion }) {
147+
recentSearches.current.saveSearch(suggestion);
133148
onClose();
134149
},
135150
getSuggestionUrl({ suggestion }) {
@@ -165,6 +180,8 @@ export function DocSearch({
165180
[indexName, searchParameters, searchClient, onClose]
166181
);
167182

183+
const { getEnvironmentProps, getRootProps } = autocomplete;
184+
168185
useEffect(() => {
169186
const isMobileMediaQuery = window.matchMedia('(max-width: 750px)');
170187

@@ -219,23 +236,26 @@ export function DocSearch({
219236
<div className="DocSearch-Modal">
220237
<header className="DocSearch-SearchBar" ref={searchBoxRef}>
221238
<SearchBox
222-
inputRef={inputRef}
223-
query={state.query}
224-
getFormProps={getFormProps}
225-
getLabelProps={getLabelProps}
226-
getInputProps={getInputProps}
239+
{...autocomplete}
240+
state={state}
227241
onClose={onClose}
242+
inputRef={inputRef}
228243
/>
229244
</header>
230245

231246
<div className="DocSearch-Dropdown" ref={dropdownRef}>
232247
<Dropdown
233-
inputRef={inputRef}
248+
{...autocomplete}
234249
state={state}
235-
getMenuProps={getMenuProps}
236-
getItemProps={getItemProps}
237-
setQuery={setQuery}
238-
refresh={refresh}
250+
inputRef={inputRef}
251+
onItemClick={item => {
252+
recentSearches.current.saveSearch(item);
253+
onClose();
254+
}}
255+
onAction={item => {
256+
recentSearches.current.deleteSearch(item);
257+
autocomplete.refresh();
258+
}}
239259
/>
240260
</div>
241261

src/Dropdown/Dropdown.tsx

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,49 @@
11
import React from 'react';
22
import {
3+
AutocompleteApi,
34
AutocompleteState,
4-
GetMenuProps,
5-
GetItemProps,
65
} from '@francoischalifour/autocomplete-core';
76

8-
import { InternalDocSearchHit } from '../types';
9-
import { Error } from '../Error';
10-
import { NoResults } from '../NoResults';
7+
import { InternalDocSearchHit, RecentDocSearchHit } from '../types';
8+
import { EmptyScreen } from '../EmptyScreen';
119
import { Results } from '../Results';
10+
import { NoResults } from '../NoResults';
11+
import { Error } from '../Error';
1212

13-
interface DropdownProps {
14-
state: AutocompleteState<InternalDocSearchHit>;
15-
getMenuProps: GetMenuProps;
16-
getItemProps: GetItemProps<InternalDocSearchHit, React.MouseEvent>;
17-
setQuery(value: string): void;
18-
refresh(): Promise<void>;
19-
inputRef: React.MutableRefObject<HTMLInputElement>;
13+
interface DropdownProps<TItem>
14+
extends AutocompleteApi<
15+
TItem,
16+
React.FormEvent,
17+
React.MouseEvent,
18+
React.KeyboardEvent
19+
> {
20+
state: AutocompleteState<TItem>;
21+
onItemClick(search: RecentDocSearchHit): void;
22+
onAction(search: RecentDocSearchHit): void;
23+
inputRef: React.MutableRefObject<null | HTMLInputElement>;
2024
}
2125

22-
export function Dropdown(props: DropdownProps) {
26+
export function Dropdown(props: DropdownProps<InternalDocSearchHit>) {
2327
if (props.state.status === 'error') {
2428
return <Error />;
2529
}
2630

27-
if (
28-
props.state.status === 'idle' &&
29-
props.state.suggestions.every(source => source.items.length === 0)
30-
) {
31+
const hasSuggestions = props.state.suggestions.some(
32+
source => source.items.length > 0
33+
);
34+
35+
if (!props.state.query) {
3136
return (
32-
<NoResults
33-
setQuery={props.setQuery}
34-
refresh={props.refresh}
35-
state={props.state}
36-
inputRef={props.inputRef}
37+
<EmptyScreen
38+
{...(props as DropdownProps<any>)}
39+
hasSuggestions={hasSuggestions}
3740
/>
3841
);
3942
}
4043

41-
return (
42-
<Results
43-
suggestions={props.state.suggestions}
44-
getMenuProps={props.getMenuProps}
45-
getItemProps={props.getItemProps}
46-
/>
47-
);
44+
if (props.state.status === 'idle' && hasSuggestions === false) {
45+
return <NoResults {...props} />;
46+
}
47+
48+
return <Results {...props} />;
4849
}

src/EmptyScreen/EmptyScreen.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import React from 'react';
2+
import {
3+
AutocompleteApi,
4+
AutocompleteState,
5+
} from '@francoischalifour/autocomplete-core';
6+
7+
import { RecentDocSearchHit } from '../types';
8+
9+
interface EmptyScreenProps
10+
extends AutocompleteApi<
11+
RecentDocSearchHit,
12+
React.FormEvent,
13+
React.MouseEvent,
14+
React.KeyboardEvent
15+
> {
16+
state: AutocompleteState<RecentDocSearchHit>;
17+
hasSuggestions: boolean;
18+
onItemClick(item: RecentDocSearchHit): void;
19+
onAction(search: RecentDocSearchHit): void;
20+
}
21+
22+
export function EmptyScreen(props: EmptyScreenProps) {
23+
if (props.state.status === 'idle' && props.hasSuggestions === false) {
24+
return (
25+
<div>
26+
<p>Select results and your history will appear here.</p>
27+
</div>
28+
);
29+
}
30+
31+
return (
32+
<div className="DocSearch-Dropdown-Container">
33+
{props.state.suggestions.map(({ source, items }, index) => {
34+
return (
35+
<section key={['recent', index].join(':')} className="DocSearch-Hits">
36+
<div className="DocSearch-Hit-source">Recent</div>
37+
38+
<ul {...props.getMenuProps()}>
39+
{items.map(item => {
40+
return (
41+
<li
42+
key={['recent', item.objectID].join(':')}
43+
className="DocSearch-Hit"
44+
{...props.getItemProps({
45+
item,
46+
source,
47+
onClick() {
48+
props.onItemClick(item);
49+
},
50+
})}
51+
>
52+
<a href={item.url}>
53+
<div className="DocSearch-Hit-Container">
54+
<div className="DocSearch-Hit-icon">
55+
<svg width="20" height="20">
56+
<g
57+
stroke="currentColor"
58+
strokeWidth="2"
59+
fill="none"
60+
fillRule="evenodd"
61+
strokeLinecap="round"
62+
strokeLinejoin="round"
63+
>
64+
<path d="M3.18 6.6a8.23 8.23 0 1112.93 9.94h0a8.23 8.23 0 01-11.63 0" />
65+
<path d="M6.44 7.25H2.55V3.36M10.45 6v5.6M10.45 11.6L13 13" />
66+
</g>
67+
</svg>
68+
</div>
69+
70+
{item.hierarchy[item.type] && item.type === 'lvl1' && (
71+
<div className="DocSearch-Hit-content-wrapper">
72+
<span className="DocSearch-Hit-title">
73+
{item.hierarchy.lvl1}
74+
</span>
75+
{item.content && (
76+
<span className="DocSearch-Hit-path">
77+
{item.content}
78+
</span>
79+
)}
80+
</div>
81+
)}
82+
83+
{item.hierarchy[item.type] &&
84+
(item.type === 'lvl2' ||
85+
item.type === 'lvl3' ||
86+
item.type === 'lvl4' ||
87+
item.type === 'lvl5' ||
88+
item.type === 'lvl6') && (
89+
<div className="DocSearch-Hit-content-wrapper">
90+
<span className="DocSearch-Hit-title">
91+
{item.hierarchy[item.type]}
92+
</span>
93+
<span className="DocSearch-Hit-path">
94+
{item.hierarchy.lvl1}
95+
</span>
96+
</div>
97+
)}
98+
99+
{item.type === 'content' && (
100+
<div className="DocSearch-Hit-content-wrapper">
101+
<span className="DocSearch-Hit-title">
102+
{item.content}
103+
</span>
104+
<span className="DocSearch-Hit-path">
105+
{item.hierarchy.lvl1}
106+
</span>
107+
</div>
108+
)}
109+
110+
<div className="DocSearch-Hit-action">
111+
<button
112+
className="DocSearch-Hit-action-button"
113+
title="Delete this search"
114+
onClick={event => {
115+
event.preventDefault();
116+
event.stopPropagation();
117+
118+
props.onAction(item);
119+
}}
120+
>
121+
<svg width="20" height="20">
122+
<g
123+
stroke="currentColor"
124+
strokeLinecap="round"
125+
strokeLinejoin="round"
126+
strokeWidth="2"
127+
>
128+
<path
129+
d="M10,10 L15.0853291,4.91467086 L10,10 L15.0853291,15.0853291 L10,10 Z M10,10 L4.91467086,4.91467086 L10,10 L4.91467086,15.0853291 L10,10 Z"
130+
transform="translate(10.000000, 10.000000) rotate(-360.000000) translate(-10.000000, -10.000000) "
131+
></path>
132+
</g>
133+
</svg>
134+
</button>
135+
</div>
136+
</div>
137+
</a>
138+
</li>
139+
);
140+
})}
141+
</ul>
142+
</section>
143+
);
144+
})}
145+
</div>
146+
);
147+
}

src/EmptyScreen/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './EmptyScreen';

src/NoResults/NoResults.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import React from 'react';
2-
import { AutocompleteState } from '@francoischalifour/autocomplete-core';
2+
import {
3+
AutocompleteApi,
4+
AutocompleteState,
5+
} from '@francoischalifour/autocomplete-core';
36

4-
interface NoResultsProps {
5-
state: AutocompleteState<any>;
6-
setQuery(value: string): void;
7-
refresh(): Promise<void>;
8-
inputRef: React.MutableRefObject<HTMLInputElement>;
7+
import { InternalDocSearchHit } from '../types';
8+
9+
interface NoResultsProps
10+
extends AutocompleteApi<
11+
InternalDocSearchHit,
12+
React.FormEvent,
13+
React.MouseEvent,
14+
React.KeyboardEvent
15+
> {
16+
state: AutocompleteState<InternalDocSearchHit>;
17+
inputRef: React.MutableRefObject<null | HTMLInputElement>;
918
}
1019

1120
export function NoResults(props: NoResultsProps) {
@@ -30,7 +39,7 @@ export function NoResults(props: NoResultsProps) {
3039
onClick={() => {
3140
props.setQuery(search.toLowerCase() + ' ');
3241
props.refresh();
33-
props.inputRef.current.focus();
42+
props.inputRef.current!.focus();
3443
}}
3544
>
3645
{search}

0 commit comments

Comments
 (0)