mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 16:15:53 +00:00
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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)]",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user