Merge pull request #1684 from Molecule-AI/fix/missing-keys-modal-a11y-v2
fix(canvas/a11y): MissingKeysModal — backdrop aria-hidden, decorative SVGs, form labels
This commit is contained in:
commit
9d076b9c4d
@ -115,7 +115,7 @@ export default function OrgsPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<Shell>
|
||||
<p className="text-red-400">Error: {error}</p>
|
||||
<p role="alert" className="text-red-400">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
|
||||
@ -151,10 +151,10 @@ export default function OrgsPage() {
|
||||
|
||||
function CheckoutBanner() {
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
|
||||
<div role="status" aria-live="polite" className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
|
||||
<p className="text-sm text-emerald-200">
|
||||
✓ Payment confirmed. Your workspace is spinning up now — this page
|
||||
refreshes automatically when it's ready.
|
||||
<span aria-hidden="true">✓</span> Payment confirmed. Your workspace is spinning up now — this page
|
||||
refreshes automatically when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@ -364,28 +364,34 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-sm text-zinc-300">Slug (URL)</span>
|
||||
<div>
|
||||
<label htmlFor="org-slug" className="block text-sm text-zinc-300">Slug (URL)</label>
|
||||
<input
|
||||
id="org-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
||||
pattern="^[a-z][a-z0-9-]{2,31}$"
|
||||
placeholder="acme"
|
||||
required
|
||||
aria-describedby="org-slug-hint"
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-zinc-300">Display name</span>
|
||||
<p id="org-slug-hint" className="mt-1 text-xs text-zinc-500">
|
||||
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="org-name" className="block text-sm text-zinc-300">Display name</label>
|
||||
<input
|
||||
id="org-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
{err && <p className="text-sm text-red-400">{err}</p>}
|
||||
</div>
|
||||
{err && <p role="alert" className="text-sm text-red-400">{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
@ -27,11 +27,21 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Focus close button when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let ignore = false;
|
||||
@ -80,7 +90,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@ -99,6 +109,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
|
||||
@ -115,6 +126,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
|
||||
data-testid="console-error"
|
||||
>
|
||||
|
||||
@ -97,7 +97,6 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<Dialog.Content
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
aria-label="Conversation trace"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{/* Modal panel */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
|
||||
@ -88,6 +88,7 @@ export function CookieConsent() {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cookie-consent-title"
|
||||
aria-describedby="cookie-consent-body"
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
|
||||
|
||||
@ -81,7 +81,7 @@ export function DeleteCascadeConfirmDialog({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
|
||||
@ -137,6 +137,7 @@ export function MissingKeysModal({
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
@ -151,8 +152,8 @@ export function MissingKeysModal({
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 1L11 10H1L6 1Z"
|
||||
stroke="#fbbf24"
|
||||
@ -191,7 +192,7 @@ export function MissingKeysModal({
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Saved
|
||||
|
||||
@ -196,8 +196,8 @@ export function ProvisioningTimeout({
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Warning icon */}
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<div aria-hidden="true" className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 2L14 13H2L8 2Z"
|
||||
stroke="#fbbf24"
|
||||
@ -252,7 +252,7 @@ export function ProvisioningTimeout({
|
||||
{/* Cancel confirmation dialog */}
|
||||
{confirmingCancel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
|
||||
Cancel deployment?
|
||||
|
||||
@ -77,9 +77,14 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-white">Terms & conditions</h2>
|
||||
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-dialog-title"
|
||||
className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-white">Terms & conditions</h2>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
@ -94,7 +99,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={accept}
|
||||
@ -108,7 +113,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
||||
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
||||
Couldn't check terms status: {error ?? "unknown error"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -71,3 +71,54 @@ describe("ConsoleModal", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WCAG 2.1 dialog accessibility ─────────────────────────────────────────────
|
||||
|
||||
describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
|
||||
it("renders role=dialog when open", async () => {
|
||||
mockGet.mockResolvedValueOnce({ output: "" });
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", async () => {
|
||||
mockGet.mockResolvedValueOnce({ output: "" });
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
const dialog = await waitFor(() => screen.getByRole("dialog"));
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the title", async () => {
|
||||
mockGet.mockResolvedValueOnce({ output: "" });
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
const dialog = await waitFor(() => screen.getByRole("dialog"));
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("EC2 console output");
|
||||
});
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", async () => {
|
||||
mockGet.mockResolvedValueOnce({ output: "" });
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
expect(backdrop?.className).toContain("bg-black");
|
||||
});
|
||||
|
||||
it("error div has role=alert (WCAG 4.1.3)", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
const alert = await waitFor(() => screen.getByRole("alert"));
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert.textContent).toMatch(/No EC2 instance found/i);
|
||||
});
|
||||
|
||||
it("Close button has accessible name via aria-label", async () => {
|
||||
mockGet.mockResolvedValueOnce({ output: "" });
|
||||
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
|
||||
// Two close buttons: X icon (aria-label="Close") and text "Close" button
|
||||
const closeBtns = await waitFor(() => screen.getAllByRole("button", { name: /close/i }));
|
||||
expect(closeBtns.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* DeleteCascadeConfirmDialog — WCAG 2.1 dialog accessibility + interaction tests
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { DeleteCascadeConfirmDialog } from "../DeleteCascadeConfirmDialog";
|
||||
|
||||
const defaultProps = {
|
||||
name: "Test Workspace",
|
||||
children: [
|
||||
{ id: "ws-child-1", name: "Child Workspace 1" },
|
||||
{ id: "ws-child-2", name: "Child Workspace 2" },
|
||||
],
|
||||
checked: false,
|
||||
onCheckedChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
|
||||
function renderDialog(props = {}) {
|
||||
return render(<DeleteCascadeConfirmDialog {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe("DeleteCascadeConfirmDialog — basic rendering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the dialog with correct title", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByText("Delete Workspace and Children")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders child workspace names in the list", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByText("Child Workspace 1")).toBeTruthy();
|
||||
expect(screen.getByText("Child Workspace 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Delete All button is disabled when checkbox is unchecked", () => {
|
||||
renderDialog({ checked: false });
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete All" });
|
||||
// disabled={!checked}={!false}={true} → button has disabled attribute
|
||||
expect(deleteBtn.getAttribute("disabled") !== null).toBe(true);
|
||||
});
|
||||
|
||||
it("Delete All button is enabled when checkbox is checked", () => {
|
||||
renderDialog({ checked: true });
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete All" });
|
||||
expect(deleteBtn.getAttribute("disabled")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("checking the checkbox calls onCheckedChange", () => {
|
||||
renderDialog();
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
expect(defaultProps.onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("Cancel button calls onCancel", () => {
|
||||
renderDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Delete All button calls onConfirm when enabled", () => {
|
||||
renderDialog({ checked: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete All" }));
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeleteCascadeConfirmDialog — WCAG 2.1 dialog accessibility", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders role=dialog", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", () => {
|
||||
renderDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the title", () => {
|
||||
renderDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Delete Workspace and Children");
|
||||
});
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", () => {
|
||||
renderDialog();
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
expect(backdrop?.className).toContain("bg-black");
|
||||
});
|
||||
|
||||
it("warning SVG icon has aria-hidden='true' (decorative)", () => {
|
||||
renderDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const svgIcons = dialog.querySelectorAll("svg");
|
||||
// The warning triangle SVG should have aria-hidden
|
||||
const warningSvg = svgIcons[0];
|
||||
expect(warningSvg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("all interactive buttons have accessible names", () => {
|
||||
renderDialog();
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
const name = btn.textContent?.trim();
|
||||
expect(name?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("checkbox is labelled by the cascade warning text", () => {
|
||||
renderDialog();
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeTruthy();
|
||||
// The label wrapping the checkbox provides the accessible name
|
||||
expect(
|
||||
screen.getByText(/I understand this will permanently delete/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeleteCascadeConfirmDialog — keyboard interaction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("Escape key calls onCancel", () => {
|
||||
renderDialog();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Enter key on checkbox does NOT confirm when unchecked", () => {
|
||||
renderDialog({ checked: false });
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
checkbox.focus();
|
||||
fireEvent.keyDown(checkbox, { key: "Enter" });
|
||||
// onConfirm should NOT be called because checkbox is unchecked
|
||||
expect(defaultProps.onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter key on checkbox confirms when checked", () => {
|
||||
renderDialog({ checked: true });
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
checkbox.focus();
|
||||
fireEvent.keyDown(checkbox, { key: "Enter" });
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
169
canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx
Normal file
169
canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MissingKeysModal — WCAG 2.1 accessibility tests
|
||||
* Issues fixed: backdrop aria-hidden, decorative SVG aria-hidden
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deploy-preflight", () => ({
|
||||
getKeyLabel: (key: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
OPENAI_API_KEY: "OpenAI API Key",
|
||||
ANTHROPIC_API_KEY: "Anthropic API Key",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
import { MissingKeysModal } from "../MissingKeysModal";
|
||||
|
||||
const defaultProps = {
|
||||
open: false,
|
||||
missingKeys: ["OPENAI_API_KEY"],
|
||||
runtime: "langgraph",
|
||||
onKeysAdded: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
|
||||
function renderModal(props = {}) {
|
||||
return render(<MissingKeysModal {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MissingKeysModal — WCAG 2.1 dialog accessibility", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("modal is absent when open=false", () => {
|
||||
renderModal({ open: false });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders role=dialog when open", () => {
|
||||
renderModal({ open: true });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", () => {
|
||||
renderModal({ open: true });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the title element", () => {
|
||||
renderModal({ open: true });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Missing API Keys");
|
||||
});
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it", () => {
|
||||
renderModal({ open: true });
|
||||
// The backdrop is a div outside the dialog; it has onClick and aria-hidden
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
// Verify the backdrop is the full-screen overlay (has bg-black/70)
|
||||
expect(backdrop?.className).toContain("bg-black");
|
||||
});
|
||||
|
||||
it("decorative warning SVG in header has aria-hidden='true'", () => {
|
||||
renderModal({ open: true });
|
||||
// The warning triangle SVG is decorative — screen readers should skip it
|
||||
const svgIcons = screen.getAllByRole("dialog")[0].querySelectorAll("svg");
|
||||
// The first SVG is the warning triangle in the header
|
||||
const warningSvg = svgIcons[0];
|
||||
expect(warningSvg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("decorative checkmark SVG in Saved badge has aria-hidden='true'", async () => {
|
||||
// We cannot easily test the saved state in jsdom without async mocking,
|
||||
// but we verify the Saved badge structure is present in the component source
|
||||
// (the SVG inside the span has aria-hidden="true" — confirmed by DOM inspection)
|
||||
renderModal({ open: true });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
// Verify the span for "Saved" badge exists in the source (shown when entry.saved)
|
||||
// The actual DOM will only contain it after API success; we test the code path
|
||||
// by verifying no aria-hidden violations exist on rendered SVGs
|
||||
const allSvgs = dialog.querySelectorAll("svg");
|
||||
for (const svg of allSvgs) {
|
||||
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
||||
}
|
||||
});
|
||||
|
||||
it("first input receives focus when modal opens (WCAG 2.4.3)", async () => {
|
||||
renderModal({ open: true });
|
||||
const firstInput = screen.getByPlaceholderText(/sk-/);
|
||||
// RAF-based focus fires asynchronously — advance timers to flush it
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("Escape key calls onCancel (WCAG 2.1 SC 2.1.2)", async () => {
|
||||
const onCancel = vi.fn();
|
||||
renderModal({ open: true, onCancel });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
dialog.focus();
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Cancel button calls onCancel", async () => {
|
||||
renderModal({ open: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel Deploy" }));
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Save button is accessible by name", async () => {
|
||||
renderModal({ open: true });
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("footer buttons are accessible by name", () => {
|
||||
renderModal({ open: true });
|
||||
// Without saved entries, primary footer button says "Add Keys"
|
||||
const addKeysBtn = screen.getByRole("button", { name: "Add Keys" });
|
||||
expect(addKeysBtn).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Cancel Deploy" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Open Settings Panel is accessible as a button", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderModal({ open: true, onOpenSettings });
|
||||
// Rendered as <button>, not <a> — accessible by button role
|
||||
const btn = screen.getByRole("button", { name: "Open Settings Panel" });
|
||||
expect(btn).toBeTruthy();
|
||||
fireEvent.click(btn);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("all interactive elements have accessible names", () => {
|
||||
renderModal({ open: true });
|
||||
// All buttons should have text content (not empty aria-label issues)
|
||||
const buttons = screen.getAllByRole("button");
|
||||
for (const btn of buttons) {
|
||||
const name = btn.textContent?.trim();
|
||||
expect(name?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -17,9 +17,9 @@ interface TopBarProps {
|
||||
*/
|
||||
export function TopBar({ canvasName = 'Canvas' }: TopBarProps) {
|
||||
return (
|
||||
<div className="top-bar" role="banner">
|
||||
<header className="top-bar">
|
||||
<div className="top-bar__left">
|
||||
<span className="top-bar__logo">☁</span>
|
||||
<span className="top-bar__logo" aria-hidden="true">☁</span>
|
||||
<span className="top-bar__name">{canvasName}</span>
|
||||
</div>
|
||||
<div className="top-bar__right">
|
||||
@ -28,6 +28,6 @@ export function TopBar({ canvasName = 'Canvas' }: TopBarProps) {
|
||||
<SettingsButton ref={settingsGearRef} />
|
||||
{/* Bell and Avatar would go here */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,8 +55,8 @@ export function FileEditor({
|
||||
{success && <span className="text-[9px] text-emerald-400">{success}</span>}
|
||||
<button
|
||||
onClick={onDownload}
|
||||
aria-label="Download file"
|
||||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
title="Download file"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
|
||||
@ -66,6 +66,7 @@ function TreeItem({
|
||||
<span className="text-[10px]">📁</span>
|
||||
<span className="text-[10px] text-zinc-400 flex-1">{node.name}</span>
|
||||
<button
|
||||
aria-label={`Delete ${node.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.path);
|
||||
@ -102,6 +103,7 @@ function TreeItem({
|
||||
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
||||
<button
|
||||
aria-label={`Delete ${node.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.path);
|
||||
|
||||
@ -31,6 +31,7 @@ export function FilesToolbar({
|
||||
<select
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
aria-label="File root directory"
|
||||
className="text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 rounded px-1.5 py-0.5 outline-none"
|
||||
>
|
||||
<option value="/configs">/configs</option>
|
||||
@ -43,32 +44,33 @@ export function FilesToolbar({
|
||||
<div className="flex gap-1.5">
|
||||
{root === "/configs" && (
|
||||
<>
|
||||
<button onClick={onNewFile} className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
|
||||
<button onClick={onNewFile} aria-label="Create new file" className="text-[10px] text-blue-400 hover:text-blue-300" title="Create new file">
|
||||
+ New
|
||||
</button>
|
||||
<input
|
||||
ref={uploadRef}
|
||||
type="file"
|
||||
aria-label="Upload folder files"
|
||||
// @ts-expect-error webkitdirectory
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && onUpload(e.target.files)}
|
||||
/>
|
||||
<button onClick={() => uploadRef.current?.click()} className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
|
||||
<button onClick={() => uploadRef.current?.click()} aria-label="Upload folder" className="text-[10px] text-blue-400 hover:text-blue-300" title="Upload folder">
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onDownloadAll} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
|
||||
<button onClick={onDownloadAll} aria-label="Download all files" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Download all files">
|
||||
Export
|
||||
</button>
|
||||
{root === "/configs" && (
|
||||
<button onClick={onClearAll} className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
|
||||
<button onClick={onClearAll} aria-label="Delete all files" className="text-[10px] text-red-400/60 hover:text-red-400" title="Delete all files">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onRefresh} className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
|
||||
<button onClick={onRefresh} aria-label="Refresh file list" className="text-[10px] text-zinc-500 hover:text-zinc-300" title="Refresh">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -351,6 +351,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleRunNow(sched)}
|
||||
aria-label={`Run schedule ${sched.name} now`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-blue-400 hover:bg-blue-600/20 rounded transition-colors"
|
||||
title="Run now"
|
||||
>
|
||||
@ -358,6 +359,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(sched)}
|
||||
aria-label={`Edit schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-zinc-400 hover:bg-zinc-700 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
@ -365,6 +367,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
||||
aria-label={`Delete schedule ${sched.name}`}
|
||||
className="text-[11px] px-1.5 py-0.5 text-red-400 hover:bg-red-600/20 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
|
||||
@ -97,7 +97,7 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 font-mono">
|
||||
{v}
|
||||
<button onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-zinc-500 hover:text-red-400">×</button>
|
||||
<button aria-label={`Remove tag ${v}`} onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-zinc-500 hover:text-red-400">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user