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`;
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user