From 7710d49c77fbba8a0f16e6058cc27b24f992b938 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 21:55:36 +0200 Subject: [PATCH] feat: keyboard shortcuts for email navigation '/' focuses the search bar from anywhere (Layout-level listener). j/k move focus up/down the email list in FolderView and SearchResults; e archives the focused email, # trashes it. All shortcuts are ignored while an input/textarea/select has focus, so typing is never hijacked. A small hint footer in the bottom-right reminds users of the shortcuts. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/EmailRow.jsx | 4 +- frontend/src/components/Layout.jsx | 21 ++++++++++- frontend/src/hooks/useListKeyboardNav.js | 48 ++++++++++++++++++++++++ frontend/src/pages/FolderView.jsx | 3 ++ frontend/src/pages/SearchResults.jsx | 3 ++ frontend/src/styles.css | 2 + 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/useListKeyboardNav.js diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx index d7819b5..eb00ca8 100644 --- a/frontend/src/components/EmailRow.jsx +++ b/frontend/src/components/EmailRow.jsx @@ -16,7 +16,7 @@ const fmtSize = (b) => { return `${(b / 1048576).toFixed(1)} MB`; }; -export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect }) { +export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); @@ -68,7 +68,7 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS return ( diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 777883c..112c6cc 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,5 +1,5 @@ import { Link, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js'; import Logo from './Logo.jsx'; import DevBanner from './DevBanner.jsx'; @@ -129,9 +129,23 @@ export default function Layout() { const loc = useLocation(); const navigate = useNavigate(); + const searchInputRef = useRef(null); useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []); + // "/" focuses the search bar from anywhere, unless already typing in a field. + useEffect(() => { + const handler = (e) => { + if (e.key !== '/') return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + searchInputRef.current?.focus(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + useEffect(() => { AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {}); }, []); @@ -227,6 +241,7 @@ export default function Layout() {
+ +
+ / search · j/k navigate · e archive · # trash +
); } diff --git a/frontend/src/hooks/useListKeyboardNav.js b/frontend/src/hooks/useListKeyboardNav.js new file mode 100644 index 0000000..9021191 --- /dev/null +++ b/frontend/src/hooks/useListKeyboardNav.js @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { BulkApi } from '../api/client.js'; + +/// +/// j/k move focus down/up the list, e archives the focused email, # (shift+3) +/// trashes it. Ignored while an input/textarea/select has focus, or while +/// the "/" search shortcut is active, so typing is never hijacked. +/// `onRemoved(id)` lets the caller drop the row from local state after a +/// successful archive/trash. +/// +export default function useListKeyboardNav(emails, onRemoved) { + const [focusedId, setFocusedId] = useState(null); + + useEffect(() => { + const handler = async (e) => { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (!emails.length) return; + + const idx = emails.findIndex((x) => x.id === focusedId); + + if (e.key === 'j') { + e.preventDefault(); + const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); + setFocusedId(emails[next].id); + } else if (e.key === 'k') { + e.preventDefault(); + const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); + setFocusedId(emails[prev].id); + } else if (e.key === 'e' && idx >= 0) { + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.archive([id]); + onRemoved(id); + } else if (e.key === '#' && idx >= 0) { + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.trash([id]); + onRemoved(id); + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [emails, focusedId, onRemoved]); + + return focusedId; +} diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index fb56f64..da35f92 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -4,6 +4,7 @@ import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; import useSelection from '../hooks/useSelection.js'; +import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; const FOLDER_META = { inbox: { icon: '📥', label: 'Inbox' }, @@ -46,6 +47,7 @@ export default function FolderView() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); + const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); // Reset when the folder changes useEffect(() => { @@ -126,6 +128,7 @@ export default function FolderView() { email={e} selected={selected.has(e.id)} onToggleSelect={toggle} + focused={focusedId === e.id} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx index fb501fe..dd49eee 100644 --- a/frontend/src/pages/SearchResults.jsx +++ b/frontend/src/pages/SearchResults.jsx @@ -4,6 +4,7 @@ import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; import useSelection from '../hooks/useSelection.js'; +import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; const PAGE_SIZE = 50; @@ -19,6 +20,7 @@ export default function SearchResults() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); + const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); useEffect(() => { setEmails([]); @@ -94,6 +96,7 @@ export default function SearchResults() { email={e} selected={selected.has(e.id)} onToggleSelect={toggle} + focused={focusedId === e.id} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} /> ))} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b568451..8132871 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -410,3 +410,5 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; } .saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); } + +.email-row--focused { outline: 1px solid var(--accent); outline-offset: -1px; }