mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
Add '/' shortcut to focus search box
This commit is contained in:
97
docs/SEARCH_SHORTCUT_DESIGN.md
Normal file
97
docs/SEARCH_SHORTCUT_DESIGN.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Global Search Keyboard Shortcut Design
|
||||
|
||||
## Business Problem
|
||||
|
||||
When exploring large family trees in Topola Genealogy, users frequently need to locate specific individuals quickly using the search feature. Currently, accessing the search box requires moving the hand to the mouse or trackpad, navigating the cursor to the top bar, and clicking the input field, which breaks the flow of keyboard-driven navigation. This document proposes introducing a global keyboard shortcut (the `/` key) to instantly focus the search input, allowing users to search without manual mouse interaction. By streamlining this transition, the application provides a faster, more accessible, and premium keyboard-centric workflow for power users navigating complex genealogical data.
|
||||
|
||||
---
|
||||
|
||||
## Technical Plan
|
||||
|
||||
The shortcut mechanism is designed to be lightweight, modular, and resilient against responsive layout duplication. Rather than introducing complex third-party shortcut libraries, the feature leverages standard web browser event handling, custom React hooks, standard accessibility attributes, and a centralized input registry.
|
||||
|
||||
### Major Components and Workflow
|
||||
|
||||
1. **The Encapsulated Hook (`useSearchShortcut`):** The keyboard shortcut logic is encapsulated within a custom hook `useSearchShortcut` (`src/menu/use_search_shortcut.ts`). Rather than being called by individual `SearchBar` components, the hook is invoked once at the layout level in `TopBar` (`src/menu/top_bar.tsx`). Individual `SearchBar` instances register their underlying `<input>` DOM elements in a centralized module-level registry (`registeredSearchInputs` `Set`). This ensures a single `window` `keydown` event listener manages focus across all responsive search bar instances.
|
||||
2. **The Modifier Guard:** To prevent conflicts with system-level and browser-level shortcuts (such as `Cmd + /` for help or extensions, or `Ctrl + /` for toggling comments), the listener ignores keydown events with `Cmd/Meta`. It also ignores `Ctrl` or `Alt` unless both are pressed simultaneously (`Ctrl + Alt`), allowing `AltGr` combinations on international keyboards to function properly.
|
||||
3. **The IME Composition & Default Prevented Guard:** To avoid hijacking keystrokes when international users are typing using an Input Method Editor (IME) for CJK (Chinese, Japanese, Korean) languages or when another event handler has already intercepted the keypress, the listener ignores keydown events when `event.isComposing === true` or `event.defaultPrevented === true`.
|
||||
4. **The Smart Filter (Collision, Repeat & Modal Guards):** Before taking action, the listener verifies `event.key === '/'` and checks three conditions:
|
||||
* **Collision Guard (`isTextEditable`):** It extracts the event path (supporting Shadow DOM encapsulation via `event.composedPath()`) and traverses all nodes in the path to check for `<input>`, `<textarea>`, `<select>`, `<button>`, elements with `contenteditable` active, or elements with `role="textbox"`, `role="searchbox"`, `role="spinbutton"`, or `role="combobox"`. This guarantees that typing `/` or pressing `/` while focused inside form controls, rich text editors, or ARIA fields does not trigger the shortcut.
|
||||
* **Repeat Guard:** It checks if the key event is repeated (`event.repeat === true`). If so, it ignores it to prevent key-repeat cycles.
|
||||
* **Modal Guard:** It inspects both the main `document` and the event target's `getRootNode()` for active modal dialogs (`dialog[open], .ui.modal.visible.active`) and ignores any modal with `aria-hidden="true"`. It verifies visibility (`checkVisibility() || getBoundingClientRect().width > 0`) without forcing synchronous layout recalculations (simple dropdown menus are not blocked). If an active visible modal is open, the shortcut is ignored to prevent stealing focus.
|
||||
If all guards pass and a visible search input is found, the listener intercepts the keypress (using `event.preventDefault()`) and initiates the focus transition.
|
||||
5. **The Centralized Search Input Registry:** Because `@artsy/fresnel` mounts both the desktop and mobile top bar menus in the DOM simultaneously, two separate instances of the `SearchBar` component exist concurrently.
|
||||
To avoid duplicate IDs in the DOM, `id="search"` is removed from the component. When mounted, each `SearchBar` registers its underlying HTML `<input>` element in a module-level `Set` (`registeredSearchInputs`) via `registerSearchInput` / `unregisterSearchInput`. When the shortcut is triggered, `useSearchShortcut` iterates through the registered inputs and identifies the active instance by checking if it is visible in the viewport (`input.checkVisibility() || input.getBoundingClientRect().width > 0`). Once found, it focuses and selects it:
|
||||
* Focus is requested with `{ preventScroll: true }` to avoid jarring page shifts.
|
||||
* `select()` is called on the HTML `<input>` element to highlight all existing text, allowing the user to immediately overwrite it with a new query.
|
||||
6. **The Translation Engine (Full Placeholder Localization):** To keep translation keys clean and avoid runtime string concatenation issues across different languages, two complete placeholder strings are defined: `"menu.search.placeholder"` (`"Search for people"`) and `"menu.search.placeholder_with_shortcut"` (`"Search for people (press '/')"`).
|
||||
* On desktop (where `hideShortcutHint` is false or omitted), `SearchBar` uses `placeholder_with_shortcut`.
|
||||
* On mobile (where `hideShortcutHint` is true), `SearchBar` uses `placeholder`, saving screen space on touch devices.
|
||||
7. **Accessibility (a11y):** The search input is decorated with the standard `aria-keyshortcuts="/"` attribute to declare the global shortcut to screen readers and assistive technologies.
|
||||
|
||||
### Component Interaction Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Web Browser (Global Window)"] -->|1. Keyboard Event ('/')| B["Shared Listener (handleKeyDown)"]
|
||||
B -->|2. Check Repeat, Composing, DefaultPrevented & Modifiers| C{"Valid Keypress?"}
|
||||
C -->|No| D["Ignore Event"]
|
||||
C -->|Yes| E{"3. Check Active Modals & isTextEditable(event)?"}
|
||||
E -->|Yes (Modal Active or Typing)| D
|
||||
E -->|No| F{"4. Iterate registeredSearchInputs for visible instance?"}
|
||||
F -->|No visible input| D
|
||||
F -->|Yes| G["5. Intercept event (preventDefault)"]
|
||||
G --> H["6. Focus (preventScroll) & Select"]
|
||||
H --> I["HTML Input Element"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Per-Component Event Listeners in `SearchBar`
|
||||
* **Alternative:** Invoke `useSearchShortcut` inside each `SearchBar` instance so that each component attaches its own `window` `keydown` event listener.
|
||||
* **Why Rejected:** Attaching multiple window event listeners concurrently (one for desktop, one for mobile) can lead to duplicate event handling or race conditions. Instead, `useSearchShortcut` is called once at the layout level in `TopBar`, while individual `SearchBar` components register their DOM `<input>` nodes in a shared module-level `Set` (`registeredSearchInputs`).
|
||||
|
||||
### 2. Using React Refs to Focus the Input
|
||||
* **Alternative:** Pass a React `ref` to `<Search>` and call `focus()` or `getBoundingClientRect()` directly on it.
|
||||
* **Why Rejected:** Semantic UI React's `Search` / `Input` components are class components or wrappers where refs do not consistently return the raw HTML `<input>` DOM node required for calling `select()`. Querying the wrapper element (`wrapperRef.current.querySelector('input')`) reliably targets the underlying HTML input element for registration.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Implementation
|
||||
|
||||
### Affected Files and Rationales
|
||||
|
||||
#### 1. [use_search_shortcut.ts](file:///home/pwiech/personal/github/topola-viewer/src/menu/use_search_shortcut.ts)
|
||||
* **Rationale:** New custom hook and registry file. Implements `registerSearchInput` / `unregisterSearchInput` to manage mounted search input instances in a module-level `Set`. Implements `isTextEditable(event)` to guard against input hijacking across standard form controls (`<input>`, `<textarea>`, `<select>`, `<button>`) and ARIA text fields (`textbox`, `searchbox`, `spinbutton`, `combobox`) by traversing all nodes in the composed path. Implements `isModalActive(event)` to verify active visible modals (`dialog[open], .ui.modal.visible.active`) across main document and Shadow DOM roots (ignoring `aria-hidden="true"`). Implements `useSearchShortcut()` to attach a single window `keydown` listener (matching `event.key === '/'` with `AltGr` support), iterate registered inputs to identify the visible instance, intercept events with `preventDefault()`, and focus/select the search input.
|
||||
|
||||
#### 2. [search.tsx](file:///home/pwiech/personal/github/topola-viewer/src/menu/search.tsx)
|
||||
* **Rationale:** Remove `id="search"` to comply with HTML specs. Register and unregister the underlying `<input>` DOM node in `registeredSearchInputs` upon mount/unmount. Select between `"menu.search.placeholder"` and `"menu.search.placeholder_with_shortcut"` based on the `hideShortcutHint` prop. Add `aria-keyshortcuts="/"` to the input shorthand.
|
||||
|
||||
#### 3. [top_bar.tsx](file:///home/pwiech/personal/github/topola-viewer/src/menu/top_bar.tsx)
|
||||
* **Rationale:** Invoke `useSearchShortcut()` at the layout level. Pass `hideShortcutHint={true}` to the mobile `SearchBar` instance rendered inside `mobileMenus()`.
|
||||
|
||||
#### 4. Localized Translation Files:
|
||||
* [de.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/de.json)
|
||||
* [pl.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/pl.json)
|
||||
* [fr.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/fr.json)
|
||||
* [it.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/it.json)
|
||||
* [ru.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/ru.json)
|
||||
* [bg.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/bg.json)
|
||||
* [cs.json](file:///home/pwiech/personal/github/topola-viewer/src/translations/cs.json)
|
||||
* **Rationale:** Define `"menu.search.placeholder_with_shortcut"` with localized translations across all supported languages.
|
||||
|
||||
#### 5. [search.spec.ts](file:///home/pwiech/personal/github/topola-viewer/tests/search.spec.ts)
|
||||
* **Rationale:** Playwright E2E test suite validating:
|
||||
* Shortcut focus and text selection upon pressing `/`.
|
||||
* Viewport switching where search query is preserved when resizing between desktop and mobile.
|
||||
* Collision safety when typing `/` into active modal inputs (e.g., "Load from URL").
|
||||
* Modal safety preventing focus theft when a modal is open and blurred.
|
||||
* Form control protection preventing focus theft when focused on buttons.
|
||||
* Modifier key exclusion (`Ctrl+/`, `Alt+/`, `Meta+/`).
|
||||
* Landing page safety where no search bar is rendered.
|
||||
* Question mark safety ensuring pressing `?` (`Shift+/`) does not trigger the shortcut.
|
||||
* Dropdown menu safety allowing the shortcut to focus the search bar while a dropdown menu is open.
|
||||
* Combobox wrapper interaction where clicking inside the search wrapper and pressing `/` focuses the input.
|
||||
* AltGr compatibility ensuring international keyboard combinations (`Ctrl+Alt+/`) trigger the shortcut.
|
||||
@@ -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) {
|
||||
);
|
||||
}
|
||||
|
||||
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<SearchResultProps[]>([]);
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const searchIndex = useRef<SearchIndex | undefined>(undefined);
|
||||
const intl = useIntl();
|
||||
|
||||
function getDescriptionLine(indi: JsonIndi) {
|
||||
const birthDate = formatDateOrRange(indi.birth, intl);
|
||||
const deathDate = formatDateOrRange(indi.death, intl);
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** Produces an object that is displayed in the Semantic UI Search results. */
|
||||
function displaySearchResult(result: SearchResult): SearchResultProps {
|
||||
function displaySearchResult(
|
||||
result: SearchResult,
|
||||
currentIntl: IntlShape,
|
||||
): SearchResultProps {
|
||||
return {
|
||||
id: result.id,
|
||||
key: result.id,
|
||||
title: getNameLine(result),
|
||||
description: getDescriptionLine(result.indi),
|
||||
description: getDescriptionLine(result.indi, currentIntl),
|
||||
} 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;
|
||||
interface Props {
|
||||
results: SearchResult[];
|
||||
value: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onResultSelect: (id: string) => void;
|
||||
hideShortcutHint?: boolean;
|
||||
}
|
||||
|
||||
/** On search input change. */
|
||||
function handleSearch(input: string | undefined) {
|
||||
if (!input) {
|
||||
/** Displays and handles the search box in the top bar. */
|
||||
export const SearchBar = React.memo(function SearchBar(props: Props) {
|
||||
const intl = useIntl();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const results = getOrBuildIndex()
|
||||
.search(input)
|
||||
.map((result) => displaySearchResult(result));
|
||||
setSearchResults(results);
|
||||
const input = el.querySelector('input');
|
||||
if (input) {
|
||||
registerSearchInput(input);
|
||||
return () => unregisterSearchInput(input);
|
||||
}
|
||||
const debouncedHandleSearch = useRef(debounce(handleSearch, 200));
|
||||
}, []);
|
||||
|
||||
/** On search result selected. */
|
||||
function handleResultSelect(id: string) {
|
||||
analyticsEvent('search_result_selected');
|
||||
props.onSelection({id, generation: 0});
|
||||
setSearchString('');
|
||||
}
|
||||
const placeholder = props.hideShortcutHint
|
||||
? intl.formatMessage({
|
||||
id: 'menu.search.placeholder',
|
||||
defaultMessage: 'Search for people',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'menu.search.placeholder_with_shortcut',
|
||||
defaultMessage: "Search for people (press '/')",
|
||||
});
|
||||
|
||||
/** 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]);
|
||||
const transformedResults = props.results.map((r) =>
|
||||
displaySearchResult(r, intl),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="item" ref={wrapperRef}>
|
||||
<Search
|
||||
onSearchChange={(_, data) => onChange(data.value)}
|
||||
onResultSelect={(_, data) => handleResultSelect(data.result.id)}
|
||||
results={searchResults}
|
||||
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={intl.formatMessage({
|
||||
id: 'menu.search.placeholder',
|
||||
defaultMessage: 'Search for people',
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
selectFirstResult={true}
|
||||
value={searchString}
|
||||
id="search"
|
||||
value={props.value}
|
||||
input={SHORTCUT_INPUT_PROP}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
107
src/menu/use_search.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
146
src/menu/use_search_shortcut.ts
Normal file
146
src/menu/use_search_shortcut.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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 е приложение за преглед на родословни дървета, която дава възможност за разглеждане структурата на семейство.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 позволяет просматривать семейное древо в интерактивном режиме.",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user