YT-AI-Automation / frontend /src /store /ToastProvider.tsx
github-actions
Sync Docker Space
5f3e9f5
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { CheckCircle2, Info, AlertTriangle, XCircle, X } from 'lucide-react'
import { ToastContext, type Toast, type ToastContextValue } from './toast'
const DEFAULT_DURATION = 4000
// H6: collapse identical toasts that fire in rapid succession (e.g. a bulk
// delete that errors on every file). If the same (variant + title + message)
// appears within this window we drop the new push and return the live id.
const DEDUPE_WINDOW_MS = 2_000
const ICONS: Record<Toast['variant'], typeof Info> = {
success: CheckCircle2,
info: Info,
warning: AlertTriangle,
error: XCircle,
}
const COLORS: Record<Toast['variant'], string> = {
success: 'border-emerald-300/60 bg-emerald-50 text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-200',
info: 'border-slate-300/60 bg-white text-slate-800 dark:border-white/10 dark:bg-slate-900 dark:text-slate-100',
warning: 'border-amber-300/60 bg-amber-50 text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200',
error: 'border-rose-300/60 bg-rose-50 text-rose-800 dark:border-rose-400/30 dark:bg-rose-500/10 dark:text-rose-200',
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const recent = useRef(new Map<string, { id: string; at: number }>())
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
for (const [key, entry] of recent.current) {
if (entry.id === id) recent.current.delete(key)
}
}, [])
const push = useCallback<ToastContextValue['push']>(
(toast) => {
const now = Date.now()
const sig = `${toast.variant}|${toast.title ?? ''}|${toast.message}`
const prior = recent.current.get(sig)
if (prior && now - prior.at < DEDUPE_WINDOW_MS) {
// Refresh timestamp so a steady stream of dupes keeps suppressing.
recent.current.set(sig, { id: prior.id, at: now })
return prior.id
}
const id = `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const next: Toast = { id, ...toast }
setToasts((prev) => [...prev, next])
const duration = toast.durationMs ?? DEFAULT_DURATION
if (duration > 0) {
const timer = setTimeout(() => dismiss(id), duration)
timers.current.set(id, timer)
}
recent.current.set(sig, { id, at: now })
return id
},
[dismiss],
)
const clear = useCallback(() => {
timers.current.forEach((t) => clearTimeout(t))
timers.current.clear()
recent.current.clear()
setToasts([])
}, [])
// Cleanup on unmount. Snapshot the current Map so the cleanup function
// doesn't reach back into the (possibly-different) ref at unmount time.
useEffect(() => {
const snapshot = timers.current
return () => {
snapshot.forEach((t) => clearTimeout(t))
snapshot.clear()
}
}, [])
const value = useMemo<ToastContextValue>(
() => ({ toasts, push, dismiss, clear }),
[toasts, push, dismiss, clear],
)
// Split toasts into two live regions: errors go to an `assertive`
// region so screen readers announce them immediately, everything else
// goes to a `polite` region. Announcing through the live region means
// we don't need `role="alert" / role="status"` on each toast — that
// would cause nested live regions and double announcements on JAWS.
const polite = toasts.filter((t) => t.variant !== 'error')
const assertive = toasts.filter((t) => t.variant === 'error')
const renderToast = (t: Toast) => {
const Icon = ICONS[t.variant]
return (
<div
key={t.id}
className={`pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-md ${COLORS[t.variant]}`}
>
<Icon size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1 text-sm">
{t.title && <div className="font-medium">{t.title}</div>}
<div className="mt-0.5 break-words">{t.message}</div>
</div>
<button
type="button"
onClick={() => dismiss(t.id)}
className="-mr-1 -mt-1 rounded p-1 text-current/70 hover:text-current focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Dismiss notification"
>
<X size={14} aria-hidden="true" />
</button>
</div>
)
}
return (
<ToastContext.Provider value={value}>
{children}
<div
role="region"
aria-label="Notifications"
className="pointer-events-none fixed inset-x-0 top-4 z-[60] flex flex-col items-center gap-2 px-4 sm:right-4 sm:left-auto sm:items-end"
>
<div aria-live="polite" aria-atomic="false" className="flex flex-col items-stretch gap-2">
{polite.map(renderToast)}
</div>
<div aria-live="assertive" aria-atomic="false" className="flex flex-col items-stretch gap-2">
{assertive.map(renderToast)}
</div>
</div>
</ToastContext.Provider>
)
}