From e7a622d4eec8862a79562e85cbbfcdc1fe2e7cd9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:52:50 +0200 Subject: [PATCH] feat: run the reader toolbar over the side panels to the right edge --- .../viewer-toolbar-over-side-panel.test.tsx | 84 +++++++++++++++++++ components/email/email-viewer.tsx | 58 ++++++++----- 2 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx new file mode 100644 index 000000000..62e31772a --- /dev/null +++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { cleanup } from '@testing-library/react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// On a desktop the reader's top toolbar runs over the plugin side panel to +// the right edge; the panel starts below it. On a phone the toolbar stays in +// the message column. + +const device = { isMobile: false }; +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-slot': name }), +})); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'p', order: 100 }] : []), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +const show = () => render( + {}} />, +); + +/** The plugin panel, open or folded to its toggle. */ +const sidePanel = () => + (document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null) + ?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement); + +/** An item of the top toolbar. */ +const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement; + +afterEach(() => { + cleanup(); + device.isMobile = false; +}); + +describe('the reader toolbar and the plugin side panel', () => { + it('runs the toolbar over the side panel on a desktop', () => { + show(); + const panel = sidePanel(); + const item = toolbarItem(); + expect(item).toBeTruthy(); + // The box that holds the panel does not hold the toolbar... + expect(panel.parentElement!.contains(item)).toBe(false); + // ...and the toolbar comes before it, at the top of the reader. + expect(item.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const root = document.querySelector('[data-tour="email-viewer"]')!; + expect(root.firstElementChild!.contains(item)).toBe(true); + expect(root.firstElementChild!.contains(panel)).toBe(false); + }); + + it('keeps the toolbar in the message column on a phone', () => { + device.isMobile = true; + show(); + expect(document.querySelector('[data-viewer-top-toolbar="full-width"]')).toBeNull(); + expect(document.querySelector('[data-viewer-top-toolbar="column"]')).toBeTruthy(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 05993b093..174b29a97 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1157,6 +1157,9 @@ export function EmailViewer({ // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); + // The top toolbar leaves the message column on desktops and tablets, so + // it runs over the side panels to the right edge. + const liftToolbar = !isMobile; // Mountain View's conversation view: an icon-only toolbar at the top, and Reply / // Reply all / Forward as buttons under the message rather than in the bar. const mvViewer = interfaceLayout === 'mountain-view' && !isMobile; @@ -1774,6 +1777,7 @@ export function EmailViewer({ }; }, [ toolbarPosition, + liftToolbar, email?.id, showToolbarLabels, isLoading, @@ -4360,12 +4364,42 @@ export function EmailViewer({ ); + // The top toolbar. On a desktop or tablet it spans the whole reader, over + // the plugin and contact side panels too; on a phone it stays in the + // message column. + const topToolbar = ( +
+
+
+ {renderToolbarItems(true)} + {mvViewer && mvPosition && ( + + )} +
+
+
+ ); + return (
+ {toolbarPosition === 'top' && liftToolbar && topToolbar} + {/* The message and the side panels, below the toolbar. Overlays anchor here. */} +
{/* Mobile More menu sidebar overlay. Inside a Pro pane both pieces anchor to the viewer's relative root with absolute positioning: the viewport is desktop-sized there even though the pane is phone-sized, @@ -4633,26 +4667,7 @@ export function EmailViewer({ {/* Main email content */}
{/* === TOOLBAR (top position) === */} - {toolbarPosition === 'top' && ( -
-
-
- {renderToolbarItems(true)} - {mvViewer && mvPosition && ( - - )} -
-
-
- )} + {toolbarPosition === 'top' && !liftToolbar && topToolbar} @@ -6538,5 +6553,6 @@ export function EmailViewer({ )}
+
); }