diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx index 37b707f9e..67d01b6ce 100644 --- a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx +++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx @@ -41,6 +41,7 @@ class NoopResizeObserver { vi.stubGlobal('ResizeObserver', NoopResizeObserver); const { EmailViewer } = await import('../email-viewer'); +const { useSettingsStore } = await import('@/stores/settings-store'); const mailboxes = [ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, @@ -68,8 +69,24 @@ const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTML afterEach(() => { cleanup(); device.isMobile = false; + useSettingsStore.setState({ toolbarPosition: 'top' }); }); +const view = (props: { email: Email | null; isLoading: boolean }) => ( + {}} {...props} /> +); +/** The panel button: there is exactly one, in a toolbar row at the top of the reader, never in the panel. */ +const theToggle = () => { + const all = document.querySelectorAll('[data-plugin-panel-toggle]'); + expect(all).toHaveLength(1); + const toggle = all[0] as HTMLElement; + expect(toggle.closest('[data-viewer-top-toolbar="full-width"]')).toBeTruthy(); + expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull(); + const root = document.querySelector('[data-tour="email-viewer"]') ?? toggle.closest('.relative.flex-col'); + expect(root!.firstElementChild!.contains(toggle)).toBe(true); + return toggle; +}; + describe('the reader toolbar and the plugin side panel', () => { it('runs the toolbar over the side panel on a desktop', () => { show(); @@ -131,6 +148,48 @@ describe('the reader toolbar and the plugin side panel', () => { expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy(); }); + it('puts the panel button in the same toolbar row with no message open, folded or not, and while one loads', () => { + const { rerender } = render(view({ email: null, isLoading: false })); + const row = () => theToggle().closest('[data-viewer-top-toolbar]') as HTMLElement; + expect(row().hasAttribute('data-toolbar-toggle-only')).toBe(true); + // As high as the full toolbar. + expect(row().firstElementChild!.className).toContain('h-14'); + expect(theToggle().getAttribute('title')).toBe('plugin_panel_hide'); + act(() => { fireEvent.click(theToggle()); }); + // Folded: no strip and no row of the panel's own, the same button in the same row. + expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull(); + expect(document.querySelector('[data-reader-side-panel]')!.children).toHaveLength(0); + expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel_show'); + expect(theToggle().getAttribute('aria-pressed')).toBe('false'); + act(() => { fireEvent.click(theToggle()); }); + // Open: the panel holds no button row above the plugin's own header. + const panel = document.querySelector('[data-plugin-detail-panel]') as HTMLElement; + expect(panel.querySelector('button')).toBeNull(); + rerender(view({ email: null, isLoading: true })); + theToggle(); + }); + + it('keeps the panel button for a scheduled message', () => { + const scheduled = { ...email, id: 'e3', isScheduled: true, scheduledSendAt: '2030-01-01T10:00:00Z', scheduledUndoStatus: 'pending' } as unknown as Email; + render(view({ email: scheduled, isLoading: false })); + theToggle(); + }); + + it('keeps the panel button at the top when the toolbar is below the subject', () => { + useSettingsStore.setState({ toolbarPosition: 'below-subject' }); + render(view({ email, isLoading: false })); + expect(theToggle().closest('[data-toolbar-toggle-only]')).toBeTruthy(); + }); + + it('keeps the side panel frame from a message to none and back', () => { + mounts['email-detail-sidebar'] = 0; + const { rerender } = render(view({ email, isLoading: false })); + rerender(view({ email: null, isLoading: true })); + rerender(view({ email: null, isLoading: false })); + rerender(view({ email, isLoading: false })); + expect(mounts['email-detail-sidebar']).toBe(1); + }); + it('keeps the toolbar in the message column on a phone', () => { device.isMobile = true; show(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index c5b85d991..65c95b116 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1883,6 +1883,22 @@ export function EmailViewer({ setDetailSidebarForcedOpen(false); } }; + // The one button that shows and hides the plugin side panel, at the end of + // the toolbar row in every state of the reader (message, loading, none, scheduled). + const panelToggle = hasDetailSidebar && !isMobile ? ( + + ) : null; useEffect(() => onPanelRequest((request) => { if (request.slot !== 'email-detail-sidebar') return null; const state = detailPanelRef.current; @@ -3851,31 +3867,29 @@ ${printWatermark ? printWatermark.html : ''} // eslint-disable-next-line react-hooks/exhaustive-deps }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // The toolbar row with only the panel button: over the empty or loading + // reader, and on top when the user put the toolbar below the subject. Same + // height and place as the full toolbar, so the button never moves. + const toggleOnlyToolbar = panelToggle && liftToolbar ? ( +
+
+ {panelToggle} +
+
+ ) : null; + // The plugin side panel. One element in the same place whatever the viewer // shows (a message, the next one loading, none), so its frame lives on while - // the user reads on instead of starting over with every message. - const panelToggleInToolbar = !!email; + // the user reads on instead of starting over with every message. Its button + // is always at the end of the toolbar row above it. const sidePanel = ( <> {hasDetailSidebar && !isMobile && (
- {/* With a message open the toggle sits in its toolbar; without one, here. */} - {sidebarCollapsed && !panelToggleInToolbar && ( -
- -
- )} {!sidebarCollapsed && ( <> {!sidebarOverlay && ( @@ -3903,22 +3917,6 @@ ${printWatermark ? printWatermark.html : ''} ? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) } : { width: detailSidebarWidth, minWidth: detailSidebarWidth }} > - {!panelToggleInToolbar && ( -
- -
- )}
@@ -3934,7 +3932,7 @@ ${printWatermark ? printWatermark.html : ''} ref={setViewerRootEl} className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)} > - {null} + {toggleOnlyToolbar}
{content} {sidePanel} @@ -4527,22 +4525,11 @@ ${printWatermark ? printWatermark.html : ''} )}
{/* The plugin side panel: shown or hidden for every message alike. */} - {hasDetailSidebar && !isMobile && ( - - )} + {toolbarPosition === 'top' && panelToggle}
)} + {/* A scheduled message has no organize actions; the panel button still ends the row. */} + {isScheduled && toolbarPosition === 'top' && panelToggle} ); @@ -4579,7 +4566,7 @@ ${printWatermark ? printWatermark.html : ''} data-tour="email-viewer" className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)} > - {toolbarPosition === 'top' && liftToolbar && topToolbar} + {toolbarPosition === 'top' && liftToolbar ? topToolbar : toggleOnlyToolbar} {/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<>