From 120b886d00f208a0f064fd5a2ab03f4e2aa9ee99 Mon Sep 17 00:00:00 2001 From: Canvas Agent Date: Thu, 16 Apr 2026 11:20:01 +0000 Subject: [PATCH] fix(a11y): WCAG ARIA fixes for time-sensitive components (Fixes #Fix1/#Fix2/#Fix3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 --- canvas/src/components/ApprovalBanner.tsx | 5 +- canvas/src/components/BundleDropZone.tsx | 98 ++++++++--- .../__tests__/aria-time-sensitive.test.tsx | 161 ++++++++++++++++++ canvas/src/components/tabs/TerminalTab.tsx | 8 +- 4 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 canvas/src/components/__tests__/aria-time-sensitive.test.tsx diff --git a/canvas/src/components/ApprovalBanner.tsx b/canvas/src/components/ApprovalBanner.tsx index 152f7997..fd2e148b 100644 --- a/canvas/src/components/ApprovalBanner.tsx +++ b/canvas/src/components/ApprovalBanner.tsx @@ -54,11 +54,14 @@ export function ApprovalBanner() { {approvals.map((approval) => (
- +
{approval.workspace_name} needs approval
diff --git a/canvas/src/components/BundleDropZone.tsx b/canvas/src/components/BundleDropZone.tsx index febfdc08..b17d8ac8 100644 --- a/canvas/src/components/BundleDropZone.tsx +++ b/canvas/src/components/BundleDropZone.tsx @@ -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(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) => { + 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) */} + + {/* Invisible drop zone covering the canvas */}
+ {/* 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) */} + + {/* Visual overlay when dragging */} {isDragging && (
-
📦
+
Drop Bundle to Import
.bundle.json files only
@@ -95,9 +135,11 @@ export function BundleDropZone() {
)} - {/* Result toast */} + {/* Result toast — role="status" announces import outcome to screen readers */} {result && (
+ 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(); + 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(); + // 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(); + 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(); + // 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(); + 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(); + // 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(); + + 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"); + }); +}); diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index 6278d377..371a5638 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -121,8 +121,8 @@ export function TerminalTab({ workspaceId }: Props) { return (
- {/* Status bar */} -
+ {/* Status bar — role="status" so connection state changes are announced politely */} +
- {/* Error message */} + {/* Error message — role="alert" announces immediately via assertive live region */} {errorMsg && ( -
+
{errorMsg}
)}