From 6c49427c7cbed3d81bf8af4e13806314d4c006a2 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 30 Jun 2026 06:44:39 +0300 Subject: [PATCH] fix(list): shift-click on the checkbox extends the selection (range) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectRangeEmails was only wired to shift-clicking the row, but the checkbox handler called stopPropagation and a plain toggle — so shift- clicking checkboxes (the obvious affordance in selection mode) selected single messages instead of the range. Make all three checkbox handlers (email-list-item, thread single-email, thread header) shift-aware: shift -> selectRangeEmails, otherwise toggle. Adds a regression test. --- .../email/__tests__/email-list-item.test.tsx | 31 +++++++++++++++++++ components/email/email-list-item.tsx | 9 +++++- components/email/thread-list-item.tsx | 10 +++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 2a6101fd..72d41e35 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => { expect(container.querySelector('p')).toBeNull(); }); }); + +describe('EmailListItem shift-range checkbox', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' }); + }); + + it('shift-clicking the checkbox extends the selection from the anchor', () => { + const e1 = makeEmail({ id: 'e1', threadId: 't1' }); + const e2 = makeEmail({ id: 'e2', threadId: 't2' }); + const e3 = makeEmail({ id: 'e3', threadId: 't3' }); + // selection mode active (so the checkbox renders), anchor on e1 + useEmailStore.setState({ + emails: [e1, e2, e3], + selectedEmailIds: new Set(['e1']), + lastSelectedEmailId: 'e1', + selectedMailbox: 'inbox', + }); + + render(); + // the checkbox is the first button in the row (shown in selection mode) + const checkbox = screen.getAllByRole('button')[0]; + act(() => { + checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + }); + + const sel = useEmailStore.getState().selectedEmailIds; + expect(sel.has('e1')).toBe(true); + expect(sel.has('e2')).toBe(true); // the in-between row got filled in + expect(sel.has('e3')).toBe(true); + }); +}); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index b6549346..2ee2ca84 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -87,7 +87,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + // Shift-click extends the selection from the anchor to here, like + // shift-clicking the row (the checkbox stops propagation, so the + // row's shift handler never runs — replicate it here). + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index ebd8ceb0..2e9caac6 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -134,7 +134,11 @@ const SingleEmailItem = React.forwardRef( const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + if (e.shiftKey) { + selectRangeEmails(email.id); + } else { + toggleEmailSelection(email.id); + } }; const handleContextMenu = (e: React.MouseEvent) => { @@ -513,6 +517,10 @@ export const ThreadListItem = React.forwardRef { e.stopPropagation(); + if (e.shiftKey) { + selectRangeEmails(latestEmail.id); + return; + } // Toggle selection for all emails in this thread const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds);