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 { 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user