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:
molecule-ai[bot] 2026-04-23 02:54:46 +00:00 committed by GitHub
commit 9d076b9c4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 452 additions and 36 deletions

View File

@ -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&apos;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}

View File

@ -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"
>

View File

@ -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">

View File

@ -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)]"

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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 &amp; 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 &amp; 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&apos;t check terms status: {error ?? "unknown error"}
</div>
)}

View File

@ -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);
});
});

View File

@ -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);
});
});

View 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);
}
});
});

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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"
>

View File

@ -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>