diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx index 9279a1e69..872b3f9b3 100644 --- a/client/src/components/shared/Toast.tsx +++ b/client/src/components/shared/Toast.tsx @@ -1,15 +1,6 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react' +import React, { useCallback } from 'react' import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react' - -type ToastType = 'success' | 'error' | 'warning' | 'info' - -interface Toast { - id: number - message: string - type: ToastType - duration: number - removing: boolean -} +import { useToastQueue, type ToastType } from './useToastQueue' declare global { interface Window { @@ -17,8 +8,6 @@ declare global { } } -let toastIdCounter = 0 - const ICON_COLORS: Record = { success: '#22c55e', error: '#ef4444', @@ -27,45 +16,7 @@ const ICON_COLORS: Record = { } export function ToastContainer() { - const [toasts, setToasts] = useState([]) - const timersRef = useRef[]>([]) - - useEffect(() => { - return () => { - timersRef.current.forEach(clearTimeout) - } - }, []) - - const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => { - const id = ++toastIdCounter - setToasts(prev => [...prev, { id, message, type, duration, removing: false }]) - - if (duration > 0) { - const t1 = setTimeout(() => { - setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) - const t2 = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) - }, 400) - timersRef.current.push(t2) - }, duration) - timersRef.current.push(t1) - } - - return id - }, []) - - const removeToast = useCallback((id: number) => { - setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) - const t = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) - }, 400) - timersRef.current.push(t) - }, []) - - useEffect(() => { - window.__addToast = addToast - return () => { delete window.__addToast } - }, [addToast]) + const { toasts, dismissToast: removeToast } = useToastQueue({ exitMs: 400, restorePrevious: false }) const icons: Record = { success: , diff --git a/client/src/components/shared/useToastQueue.test.ts b/client/src/components/shared/useToastQueue.test.ts new file mode 100644 index 000000000..d46dc27f8 --- /dev/null +++ b/client/src/components/shared/useToastQueue.test.ts @@ -0,0 +1,86 @@ +// FE-COMP-TOASTQUEUE-001 to -006: the toast queue both toast presenters share. +import { act, renderHook } from '@testing-library/react'; + +import { useToastQueue } from './useToastQueue'; + +beforeEach(() => { + vi.useFakeTimers(); + delete window.__addToast; +}); +afterEach(() => { + vi.useRealTimers(); + delete window.__addToast; +}); + +describe('useToastQueue', () => { + it('FE-COMP-TOASTQUEUE-001: registers the bus and queues a toast with the default type and duration', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + let id = 0; + act(() => { + id = window.__addToast!('Saved'); + }); + expect(id).toBeGreaterThan(0); + expect(result.current.toasts).toEqual([{ id, message: 'Saved', type: 'info', duration: 3000, removing: false }]); + }); + + it('FE-COMP-TOASTQUEUE-002: auto-dismisses after the duration, then drops the toast after the exit time', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 220, restorePrevious: true })); + act(() => { + window.__addToast!('Done', 'success', 1000); + }); + act(() => vi.advanceTimersByTime(999)); + expect(result.current.toasts[0].removing).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.toasts[0].removing).toBe(true); + act(() => vi.advanceTimersByTime(219)); + expect(result.current.toasts).toHaveLength(1); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.toasts).toEqual([]); + }); + + it('FE-COMP-TOASTQUEUE-003: a duration of zero keeps the toast until it is dismissed', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + act(() => { + window.__addToast!('Sticky', 'warning', 0); + }); + act(() => vi.advanceTimersByTime(60_000)); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].removing).toBe(false); + + act(() => result.current.dismissToast(result.current.toasts[0].id)); + expect(result.current.toasts[0].removing).toBe(true); + act(() => vi.advanceTimersByTime(400)); + expect(result.current.toasts).toEqual([]); + }); + + it('FE-COMP-TOASTQUEUE-004: the desktop mode deletes the bus on unmount', () => { + const previous = vi.fn(() => 1); + window.__addToast = previous; + const { unmount } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + expect(window.__addToast).not.toBe(previous); + unmount(); + expect(window.__addToast).toBeUndefined(); + }); + + it('FE-COMP-TOASTQUEUE-005: the phone mode hands the bus back to the previous holder on unmount', () => { + const previous = vi.fn(() => 1); + window.__addToast = previous; + const { unmount } = renderHook(() => useToastQueue({ exitMs: 220, restorePrevious: true })); + expect(window.__addToast).not.toBe(previous); + unmount(); + expect(window.__addToast).toBe(previous); + }); + + it('FE-COMP-TOASTQUEUE-006: pending timers are cleared on unmount', () => { + const clear = vi.spyOn(globalThis, 'clearTimeout'); + const { unmount } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + act(() => { + window.__addToast!('One', 'info', 1000); + window.__addToast!('Two', 'error', 2000); + }); + const before = clear.mock.calls.length; + unmount(); + expect(clear.mock.calls.length - before).toBe(2); + clear.mockRestore(); + }); +}); diff --git a/client/src/components/shared/useToastQueue.ts b/client/src/components/shared/useToastQueue.ts new file mode 100644 index 000000000..269d8d7d0 --- /dev/null +++ b/client/src/components/shared/useToastQueue.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface QueuedToast { + id: number; + message: string; + type: ToastType; + /** duration <= 0: no auto-dismiss. */ + duration: number; + removing: boolean; +} + +interface ToastQueueOptions { + /** How long a dismissed toast keeps rendering for its exit animation. */ + exitMs: number; + /** On unmount, hand `window.__addToast` back to whoever held it before (else it is deleted). */ + restorePrevious: boolean; +} + +let toastIdCounter = 0; + +/** + * The toast queue behind the global `window.__addToast` bridge (fed by useToast and + * store/notify): registers itself while mounted, auto-dismisses after `duration`, + * keeps a dismissed toast around for `exitMs` so the view can animate it out. + */ +export function useToastQueue({ exitMs, restorePrevious }: ToastQueueOptions) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef[]>([]); + + useEffect(() => { + const timers = timersRef.current; + return () => timers.forEach(clearTimeout); + }, []); + + const dismissToast = useCallback( + (id: number) => { + setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, removing: true } : t))); + const t = setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, exitMs); + timersRef.current.push(t); + }, + [exitMs] + ); + + const addToast = useCallback( + (message: string, type: ToastType = 'info', duration: number = 3000) => { + const id = ++toastIdCounter; + setToasts((prev) => [...prev, { id, message, type, duration, removing: false }]); + + if (duration > 0) { + const t = setTimeout(() => dismissToast(id), duration); + timersRef.current.push(t); + } + + return id; + }, + [dismissToast] + ); + + useEffect(() => { + const previous = window.__addToast; + window.__addToast = addToast; + return () => { + if (restorePrevious) window.__addToast = previous; + else delete window.__addToast; + }; + }, [addToast, restorePrevious]); + + return { toasts, dismissToast }; +} diff --git a/client/src/mobile/components/MToastHost.tsx b/client/src/mobile/components/MToastHost.tsx index 0d29b7b31..429e05c53 100644 --- a/client/src/mobile/components/MToastHost.tsx +++ b/client/src/mobile/components/MToastHost.tsx @@ -1,18 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react' import MStatusDot, { MStatus } from './MStatusDot' +import { useToastQueue, type QueuedToast, type ToastType } from '../../components/shared/useToastQueue' -type ToastType = 'success' | 'error' | 'warning' | 'info' - -interface MToast { - id: number - message: string - type: ToastType - /** duration <= 0: no auto-dismiss, the pill closes on tap instead. */ - sticky: boolean - removing: boolean -} - -let toastId = 0 const EXIT_MS = 220 // Roughly what one line of the pill holds on a 375px phone. Longer messages @@ -27,18 +15,20 @@ const TYPE_DOT: Partial> = { warning: 'pending', } -function MToastPill({ toast, onDismiss }: { toast: MToast; onDismiss: (id: number) => void }) { +function MToastPill({ toast, onDismiss }: { toast: QueuedToast; onDismiss: (id: number) => void }) { const dot = TYPE_DOT[toast.type] const wraps = toast.message.length > PILL_CHARS + // duration <= 0: no auto-dismiss, the pill closes on tap instead. + const sticky = toast.duration <= 0 return (
onDismiss(toast.id) : undefined} - onKeyDown={toast.sticky ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onDismiss(toast.id) } } : undefined} + role={sticky ? 'button' : undefined} + tabIndex={sticky ? 0 : undefined} + onClick={sticky ? () => onDismiss(toast.id) : undefined} + onKeyDown={sticky ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onDismiss(toast.id) } } : undefined} className={`flex max-w-full gap-2 bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg shadow-[0_10px_30px_-8px_rgba(0,0,0,.5)] ${ wraps ? 'items-start rounded-2xl' : 'items-center rounded-full' - } ${toast.sticky ? 'pointer-events-auto cursor-pointer' : ''} ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} + } ${sticky ? 'pointer-events-auto cursor-pointer' : ''} ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} > {dot && } {/* No display utility next to line-clamp: Tailwind emits it later and kills the clamp. */} @@ -55,44 +45,7 @@ function MToastPill({ toast, onDismiss }: { toast: MToast; onDismiss: (id: numbe * The desktop ToastContainer handler is restored on unmount. */ export default function MToastHost() { - const [toasts, setToasts] = useState([]) - const timersRef = useRef[]>([]) - - useEffect(() => { - const timers = timersRef.current - return () => timers.forEach(clearTimeout) - }, []) - - const dismissToast = useCallback((id: number) => { - setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, removing: true } : t))) - const t = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, EXIT_MS) - timersRef.current.push(t) - }, []) - - const addToast = useCallback( - (message: string, type: ToastType = 'info', duration: number = 3000) => { - const id = ++toastId - setToasts((prev) => [...prev, { id, message, type, sticky: duration <= 0, removing: false }]) - - if (duration > 0) { - const t = setTimeout(() => dismissToast(id), duration) - timersRef.current.push(t) - } - - return id - }, - [dismissToast], - ) - - useEffect(() => { - const previous = window.__addToast - window.__addToast = addToast - return () => { - window.__addToast = previous - } - }, [addToast]) + const { toasts, dismissToast } = useToastQueue({ exitMs: EXIT_MS, restorePrevious: true }) if (toasts.length === 0) return null