diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 37c42021d..99156b232 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -781,3 +781,31 @@ describe('ThreadListItem date (#891)', () => { expect(container.textContent).not.toMatch(/2024/); }); }); + +describe('ThreadListItem hover actions', () => { + // When the row above is deleted from its hover actions, the next row slides + // under the resting pointer. The browser then reports a pointerover whose + // relatedTarget is the list around the row, and React builds no + // onPointerEnter from that, so the row has to arm its actions on pointerover. + const renderInList = (thread: ReturnType[number]) => + render( +
+ {}} onEmailSelect={() => {}} onDelete={() => {}} /> +
, + ); + + it.each([ + ['a single message', () => groupEmailsByThread([makeEmail()])[0]], + ['a conversation', () => groupEmailsByThread([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z' }), + ])[0]], + ])('mounts them for %s that slides under a resting pointer', (_name, makeThread) => { + const { container } = renderInList(makeThread()); + expect(screen.queryByTestId('hover-action-delete')).toBeNull(); + const row = screen.queryByTestId('email-list-item') ?? container.querySelector('[data-unread]'); + expect(row).not.toBeNull(); + fireEvent.pointerOver(row!, { relatedTarget: screen.getByTestId('list') }); + expect(screen.getByTestId('hover-action-delete')).toBeTruthy(); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2ccafb6b6..eb04344a8 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -59,8 +59,11 @@ function isMousePressOnRow(e: React.PointerEvent): boolean { * Whether a row has been pointed at yet. The hover actions stay display:none * until the pointer is over the row, so mounting them for every row up front * only added a handful of icon buttons per row to every list render. They - * mount on the first pointer-enter and stay from then on, so the hover - * behaviour itself is unchanged. + * mount on the first pointer-over and stay from then on, so the hover + * behaviour itself is unchanged. Pointer-over rather than pointer-enter: when + * a row slides under a resting pointer (the row above was just deleted from + * its hover actions), the browser's pointerover comes from the list around + * it, and React builds no onPointerEnter from that. */ /** * Role of the folder the list shows (sender vs recipient, junk actions). In @@ -77,7 +80,7 @@ export function listMailboxRole(state: { ?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined); } -function useArmOnPointerEnter(): [boolean, () => void] { +function useArmOnPointerOver(): [boolean, () => void] { const [armed, setArmed] = React.useState(false); const arm = React.useCallback(() => setArmed(true), []); return [armed, arm]; @@ -429,7 +432,7 @@ const SingleEmailItem = React.forwardRef( sourceMailboxId: selectedMailbox, }); - const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter(); + const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver(); const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( @@ -526,7 +529,7 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...touchHandlers} data-testid="email-list-item" - onPointerEnter={armHoverActions} + onPointerOver={armHoverActions} onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} @@ -980,7 +983,7 @@ export const ThreadListItem = React.forwardRef { onContextMenu?.( @@ -1126,7 +1129,7 @@ export const ThreadListItem = React.forwardRef { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'}