diff --git a/README.md b/README.md new file mode 100644 index 00000000..c48f0f6f --- /dev/null +++ b/README.md @@ -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) diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 00000000..602182e1 --- /dev/null +++ b/babel.config.js @@ -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']], + }; +}; diff --git a/button.js b/button.js new file mode 100644 index 00000000..5614c2a7 --- /dev/null +++ b/button.js @@ -0,0 +1 @@ +export { DocSearchButton } from './dist/esm/DocSearchButton.js'; diff --git a/modal.js b/modal.js new file mode 100644 index 00000000..7ae1e01d --- /dev/null +++ b/modal.js @@ -0,0 +1 @@ +export { DocSearchModal } from './dist/esm/DocSearchModal.js'; diff --git a/package.json b/package.json new file mode 100644 index 00000000..d4653909 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 00000000..124ff745 --- /dev/null +++ b/rollup.config.js @@ -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, +}; diff --git a/src/AlgoliaLogo.tsx b/src/AlgoliaLogo.tsx new file mode 100644 index 00000000..7690b5f7 --- /dev/null +++ b/src/AlgoliaLogo.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export function AlgoliaLogo() { + return ( + + Search by + + + + + ); +} diff --git a/src/DocSearch.tsx b/src/DocSearch.tsx new file mode 100644 index 00000000..49f9add9 --- /dev/null +++ b/src/DocSearch.tsx @@ -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, '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; + }): JSX.Element | null; + transformSearchClient?(searchClient: SearchClient): SearchClient; + disableUserPersonalization?: boolean; + initialQuery?: string; +} + +export function DocSearch(props: DocSearchProps) { + const searchButtonRef = React.useRef(null); + const [isOpen, setIsOpen] = React.useState(false); + const [initialQuery, setInitialQuery] = React.useState( + 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 ( + <> + + + {isOpen && + createPortal( + , + document.body + )} + + ); +} diff --git a/src/DocSearchButton.tsx b/src/DocSearchButton.tsx new file mode 100644 index 00000000..d987a5ee --- /dev/null +++ b/src/DocSearchButton.tsx @@ -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 +>; + +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 ( + + ); +}); diff --git a/src/DocSearchModal.tsx b/src/DocSearchModal.tsx new file mode 100644 index 00000000..fdb21f8b --- /dev/null +++ b/src/DocSearchModal.tsx @@ -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 + >({ + query: '', + suggestions: [], + } as any); + + const containerRef = React.useRef(null); + const searchBoxRef = React.useRef(null); + const dropdownRef = React.useRef(null); + const inputRef = React.useRef(null); + const snippetLength = React.useRef(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({ + key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`, + limit: 10, + }) + ).current; + const recentSearches = React.useRef( + createStoredSearches({ + key: `__DOCSEARCH_RECENT_SEARCHES__${indexName}`, + // We display 7 recent searches and there's no favorites, but only + // 4 when there are favorites. + limit: favoriteSearches.getAll().length === 0 ? 7 : 4, + }) + ).current; + + const saveRecentSearch = React.useCallback( + function saveRecentSearch(item: InternalDocSearchHit) { + 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, + 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: '', + highlightPostTag: '', + 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(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 ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+ +
+ +
+ { + saveRecentSearch(item); + onClose(); + }} + inputRef={inputRef} + /> +
+ +
+
+
+
+
+ ); +} diff --git a/src/ErrorScreen.tsx b/src/ErrorScreen.tsx new file mode 100644 index 00000000..0c55af06 --- /dev/null +++ b/src/ErrorScreen.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { ErrorIcon } from './icons'; + +export function ErrorScreen() { + return ( +
+
+ +
+

Unable to fetch results

+

+ You might want to check your network connection. +

+
+ ); +} diff --git a/src/Footer.tsx b/src/Footer.tsx new file mode 100644 index 00000000..7e098632 --- /dev/null +++ b/src/Footer.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { AlgoliaLogo } from './AlgoliaLogo'; + +export function Footer() { + return ( + <> +
+ +
+
    +
  • + + + + + + to select +
  • +
  • + + + + + + + + + + + to navigate +
  • +
  • + + + + + + to close +
  • +
+ + ); +} + +interface CommandIconProps { + children: React.ReactNode; +} + +function CommandIcon(props: CommandIconProps) { + return ( + + + {props.children} + + + ); +} diff --git a/src/Hit.tsx b/src/Hit.tsx new file mode 100644 index 00000000..09e539ee --- /dev/null +++ b/src/Hit.tsx @@ -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 {children}; +} diff --git a/src/NoResultsScreen.tsx b/src/NoResultsScreen.tsx new file mode 100644 index 00000000..40bea85b --- /dev/null +++ b/src/NoResultsScreen.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { NoResultsIcon } from './icons'; +import { ScreenStateProps } from './ScreenState'; +import { InternalDocSearchHit } from './types'; + +type NoResultsScreenProps = ScreenStateProps; + +export function NoResultsScreen(props: NoResultsScreenProps) { + const searchSuggestions: string[] | undefined = props.state.context + .searchSuggestions as string[]; + + return ( +
+
+ +
+

+ No results for "{props.state.query}" +

+ + {searchSuggestions && searchSuggestions.length > 0 && ( +
+

Try searching for:

+
    + {searchSuggestions.slice(0, 3).reduce( + (acc, search) => [ + ...acc, +
  • + +
  • , + ], + [] + )} +
+
+ )} + +

+ Believe this query should return results?{' '} + + Let us know + + . +

+
+ ); +} diff --git a/src/Results.tsx b/src/Results.tsx new file mode 100644 index 00000000..0b38e7dd --- /dev/null +++ b/src/Results.tsx @@ -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 + extends AutocompleteApi< + TItem, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + title: string; + suggestion: AutocompleteState['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( + props: ResultsProps +) { + if (!props.suggestion || props.suggestion.items.length === 0) { + return null; + } + + return ( +
+
{props.title}
+ +
    + {props.suggestion.items.map((item, index) => { + return ( + + ); + })} +
+
+ ); +} + +interface ResultProps extends ResultsProps { + item: TItem; + index: number; +} + +function Result({ + item, + index, + renderIcon, + renderAction, + getItemProps, + onItemClick, + suggestion, + hitComponent, +}: ResultProps) { + 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 ( +
  • { + if (action.current) { + action.current(); + } + }} + {...getItemProps({ + item, + source: suggestion.source, + onClick() { + onItemClick(item); + }, + })} + > + +
    + {renderIcon({ item, index })} + + {item.hierarchy[item.type] && item.type === 'lvl1' && ( +
    + + {item.content && ( + + )} +
    + )} + + {item.hierarchy[item.type] && + (item.type === 'lvl2' || + item.type === 'lvl3' || + item.type === 'lvl4' || + item.type === 'lvl5' || + item.type === 'lvl6') && ( +
    + + +
    + )} + + {item.type === 'content' && ( +
    + + +
    + )} + + {renderAction({ item, runDeleteTransition, runFavoriteTransition })} +
    +
    +
  • + ); +} diff --git a/src/ResultsScreen.tsx b/src/ResultsScreen.tsx new file mode 100644 index 00000000..229b4280 --- /dev/null +++ b/src/ResultsScreen.tsx @@ -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; + +export function ResultsScreen(props: ResultsScreenProps) { + return ( +
    + {props.state.suggestions.map((suggestion, index) => { + if (suggestion.items.length === 0) { + return null; + } + + const title = suggestion.items[0].hierarchy.lvl0; + + return ( + ( + <> + {item.__docsearch_parent && ( + + + {item.__docsearch_parent !== + suggestion.items[index + 1]?.__docsearch_parent ? ( + + ) : ( + + )} + + + )} + +
    + +
    + + )} + renderAction={() => ( +
    + +
    + )} + /> + ); + })} + + {props.resultsFooterComponent && ( +
    + +
    + )} +
    + ); +} diff --git a/src/ScreenState.tsx b/src/ScreenState.tsx new file mode 100644 index 00000000..b21cb5bb --- /dev/null +++ b/src/ScreenState.tsx @@ -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 + extends AutocompleteApi< + TItem, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > { + state: AutocompleteState; + recentSearches: StoredSearchPlugin; + favoriteSearches: StoredSearchPlugin; + onItemClick(item: InternalDocSearchHit): void; + inputRef: React.MutableRefObject; + hitComponent: DocSearchProps['hitComponent']; + indexName: DocSearchProps['indexName']; + disableUserPersonalization: boolean; + resultsFooterComponent: DocSearchProps['resultsFooterComponent']; +} + +export const ScreenState = React.memo( + (props: ScreenStateProps) => { + if (props.state.status === 'error') { + return ; + } + + const hasSuggestions = props.state.suggestions.some( + (suggestion) => suggestion.items.length > 0 + ); + + if (!props.state.query) { + return ; + } + + if (hasSuggestions === false) { + return ; + } + + return ; + }, + 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' + ); + } +); diff --git a/src/SearchBox.tsx b/src/SearchBox.tsx new file mode 100644 index 00000000..3d1a281f --- /dev/null +++ b/src/SearchBox.tsx @@ -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; + autoFocus: boolean; + inputRef: MutableRefObject; + 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 ( + <> +
    { + event.preventDefault(); + }} + onReset={onReset} + > + + +
    + +
    + + + + +
    + + + + ); +} diff --git a/src/Snippet.tsx b/src/Snippet.tsx new file mode 100644 index 00000000..39d1ceae --- /dev/null +++ b/src/Snippet.tsx @@ -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 { + [prop: string]: unknown; + hit: TItem; + attribute: string; + tagName?: string; +} + +export function Snippet({ + hit, + attribute, + tagName = 'span', + ...rest +}: SnippetProps) { + return createElement(tagName, { + ...rest, + dangerouslySetInnerHTML: { + __html: + getPropertyByPath(hit, `_snippetResult.${attribute}.value`) || + getPropertyByPath(hit, attribute), + }, + }); +} diff --git a/src/StartScreen.tsx b/src/StartScreen.tsx new file mode 100644 index 00000000..e0463594 --- /dev/null +++ b/src/StartScreen.tsx @@ -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 { + hasSuggestions: boolean; +} + +export function StartScreen(props: StartScreenProps) { + if (props.state.status === 'idle' && props.hasSuggestions === false) { + if (props.disableUserPersonalization) { + return null; + } + + return ( +
    +

    No recent searches

    +
    + ); + } + + if (props.hasSuggestions === false) { + return null; + } + + return ( +
    + ( +
    + +
    + )} + renderAction={({ + item, + runFavoriteTransition, + runDeleteTransition, + }) => ( + <> +
    + +
    +
    + +
    + + )} + /> + + ( +
    + +
    + )} + renderAction={({ item, runDeleteTransition }) => ( +
    + +
    + )} + /> +
    + ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..bbeca55a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const MAX_QUERY_SIZE = 64; diff --git a/src/icons/ControlKeyIcon.tsx b/src/icons/ControlKeyIcon.tsx new file mode 100644 index 00000000..de1f4e34 --- /dev/null +++ b/src/icons/ControlKeyIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export function ControlKeyIcon() { + return ( + + + + ); +} diff --git a/src/icons/ErrorIcon.tsx b/src/icons/ErrorIcon.tsx new file mode 100644 index 00000000..ef5197d0 --- /dev/null +++ b/src/icons/ErrorIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export function ErrorIcon() { + return ( + + + + ); +} diff --git a/src/icons/GoToExternalIcon.tsx b/src/icons/GoToExternalIcon.tsx new file mode 100644 index 00000000..4b47c7f8 --- /dev/null +++ b/src/icons/GoToExternalIcon.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export function GoToExternal() { + return ( + + + + ); +} diff --git a/src/icons/LoadingIcon.tsx b/src/icons/LoadingIcon.tsx new file mode 100644 index 00000000..6963ca43 --- /dev/null +++ b/src/icons/LoadingIcon.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export function LoadingIcon() { + return ( + + + + + + + + + + + ); +} diff --git a/src/icons/NoResultsIcon.tsx b/src/icons/NoResultsIcon.tsx new file mode 100644 index 00000000..d9e9cc5d --- /dev/null +++ b/src/icons/NoResultsIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export function NoResultsIcon() { + return ( + + + + ); +} diff --git a/src/icons/RecentIcon.tsx b/src/icons/RecentIcon.tsx new file mode 100644 index 00000000..51f7ba31 --- /dev/null +++ b/src/icons/RecentIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +export function RecentIcon() { + return ( + + + + + + + ); +} diff --git a/src/icons/ResetIcon.tsx b/src/icons/ResetIcon.tsx new file mode 100644 index 00000000..eeb0e046 --- /dev/null +++ b/src/icons/ResetIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export function ResetIcon() { + return ( + + + + ); +} diff --git a/src/icons/SearchIcon.tsx b/src/icons/SearchIcon.tsx new file mode 100644 index 00000000..b95097de --- /dev/null +++ b/src/icons/SearchIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +export function SearchIcon() { + return ( + + + + ); +} diff --git a/src/icons/SelectIcon.tsx b/src/icons/SelectIcon.tsx new file mode 100644 index 00000000..3867f655 --- /dev/null +++ b/src/icons/SelectIcon.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export function SelectIcon() { + return ( + + + + + + + ); +} diff --git a/src/icons/SourceIcon.tsx b/src/icons/SourceIcon.tsx new file mode 100644 index 00000000..d9a0c83f --- /dev/null +++ b/src/icons/SourceIcon.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +export function SourceIcon(props: { type: string }) { + switch (props.type) { + case 'lvl1': + return ; + case 'content': + return ; + default: + return ; + } +} + +function LvlIcon() { + return ( + + + + ); +} + +function AnchorIcon() { + return ( + + + + ); +} + +function ContentIcon() { + return ( + + + + ); +} diff --git a/src/icons/StarIcon.tsx b/src/icons/StarIcon.tsx new file mode 100644 index 00000000..c37e9296 --- /dev/null +++ b/src/icons/StarIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export function StarIcon() { + return ( + + + + ); +} diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 00000000..b3c85dc9 --- /dev/null +++ b/src/icons/index.ts @@ -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'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..d4e93969 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from './DocSearch'; +export * from './DocSearchButton'; +export * from './DocSearchModal'; +export * from './useDocSearchKeyboardEvents'; +export * from './version'; diff --git a/src/stored-searches.ts b/src/stored-searches.ts new file mode 100644 index 00000000..90b6b8cb --- /dev/null +++ b/src/stored-searches.ts @@ -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(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 = { + add(item: TItem): void; + remove(item: TItem): void; + getAll(): TItem[]; +}; + +export function createStoredSearches({ + key, + limit = 5, +}: CreateStoredSearchesOptions): StoredSearchPlugin { + const storage = createStorage(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; + }, + }; +} diff --git a/src/types/DocSearchHit.ts b/src/types/DocSearchHit.ts new file mode 100644 index 00000000..71be3c93 --- /dev/null +++ b/src/types/DocSearchHit.ts @@ -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; +} diff --git a/src/types/InternalDocSearchHit.ts b/src/types/InternalDocSearchHit.ts new file mode 100644 index 00000000..136aca0d --- /dev/null +++ b/src/types/InternalDocSearchHit.ts @@ -0,0 +1,5 @@ +import { DocSearchHit } from './DocSearchHit'; + +export type InternalDocSearchHit = DocSearchHit & { + __docsearch_parent: null | InternalDocSearchHit; +}; diff --git a/src/types/SearchClient.ts b/src/types/SearchClient.ts new file mode 100644 index 00000000..d39c22c7 --- /dev/null +++ b/src/types/SearchClient.ts @@ -0,0 +1 @@ +export type SearchClient = any; diff --git a/src/types/StoredDocSearchHit.ts b/src/types/StoredDocSearchHit.ts new file mode 100644 index 00000000..db41dbf4 --- /dev/null +++ b/src/types/StoredDocSearchHit.ts @@ -0,0 +1,6 @@ +import { DocSearchHit } from './DocSearchHit'; + +export type StoredDocSearchHit = Omit< + DocSearchHit, + '_highlightResult' | '_snippetResult' +>; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..779c5649 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './DocSearchHit'; +export * from './InternalDocSearchHit'; +export * from './SearchClient'; +export * from './StoredDocSearchHit'; diff --git a/src/useDocSearchKeyboardEvents.ts b/src/useDocSearchKeyboardEvents.ts new file mode 100644 index 00000000..09db4de8 --- /dev/null +++ b/src/useDocSearchKeyboardEvents.ts @@ -0,0 +1,73 @@ +import React from 'react'; + +export interface UseDocSearchKeyboardEventsProps { + isOpen: boolean; + onOpen(): void; + onClose(): void; + onInput?(event: KeyboardEvent): void; + searchButtonRef?: React.RefObject; +} + +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]); +} diff --git a/src/useSearchClient.ts b/src/useSearchClient.ts new file mode 100644 index 00000000..8013d5ae --- /dev/null +++ b/src/useSearchClient.ts @@ -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; +} diff --git a/src/useTouchEvents.ts b/src/useTouchEvents.ts new file mode 100644 index 00000000..0dedd1c2 --- /dev/null +++ b/src/useTouchEvents.ts @@ -0,0 +1,36 @@ +import { AutocompleteApi } from '@francoischalifour/autocomplete-core'; +import React from 'react'; + +interface UseTouchEventsProps { + getEnvironmentProps: AutocompleteApi['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]); +} diff --git a/src/useTrapFocus.ts b/src/useTrapFocus.ts new file mode 100644 index 00000000..4e8c3665 --- /dev/null +++ b/src/useTrapFocus.ts @@ -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( + '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]); +} diff --git a/src/utils/groupBy.ts b/src/utils/groupBy.ts new file mode 100644 index 00000000..f8837400 --- /dev/null +++ b/src/utils/groupBy.ts @@ -0,0 +1,20 @@ +export function groupBy( + values: TValue[], + predicate: (value: TValue) => string +): Record { + return values.reduce>((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; + }, {}); +} diff --git a/src/utils/identity.ts b/src/utils/identity.ts new file mode 100644 index 00000000..440329ed --- /dev/null +++ b/src/utils/identity.ts @@ -0,0 +1,3 @@ +export function identity(x: TParam): TParam { + return x; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..877f02d6 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './groupBy'; +export * from './identity'; +export * from './noop'; diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 00000000..cadb2cd0 --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1 @@ +export function noop(..._args: any[]): void {} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..e8664ca8 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const version = '1.0.0-alpha.28'; diff --git a/style/button.js b/style/button.js new file mode 100644 index 00000000..86e69d1f --- /dev/null +++ b/style/button.js @@ -0,0 +1 @@ +export * from '@docsearch/css/dist/button.css'; diff --git a/style/index.js b/style/index.js new file mode 100644 index 00000000..26d58a9d --- /dev/null +++ b/style/index.js @@ -0,0 +1 @@ +export * from '@docsearch/css'; diff --git a/style/modal.js b/style/modal.js new file mode 100644 index 00000000..fb35bb60 --- /dev/null +++ b/style/modal.js @@ -0,0 +1 @@ +export * from '@docsearch/css/dist/modal.css'; diff --git a/style/variables.js b/style/variables.js new file mode 100644 index 00000000..ecbb7b20 --- /dev/null +++ b/style/variables.js @@ -0,0 +1 @@ +export * from '@docsearch/css/dist/_variables.css'; diff --git a/tsconfig.declaration.json b/tsconfig.declaration.json new file mode 100644 index 00000000..9bd7baf2 --- /dev/null +++ b/tsconfig.declaration.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..41716a7d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig" +}