1
0
Fork 0
This commit is contained in:
François Chalifour 2020-09-01 11:34:22 +02:00
commit b93a8be6bb
55 changed files with 2001 additions and 0 deletions

17
README.md Normal file
View file

@ -0,0 +1,17 @@
# @docsearch/react
React package for [DocSearch](http://docsearch.algolia.com/), the best search experience for docs.
[![Percy](https://percy.io/static/images/percy-badge.svg)](https://percy.io/DX/DocSearch)
## Installation
```sh
yarn add @docsearch/react@alpha
# or
npm install @docsearch/react@alpha
```
## Documentation
[Read documentation →](https://autocomplete-experimental.netlify.app/docs/DocSearch)

27
babel.config.js Normal file
View file

@ -0,0 +1,27 @@
/* eslint-disable import/no-commonjs */
module.exports = (api) => {
const isTest = api.env('test');
const modules = isTest ? 'commonjs' : false;
const targets = {};
if (isTest) {
targets.node = true;
} else {
targets.browsers = ['last 2 versions', 'ie >= 9'];
}
return {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
modules,
targets,
},
],
],
plugins: [['@babel/plugin-transform-react-jsx']],
};
};

1
button.js Normal file
View file

@ -0,0 +1 @@
export { DocSearchButton } from './dist/esm/DocSearchButton.js';

1
modal.js Normal file
View file

@ -0,0 +1 @@
export { DocSearchModal } from './dist/esm/DocSearchModal.js';

45
package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "@docsearch/react",
"description": "React package for DocSearch, the best search experience for docs.",
"version": "1.0.0-alpha.28",
"license": "MIT",
"homepage": "https://github.com/francoischalifour/autocomplete.js",
"repository": "francoischalifour/autocomplete.js",
"author": {
"name": "Algolia, Inc.",
"url": "https://www.algolia.com"
},
"sideEffects": false,
"files": [
"dist/",
"style/",
"button.js",
"modal.js"
],
"source": "src/index.ts",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/umd/index.js",
"umd:main": "dist/umd/index.js",
"unpkg": "dist/umd/index.js",
"jsdelivr": "dist/umd/index.js",
"scripts": {
"build": "yarn build:clean && yarn build:umd && yarn build:esm && yarn build:types",
"build:esm": "babel src --root-mode upward --extensions '.ts,.tsx' --out-dir dist/esm",
"build:umd": "rollup --config",
"build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm",
"build:clean": "rm -rf ./dist",
"on:change": "concurrently \"yarn build:esm\" \"yarn build:types\"",
"watch": "watch \"yarn on:change\" --ignoreDirectoryPattern \"/dist/\""
},
"dependencies": {
"@docsearch/css": "^1.0.0-alpha.28",
"@francoischalifour/autocomplete-core": "^1.0.0-alpha.28",
"@francoischalifour/autocomplete-preset-algolia": "^1.0.0-alpha.28",
"algoliasearch": "^4.0.0"
},
"peerDependencies": {
"react": "^16.8.0",
"react-dom": "^16.8.0"
}
}

21
rollup.config.js Normal file
View file

@ -0,0 +1,21 @@
import { plugins } from '../../rollup.base.config';
import { getBundleBanner } from '../../scripts/getBundleBanner';
import pkg from './package.json';
export default {
input: 'src/index.ts',
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
file: 'dist/umd/index.js',
format: 'umd',
sourcemap: true,
name: pkg.name,
banner: getBundleBanner(pkg),
},
plugins,
};

20
src/AlgoliaLogo.tsx Normal file

File diff suppressed because one or more lines are too long

85
src/DocSearch.tsx Normal file
View file

@ -0,0 +1,85 @@
import {
AutocompleteState,
PublicAutocompleteOptions,
} from '@francoischalifour/autocomplete-core';
import React from 'react';
import { createPortal } from 'react-dom';
import { DocSearchButton } from './DocSearchButton';
import { DocSearchModal } from './DocSearchModal';
import {
DocSearchHit,
InternalDocSearchHit,
StoredDocSearchHit,
SearchClient,
} from './types';
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
export interface DocSearchProps
extends Pick<PublicAutocompleteOptions<InternalDocSearchHit>, 'navigator'> {
appId?: string;
apiKey: string;
indexName: string;
placeholder?: string;
searchParameters?: any;
transformItems?(items: DocSearchHit[]): DocSearchHit[];
hitComponent?(props: {
hit: InternalDocSearchHit | StoredDocSearchHit;
children: React.ReactNode;
}): JSX.Element;
resultsFooterComponent?(props: {
state: AutocompleteState<InternalDocSearchHit>;
}): JSX.Element | null;
transformSearchClient?(searchClient: SearchClient): SearchClient;
disableUserPersonalization?: boolean;
initialQuery?: string;
}
export function DocSearch(props: DocSearchProps) {
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [initialQuery, setInitialQuery] = React.useState<string | undefined>(
undefined
);
const onOpen = React.useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
const onClose = React.useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
const onInput = React.useCallback(
(event: KeyboardEvent) => {
setIsOpen(true);
setInitialQuery(event.key);
},
[setIsOpen, setInitialQuery]
);
useDocSearchKeyboardEvents({
isOpen,
onOpen,
onClose,
onInput,
searchButtonRef,
});
return (
<>
<DocSearchButton onClick={onOpen} ref={searchButtonRef} />
{isOpen &&
createPortal(
<DocSearchModal
{...props}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
onClose={onClose}
/>,
document.body
)}
</>
);
}

53
src/DocSearchButton.tsx Normal file
View file

@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { ControlKeyIcon } from './icons/ControlKeyIcon';
import { SearchIcon } from './icons/SearchIcon';
export type DocSearchButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
const ACTION_KEY_DEFAULT = 'Ctrl';
const ACTION_KEY_APPLE = '⌘';
function isAppleDevice() {
if (typeof navigator === 'undefined') {
return ACTION_KEY_DEFAULT;
}
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}
export const DocSearchButton = React.forwardRef<
HTMLButtonElement,
DocSearchButtonProps
>((props, ref) => {
const [key, setKey] = useState(() =>
isAppleDevice() ? ACTION_KEY_APPLE : ACTION_KEY_DEFAULT
);
useEffect(() => {
if (isAppleDevice()) {
setKey(ACTION_KEY_APPLE);
}
}, []);
return (
<button
type="button"
className="DocSearch DocSearch-Button"
aria-label="Search"
{...props}
ref={ref}
>
<SearchIcon />
<span className="DocSearch-Button-Placeholder">Search</span>
<span className="DocSearch-Button-Key">
{key === ACTION_KEY_DEFAULT ? <ControlKeyIcon /> : key}
</span>
<span className="DocSearch-Button-Key">K</span>
</button>
);
});

386
src/DocSearchModal.tsx Normal file
View file

@ -0,0 +1,386 @@
import {
AutocompleteState,
createAutocomplete,
} from '@francoischalifour/autocomplete-core';
import { getAlgoliaResults } from '@francoischalifour/autocomplete-preset-algolia';
import React from 'react';
import { MAX_QUERY_SIZE } from './constants';
import { DocSearchProps } from './DocSearch';
import { Footer } from './Footer';
import { Hit } from './Hit';
import { ScreenState } from './ScreenState';
import { SearchBox } from './SearchBox';
import { createStoredSearches } from './stored-searches';
import {
DocSearchHit,
InternalDocSearchHit,
StoredDocSearchHit,
} from './types';
import { useSearchClient } from './useSearchClient';
import { useTouchEvents } from './useTouchEvents';
import { useTrapFocus } from './useTrapFocus';
import { groupBy, identity, noop } from './utils';
export interface DocSearchModalProps extends DocSearchProps {
initialScrollY: number;
onClose?(): void;
}
export function DocSearchModal({
appId = 'BH4D9OD16A',
apiKey,
indexName,
placeholder = 'Search docs',
searchParameters,
onClose = noop,
transformItems = identity,
hitComponent = Hit,
resultsFooterComponent = () => null,
navigator,
initialScrollY = 0,
transformSearchClient = identity,
disableUserPersonalization = false,
initialQuery: initialQueryFromProp = '',
}: DocSearchModalProps) {
const [state, setState] = React.useState<
AutocompleteState<InternalDocSearchHit>
>({
query: '',
suggestions: [],
} as any);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const searchBoxRef = React.useRef<HTMLDivElement | null>(null);
const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const snippetLength = React.useRef<number>(10);
const initialQuery = React.useRef(
initialQueryFromProp || typeof window !== 'undefined'
? window.getSelection()!.toString().slice(0, MAX_QUERY_SIZE)
: ''
).current;
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
const favoriteSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`,
limit: 10,
})
).current;
const recentSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
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) {
if (disableUserPersonalization) {
return;
}
// 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, disableUserPersonalization]
);
const autocomplete = React.useMemo(
() =>
createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>({
id: 'docsearch',
defaultHighlightedIndex: 0,
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) {
if (disableUserPersonalization) {
return [];
}
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 getAlgoliaResults({
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:${snippetLength.current}`,
`hierarchy.lvl2:${snippetLength.current}`,
`hierarchy.lvl3:${snippetLength.current}`,
`hierarchy.lvl4:${snippetLength.current}`,
`hierarchy.lvl5:${snippetLength.current}`,
`hierarchy.lvl6:${snippetLength.current}`,
`content:${snippetLength.current}`,
],
snippetEllipsisText: '…',
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
hitsPerPage: 20,
...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((results) => {
const hits: DocSearchHit[] = results[0].hits;
const nbHits: number = results[0].nbHits;
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),
});
}
setContext({ nbHits });
return Object.values<DocSearchHit[]>(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,
disableUserPersonalization,
]
);
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');
return () => {
document.body.classList.remove('DocSearch--active');
// IE11 doesn't support `scrollTo` so we check that the method exists
// first.
window.scrollTo?.(0, initialScrollY);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const isMobileMediaQuery = window.matchMedia('(max-width: 750px)');
if (isMobileMediaQuery.matches) {
snippetLength.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();
if (inputRef.current) {
inputRef.current.focus();
}
}
}, [initialQuery, refresh]);
return (
<div
ref={containerRef}
{...getRootProps({
'aria-expanded': true,
})}
className={[
'DocSearch',
'DocSearch-Container',
state.status === 'stalled' && 'DocSearch-Container--Stalled',
state.status === 'error' && 'DocSearch-Container--Errored',
]
.filter(Boolean)
.join(' ')}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div className="DocSearch-Modal">
<header className="DocSearch-SearchBar" ref={searchBoxRef}>
<SearchBox
{...autocomplete}
state={state}
autoFocus={initialQuery.length === 0}
onClose={onClose}
inputRef={inputRef}
/>
</header>
<div className="DocSearch-Dropdown" ref={dropdownRef}>
<ScreenState
{...autocomplete}
indexName={indexName}
state={state}
hitComponent={hitComponent}
resultsFooterComponent={resultsFooterComponent}
disableUserPersonalization={disableUserPersonalization}
recentSearches={recentSearches}
favoriteSearches={favoriteSearches}
onItemClick={(item) => {
saveRecentSearch(item);
onClose();
}}
inputRef={inputRef}
/>
</div>
<footer className="DocSearch-Footer">
<Footer />
</footer>
</div>
</div>
);
}

17
src/ErrorScreen.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from 'react';
import { ErrorIcon } from './icons';
export function ErrorScreen() {
return (
<div className="DocSearch-ErrorScreen">
<div className="DocSearch-Screen-Icon">
<ErrorIcon />
</div>
<p className="DocSearch-Title">Unable to fetch results</p>
<p className="DocSearch-Help">
You might want to check your network connection.
</p>
</div>
);
}

64
src/Footer.tsx Normal file
View file

@ -0,0 +1,64 @@
import React from 'react';
import { AlgoliaLogo } from './AlgoliaLogo';
export function Footer() {
return (
<>
<div className="DocSearch-Logo">
<AlgoliaLogo />
</div>
<ul className="DocSearch-Commands">
<li>
<span className="DocSearch-Commands-Key">
<CommandIcon>
<path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" />
</CommandIcon>
</span>
<span className="DocSearch-Label">to select</span>
</li>
<li>
<span className="DocSearch-Commands-Key">
<CommandIcon>
<path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" />
</CommandIcon>
</span>
<span className="DocSearch-Commands-Key">
<CommandIcon>
<path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" />
</CommandIcon>
</span>
<span className="DocSearch-Label">to navigate</span>
</li>
<li>
<span className="DocSearch-Commands-Key">
<CommandIcon>
<path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" />
</CommandIcon>
</span>
<span className="DocSearch-Label">to close</span>
</li>
</ul>
</>
);
}
interface CommandIconProps {
children: React.ReactNode;
}
function CommandIcon(props: CommandIconProps) {
return (
<svg width="15" height="15">
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.2"
>
{props.children}
</g>
</svg>
);
}

12
src/Hit.tsx Normal file
View file

@ -0,0 +1,12 @@
import React from 'react';
import { DocSearchHit } from './types';
interface HitProps {
hit: DocSearchHit;
children: React.ReactNode;
}
export function Hit({ hit, children }: HitProps) {
return <a href={hit.url}>{children}</a>;
}

62
src/NoResultsScreen.tsx Normal file
View file

@ -0,0 +1,62 @@
import React from 'react';
import { NoResultsIcon } from './icons';
import { ScreenStateProps } from './ScreenState';
import { InternalDocSearchHit } from './types';
type NoResultsScreenProps = ScreenStateProps<InternalDocSearchHit>;
export function NoResultsScreen(props: NoResultsScreenProps) {
const searchSuggestions: string[] | undefined = props.state.context
.searchSuggestions as string[];
return (
<div className="DocSearch-NoResults">
<div className="DocSearch-Screen-Icon">
<NoResultsIcon />
</div>
<p className="DocSearch-Title">
No results for "<strong>{props.state.query}</strong>"
</p>
{searchSuggestions && searchSuggestions.length > 0 && (
<div className="DocSearch-NoResults-Prefill-List">
<p className="DocSearch-Help">Try searching for:</p>
<ul>
{searchSuggestions.slice(0, 3).reduce<React.ReactNode[]>(
(acc, search) => [
...acc,
<li key={search}>
<button
className="DocSearch-Prefill"
key={search}
onClick={() => {
props.setQuery(search.toLowerCase() + ' ');
props.refresh();
props.inputRef.current!.focus();
}}
>
{search}
</button>
</li>,
],
[]
)}
</ul>
</div>
)}
<p className="DocSearch-Help">
Believe this query should return results?{' '}
<a
href={`https://github.com/algolia/docsearch-configs/issues/new?template=Missing_results.md&title=[${props.indexName}]+Missing+results+for+query+"${props.state.query}"`}
target="_blank"
rel="noopener noreferrer"
>
Let us know
</a>
.
</p>
</div>
);
}

172
src/Results.tsx Normal file
View file

@ -0,0 +1,172 @@
import {
AutocompleteApi,
AutocompleteState,
} from '@francoischalifour/autocomplete-core';
import React from 'react';
import { DocSearchProps } from './DocSearch';
import { Snippet } from './Snippet';
import { InternalDocSearchHit, StoredDocSearchHit } from './types';
interface ResultsProps<TItem>
extends AutocompleteApi<
TItem,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
title: string;
suggestion: AutocompleteState<TItem>['suggestions'][0];
renderIcon(props: { item: TItem; index: number }): React.ReactNode;
renderAction(props: {
item: TItem;
runDeleteTransition: (cb: () => void) => void;
runFavoriteTransition: (cb: () => void) => void;
}): React.ReactNode;
onItemClick(item: TItem): void;
hitComponent: DocSearchProps['hitComponent'];
}
export function Results<TItem extends StoredDocSearchHit>(
props: ResultsProps<TItem>
) {
if (!props.suggestion || props.suggestion.items.length === 0) {
return null;
}
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">{props.title}</div>
<ul {...props.getMenuProps()}>
{props.suggestion.items.map((item, index) => {
return (
<Result
key={[props.title, item.objectID].join(':')}
item={item}
index={index}
{...props}
/>
);
})}
</ul>
</section>
);
}
interface ResultProps<TItem> extends ResultsProps<TItem> {
item: TItem;
index: number;
}
function Result<TItem extends StoredDocSearchHit>({
item,
index,
renderIcon,
renderAction,
getItemProps,
onItemClick,
suggestion,
hitComponent,
}: ResultProps<TItem>) {
const [isDeleting, setIsDeleting] = React.useState(false);
const [isFavoriting, setIsFavoriting] = React.useState(false);
const action = React.useRef<(() => void) | null>(null);
const Hit = hitComponent!;
function runDeleteTransition(cb: () => void) {
setIsDeleting(true);
action.current = cb;
}
function runFavoriteTransition(cb: () => void) {
setIsFavoriting(true);
action.current = cb;
}
return (
<li
className={[
'DocSearch-Hit',
((item as unknown) as InternalDocSearchHit).__docsearch_parent &&
'DocSearch-Hit--Child',
isDeleting && 'DocSearch-Hit--deleting',
isFavoriting && 'DocSearch-Hit--favoriting',
]
.filter(Boolean)
.join(' ')}
onTransitionEnd={() => {
if (action.current) {
action.current();
}
}}
{...getItemProps({
item,
source: suggestion.source,
onClick() {
onItemClick(item);
},
})}
>
<Hit hit={item}>
<div className="DocSearch-Hit-Container">
{renderIcon({ item, index })}
{item.hierarchy[item.type] && item.type === 'lvl1' && (
<div className="DocSearch-Hit-content-wrapper">
<Snippet
className="DocSearch-Hit-title"
hit={item}
attribute="hierarchy.lvl1"
/>
{item.content && (
<Snippet
className="DocSearch-Hit-path"
hit={item}
attribute="content"
/>
)}
</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">
<Snippet
className="DocSearch-Hit-title"
hit={item}
attribute={`hierarchy.${item.type}`}
/>
<Snippet
className="DocSearch-Hit-path"
hit={item}
attribute="hierarchy.lvl1"
/>
</div>
)}
{item.type === 'content' && (
<div className="DocSearch-Hit-content-wrapper">
<Snippet
className="DocSearch-Hit-title"
hit={item}
attribute="content"
/>
<Snippet
className="DocSearch-Hit-path"
hit={item}
attribute="hierarchy.lvl1"
/>
</div>
)}
{renderAction({ item, runDeleteTransition, runFavoriteTransition })}
</div>
</Hit>
</li>
);
}

68
src/ResultsScreen.tsx Normal file
View file

@ -0,0 +1,68 @@
import React from 'react';
import { SelectIcon, SourceIcon } from './icons';
import { Results } from './Results';
import { ScreenStateProps } from './ScreenState';
import { InternalDocSearchHit } from './types';
type ResultsScreenProps = ScreenStateProps<InternalDocSearchHit>;
export function ResultsScreen(props: ResultsScreenProps) {
return (
<div className="DocSearch-Dropdown-Container">
{props.state.suggestions.map((suggestion, index) => {
if (suggestion.items.length === 0) {
return null;
}
const title = suggestion.items[0].hierarchy.lvl0;
return (
<Results
{...props}
key={index}
title={title}
suggestion={suggestion}
renderIcon={({ item, index }) => (
<>
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree" viewBox="0 0 24 54">
<g
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !==
suggestion.items[index + 1]?.__docsearch_parent ? (
<path d="M8 6v21M20 27H8.3" />
) : (
<path d="M8 6v42M20 27H8.3" />
)}
</g>
</svg>
)}
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
</>
)}
renderAction={() => (
<div className="DocSearch-Hit-action">
<SelectIcon />
</div>
)}
/>
);
})}
{props.resultsFooterComponent && (
<section className="DocSearch-HitsFooter">
<props.resultsFooterComponent state={props.state} />
</section>
)}
</div>
);
}

63
src/ScreenState.tsx Normal file
View file

@ -0,0 +1,63 @@
import {
AutocompleteApi,
AutocompleteState,
} from '@francoischalifour/autocomplete-core';
import React from 'react';
import { DocSearchProps } from './DocSearch';
import { ErrorScreen } from './ErrorScreen';
import { NoResultsScreen } from './NoResultsScreen';
import { ResultsScreen } from './ResultsScreen';
import { StartScreen } from './StartScreen';
import { StoredSearchPlugin } from './stored-searches';
import { InternalDocSearchHit, StoredDocSearchHit } from './types';
export interface ScreenStateProps<TItem>
extends AutocompleteApi<
TItem,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
state: AutocompleteState<TItem>;
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
onItemClick(item: InternalDocSearchHit): void;
inputRef: React.MutableRefObject<null | HTMLInputElement>;
hitComponent: DocSearchProps['hitComponent'];
indexName: DocSearchProps['indexName'];
disableUserPersonalization: boolean;
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
}
export const ScreenState = React.memo(
(props: ScreenStateProps<InternalDocSearchHit>) => {
if (props.state.status === 'error') {
return <ErrorScreen />;
}
const hasSuggestions = props.state.suggestions.some(
(suggestion) => suggestion.items.length > 0
);
if (!props.state.query) {
return <StartScreen {...props} hasSuggestions={hasSuggestions} />;
}
if (hasSuggestions === false) {
return <NoResultsScreen {...props} />;
}
return <ResultsScreen {...props} />;
},
function areEqual(_prevProps, nextProps) {
// We don't update the screen when Autocomplete is loading or stalled to
// avoid UI flashes:
// - Empty screen → Results screen
// - NoResults screen → NoResults screen with another query
return (
nextProps.state.status === 'loading' ||
nextProps.state.status === 'stalled'
);
}
);

84
src/SearchBox.tsx Normal file
View file

@ -0,0 +1,84 @@
import {
AutocompleteApi,
AutocompleteState,
} from '@francoischalifour/autocomplete-core';
import React, { MutableRefObject } from 'react';
import { MAX_QUERY_SIZE } from './constants';
import { LoadingIcon } from './icons/LoadingIcon';
import { ResetIcon } from './icons/ResetIcon';
import { SearchIcon } from './icons/SearchIcon';
import { InternalDocSearchHit } from './types';
interface SearchBoxProps
extends AutocompleteApi<
InternalDocSearchHit,
React.FormEvent,
React.MouseEvent,
React.KeyboardEvent
> {
state: AutocompleteState<InternalDocSearchHit>;
autoFocus: boolean;
inputRef: MutableRefObject<HTMLInputElement | null>;
onClose(): void;
}
export function SearchBox(props: SearchBoxProps) {
const { onReset } = props.getFormProps({
inputElement: props.inputRef.current,
});
React.useEffect(() => {
if (props.autoFocus && props.inputRef.current) {
props.inputRef.current.focus();
}
}, [props.autoFocus, props.inputRef]);
return (
<>
<form
action=""
role="search"
noValidate
className="DocSearch-Form"
onSubmit={(event) => {
event.preventDefault();
}}
onReset={onReset}
>
<label className="DocSearch-MagnifierLabel" {...props.getLabelProps()}>
<SearchIcon />
</label>
<div className="DocSearch-LoadingIndicator">
<LoadingIcon />
</div>
<input
className="DocSearch-Input"
ref={props.inputRef}
{...props.getInputProps({
inputElement: props.inputRef.current!,
autoFocus: props.autoFocus,
maxLength: MAX_QUERY_SIZE,
enterkeyhint: 'go',
})}
/>
<button
type="reset"
title="Clear the query"
className="DocSearch-Reset"
hidden={!props.state.query}
onClick={onReset}
>
<ResetIcon />
</button>
</form>
<button className="DocSearch-Cancel" onClick={props.onClose}>
Cancel
</button>
</>
);
}

32
src/Snippet.tsx Normal file
View file

@ -0,0 +1,32 @@
import { createElement } from 'react';
import { StoredDocSearchHit } from './types';
function getPropertyByPath(object: object, path: string): any {
const parts = path.split('.');
return parts.reduce((current, key) => current && current[key], object);
}
interface SnippetProps<TItem> {
[prop: string]: unknown;
hit: TItem;
attribute: string;
tagName?: string;
}
export function Snippet<TItem extends StoredDocSearchHit>({
hit,
attribute,
tagName = 'span',
...rest
}: SnippetProps<TItem>) {
return createElement(tagName, {
...rest,
dangerouslySetInnerHTML: {
__html:
getPropertyByPath(hit, `_snippetResult.${attribute}.value`) ||
getPropertyByPath(hit, attribute),
},
});
}

113
src/StartScreen.tsx Normal file
View file

@ -0,0 +1,113 @@
import React from 'react';
import { RecentIcon, ResetIcon, StarIcon } from './icons';
import { Results } from './Results';
import { ScreenStateProps } from './ScreenState';
import { InternalDocSearchHit } from './types';
interface StartScreenProps extends ScreenStateProps<InternalDocSearchHit> {
hasSuggestions: boolean;
}
export function StartScreen(props: StartScreenProps) {
if (props.state.status === 'idle' && props.hasSuggestions === false) {
if (props.disableUserPersonalization) {
return null;
}
return (
<div className="DocSearch-StartScreen">
<p className="DocSearch-Help">No recent searches</p>
</div>
);
}
if (props.hasSuggestions === false) {
return null;
}
return (
<div className="DocSearch-Dropdown-Container">
<Results
{...props}
title="Recent"
suggestion={props.state.suggestions[0]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<RecentIcon />
</div>
)}
renderAction={({
item,
runFavoriteTransition,
runDeleteTransition,
}) => (
<>
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title="Save this search"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
runFavoriteTransition(() => {
props.favoriteSearches.add(item);
props.recentSearches.remove(item);
props.refresh();
});
}}
>
<StarIcon />
</button>
</div>
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title="Remove this search from history"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
runDeleteTransition(() => {
props.recentSearches.remove(item);
props.refresh();
});
}}
>
<ResetIcon />
</button>
</div>
</>
)}
/>
<Results
{...props}
title="Favorites"
suggestion={props.state.suggestions[1]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<StarIcon />
</div>
)}
renderAction={({ item, runDeleteTransition }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title="Remove this search from favorites"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
runDeleteTransition(() => {
props.favoriteSearches.remove(item);
props.refresh();
});
}}
>
<ResetIcon />
</button>
</div>
)}
/>
</div>
);
}

1
src/constants.ts Normal file
View file

@ -0,0 +1 @@
export const MAX_QUERY_SIZE = 64;

View file

@ -0,0 +1,15 @@
import React from 'react';
export function ControlKeyIcon() {
return (
<svg width="15" height="15" className="DocSearch-Control-Key-Icon">
<path
d="M4.505 4.496h2M5.505 5.496v5M8.216 4.496l.055 5.993M10 7.5c.333.333.5.667.5 1v2M12.326 4.5v5.996M8.384 4.496c1.674 0 2.116 0 2.116 1.5s-.442 1.5-2.116 1.5M3.205 9.303c-.09.448-.277 1.21-1.241 1.203C1 10.5.5 9.513.5 8V7c0-1.57.5-2.5 1.464-2.494.964.006 1.134.598 1.24 1.342M12.553 10.5h1.953"
strokeWidth="1.2"
stroke="currentColor"
fill="none"
strokeLinecap="square"
/>
</svg>
);
}

18
src/icons/ErrorIcon.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
export function ErrorIcon() {
return (
<svg
width="40"
height="40"
viewBox="0 0 20 20"
fill="none"
fillRule="evenodd"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 4.8a16 16 0 00-2-1.2m-3.3-1.2A16 16 0 001.1 4.7M16.7 8a12 12 0 00-2.8-1.4M10 6a12 12 0 00-6.7 2M12.3 14.7a4 4 0 00-4.5 0M14.5 11.4A8 8 0 0010 10M3 16L18 2M10 18h0"></path>
</svg>
);
}

View file

@ -0,0 +1,13 @@
import React from 'react';
export function GoToExternal() {
return (
<svg width="20" height="20">
<path
d="M5 6v9h9v-3a1 1 0 112 0v4l-1 1H4l-1-1V5l1-1h4a1 1 0 110 2H5zm5 5a1 1 0 11-1-1l5-6h-3a1 1 0 110-2h6a1 1 0 011 1v6a1 1 0 11-2 0V6l-6 5z"
fill="currentColor"
fillRule="nonzero"
/>
</svg>
);
}

23
src/icons/LoadingIcon.tsx Normal file
View file

@ -0,0 +1,23 @@
import React from 'react';
export function LoadingIcon() {
return (
<svg viewBox="0 0 38 38" stroke="currentColor" strokeOpacity=".5">
<g fill="none" fillRule="evenodd">
<g transform="translate(1 1)" strokeWidth="2">
<circle strokeOpacity=".3" cx="18" cy="18" r="18" />
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</g>
</svg>
);
}

View file

@ -0,0 +1,18 @@
import React from 'react';
export function NoResultsIcon() {
return (
<svg
width="40"
height="40"
viewBox="0 0 20 20"
fill="none"
fillRule="evenodd"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15.5 4.8c2 3 1.7 7-1 9.7h0l4.3 4.3-4.3-4.3a7.8 7.8 0 01-9.8 1m-2.2-2.2A7.8 7.8 0 0113.2 2.4M2 18L18 2"></path>
</svg>
);
}

18
src/icons/RecentIcon.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from 'react';
export function RecentIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<g
stroke="currentColor"
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>
);
}

16
src/icons/ResetIcon.tsx Normal file
View file

@ -0,0 +1,16 @@
import React from 'react';
export function ResetIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M10 10l5.09-5.09L10 10l5.09 5.09L10 10zm0 0L4.91 4.91 10 10l-5.09 5.09L10 10z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

21
src/icons/SearchIcon.tsx Normal file
View file

@ -0,0 +1,21 @@
import React from 'react';
export function SearchIcon() {
return (
<svg
width="20"
height="20"
className="DocSearch-Search-Icon"
viewBox="0 0 20 20"
>
<path
d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

23
src/icons/SelectIcon.tsx Normal file
View file

@ -0,0 +1,23 @@
import React from 'react';
export function SelectIcon() {
return (
<svg
className="DocSearch-Hit-Select-Icon"
width="20"
height="20"
viewBox="0 0 20 20"
>
<g
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 3v4c0 2-2 4-4 4H2" />
<path d="M8 17l-6-6 6-6" />
</g>
</svg>
);
}

55
src/icons/SourceIcon.tsx Normal file
View file

@ -0,0 +1,55 @@
import React from 'react';
export function SourceIcon(props: { type: string }) {
switch (props.type) {
case 'lvl1':
return <LvlIcon />;
case 'content':
return <ContentIcon />;
default:
return <AnchorIcon />;
}
}
function LvlIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M17 6v12c0 .52-.2 1-1 1H4c-.7 0-1-.33-1-1V2c0-.55.42-1 1-1h8l5 5zM14 8h-3.13c-.51 0-.87-.34-.87-.87V4"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinejoin="round"
/>
</svg>
);
}
function AnchorIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M13 13h4-4V8H7v5h6v4-4H7V8H3h4V3v5h6V3v5h4-4v5zm-6 0v4-4H3h4z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ContentIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M17 5H3h14zm0 5H3h14zm0 5H3h14z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinejoin="round"
/>
</svg>
);
}

15
src/icons/StarIcon.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
export function StarIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path
d="M10 14.2L5 17l1-5.6-4-4 5.5-.7 2.5-5 2.5 5 5.6.8-4 4 .9 5.5z"
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinejoin="round"
/>
</svg>
);
}

10
src/icons/index.ts Normal file
View file

@ -0,0 +1,10 @@
export * from './GoToExternalIcon';
export * from './LoadingIcon';
export * from './RecentIcon';
export * from './ResetIcon';
export * from './SearchIcon';
export * from './SelectIcon';
export * from './SourceIcon';
export * from './StarIcon';
export * from './ErrorIcon';
export * from './NoResultsIcon';

5
src/index.ts Normal file
View file

@ -0,0 +1,5 @@
export * from './DocSearch';
export * from './DocSearchButton';
export * from './DocSearchModal';
export * from './useDocSearchKeyboardEvents';
export * from './version';

86
src/stored-searches.ts Normal file
View file

@ -0,0 +1,86 @@
import { DocSearchHit, StoredDocSearchHit } from './types';
function isLocalStorageSupported() {
const key = '__TEST_KEY__';
try {
localStorage.setItem(key, '');
localStorage.removeItem(key);
return true;
} catch (error) {
return false;
}
}
function createStorage<TItem>(key: string) {
if (isLocalStorageSupported() === false) {
return {
setItem() {},
getItem() {
return [];
},
};
}
return {
setItem(item: TItem[]) {
return window.localStorage.setItem(key, JSON.stringify(item));
},
getItem(): TItem[] {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : [];
},
};
}
type CreateStoredSearchesOptions = {
key: string;
limit?: number;
};
export type StoredSearchPlugin<TItem> = {
add(item: TItem): void;
remove(item: TItem): void;
getAll(): TItem[];
};
export function createStoredSearches<TItem extends StoredDocSearchHit>({
key,
limit = 5,
}: CreateStoredSearchesOptions): StoredSearchPlugin<TItem> {
const storage = createStorage<TItem>(key);
let items = storage.getItem().slice(0, limit);
return {
add(item: TItem) {
const {
_highlightResult,
_snippetResult,
...hit
} = (item as unknown) as DocSearchHit;
const isQueryAlreadySaved = items.findIndex(
(x) => x.objectID === hit.objectID
);
if (isQueryAlreadySaved > -1) {
items.splice(isQueryAlreadySaved, 1);
}
items.unshift(hit as TItem);
items = items.slice(0, limit);
storage.setItem(items);
},
remove(item: TItem) {
items = items.filter((x) => x.objectID !== item.objectID);
storage.setItem(items);
},
getAll() {
return items;
},
};
}

81
src/types/DocSearchHit.ts Normal file
View file

@ -0,0 +1,81 @@
type ContentType =
| 'content'
| 'lvl0'
| 'lvl1'
| 'lvl2'
| 'lvl3'
| 'lvl4'
| 'lvl5'
| 'lvl6';
interface DocSearchHitAttributeHighlightResult {
value: string;
matchLevel: 'none' | 'partial' | 'full';
matchedWords: string[];
fullyHighlighted?: boolean;
}
interface DocSearchHitHighlightResultHierarchy {
lvl0: DocSearchHitAttributeHighlightResult;
lvl1: DocSearchHitAttributeHighlightResult;
lvl2: DocSearchHitAttributeHighlightResult;
lvl3: DocSearchHitAttributeHighlightResult;
lvl4: DocSearchHitAttributeHighlightResult;
lvl5: DocSearchHitAttributeHighlightResult;
lvl6: DocSearchHitAttributeHighlightResult;
}
interface DocSearchHitHighlightResult {
content: DocSearchHitAttributeHighlightResult;
hierarchy: DocSearchHitHighlightResultHierarchy;
hierarchy_camel: DocSearchHitHighlightResultHierarchy[];
}
interface DocSearchHitAttributeSnippetResult {
value: string;
matchLevel: 'none' | 'partial' | 'full';
}
interface DocSearchHitSnippetResult {
content: DocSearchHitAttributeSnippetResult;
hierarchy: DocSearchHitHighlightResultHierarchy;
hierarchy_camel: DocSearchHitHighlightResultHierarchy[];
}
export interface DocSearchHit {
objectID: string;
content: string | null;
url: string;
url_without_anchor: string;
type: ContentType;
anchor: string | null;
hierarchy: {
lvl0: string;
lvl1: string;
lvl2: string | null;
lvl3: string | null;
lvl4: string | null;
lvl5: string | null;
lvl6: string | null;
};
_highlightResult: DocSearchHitHighlightResult;
_snippetResult: DocSearchHitSnippetResult;
_rankingInfo?: {
promoted: boolean;
nbTypos: number;
firstMatchedWord: number;
proximityDistance?: number;
geoDistance: number;
geoPrecision?: number;
nbExactWords: number;
words: number;
filters: number;
userScore: number;
matchedGeoLocation?: {
lat: number;
lng: number;
distance: number;
};
};
_distinctSeqID?: number;
}

View file

@ -0,0 +1,5 @@
import { DocSearchHit } from './DocSearchHit';
export type InternalDocSearchHit = DocSearchHit & {
__docsearch_parent: null | InternalDocSearchHit;
};

View file

@ -0,0 +1 @@
export type SearchClient = any;

View file

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

4
src/types/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from './DocSearchHit';
export * from './InternalDocSearchHit';
export * from './SearchClient';
export * from './StoredDocSearchHit';

View file

@ -0,0 +1,73 @@
import React from 'react';
export interface UseDocSearchKeyboardEventsProps {
isOpen: boolean;
onOpen(): void;
onClose(): void;
onInput?(event: KeyboardEvent): void;
searchButtonRef?: React.RefObject<HTMLButtonElement>;
}
function isEditingContent(event: KeyboardEvent): boolean {
const element = event.target as HTMLElement;
const tagName = element.tagName;
return (
element.isContentEditable ||
tagName === 'INPUT' ||
tagName === 'SELECT' ||
tagName === 'TEXTAREA'
);
}
export function useDocSearchKeyboardEvents({
isOpen,
onOpen,
onClose,
onInput,
searchButtonRef,
}: UseDocSearchKeyboardEventsProps) {
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
function open() {
// We check that no other DocSearch modal is showing before opening
// another one.
if (!document.body.classList.contains('DocSearch--active')) {
onOpen();
}
}
if (
(event.keyCode === 27 && isOpen) ||
// The `Cmd+K` shortcut both opens and closes the modal.
(event.key === 'k' && (event.metaKey || event.ctrlKey)) ||
// The `/` shortcut opens but doesn't close the modal because it's
// a character.
(!isEditingContent(event) && event.key === '/' && !isOpen)
) {
event.preventDefault();
if (isOpen) {
onClose();
} else if (!document.body.classList.contains('DocSearch--active')) {
open();
}
}
if (
searchButtonRef &&
searchButtonRef.current === document.activeElement &&
onInput
) {
if (/[a-zA-Z0-9]/.test(String.fromCharCode(event.keyCode))) {
onInput(event);
}
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [isOpen, onOpen, onClose, onInput, searchButtonRef]);
}

31
src/useSearchClient.ts Normal file
View file

@ -0,0 +1,31 @@
import algoliasearch from 'algoliasearch/dist/algoliasearch-lite.esm.browser';
import React from 'react';
import { SearchClient } from './types';
import { version } from './version';
export function useSearchClient(
appId: string,
apiKey: string,
transformSearchClient: (searchClient: SearchClient) => SearchClient
): SearchClient {
const searchClient = React.useMemo(() => {
const client = algoliasearch(appId, apiKey);
client.addAlgoliaAgent('docsearch', version);
// Since DocSearch.js relies on DocSearch React with an alias to Preact,
// we cannot add the `docsearch-react` user agent by default, otherwise
// it would also be sent on a DocSearch.js integration.
// We therefore only add the `docsearch-react` user agent if `docsearch.js`
// is not present.
if (
/docsearch.js \(.*\)/.test(client.transporter.userAgent.value) === false
) {
client.addAlgoliaAgent('docsearch-react', version);
}
return transformSearchClient(client);
}, [appId, apiKey, transformSearchClient]);
return searchClient;
}

36
src/useTouchEvents.ts Normal file
View file

@ -0,0 +1,36 @@
import { AutocompleteApi } from '@francoischalifour/autocomplete-core';
import React from 'react';
interface UseTouchEventsProps {
getEnvironmentProps: AutocompleteApi<any>['getEnvironmentProps'];
dropdownElement: HTMLDivElement | null;
searchBoxElement: HTMLDivElement | null;
inputElement: HTMLInputElement | null;
}
export function useTouchEvents({
getEnvironmentProps,
dropdownElement,
searchBoxElement,
inputElement,
}: UseTouchEventsProps) {
React.useEffect(() => {
if (!(dropdownElement && searchBoxElement && inputElement)) {
return undefined;
}
const { onTouchStart, onTouchMove } = getEnvironmentProps({
dropdownElement,
searchBoxElement,
inputElement,
});
window.addEventListener('touchstart', onTouchStart);
window.addEventListener('touchmove', onTouchMove);
return () => {
window.removeEventListener('touchstart', onTouchStart);
window.removeEventListener('touchmove', onTouchMove);
};
}, [getEnvironmentProps, dropdownElement, searchBoxElement, inputElement]);
}

41
src/useTrapFocus.ts Normal file
View file

@ -0,0 +1,41 @@
import React from 'react';
interface UseTrapFocusProps {
container: HTMLElement | null;
}
export function useTrapFocus({ container }: UseTrapFocusProps) {
React.useEffect(() => {
if (!container) {
return undefined;
}
const focusableElements = container.querySelectorAll<HTMLElement>(
'a[href]:not([disabled]), button:not([disabled]), input:not([disabled])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
function trapFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') {
return;
}
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
container.addEventListener('keydown', trapFocus);
return () => {
container.removeEventListener('keydown', trapFocus);
};
}, [container]);
}

20
src/utils/groupBy.ts Normal file
View file

@ -0,0 +1,20 @@
export function groupBy<TValue extends object>(
values: TValue[],
predicate: (value: TValue) => string
): Record<string, TValue[]> {
return values.reduce<Record<string, TValue[]>>((acc, item) => {
const key = predicate(item);
if (!acc.hasOwnProperty(key)) {
acc[key] = [];
}
// We limit each section to show 5 hits maximum.
// This acts as a frontend alternative to `distinct`.
if (acc[key].length < 5) {
acc[key].push(item);
}
return acc;
}, {});
}

3
src/utils/identity.ts Normal file
View file

@ -0,0 +1,3 @@
export function identity<TParam>(x: TParam): TParam {
return x;
}

3
src/utils/index.ts Normal file
View file

@ -0,0 +1,3 @@
export * from './groupBy';
export * from './identity';
export * from './noop';

1
src/utils/noop.ts Normal file
View file

@ -0,0 +1 @@
export function noop(..._args: any[]): void {}

1
src/version.ts Normal file
View file

@ -0,0 +1 @@
export const version = '1.0.0-alpha.28';

1
style/button.js Normal file
View file

@ -0,0 +1 @@
export * from '@docsearch/css/dist/button.css';

1
style/index.js Normal file
View file

@ -0,0 +1 @@
export * from '@docsearch/css';

1
style/modal.js Normal file
View file

@ -0,0 +1 @@
export * from '@docsearch/css/dist/modal.css';

1
style/variables.js Normal file
View file

@ -0,0 +1 @@
export * from '@docsearch/css/dist/_variables.css';

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true
}
}

3
tsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "../../tsconfig"
}