From af8c8a6ea4a97e64d2b7178e1f28b4eb23469a70 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:39:07 +0100 Subject: [PATCH] fix: keep toasts above the quick reply box --- .../__tests__/viewer-quick-reply-words.test.tsx | 15 +++++++++++++++ components/email/email-viewer.tsx | 17 ++++++++++++++++- components/ui/__tests__/toast-layout.test.tsx | 8 ++++++++ components/ui/toast.tsx | 3 +++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx index f980a7c93..2262a4ba1 100644 --- a/components/email/__tests__/viewer-quick-reply-words.test.tsx +++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx @@ -85,3 +85,18 @@ describe('quick reply wording', () => { expect(onReply).toHaveBeenCalledWith('Thanks, will do'); }); }); + +// compose-p2-17: the toasts lift over the box by its height (toast.tsx). +describe('quick reply box and the toasts', () => { + it('marks the box and gives the page its height, and takes both back when it goes', async () => { + const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96); + const { unmount } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + + expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull(); + expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px'); + unmount(); + expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe(''); + height.mockRestore(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 80ffc1c4c..7cdfca9a8 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3516,6 +3516,21 @@ export function EmailViewer({ // Not on spam: a reply confirms the address to the sender. const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder && (!effectiveEmailContent.isHtml || iframeReady); + // Toasts sit above the quick reply box, as above a composer's footer + // (compose-p2-17): its height for toast.tsx, as it grows when focused. + useEffect(() => { + const box = quickReplyBoxRef.current; + if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return; + const root = document.documentElement; + const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`); + update(); + const observer = new ResizeObserver(update); + observer.observe(box); + return () => { + observer.disconnect(); + root.style.removeProperty('--quick-reply-height'); + }; + }, [showQuickReply]); // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); // Read aloud (browser speech): the body as shown, HTML frame or plain text. @@ -6191,7 +6206,7 @@ ${printWatermark ? printWatermark.html : ''} )} {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {showQuickReply && (
+ {showQuickReply && (
{ expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20'); }); + // compose-p2-17: "Draft saved" covered the quick reply box. + it('sits over the quick reply box, by its height', () => { + render(); + const container = toastContainer(); + expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]'); + expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]'); + }); + it('sits above a registered bottom bar on the desktop too, such as a form footer', () => { render(); const container = toastContainer(); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 33ebeab9b..8de8acca0 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: // lifted over a registered bottom bar (a form's Save/Cancel footer) // and over an open composer's footer. "fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20", + // And over the reader's quick reply box (compose-p2-17). + "[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]", // Phone: full width with 12px margins, above the bottom controls // (the tab bar, or whichever bar registered with useBottomControls). "max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]", "max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]", + "max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]", // Out of the way of the focused element: the top end corner. dodge && "top-5 bottom-auto!", held && "invisible"