diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx index 97fa140..53c4be7 100644 --- a/frontend/src/components/BulkToolbar.jsx +++ b/frontend/src/components/BulkToolbar.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react'; +import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react'; import { BulkApi } from '../api/client.js'; import { Button, useToast, @@ -49,25 +49,54 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) { }; return ( -
- {count} selected -
- - - - - - +
+ {/* Selection count — primary emphasis so it reads first. */} +
+ + + selected + + {/* Obvious clear-selection affordance, kept next to the count. */} + +
+ +
+ +
+ + + + + +
!busy && setConfirmTrash(o)}> diff --git a/frontend/src/hooks/useListKeyboardNav.js b/frontend/src/hooks/useListKeyboardNav.js index 9021191..c19518d 100644 --- a/frontend/src/hooks/useListKeyboardNav.js +++ b/frontend/src/hooks/useListKeyboardNav.js @@ -2,9 +2,15 @@ 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. +/// Keyboard navigation for an email list. Shortcuts: +/// j / ArrowDown move focus down +/// k / ArrowUp move focus up +/// e archive the focused email +/// u mark the focused email unread +/// # (shift+3) trash the focused email +/// All shortcuts are ignored while an input/textarea/select (or any +/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left +/// alone, so typing and browser/OS shortcuts are never hijacked. /// `onRemoved(id)` lets the caller drop the row from local state after a /// successful archive/trash. /// @@ -12,31 +18,59 @@ export default function useListKeyboardNav(emails, onRemoved) { const [focusedId, setFocusedId] = useState(null); useEffect(() => { + const isEditable = (el) => { + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; + }; + const handler = async (e) => { - const tag = document.activeElement?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…). + if (isEditable(document.activeElement)) return; + if (e.ctrlKey || e.metaKey || e.altKey) 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); + switch (e.key) { + case 'j': + case 'ArrowDown': { + e.preventDefault(); + const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); + setFocusedId(emails[next].id); + break; + } + case 'k': + case 'ArrowUp': { + e.preventDefault(); + const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); + setFocusedId(emails[prev].id); + break; + } + case 'e': { + if (idx < 0) break; + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.archive([id]); + onRemoved(id); + break; + } + case 'u': { + if (idx < 0) break; + e.preventDefault(); + await BulkApi.markUnread([emails[idx].id]); + break; + } + case '#': { + if (idx < 0) break; + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.trash([id]); + onRemoved(id); + break; + } + default: + break; } };