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) => (
-
+
))}
,