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:
@@ -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 (
|
||||
<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}
|
||||
title="Open in Gmail"
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
</nav>
|
||||
<form className="search-form" onSubmit={submitSearch}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="search-input"
|
||||
type="search"
|
||||
placeholder="Search… (from:, is:unread, has:attachment)"
|
||||
@@ -322,6 +337,10 @@ export default function Layout() {
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className="kbd-hint">
|
||||
<kbd>/</kbd> search · <kbd>j</kbd>/<kbd>k</kbd> navigate · <kbd>e</kbd> archive · <kbd>#</kbd> trash
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user