1
0
Fork 0

feat(docsearch): add recent searches (#40)

This commit is contained in:
François Chalifour 2020-04-02 11:52:13 +02:00 committed by GitHub
parent ba6bdde041
commit 4c0aa3b71e
13 changed files with 430 additions and 123 deletions

View file

@ -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<RecentDocSearchHit>());
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<DocSearchHit[]>(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({
<div className="DocSearch-Modal">
<header className="DocSearch-SearchBar" ref={searchBoxRef}>
<SearchBox
inputRef={inputRef}
query={state.query}
getFormProps={getFormProps}
getLabelProps={getLabelProps}
getInputProps={getInputProps}
{...autocomplete}
state={state}
onClose={onClose}
inputRef={inputRef}
/>
</header>
<div className="DocSearch-Dropdown" ref={dropdownRef}>
<Dropdown
inputRef={inputRef}
{...autocomplete}
state={state}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
setQuery={setQuery}
refresh={refresh}
inputRef={inputRef}
onItemClick={item => {
recentSearches.current.saveSearch(item);
onClose();
}}
onAction={item => {
recentSearches.current.deleteSearch(item);
autocomplete.refresh();
}}
/>
</div>

View file

@ -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<InternalDocSearchHit>;
getMenuProps: GetMenuProps;
getItemProps: GetItemProps<InternalDocSearchHit, React.MouseEvent>;
setQuery(value: string): void;
refresh(): Promise<void>;
inputRef: React.MutableRefObject<HTMLInputElement>;
interface DropdownProps<TItem>
extends AutocompleteApi<
TItem,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
state: AutocompleteState<TItem>;
onItemClick(search: RecentDocSearchHit): void;
onAction(search: RecentDocSearchHit): void;
inputRef: React.MutableRefObject<null | HTMLInputElement>;
}
export function Dropdown(props: DropdownProps) {
export function Dropdown(props: DropdownProps<InternalDocSearchHit>) {
if (props.state.status === 'error') {
return <Error />;
}
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 (
<NoResults
setQuery={props.setQuery}
refresh={props.refresh}
state={props.state}
inputRef={props.inputRef}
<EmptyScreen
{...(props as DropdownProps<any>)}
hasSuggestions={hasSuggestions}
/>
);
}
return (
<Results
suggestions={props.state.suggestions}
getMenuProps={props.getMenuProps}
getItemProps={props.getItemProps}
/>
);
if (props.state.status === 'idle' && hasSuggestions === false) {
return <NoResults {...props} />;
}
return <Results {...props} />;
}

View file

@ -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<RecentDocSearchHit>;
hasSuggestions: boolean;
onItemClick(item: RecentDocSearchHit): void;
onAction(search: RecentDocSearchHit): void;
}
export function EmptyScreen(props: EmptyScreenProps) {
if (props.state.status === 'idle' && props.hasSuggestions === false) {
return (
<div>
<p>Select results and your history will appear here.</p>
</div>
);
}
return (
<div className="DocSearch-Dropdown-Container">
{props.state.suggestions.map(({ source, items }, index) => {
return (
<section key={['recent', index].join(':')} className="DocSearch-Hits">
<div className="DocSearch-Hit-source">Recent</div>
<ul {...props.getMenuProps()}>
{items.map(item => {
return (
<li
key={['recent', item.objectID].join(':')}
className="DocSearch-Hit"
{...props.getItemProps({
item,
source,
onClick() {
props.onItemClick(item);
},
})}
>
<a href={item.url}>
<div className="DocSearch-Hit-Container">
<div className="DocSearch-Hit-icon">
<svg width="20" height="20">
<g
stroke="currentColor"
strokeWidth="2"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.18 6.6a8.23 8.23 0 1112.93 9.94h0a8.23 8.23 0 01-11.63 0" />
<path d="M6.44 7.25H2.55V3.36M10.45 6v5.6M10.45 11.6L13 13" />
</g>
</svg>
</div>
{item.hierarchy[item.type] && item.type === 'lvl1' && (
<div className="DocSearch-Hit-content-wrapper">
<span className="DocSearch-Hit-title">
{item.hierarchy.lvl1}
</span>
{item.content && (
<span className="DocSearch-Hit-path">
{item.content}
</span>
)}
</div>
)}
{item.hierarchy[item.type] &&
(item.type === 'lvl2' ||
item.type === 'lvl3' ||
item.type === 'lvl4' ||
item.type === 'lvl5' ||
item.type === 'lvl6') && (
<div className="DocSearch-Hit-content-wrapper">
<span className="DocSearch-Hit-title">
{item.hierarchy[item.type]}
</span>
<span className="DocSearch-Hit-path">
{item.hierarchy.lvl1}
</span>
</div>
)}
{item.type === 'content' && (
<div className="DocSearch-Hit-content-wrapper">
<span className="DocSearch-Hit-title">
{item.content}
</span>
<span className="DocSearch-Hit-path">
{item.hierarchy.lvl1}
</span>
</div>
)}
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title="Delete this search"
onClick={event => {
event.preventDefault();
event.stopPropagation();
props.onAction(item);
}}
>
<svg width="20" height="20">
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path
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"
transform="translate(10.000000, 10.000000) rotate(-360.000000) translate(-10.000000, -10.000000) "
></path>
</g>
</svg>
</button>
</div>
</div>
</a>
</li>
);
})}
</ul>
</section>
);
})}
</div>
);
}

1
src/EmptyScreen/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './EmptyScreen';

View file

@ -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<any>;
setQuery(value: string): void;
refresh(): Promise<void>;
inputRef: React.MutableRefObject<HTMLInputElement>;
import { InternalDocSearchHit } from '../types';
interface NoResultsProps
extends AutocompleteApi<
InternalDocSearchHit,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
state: AutocompleteState<InternalDocSearchHit>;
inputRef: React.MutableRefObject<null | HTMLInputElement>;
}
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}

View file

@ -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<AutocompleteSuggestion<InternalDocSearchHit>>;
getMenuProps: GetMenuProps;
getItemProps: GetItemProps<InternalDocSearchHit, React.MouseEvent>;
interface ResultsProps
extends AutocompleteApi<
InternalDocSearchHit,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
state: AutocompleteState<InternalDocSearchHit>;
onItemClick(item: InternalDocSearchHit): void;
onAction(search: InternalDocSearchHit): void;
}
export function Results(props: ResultsProps) {
return (
<div className="DocSearch-Dropdown-Container">
{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);
},
})}
>
<a href={item.url}>
<div className="DocSearch-Hit-Container">
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree">
<g
stroke="currentColor"
strokeWidth="2"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !==
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree">
<g
stroke="currentColor"
strokeWidth="2"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !==
items[index + 1]?.__docsearch_parent ? (
<path d="M8 8v22M26.5 30H8.3" />
) : (
<path d="M8 8v44M26.5 30H8.3" />
)}
</g>
</svg>
)}
</g>
</svg>
)}
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
{item.hierarchy[item.type] && item.type === 'lvl1' && (
<div className="DocSearch-Hit-content-wrapper">
<span
<Snippet
className="DocSearch-Hit-title"
dangerouslySetInnerHTML={{
__html:
item._snippetResult.hierarchy.lvl1.value,
}}
hit={item}
attribute="hierarchy.lvl1"
/>
{item.content && (
<span
<Snippet
className="DocSearch-Hit-path"
dangerouslySetInnerHTML={{
__html: item._snippetResult.content.value,
}}
hit={item}
attribute="content"
/>
)}
</div>
@ -94,38 +104,30 @@ export function Results(props: ResultsProps) {
item.type === 'lvl5' ||
item.type === 'lvl6') && (
<div className="DocSearch-Hit-content-wrapper">
<span
<Snippet
className="DocSearch-Hit-title"
dangerouslySetInnerHTML={{
__html:
item._snippetResult.hierarchy[item.type]
.value,
}}
hit={item}
attribute={`hierarchy.${item.type}`}
/>
<span
<Snippet
className="DocSearch-Hit-path"
dangerouslySetInnerHTML={{
__html:
item._snippetResult.hierarchy.lvl1.value,
}}
hit={item}
attribute="hierarchy.lvl1"
/>
</div>
)}
{item.type === 'content' && (
<div className="DocSearch-Hit-content-wrapper">
<span
<Snippet
className="DocSearch-Hit-title"
dangerouslySetInnerHTML={{
__html: item._snippetResult.content.value,
}}
hit={item}
attribute="content"
/>
<span
<Snippet
className="DocSearch-Hit-path"
dangerouslySetInnerHTML={{
__html:
item._snippetResult.hierarchy.lvl1.value,
}}
hit={item}
attribute="hierarchy.lvl1"
/>
</div>
)}

View file

@ -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<HTMLInputElement | null>;
query: string;
getFormProps: GetFormProps<React.FormEvent>;
getLabelProps: GetLabelProps;
getInputProps: GetInputProps<
React.ChangeEvent,
interface SearchBoxProps
extends AutocompleteApi<
InternalDocSearchHit,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
>;
> {
state: AutocompleteState<InternalDocSearchHit>;
inputRef: MutableRefObject<HTMLInputElement | null>;
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}
>
<ResetIcon />

32
src/Snippet.tsx Normal file
View file

@ -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],
},
});
}

80
src/recent-searches.ts Normal file
View file

@ -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<TItem>() {
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<TItem extends RecentDocSearchHit>() {
const storage = createStorage<TItem>();
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;
},
};
}

View file

@ -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;

View file

@ -0,0 +1,6 @@
import { DocSearchHit } from './DocSearchHit';
export type RecentDocSearchHit = Omit<
DocSearchHit,
'_highlightResult' | '_snippetResult'
>;

View file

@ -1,2 +1,3 @@
export * from './DocSearchHit';
export * from './InternalDocSearchHit';
export * from './RecentDocSearchHit';