175 lines
4.8 KiB
TypeScript
175 lines
4.8 KiB
TypeScript
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
|
import { IconCheck, IconX, IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react'
|
|
|
|
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
|
|
|
interface Toast {
|
|
id: string
|
|
message: string
|
|
type: ToastType
|
|
duration: number
|
|
}
|
|
|
|
interface ToastAPI {
|
|
success: (msg: string, duration?: number) => void
|
|
error: (msg: string, duration?: number) => void
|
|
info: (msg: string, duration?: number) => void
|
|
warning: (msg: string, duration?: number) => void
|
|
}
|
|
|
|
const ToastContext = createContext<ToastAPI>({
|
|
success: () => {},
|
|
error: () => {},
|
|
info: () => {},
|
|
warning: () => {},
|
|
})
|
|
|
|
export function useToast() {
|
|
return useContext(ToastContext)
|
|
}
|
|
|
|
// global emitter for non-React code (api.ts etc)
|
|
type ToastFn = (msg: string, type?: string) => void
|
|
let globalToast: ToastFn | null = null
|
|
export function setGlobalToast(fn: ToastFn) { globalToast = fn }
|
|
export function emitToast(msg: string, type: ToastType = 'info') {
|
|
if (globalToast) globalToast(msg, type)
|
|
}
|
|
|
|
let idCounter = 0
|
|
|
|
const TYPE_COLORS: Record<ToastType, string> = {
|
|
success: 'var(--success)',
|
|
error: 'var(--error)',
|
|
warning: 'var(--warning)',
|
|
info: 'var(--info)',
|
|
}
|
|
|
|
const TYPE_ICONS: Record<ToastType, typeof IconCheck> = {
|
|
success: IconCheck,
|
|
error: IconX,
|
|
warning: IconAlertTriangle,
|
|
info: IconInfoCircle,
|
|
}
|
|
|
|
const MAX_VISIBLE = 5
|
|
|
|
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
|
const [exiting, setExiting] = useState(false)
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
|
const Icon = TYPE_ICONS[toast.type]
|
|
|
|
useEffect(() => {
|
|
timerRef.current = setTimeout(() => {
|
|
setExiting(true)
|
|
setTimeout(() => onDismiss(toast.id), 200)
|
|
}, toast.duration)
|
|
return () => clearTimeout(timerRef.current)
|
|
}, [toast.id, toast.duration, onDismiss])
|
|
|
|
const handleClose = () => {
|
|
clearTimeout(timerRef.current)
|
|
setExiting(true)
|
|
setTimeout(() => onDismiss(toast.id), 200)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '10px 14px',
|
|
background: 'var(--surface)',
|
|
border: '1px solid var(--border)',
|
|
borderLeft: `3px solid ${TYPE_COLORS[toast.type]}`,
|
|
borderRadius: 'var(--radius-md)',
|
|
boxShadow: 'var(--shadow-md)',
|
|
fontSize: 'var(--text-sm)',
|
|
color: 'var(--text)',
|
|
maxWidth: 400,
|
|
minWidth: 220,
|
|
animation: exiting ? 'toastOut 200ms ease-out forwards' : 'toastIn 250ms var(--ease-out)',
|
|
pointerEvents: 'auto',
|
|
}}
|
|
>
|
|
<Icon
|
|
size={16}
|
|
stroke={2.5}
|
|
style={{ color: TYPE_COLORS[toast.type], flexShrink: 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
<span style={{ flex: 1, lineHeight: 1.4 }}>{toast.message}</span>
|
|
<button
|
|
onClick={handleClose}
|
|
aria-label="Dismiss"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: 'var(--text-tertiary)',
|
|
cursor: 'pointer',
|
|
padding: 2,
|
|
lineHeight: 0,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<IconX size={14} stroke={2} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [toasts, setToasts] = useState<Toast[]>([])
|
|
|
|
const dismiss = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
}, [])
|
|
|
|
const add = useCallback((message: string, type: ToastType, duration = 4000) => {
|
|
const id = `toast-${++idCounter}`
|
|
setToasts((prev) => {
|
|
const next = [...prev, { id, message, type, duration }]
|
|
return next.slice(-MAX_VISIBLE)
|
|
})
|
|
}, [])
|
|
|
|
const api: ToastAPI = {
|
|
success: useCallback((msg: string, dur?: number) => add(msg, 'success', dur), [add]),
|
|
error: useCallback((msg: string, dur?: number) => add(msg, 'error', dur), [add]),
|
|
info: useCallback((msg: string, dur?: number) => add(msg, 'info', dur), [add]),
|
|
warning: useCallback((msg: string, dur?: number) => add(msg, 'warning', dur), [add]),
|
|
}
|
|
|
|
useEffect(() => {
|
|
setGlobalToast((msg, type) => add(msg, (type as ToastType) || 'info'))
|
|
return () => { globalToast = null }
|
|
}, [add])
|
|
|
|
return (
|
|
<ToastContext.Provider value={api}>
|
|
{children}
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-label="Notifications"
|
|
style={{
|
|
position: 'fixed',
|
|
top: 16,
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 9999,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{toasts.map((t) => (
|
|
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
|
))}
|
|
</div>
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|