fix(a11y): WCAG ARIA fixes for time-sensitive components (Fixes #Fix1/#Fix2/#Fix3)

- ApprovalBanner: add role="alert" aria-live="assertive" aria-atomic="true" to
  each pending approval card; aria-hidden="true" on decorative ⚠ icon span
- TerminalTab: add role="status" aria-live="polite" to connection status bar;
  add role="alert" to inline error message div
- BundleDropZone: extract shared processFile(); add hidden <input type="file">
  with id/accept/aria-label; add sr-only focus:not-sr-only keyboard trigger
  button; add role="status" aria-live="polite" to result toast

Tests: 7 new assertions in aria-time-sensitive.test.tsx covering all 3 fixes
(496/496 pass, build clean)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Canvas Agent 2026-04-16 11:20:01 +00:00 committed by Hongming Wang
parent 242306d239
commit 120b886d00
4 changed files with 239 additions and 33 deletions

View File

@ -54,11 +54,14 @@ export function ApprovalBanner() {
{approvals.map((approval) => (
<div
key={approval.id}
role="alert"
aria-live="assertive"
aria-atomic="true"
className="bg-amber-950/90 backdrop-blur-md border border-amber-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
<span className="text-amber-300 text-lg"></span>
<span className="text-amber-300 text-lg" aria-hidden="true"></span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>

View File

@ -1,42 +1,24 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import { api } from "@/lib/api";
export function BundleDropZone() {
const [isDragging, setIsDragging] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ status: string; name?: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = Array.from(e.dataTransfer.files).find(
(f) => f.name.endsWith(".bundle.json")
);
if (!file) {
/**
* Core file processor shared between drag-drop and keyboard file-picker
* so both code paths have identical import behaviour (WCAG 2.1.1).
*/
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith(".bundle.json")) {
setResult({ status: "error", name: "Only .bundle.json files are accepted" });
setTimeout(() => setResult(null), 3000);
return;
}
setImporting(true);
try {
const text = await file.text();
@ -58,8 +40,55 @@ export function BundleDropZone() {
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = Array.from(e.dataTransfer.files).find(
(f) => f.name.endsWith(".bundle.json")
);
if (!file) {
setResult({ status: "error", name: "Only .bundle.json files are accepted" });
setTimeout(() => setResult(null), 3000);
return;
}
await processFile(file);
}, [processFile]);
const handleFileInput = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = ""; // reset so the same file can be re-selected
await processFile(file);
}, [processFile]);
return (
<>
{/* Hidden file input — keyboard / assistive-tech alternative to drag-drop (WCAG 2.1.1) */}
<input
ref={fileInputRef}
id="bundle-file-input"
type="file"
accept=".bundle.json"
className="sr-only"
onChange={handleFileInput}
aria-label="Import bundle file"
/>
{/* Invisible drop zone covering the canvas */}
<div
className="fixed inset-0 z-10 pointer-events-none"
@ -76,11 +105,22 @@ export function BundleDropZone() {
style={{ pointerEvents: "none" }}
/>
{/* Keyboard-accessible import button visible on focus or hover so
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
<button
onClick={() => fileInputRef.current?.click()}
aria-label="Import bundle file"
aria-controls="bundle-file-input"
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
>
📦 Import bundle
</button>
{/* Visual overlay when dragging */}
{isDragging && (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2">📦</div>
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
</div>
@ -95,9 +135,11 @@ export function BundleDropZone() {
</div>
)}
{/* Result toast */}
{/* Result toast — role="status" announces import outcome to screen readers */}
{result && (
<div
role="status"
aria-live="polite"
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl px-5 py-3 shadow-2xl text-sm ${
result.status === "success"
? "bg-emerald-950/90 border border-emerald-700/50 text-emerald-200"

View File

@ -0,0 +1,161 @@
// @vitest-environment jsdom
/**
* WCAG 2 audit time-sensitive component ARIA fixes:
* Fix 1: ApprovalBanner role="alert" aria-live="assertive" + aria-hidden on icon
* Fix 2: TerminalTab role="status" on connection bar, role="alert" on error
* Fix 3: BundleDropZone keyboard file-picker (hidden <input> + accessible button)
* + role="status" on result toast
*/
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ────────────────────────────────────────────────────────────────────────────
// Fix 1 — ApprovalBanner
// ────────────────────────────────────────────────────────────────────────────
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn().mockResolvedValue([]),
post: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
import { api } from "@/lib/api";
import { ApprovalBanner } from "../ApprovalBanner";
// Stub a minimal approval so the banner renders
const mockApproval = {
id: "a1",
workspace_id: "ws-1",
workspace_name: "PM Agent",
action: "Run deployment script",
reason: "Routine release",
status: "pending",
created_at: new Date().toISOString(),
};
describe("ApprovalBanner — ARIA time-sensitive (Fix 1)", () => {
beforeEach(() => {
vi.mocked(api.get).mockResolvedValue([mockApproval]);
});
it("renders role='alert' with aria-live='assertive' on each approval card", async () => {
const { findByRole } = render(<ApprovalBanner />);
const alert = await findByRole("alert");
expect(alert.getAttribute("aria-live")).toBe("assertive");
expect(alert.getAttribute("aria-atomic")).toBe("true");
});
it("⚠ icon span has aria-hidden='true'", async () => {
render(<ApprovalBanner />);
// Wait for data
await screen.findByRole("alert");
// The ⚠ span should be aria-hidden
const hiddenSpans = document.querySelectorAll('[aria-hidden="true"]');
const warningSpan = Array.from(hiddenSpans).find((el) =>
el.textContent?.includes("⚠")
);
expect(warningSpan).not.toBeNull();
});
});
// ────────────────────────────────────────────────────────────────────────────
// Fix 2 — TerminalTab
// ────────────────────────────────────────────────────────────────────────────
// Mock xterm — not installed in jsdom, just need component to render
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon = vi.fn();
open = vi.fn();
dispose = vi.fn();
onData = vi.fn(() => ({ dispose: vi.fn() }));
onResize = vi.fn(() => ({ dispose: vi.fn() }));
writeln = vi.fn();
write = vi.fn();
clear = vi.fn();
options = {};
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit = vi.fn();
activate = vi.fn();
dispose = vi.fn();
},
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: class { activate = vi.fn(); dispose = vi.fn(); },
}));
import { TerminalTab } from "../tabs/TerminalTab";
describe("TerminalTab — ARIA live regions (Fix 2)", () => {
it("status bar wrapper has role='status' and aria-live='polite'", () => {
render(<TerminalTab workspaceId="ws-1" />);
const statusBar = document.querySelector('[role="status"]');
expect(statusBar).not.toBeNull();
expect(statusBar?.getAttribute("aria-live")).toBe("polite");
});
it("status bar text changes reflect connection state (content test)", () => {
render(<TerminalTab workspaceId="ws-1" />);
// Default state while attempting to connect will show some status text
const statusBar = document.querySelector('[role="status"]');
expect(statusBar?.textContent?.length).toBeGreaterThan(0);
});
});
// ────────────────────────────────────────────────────────────────────────────
// Fix 3 — BundleDropZone
// ────────────────────────────────────────────────────────────────────────────
import { BundleDropZone } from "../BundleDropZone";
describe("BundleDropZone — keyboard accessibility (Fix 3)", () => {
it("renders a hidden file input with accept='.bundle.json' and an accessible label", () => {
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
expect(input).not.toBeNull();
expect(input?.type).toBe("file");
expect(input?.accept).toBe(".bundle.json");
expect(input?.getAttribute("aria-label")).toBeTruthy();
// Must be visually hidden but still reachable by AT
expect(input?.className).toContain("sr-only");
});
it("renders a keyboard-accessible import button that is tabbable", () => {
render(<BundleDropZone />);
// The button may be sr-only but must exist in the DOM and be focusable
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).not.toBeNull();
});
it("result toast renders with role='status' and aria-live='polite'", async () => {
vi.mocked(api.post).mockResolvedValue({ name: "my-bundle", status: "ok" });
render(<BundleDropZone />);
const input = document.getElementById("bundle-file-input") as HTMLInputElement;
const file = new File(['{"workspaces":[]}'], "test.bundle.json", {
type: "application/json",
});
// Simulate file selection via the hidden input
Object.defineProperty(input, "files", { value: [file], configurable: true });
await fireEvent.change(input);
// Toast should appear with role=status
const toast = await screen.findByRole("status");
expect(toast).not.toBeNull();
expect(toast.getAttribute("aria-live")).toBe("polite");
});
});

View File

@ -121,8 +121,8 @@ export function TerminalTab({ workspaceId }: Props) {
return (
<div className="flex flex-col h-full">
{/* Status bar */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
{/* Status bar — role="status" so connection state changes are announced politely */}
<div role="status" aria-live="polite" className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
status === "connected" ? "bg-green-500" :
@ -145,9 +145,9 @@ export function TerminalTab({ workspaceId }: Props) {
)}
</div>
{/* Error message */}
{/* Error message — role="alert" announces immediately via assertive live region */}
{errorMsg && (
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
{errorMsg}
</div>
)}