From addf330bd048d8e8d08fb42e5578acfbb36dd8ae Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:49:09 +0100 Subject: [PATCH] fix: keep toasts out of the way of an open menu on phones and hold their countdown until it closes --- components/ui/__tests__/toast-layout.test.tsx | 58 +++++++++++++++++++ components/ui/context-menu.tsx | 16 +++++ components/ui/toast.tsx | 22 +++++-- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 5068e33a2..f7a7be479 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -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 ( + <> + + {}} label="Row"> + {}} /> + + + ); + } + + it('hides a phone toast while a menu is open and keeps it until the menu closes', () => { + const { rerender } = render(); + act(() => { toast.success('Moved to Trash'); }); + const container = screen.getByRole('status'); + expect(container).not.toHaveClass('invisible'); + + rerender(); + expect(container).toHaveClass('invisible'); + act(() => { vi.advanceTimersByTime(20000); }); + expect(screen.getByText('Moved to Trash')).toBeInTheDocument(); + + rerender(); + 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(); + act(() => { toast.success('Saved'); }); + expect(screen.getByRole('status')).not.toHaveClass('invisible'); + }); +}); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index c6f7e3674..dfbdb889c 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -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 = [ diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 5d43a493c..a52f1189f 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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) => (
- +
))} ,