diff --git a/components/Common/Search/States/WithEmptyState.tsx b/components/Common/Search/States/WithEmptyState.tsx deleted file mode 100644 index f554a0bec206b..0000000000000 --- a/components/Common/Search/States/WithEmptyState.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useTranslations } from 'next-intl'; -import type { FC } from 'react'; - -import styles from './index.module.css'; - -export const WithEmptyState: FC = () => { - const t = useTranslations(); - - return ( -
- {t('components.search.emptyState.text')} -
- ); -}; diff --git a/components/Common/Search/States/WithSearchBox.tsx b/components/Common/Search/States/WithSearchBox.tsx index c15188d3c7b7b..d3a2cff45087c 100644 --- a/components/Common/Search/States/WithSearchBox.tsx +++ b/components/Common/Search/States/WithSearchBox.tsx @@ -5,22 +5,22 @@ import { ChevronLeftIcon, } from '@heroicons/react/24/outline'; import type { Results, Nullable } from '@orama/orama'; -import classNames from 'classnames'; import { useState, useRef, useEffect } from 'react'; import type { FC } from 'react'; import styles from '@/components/Common/Search/States/index.module.css'; import { WithAllResults } from '@/components/Common/Search/States/WithAllResults'; -import { WithEmptyState } from '@/components/Common/Search/States/WithEmptyState'; import { WithError } from '@/components/Common/Search/States/WithError'; import { WithNoResults } from '@/components/Common/Search/States/WithNoResults'; import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy'; import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult'; -import { useClickOutside } from '@/hooks/react-client'; +import Tabs from '@/components/Common/Tabs'; +import { useClickOutside, useKeyboardCommands } from '@/hooks/react-client'; import { useRouter } from '@/navigation.mjs'; import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs'; import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs'; import type { SearchDoc } from '@/types'; +import { searchHitToLinkPath } from '@/util/searchUtils'; type Facets = { [key: string]: number }; @@ -31,6 +31,7 @@ type SearchBoxProps = { onClose: () => void }; export const WithSearchBox: FC = ({ onClose }) => { const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState(null); + const [selectedResult, setSelectedResult] = useState(); const [selectedFacet, setSelectedFacet] = useState(0); const [searchError, setSearchError] = useState>(null); @@ -56,10 +57,19 @@ export const WithSearchBox: FC = ({ onClose }) => { .catch(setSearchError); }; - useClickOutside(searchBoxRef, () => { + const reset = () => { + setSearchTerm(''); + setSearchResults(null); + setSelectedResult(undefined); + setSelectedFacet(0); + }; + + const handleClose = () => { reset(); onClose(); - }); + }; + + useClickOutside(searchBoxRef, handleClose); useEffect(() => { searchInputRef.current?.focus(); @@ -78,19 +88,54 @@ export const WithSearchBox: FC = ({ onClose }) => { [searchTerm, selectedFacet] ); - const reset = () => { - setSearchTerm(''); - setSearchResults(null); - setSelectedFacet(0); + useKeyboardCommands(cmd => { + if (searchError || !searchResults || searchResults.count <= 0) { + return; + } + + switch (true) { + case cmd === 'down' && selectedResult === undefined: + setSelectedResult(0); + break; + case cmd === 'down' && + selectedResult != undefined && + selectedResult < searchResults.count && + selectedResult < DEFAULT_ORAMA_QUERY_PARAMS.limit - 1: + setSelectedResult(selectedResult + 1); + break; + case cmd === 'up' && selectedResult != undefined && selectedResult != 0: + setSelectedResult(selectedResult - 1); + break; + case cmd === 'enter': + handleEnter(); + break; + default: + } + }); + + const handleEnter = () => { + if (!searchResults || !selectedResult) { + return; + } + + const selectedHit = searchResults.hits[selectedResult]; + + if (!selectedHit) { + return; + } + + handleClose(); + router.push(searchHitToLinkPath(selectedHit)); }; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); + + handleClose(); router.push(`/search?q=${searchTerm}§ion=${selectedFacetName}`); - onClose(); }; - const changeFacet = (idx: number) => setSelectedFacet(idx); + const changeFacet = (idx: string) => setSelectedFacet(Number(idx)); const filterBySection = () => { if (selectedFacet === 0) { @@ -131,6 +176,16 @@ export const WithSearchBox: FC = ({ onClose }) => {
setSearchTerm(event.target.value)} @@ -140,36 +195,38 @@ export const WithSearchBox: FC = ({ onClose }) => {
- {Object.keys(facets).map((facetName, idx) => ( - - ))} + ({ + key: facetName, + label: facetName, + secondaryLabel: `(${facets[facetName].toLocaleString('en')})`, + value: idx.toString(), + }))} + onValueChange={changeFacet} + />
-
+
{searchError && } - {!searchError && !searchTerm && } - - {!searchError && searchTerm && ( + {!searchError && ( <> {searchResults && searchResults.count > 0 && - searchResults.hits.map(hit => ( + searchResults.hits.map((hit, idx) => ( ))} diff --git a/components/Common/Search/States/WithSearchResult.tsx b/components/Common/Search/States/WithSearchResult.tsx index 0a60f351a5e23..05e0dd47ef051 100644 --- a/components/Common/Search/States/WithSearchResult.tsx +++ b/components/Common/Search/States/WithSearchResult.tsx @@ -1,32 +1,43 @@ import type { Result } from '@orama/orama'; -import type { FC } from 'react'; +import { useEffect, type FC, useRef } from 'react'; import { pathToBreadcrumbs } from '@/components/Common/Search/utils'; import Link from '@/components/Link'; -import { BASE_URL } from '@/next.constants.mjs'; import { highlighter } from '@/next.orama.mjs'; import type { SearchDoc } from '@/types'; +import { searchHitToLinkPath } from '@/util/searchUtils'; import styles from './index.module.css'; type SearchResultProps = { hit: Result; searchTerm: string; + selected: boolean; + idx: number; }; export const WithSearchResult: FC = props => { - const isAPIResult = props.hit.document.siteSection.toLowerCase() === 'docs'; - const basePath = isAPIResult ? BASE_URL : ''; - const path = `${basePath}/${props.hit.document.path}`; + const divRef = useRef(null); + const path = searchHitToLinkPath(props.hit); + + useEffect(() => { + if (props.selected && divRef.current) { + divRef.current.scrollIntoView({ block: 'center' }); + } + }, [props.selected]); return (
; @@ -27,10 +32,15 @@ const Tabs: FC> = ({ {tabs.map(tab => ( {tab.label} + {tab.secondaryLabel ? ( + + {tab.secondaryLabel} + + ) : null} ))} diff --git a/hooks/react-client/useKeyboardCommands.ts b/hooks/react-client/useKeyboardCommands.ts index e6f9326fcf055..89b4513edbd85 100644 --- a/hooks/react-client/useKeyboardCommands.ts +++ b/hooks/react-client/useKeyboardCommands.ts @@ -6,7 +6,7 @@ type KeyboardCommandCallback = (key: KeyboardCommand) => void; const useKeyboardCommands = (fn: KeyboardCommandCallback) => { useEffect(() => { - document.addEventListener('keydown', event => { + const handleKeyDown = (event: KeyboardEvent) => { // Detect ⌘ + k on Mac, Ctrl + k on Windows if ((event.metaKey || event.ctrlKey) && event.key === 'k') { event.preventDefault(); @@ -27,9 +27,11 @@ const useKeyboardCommands = (fn: KeyboardCommandCallback) => { fn('up'); break; } - }); + }; - return () => document.removeEventListener('keydown', () => {}); + document.addEventListener('keydown', handleKeyDown); + + return () => document.removeEventListener('keydown', handleKeyDown); }, [fn]); }; diff --git a/util/searchUtils.ts b/util/searchUtils.ts new file mode 100644 index 0000000000000..6c5e1cb243b2c --- /dev/null +++ b/util/searchUtils.ts @@ -0,0 +1,10 @@ +import type { Result } from '@orama/orama'; + +import { BASE_URL } from '@/next.constants.mjs'; +import type { SearchDoc } from '@/types'; + +export const searchHitToLinkPath = (hit: Result) => { + const isAPIResult = hit.document.siteSection.toLowerCase() === 'docs'; + const basePath = isAPIResult ? BASE_URL : ''; + return `${basePath}/${hit.document.path}`; +};