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 (
+
+ 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}
+ />
+
+ );
+});
diff --git a/src/menu/top_bar.tsx b/src/menu/top_bar.tsx
index 72b889b..00f0ab7 100644
--- a/src/menu/top_bar.tsx
+++ b/src/menu/top_bar.tsx
@@ -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) {
{chartTypeItems}
undefined)
- }
- {...props}
+ results={searchResults}
+ value={searchString}
+ onSearchChange={setSearchString}
+ onResultSelect={handleResultSelect}
/>
>
);
@@ -352,9 +358,11 @@ export function TopBar(props: Props) {
{props.showingChart && props.data && (
undefined)}
- {...props}
+ results={searchResults}
+ value={searchString}
+ onSearchChange={setSearchString}
+ onResultSelect={handleResultSelect}
+ hideShortcutHint={true}
/>
)}
>
@@ -386,7 +394,7 @@ export function TopBar(props: Props) {
}
return (
- <>
+
- >
+
);
}
diff --git a/src/menu/use_search.tsx b/src/menu/use_search.tsx
new file mode 100644
index 0000000..ced0a32
--- /dev/null
+++ b/src/menu/use_search.tsx
@@ -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([]);
+ const [searchString, setSearchString] = useState('');
+ const searchIndex = useRef(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,
+ };
+}
diff --git a/src/menu/use_search_shortcut.ts b/src/menu/use_search_shortcut.ts
new file mode 100644
index 0000000..52632cc
--- /dev/null
+++ b/src/menu/use_search_shortcut.ts
@@ -0,0 +1,146 @@
+import {useEffect} from 'react';
+
+const registeredSearchInputs = new Set();
+
+/** 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]);
+ 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);
+ };
+ }, []);
+}
diff --git a/src/translations/bg.json b/src/translations/bg.json
index b0b5be6..ad08b58 100644
--- a/src/translations/bg.json
+++ b/src/translations/bg.json
@@ -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 е приложение за преглед на родословни дървета, която дава възможност за разглеждане структурата на семейство.",
diff --git a/src/translations/cs.json b/src/translations/cs.json
index f836f7e..c8e103b 100644
--- a/src/translations/cs.json
+++ b/src/translations/cs.json
@@ -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.",
diff --git a/src/translations/de.json b/src/translations/de.json
index 281a84e..2ad5875 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -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.",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index fdf64c7..330e641 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -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.",
diff --git a/src/translations/it.json b/src/translations/it.json
index 85743c8..b195341 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -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.",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 59aef69..2be190c 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -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.",
diff --git a/src/translations/ru.json b/src/translations/ru.json
index a50c70f..77ebb92 100644
--- a/src/translations/ru.json
+++ b/src/translations/ru.json
@@ -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 позволяет просматривать семейное древо в интерактивном режиме.",
diff --git a/tests/search.spec.ts b/tests/search.spec.ts
index ddfa3ae..d347c6e 100644
--- a/tests/search.spec.ts
+++ b/tests/search.spec.ts
@@ -1,22 +1,221 @@
import {expect, test} from '@playwright/test';
import {setupGedcomRoute} from './helpers';
-test.describe('Search functionality', () => {
+test.describe('Search functionality and global shortcut', () => {
test.beforeEach(async ({context}) => {
await setupGedcomRoute(context);
});
- test('Search works', async ({page}) => {
+ test('Search works and shortcut "/" focuses and selects all text', async ({
+ page,
+ }) => {
await page.goto('/#/view?url=https://example.org/family.ged');
await expect(page.locator('#content')).not.toContainText('Chike');
- const searchInput = page.getByPlaceholder('Search for people');
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).toBeVisible();
+
+ // Fill some text, blur, and press '/'
+ await searchInput.fill('Test Query');
+ await searchInput.blur();
+ await expect(searchInput).not.toBeFocused();
+
+ await page.keyboard.press('/');
+ await expect(searchInput).toBeFocused();
+
+ // Assert that all text is selected
+ const isTextSelected = await searchInput.evaluate(
+ (el: HTMLInputElement) => {
+ return el.selectionStart === 0 && el.selectionEnd === el.value.length;
+ },
+ );
+ expect(isTextSelected).toBe(true);
+
+ // Perform search
await searchInput.fill('chik');
-
- // Wait for the debounced suggestion panel to render.
await expect(page.locator('.results')).toContainText('Chike');
-
await searchInput.press('Enter');
await expect(page.locator('#content')).toContainText('Chike');
});
+
+ test('Viewport switching: Search query is preserved when resizing between desktop and mobile', async ({
+ page,
+ }) => {
+ await page.setViewportSize({width: 1200, height: 800});
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ const desktopInput = page.getByPlaceholder("Search for people (press '/')");
+ await desktopInput.fill('chik');
+ await expect(page.locator('.results')).toContainText('Chike');
+
+ // Resize to mobile viewport
+ await page.setViewportSize({width: 500, height: 800});
+ const mobileInput = page.getByPlaceholder('Search for people', {
+ exact: true,
+ });
+ await expect(mobileInput).toBeVisible();
+ await expect(mobileInput).toHaveValue('chik');
+ await expect(page.locator('.results')).toContainText('Chike');
+ });
+
+ test('Collision safety: Typing "/" inside an active modal input types "/" and does not trigger shortcut', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ await page.getByText('Open', {exact: true}).click();
+ await page.getByText('Load from URL').click();
+
+ const modalInput = page.getByPlaceholder('https://');
+ await expect(modalInput).toBeVisible();
+ await expect(modalInput).toBeFocused();
+
+ await page.keyboard.type('http://test/data');
+ await expect(modalInput).toHaveValue('http://test/data');
+
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).not.toBeFocused();
+ });
+
+ test('Modal safety: Pressing "/" while a modal is open does not steal focus even if modal input is blurred', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ await page.getByText('Open', {exact: true}).click();
+ await page.getByText('Load from URL').click();
+
+ const modalInput = page.getByPlaceholder('https://');
+ await expect(modalInput).toBeVisible();
+ await modalInput.blur();
+
+ await page.keyboard.press('/');
+
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).not.toBeFocused();
+ });
+
+ test('Form control protection: Pressing "/" while focused on a button does not steal focus', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ await page.getByText('Open', {exact: true}).click();
+ await page.getByText('Load from URL').click();
+
+ const cancelButton = page.getByRole('button', {name: 'Cancel'});
+ await cancelButton.focus();
+ await expect(cancelButton).toBeFocused();
+
+ await page.keyboard.press('/');
+
+ await expect(cancelButton).toBeFocused();
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).not.toBeFocused();
+ });
+
+ test('Modifier exclusion: Modifier combinations do not trigger the shortcut', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).not.toBeFocused();
+
+ await page.keyboard.press('Control+/');
+ await expect(searchInput).not.toBeFocused();
+
+ await page.keyboard.press('Alt+/');
+ await expect(searchInput).not.toBeFocused();
+
+ await page.keyboard.press('Meta+/');
+ await expect(searchInput).not.toBeFocused();
+ });
+
+ test('Landing page safety: Pressing "/" on landing page has no effect and doesn\'t throw errors', async ({
+ page,
+ }) => {
+ const errors: string[] = [];
+ page.on('pageerror', (err) => {
+ errors.push(err.message);
+ });
+ page.on('console', (msg) => {
+ if (msg.type() === 'error' && !msg.text().includes('Warning:')) {
+ errors.push(msg.text());
+ }
+ });
+
+ await page.goto('/');
+ await page.waitForLoadState('domcontentloaded');
+ await page.keyboard.press('/');
+
+ expect(errors).toEqual([]);
+ });
+
+ test('Question mark safety: Pressing "?" (Shift+/) does not trigger search shortcut', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).not.toBeFocused();
+
+ await page.keyboard.press('?');
+ await expect(searchInput).not.toBeFocused();
+ });
+
+ test('Dropdown menu interaction: Pressing "/" while dropdown is open focuses search bar', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ await page.getByText('View', {exact: true}).click();
+ await expect(page.getByText('Hourglass chart')).toBeVisible();
+
+ await page.keyboard.press('/');
+
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).toBeFocused();
+ });
+
+ test('Combobox wrapper interaction: Clicking inside search bar wrapper and pressing "/" focuses input', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await page.locator('.ui.search').first().click();
+ await searchInput.blur();
+
+ await page.keyboard.press('/');
+ await expect(searchInput).toBeFocused();
+ });
+
+ test('AltGr compatibility: Control+Alt+/ triggers the shortcut', async ({
+ page,
+ }) => {
+ await page.goto('/#/view?url=https://example.org/family.ged');
+ const searchInput = page.getByPlaceholder('Search for people', {
+ exact: false,
+ });
+ await expect(searchInput).toBeVisible();
+
+ await page.keyboard.press('Control+Alt+/');
+ await expect(searchInput).toBeFocused();
+ });
});