fix: lift toasts above the quick reply box and a docked reply while they are on screen

The toast-layout assertion changes because toasts now clear a bar taller
than 5rem while a composer is open, instead of a fixed 5rem.
This commit is contained in:
Linus Rath
2026-11-04 12:53:52 +01:00
parent 0728f854d2
commit c5eb55467d
6 changed files with 34 additions and 5 deletions
@@ -63,6 +63,19 @@ describe('quick reply', () => {
});
});
// mail-p2-12: toasts covered the right half of the quick reply box.
describe('quick reply and toasts', () => {
it('counts as a bottom bar, so toasts sit above it', async () => {
document.documentElement.style.removeProperty('--bottom-controls-height');
const { unmount } = render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).not.toBe('');
unmount();
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
});
});
// mail-p2-8: Undo send on a quick reply brings its text back into this box.
describe('quick reply after Undo send', () => {
it('takes the undone text back into the box of the message it answered', async () => {
+11 -2
View File
@@ -1827,7 +1827,16 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const quickReplyRef = useRef<HTMLTextAreaElement>(null);
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const quickReplyBoxRef = useRef<HTMLDivElement>(null);
const quickReplyBoxRef = useRef<HTMLDivElement | null>(null);
// Toasts ("Archived · Undo", "Sending in 5 seconds") sit above the quick
// reply box while it is on screen, not over it (mail-p2-12).
const [quickReplyBoxShown, setQuickReplyBoxShown] = useState(false);
const quickReplyBarRef = useBottomControls(quickReplyBoxShown);
const setQuickReplyBox = useCallback((el: HTMLDivElement | null) => {
quickReplyBoxRef.current = el;
setQuickReplyBoxShown(!!el);
quickReplyBarRef(el);
}, [quickReplyBarRef]);
// Focused, the box grows by its buttons: bring them up, they'd sit below the fold.
useEffect(() => {
if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' });
@@ -6393,7 +6402,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{showQuickReply && (<div ref={quickReplyBoxRef} className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
{showQuickReply && (<div ref={setQuickReplyBox} data-quick-reply-box="" className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
+4 -1
View File
@@ -4,6 +4,7 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp } from "@/components/icons";
import { cn } from "@/lib/utils";
import { useBottomControls } from "@/hooks/use-bottom-controls";
/**
* Below this reading-pane height a docked reply can't share the pane with
@@ -34,6 +35,8 @@ export function ReplyDock({ children }: { children: ReactNode }) {
// The reader's toolbar and subject above its scrolling part: they stay.
const [readerChrome, setReaderChrome] = useState(0);
const [showMessage, setShowMessage] = useState(false);
// Toasts sit above the reply and its Send button (mail-p2-12).
const barRef = useBottomControls();
useLayoutEffect(() => {
const pane = ref.current?.parentElement;
@@ -78,7 +81,7 @@ export function ReplyDock({ children }: { children: ReactNode }) {
</button>
)}
{/* Folded, the composer stays mounted, with its text and its undo history. */}
<div className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
<div ref={barRef} className={cn("flex min-h-0 flex-1 flex-col", folded && "hidden")}>
{children}
</div>
</div>
@@ -40,7 +40,7 @@ describe('toast layout', () => {
render(<Toasts />);
const container = toastContainer();
expect(container.className).toContain('max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]');
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,0px)+0.75rem))]');
});
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
+1 -1
View File
@@ -474,7 +474,7 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// Newest at the bottom, at the end corner (bottom left in RTL);
// 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",
"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-[max(5rem,calc(var(--bottom-controls-height,0px)+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)]",
+4
View File
@@ -48,9 +48,13 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
observer?.observe(el);
window.addEventListener('resize', schedule);
// A bar inside a scrolling pane (the quick reply under a message) moves
// when the pane scrolls.
window.addEventListener('scroll', schedule, { capture: true, passive: true });
return () => {
observer?.disconnect();
window.removeEventListener('resize', schedule);
window.removeEventListener('scroll', schedule, { capture: true });
bars.delete(el);
update();
};