mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: run the reader toolbar over the side panels to the right edge
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user