From 3241f17c44db8da9f24833b41f0cbc3f9647fba4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:57:58 +0100 Subject: [PATCH] fix: open a folder from anywhere in its sidebar row #1501 --- .../__tests__/sidebar-row-click-area.test.tsx | 85 +++++++++++++++++++ components/layout/sidebar.tsx | 10 +++ 2 files changed, 95 insertions(+) create mode 100644 components/layout/__tests__/sidebar-row-click-area.test.tsx diff --git a/components/layout/__tests__/sidebar-row-click-area.test.tsx b/components/layout/__tests__/sidebar-row-click-area.test.tsx new file mode 100644 index 000000000..b09e4363b --- /dev/null +++ b/components/layout/__tests__/sidebar-row-click-area.test.tsx @@ -0,0 +1,85 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// A folder row opens its folder wherever it is clicked, not only on the +// button inside it. Mountain View draws a 32px pill around a ~20px button, +// and clicks on the pill's padding, the chevron slot or the count did +// nothing (#1501). The row's own controls keep their click. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); + +const { Sidebar } = await import('../sidebar'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { useUIStore } = await import('@/stores/ui-store'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 9, unreadEmails: 4, sortOrder: 0, myRights: { mayAddItems: true } }, + { id: 'receipts', name: 'Receipts', role: null, parentId: null, totalEmails: 5, unreadEmails: 3, sortOrder: 1, myRights: { mayAddItems: true } }, + { id: 'shops', name: 'Shops', role: null, parentId: 'receipts', totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const row = (id: string) => document.querySelector(`[data-testid="folder-row"][data-mailbox-id="${id}"]`)!; + +describe.each([ + ['Mountain View', 'mountain-view', true], + ['the default layout', 'default', false], +] as const)('folder row click area in %s', (_name, interfaceLayout, mvShell) => { + beforeEach(() => { + useUIStore.setState({ sidebarCollapsed: false } as never); + useSettingsStore.setState({ interfaceLayout, hideAccountSwitcher: true, enableUnifiedMailbox: false } as never); + }); + + it('opens the folder from a click on the row outside its button', () => { + const onMailboxSelect = vi.fn(); + render(); + fireEvent.click(row('receipts')); + expect(onMailboxSelect).toHaveBeenCalledWith('receipts'); + }); + + it('opens the folder from a click on its count', () => { + const onMailboxSelect = vi.fn(); + render(); + fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"]')!); + expect(onMailboxSelect).toHaveBeenCalledWith('receipts'); + }); + + it('shows the pointer over the whole row', () => { + render( {}} mvShell={mvShell} />); + expect(row('receipts').className).toContain('cursor-pointer'); + }); + + it('only expands or collapses the folder from its chevron', () => { + const onMailboxSelect = vi.fn(); + render(); + const shownBefore = row('shops') !== null; + fireEvent.click(row('receipts').querySelector('[data-testid="folder-expand-toggle"]')!); + expect(row('shops') !== null).toBe(!shownBefore); + expect(onMailboxSelect).not.toHaveBeenCalled(); + }); +}); + +describe('folder row click area with the unread filter', () => { + it('filters to unread from the unread count without also opening the folder', () => { + useSettingsStore.setState({ interfaceLayout: 'default', hideAccountSwitcher: true, enableUnifiedMailbox: false } as never); + const onMailboxSelect = vi.fn(); + const onUnreadFilterClick = vi.fn(); + render(); + fireEvent.click(row('receipts').querySelector('[data-testid="folder-counts"] button')!); + expect(onUnreadFilterClick).toHaveBeenCalledWith('receipts'); + expect(onMailboxSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a76ce0940..a98c60099 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -430,11 +430,20 @@ function SidebarRow({ const longPress = useLongPress((point) => { if (rowRef.current && onContextMenu) onContextMenu(menuEventAt(rowRef.current, point)); }, hasMenu); + // The whole row opens the folder, not just its button: the row's padding, + // the chevron slot and the space around the count were dead (#1501). The + // button stays the keyboard target; controls of their own keep their click. + const openFromRow = !isVirtual && onClick + ? (e: React.MouseEvent) => { + if (!(e.target as Element).closest('button, a, input, [role="button"]')) onClick(); + } + : undefined; return (