fix: show toasts above the phone's bottom bars, newest at the bottom, and let a swipe dismiss them

This commit is contained in:
Linus Rath
2026-10-28 01:48:36 +01:00
parent 635ee883b2
commit 03304a3cdc
6 changed files with 249 additions and 8 deletions
+3 -3
View File
@@ -492,10 +492,10 @@ optgroup {
opacity: 0.8;
}
/* Toast animations */
/* Toast animations. They slide in from the end edge: --toast-slide is -1 in RTL. */
@keyframes toast-enter {
from {
transform: translateX(calc(100% + 1.25rem));
transform: translateX(calc((100% + 1.25rem) * var(--toast-slide, 1)));
opacity: 0;
}
to {
@@ -510,7 +510,7 @@ optgroup {
opacity: 1;
}
to {
transform: translateX(calc(100% + 1.25rem));
transform: translateX(calc((100% + 1.25rem) * var(--toast-slide, 1)));
opacity: 0;
}
}
+4 -1
View File
@@ -18,6 +18,7 @@ import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
@@ -1174,6 +1175,8 @@ export function EmailViewer({
// and has no bar fixed at the bottom; previous / next move to the top bar.
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
const mvReplyRow = mvViewer || mvMobileViewer;
// The phone's bottom action bar: toasts sit above it.
const bottomBarRef = useBottomControls(isMobile && !mvMobileViewer);
// The open conversation around the open message, split at it.
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
@@ -6529,7 +6532,7 @@ export function EmailViewer({
viewport `sm:hidden` guard would otherwise hide it there, and
`fixed` would span the whole app instead of the pane). */}
{isMobile && !mvMobileViewer && (
<nav className={cn(
<nav ref={bottomBarRef} className={cn(
"z-50 bg-background border-t border-border overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
isPaneScoped ? "absolute bottom-0 left-0 right-0" : "fixed bottom-0 left-0 right-0 sm:hidden",
)}>
+4
View File
@@ -31,6 +31,7 @@ import { Avatar } from "@/components/ui/avatar";
import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import { useBottomControls } from "@/hooks/use-bottom-controls";
interface NavItem {
id: string;
@@ -213,6 +214,8 @@ export function NavigationRail({
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const tGlobalSearch = useTranslations("global_search");
// The phone tab bar: toasts sit above it.
const tabBarRef = useBottomControls(orientation === "horizontal");
const pathname = usePathname();
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
@@ -350,6 +353,7 @@ export function NavigationRail({
if (orientation === "horizontal") {
return (
<nav
ref={tabBarRef}
data-mobile-navigation=""
className={cn(
"flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
@@ -0,0 +1,104 @@
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';
import { useBottomControls } from '@/hooks/use-bottom-controls';
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
const card = (title: string) => screen.getByText(title).closest('.toast-item') as HTMLElement;
describe('toast layout', () => {
beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('stacks the newest toast at the bottom, at the end corner', () => {
render(<Toasts />);
act(() => { toast.info('First'); });
act(() => { toast.info('Second'); });
const container = screen.getByRole('status');
expect(container).toHaveClass('flex-col', 'end-5', 'max-sm:inset-x-3');
expect(container).not.toHaveClass('flex-col-reverse');
expect(container.className).not.toMatch(/\bright-5\b/);
const titles = Array.from(container.querySelectorAll('.toast-item p:first-child')).map((p) => p.textContent);
expect(titles).toEqual(['First', 'Second']);
});
it('sits above the bottom controls on a phone, and over an open composer', () => {
render(<Toasts />);
const container = screen.getByRole('status');
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');
});
it('names the close button and hides it on a phone', () => {
render(<Toasts />);
act(() => { toast.success('Saved'); });
const close = screen.getByRole('button', { name: 'close' });
expect(close).toHaveClass('max-sm:hidden');
});
it('is dismissed by a sideways swipe on a touch screen', () => {
const onDismiss = vi.fn();
render(<Toasts />);
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Moved', onDismiss }); });
const el = card('Moved');
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 1, clientX: 100, clientY: 10 });
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 140, clientY: 12 });
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 200, clientY: 12 });
expect(el.style.transform).toBe('translateX(100px)');
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 1, clientX: 200, clientY: 12 });
expect(onDismiss).toHaveBeenCalledTimes(1);
act(() => { vi.advanceTimersByTime(250); });
expect(screen.queryByText('Moved')).toBeNull();
});
it('snaps back after a short swipe, and ignores mouse drags and vertical moves', () => {
render(<Toasts />);
act(() => { toast.info('Stays'); });
const el = card('Stays');
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 1, clientX: 100, clientY: 10 });
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 1, clientX: 130, clientY: 10 });
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 1, clientX: 130, clientY: 10 });
expect(el.style.transform).toBe('');
fireEvent.pointerDown(el, { pointerType: 'mouse', pointerId: 2, clientX: 100, clientY: 10 });
fireEvent.pointerMove(el, { pointerType: 'mouse', pointerId: 2, clientX: 300, clientY: 10 });
fireEvent.pointerUp(el, { pointerType: 'mouse', pointerId: 2, clientX: 300, clientY: 10 });
fireEvent.pointerDown(el, { pointerType: 'touch', pointerId: 3, clientX: 100, clientY: 10 });
fireEvent.pointerMove(el, { pointerType: 'touch', pointerId: 3, clientX: 110, clientY: 200 });
fireEvent.pointerUp(el, { pointerType: 'touch', pointerId: 3, clientX: 190, clientY: 200 });
act(() => { vi.advanceTimersByTime(300); });
expect(screen.getByText('Stays')).toBeInTheDocument();
});
});
describe('useBottomControls', () => {
afterEach(() => vi.restoreAllMocks());
function Bar({ active = true }: { active?: boolean }) {
const ref = useBottomControls(active);
return <nav ref={ref}>tabs</nav>;
}
it('publishes how far the bar reaches from the bottom while it is mounted', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
() => ({ left: 0, top: window.innerHeight - 64, width: 390, height: 64, right: 390, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect,
);
const { unmount } = render(<Bar />);
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('64px');
unmount();
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
});
});
+76 -4
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
import { cn } from "@/lib/utils";
@@ -74,9 +75,17 @@ const progressBarStyles = {
warning: "bg-warning",
};
// A sideways swipe this far (px) on a touch screen dismisses the toast.
const SWIPE_DISMISS_DISTANCE = 72;
export function ToastItem({ toast, onClose }: ToastProps) {
const tCommon = useTranslations("common");
const Icon = icons[toast.type];
const [exiting, setExiting] = useState(false);
// Swipe to dismiss (touch only): how far it is dragged, and where it flew off to.
const [dragX, setDragX] = useState(0);
const [flungTo, setFlungTo] = useState<number | null>(null);
const swipeRef = useRef<{ id: number; x: number; y: number; horizontal: boolean | null } | null>(null);
const [paused, setPaused] = useState(false);
const remainingRef = useRef(toast.duration ?? 5000);
const startRef = useRef(Date.now());
@@ -120,6 +129,44 @@ export function ToastItem({ toast, onClose }: ToastProps) {
};
}, [toast.duration, paused, dismiss, isProgress]);
const onPointerDown = (e: React.PointerEvent) => {
if (e.pointerType !== "touch" || exiting) return;
swipeRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, horizontal: null };
};
const onPointerMove = (e: React.PointerEvent) => {
const swipe = swipeRef.current;
if (!swipe || swipe.id !== e.pointerId) return;
const dx = e.clientX - swipe.x;
const dy = e.clientY - swipe.y;
if (swipe.horizontal === null) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
swipe.horizontal = Math.abs(dx) > Math.abs(dy);
if (swipe.horizontal) setPaused(true);
}
if (swipe.horizontal) setDragX(dx);
};
const endSwipe = (e: React.PointerEvent) => {
const swipe = swipeRef.current;
if (!swipe || swipe.id !== e.pointerId) return;
swipeRef.current = null;
if (!swipe.horizontal) return;
const dx = e.clientX - swipe.x;
if (Math.abs(dx) >= SWIPE_DISMISS_DISTANCE) {
setFlungTo(Math.sign(dx) * window.innerWidth);
try {
toast.onDismiss?.();
} catch {
// A failing listener must not keep the toast on screen.
}
setTimeout(() => onClose(toast.id), 200);
} else {
setDragX(0);
setPaused(false);
}
};
// The click that ends a swipe is not a tap on the toast.
const swiped = dragX !== 0 || flungTo !== null;
const actionButton = (action: ToastAction) => (
<button
onClick={(e) => {
@@ -147,12 +194,26 @@ export function ToastItem({ toast, onClose }: ToastProps) {
"bg-popover text-popover-foreground border border-border",
"shadow-[0_6px_16px_rgb(0,0,0,0.1)] dark:shadow-[0_6px_16px_rgb(0,0,0,0.4)]",
"overflow-hidden pt-3 pe-2.5 pb-3.5 ps-3.5",
// Phone: as wide as the screen allows (the container keeps 12px).
"max-sm:w-full max-sm:max-w-none touch-pan-y rtl:[--toast-slide:-1]",
exiting ? "toast-exit" : "toast-enter",
toast.onClick && !toast.action && "cursor-pointer"
)}
style={
flungTo !== null
? { transform: `translateX(${flungTo}px)`, opacity: 0, transition: "transform 200ms ease-out, opacity 200ms ease-out" }
: dragX !== 0
? { transform: `translateX(${dragX}px)`, opacity: Math.max(0.3, 1 - Math.abs(dragX) / 240) }
: undefined
}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endSwipe}
onPointerCancel={endSwipe}
onMouseEnter={() => { if (toast.pauseOnHover !== false) setPaused(true); }}
onMouseLeave={() => setPaused(false)}
onClick={() => {
if (swiped) return;
if (toast.onClick && !toast.action) {
toast.onClick();
dismissByUser();
@@ -190,16 +251,19 @@ export function ToastItem({ toast, onClose }: ToastProps) {
</div>
)}
{/* Close button */}
{/* Close button. Phones swipe the toast away instead. */}
<button
type="button"
aria-label={tCommon("close")}
title={tCommon("close")}
onClick={(e) => {
e.stopPropagation();
dismissByUser();
}}
className={cn(
"flex-shrink-0 p-1 rounded-md transition-all",
"flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden",
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
>
<X className="w-3.5 h-3.5" />
@@ -248,7 +312,15 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
return createPortal(
<div
className="fixed bottom-5 right-5 z-[99999] flex flex-col-reverse gap-2.5 [html[data-composer-open]_&]:bottom-20"
className={cn(
// Newest at the bottom, at the end corner (bottom left in RTL);
// lifted over an open composer's footer.
"fixed bottom-5 end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
// 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))]"
)}
role="status"
aria-live="polite"
style={{ pointerEvents: "none" }}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Bars pinned to the bottom of the screen on a phone (the tab bar, a
* viewer's action bar) register here. 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.
*/
const bars = new Set<HTMLElement>();
let frame = 0;
function update() {
frame = 0;
if (typeof document === 'undefined') return;
let height = 0;
for (const bar of bars) {
const rect = bar.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
height = Math.max(height, window.innerHeight - rect.top);
}
const root = document.documentElement.style;
if (bars.size === 0) root.removeProperty('--bottom-controls-height');
else root.setProperty('--bottom-controls-height', `${Math.max(0, Math.round(height))}px`);
}
function schedule() {
if (frame) return;
if (typeof requestAnimationFrame === 'undefined') update();
else frame = requestAnimationFrame(update);
}
/**
* Measure a bottom bar into `--bottom-controls-height` while it is mounted
* and `active`. Returns the ref to put on the bar:
*
* const barRef = useBottomControls(isMobile);
* <nav ref={barRef} className="fixed bottom-0 ...">
*/
export function useBottomControls(active = true): (el: HTMLElement | null) => void {
const [el, setEl] = useState<HTMLElement | null>(null);
useEffect(() => {
if (!el || !active) return;
bars.add(el);
update();
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
observer?.observe(el);
window.addEventListener('resize', schedule);
return () => {
observer?.disconnect();
window.removeEventListener('resize', schedule);
bars.delete(el);
update();
};
}, [el, active]);
return setEl;
}