diff --git a/components/Common/OramaSearchBar/OramaLogo.tsx b/components/Common/OramaSearchBar/OramaLogo.tsx new file mode 100644 index 0000000000000..530ea0fb7e31d --- /dev/null +++ b/components/Common/OramaSearchBar/OramaLogo.tsx @@ -0,0 +1,695 @@ +type OramaLogoProps = { + className?: string; +}; + +// Keep up to date with: https://docs.oramasearch.com/logo/logo-orama-dark.svg +const OramaLogoDark = ({ className }: OramaLogoProps): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// Keep up to date with: https://docs.oramasearch.com/logo/logo-orama-light.svg +const OramaLogoLight = ({ className }: OramaLogoProps): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export { OramaLogoLight, OramaLogoDark }; diff --git a/components/Common/OramaSearchBar/__snapshots__/index.stories.tsx.snap b/components/Common/OramaSearchBar/__snapshots__/index.stories.tsx.snap new file mode 100644 index 0000000000000..8d0fc314c1571 --- /dev/null +++ b/components/Common/OramaSearchBar/__snapshots__/index.stories.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Common/OramaSearchBar Default smoke-test 1`] = ` +
+ +
+`; diff --git a/components/Common/OramaSearchBar/index.module.scss b/components/Common/OramaSearchBar/index.module.scss new file mode 100644 index 0000000000000..73a34df327b0a --- /dev/null +++ b/components/Common/OramaSearchBar/index.module.scss @@ -0,0 +1,24 @@ +.oramaSearchBarFooter { + align-items: center; + column-gap: 1ch; + display: flex; + justify-content: end; + margin: 5px; + + .oramaSearchBarFooterLabel { + color: var(--black6); + font-size: 0.8em; + margin-top: -2px; + white-space: nowrap; + } + + .oramaSearchBarFooterLink { + align-items: center; + display: flex; + justify-content: center; + } + + .oramaSearchBarFooterLogo { + min-width: 6em; + } +} diff --git a/components/Common/OramaSearchBar/index.stories.tsx b/components/Common/OramaSearchBar/index.stories.tsx new file mode 100644 index 0000000000000..0967fc6bb806b --- /dev/null +++ b/components/Common/OramaSearchBar/index.stories.tsx @@ -0,0 +1,50 @@ +import { create, insertMultiple, save } from '@orama/orama'; +import OramaSearchBar from './index'; + +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; +import type { Schema } from '@orama/orama'; + +type Story = StoryObj; +type Meta = MetaObj; + +const schema: Schema = { title: 'string', displayTitle: 'string' }; +const documents = [ + { + id: 'voluptate', + slug: 'voluptate', + title: 'Nisi deserunt excepteur', + category: 'aliqua-sunt', + displayTitle: 'Adipisicing magna irure elit velit.', + }, + { + id: 'ullamco', + slug: 'ullamco', + title: 'Lorem adipisicing ut', + category: 'excepteur', + displayTitle: 'Aliqua voluptate aliqua non consectetur sunt consequat.', + }, + { + id: 'deserunt', + slug: 'deserunt', + title: 'Qui irure do irure', + category: 'laborum', + wrapInCode: true, + }, +]; + +export const Default: Story = { + loaders: [ + async () => { + const database = await create({ schema }); + await insertMultiple(database, documents); + return { orama: await save(database) }; + }, + ], +}; + +export default { + component: OramaSearchBar, + render: (args, { loaded: { orama } }) => { + return ; + }, +} as Meta; diff --git a/components/Common/OramaSearchBar/index.tsx b/components/Common/OramaSearchBar/index.tsx new file mode 100644 index 0000000000000..fae237e005e07 --- /dev/null +++ b/components/Common/OramaSearchBar/index.tsx @@ -0,0 +1,64 @@ +import { create, load } from '@orama/orama'; +import { useTheme } from 'next-themes'; +import { useCallback, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { OramaLogoDark, OramaLogoLight } from './OramaLogo'; +import styles from './index.module.scss'; +import { identityDocumentMapper, performSearch } from './search'; +import SearchBar from '../SearchBar'; + +import type { FC } from 'react'; +import type { OramaSearchBarProps } from './search'; +import type { SearchFunction } from '../../../types'; + +const Footer: FC = () => { + const { resolvedTheme: theme } = useTheme(); + + const isDark = theme === 'dark'; + + return ( +
+ + + + + {isDark ? ( + + ) : ( + + )} + +
+ ); +}; + +const OramaSearchBar: FC = ({ + schema, + index, + documentMapper, +}: OramaSearchBarProps) => { + const [searchFunction, setSearchFunction] = useState(); + + // Create a setup function to deserialize the database + const setup = useCallback(async () => { + const database = await create({ schema }); + await load(database, index); + + setSearchFunction(() => + performSearch.bind( + null, + database, + documentMapper ?? identityDocumentMapper + ) + ); + }, [schema, index, documentMapper, setSearchFunction]); + + return ; +}; + +export default OramaSearchBar; diff --git a/components/Common/OramaSearchBar/search.ts b/components/Common/OramaSearchBar/search.ts new file mode 100644 index 0000000000000..c3b3891bc1ac2 --- /dev/null +++ b/components/Common/OramaSearchBar/search.ts @@ -0,0 +1,26 @@ +import { search } from '@orama/orama'; + +import type { Document, Orama, RawData, Schema } from '@orama/orama'; +import type { SearchResult } from '../../../types'; + +export type DocumentMapper = (doc: Document) => SearchResult; + +export type OramaSearchBarProps = { + schema: Schema; + index: RawData; + documentMapper?: DocumentMapper; +}; + +export const identityDocumentMapper = (doc: Document): SearchResult => { + return doc as unknown as SearchResult; +}; + +export const performSearch = async ( + database: Orama, + mapper: DocumentMapper, + term: string +): Promise => { + const results = await search(database, { term }); + + return results.hits.map(h => mapper(h.document)); +}; diff --git a/components/Common/SearchBar/__snapshots__/index.stories.tsx.snap b/components/Common/SearchBar/__snapshots__/index.stories.tsx.snap new file mode 100644 index 0000000000000..59f5c0602a2ee --- /dev/null +++ b/components/Common/SearchBar/__snapshots__/index.stories.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Common/SearchBar Default smoke-test 1`] = ` +
+ +
+`; diff --git a/components/Common/SearchBar/index.module.scss b/components/Common/SearchBar/index.module.scss new file mode 100644 index 0000000000000..7bab2ac244c9c --- /dev/null +++ b/components/Common/SearchBar/index.module.scss @@ -0,0 +1,136 @@ +.searchBarContainer { + background-color: var(--color-fill-top-nav); + border-radius: 6px; + box-shadow: none; + display: flex; + flex-direction: column; + max-height: 50vh; + max-width: 450px; + min-height: min-content; + min-width: min-content; + overflow-y: hidden; + + &.expanded { + box-shadow: 0px 2px 12px 3px rgba(153, 204, 125, 0.14); + } + + .searchInputContainer { + align-items: center; + display: flex; + position: relative; + width: 100%; + + .searchIcon, + .closeIcon { + color: var(--color-text-accent); + padding: 1rem; + vertical-align: middle; + } + + .closeIcon { + padding: 0 1rem 0 0; + } + + .searchLabel { + align-items: center; + color: var(--color-text-accent); + cursor: pointer; + display: flex; + flex: 1; + font-weight: var(--font-weight-semibold); + transition: all 250ms ease-in-out; + width: 100%; + } + + .searchInput { + background-color: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-primary); + font-size: var(--font-size-body1); + font-weight: var(--font-weight-semibold); + height: 100%; + outline: none; + width: 100%; + + &:focus { + outline: none; + } + } + } + + .searchResults { + display: flex; + flex-direction: column; + height: 100%; + min-height: 5em; + overflow-y: auto; + width: 100%; + + &.noResults { + justify-content: center; + } + + .loadingMessage { + align-self: center; + color: var(--color-text-primary); + display: flex; + font-size: 14px; + justify-content: center; + justify-self: center; + white-space: nowrap; + } + } + + .searchResultsList { + list-style: circle; + margin: 0; + padding: 0 0 0 var(--space-32); + } + + .searchResultsFooter { + border-top: var(--space-01) solid var(--black2); + } + + .searchResult { + color: var(--color-text-primary); + display: list-item; + margin: 0; + padding: 3px; + + &:hover { + font-weight: bold; + } + + .searchResultLink { + display: block; + padding: 2px; + text-decoration: none; + text-overflow: ellipsis; + + &:focus { + outline: var(--brand3) dotted 2px; + } + } + + .searchResultText { + text-transform: capitalize; + } + + .searchResultCode { + border-radius: 0.3rem; + font-weight: var(--font-weight-light); + padding: 0 6px; + } + } +} + +[data-theme='light'] .searchResultCode { + background-color: var(--black2); + color: var(--black9); +} + +[data-theme='dark'] .searchResultCode { + background-color: var(--black9); + color: var(--black2); +} diff --git a/components/Common/SearchBar/index.stories.tsx b/components/Common/SearchBar/index.stories.tsx new file mode 100644 index 0000000000000..af290e90aa206 --- /dev/null +++ b/components/Common/SearchBar/index.stories.tsx @@ -0,0 +1,39 @@ +import SearchBar from './index'; + +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + search() { + return [ + { + id: 'voluptate', + slug: 'voluptate', + title: 'Nisi deserunt excepteur', + category: 'aliqua-sunt', + displayTitle: 'Adipisicing magna irure elit velit.', + }, + { + id: 'ullamco', + slug: 'ullamco', + title: 'Lorem adipisicing ut', + category: 'excepteur', + displayTitle: + 'Aliqua voluptate aliqua non consectetur sunt consequat.', + }, + { + id: 'deserunt', + slug: 'deserunt', + title: 'Qui irure do irure', + category: 'laborum', + wrapInCode: true, + }, + ]; + }, + }, +}; + +export default { component: SearchBar } as Meta; diff --git a/components/Common/SearchBar/index.tsx b/components/Common/SearchBar/index.tsx new file mode 100644 index 0000000000000..bddbe8e22d55d --- /dev/null +++ b/components/Common/SearchBar/index.tsx @@ -0,0 +1,337 @@ +import { MdTravelExplore, MdClose } from 'react-icons/md'; +import classNames from 'classnames'; +import { AnimatePresence, motion } from 'framer-motion'; +import Link from 'next/link'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import styles from './index.module.scss'; +import SectionTitle from '../SectionTitle'; +import { useClickOutside } from '../../../hooks/useClickOutside'; +import useKeyPress from '../../../hooks/useKeypress'; +import type { FC } from 'react'; +import type { SearchFunction, SearchResult } from '../../../types'; + +type SearchBarProps = { + search?: SearchFunction; + setup?: () => Promise; + footer?: FC | (() => JSX.Element); +}; + +const containerTransition = { type: 'spring', damping: 22, stiffness: 150 }; + +const containerVariants = { + expanded: { + width: 'auto', + height: 'auto', + }, + collapsed: { + width: 0, + height: '3em', + }, +}; + +const SearchBar: FC = ({ + setup, + search, + footer: Footer, +}: SearchBarProps) => { + const [isLoading, setIsLoading] = useState(true); + const searchInputRef = useRef(null); + const listRef = useRef(null); + const activeResultRef = useRef(-1); + + const [isExpanded, setExpanded] = useState(false); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const isEmpty = !results || results.length === 0; + + const containerClassNames = classNames(styles.searchBarContainer, { + [styles.expanded]: isExpanded, + }); + + const resultsClassNames = classNames(styles.searchResults, { + [styles.noResults]: isEmpty, + }); + + const expandContainer = useCallback(() => { + setExpanded(true); + }, [setExpanded]); + + const collapseContainer = useCallback(() => { + setQuery(''); + setExpanded(false); + }, [setQuery, setExpanded]); + + const selectItem = useCallback( + (index: number) => { + if (!listRef.current) { + return; + } + + const el = listRef.current.children[index]!; + el.querySelector('a')?.focus(); + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, + [listRef] + ); + + const toggleContainer = useCallback(() => { + isExpanded ? collapseContainer() : expandContainer(); + }, [isExpanded, collapseContainer, expandContainer]); + + const onKeyPressHandler = useCallback(() => { + if (!isExpanded) { + expandContainer(); + } + }, [isExpanded, expandContainer]); + + const onQueryChangeHandler = useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + + // Clear results when the query is emptied + if (!e.target.value) { + setResults([]); + } + + setQuery(e.target.value); + }, + [setQuery, setResults] + ); + + const onBlurHandler = useCallback( + (e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.target) || isEmpty) { + collapseContainer(); + } + }, + [isEmpty, collapseContainer] + ); + + const onCloseHandler = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (isExpanded) { + collapseContainer(); + } + }, + [isExpanded, collapseContainer] + ); + + const onSearchResultKeyHandler = useCallback( + (e: React.KeyboardEvent) => { + if (!isExpanded || isEmpty || !listRef.current) { + return; + } + + const currentResultIndex = activeResultRef.current; + const maxResultIndex = listRef.current.children.length - 1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + + activeResultRef.current = + currentResultIndex + 1 > maxResultIndex ? 0 : currentResultIndex + 1; + selectItem(activeResultRef.current); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + + activeResultRef.current = + currentResultIndex - 1 < 0 ? maxResultIndex : currentResultIndex - 1; + selectItem(activeResultRef.current); + } + }, + [isExpanded, isEmpty, activeResultRef, selectItem] + ); + + const parentRef = useClickOutside(collapseContainer); + + // Focus the field when expanding the bar + useEffect(() => { + if (isExpanded) { + searchInputRef.current?.focus(); + } + }, [isExpanded, searchInputRef]); + + // Perform the setup call, if any + useEffect(() => { + if (!setup) { + setIsLoading(false); + return; + } + + setup() + .then(() => { + setIsLoading(false); + }) + .catch((error: Error) => { + console.error('Cannot load search data.', error); + }); + }, [setup, setIsLoading]); + + // Perform a search when the query changes + useEffect(() => { + if (isLoading || !query || !search) { + return; + } + + try { + const searchReturnValue = search(query); + + if ( + typeof (searchReturnValue as Promise).then === + 'function' + ) { + (searchReturnValue as Promise) + .then(r => setResults(r)) + .catch((error: Error) => { + console.error('Cannot perform async search.', error); + }); + } else { + setResults(searchReturnValue as SearchResult[]); + } + } catch (error) { + console.error('Cannot perform search.', error); + } + }, [isLoading, query, search, setResults]); + + useKeyPress({ + targetKey: 'ctrl+k', + callback: toggleContainer, + preventDefault: true, + }); + + useKeyPress({ + targetKey: 'meta+k', + callback: toggleContainer, + preventDefault: true, + }); + + useKeyPress({ + targetKey: 'Escape', + callback() { + if (isExpanded) { + collapseContainer(); + } + }, + preventDefault: true, + }); + + return ( + +
+ + + + {isExpanded && ( + + + + )} + +
+ + {isExpanded && ( + <> +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && isEmpty && ( +
+ +
+ )} + {!isLoading && !isEmpty && ( +
    + {results.map((result: SearchResult) => { + const sectionPath = + result.category === 'api' + ? ['home', result.category, result.title] + : ['home', result.category]; + + const displayTitle = result.displayTitle || result.title; + + return ( +
  • + + {displayTitle && + (result.wrapInCode ? ( + + {displayTitle} + + ) : ( + + {displayTitle} + + ))} + + +
  • + ); + })} +
+ )} +
+ + {Footer && ( +
+
+
+ )} + + )} +
+ ); +}; + +export default SearchBar; diff --git a/hooks/useKeypress.ts b/hooks/useKeypress.ts new file mode 100644 index 0000000000000..c443ebc726dd0 --- /dev/null +++ b/hooks/useKeypress.ts @@ -0,0 +1,60 @@ +/** + * A hook for detecting key presses. + */ +import { useEffect, useState } from 'react'; + +const decorateKey = ({ metaKey, ctrlKey, key }: KeyboardEvent) => { + if (metaKey) { + return `meta+${key}`; + } + + if (ctrlKey) { + return `ctrl+${key}`; + } + + return key; +}; + +interface UseKeyPressProps { + targetKey: string; + callback?: () => void; + preventDefault?: boolean; +} + +function useKeyPress({ + targetKey, + callback, + preventDefault = false, +}: UseKeyPressProps): boolean { + const [keyPressed, setKeyPressed] = useState(false); + + useEffect(() => { + const downHandler = (e: KeyboardEvent) => { + if (decorateKey(e) === targetKey) { + if (preventDefault) { + e.preventDefault(); + } + setKeyPressed(true); + if (typeof callback === 'function') { + callback(); + } + } + }; + + const upHandler = (e: KeyboardEvent) => { + if (decorateKey(e) === targetKey) { + setKeyPressed(false); + } + }; + + window.addEventListener('keydown', downHandler); + window.addEventListener('keyup', upHandler); + return () => { + window.removeEventListener('keydown', downHandler); + window.removeEventListener('keyup', upHandler); + }; + }, [callback, targetKey, preventDefault]); + return keyPressed; +} + +export default useKeyPress; diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 7f42d271d3f8a..c911190d9fc66 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -78,5 +78,10 @@ "components.api.apiChanges.addedIn": "Added in: {version}", "components.api.apiChanges.history": "History", "components.api.apiChanges.history.version": "Version", - "components.api.apiChanges.history.changes": "Changes" + "components.api.apiChanges.history.changes": "Changes", + "components.searchBar.placeholder": "Search", + "components.searchBar.search.title": "Start typing to search", + "components.searchBar.search.loading": "Loading search data ...", + "components.searchBar.search.noResults": "No results match your query", + "components.oramaSearchBar.search.poweredBy": "Search by" } diff --git a/package-lock.json b/package-lock.json index dcaadfef327e8..077ff3dcba674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@mdx-js/react": "^2.3.0", "@mui/material": "^5.13.6", "@nodevu/core": "^0.1.0", - "@types/node": "^18.16.19", + "@orama/orama": "^1.0.8", "@vercel/analytics": "^1.0.1", "classnames": "^2.3.2", "cross-env": "^7.0.3", @@ -55,6 +55,7 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.2", "@types/mdx": "^2.0.5", + "@types/node": "^18.16.19", "@types/prismjs": "^1.26.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", @@ -5249,6 +5250,14 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@orama/orama": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-1.0.8.tgz", + "integrity": "sha512-UJvfnhCL4m4NQZFTs2kUDel67HHkzWCZJqF6Q4Dsx8hROXP82OXvPXsTXfHhHIyxtETdliEEkub1c6k6+36Gqw==", + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", diff --git a/package.json b/package.json index 9e8e563a27df8..57179929ba4c1 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@mdx-js/react": "^2.3.0", "@mui/material": "^5.13.6", "@nodevu/core": "^0.1.0", - "@types/node": "^18.16.19", + "@orama/orama": "^1.0.8", "@vercel/analytics": "^1.0.1", "classnames": "^2.3.2", "cross-env": "^7.0.3", @@ -84,6 +84,7 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.2", "@types/mdx": "^2.0.5", + "@types/node": "^18.16.19", "@types/prismjs": "^1.26.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", diff --git a/types/index.ts b/types/index.ts index 36de67ba63c56..c21a4e74acbf5 100644 --- a/types/index.ts +++ b/types/index.ts @@ -6,7 +6,8 @@ export * from './features'; export * from './frontmatter'; export * from './i18n'; export * from './layouts'; +export * from './middlewares'; export * from './navigation'; export * from './prevNextLink'; export * from './releases'; -export * from './middlewares'; +export * from './search'; diff --git a/types/search.ts b/types/search.ts new file mode 100644 index 0000000000000..edd45fb3dc6af --- /dev/null +++ b/types/search.ts @@ -0,0 +1,12 @@ +export type SearchResult = { + id: React.Key | null | undefined; + slug: string; + title: string; + category: string; + displayTitle?: string | JSX.Element; + wrapInCode?: boolean; +}; + +export type SearchFunction = ( + query: string +) => SearchResult[] | Promise;