feat(ui): global keyboard shortcuts + shortcut help banner (Sprint 5 F2)

F2 — Vim-style keyboard shortcuts (the audit's F2 / H7 finding).

New files:
- frontend/src/hooks/useKeyboardShortcuts.ts: lightweight global
  handler. Supports both single keys ('/', '?', 'Escape') and
  vim-style 2-key sequences ('g d', 'g r', 'g p', 'g s' for nav).
  Sequence timeout is 1500ms; pending prefix is cleared on any
  unrecognised key so typing 'g' alone is safe. Suppressed when
  the user is typing in an input/textarea/select/contenteditable,
  or when any modifier key (Ctrl/Cmd/Alt) is held — those chords
  belong to the browser or other handlers. Uses a ref so the
  listener is registered once and always sees the latest callbacks.

- frontend/src/hooks/useFocusSearch.ts: tiny CustomEvent bus.
  requestFocusSearch() dispatches a 'mealplanner:focus-search'
  event; useFocusSearchOnShortcut(ref) subscribes and focuses the
  supplied input. The decoupling lets any page opt in without the
  global handler needing to know the page's DOM.

- frontend/src/components/ShortcutHelpBanner.tsx: dismissible help
  dialog that slides down under the nav when '?' is pressed.
  Auto-dismisses after 6s; Escape also dismisses. role=dialog +
  aria-label for screen readers; the kbd elements use the
  <kbd> semantic for assistive tech.

Wired in App.tsx:
- New <GlobalShortcuts /> child of <BrowserRouter> calls
  useKeyboardShortcuts with the 4 nav sequences, '/' →
  requestFocusSearch(), and '?' → dispatch SHOW_SHORTCUT_HELP_EVENT.
- <ShortcutHelpBanner /> mounted inside the page wrapper (after
  <main>).

Pantry and Recipes now call useFocusSearchOnShortcut with a
forwardRef attached to their top search inputs. Recipes's search
already debounced via handleSearch so focusing just selects the
existing text for the user to replace. Pantry's search is a plain
controlled input, same treatment.

Behaviour summary:
- g d / g r / g p / g s → navigate to the 4 main pages
- / → focus the search input on the current page (Pantry + Recipes
  only — other pages have no search)
- ? → show the help banner
- All shortcuts are no-ops inside text-entry controls, so a user
  typing 'p' into the pantry search box will not trigger navigation.

Build: tsc 0 errors, vite 0 errors. 5 files, +185/-3.
This commit is contained in:
2026-06-04 12:36:32 -07:00
parent d78bd1864e
commit f740f40103
6 changed files with 253 additions and 3 deletions
+42
View File
@@ -0,0 +1,42 @@
/**
* Tiny bus for "focus the first search input on this page" requests
* dispatched by the global keyboard shortcut handler. Pages that have
* a top-of-page search input call `useFocusSearchOnShortcut(inputRef)`
* to listen; the global handler in App.tsx dispatches the event when
* the user presses '/'.
*
* The bus is just a CustomEvent on `window`. We don't use a full
* EventEmitter because nothing else needs it and a DOM event gives
* us bubbling + capture for free if we ever want it.
*/
import { useEffect, type RefObject } from 'react'
export const FOCUS_SEARCH_EVENT = 'mealplanner:focus-search'
/** Dispatch a focus-search request. Called from the global keyboard handler. */
export function requestFocusSearch(): void {
window.dispatchEvent(new CustomEvent(FOCUS_SEARCH_EVENT))
}
/**
* Subscribe a search input to focus-search requests. `ref` must point
* to the underlying <input> element. `enabled` lets pages opt out
* (e.g. when the search field is hidden behind a tab).
*/
export function useFocusSearchOnShortcut(
ref: RefObject<HTMLInputElement>,
enabled = true,
): void {
useEffect(() => {
if (!enabled) return
const handler = () => {
const el = ref.current
if (!el) return
el.focus()
// Select existing text so the user can replace it with a new query.
el.select()
}
window.addEventListener(FOCUS_SEARCH_EVENT, handler)
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, handler)
}, [ref, enabled])
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Lightweight global keyboard shortcut handler.
*
* Two shortcut flavours are supported:
* - Plain keys: '?', '/', 'Escape' fire on keydown of that key.
* - Sequences: 'g d' fires after the user presses 'g' followed by
* 'd' within SEQUENCE_TIMEOUT_MS. The 'g' prefix is
* reset if no second key arrives in time, so typing
* 'g' alone (e.g. into a future textbox) is safe.
*
* Shortcuts are suppressed when the user is typing in an input,
* textarea, or contenteditable element (or when any modifier key is
* held) — that is the entire point of `data-shortcut="false"` being
* the default and the hook reading `event.target`.
*
* The hook owns no state and never throws. Components opt in by
* calling `useKeyboardShortcuts(map)`. The `map` is an object whose
* keys are shortcut strings and values are callbacks. Use
* `[shortcut, callback]` tuple form when the callback would close
* over changing values, to avoid registering a new listener on every
* render.
*/
import { useEffect, useRef } from 'react'
const SEQUENCE_TIMEOUT_MS = 1500
export type ShortcutMap = Record<string, () => void>
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return false
}
function normaliseKey(e: KeyboardEvent): string | null {
// Ignore modifier-key chords (Ctrl+R, Cmd+L, etc.) — those belong to
// the browser, OS, or other registered handlers.
if (e.ctrlKey || e.metaKey || e.altKey) return null
const k = e.key
if (k === ' ' || k === 'Tab' || k === 'Enter') return null
return k.length === 1 ? k.toLowerCase() : k
}
export function useKeyboardShortcuts(map: ShortcutMap): void {
// Use a ref so the listener (registered once) always sees the latest
// callbacks without re-binding on every render.
const mapRef = useRef(map)
mapRef.current = map
useEffect(() => {
let pendingPrefix: string | null = null
let pendingTimer: ReturnType<typeof setTimeout> | null = null
const clearPending = () => {
pendingPrefix = null
if (pendingTimer !== null) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return
const k = normaliseKey(e)
if (k === null) return
// 1) Try the full key first (handles '?', 'Escape', '/', and
// any 2-key sequence the caller registered as a single string).
const full = pendingPrefix ? `${pendingPrefix} ${k}` : k
const cb = mapRef.current[full] ?? (pendingPrefix ? null : mapRef.current[k])
if (cb) {
e.preventDefault()
cb()
clearPending()
return
}
// 2) Otherwise, if this key is itself a registered prefix
// (e.g. 'g' with map having 'g d', 'g r', ...), arm the
// sequence and wait for the next key.
const isPrefix = Object.keys(mapRef.current).some(
(key) => key.length > 1 && key.startsWith(`${k} `) && !key.includes(' '),
)
if (isPrefix && !pendingPrefix) {
e.preventDefault()
pendingPrefix = k
pendingTimer = setTimeout(clearPending, SEQUENCE_TIMEOUT_MS)
return
}
// Unrecognised key — drop any pending prefix silently.
clearPending()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
clearPending()
}
}, [])
}