import type {
DocSearchHit,
DocSearchModalProps,
DocSearchModal as DocSearchModalType,
DocSearchTransformClient,
DocSearchTranslations,
InternalDocSearchHit,
StoredDocSearchHit,
UseDocSearchKeyboardEventsProps,
} from '@docsearch/react';
import { useDocSearchKeyboardEvents } from '@docsearch/react/useDocSearchKeyboardEvents';
import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link';
import { useHistory } from '@docusaurus/router';
import { isRegexpStringMatch, useSearchLinkCreator } from '@docusaurus/theme-common';
import {
mergeFacetFilters,
useAlgoliaAskAi,
useAlgoliaContextualFacetFilters,
useSearchResultUrlProcessor,
} from '@docusaurus/theme-search-algolia/client';
import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import { IconSearch } from '@tabler/icons-react';
import translations from '@theme/SearchTranslations';
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import './styles.css';
import type { AutocompleteState } from '@algolia/autocomplete-core';
import type { ThemeConfigAlgolia } from '@docusaurus/theme-search-algolia';
import type { FacetFilters } from 'algoliasearch/lite';
type DocSearchProps = Omit<DocSearchModalProps, 'onClose' | 'initialScrollY'> & {
contextualSearch?: string;
externalUrlRegex?: string;
searchPagePath: boolean | string;
askAi?: Exclude<(DocSearchModalProps & { askAi: unknown })['askAi'], string | undefined>;
};
// extend DocSearchProps for v4 features
// TODO Docusaurus v4: cleanup after we drop support for DocSearch v3
interface DocSearchV4Props extends DocSearchProps {
indexName: string;
askAi?: ThemeConfigAlgolia['askAi'];
translations?: DocSearchTranslations;
}
let DocSearchModal: typeof DocSearchModalType | null = null;
function importDocSearchModalIfNeeded() {
if (DocSearchModal) {
return Promise.resolve();
}
return Promise.all([import('@docsearch/react/modal'), import('@docsearch/react/style'), import('./styles.css')]).then(
([{ DocSearchModal: Modal }]) => {
DocSearchModal = Modal;
}
);
}
function useNavigator({ externalUrlRegex }: Pick<DocSearchProps, 'externalUrlRegex'>) {
const history = useHistory();
const [navigator] = useState<DocSearchModalProps['navigator']>(() => {
return {
navigate(params) {
// Algolia results could contain URL's from other domains which cannot
// be served through history and should navigate with window.location
if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) {
window.location.href = params.itemUrl;
} else {
history.push(params.itemUrl);
}
},
};
});
return navigator;
}
function useTransformSearchClient(): DocSearchModalProps['transformSearchClient'] {
const {
siteMetadata: { docusaurusVersion },
} = useDocusaurusContext();
return useCallback(
(searchClient: DocSearchTransformClient) => {
searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);
return searchClient;
},
[docusaurusVersion]
);
}
function useTransformItems(props: Pick<DocSearchProps, 'transformItems'>) {
const processSearchResultUrl = useSearchResultUrlProcessor();
const [transformItems] = useState<DocSearchModalProps['transformItems']>(() => {
return (items: DocSearchHit[]) =>
props.transformItems
? // Custom transformItems
props.transformItems(items)
: // Default transformItems
items.map((item) => ({
...item,
url: processSearchResultUrl(item.url),
}));
});
return transformItems;
}
function useResultsFooterComponent({
closeModal,
}: {
closeModal: () => void;
}): DocSearchProps['resultsFooterComponent'] {
return useMemo(
() =>
({ state }) => <ResultsFooter state={state} onClose={closeModal} />,
[closeModal]
);
}
function Hit({ hit, children }: { hit: InternalDocSearchHit | StoredDocSearchHit; children: ReactNode }) {
return <Link to={hit.url}>{children}</Link>;
}
type ResultsFooterProps = {
state: AutocompleteState<InternalDocSearchHit>;
onClose: () => void;
};
function ResultsFooter({ state, onClose }: ResultsFooterProps) {
const createSearchLink = useSearchLinkCreator();
return (
<Link to={createSearchLink(state.query)} onClick={onClose}>
<Translate id="theme.SearchBar.seeAll" values={{ count: state.context.nbHits }}>
{'See all {count} results'}
</Translate>
</Link>
);
}
function useSearchParameters({ contextualSearch, ...props }: DocSearchProps): DocSearchProps['searchParameters'] {
const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters();
const configFacetFilters: FacetFilters = props.searchParameters?.facetFilters ?? [];
const facetFilters: FacetFilters = contextualSearch
? // Merge contextual search filters with config filters
mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
: // ... or use config facetFilters
configFacetFilters;
// We let users override default searchParameters if they want to
return {
...props.searchParameters,
facetFilters,
};
}
function DocSearch({ externalUrlRegex, ...props }: DocSearchV4Props) {
const navigator = useNavigator({ externalUrlRegex });
const searchParameters = useSearchParameters({ ...props });
const transformItems = useTransformItems(props);
const transformSearchClient = useTransformSearchClient();
const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
const { isAskAiActive, currentPlaceholder, onAskAiToggle, extraAskAiProps } = useAlgoliaAskAi(props);
const prepareSearchContainer = useCallback(() => {
if (!searchContainer.current) {
const divElement = document.createElement('div');
searchContainer.current = divElement;
document.body.insertBefore(divElement, document.body.firstChild);
}
}, []);
const openModal = useCallback(() => {
prepareSearchContainer();
importDocSearchModalIfNeeded().then(() => setIsOpen(true));
}, [prepareSearchContainer]);
const closeModal = useCallback(() => {
setIsOpen(false);
searchButtonRef.current?.focus();
setInitialQuery(undefined);
onAskAiToggle(false);
}, [onAskAiToggle]);
const handleInput = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
// ignore browser's ctrl+f
return;
}
// prevents duplicate key insertion in the modal input
event.preventDefault();
setInitialQuery(event.key);
openModal();
},
[openModal]
);
const resultsFooterComponent = useResultsFooterComponent({ closeModal });
useDocSearchKeyboardEvents({
isOpen,
onOpen: openModal,
onClose: closeModal,
onInput: handleInput,
searchButtonRef,
isAskAiActive: isAskAiActive ?? false,
onAskAiToggle: onAskAiToggle ?? (() => {}),
} satisfies UseDocSearchKeyboardEventsProps & {
// TODO Docusaurus v4: cleanup after we drop support for DocSearch v3
isAskAiActive: boolean;
onAskAiToggle: (askAiToggle: boolean) => void;
} as UseDocSearchKeyboardEventsProps);
return (
<>
<Head>
{/* This hints the browser that the website will load data from Algolia,
and allows it to preconnect to the DocSearch cluster. It makes the first
query faster, especially on mobile. */}
<link rel="preconnect" href={`https://${props.appId}-dsn.algolia.net`} crossOrigin="anonymous" />
</Head>
<button
type="button"
onTouchStart={importDocSearchModalIfNeeded}
onFocus={importDocSearchModalIfNeeded}
onMouseOver={importDocSearchModalIfNeeded}
onClick={openModal}
ref={searchButtonRef}
className="searchButton"
aria-label="Search"
>
<IconSearch size={18} stroke={2} />
</button>
{isOpen &&
DocSearchModal &&
searchContainer.current &&
createPortal(
<DocSearchModal
onClose={closeModal}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
navigator={navigator}
transformItems={transformItems}
hitComponent={Hit}
transformSearchClient={transformSearchClient}
{...(props.searchPagePath && {
resultsFooterComponent,
})}
placeholder={currentPlaceholder}
{...props}
translations={props.translations?.modal ?? translations.modal}
searchParameters={searchParameters}
{...extraAskAiProps}
/>,
searchContainer.current
)}
</>
);
}
export default function SearchBar(): ReactNode {
const { siteConfig } = useDocusaurusContext();
return <DocSearch {...(siteConfig.themeConfig.algolia as DocSearchV4Props)} />;
}