diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55d236f..9f86905 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { ErrorBoundary } from './components/ErrorBoundary' +import { ShortcutHelpBanner, SHOW_SHORTCUT_HELP_EVENT } from './components/ShortcutHelpBanner' import { showApiError } from './lib/toast' +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' +import { requestFocusSearch } from './hooks/useFocusSearch' import Dashboard from './pages/Dashboard' import MealDetail from './pages/MealDetail' 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() { return ( +
@@ -76,6 +93,7 @@ function App() { } />
+
diff --git a/frontend/src/components/ShortcutHelpBanner.tsx b/frontend/src/components/ShortcutHelpBanner.tsx new file mode 100644 index 0000000..21ad0e2 --- /dev/null +++ b/frontend/src/components/ShortcutHelpBanner.tsx @@ -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 | 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 ( +
+
+
+ + Keyboard shortcuts +
+ +
+
    + {SHORTCUTS.map((s) => ( +
  • + {s.label} + + {s.keys} + +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/hooks/useFocusSearch.ts b/frontend/src/hooks/useFocusSearch.ts new file mode 100644 index 0000000..97a81c8 --- /dev/null +++ b/frontend/src/hooks/useFocusSearch.ts @@ -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 element. `enabled` lets pages opt out + * (e.g. when the search field is hidden behind a tab). + */ +export function useFocusSearchOnShortcut( + ref: RefObject, + 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]) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..3104919 --- /dev/null +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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 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 | 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() + } + }, []) +} diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index 591854a..e431fa3 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react' import { mealPlannerApi } from '../api' @@ -10,6 +10,7 @@ import { Select } from '../components/ui/Select' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' import { showToast, showApiError } from '../lib/toast' +import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch' const AISLE_OPTIONS = [ { value: '', label: 'Select aisle…' }, @@ -21,6 +22,8 @@ export default function Pantry() { const [showAddForm, setShowAddForm] = useState(false) const [removeId, setRemoveId] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) + useFocusSearchOnShortcut(searchInputRef) /* ingredient name typed by user */ const [ingredientName, setIngredientName] = useState('') @@ -272,6 +275,7 @@ export default function Pantry() {
setSearchQuery(e.target.value)} placeholder="Search pantry items..." diff --git a/frontend/src/pages/Recipes.tsx b/frontend/src/pages/Recipes.tsx index e45bcab..8215c2d 100644 --- a/frontend/src/pages/Recipes.tsx +++ b/frontend/src/pages/Recipes.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router-dom' import { @@ -13,6 +13,7 @@ import { Card, CardBody } from '../components/ui/Card' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' import { Select } from '../components/ui/Select' +import { useFocusSearchOnShortcut } from '../hooks/useFocusSearch' const CUISINE_OPTIONS = [ { value: '', label: 'All cuisines' }, @@ -43,6 +44,8 @@ const PROTEIN_OPTIONS = [ export default function RecipesPage() { const [q, setQ] = useState('') + const searchInputRef = useRef(null) + useFocusSearchOnShortcut(searchInputRef) const [debouncedQ, setDebouncedQ] = useState('') const [showFilters, setShowFilters] = useState(false) @@ -142,6 +145,7 @@ export default function RecipesPage() {
handleSearch(e.target.value)} placeholder="Search recipes..."