mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 01:31:47 +00:00
Add '/' shortcut to focus search box
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user