import debounce from 'debounce'; import {useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Search, SearchResultProps} from 'semantic-ui-react'; import {IndiInfo, JsonGedcomData, JsonIndi} from 'topola'; import {analyticsEvent} from '../util/analytics'; import {formatDateOrRange} from '../util/date_util'; import {buildSearchIndex, SearchIndex, SearchResult} from './search_index'; function getNameLine(result: SearchResult) { const name = [result.indi.firstName, result.indi.lastName].join(' ').trim(); if (result.id.length > 8) { return name; } return ( <> {name} ({result.id}) ); } interface Props { /** Data used for the search index. */ data: JsonGedcomData; onSelection: (indiInfo: IndiInfo) => void; } /** Displays and handles the search box in the top bar. */ export function SearchBar(props: Props) { const [searchResults, setSearchResults] = useState([]); const [searchString, setSearchString] = useState(''); const searchIndex = useRef(undefined); const intl = useIntl(); function getDescriptionLine(indi: JsonIndi) { const birthDate = formatDateOrRange(indi.birth, intl); const deathDate = formatDateOrRange(indi.death, intl); if (!deathDate) { return birthDate; } return `${birthDate} – ${deathDate}`; } /** Produces an object that is displayed in the Semantic UI Search results. */ function displaySearchResult(result: SearchResult): SearchResultProps { return { id: result.id, key: result.id, title: getNameLine(result), description: getDescriptionLine(result.indi), } as SearchResultProps; } /** Returns the index, building it on first use to avoid blocking the initial render. */ function getOrBuildIndex(): SearchIndex { if (!searchIndex.current) { searchIndex.current = buildSearchIndex(props.data); } return searchIndex.current; } /** On search input change. */ function handleSearch(input: string | undefined) { if (!input) { return; } const results = getOrBuildIndex() .search(input) .map((result) => displaySearchResult(result)); setSearchResults(results); } const debouncedHandleSearch = useRef(debounce(handleSearch, 200)); /** On search result selected. */ function handleResultSelect(id: string) { analyticsEvent('search_result_selected'); props.onSelection({id, generation: 0}); setSearchString(''); } /** On search string changed. */ function onChange(value: string | undefined) { debouncedHandleSearch.current(value); setSearchString(value || ''); } // When data changes, reset the index and schedule a background rebuild so // the first keystroke doesn't block the UI. Falls back to a 200ms timeout // if requestIdleCallback is unavailable (e.g. Firefox 115, Safari 16). useEffect(() => { searchIndex.current = undefined; const build = () => { searchIndex.current = buildSearchIndex(props.data); }; if (typeof requestIdleCallback !== 'undefined') { const handle = requestIdleCallback(build, {timeout: 5000}); return () => cancelIdleCallback(handle); } const handle = setTimeout(build, 200); return () => clearTimeout(handle); }, [props.data]); return ( onChange(data.value)} onResultSelect={(_, data) => handleResultSelect(data.result.id)} results={searchResults} noResultsMessage={intl.formatMessage({ id: 'menu.search.no_results', defaultMessage: 'No results found', })} placeholder={intl.formatMessage({ id: 'menu.search.placeholder', defaultMessage: 'Search for people', })} selectFirstResult={true} value={searchString} id="search" /> ); }