molecule-core/canvas/src/components/Toaster.tsx
Hongming Wang 3b244ca6c6 canvas/Toaster: add Esc dismiss + focus-visible ring + larger touch target
Three small a11y fixes for the global toast surface:

1. Esc dismisses the newest toast. Errors never auto-expire, so without
   a keyboard shortcut a keyboard-only user has to tab through the entire
   app to reach the × button on a stuck error.

2. Dismiss button gets focus-visible ring + theme-aware tint. The previous
   `opacity-70 hover:opacity-100` gave no visible focus indicator (WCAG
   2.4.7). Info toasts use the semantic surface that flips with theme,
   so the dismiss tint splits per type — accent ring on info, white ring
   on the always-dark success/error toasts.

3. Touch target bumps from p-1 (~24x24) to w-7 h-7 (28x28) toward WCAG
   2.5.5 AAA's 44x44 ideal.

Tests: 5 new vitest cases covering Esc on info/error, no-op on empty
queue, accessible label, and per-toast click dismissal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:55:24 -07:00

130 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
interface Toast {
id: string;
message: string;
type: "success" | "error" | "info";
}
let addToastFn: ((message: string, type?: Toast["type"]) => void) | null = null;
/** Call from anywhere to show a toast */
export function showToast(message: string, type: Toast["type"] = "info") {
addToastFn?.(message, type);
}
export function Toaster() {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = (id: string) =>
setToasts((prev) => prev.filter((t) => t.id !== id));
useEffect(() => {
addToastFn = (message, type = "info") => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev.slice(-4), { id, message, type }]);
// Errors persist until the user explicitly dismisses them.
// Success / info auto-expire after 4 s.
if (type !== "error") {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}
};
return () => {
addToastFn = null;
};
}, []);
// Esc dismisses the newest toast — keyboard parity with the × button.
// Errors never auto-expire, so without this a keyboard-only user has to
// tab through the entire app to reach the dismiss button on a stuck error.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const toastCls = (type: Toast["type"]) =>
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
type === "success"
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
: type === "error"
? "bg-red-950/90 border border-red-700/40 text-red-200"
: "bg-surface-sunken/90 border border-line/40 text-ink"
}`;
// Success/error toasts are intentionally dark in both themes (high-vis).
// Info uses the semantic surface that flips with theme — so the dismiss
// button needs a tint that stays visible on a light bg in light mode.
const dismissCls = (type: Toast["type"]) => {
const base =
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
return type === "info"
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
};
const pos =
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
return (
<>
{/*
* Polite live region — success & info notifications.
* Always rendered so screen readers register it before any toast fires.
*/}
<div role="status" aria-live="polite" aria-atomic="false" className={pos}>
{toasts
.filter((t) => t.type !== "error")
.map((toast) => (
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className={dismissCls(toast.type)}
>
×
</button>
</div>
))}
</div>
{/*
* Assertive live region — errors only.
* aria-live="assertive" interrupts the screen reader immediately.
* Errors never auto-expire; user must dismiss via the × button.
*/}
<div
role="alert"
aria-live="assertive"
aria-atomic="false"
className={pos}
>
{toasts
.filter((t) => t.type === "error")
.map((toast) => (
<div key={toast.id} className={toastCls(toast.type)}>
<span>{toast.message}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className={dismissCls(toast.type)}
>
×
</button>
</div>
))}
</div>
</>
);
}