forked from molecule-ai/molecule-core
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>
This commit is contained in:
parent
4a65c72860
commit
cf8db07020
1784
canvas/package-lock.json
generated
1784
canvas/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,11 +42,48 @@ export function ConfirmDialog({
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3 / 3.2.2)
|
||||
useEffect(() => {
|
||||
if (!open || !mounted) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, mounted]);
|
||||
|
||||
// Keyboard: Escape cancels, Enter confirms, Tab is trapped within the dialog
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancelRef.current();
|
||||
if (e.key === "Enter") onConfirmRef.current();
|
||||
if (e.key === "Escape") {
|
||||
onCancelRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
onConfirmRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && dialogRef.current) {
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((el) => !el.hasAttribute("disabled"));
|
||||
if (focusable.length === 0) { e.preventDefault(); return; }
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
@ -68,13 +105,16 @@ export function ConfirmDialog({
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -18,35 +18,87 @@ export function showToast(message: string, type: Toast["type"] = "info") {
|
||||
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 }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
// 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;
|
||||
};
|
||||
return () => { addToastFn = null; };
|
||||
}, []);
|
||||
|
||||
if (toasts.length === 0) return 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 (
|
||||
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
toast.type === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||||
: toast.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"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/*
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,6 +77,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.name} workspace — ${data.status}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectNode(isSelected ? null : id);
|
||||
@ -92,6 +96,21 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
e.stopPropagation();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY, nodeId: id, nodeData: data });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectNode(isSelected ? null : id);
|
||||
} else if (e.key === "ContextMenu") {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
openContextMenu({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
nodeId: id,
|
||||
nodeData: data,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
group relative rounded-xl
|
||||
${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"}
|
||||
|
||||
@ -97,9 +97,12 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
|
||||
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
|
||||
<div role="tablist" className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={subTab === "my-chat"}
|
||||
aria-controls="chat-panel-my-chat"
|
||||
onClick={() => setSubTab("my-chat")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "my-chat"
|
||||
@ -110,6 +113,9 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
My Chat
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={subTab === "agent-comms"}
|
||||
aria-controls="chat-panel-agent-comms"
|
||||
onClick={() => setSubTab("agent-comms")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "agent-comms"
|
||||
@ -123,9 +129,13 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{subTab === "my-chat" ? (
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
<div id="chat-panel-my-chat" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
) : (
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -408,6 +418,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div className="p-3 border-t border-zinc-800">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
aria-label="Message to agent"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user