Merge branch 'ds-react' of https://github.com/francoischalifour/autocomplete.js
This commit is contained in:
commit
b93a8be6bb
55 changed files with 2001 additions and 0 deletions
17
README.md
Normal file
17
README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# @docsearch/react
|
||||
|
||||
React package for [DocSearch](http://docsearch.algolia.com/), the best search experience for docs.
|
||||
|
||||
[](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
27
babel.config.js
Normal 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
1
button.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DocSearchButton } from './dist/esm/DocSearchButton.js';
|
||||
1
modal.js
Normal file
1
modal.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DocSearchModal } from './dist/esm/DocSearchModal.js';
|
||||
45
package.json
Normal file
45
package.json
Normal 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
21
rollup.config.js
Normal 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
20
src/AlgoliaLogo.tsx
Normal file
File diff suppressed because one or more lines are too long
85
src/DocSearch.tsx
Normal file
85
src/DocSearch.tsx
Normal 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
53
src/DocSearchButton.tsx
Normal 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
386
src/DocSearchModal.tsx
Normal 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
17
src/ErrorScreen.tsx
Normal 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
64
src/Footer.tsx
Normal 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
12
src/Hit.tsx
Normal 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
62
src/NoResultsScreen.tsx
Normal 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
172
src/Results.tsx
Normal 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
68
src/ResultsScreen.tsx
Normal 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
63
src/ScreenState.tsx
Normal 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
84
src/SearchBox.tsx
Normal 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
32
src/Snippet.tsx
Normal 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
113
src/StartScreen.tsx
Normal 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
1
src/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const MAX_QUERY_SIZE = 64;
|
||||
15
src/icons/ControlKeyIcon.tsx
Normal file
15
src/icons/ControlKeyIcon.tsx
Normal 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
18
src/icons/ErrorIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/icons/GoToExternalIcon.tsx
Normal file
13
src/icons/GoToExternalIcon.tsx
Normal 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
23
src/icons/LoadingIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/icons/NoResultsIcon.tsx
Normal file
18
src/icons/NoResultsIcon.tsx
Normal 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
18
src/icons/RecentIcon.tsx
Normal 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
16
src/icons/ResetIcon.tsx
Normal 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
21
src/icons/SearchIcon.tsx
Normal 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
23
src/icons/SelectIcon.tsx
Normal 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
55
src/icons/SourceIcon.tsx
Normal 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
15
src/icons/StarIcon.tsx
Normal 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
10
src/icons/index.ts
Normal 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
5
src/index.ts
Normal 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
86
src/stored-searches.ts
Normal 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
81
src/types/DocSearchHit.ts
Normal 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;
|
||||
}
|
||||
5
src/types/InternalDocSearchHit.ts
Normal file
5
src/types/InternalDocSearchHit.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { DocSearchHit } from './DocSearchHit';
|
||||
|
||||
export type InternalDocSearchHit = DocSearchHit & {
|
||||
__docsearch_parent: null | InternalDocSearchHit;
|
||||
};
|
||||
1
src/types/SearchClient.ts
Normal file
1
src/types/SearchClient.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type SearchClient = any;
|
||||
6
src/types/StoredDocSearchHit.ts
Normal file
6
src/types/StoredDocSearchHit.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { DocSearchHit } from './DocSearchHit';
|
||||
|
||||
export type StoredDocSearchHit = Omit<
|
||||
DocSearchHit,
|
||||
'_highlightResult' | '_snippetResult'
|
||||
>;
|
||||
4
src/types/index.ts
Normal file
4
src/types/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './DocSearchHit';
|
||||
export * from './InternalDocSearchHit';
|
||||
export * from './SearchClient';
|
||||
export * from './StoredDocSearchHit';
|
||||
73
src/useDocSearchKeyboardEvents.ts
Normal file
73
src/useDocSearchKeyboardEvents.ts
Normal 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
31
src/useSearchClient.ts
Normal 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
36
src/useTouchEvents.ts
Normal 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
41
src/useTrapFocus.ts
Normal 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
20
src/utils/groupBy.ts
Normal 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
3
src/utils/identity.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function identity<TParam>(x: TParam): TParam {
|
||||
return x;
|
||||
}
|
||||
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './groupBy';
|
||||
export * from './identity';
|
||||
export * from './noop';
|
||||
1
src/utils/noop.ts
Normal file
1
src/utils/noop.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function noop(..._args: any[]): void {}
|
||||
1
src/version.ts
Normal file
1
src/version.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const version = '1.0.0-alpha.28';
|
||||
1
style/button.js
Normal file
1
style/button.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@docsearch/css/dist/button.css';
|
||||
1
style/index.js
Normal file
1
style/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@docsearch/css';
|
||||
1
style/modal.js
Normal file
1
style/modal.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@docsearch/css/dist/modal.css';
|
||||
1
style/variables.js
Normal file
1
style/variables.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from '@docsearch/css/dist/_variables.css';
|
||||
8
tsconfig.declaration.json
Normal file
8
tsconfig.declaration.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true
|
||||
}
|
||||
}
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../../tsconfig"
|
||||
}
|
||||
Loading…
Reference in a new issue