make toasts accessible: ARIA live region, dismiss button, pause on hover

This commit is contained in:
Your Name
2026-02-19 19:44:06 +02:00
parent b1c5e9caa9
commit 2044a7026d
2 changed files with 66 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { springs } from "@/lib/motion"; import { springs } from "@/lib/motion";
import { useToastStore } from "@/stores/toast-store"; import { useToastStore } from "@/stores/toast-store";
@@ -10,9 +11,17 @@ const TYPE_STYLES = {
export function ToastContainer() { export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts); 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 ( 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> <AnimatePresence>
{toasts.map((toast) => ( {toasts.map((toast) => (
<motion.div <motion.div
@@ -21,9 +30,20 @@ export function ToastContainer() {
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }} exit={{ opacity: 0, y: 20, scale: 0.9 }}
transition={springs.wobbly} 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> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>

View File

@@ -12,9 +12,28 @@ interface ToastState {
toasts: Toast[]; toasts: Toast[];
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
pauseToast: (id: string) => void;
resumeToast: (id: string) => void;
} }
let nextId = 0; 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) => ({ export const useToastStore = create<ToastState>((set) => ({
toasts: [], toasts: [],
@@ -22,12 +41,33 @@ export const useToastStore = create<ToastState>((set) => ({
addToast: (message, type = "info") => { addToast: (message, type = "info") => {
const id = String(++nextId); const id = String(++nextId);
set((s) => ({ toasts: [...s.toasts, { id, message, type }] })); set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
setTimeout(() => { startTimer(id, TOAST_DURATION, set);
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
}, },
removeToast: (id) => { 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) })); 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);
}
},
})); }));