mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user