89 lines
3.4 KiB
TypeScript
89 lines
3.4 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { CheckCircle2, CircleAlert, X } from 'lucide-react';
|
|
|
|
type Toast = {
|
|
id: number;
|
|
title: string;
|
|
message?: string;
|
|
variant?: 'success' | 'error';
|
|
entering?: boolean;
|
|
closing?: boolean;
|
|
};
|
|
|
|
type ToastContextValue = {
|
|
showToast: (toast: Omit<Toast, 'id'>) => void;
|
|
};
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
const TOAST_DURATION_MS = 2800;
|
|
const TOAST_EXIT_MS = 180;
|
|
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
const removeToast = useCallback((id: number) => {
|
|
setToasts((current) => current.map((toast) => (toast.id === id ? { ...toast, closing: true } : toast)));
|
|
window.setTimeout(() => {
|
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
|
}, TOAST_EXIT_MS);
|
|
}, []);
|
|
|
|
const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
|
const id = Date.now() + Math.floor(Math.random() * 1000);
|
|
setToasts((current) => [...current, { ...toast, id, entering: true }]);
|
|
window.setTimeout(() => {
|
|
setToasts((current) => current.map((item) => (item.id === id ? { ...item, entering: false } : item)));
|
|
}, 20);
|
|
}, []);
|
|
|
|
const value = useMemo(() => ({ showToast }), [showToast]);
|
|
|
|
return (
|
|
<ToastContext.Provider value={value}>
|
|
{children}
|
|
{createPortal(
|
|
<div className="fixed bottom-5 right-5 z-[80] flex w-[calc(100vw-40px)] max-w-[360px] flex-col gap-2">
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useToast() {
|
|
const context = useContext(ToastContext);
|
|
if (!context) throw new Error('useToast must be used within ToastProvider');
|
|
return context;
|
|
}
|
|
|
|
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(onClose, TOAST_DURATION_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [onClose]);
|
|
|
|
const Icon = toast.variant === 'error' ? CircleAlert : CheckCircle2;
|
|
const iconTone = toast.variant === 'error' ? 'bg-[#e11d48] text-white' : 'bg-[#4f46e5] text-white';
|
|
|
|
return (
|
|
<div className={`rounded-lg border border-[#1e293b] bg-[#111827] px-4 py-3 shadow-[0_22px_54px_rgba(15,23,42,0.32)] transition duration-180 ease-out ${toast.closing || toast.entering ? 'translate-y-2 opacity-0' : 'translate-y-0 opacity-100'}`}>
|
|
<div className="flex items-start gap-3">
|
|
<span className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md ${iconTone}`}>
|
|
<Icon size={17} aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13px] font-black text-white">{toast.title}</p>
|
|
{toast.message ? <p className="mt-1 truncate text-[12px] font-bold text-[#cbd5e1]">{toast.message}</p> : null}
|
|
</div>
|
|
<button className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-[#cbd5e1] hover:bg-white/10 hover:text-white" type="button" onClick={onClose} aria-label="토스트 닫기" title="토스트 닫기">
|
|
<X size={14} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|