diff --git a/packages/docsearch-react/src/DocSearch.tsx b/packages/docsearch-react/src/DocSearch.tsx index 1e3ce73ec..a616fa64f 100644 --- a/packages/docsearch-react/src/DocSearch.tsx +++ b/packages/docsearch-react/src/DocSearch.tsx @@ -5,12 +5,18 @@ import { } from '@francoischalifour/autocomplete-core'; import { getAlgoliaHits } from '@francoischalifour/autocomplete-preset-algolia'; -import { DocSearchHit, InternalDocSearchHit } from './types'; +import { + DocSearchHit, + InternalDocSearchHit, + RecentDocSearchHit, +} from './types'; import { createSearchClient, groupBy, noop } from './utils'; import { SearchBox } from './SearchBox'; import { Dropdown } from './Dropdown'; import { Footer } from './Footer'; +import { createRecentSearches } from './recent-searches'; + interface DocSearchProps { appId?: string; apiKey: string; @@ -42,18 +48,9 @@ export function DocSearch({ appId, apiKey, ]); + const recentSearches = useRef(createRecentSearches()); - const { - getEnvironmentProps, - getRootProps, - getFormProps, - getLabelProps, - getInputProps, - getMenuProps, - getItemProps, - setQuery, - refresh, - } = React.useMemo( + const autocomplete = React.useMemo( () => createAutocomplete< InternalDocSearchHit, @@ -127,9 +124,27 @@ export function DocSearch({ }); } + if (!query) { + return [ + { + onSelect({ suggestion }) { + recentSearches.current.saveSearch(suggestion); + onClose(); + }, + getSuggestionUrl({ suggestion }) { + return suggestion.url; + }, + getSuggestions() { + return recentSearches.current.getSearches(); + }, + }, + ]; + } + return Object.values(sources).map(items => { return { - onSelect() { + onSelect({ suggestion }) { + recentSearches.current.saveSearch(suggestion); onClose(); }, getSuggestionUrl({ suggestion }) { @@ -165,6 +180,8 @@ export function DocSearch({ [indexName, searchParameters, searchClient, onClose] ); + const { getEnvironmentProps, getRootProps } = autocomplete; + useEffect(() => { const isMobileMediaQuery = window.matchMedia('(max-width: 750px)'); @@ -219,23 +236,26 @@ export function DocSearch({
{ + recentSearches.current.saveSearch(item); + onClose(); + }} + onAction={item => { + recentSearches.current.deleteSearch(item); + autocomplete.refresh(); + }} />
diff --git a/packages/docsearch-react/src/Dropdown/Dropdown.tsx b/packages/docsearch-react/src/Dropdown/Dropdown.tsx index 889acf403..0643b814c 100644 --- a/packages/docsearch-react/src/Dropdown/Dropdown.tsx +++ b/packages/docsearch-react/src/Dropdown/Dropdown.tsx @@ -1,48 +1,49 @@ import React from 'react'; import { + AutocompleteApi, AutocompleteState, - GetMenuProps, - GetItemProps, } from '@francoischalifour/autocomplete-core'; -import { InternalDocSearchHit } from '../types'; -import { Error } from '../Error'; -import { NoResults } from '../NoResults'; +import { InternalDocSearchHit, RecentDocSearchHit } from '../types'; +import { EmptyScreen } from '../EmptyScreen'; import { Results } from '../Results'; +import { NoResults } from '../NoResults'; +import { Error } from '../Error'; -interface DropdownProps { - state: AutocompleteState; - getMenuProps: GetMenuProps; - getItemProps: GetItemProps; - setQuery(value: string): void; - refresh(): Promise; - inputRef: React.MutableRefObject; +interface DropdownProps + extends AutocompleteApi< + TItem, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + state: AutocompleteState; + onItemClick(search: RecentDocSearchHit): void; + onAction(search: RecentDocSearchHit): void; + inputRef: React.MutableRefObject; } -export function Dropdown(props: DropdownProps) { +export function Dropdown(props: DropdownProps) { if (props.state.status === 'error') { return ; } - if ( - props.state.status === 'idle' && - props.state.suggestions.every(source => source.items.length === 0) - ) { + const hasSuggestions = props.state.suggestions.some( + source => source.items.length > 0 + ); + + if (!props.state.query) { return ( - )} + hasSuggestions={hasSuggestions} /> ); } - return ( - - ); + if (props.state.status === 'idle' && hasSuggestions === false) { + return ; + } + + return ; } diff --git a/packages/docsearch-react/src/EmptyScreen/EmptyScreen.tsx b/packages/docsearch-react/src/EmptyScreen/EmptyScreen.tsx new file mode 100644 index 000000000..c2bbb7f3f --- /dev/null +++ b/packages/docsearch-react/src/EmptyScreen/EmptyScreen.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { + AutocompleteApi, + AutocompleteState, +} from '@francoischalifour/autocomplete-core'; + +import { RecentDocSearchHit } from '../types'; + +interface EmptyScreenProps + extends AutocompleteApi< + RecentDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + state: AutocompleteState; + hasSuggestions: boolean; + onItemClick(item: RecentDocSearchHit): void; + onAction(search: RecentDocSearchHit): void; +} + +export function EmptyScreen(props: EmptyScreenProps) { + if (props.state.status === 'idle' && props.hasSuggestions === false) { + return ( +
+

Select results and your history will appear here.

+
+ ); + } + + return ( + + ); +} diff --git a/packages/docsearch-react/src/EmptyScreen/index.ts b/packages/docsearch-react/src/EmptyScreen/index.ts new file mode 100644 index 000000000..f184be926 --- /dev/null +++ b/packages/docsearch-react/src/EmptyScreen/index.ts @@ -0,0 +1 @@ +export * from './EmptyScreen'; diff --git a/packages/docsearch-react/src/NoResults/NoResults.tsx b/packages/docsearch-react/src/NoResults/NoResults.tsx index a296ea1f7..147d4e705 100644 --- a/packages/docsearch-react/src/NoResults/NoResults.tsx +++ b/packages/docsearch-react/src/NoResults/NoResults.tsx @@ -1,11 +1,20 @@ import React from 'react'; -import { AutocompleteState } from '@francoischalifour/autocomplete-core'; +import { + AutocompleteApi, + AutocompleteState, +} from '@francoischalifour/autocomplete-core'; -interface NoResultsProps { - state: AutocompleteState; - setQuery(value: string): void; - refresh(): Promise; - inputRef: React.MutableRefObject; +import { InternalDocSearchHit } from '../types'; + +interface NoResultsProps + extends AutocompleteApi< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + state: AutocompleteState; + inputRef: React.MutableRefObject; } export function NoResults(props: NoResultsProps) { @@ -30,7 +39,7 @@ export function NoResults(props: NoResultsProps) { onClick={() => { props.setQuery(search.toLowerCase() + ' '); props.refresh(); - props.inputRef.current.focus(); + props.inputRef.current!.focus(); }} > {search} diff --git a/packages/docsearch-react/src/Results/Results.tsx b/packages/docsearch-react/src/Results/Results.tsx index c8a310adb..d63b024b3 100644 --- a/packages/docsearch-react/src/Results/Results.tsx +++ b/packages/docsearch-react/src/Results/Results.tsx @@ -1,24 +1,34 @@ import React from 'react'; import { - GetMenuProps, - GetItemProps, - AutocompleteSuggestion, + AutocompleteApi, + AutocompleteState, } from '@francoischalifour/autocomplete-core'; +import { Snippet } from '../Snippet'; import { SourceIcon } from './SourceIcon'; import { SelectIcon } from './ActionIcon'; import { InternalDocSearchHit } from '../types'; -interface ResultsProps { - suggestions: Array>; - getMenuProps: GetMenuProps; - getItemProps: GetItemProps; +interface ResultsProps + extends AutocompleteApi< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + state: AutocompleteState; + onItemClick(item: InternalDocSearchHit): void; + onAction(search: InternalDocSearchHit): void; } export function Results(props: ResultsProps) { return (
- {props.suggestions.map(({ source, items }) => { + {props.state.suggestions.map(({ source, items }) => { + if (items.length === 0) { + return null; + } + const title = items[0].hierarchy.lvl0; return ( @@ -39,49 +49,49 @@ export function Results(props: ResultsProps) { {...props.getItemProps({ item, source, + onClick() { + props.onItemClick(item); + }, })} > -
- {item.__docsearch_parent && ( - - - {item.__docsearch_parent !== + {item.__docsearch_parent && ( + + + {item.__docsearch_parent !== items[index + 1]?.__docsearch_parent ? ( ) : ( )} - - - )} + + + )} +
{item.hierarchy[item.type] && item.type === 'lvl1' && (
- {item.content && ( - )}
@@ -94,38 +104,30 @@ export function Results(props: ResultsProps) { item.type === 'lvl5' || item.type === 'lvl6') && (
- -
)} {item.type === 'content' && (
- -
)} diff --git a/packages/docsearch-react/src/SearchBox/SearchBox.tsx b/packages/docsearch-react/src/SearchBox/SearchBox.tsx index 8952d4ab4..12b6b23e1 100644 --- a/packages/docsearch-react/src/SearchBox/SearchBox.tsx +++ b/packages/docsearch-react/src/SearchBox/SearchBox.tsx @@ -1,24 +1,23 @@ import React, { MutableRefObject } from 'react'; import { - GetFormProps, - GetLabelProps, - GetInputProps, + AutocompleteApi, + AutocompleteState, } from '@francoischalifour/autocomplete-core'; +import { InternalDocSearchHit } from '../types'; import { SearchIcon } from './SearchIcon'; import { ResetIcon } from './ResetIcon'; import { LoadingIcon } from './LoadingIcon'; -interface SearchBoxProps { - inputRef: MutableRefObject; - query: string; - getFormProps: GetFormProps; - getLabelProps: GetLabelProps; - getInputProps: GetInputProps< - React.ChangeEvent, +interface SearchBoxProps + extends AutocompleteApi< + InternalDocSearchHit, + React.FormEvent, React.MouseEvent, React.KeyboardEvent - >; + > { + state: AutocompleteState; + inputRef: MutableRefObject; onClose(): void; } @@ -59,7 +58,7 @@ export function SearchBox(props: SearchBoxProps) { type="reset" title="Clear the query" className="DocSearch-Reset" - hidden={!props.query} + hidden={!props.state.query} onClick={onReset} > diff --git a/packages/docsearch-react/src/Snippet.tsx b/packages/docsearch-react/src/Snippet.tsx new file mode 100644 index 000000000..807ca1d23 --- /dev/null +++ b/packages/docsearch-react/src/Snippet.tsx @@ -0,0 +1,32 @@ +import { createElement } from 'react'; + +import { InternalDocSearchHit } from './types'; + +function getPropertyByPath(object: object, path: string): any { + const parts = path.split('.'); + + return parts.reduce((current, key) => current && current[key], object); +} + +interface SnippetProps { + [prop: string]: unknown; + hit: InternalDocSearchHit; + attribute: string; + tagName?: string; +} + +export function Snippet({ + hit, + attribute, + tagName = 'span', + ...rest +}: SnippetProps) { + return createElement(tagName, { + ...rest, + dangerouslySetInnerHTML: { + __html: + getPropertyByPath(hit, `_snippetResult.${attribute}.value`) || + hit[attribute], + }, + }); +} diff --git a/packages/docsearch-react/src/recent-searches.ts b/packages/docsearch-react/src/recent-searches.ts new file mode 100644 index 000000000..73a77db18 --- /dev/null +++ b/packages/docsearch-react/src/recent-searches.ts @@ -0,0 +1,80 @@ +import { DocSearchHit, RecentDocSearchHit } from './types'; + +function isLocalStorageSupported() { + const key = '__TEST_KEY__'; + + try { + localStorage.setItem(key, ''); + localStorage.removeItem(key); + + return true; + } catch (error) { + return false; + } +} + +function createStorage() { + if (isLocalStorageSupported() === false) { + return { + setItem() {}, + getItem() { + return []; + }, + }; + } + + const STORAGE_KEY = '__AUTOCOMPLETE_RECENT_SEARCHES__'; + + return { + setItem(item: TItem[]) { + return window.localStorage.setItem(STORAGE_KEY, JSON.stringify(item)); + }, + getItem(): TItem[] { + const item = window.localStorage.getItem(STORAGE_KEY); + + return item ? JSON.parse(item) : []; + }, + }; +} + +export function createRecentSearches() { + const storage = createStorage(); + let items = storage.getItem(); + + return { + saveSearch(search: TItem) { + const { + _highlightResult, + _snippetResult, + ...item + } = (search as unknown) as DocSearchHit; + + if (item.type === 'content') { + return false; + } + + const isQueryAlreadySaved = items.findIndex( + x => x.objectID === item.objectID + ); + + if (isQueryAlreadySaved > -1) { + items.splice(isQueryAlreadySaved, 1); + } + + items.unshift(item as TItem); + items = items.slice(0, 5); + + storage.setItem(items); + + return true; + }, + deleteSearch(item: TItem) { + items = items.filter(x => x.objectID !== item.objectID); + + storage.setItem(items); + }, + getSearches() { + return items; + }, + }; +} diff --git a/packages/docsearch-react/src/style.css b/packages/docsearch-react/src/style.css index 875b3d7bc..27d5eb4f2 100644 --- a/packages/docsearch-react/src/style.css +++ b/packages/docsearch-react/src/style.css @@ -447,6 +447,15 @@ html[data-theme='dark'] { color: var(--docsearch-muted-color); } +.DocSearch-Hit-action-button { + appearance: none; + background: none; + color: inherit; + border: none; + padding: 0; + cursor: pointer; +} + .DocSearch-Hit-content-wrapper { position: relative; display: flex; diff --git a/packages/docsearch-react/src/types/InternalDocSearchHit.tsx b/packages/docsearch-react/src/types/InternalDocSearchHit.ts similarity index 100% rename from packages/docsearch-react/src/types/InternalDocSearchHit.tsx rename to packages/docsearch-react/src/types/InternalDocSearchHit.ts diff --git a/packages/docsearch-react/src/types/RecentDocSearchHit.ts b/packages/docsearch-react/src/types/RecentDocSearchHit.ts new file mode 100644 index 000000000..e00c482b9 --- /dev/null +++ b/packages/docsearch-react/src/types/RecentDocSearchHit.ts @@ -0,0 +1,6 @@ +import { DocSearchHit } from './DocSearchHit'; + +export type RecentDocSearchHit = Omit< + DocSearchHit, + '_highlightResult' | '_snippetResult' +>; diff --git a/packages/docsearch-react/src/types/index.ts b/packages/docsearch-react/src/types/index.ts index 7ccf20336..76849a249 100644 --- a/packages/docsearch-react/src/types/index.ts +++ b/packages/docsearch-react/src/types/index.ts @@ -1,2 +1,3 @@ export * from './DocSearchHit'; export * from './InternalDocSearchHit'; +export * from './RecentDocSearchHit';