Add '/' shortcut to focus search box

This commit is contained in:
Przemek Więch
2026-06-30 23:49:53 +02:00
parent f11b3187a2
commit b5d41b91fb
13 changed files with 657 additions and 109 deletions

View File

@@ -1,11 +1,17 @@
import debounce from 'debounce';
import {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import React, {useEffect, useRef} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {Search, SearchResultProps} from 'semantic-ui-react';
import {IndiInfo, JsonGedcomData, JsonIndi} from 'topola';
import {analyticsEvent} from '../util/analytics';
import {JsonIndi} from 'topola';
import {formatDateOrRange} from '../util/date_util';
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
import {SearchResult} from './search_index';
import {
registerSearchInput,
unregisterSearchInput,
} from './use_search_shortcut';
const SHORTCUT_INPUT_PROP = {
'aria-keyshortcuts': '/',
};
function getNameLine(result: SearchResult) {
const name = [result.indi.firstName, result.indi.lastName].join(' ').trim();
@@ -19,103 +25,81 @@ function getNameLine(result: SearchResult) {
);
}
function getDescriptionLine(indi: JsonIndi, currentIntl: IntlShape) {
const birthDate = formatDateOrRange(indi.birth, currentIntl);
const deathDate = formatDateOrRange(indi.death, currentIntl);
if (!deathDate) {
return birthDate;
}
return `${birthDate} ${deathDate}`;
}
function displaySearchResult(
result: SearchResult,
currentIntl: IntlShape,
): SearchResultProps {
return {
id: result.id,
key: result.id,
title: getNameLine(result),
description: getDescriptionLine(result.indi, currentIntl),
} as SearchResultProps;
}
interface Props {
/** Data used for the search index. */
data: JsonGedcomData;
onSelection: (indiInfo: IndiInfo) => void;
results: SearchResult[];
value: string;
onSearchChange: (value: string) => void;
onResultSelect: (id: string) => void;
hideShortcutHint?: boolean;
}
/** Displays and handles the search box in the top bar. */
export function SearchBar(props: Props) {
const [searchResults, setSearchResults] = useState<SearchResultProps[]>([]);
const [searchString, setSearchString] = useState('');
const searchIndex = useRef<SearchIndex | undefined>(undefined);
export const SearchBar = React.memo(function SearchBar(props: Props) {
const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null);
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) {
useEffect(() => {
const el = wrapperRef.current;
if (!el) {
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 input = el.querySelector('input');
if (input) {
registerSearchInput(input);
return () => unregisterSearchInput(input);
}
const handle = setTimeout(build, 200);
return () => clearTimeout(handle);
}, [props.data]);
}, []);
return (
<Search
onSearchChange={(_, data) => 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({
const placeholder = props.hideShortcutHint
? intl.formatMessage({
id: 'menu.search.placeholder',
defaultMessage: 'Search for people',
})}
selectFirstResult={true}
value={searchString}
id="search"
/>
})
: intl.formatMessage({
id: 'menu.search.placeholder_with_shortcut',
defaultMessage: "Search for people (press '/')",
});
const transformedResults = props.results.map((r) =>
displaySearchResult(r, intl),
);
}
return (
<div className="item" ref={wrapperRef}>
<Search
onSearchChange={(_, data) => props.onSearchChange(data.value || '')}
onResultSelect={(_, data) => props.onResultSelect(data.result.id)}
results={transformedResults}
noResultsMessage={intl.formatMessage({
id: 'menu.search.no_results',
defaultMessage: 'No results found',
})}
placeholder={placeholder}
selectFirstResult={true}
value={props.value}
input={SHORTCUT_INPUT_PROP}
/>
</div>
);
});