diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index af8e265a9..089fc98dd 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -436,6 +436,65 @@ describe('ThreadListItem whole row toggles in selection mode', () => { }); }); +describe('ThreadListItem avatar gutter (#1381)', () => { + function setup(emails: Email[]) { + useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', density: 'regular' } as never); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); + const onEmailSelect = vi.fn(); + const onEmailDoubleClick = vi.fn(); + const onToggleExpand = vi.fn(); + const [thread] = groupEmailsByThread(emails); + const utils = render( + , + ); + const gutter = utils.container.querySelector('[data-select-gutter]')!; + return { ...utils, gutter, onEmailSelect, onEmailDoubleClick, onToggleExpand }; + } + const selection = () => [...useEmailStore.getState().selectedEmailKeys].sort(); + + it('ticks a message from beside its avatar instead of opening it', () => { + const { gutter, onEmailSelect } = setup([makeEmail()]); + fireEvent.click(gutter); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['email-1']); + fireEvent.click(gutter); + expect(selection()).toEqual([]); + }); + + it('does not open the message on a double click there', () => { + const { gutter, onEmailDoubleClick } = setup([makeEmail()]); + fireEvent.doubleClick(gutter); + expect(onEmailDoubleClick).not.toHaveBeenCalled(); + }); + + it('ticks a thread from beside its avatar, and its chevron still only unfolds it', () => { + const { gutter, container, onEmailSelect, onToggleExpand } = setup([ + makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }), + makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }), + ]); + fireEvent.click(gutter); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['newer', 'older']); + + fireEvent.click(container.querySelector('[data-expand-toggle]')!); + expect(onToggleExpand).toHaveBeenCalledTimes(1); + expect(selection()).toEqual(['newer', 'older']); + }); + + it('ticks the row once from the avatar itself', () => { + const { onEmailSelect } = setup([makeEmail()]); + fireEvent.click(screen.getByRole('checkbox')); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['email-1']); + }); +}); + describe('ThreadListItem Mountain View phone row layout', () => { it('sets the star at the end of the preview line, not under the time', () => { useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never); @@ -695,6 +754,22 @@ describe('ThreadListItem Mountain View desktop row', () => { useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); }); + it('ticks the row from its checkbox without opening it, even on a double click (#1381)', () => { + const onEmailSelect = vi.fn(); + const onEmailDoubleClick = vi.fn(); + const [thread] = groupEmailsByThread([makeEmail()]); + render( + {}} onEmailSelect={onEmailSelect} onEmailDoubleClick={onEmailDoubleClick} />, + ); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + fireEvent.click(checkbox); + fireEvent.doubleClick(checkbox); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(onEmailDoubleClick).not.toHaveBeenCalled(); + expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); + }); + it('runs the snippet on from the subject after a dash', () => { const { container } = renderRow(makeEmail({ preview: 'See you there' })); expect(container.textContent).toContain('Test Subject - See you there'); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index de8af4e3e..f66dc5019 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -135,10 +135,48 @@ function mvHoverBackground(checked: boolean, unread: boolean, selected: boolean) return unread ? "bg-background" : "bg-[var(--mv-row-read)]"; } +/** + * The strip the sender avatar sits in, from the row's leading edge to the + * text and over the row's full height. A click anywhere in it ticks the row + * instead of opening it, so a slightly missed avatar doesn't open the mail + * (#1381). It is for pointers only: the avatar inside stays the checkbox + * keyboards and screen readers use. + */ +function AvatarGutter({ + onToggle, + children, +}: { + onToggle: (e: React.MouseEvent) => void; + children: React.ReactNode; +}) { + return ( +
{ + e.stopPropagation(); + onToggle(e); + }} + // Two quick clicks tick and untick; they must not open the message too. + onDoubleClick={(e) => e.stopPropagation()} + // Hovering the strip shows the avatar's check, as hovering the avatar does. + className="group/select -ms-3 flex flex-shrink-0 cursor-pointer items-center self-stretch ps-3" + style={{ + marginBlock: 'calc(var(--density-item-py) * -1)', + paddingBlock: 'var(--density-item-py)', + marginInlineEnd: 'calc(var(--density-item-gap) * -1)', + paddingInlineEnd: 'var(--density-item-gap)', + }} + > + {children} +
+ ); +} + /** * Mountain View's leading controls: a checkbox and a star, both always * visible and both acting on the row without opening it. They take the slot - * the default layout gives to the sender avatar. + * the default layout gives to the sender avatar. The checkbox reaches from + * the row's leading edge over its full height, as in Gmail (#1381). */ function MvRowLead({ checked, @@ -156,43 +194,52 @@ function MvRowLead({ starLabel: string; }) { return ( -
+
{onToggleStar && ( - + + + )}
); @@ -686,19 +733,21 @@ const SingleEmailItem = React.forwardRef( )} {!mvRow && density !== 'extra-compact' && ( -
- - {isUnread && } -
+ +
+ + {isUnread && } +
+
)} {isUnread && !mvRow && density === 'extra-compact' && ( @@ -1385,46 +1434,48 @@ export const ThreadListItem = React.forwardRef - - {hasUnread && } - {!isMobile && !columns && ( - - )} -
+ +
+ + {hasUnread && } + {!isMobile && !columns && ( + + )} +
+
)} {hasUnread && !mvRow && density === 'extra-compact' && (