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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 21:55:36 +02:00
parent c11d747919
commit 7710d49c77
6 changed files with 78 additions and 3 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`; 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 [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -68,7 +68,7 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
return ( return (
<tr <tr
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}`} className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
onClick={openInGmail} onClick={openInGmail}
title="Open in Gmail" title="Open in Gmail"
> >
+20 -1
View File
@@ -1,5 +1,5 @@
import { Link, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; 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 { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
import Logo from './Logo.jsx'; import Logo from './Logo.jsx';
import DevBanner from './DevBanner.jsx'; import DevBanner from './DevBanner.jsx';
@@ -129,9 +129,23 @@ export default function Layout() {
const loc = useLocation(); const loc = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const searchInputRef = useRef(null);
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []); 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(() => { useEffect(() => {
AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {}); AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {});
}, []); }, []);
@@ -227,6 +241,7 @@ export default function Layout() {
</nav> </nav>
<form className="search-form" onSubmit={submitSearch}> <form className="search-form" onSubmit={submitSearch}>
<input <input
ref={searchInputRef}
className="search-input" className="search-input"
type="search" type="search"
placeholder="Search… (from:, is:unread, has:attachment)" placeholder="Search… (from:, is:unread, has:attachment)"
@@ -322,6 +337,10 @@ export default function Layout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
<div className="kbd-hint">
<kbd>/</kbd> search · <kbd>j</kbd>/<kbd>k</kbd> navigate · <kbd>e</kbd> archive · <kbd>#</kbd> trash
</div>
</div> </div>
); );
} }
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { BulkApi } from '../api/client.js';
/// <summary>
/// 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.
/// </summary>
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;
}
+3
View File
@@ -4,6 +4,7 @@ import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
const FOLDER_META = { const FOLDER_META = {
inbox: { icon: '📥', label: 'Inbox' }, inbox: { icon: '📥', label: 'Inbox' },
@@ -46,6 +47,7 @@ export default function FolderView() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); 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 // Reset when the folder changes
useEffect(() => { useEffect(() => {
@@ -126,6 +128,7 @@ export default function FolderView() {
email={e} email={e}
selected={selected.has(e.id)} selected={selected.has(e.id)}
onToggleSelect={toggle} onToggleSelect={toggle}
focused={focusedId === e.id}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />
))} ))}
+3
View File
@@ -4,6 +4,7 @@ import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -19,6 +20,7 @@ export default function SearchResults() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
useEffect(() => { useEffect(() => {
setEmails([]); setEmails([]);
@@ -94,6 +96,7 @@ export default function SearchResults() {
email={e} email={e}
selected={selected.has(e.id)} selected={selected.has(e.id)}
onToggleSelect={toggle} onToggleSelect={toggle}
focused={focusedId === e.id}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/> />
))} ))}
+2
View File
@@ -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; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer;
} }
.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); } .saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); }
.email-row--focused { outline: 1px solid var(--accent); outline-offset: -1px; }