diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index 0679442..59d0a9a 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -29,6 +29,10 @@ import { groupIntoThreads, type ThreadSummary, } from "@/lib/emailList"; +import { + nextConversationIndex, + shouldCaptureConversationPointer, +} from "@/lib/mailInteraction"; import { formatDate } from "@/lib/format"; import { loadMoreUnreads, loadMoreReads, searchEmailsAction, bulkMarkAsRead, bulkMarkAsUnread, bulkSetPin, bulkMoveToMailbox } from "@/app/(inbox)/actions"; import { deleteDraftAction } from "@/app/compose/actions"; @@ -295,6 +299,7 @@ export default function EmailListPanel({ const [archivedIds, setArchivedIds] = useState(new Set()); const [keyboardThreadId, setKeyboardThreadId] = useState(null); const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); + const shortcutHelpCloseRef = useRef(null); const rowRefs = useRef(new Map()); const longPressTimer = useRef | null>(null); @@ -355,7 +360,7 @@ export default function EmailListPanel({ // Pointer capture is only needed for touch/pen swipe gestures. Capturing a // mouse pointer can retarget the completed click to this wrapper instead // of the nested conversation link, leaving desktop rows unopenable. - if (event.pointerType === "mouse") { + if (!shouldCaptureConversationPointer(event.pointerType)) { cancelLongPress(); swipeGesture.current = null; return; @@ -772,11 +777,27 @@ export default function EmailListPanel({ useEffect(() => { if (!keyboardThreadId) return; + const behavior = window.matchMedia("(prefers-reduced-motion: reduce)") + .matches + ? "auto" + : "smooth"; rowRefs.current .get(keyboardThreadId) - ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + ?.scrollIntoView({ block: "nearest", behavior }); }, [keyboardThreadId]); + useEffect(() => { + if (!shortcutHelpOpen) return; + const previousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + shortcutHelpCloseRef.current?.focus(); + return () => { + if (previousFocus?.isConnected) previousFocus.focus(); + }; + }, [shortcutHelpOpen]); + useEffect(() => { function onKeyboardShortcut(event: KeyboardEvent) { const target = event.target as HTMLElement | null; @@ -791,6 +812,11 @@ export default function EmailListPanel({ setShortcutHelpOpen(false); return; } + if (event.key === "Escape" && selectionMode) { + event.preventDefault(); + clearSelection(); + return; + } if (isEditing || event.metaKey || event.ctrlKey || event.altKey) return; if (event.key === "/" && view === "inbox") { @@ -800,6 +826,7 @@ export default function EmailListPanel({ } if (event.key.toLowerCase() === "c") { event.preventDefault(); + if (!confirmNavigation()) return; router.push("/compose"); return; } @@ -824,11 +851,10 @@ export default function EmailListPanel({ if (event.key.toLowerCase() === "j" || event.key.toLowerCase() === "k") { event.preventDefault(); const direction = event.key.toLowerCase() === "j" ? 1 : -1; - const startingIndex = - currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 1; - const nextIndex = Math.max( - 0, - Math.min(visibleThreads.length - 1, startingIndex + direction), + const nextIndex = nextConversationIndex( + currentIndex, + visibleThreads.length, + direction, ); setKeyboardThreadId(visibleThreads[nextIndex].threadId); return; @@ -841,6 +867,7 @@ export default function EmailListPanel({ if (event.key === "Enter") { event.preventDefault(); + if (!confirmNavigation()) return; router.push( view === "spam" ? `/thread/${activeThread.threadId}?from=spam` @@ -861,6 +888,7 @@ export default function EmailListPanel({ void toggleThreadReadState(activeThread, unread); } else if (event.key.toLowerCase() === "r") { event.preventDefault(); + if (!confirmNavigation()) return; router.push(`/compose?mode=reply&id=${activeThread.latestEmail.id}`); } } @@ -873,15 +901,22 @@ export default function EmailListPanel({ archiveMailboxId, clientReadIds, clientUnreadIds, + confirmNavigation, keyboardThreadId, router, selectedEmailId, selectedThreadId, + selectionMode, shortcutHelpOpen, view, visibleThreads, ]); + const keyboardThreadAnnouncement = keyboardThreadId + ? visibleThreads.find((thread) => thread.threadId === keyboardThreadId) + ?.latestEmail.subject || "(no subject)" + : ""; + const actionBtnCls = "flex h-10 w-10 items-center justify-center rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/60 text-blue-600 dark:text-blue-400 transition-colors shrink-0"; @@ -892,6 +927,11 @@ export default function EmailListPanel({ {deferredContent}
+
+ {keyboardThreadAnnouncement + ? `Selected conversation: ${keyboardThreadAnnouncement}` + : ""} +
{/* Header */}
@@ -1035,6 +1075,7 @@ export default function EmailListPanel({
{/* Avatar — morphs to checkbox on hover / in selection mode */} -
{ if (!selectionMode) setSelectionMode(true); // Toggle all emails in this thread @@ -1371,11 +1423,12 @@ export default function EmailListPanel({ ].join(" ")}> {isChecked && }
-
+ {/* Text content */} { if (suppressLinkClick.current === thread.threadId) { e.preventDefault(); diff --git a/src/lib/__tests__/mailInteraction.test.ts b/src/lib/__tests__/mailInteraction.test.ts new file mode 100644 index 0000000..fb444bc --- /dev/null +++ b/src/lib/__tests__/mailInteraction.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + nextConversationIndex, + shouldCaptureConversationPointer, +} from "../mailInteraction"; + +describe("shouldCaptureConversationPointer", () => { + it("keeps ordinary mouse clicks native", () => { + assert.equal(shouldCaptureConversationPointer("mouse"), false); + }); + + it("retains touch and pen gesture tracking", () => { + assert.equal(shouldCaptureConversationPointer("touch"), true); + assert.equal(shouldCaptureConversationPointer("pen"), true); + }); +}); + +describe("nextConversationIndex", () => { + it("starts at the first or last conversation based on direction", () => { + assert.equal(nextConversationIndex(-1, 5, 1), 0); + assert.equal(nextConversationIndex(-1, 5, -1), 4); + }); + + it("moves within the list and stops at its bounds", () => { + assert.equal(nextConversationIndex(1, 5, 1), 2); + assert.equal(nextConversationIndex(3, 5, -1), 2); + assert.equal(nextConversationIndex(4, 5, 1), 4); + assert.equal(nextConversationIndex(0, 5, -1), 0); + }); + + it("returns no selection for an empty list", () => { + assert.equal(nextConversationIndex(-1, 0, 1), -1); + }); +}); diff --git a/src/lib/mailInteraction.ts b/src/lib/mailInteraction.ts new file mode 100644 index 0000000..5d781b9 --- /dev/null +++ b/src/lib/mailInteraction.ts @@ -0,0 +1,18 @@ +export function shouldCaptureConversationPointer(pointerType: string): boolean { + return pointerType !== "mouse"; +} + +export function nextConversationIndex( + currentIndex: number, + conversationCount: number, + direction: 1 | -1, +): number { + if (conversationCount <= 0) return -1; + if (currentIndex < 0) { + return direction === 1 ? 0 : conversationCount - 1; + } + return Math.max( + 0, + Math.min(conversationCount - 1, currentIndex + direction), + ); +}