import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react' import {SearchIcon} from '@primer/octicons-react' import {FormControl, TextInput} from '@primer/react' import {Heading, Stack, Text} from '@primer/react-brand' import {clsx} from 'clsx' import type {MdxFile} from 'nextra' import Link from 'next/link' import {useRouter} from 'next/navigation' import styles from './GlobalSearch.module.css' import type {DocsItem} from '../../../types' import {HighlightSearchTerm} from '../highlight-search-term/HighlightSearchTerm' type GlobalSearchProps = { flatDocsDirectories: DocsItem[] siteTitle: string onNavigate?: () => void } type SearchResult = { title: string description: string url: string } export const GlobalSearch = forwardRef( ({siteTitle, flatDocsDirectories, onNavigate}, forwardedRef) => { const router = useRouter() const listboxRef = useRef(null) const searchResultsRef = useRef(null) const [isSearchResultOpen, setIsSearchResultOpen] = useState(false) const [searchResults, setSearchResults] = useState([]) const [searchTerm, setSearchTerm] = useState('') const [activeDescendant, setActiveDescendant] = useState(-1) const [statusMessage, setStatusMessage] = useState('') useEffect(() => { const timeout = setTimeout(() => { setStatusMessage( searchTerm ? searchResults.length === 0 ? 'No results found' : `${searchResults.length} result${searchResults.length === 1 ? '' : 's'} available` : '', ) // debounce }, 1000) return () => clearTimeout(timeout) }, [searchTerm, searchResults.length]) useEffect(() => { const handleClickAway = (event: MouseEvent) => { if (!searchResultsRef.current?.contains(event.target as Node)) { setIsSearchResultOpen(false) } } document.addEventListener('click', handleClickAway) return () => { document.removeEventListener('click', handleClickAway) } }, []) const searchData = useMemo( () => flatDocsDirectories.reduce((acc, item) => { if (item.route === '/') return acc // remove homepage const {frontMatter, route} = item as MdxFile if (!frontMatter) return acc const result = { title: frontMatter['show-tabs'] && frontMatter['tab-label'] ? `${frontMatter.title} | ${frontMatter['tab-label']}` : frontMatter.title ? frontMatter.title : '', description: frontMatter.description ? frontMatter.description : '', url: route, } return [...acc, result] }, []), [flatDocsDirectories], ) const handleChange = (e: React.ChangeEvent) => { const value = e.target.value.toLowerCase() if (value.length === 0) { setSearchTerm(undefined) setSearchResults([]) setIsSearchResultOpen(false) return } const filteredData = searchData.filter(data => { const title = data.title.toLowerCase() const description = data.description.toLowerCase() return title.includes(value) || description.includes(value) }) const sortedData = filteredData.sort((a, b) => { const aTitle = a.title.toLowerCase() const bTitle = b.title.toLowerCase() const aIncludes = aTitle.includes(value) const bIncludes = bTitle.includes(value) if (aIncludes && !bIncludes) { return -1 } else if (!aIncludes && bIncludes) { return 1 } else { return 0 } }) setSearchResults(sortedData) setSearchTerm(value) setIsSearchResultOpen(true) return } const updateActiveDescendant = (offset: number) => { if (searchResults.length === 0) { setActiveDescendant(-1) return } // Wraps from the last item to the first and vice versa const nextActiveDescendant = (activeDescendant + offset + searchResults.length) % searchResults.length setActiveDescendant(nextActiveDescendant) listboxRef.current ?.querySelector(`#search-result-${nextActiveDescendant}`) // Scroll all the way to the top when the first item is selected ?.scrollIntoView({block: nextActiveDescendant === 0 ? 'center' : 'nearest'}) } const resetSearch = () => { setSearchTerm('') setSearchResults([]) setIsSearchResultOpen(false) setActiveDescendant(-1) } const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault() updateActiveDescendant(1) break case 'ArrowUp': e.preventDefault() updateActiveDescendant(-1) break case 'Enter': e.preventDefault() if (activeDescendant !== -1) { const selectedResult = searchResults[activeDescendant] if (selectedResult.url) { router.push(selectedResult.url) onNavigate?.() resetSearch() } } break case 'Escape': if (isSearchResultOpen) { e.preventDefault() resetSearch() } break case 'Tab': resetSearch() break default: break } } return (
Search } placeholder={`Search ${siteTitle}`} ref={forwardedRef} value={searchTerm} onChange={handleChange} onKeyDown={handleKeyDown} role="combobox" aria-activedescendant={activeDescendant === -1 ? undefined : `search-result-${activeDescendant}`} aria-autocomplete="list" aria-controls="search-results-listbox" aria-expanded={isSearchResultOpen} />
{statusMessage}
{searchTerm && (
{searchTerm && ( {searchResults.length} Results for "{searchTerm}" )} {searchResults.length > 0 ? (
    {searchResults.map((result, index) => (
  • { onNavigate?.() resetSearch() }} > {result.title} {result.description}
  • ))}
) : (
No results found
)}
)}
) }, )