import React from 'react'; import { createAutocomplete, AutocompleteState, } from '@francoischalifour/autocomplete-core'; import { getAlgoliaHits } from '@francoischalifour/autocomplete-preset-algolia'; import { MAX_QUERY_SIZE } from './constants'; import { DocSearchHit, InternalDocSearchHit, StoredDocSearchHit, } from './types'; import { groupBy, noop } from './utils'; import { createStoredSearches } from './stored-searches'; import { useSearchClient } from './useSearchClient'; import { useTrapFocus } from './useTrapFocus'; import { useTouchEvents } from './useTouchEvents'; import { DocSearchProps } from './DocSearch'; import { Hit } from './Hit'; import { SearchBox } from './SearchBox'; import { ScreenState } from './ScreenState'; import { Footer } from './Footer'; interface DocSearchModalProps extends DocSearchProps { onClose?(): void; } export function DocSearchModal({ appId = 'BH4D9OD16A', apiKey, indexName, placeholder = 'Search docs', searchParameters, onClose = noop, transformItems = (x) => x, hitComponent = Hit, navigator, }: DocSearchModalProps) { const [state, setState] = React.useState< AutocompleteState >({ query: '', suggestions: [], } as any); const containerRef = React.useRef(null); const searchBoxRef = React.useRef(null); const dropdownRef = React.useRef(null); const inputRef = React.useRef(null); const snipetLength = React.useRef(10); const initialQuery = React.useRef( typeof window !== 'undefined' ? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE) : '' ).current; const scrollY = React.useRef(0); const searchClient = useSearchClient(appId, apiKey); const favoriteSearches = React.useRef( createStoredSearches({ key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`, limit: 10, }) ).current; const recentSearches = React.useRef( createStoredSearches({ key: `__DOCSEARCH_RECENT_SEARCHES__${indexName}`, // We display 7 recent searches and there's no favorites, but only // 4 when there are favorites. limit: favoriteSearches.getAll().length === 0 ? 7 : 4, }) ).current; const saveRecentSearch = React.useCallback( function saveRecentSearch(item: InternalDocSearchHit) { // We don't store `content` record, but their parent if available. const search = item.type === 'content' ? item.__docsearch_parent : item; // We save the recent search only if it's not favorited. if ( search && favoriteSearches .getAll() .findIndex((x) => x.objectID === search.objectID) === -1 ) { recentSearches.add(search); } }, [favoriteSearches, recentSearches] ); const autocomplete = React.useMemo( () => createAutocomplete< InternalDocSearchHit, React.FormEvent, React.MouseEvent, React.KeyboardEvent >({ id: 'docsearch', defaultHighlightedIndex: 0, autoFocus: true, placeholder, openOnFocus: true, initialState: { query: initialQuery, context: { searchSuggestions: [], }, }, navigator, onStateChange({ state }) { setState(state as any); }, // @ts-ignore Temporarily ignore bad typing in autocomplete-core. getSources({ query, state, setContext, setStatus }) { if (!query) { return [ { onSelect({ suggestion }) { saveRecentSearch(suggestion); onClose(); }, getSuggestionUrl({ suggestion }) { return suggestion.url; }, getSuggestions() { return recentSearches.getAll(); }, }, { onSelect({ suggestion }) { saveRecentSearch(suggestion); onClose(); }, getSuggestionUrl({ suggestion }) { return suggestion.url; }, getSuggestions() { return favoriteSearches.getAll(); }, }, ]; } return getAlgoliaHits({ searchClient, queries: [ { indexName, query, params: { attributesToRetrieve: [ 'hierarchy.lvl0', 'hierarchy.lvl1', 'hierarchy.lvl2', 'hierarchy.lvl3', 'hierarchy.lvl4', 'hierarchy.lvl5', 'hierarchy.lvl6', 'content', 'type', 'url', ], attributesToSnippet: [ `hierarchy.lvl1:${snipetLength.current}`, `hierarchy.lvl2:${snipetLength.current}`, `hierarchy.lvl3:${snipetLength.current}`, `hierarchy.lvl4:${snipetLength.current}`, `hierarchy.lvl5:${snipetLength.current}`, `hierarchy.lvl6:${snipetLength.current}`, `content:${snipetLength.current}`, ], snippetEllipsisText: '…', highlightPreTag: '', highlightPostTag: '', hitsPerPage: 20, distinct: 4, ...searchParameters, }, }, ], }) .catch((error) => { // The Algolia `RetryError` happens when all the servers have // failed, meaning that there's no chance the response comes // back. This is the right time to display an error. // See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5 if (error.name === 'RetryError') { setStatus('error'); } throw error; }) .then((hits: DocSearchHit[]) => { const sources = groupBy(hits, (hit) => hit.hierarchy.lvl0); // We store the `lvl0`s to display them as search suggestions // in the “no results“ screen. if ( (state.context.searchSuggestions as any[]).length < Object.keys(sources).length ) { setContext({ searchSuggestions: Object.keys(sources), }); } return Object.values(sources).map((items) => { return { onSelect({ suggestion }) { saveRecentSearch(suggestion); onClose(); }, getSuggestionUrl({ suggestion }) { return suggestion.url; }, getSuggestions() { return Object.values( groupBy(items, (item) => item.hierarchy.lvl1) ) .map(transformItems) .map((hits) => hits.map((item) => { return { ...item, // eslint-disable-next-line @typescript-eslint/camelcase __docsearch_parent: item.type !== 'lvl1' && hits.find( (siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1 ), }; }) ) .flat(); }, }; }); }); }, }), [ indexName, searchParameters, searchClient, onClose, recentSearches, favoriteSearches, saveRecentSearch, initialQuery, placeholder, navigator, transformItems, ] ); const { getEnvironmentProps, getRootProps, refresh } = autocomplete; useTouchEvents({ getEnvironmentProps, dropdownElement: dropdownRef.current, searchBoxElement: searchBoxRef.current, inputElement: inputRef.current, }); useTrapFocus({ container: containerRef.current }); React.useEffect(() => { document.body.classList.add('DocSearch--active'); scrollY.current = window.scrollY; return () => { document.body.classList.remove('DocSearch--active'); document.body.scrollTop = scrollY.current; }; }, []); React.useEffect(() => { const isMobileMediaQuery = window.matchMedia('(max-width: 750px)'); if (isMobileMediaQuery.matches) { snipetLength.current = 5; } }, []); React.useEffect(() => { if (dropdownRef.current) { dropdownRef.current.scrollTop = 0; } }, [state.query]); // We don't focus the input when there's an initial query (i.e. Selection // Search) because users rather want to see the results directly, without the // keyboard appearing. // We therefore need to refresh the autocomplete instance to load all the // results, which is usually triggered on focus. React.useEffect(() => { if (initialQuery.length > 0) { refresh(); } }, [initialQuery, refresh]); return (
{ if (event.target === event.currentTarget) { onClose(); } }} >
{ saveRecentSearch(item); onClose(); }} inputRef={inputRef} />
); }