mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(toast): share the toast queue between desktop and phone
Both presenters ran their own queue behind the window toast bus. The queue, the auto dismiss and the exit delay now live in useToastQueue; each presenter keeps its markup, its exit timing and its unmount handover.
This commit is contained in:
@@ -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<ToastType, string> = {
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
@@ -27,45 +16,7 @@ const ICON_COLORS: Record<ToastType, string> = {
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
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<ToastType, React.ReactNode> = {
|
||||
success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<QueuedToast[]>([]);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<Record<ToastType, MStatus>> = {
|
||||
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 (
|
||||
<div
|
||||
role={toast.sticky ? 'button' : undefined}
|
||||
tabIndex={toast.sticky ? 0 : undefined}
|
||||
onClick={toast.sticky ? () => 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 && <MStatusDot status={dot} size={6} className={wraps ? 'mt-[0.45rem]' : ''} />}
|
||||
{/* 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<MToast[]>([])
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user