molecule-core/canvas/src/components/Toaster.tsx
Dev Lead Agent cf8db07020 fix(canvas): WCAG critical — ARIA live toasts, dialog focus trap, keyboard nav
Addresses the three release-blocking WCAG violations from the UX audit
(3rd consecutive cycle) and the new ChatTab ARIA gap from Audit #2.

Changes:
- Toaster: split into polite (success/info) + assertive (error) live
  regions, both always in DOM so screen readers register them before
  any toast fires. Adds x dismiss button on every toast. Errors no
  longer auto-expire after 4s — persist until explicitly dismissed.
- ConfirmDialog: on open, requestAnimationFrame focuses the first
  button inside the dialog. Tab/Shift-Tab is now trapped inside the
  dialog while open. Added role="dialog" aria-modal="true" and
  aria-labelledby pointing to the title h3.
- WorkspaceNode: outer div gains role="button", tabIndex={0},
  aria-label, aria-pressed, and onKeyDown (Enter/Space => selectNode,
  ContextMenu key => openContextMenu). Keyboard-only users can now
  reach and activate workspace nodes.
- ChatTab sub-tab bar: role="tablist" on wrapper, role="tab" +
  aria-selected + aria-controls on each button, matching
  role="tabpanel" + id on each panel div. Textarea gets
  aria-label="Message to agent".

453/453 Vitest tests pass. Production build clean (Next.js 15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:31:06 +00:00

105 lines
3.3 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;
};
}, []);
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-zinc-900/90 border border-zinc-700/40 text-zinc-200"
}`;
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
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</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
onClick={() => dismiss(toast.id)}
aria-label="Dismiss notification"
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
>
×
</button>
</div>
))}
</div>
</>
);
}