fix: keep toasts in the bottom corner above what is docked there now, instead of stranded over the message

Toasts took their place from values worked out earlier: the quick reply
box's height (not where it is), a fixed 80 px while any composer was
open, a fixed 400 px beside the calendar panel, and a move to the top
corner for the focused element that nothing undid once that element went.
After a page change, a move that opened the next message or a closed
reply dock they stood over the content, over the quick reply box on a
phone, or off screen beside the panel in a narrow window.

lib/toast-placement.ts now measures what is marked data-toast-avoid or
data-end-sheet where it is on screen, in the frame after anything changes.
The toast-layout, quick reply and calendar sheet tests asserted the old
classes, CSS variable and stylesheet rule, which are gone; they now check
the defaults and the marks, and toast-placement.test.tsx checks the place.
This commit is contained in:
Linus Rath
2026-11-04 18:18:14 +01:00
parent 2e6ca6eab8
commit 83f06161e6
12 changed files with 512 additions and 157 deletions
-8
View File
@@ -643,14 +643,6 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
/*
* A sheet docked at the window's end (the calendar's event and task panels) has its title
* and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19).
*/
body:has([data-end-sheet]) [data-toast-container] {
inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem);
}
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
html[data-window-controls] [data-toast-container][data-dodging] {
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
@@ -4,39 +4,25 @@ import { describe, expect, it } from 'vitest';
// pim-p2-19: toasts at the top right covered the event sheet's title and
// close button; at the bottom right they covered its Save. They stand beside
// a sheet docked at the window's end now. jsdom has no layout, so the rule is
// checked through the cascade: the stylesheet's own rule, applied in jsdom.
// a sheet docked at the window's end, or above its buttons when the window
// has no room beside it. lib/toast-placement does that for whatever is marked
// (components/ui/__tests__/toast-placement.test.tsx); here: the marks.
const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
function endSheetRule(): string {
const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css);
expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull();
return match![0];
}
const read = (...parts: string[]) => readFileSync(path.join(process.cwd(), ...parts), 'utf8');
describe('toasts beside an end sheet', () => {
it('move the stack past the sheet while one is open', () => {
const style = document.createElement('style');
style.textContent = endSheetRule();
document.head.appendChild(style);
const container = document.createElement('div');
container.setAttribute('data-toast-container', '');
document.body.appendChild(container);
expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px');
const sheet = document.createElement('div');
sheet.setAttribute('data-end-sheet', '');
document.body.appendChild(sheet);
expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)');
style.remove();
container.remove();
sheet.remove();
it('leaves the place to lib/toast-placement, with no fixed width in the stylesheet', () => {
expect(read('app', 'globals.css')).not.toMatch(/\[data-end-sheet\][^{]*\[data-toast-container\]/);
});
it('marks the calendar event and task panels as end sheets', () => {
const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8');
const app = read('components', 'calendar', 'calendar-app.tsx');
expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/);
expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40}<TaskModal/);
});
it('marks the button rows of the panels, which toasts keep clear of when there is no room beside', () => {
expect(read('components', 'calendar', 'event-modal.tsx').match(/data-toast-avoid="bottom"/g)).toHaveLength(2);
expect(read('components', 'calendar', 'task-modal.tsx')).toContain('data-toast-avoid="bottom"');
});
});
+2 -2
View File
@@ -1327,7 +1327,7 @@ export function EventModal({
)}
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between gap-2">
<div data-toast-avoid="bottom" className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
{onDelete && canEditBody && (
<IconButton label={t("events.delete")} onClick={() => onDelete(event.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive">
@@ -1721,7 +1721,7 @@ export function EventModal({
</div>
</div>
<div className="flex items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<div data-toast-avoid="bottom" className="flex items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
{isEdit && onDelete && (
<IconButton size="icon" label={t("events.delete")} onClick={() => onDelete(event!.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive">
<Trash2 className="w-4 h-4" />
+1 -1
View File
@@ -393,7 +393,7 @@ export function TaskModal({
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<div data-toast-avoid="bottom" className="flex items-center justify-between px-4 py-3 border-t border-border">
<div>
{isEdit && onDelete && task && (
<Button
@@ -86,17 +86,17 @@ describe('quick reply wording', () => {
});
});
// compose-p2-17: the toasts lift over the box by its height (toast.tsx).
// compose-p2-17: toasts stand above the box. It marks itself for
// lib/toast-placement, which measures it where it is on screen; nothing about
// it is kept on the page once it goes.
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);
it('marks the box as docked at the bottom and leaves nothing behind when it goes', async () => {
const { unmount } = render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} />);
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');
expect(document.querySelector('[data-quick-reply-box]')).toHaveAttribute('data-toast-avoid', 'bottom');
unmount();
expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('');
height.mockRestore();
expect(document.querySelector('[data-toast-avoid]')).toBeNull();
expect(document.documentElement.getAttribute('style') ?? '').not.toContain('--quick-reply-height');
});
});
+1
View File
@@ -5874,6 +5874,7 @@ export function EmailComposer({
the tools after it, and Discard as a bin at the far end. */}
<div
data-composer-footer=""
data-toast-avoid="bottom"
data-mv-compose-footer={mvWindow ? "" : undefined}
className={cn(
"@container/composer-bar flex items-center justify-between px-4 py-2.5 max-md:py-1.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
+2 -18
View File
@@ -3706,22 +3706,6 @@ export function EmailViewer({
const showReplyDraft = !!email && !!replyDraft && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled;
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !showReplyDraft && !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(() => {
// The reply draft's card takes the box's place, and its ref.
const box = quickReplyBoxRef.current;
if (!(showQuickReply || showReplyDraft) || !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, showReplyDraft]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6418,7 +6402,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* The reply already started on this message, as Gmail shows it. */}
{showReplyDraft && replyDraft && (
<div ref={quickReplyBoxRef} data-quick-reply-box="" data-testid="reply-draft" className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div ref={quickReplyBoxRef} data-quick-reply-box="" data-toast-avoid="bottom" data-testid="reply-draft" className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-muted/30 px-3 py-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-destructive shrink-0">
<File className="w-4 h-4" />
@@ -6440,7 +6424,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{showQuickReply && (<div ref={quickReplyBoxRef} 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)' }}>
{showQuickReply && (<div ref={quickReplyBoxRef} data-quick-reply-box="" data-toast-avoid="bottom" 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
+9 -22
View File
@@ -36,26 +36,10 @@ describe('toast layout', () => {
expect(titles).toEqual(['First', 'Second']);
});
it('sits above the bottom controls on a phone, and over an open composer', () => {
// Where it stands above docked boxes and beside panels: toast-placement.test.tsx.
it('starts in the bottom end corner, across the bottom on a phone', () => {
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');
});
// compose-p2-17: "Draft saved" covered the quick reply box.
it('sits over the quick reply box, by its height', () => {
render(<Toasts />);
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(<Toasts />);
const container = toastContainer();
expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]');
expect(container).not.toHaveClass('bottom-5');
expect(toastContainer()).toHaveClass('bottom-5', 'end-5', 'max-sm:inset-x-3', 'max-sm:bottom-3');
});
it('names the close button and hides it on a phone', () => {
@@ -115,6 +99,8 @@ describe('useBottomControls', () => {
);
const { unmount } = render(<Bar />);
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('64px');
// Toasts keep clear of it (lib/toast-placement).
expect(screen.getByText('tabs')).toHaveAttribute('data-toast-avoid', 'bottom');
unmount();
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
});
@@ -209,7 +195,8 @@ describe('toasts and the focused element', () => {
act(() => { (document.getElementById('save') as HTMLElement).focus(); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).toHaveAttribute('data-dodging');
expect(toastContainer()).toHaveClass('top-5');
expect(toastContainer().style.top).toBe('20px');
expect(toastContainer().style.bottom).toBe('auto');
act(() => { (document.getElementById('title') as HTMLElement).focus(); });
act(() => { vi.advanceTimersByTime(20); });
@@ -232,7 +219,7 @@ describe('toasts and the focused element', () => {
act(() => { toast.success('Email sent'); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).not.toHaveAttribute('data-dodging');
expect(toastContainer()).not.toHaveClass('top-5');
expect(toastContainer().style.top).toBe('');
});
// On a phone the top of the screen is the header with Back and the message
@@ -255,6 +242,6 @@ describe('toasts and the focused element', () => {
act(() => { toast.success('Moved to Trash'); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).not.toHaveAttribute('data-dodging');
expect(toastContainer().className).not.toMatch(/(^|\s)(max-sm:)?top-/);
expect(toastContainer().style.top).toBe('');
});
});
@@ -0,0 +1,220 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ToastContainer } from '../toast';
import { toast, useToastStore } from '@/stores/toast-store';
// Toasts were left in the middle of the content: their place was worked out
// from a box (the quick reply box's height, a bar's top, the focused element)
// at one moment and kept after the box moved, closed or went with a page
// change. The stack is measured against what is on screen now, again in the
// frame after anything changes.
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
const W = window.innerWidth; // 1024 in jsdom
const H = window.innerHeight; // 768 in jsdom
const stack = () => document.querySelector('[data-toast-container]') as HTMLElement;
function rect(top: number, left: number, width: number, height: number) {
return { top, left, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
/**
* Elements say where they are through data-rect="top,left,width,height";
* fixtures that change size change their style too, as a resize does in the
* app (jsdom has no ResizeObserver).
*/
function mockLayout() {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.hasAttribute('data-toast-container')) {
const bottom = parseFloat((this as HTMLElement).style.bottom) || 20;
return rect(H - bottom - 60, W - 20 - 360, 360, 60);
}
const r = this.getAttribute('data-rect');
if (!r) return rect(0, 0, 0, 0);
const [top, left, width, height] = r.split(',').map(Number);
return rect(top, left, width, height);
});
}
/** Run what a frame runs: observers' callbacks, then animation frames. */
async function nextFrame() {
await act(async () => {
await Promise.resolve();
vi.advanceTimersByTime(20);
});
}
function QuickReplyBox({ top = H - 90, height = 90 }: { top?: number; height?: number }) {
// Its height in its style, as the growing text field changes it in the app.
return <div data-quick-reply-box="" data-toast-avoid="bottom" data-rect={`${top},300,${W - 300},${height}`} style={{ height }} />;
}
function ComposerFooter() {
return <div data-composer-footer="" data-toast-avoid="bottom" data-rect={`${H - 56},300,${W - 300},56`} />;
}
describe('where the toast stack stands', () => {
beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
mockLayout();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('stands above the quick reply box, and comes down in the next frame when the box goes', async () => {
const { rerender } = render(<><Toasts /><QuickReplyBox /></>);
act(() => { toast.success('Archived'); });
expect(stack().style.bottom).toBe(`${90 + 12}px`);
// The message is moved: the next one is still loading, no box yet.
rerender(<><Toasts /></>);
await nextFrame();
expect(stack().style.bottom).toBe('20px');
});
it('follows the box as it grows, from where it is now rather than its height', async () => {
// On a phone the box sits above the action bar: its height alone put the
// toasts over it.
const { rerender } = render(<><Toasts /><QuickReplyBox top={H - 150} height={96} /></>);
act(() => { toast.success('Moved back'); });
expect(stack().style.bottom).toBe(`${150 + 12}px`);
rerender(<><Toasts /><QuickReplyBox top={H - 220} height={166} /></>);
await nextFrame();
expect(stack().style.bottom).toBe(`${220 + 12}px`);
});
it('moves from the reply dock\'s footer to the quick reply box when the dock closes', async () => {
const { rerender } = render(<><Toasts /><ComposerFooter /></>);
act(() => { toast.success('Draft saved'); });
expect(stack().style.bottom).toBe(`${56 + 12}px`);
rerender(<><Toasts /><QuickReplyBox top={H - 140} height={140} /></>);
await nextFrame();
expect(stack().style.bottom).toBe(`${140 + 12}px`);
});
it('starts over on another page: nothing from the last one is kept', async () => {
function Mail() { return <main><QuickReplyBox top={H - 160} height={160} /></main>; }
function Calendar() { return <main data-rect={`0,0,${W},${H}`}>month</main>; }
const { rerender } = render(<><Toasts /><Mail /></>);
act(() => { toast.success('Archived'); });
expect(stack().style.bottom).toBe(`${160 + 12}px`);
rerender(<><Toasts /><Calendar /></>);
await nextFrame();
expect(stack().style.bottom).toBe('20px');
});
it('leaves out a box scrolled out of view, one in the middle, and one under other columns', () => {
render(
<>
<Toasts />
{/* Below the fold at the end of a long message. */}
<div data-toast-avoid="bottom" data-rect={`${H + 40},300,${W - 300},90`} />
{/* A footer standing in the middle of a short form. */}
<div data-toast-avoid="bottom" data-rect={`${H / 2 - 100},300,${W - 300},60`} />
{/* Under the list column, not under the toasts. */}
<div data-toast-avoid="bottom" data-rect={`${H - 200},0,300,200`} />
</>,
);
act(() => { toast.success('Archived'); });
expect(stack().style.bottom).toBe('20px');
});
it('stands beside a panel at the window\'s end, and over it above its buttons when there is no room', async () => {
const { rerender } = render(
<><Toasts /><div data-end-sheet="" data-rect={`0,${W - 400},400,${H}`} style={{ width: 400 }}><div data-toast-avoid="bottom" data-rect={`${H - 60},${W - 400},400,60`} /></div></>,
);
act(() => { toast.success('Event created'); });
expect(stack().style.insetInlineEnd).toBe(`${400 + 20}px`);
expect(stack().style.bottom).toBe('20px');
// A window too narrow for both: the corner, above the panel's Save.
rerender(
<><Toasts /><div data-end-sheet="" data-rect={`0,${W - 700},700,${H}`} style={{ width: 700 }}><div data-toast-avoid="bottom" data-rect={`${H - 60},${W - 700},700,60`} /></div></>,
);
await nextFrame();
expect(stack().style.insetInlineEnd).toBe('20px');
expect(stack().style.bottom).toBe(`${60 + 12}px`);
rerender(<><Toasts /></>);
await nextFrame();
expect(stack().style.insetInlineEnd).toBe('20px');
expect(stack().style.bottom).toBe('20px');
});
it('comes back down when the focused element it stepped away from is gone', async () => {
// The stack goes to the top corner while it would cover the focused
// button. Removing the button sends the focus to <body> with no focusin:
// the stack stayed at the top over the next page.
function Page({ withSave }: { withSave: boolean }) {
return withSave ? <button id="save" data-rect={`${H - 70},${W - 120},80,36`}>Save</button> : <p>next page</p>;
}
const { rerender } = render(<><Toasts /><Page withSave /></>);
act(() => { toast.success('Settings saved'); });
act(() => { (document.getElementById('save') as HTMLElement).focus(); });
await nextFrame();
expect(stack()).toHaveAttribute('data-dodging');
rerender(<><Toasts /><Page withSave={false} /></>);
await nextFrame();
expect(stack()).not.toHaveAttribute('data-dodging');
expect(stack().style.bottom).toBe('20px');
});
it('stays at the bottom while the focus is in the editor of the reply dock under it', async () => {
// Above the dock's footer the stack covered the lower corner of the
// editor; stepping up for it put the toasts over the reader's toolbar.
render(
<>
<Toasts />
<ComposerFooter />
<div id="editor" contentEditable suppressContentEditableWarning tabIndex={0} data-rect={`${H - 180},${W - 500},500,120`}>Thanks</div>
</>,
);
act(() => { (document.getElementById('editor') as HTMLElement).focus(); });
act(() => { toast.success('Archived'); });
await nextFrame();
expect(stack()).not.toHaveAttribute('data-dodging');
expect(stack().style.bottom).toBe(`${56 + 12}px`);
});
it('holds still while the mouse is on its way to it, and catches up once it leaves', async () => {
const { rerender } = render(<><Toasts /></>);
act(() => { toast.success('Archived'); });
expect(stack().style.bottom).toBe('20px');
// The pointer is next to the stack when the quick reply box appears.
fireEvent.pointerMove(document, { pointerType: 'mouse', clientX: W - 200, clientY: H - 60 });
rerender(<><Toasts /><QuickReplyBox /></>);
await nextFrame();
expect(stack().style.bottom).toBe('20px');
fireEvent.pointerMove(document, { pointerType: 'mouse', clientX: 100, clientY: 100 });
await nextFrame();
expect(stack().style.bottom).toBe(`${90 + 12}px`);
});
it('keeps every toast while the pointer is on the stack, so none goes from under it', () => {
render(<Toasts />);
act(() => { toast.success('First'); });
act(() => { toast.success('Second'); });
fireEvent.pointerOver(screen.getByText('Second'), { pointerType: 'mouse' });
act(() => { vi.advanceTimersByTime(20000); });
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
fireEvent.pointerLeave(stack(), { pointerType: 'mouse' });
act(() => { vi.advanceTimersByTime(6000); });
expect(screen.queryByText('First')).not.toBeInTheDocument();
});
});
+132 -72
View File
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { TOUCH_HIT_AREA } from "@/components/ui/button";
import { useContextMenuOpen } from "@/components/ui/context-menu";
import { useMediaQuery, narrowerThan } from "@/hooks/use-media-query";
import { computeToastPlacement, stackWouldCoverFocus, TOAST_AVOID_BOTTOM, TOAST_AVOID_END, TOAST_EDGE } from "@/lib/toast-placement";
export type ToastType = "success" | "error" | "info" | "warning";
@@ -62,6 +63,8 @@ interface ToastProps {
onClose: (id: string) => void;
/** Kept out of sight for now (a menu is open over it): the countdown waits. */
held?: boolean;
/** The pointer is on the stack: counts as hovering every toast in it. */
stackHovered?: boolean;
/** The toast goes away with the keyboard focus in it: put the focus back. */
onFocusLost?: () => void;
}
@@ -105,7 +108,7 @@ const progressBarStyles = {
// A sideways swipe this far (px) on a touch screen dismisses the toast.
const SWIPE_DISMISS_DISTANCE = 72;
export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastProps) {
export function ToastItem({ toast, onClose, held = false, stackHovered = false, onFocusLost }: ToastProps) {
const tCommon = useTranslations("common");
const Icon = icons[toast.type];
const [exiting, setExiting] = useState(false);
@@ -150,7 +153,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
}, [toast, dismiss]);
const canPause = toast.pauseOnHover !== false;
const waiting = paused || held || (canPause && (hovered || focused));
const waiting = paused || held || (canPause && (hovered || focused || stackHovered));
useEffect(() => {
if (!toast.duration || toast.duration <= 0 || isProgress) return;
@@ -361,38 +364,121 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
}
/**
* True when the toast stack, in its usual place at the bottom, hides at
* least half of the element that has the focus: a button or a field. A
* large element it only overlaps at a corner (the composer's text, the
* quick reply box) stays usable, and moving the stack to the top for it put
* the toasts over the composer's close button and the reader's toolbar. The
* stack's own box is measured where it is now; while it sits at the top,
* its bottom place is worked out from the gap it left there
* (data-bottom-gap).
* Keeps the stack where lib/toast-placement puts it, while toasts are up: at
* the bottom end corner above what is docked at the bottom on screen now,
* beside a sheet at the window's end, and at the top end corner while it
* would cover the focused element (WCAG 2.4.11; not on a phone, where the
* top is the header with Back and the message actions, #1472). Measured
* again in the frame after anything on the page changes, scrolls or
* resizes, and when the focus moves or the focused element goes. It stays
* put while the pointer is on or near it, so an Undo doesn't move away from
* the pointer heading for it.
*/
export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
if (!stack) return false;
const focused = document.activeElement as HTMLElement | null;
if (!focused || focused === document.body || stack.contains(focused)) return false;
const box = stack.getBoundingClientRect();
if (box.height === 0) return false;
let top = box.top;
let bottom = box.bottom;
if (stack.dataset.dodging) {
const gap = Number(stack.dataset.bottomGap || 20);
bottom = window.innerHeight - gap;
top = bottom - box.height;
} else {
stack.dataset.bottomGap = String(Math.max(0, window.innerHeight - box.bottom));
}
const f = focused.getBoundingClientRect();
const overlapWidth = Math.min(f.right, box.right) - Math.max(f.left, box.left);
const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top);
if (overlapWidth <= 0 || overlapHeight <= 0) return false;
const area = Math.max(f.width, 0) * Math.max(f.height, 0);
return area === 0 || overlapWidth * overlapHeight >= area / 2;
function useToastPlacement(
containerRef: React.RefObject<HTMLDivElement | null>,
active: boolean,
phone: boolean,
): void {
useLayoutEffect(() => {
const stack = containerRef.current;
if (!active || !stack) return;
let frame = 0;
let pointer: { x: number; y: number } | null = null;
const near = () => {
if (!pointer || phone) return false;
const r = stack.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const m = POINTER_HOLD_MARGIN;
return pointer.x >= r.left - m && pointer.x <= r.right + m && pointer.y >= r.top - m && pointer.y <= r.bottom + m;
};
const apply = (force = false) => {
frame = 0;
if (!force && near()) return;
const rtl = getComputedStyle(stack).direction === "rtl";
const place = computeToastPlacement(stack, { phone, rtl });
const dodge = !phone && stackWouldCoverFocus(stack, place, rtl);
const set = (prop: "bottom" | "top" | "insetInlineEnd", value: string) => {
if (stack.style[prop] !== value) stack.style[prop] = value;
};
set("bottom", dodge ? "auto" : `${place.bottom}px`);
set("top", dodge ? `${TOAST_EDGE}px` : "");
set("insetInlineEnd", place.end === null ? "" : `${place.end}px`);
if (dodge) {
if (stack.dataset.dodging === undefined) stack.dataset.dodging = "true";
} else if (stack.dataset.dodging !== undefined) {
delete stack.dataset.dodging;
}
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(() => apply());
};
// Before the first paint: a new toast appears in its place.
apply(true);
// Something docked resized without a change in the page (a field growing).
const watched = new Set<Element>();
const sizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
const watch = () => {
if (!sizes) return;
const now = new Set(document.querySelectorAll(`${TOAST_AVOID_BOTTOM}, ${TOAST_AVOID_END}`));
for (const el of watched) if (!now.has(el)) { sizes.unobserve(el); watched.delete(el); }
for (const el of now) if (!watched.has(el)) { sizes.observe(el); watched.add(el); }
};
watch();
// The page changed: a box opened, closed or grew, a route rendered another
// page. Changes inside the stack itself (its own toasts) don't count.
const mutations = new MutationObserver((records) => {
if (!records.some((r) => !stack.contains(r.target))) return;
watch();
schedule();
});
mutations.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "open", "data-toast-avoid", "data-end-sheet"],
});
const onPointer = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const wasNear = near();
pointer = { x: e.clientX, y: e.clientY };
// Leaving: catch up on what changed meanwhile.
if (wasNear && !near()) schedule();
};
const onPointerOut = (e: PointerEvent) => {
if (e.relatedTarget) return;
pointer = null;
schedule();
};
// Focus moved, or the focused element went (no focusin then).
document.addEventListener("focusin", schedule);
document.addEventListener("focusout", schedule);
document.addEventListener("scroll", schedule, { capture: true, passive: true });
document.addEventListener("transitionend", schedule, true);
document.addEventListener("animationend", schedule, true);
document.addEventListener("pointermove", onPointer, { passive: true });
document.addEventListener("pointerout", onPointerOut);
window.addEventListener("resize", schedule);
return () => {
cancelAnimationFrame(frame);
mutations.disconnect();
sizes?.disconnect();
document.removeEventListener("focusin", schedule);
document.removeEventListener("focusout", schedule);
document.removeEventListener("scroll", schedule, { capture: true });
document.removeEventListener("transitionend", schedule, true);
document.removeEventListener("animationend", schedule, true);
document.removeEventListener("pointermove", onPointer);
document.removeEventListener("pointerout", onPointerOut);
window.removeEventListener("resize", schedule);
};
}, [containerRef, active, phone]);
}
/** How close (px) the mouse has to be for the stack to hold still. */
const POINTER_HOLD_MARGIN = 48;
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
const tCenter = useTranslations("notification_center");
const [mounted, setMounted] = useState(false);
@@ -405,38 +491,19 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
const containerRef = useRef<HTMLDivElement>(null);
// Where the focus was before the shortcut moved it into a toast.
const returnFocusRef = useRef<HTMLElement | null>(null);
// The stack moves to the top corner while it would cover the element that
// has the keyboard focus (WCAG 2.4.11): a Save button at the foot of the
// event editor sat right under it. Not on a phone: there the top of the
// screen is the header with Back and the message actions, which an Undo
// toast covered after a delete (#1472), and the stack already rides above
// the bottom bars.
const [dodge, setDodge] = useState(false);
// The pointer is on the stack: every countdown waits, so no toast goes
// and none moves under the pointer.
const [stackHovered, setStackHovered] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const hasToasts = toasts.length > 0;
useLayoutEffect(() => {
if (!hasToasts || phone) {
setDodge(false);
return;
}
let frame = 0;
const check = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => setDodge(stackWouldCoverFocus(containerRef.current)));
};
check();
document.addEventListener("focusin", check);
window.addEventListener("resize", check);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("focusin", check);
window.removeEventListener("resize", check);
};
}, [hasToasts, toasts.length, phone]);
useToastPlacement(containerRef, mounted && hasToasts, phone);
useEffect(() => {
if (!hasToasts) setStackHovered(false);
}, [hasToasts]);
useEffect(() => {
if (!hasToasts) return;
const onKeyDown = (e: KeyboardEvent) => {
@@ -471,22 +538,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
return createPortal(
<div
className={cn(
// 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",
// 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!",
// Newest at the bottom, at the end corner (bottom left in RTL). The
// exact place comes from useToastPlacement; these are the defaults.
"fixed bottom-5 end-5 z-[99999] flex flex-col gap-2.5",
// Phone: full width with 12px margins.
"max-sm:inset-x-3 max-sm:bottom-3",
held && "invisible"
)}
data-dodging={dodge || undefined}
// Not a live region: the toast store reads each toast out through
// lib/announce (errors assertively). A labelled region while toasts
// are up, so screen reader users can find them.
@@ -495,10 +553,12 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
role={toasts.length > 0 ? "region" : undefined}
aria-label={toasts.length > 0 ? tCenter("title") : undefined}
style={{ pointerEvents: "none" }}
onPointerOver={(e) => { if (e.pointerType === "mouse") setStackHovered(true); }}
onPointerLeave={() => setStackHovered(false)}
>
{toasts.map((toast) => (
<div key={toast.id} style={{ pointerEvents: "auto" }}>
<ToastItem toast={toast} onClose={onClose} held={held} onFocusLost={returnFocus} />
<ToastItem toast={toast} onClose={onClose} held={held} stackHovered={stackHovered} onFocusLost={returnFocus} />
</div>
))}
</div>,
+5 -1
View File
@@ -7,7 +7,9 @@ import { useEffect, useState } from 'react';
* bar and a viewer's action bar, on any screen an editor's Save/Cancel
* footer. While any is on screen,
* `--bottom-controls-height` on <html> says how far from the bottom of the
* window the topmost of them reaches, and toasts sit above it.
* window the topmost of them reaches, for what floats above them (the
* install and push prompts). Each is also marked data-toast-avoid="bottom",
* so the toasts keep clear of it (lib/toast-placement).
*/
const bars = new Set<HTMLElement>();
let frame = 0;
@@ -44,6 +46,7 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo
useEffect(() => {
if (!el || !active) return;
bars.add(el);
el.setAttribute('data-toast-avoid', 'bottom');
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
observer?.observe(el);
@@ -52,6 +55,7 @@ export function useBottomControls(active = true): (el: HTMLElement | null) => vo
observer?.disconnect();
window.removeEventListener('resize', schedule);
bars.delete(el);
el.removeAttribute('data-toast-avoid');
update();
};
}, [el, active]);
+121
View File
@@ -0,0 +1,121 @@
/**
* Where the toast stack goes, for the whole app. One rule: at the bottom end
* corner of the window (bottom left in RTL; across the bottom on a phone),
* above whatever is docked at the bottom on screen now, and beside a sheet
* docked at the window's end when there is room for it.
*
* The things to keep clear mark themselves:
* data-toast-avoid="bottom" docked at the bottom: the quick reply box, a
* composer's or form's footer, the phone's bars
* (hooks/use-bottom-controls marks those)
* data-end-sheet a panel docked at the window's end: the
* calendar's event and task panels
*
* Everything is measured where it is on screen at the moment, never kept from
* earlier: an offset worked out from a box that then moved, closed or went
* with a page change left toasts in the middle of the content.
*/
export const TOAST_AVOID_BOTTOM = '[data-toast-avoid="bottom"]';
export const TOAST_AVOID_END = '[data-end-sheet]';
/** Distance from the window's edges (px). */
export const TOAST_EDGE = 20;
/** The same on a phone, where the stack spans the screen. */
export const TOAST_PHONE_EDGE = 12;
/** Room left above something docked at the bottom (px). */
export const TOAST_GAP = 12;
/** The stack's width when it can't be measured (w-[360px] in toast.tsx). */
const STACK_WIDTH = 360;
export interface ToastPlacement {
/** From the window's bottom edge to the stack's bottom (px). */
bottom: number;
/** From the window's end edge (px); null on a phone, where the stack spans the screen. */
end: number | null;
}
function shownRect(el: Element): DOMRect | null {
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
if (getComputedStyle(el).visibility === 'hidden') return null;
return r;
}
function viewport(): { width: number; height: number } {
const root = document.documentElement;
return {
width: root.clientWidth || window.innerWidth,
height: window.innerHeight || root.clientHeight,
};
}
/** The stack's place, from what is on screen now. */
export function computeToastPlacement(stack: HTMLElement, opts: { phone: boolean; rtl: boolean }): ToastPlacement {
const { width: vw, height: vh } = viewport();
const width = Math.min(stack.offsetWidth || STACK_WIDTH, vw);
// Beside a sheet at the window's end, when the stack fits next to it.
// Without room it stays in the corner and clears the sheet's footer below.
let end: number | null = opts.phone ? null : TOAST_EDGE;
if (end !== null) {
for (const sheet of document.querySelectorAll(TOAST_AVOID_END)) {
if (stack.contains(sheet)) continue;
const r = shownRect(sheet);
if (!r) continue;
const atEnd = opts.rtl ? r.left <= 1 : r.right >= vw - 1;
if (!atEnd) continue;
const beside = (opts.rtl ? r.right : vw - r.left) + TOAST_EDGE;
if (beside + width + TOAST_EDGE <= vw) end = Math.max(end, beside);
}
}
// The columns the stack covers.
const left = end === null ? TOAST_PHONE_EDGE : opts.rtl ? end : vw - end - width;
const right = end === null ? vw - TOAST_PHONE_EDGE : left + width;
// Above what is docked at the bottom under those columns. Only what is on
// screen and sits in the lower half counts: a box scrolled out of view, or
// one standing in the middle, would put the toasts over the content.
let bottom = opts.phone ? TOAST_PHONE_EDGE : TOAST_EDGE;
for (const el of document.querySelectorAll(TOAST_AVOID_BOTTOM)) {
if (stack.contains(el)) continue;
const r = shownRect(el);
if (!r) continue;
if (r.right <= left || r.left >= right) continue;
if (r.top >= vh || r.bottom <= 0) continue;
if (r.top < vh / 2) continue;
bottom = Math.max(bottom, vh - r.top + TOAST_GAP);
}
return { bottom: Math.round(bottom), end };
}
/**
* True when the stack, at `place`, would hide at least half of the element
* that has the focus: a button or a one-line field. A large element it only
* overlaps at a corner stays usable, and a text area or editor never counts:
* moving the stack to the top for one put the toasts over the composer's close
* button and the reader's toolbar.
*/
export function stackWouldCoverFocus(stack: HTMLElement, place: ToastPlacement, rtl = false): boolean {
const focused = document.activeElement as HTMLElement | null;
if (!focused || focused === document.body || !focused.isConnected || stack.contains(focused)) return false;
// Text being written: the caret moves, and the stack over the lower corner
// of the reply dock's editor sent it to the top, over the toolbar.
if (focused.tagName === 'TEXTAREA' || focused.isContentEditable || focused.closest('[contenteditable]:not([contenteditable="false"])')) return false;
const height = stack.offsetHeight || stack.getBoundingClientRect().height;
if (height === 0) return false;
const { width: vw, height: vh } = viewport();
const width = Math.min(stack.offsetWidth || STACK_WIDTH, vw);
const bottom = vh - place.bottom;
const top = bottom - height;
const end = place.end ?? TOAST_PHONE_EDGE;
const left = rtl ? end : vw - end - width;
const right = left + width;
const f = focused.getBoundingClientRect();
const overlapWidth = Math.min(f.right, right) - Math.max(f.left, left);
const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top);
if (overlapWidth <= 0 || overlapHeight <= 0) return false;
const area = Math.max(f.width, 0) * Math.max(f.height, 0);
return area === 0 || overlapWidth * overlapHeight >= area / 2;
}