Files
topola-viewer/docs/SEARCH_SHORTCUT_DESIGN.md
2026-07-05 22:51:04 +02:00

11 KiB

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

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

  • 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

  • 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

  • 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

  • Rationale: Invoke useSearchShortcut() at the layout level. Pass hideShortcutHint={true} to the mobile SearchBar instance rendered inside mobileMenus().

4. Localized Translation Files:

5. 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.