make toasts accessible: ARIA live region, dismiss button, pause on hover
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { springs } from "@/lib/motion";
|
||||
import { useToastStore } from "@/stores/toast-store";
|
||||
|
||||
@@ -10,9 +11,17 @@ const TYPE_STYLES = {
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
const pauseToast = useToastStore((s) => s.pauseToast);
|
||||
const resumeToast = useToastStore((s) => s.resumeToast);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
@@ -21,9 +30,20 @@ export function ToastContainer() {
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
transition={springs.wobbly}
|
||||
className={`pointer-events-auto rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
|
||||
className={`pointer-events-auto flex items-center gap-2 rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
|
||||
onMouseEnter={() => pauseToast(toast.id)}
|
||||
onMouseLeave={() => resumeToast(toast.id)}
|
||||
onFocus={() => pauseToast(toast.id)}
|
||||
onBlur={() => resumeToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -12,9 +12,28 @@ interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: string) => void;
|
||||
pauseToast: (id: string) => void;
|
||||
resumeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const remaining = new Map<string, number>();
|
||||
const startTimes = new Map<string, number>();
|
||||
|
||||
const TOAST_DURATION = 8000;
|
||||
|
||||
function startTimer(id: string, duration: number, set: (fn: (s: ToastState) => Partial<ToastState>) => void) {
|
||||
startTimes.set(id, Date.now());
|
||||
remaining.set(id, duration);
|
||||
const timer = setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
timers.delete(id);
|
||||
remaining.delete(id);
|
||||
startTimes.delete(id);
|
||||
}, duration);
|
||||
timers.set(id, timer);
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
@@ -22,12 +41,33 @@ export const useToastStore = create<ToastState>((set) => ({
|
||||
addToast: (message, type = "info") => {
|
||||
const id = String(++nextId);
|
||||
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
|
||||
setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, 3000);
|
||||
startTimer(id, TOAST_DURATION, set);
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
remaining.delete(id);
|
||||
startTimes.delete(id);
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
},
|
||||
|
||||
pauseToast: (id) => {
|
||||
const timer = timers.get(id);
|
||||
const start = startTimes.get(id);
|
||||
const rem = remaining.get(id);
|
||||
if (timer && start != null && rem != null) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
remaining.set(id, rem - (Date.now() - start));
|
||||
}
|
||||
},
|
||||
|
||||
resumeToast: (id) => {
|
||||
const rem = remaining.get(id);
|
||||
if (rem != null && rem > 0) {
|
||||
startTimer(id, rem, set);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user