Public Access
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:
+19
-1
@@ -1,7 +1,10 @@
|
|||||||
import { BrowserRouter, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link, useLocation, useNavigate, Navigate } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { ShortcutHelpBanner, SHOW_SHORTCUT_HELP_EVENT } from './components/ShortcutHelpBanner'
|
||||||
import { showApiError } from './lib/toast'
|
import { showApiError } from './lib/toast'
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
import { requestFocusSearch } from './hooks/useFocusSearch'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MealDetail from './pages/MealDetail'
|
import MealDetail from './pages/MealDetail'
|
||||||
import Pantry from './pages/Pantry'
|
import Pantry from './pages/Pantry'
|
||||||
@@ -56,11 +59,25 @@ function Navigation() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GlobalShortcuts() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
'g d': () => navigate('/'),
|
||||||
|
'g r': () => navigate('/recipes'),
|
||||||
|
'g p': () => navigate('/pantry'),
|
||||||
|
'g s': () => navigate('/shopping-list'),
|
||||||
|
'/': () => requestFocusSearch(),
|
||||||
|
'?': () => window.dispatchEvent(new CustomEvent(SHOW_SHORTCUT_HELP_EVENT)),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<GlobalShortcuts />
|
||||||
<div className="min-h-screen bg-surface-50">
|
<div className="min-h-screen bg-surface-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" id="main-content">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" id="main-content">
|
||||||
@@ -76,6 +93,7 @@ function App() {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
<ShortcutHelpBanner />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, Keyboard } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global shortcut help banner. Listens for `show-shortcut-help` custom
|
||||||
|
* events on `window` and renders a dismissible banner near the top of
|
||||||
|
* the viewport. Auto-dismisses after 6s. Escape also dismisses.
|
||||||
|
*
|
||||||
|
* Triggered by the global keyboard handler when the user presses '?'.
|
||||||
|
* Pages can also dispatch the event themselves (e.g. from a tooltip).
|
||||||
|
*/
|
||||||
|
export const SHOW_SHORTCUT_HELP_EVENT = 'mealplanner:show-shortcut-help'
|
||||||
|
|
||||||
|
export const SHORTCUTS: ReadonlyArray<{ keys: string; label: string }> = [
|
||||||
|
{ keys: 'g d', label: 'Go to Dashboard' },
|
||||||
|
{ keys: 'g r', label: 'Go to Recipes' },
|
||||||
|
{ keys: 'g p', label: 'Go to Pantry' },
|
||||||
|
{ keys: 'g s', label: 'Go to Shopping List' },
|
||||||
|
{ keys: '/', label: 'Focus the search box' },
|
||||||
|
{ keys: '?', label: 'Show this help' },
|
||||||
|
{ keys: 'Esc', label: 'Close banners' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ShortcutHelpBanner() {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const show = () => {
|
||||||
|
setVisible(true)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => setVisible(false), 6000)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && visible) setVisible(false)
|
||||||
|
}
|
||||||
|
window.addEventListener(SHOW_SHORTCUT_HELP_EVENT, show)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SHOW_SHORTCUT_HELP_EVENT, show)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
className="fixed top-16 left-1/2 -translate-x-1/2 z-50 bg-white border border-surface-200 rounded-xl shadow-lg p-4 max-w-md w-[calc(100vw-2rem)] animate-fade-in"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-surface-900">
|
||||||
|
<Keyboard className="w-4 h-4 text-primary-600" />
|
||||||
|
Keyboard shortcuts
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisible(false)}
|
||||||
|
aria-label="Close shortcut help"
|
||||||
|
className="p-1 rounded text-surface-500 hover:bg-surface-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{SHORTCUTS.map((s) => (
|
||||||
|
<li key={s.keys} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-surface-600">{s.label}</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-surface-100 text-surface-700 rounded text-xs font-mono border border-surface-200">
|
||||||
|
{s.keys}
|
||||||
|
</kbd>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
|
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
@@ -10,6 +10,7 @@ import { Select } from '../components/ui/Select'
|
|||||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
import { showToast, showApiError } from '../lib/toast'
|
import { showToast, showApiError } from '../lib/toast'
|
||||||
|
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
|
||||||
|
|
||||||
const AISLE_OPTIONS = [
|
const AISLE_OPTIONS = [
|
||||||
{ value: '', label: 'Select aisle…' },
|
{ value: '', label: 'Select aisle…' },
|
||||||
@@ -21,6 +22,8 @@ export default function Pantry() {
|
|||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [removeId, setRemoveId] = useState<string | null>(null)
|
const [removeId, setRemoveId] = useState<string | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
useFocusSearchOnShortcut(searchInputRef)
|
||||||
|
|
||||||
/* ingredient name typed by user */
|
/* ingredient name typed by user */
|
||||||
const [ingredientName, setIngredientName] = useState('')
|
const [ingredientName, setIngredientName] = useState('')
|
||||||
@@ -272,6 +275,7 @@ export default function Pantry() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search pantry items..."
|
placeholder="Search pantry items..."
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card'
|
|||||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
import { Select } from '../components/ui/Select'
|
import { Select } from '../components/ui/Select'
|
||||||
|
import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch'
|
||||||
|
|
||||||
const CUISINE_OPTIONS = [
|
const CUISINE_OPTIONS = [
|
||||||
{ value: '', label: 'All cuisines' },
|
{ value: '', label: 'All cuisines' },
|
||||||
@@ -43,6 +44,8 @@ const PROTEIN_OPTIONS = [
|
|||||||
|
|
||||||
export default function RecipesPage() {
|
export default function RecipesPage() {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
useFocusSearchOnShortcut(searchInputRef)
|
||||||
const [debouncedQ, setDebouncedQ] = useState('')
|
const [debouncedQ, setDebouncedQ] = useState('')
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
|
||||||
@@ -142,6 +145,7 @@ export default function RecipesPage() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
placeholder="Search recipes..."
|
placeholder="Search recipes..."
|
||||||
|
|||||||
Reference in New Issue
Block a user