feat: run the reader toolbar over the side panels to the right edge

This commit is contained in:
Linus Rath
2026-10-20 18:52:50 +02:00
parent 42cb8a8bbe
commit e7a622d4ee
2 changed files with 121 additions and 21 deletions
@@ -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(
<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
);
/** 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();
});
});
+37 -21
View File
@@ -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 = (
<div
data-viewer-top-toolbar={liftToolbar ? "full-width" : "column"}
className={cn(
"bg-background flex-shrink-0",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
<div
ref={toolbarRef}
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
>
{renderToolbarItems(true)}
{mvViewer && mvPosition && (
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
)}
</div>
</div>
</div>
);
return (
<div
ref={setViewerRootEl}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{toolbarPosition === 'top' && liftToolbar && topToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{/* 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 */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* === TOOLBAR (top position) === */}
{toolbarPosition === 'top' && (
<div className={cn(
"bg-background",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
<div
ref={toolbarRef}
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
>
{renderToolbarItems(true)}
{mvViewer && mvPosition && (
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
)}
</div>
</div>
</div>
)}
{toolbarPosition === 'top' && !liftToolbar && topToolbar}
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
@@ -6538,5 +6553,6 @@ export function EmailViewer({
)}
</div>
</div>
);
}