fix: keep toasts out of the way of an open menu on phones and hold their countdown until it closes

This commit is contained in:
Linus Rath
2026-10-28 12:49:09 +01:00
parent 2bb63f648b
commit addf330bd0
3 changed files with 91 additions and 5 deletions
@@ -3,6 +3,7 @@ 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';
import { ContextMenu, ContextMenuItem } from '../context-menu';
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
@@ -109,3 +110,60 @@ describe('useBottomControls', () => {
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
});
});
// On a phone a toast spans the screen and covered the last items of a
// long-press menu. While a menu is open it steps aside and its countdown
// waits; it comes back when the menu closes.
describe('toasts and open menus', () => {
let phone = true;
beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
phone = true;
vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
matches: query === '(max-width: 639px)' ? phone : false,
media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function Screen({ menu }: { menu: boolean }) {
return (
<>
<Toasts />
<ContextMenu isOpen={menu} position={{ x: 10, y: 10 }} onClose={() => {}} label="Row">
<ContextMenuItem label="Archive" onClick={() => {}} />
</ContextMenu>
</>
);
}
it('hides a phone toast while a menu is open and keeps it until the menu closes', () => {
const { rerender } = render(<Screen menu={false} />);
act(() => { toast.success('Moved to Trash'); });
const container = screen.getByRole('status');
expect(container).not.toHaveClass('invisible');
rerender(<Screen menu />);
expect(container).toHaveClass('invisible');
act(() => { vi.advanceTimersByTime(20000); });
expect(screen.getByText('Moved to Trash')).toBeInTheDocument();
rerender(<Screen menu={false} />);
expect(container).not.toHaveClass('invisible');
act(() => { vi.advanceTimersByTime(6000); });
expect(screen.queryByText('Moved to Trash')).not.toBeInTheDocument();
});
it('leaves desktop toasts where they are', () => {
phone = false;
render(<Screen menu />);
act(() => { toast.success('Saved'); });
expect(screen.getByRole('status')).not.toHaveClass('invisible');
});
});
+16
View File
@@ -11,6 +11,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
@@ -56,10 +57,13 @@ const HOVER_CLOSE_DELAY = 150;
// Open menus, the one opened last at the end. Only that one answers keys.
const openMenus: object[] = [];
const openListeners = new Set<() => void>();
function syncOpenFlag() {
if (typeof document === "undefined") return;
if (openMenus.length > 0) document.documentElement.dataset.contextMenuOpen = "";
else delete document.documentElement.dataset.contextMenuOpen;
for (const listener of openListeners) listener();
}
/**
@@ -71,6 +75,18 @@ export function isContextMenuOpen(): boolean {
return openMenus.length > 0;
}
function subscribeContextMenuOpen(listener: () => void): () => void {
openListeners.add(listener);
return () => {
openListeners.delete(listener);
};
}
/** isContextMenuOpen as state: re-renders when a menu opens or the last one closes. */
export function useContextMenuOpen(): boolean {
return useSyncExternalStore(subscribeContextMenuOpen, isContextMenuOpen, () => false);
}
// ─── Items of a menu level ─────────────────────────────────────
const ITEM_SELECTOR = [
+17 -5
View File
@@ -6,6 +6,8 @@ import { useTranslations } from "next-intl";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
import { cn } from "@/lib/utils";
import { TOUCH_HIT_AREA } from "@/components/ui/button";
import { useContextMenuOpen } from "@/components/ui/context-menu";
import { useMediaQuery } from "@/hooks/use-media-query";
export type ToastType = "success" | "error" | "info" | "warning";
@@ -51,6 +53,8 @@ export interface Toast {
interface ToastProps {
toast: Toast;
onClose: (id: string) => void;
/** Kept out of sight for now (a menu is open over it): the countdown waits. */
held?: boolean;
}
const icons = {
@@ -79,7 +83,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 }: ToastProps) {
export function ToastItem({ toast, onClose, held = false }: ToastProps) {
const tCommon = useTranslations("common");
const Icon = icons[toast.type];
const [exiting, setExiting] = useState(false);
@@ -114,10 +118,11 @@ export function ToastItem({ toast, onClose }: ToastProps) {
dismiss();
}, [toast, dismiss]);
const waiting = paused || held;
useEffect(() => {
if (!toast.duration || toast.duration <= 0 || isProgress) return;
if (paused) {
if (waiting) {
if (timerRef.current) clearTimeout(timerRef.current);
remainingRef.current = remainingRef.current - (Date.now() - startRef.current);
return;
@@ -128,7 +133,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [toast.duration, paused, dismiss, isProgress]);
}, [toast.duration, waiting, dismiss, isProgress]);
const onPointerDown = (e: React.PointerEvent) => {
if (e.pointerType !== "touch" || exiting) return;
@@ -306,6 +311,12 @@ export function ToastItem({ toast, onClose }: ToastProps) {
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
const [mounted, setMounted] = useState(false);
// On a phone the toasts span the screen and covered a long-press menu's
// last items. While a menu is open they step aside, and their countdown
// waits, so an Undo is still there when the menu closes.
const menuOpen = useContextMenuOpen();
const phone = useMediaQuery("(max-width: 639px)");
const held = menuOpen && phone;
useEffect(() => {
setMounted(true);
@@ -323,7 +334,8 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// 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[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
held && "invisible"
)}
role="status"
aria-live="polite"
@@ -331,7 +343,7 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
>
{toasts.map((toast) => (
<div key={toast.id} style={{ pointerEvents: "auto" }}>
<ToastItem toast={toast} onClose={onClose} />
<ToastItem toast={toast} onClose={onClose} held={held} />
</div>
))}
</div>,