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>
);
});

View File

@@ -10,6 +10,7 @@ import {MenuItem, MenuType} from './menu_item';
import {SearchBar} from './search';
import {UploadMenu} from './upload_menu';
import {UrlMenu} from './url_menu';
import {useSearch} from './use_search';
import {WikiTreeLoginMenu, WikiTreeMenu} from './wikitree_menu';
enum ScreenSize {
@@ -49,6 +50,12 @@ export function TopBar(props: Props) {
const navigate = useNavigate();
const location = useLocation();
const {searchResults, searchString, setSearchString, handleResultSelect} =
useSearch({
data: props.data,
onSelection: props.eventHandlers?.onSelection,
});
function changeView(view: string) {
const search = queryString.parse(location.search);
if (search.view !== view) {
@@ -155,11 +162,10 @@ export function TopBar(props: Props) {
<Dropdown.Menu>{chartTypeItems}</Dropdown.Menu>
</Dropdown>
<SearchBar
data={props.data}
onSelection={
props.eventHandlers?.onSelection || (() => undefined)
}
{...props}
results={searchResults}
value={searchString}
onSearchChange={setSearchString}
onResultSelect={handleResultSelect}
/>
</>
);
@@ -352,9 +358,11 @@ export function TopBar(props: Props) {
</div>
{props.showingChart && props.data && (
<SearchBar
data={props.data}
onSelection={props.eventHandlers?.onSelection || (() => undefined)}
{...props}
results={searchResults}
value={searchString}
onSearchChange={setSearchString}
onResultSelect={handleResultSelect}
hideShortcutHint={true}
/>
)}
</>
@@ -386,7 +394,7 @@ export function TopBar(props: Props) {
}
return (
<>
<div>
<Menu
as={Media}
greaterThanOrEqual="large"
@@ -407,6 +415,6 @@ export function TopBar(props: Props) {
>
{mobileMenus()}
</Menu>
</>
</div>
);
}

107
src/menu/use_search.tsx Normal file
View File

@@ -0,0 +1,107 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {IndiInfo, JsonGedcomData} from 'topola';
import {analyticsEvent} from '../util/analytics';
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
import {useSearchShortcut} from './use_search_shortcut';
/**
* Schedules a task to run during browser idle time, falling back to setTimeout.
* @returns A function that cancels the scheduled task.
*/
function scheduleIdleTask(callback: () => void, timeout = 5000): () => void {
if (typeof requestIdleCallback !== 'undefined') {
const handle = requestIdleCallback(callback, {timeout});
return () => cancelIdleCallback(handle);
}
const handle = setTimeout(callback, 200);
return () => clearTimeout(handle);
}
interface UseSearchProps {
/** Data used for the search index. */
data?: JsonGedcomData;
/** Callback triggered when a search result is selected. */
onSelection?: (indiInfo: IndiInfo) => void;
}
interface UseSearchResult {
/** The list of search results matching the current query. */
searchResults: SearchResult[];
/** The current search query string. */
searchString: string;
/** Sets the search query string. */
setSearchString: (value: string) => void;
/** Callback triggered when a search result is selected. */
handleResultSelect: (id: string) => void;
}
/**
* Encapsulates search index building, debounced querying, result selection,
* and global keyboard shortcut registration for the TopBar.
* @returns An object containing search results, search string state, and callbacks.
*/
export function useSearch({
data,
onSelection,
}: UseSearchProps): UseSearchResult {
useSearchShortcut();
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchString, setSearchString] = useState('');
const searchIndex = useRef<SearchIndex | undefined>(undefined);
const latestDataRef = useRef(data);
latestDataRef.current = data;
// Build search index once for both desktop and mobile search bars
useEffect(() => {
searchIndex.current = undefined;
if (!data) {
return;
}
const currentData = data;
const build = () => {
if (!searchIndex.current) {
searchIndex.current = buildSearchIndex(currentData);
}
};
return scheduleIdleTask(build);
}, [data]);
// Debounced search effect
useEffect(() => {
if (!searchString) {
setSearchResults((prev) => (prev.length === 0 ? prev : []));
return;
}
const handle = setTimeout(() => {
const currentData = latestDataRef.current;
if (!currentData) {
return;
}
if (!searchIndex.current) {
searchIndex.current = buildSearchIndex(currentData);
}
const index = searchIndex.current;
const results = index.search(searchString);
setSearchResults(results);
}, 200);
return () => clearTimeout(handle);
}, [searchString]);
const handleResultSelect = useCallback(
(id: string) => {
analyticsEvent('search_result_selected');
onSelection?.({id, generation: 0});
setSearchString('');
setSearchResults([]);
},
[onSelection],
);
return {
searchResults,
searchString,
setSearchString,
handleResultSelect,
};
}

View File

@@ -0,0 +1,146 @@
import {useEffect} from 'react';
const registeredSearchInputs = new Set<HTMLInputElement>();
/** Registers a search input DOM element to receive global '/' shortcut focus. */
export function registerSearchInput(input: HTMLInputElement) {
registeredSearchInputs.add(input);
}
/** Unregisters a search input DOM element. */
export function unregisterSearchInput(input: HTMLInputElement) {
registeredSearchInputs.delete(input);
}
/**
* Checks whether an element is visible without short-circuiting dead code
* or failing on position: fixed elements.
*/
function isElementVisible(el: HTMLElement): boolean {
if (typeof el.checkVisibility === 'function') {
return el.checkVisibility();
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
/**
* Checks if the user is currently typing inside a text-editable element or form control.
* Traverses all nodes in the composed path (supporting Shadow DOM) to ensure
* nested child elements inside rich text editors or ARIA textfields do not
* trigger false negatives. Protects all inputs, textareas, selects, and buttons.
*/
function isTextEditable(event: KeyboardEvent): boolean {
const path =
typeof event.composedPath === 'function'
? event.composedPath()
: [event.target];
for (const node of path) {
if (!node || !(node instanceof Element)) {
continue;
}
if (node instanceof HTMLElement && node.isContentEditable) {
return true;
}
const tagName = node.tagName.toUpperCase();
if (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT' ||
tagName === 'BUTTON'
) {
return true;
}
const role = node.getAttribute('role');
if (
role === 'textbox' ||
role === 'searchbox' ||
role === 'spinbutton' ||
role === 'combobox'
) {
return true;
}
}
return false;
}
/**
* Checks if any open modal dialog is visible in the main document or Shadow DOM.
*/
function isModalActive(event: KeyboardEvent): boolean {
const roots = new Set<Document | ShadowRoot>([document]);
const targetRoot = (event.target as Element | null)?.getRootNode();
if (
targetRoot &&
(targetRoot instanceof Document || targetRoot instanceof ShadowRoot)
) {
roots.add(targetRoot);
}
for (const root of roots) {
const modals = root.querySelectorAll(
'dialog[open], .ui.modal.visible.active',
);
for (const modal of Array.from(modals)) {
if (!(modal instanceof HTMLElement)) {
continue;
}
if (modal.getAttribute('aria-hidden') === 'true') {
continue;
}
if (isElementVisible(modal)) {
return true;
}
}
}
return false;
}
/**
* Hook that registers a centralized global '/' keyboard shortcut to focus the visible search input.
*/
export function useSearchShortcut() {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== '/') {
return;
}
if (event.repeat || event.isComposing || event.defaultPrevented) {
return;
}
// Block metaKey (Cmd). Block Ctrl or Alt unless both are pressed (AltGr).
if (
event.metaKey ||
(event.ctrlKey && !event.altKey) ||
(!event.ctrlKey && event.altKey)
) {
return;
}
if (isModalActive(event) || isTextEditable(event)) {
return;
}
for (const input of registeredSearchInputs) {
if (isElementVisible(input)) {
event.preventDefault();
input.focus({preventScroll: true});
input.select();
break;
}
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
}

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Вписани в WikiTree",
"menu.github": "Проект в GitHub",
"menu.search.placeholder": "Търсене на лице",
"menu.search.placeholder_with_shortcut": "Търсене на лице (натиснете '/')",
"menu.search.no_results": "Няма резултати",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy е приложение за преглед на родословни дървета, която дава възможност за разглеждане структурата на семейство.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Přihlášeno do WikiTree",
"menu.github": "GitHub projekt",
"menu.search.placeholder": "Hledej osobu",
"menu.search.placeholder_with_shortcut": "Hledej osobu (stiskněte '/')",
"menu.search.no_results": "Žádné výsledky",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy vám umožňuje interaktivní prohlížení rodokmenu.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Bei WikiTree angemeldet",
"menu.github": "Projekt auf der GitHub-Website",
"menu.search.placeholder": "Person suchen",
"menu.search.placeholder_with_shortcut": "Person suchen ('/' drücken)",
"menu.search.no_results": "Keine Ergebnisse",
"intro.title": "Topola Genealogie",
"intro.description": "Mit der Topola Genealogie können Sie den Stammbaum auf interaktive Weise durchsuchen.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Connecté à WikiTree",
"menu.github": "Projet sur le site Web GitHub",
"menu.search.placeholder": "Rechercher une personne",
"menu.search.placeholder_with_shortcut": "Rechercher une personne (appuyez sur '/')",
"menu.search.no_results": "Aucun résultat",
"intro.title": "Topola Généalogie",
"intro.description": "La Topola Généalogie vous permet de parcourir l'arbre généalogique de manière interactive.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Accesso a WikiTree",
"menu.github": "Progetto sul sito web GitHub",
"menu.search.placeholder": "Cerca persona",
"menu.search.placeholder_with_shortcut": "Cerca persona (premi '/')",
"menu.search.no_results": "Nessun risultato",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy ti consente di esplorare l'albero genealogico in modo interattivo.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Zalogowano do WikiTree",
"menu.github": "Projekt na stronie GitHub",
"menu.search.placeholder": "Szukaj osoby",
"menu.search.placeholder_with_shortcut": "Szukaj osoby (naciśnij '/')",
"menu.search.no_results": "Brak wyników",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy pozwala przeglądać drzewo genealogiczne w interaktywny sposób.",

View File

@@ -22,6 +22,7 @@
"menu.wikitree_popup": "Вы вошли в WikiTree",
"menu.github": "Проект на сайте GitHub",
"menu.search.placeholder": "Искать человека",
"menu.search.placeholder_with_shortcut": "Искать человека (нажмите '/')",
"menu.search.no_results": "Нет результатов",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy позволяет просматривать семейное древо в интерактивном режиме.",