fix(canvas): expand a11y htmlFor/aria-label to SkillsTab, FilesTab, ChannelsTab, ScheduleTab (issue #856)
WCAG 1.3.1 fixes for 4 remaining tabs identified in UIUX Cycle 4 audit:
- SkillsTab: aria-label="Install plugin from source URL" on bare source input
- FilesTab: aria-label="New file path" on bare new-file input
- ChannelsTab: useId() + htmlFor/id pairs for Platform, Bot Token,
Chat IDs, and Allowed Users label↔input associations (4 pairs)
- ScheduleTab: aria-label="Schedule name" on bare name input;
useId() + htmlFor/id pairs for Cron Expression, Timezone,
and Prompt/Task label↔control associations (3 pairs)
- DetailsTab: fix ReactElement<{ id?: string }> cast in Field
component to resolve React 19 TypeScript overload error
Adds 14 new WCAG tests in tabs.a11y.test.tsx covering all above fixes.
No visual change. All 736 tests pass. Build clean.
Closes #856
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fb17f430b7
commit
eba6e3a3de
289
canvas/src/components/__tests__/tabs.a11y.test.tsx
Normal file
289
canvas/src/components/__tests__/tabs.a11y.test.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WCAG 1.3.1 — label↔input association tests for SkillsTab, FilesTab,
|
||||
* ChannelsTab, and ScheduleTab.
|
||||
*
|
||||
* Each test verifies that every form control has an accessible name either via:
|
||||
* - `aria-label` (bare inputs without a visible label element)
|
||||
* - `htmlFor` + matching `id` wired through `useId()` (label↔control pairs)
|
||||
*
|
||||
* `getByLabelText` is the definitive assertion for the htmlFor/id pattern —
|
||||
* if it resolves, the association is valid per the AT accessibility tree.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Global mocks (hoisted before imports) ────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (...args: unknown[]) => mockApiGet(...args),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
put: vi.fn().mockResolvedValue({}),
|
||||
del: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ setPanelTab: vi.fn() })
|
||||
),
|
||||
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// FilesTab sub-module stubs — stub them so we control the onNewFile callback
|
||||
vi.mock("../tabs/FilesTab/FilesToolbar", () => ({
|
||||
FilesToolbar: ({ onNewFile }: { onNewFile: () => void }) => (
|
||||
<button onClick={onNewFile} data-testid="new-file-btn">New File</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/FileTree", () => ({
|
||||
FileTree: () => <div data-testid="file-tree" />,
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/FileEditor", () => ({
|
||||
FileEditor: () => <div data-testid="file-editor" />,
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/useFilesApi", () => ({
|
||||
useFilesApi: () => ({
|
||||
files: [],
|
||||
loading: false,
|
||||
loadFiles: vi.fn(),
|
||||
expandedDirs: new Set<string>(),
|
||||
loadingDir: null,
|
||||
toggleDir: vi.fn(),
|
||||
readFile: vi.fn().mockResolvedValue({ content: "" }),
|
||||
writeFile: vi.fn().mockResolvedValue({}),
|
||||
deleteFile: vi.fn().mockResolvedValue({}),
|
||||
downloadAllFiles: vi.fn(),
|
||||
uploadFiles: vi.fn(),
|
||||
deleteAllFiles: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../tabs/FilesTab/tree", () => ({
|
||||
buildTree: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../ConfirmDialog", () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
// ── Static imports (after mocks) ─────────────────────────────────────────────
|
||||
|
||||
import { SkillsTab } from "../tabs/SkillsTab";
|
||||
import { FilesTab } from "../tabs/FilesTab";
|
||||
import { ChannelsTab } from "../tabs/ChannelsTab";
|
||||
import { ScheduleTab } from "../tabs/ScheduleTab";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSkillsData() {
|
||||
return {
|
||||
id: "ws-1",
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "agent",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "http://localhost:9000",
|
||||
parentId: null,
|
||||
currentTask: "",
|
||||
runtime: "langgraph",
|
||||
needsRestart: false,
|
||||
budgetLimit: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1. SkillsTab — aria-label on the "Install from source" bare input
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SkillsTab — aria-label on bare source input (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('install source input has aria-label="Install plugin from source URL"', async () => {
|
||||
render(<SkillsTab data={makeSkillsData() as never} />);
|
||||
|
||||
// The source input is inside the registry section (showRegistry=false initially).
|
||||
// Click the "+ Install Plugin" button to reveal it.
|
||||
const installBtn = screen.getByRole("button", { name: /install plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: /install plugin from source url/i,
|
||||
});
|
||||
expect(input).toBeDefined();
|
||||
expect(input.getAttribute("aria-label")).toBe("Install plugin from source URL");
|
||||
});
|
||||
|
||||
it("install source input is a text input (not hidden)", async () => {
|
||||
render(<SkillsTab data={makeSkillsData() as never} />);
|
||||
|
||||
const installBtn = screen.getByRole("button", { name: /install plugin/i });
|
||||
fireEvent.click(installBtn);
|
||||
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: /install plugin from source url/i,
|
||||
});
|
||||
expect(input.tagName.toLowerCase()).toBe("input");
|
||||
expect((input as HTMLInputElement).type).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. FilesTab — aria-label on the new file path bare input
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — aria-label on new file path input (WCAG 1.3.1)", () => {
|
||||
it('new file input has aria-label="New file path"', () => {
|
||||
render(<FilesTab workspaceId="ws-1" />);
|
||||
|
||||
// Trigger showNewFile via the FilesToolbar stub
|
||||
const btn = screen.getByTestId("new-file-btn");
|
||||
fireEvent.click(btn);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: /new file path/i });
|
||||
expect(input).toBeDefined();
|
||||
expect(input.getAttribute("aria-label")).toBe("New file path");
|
||||
});
|
||||
|
||||
it("new file input is not shown before clicking the new file button", () => {
|
||||
render(<FilesTab workspaceId="ws-1" />);
|
||||
|
||||
expect(screen.queryByRole("textbox", { name: /new file path/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 3. ChannelsTab — htmlFor/id label associations via useId()
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockImplementation((url: string) => {
|
||||
if (url.includes("/channels/adapters")) {
|
||||
return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function renderAndOpenForm() {
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /\+ connect/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ connect/i }));
|
||||
}
|
||||
|
||||
it("Platform label is associated with the select via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const platformSelect = screen.getByLabelText("Platform");
|
||||
expect(platformSelect.tagName.toLowerCase()).toBe("select");
|
||||
});
|
||||
|
||||
it("Bot Token label is associated with the password input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const botTokenInput = screen.getByLabelText("Bot Token");
|
||||
expect(botTokenInput.tagName.toLowerCase()).toBe("input");
|
||||
expect((botTokenInput as HTMLInputElement).type).toBe("password");
|
||||
});
|
||||
|
||||
it("Chat IDs label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const chatIdInput = screen.getByLabelText("Chat IDs");
|
||||
expect(chatIdInput.tagName.toLowerCase()).toBe("input");
|
||||
});
|
||||
|
||||
it("Allowed Users label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
// Label contains "(optional, comma-separated)" in a nested span — use regex
|
||||
const allowedUsersInput = screen.getByLabelText(/allowed users/i);
|
||||
expect(allowedUsersInput.tagName.toLowerCase()).toBe("input");
|
||||
});
|
||||
|
||||
it("all form control ids are unique and non-empty", async () => {
|
||||
await renderAndOpenForm();
|
||||
|
||||
const platformSelect = screen.getByLabelText("Platform");
|
||||
const botTokenInput = screen.getByLabelText("Bot Token");
|
||||
const chatIdInput = screen.getByLabelText("Chat IDs");
|
||||
const allowedUsersInput = screen.getByLabelText(/allowed users/i);
|
||||
|
||||
const ids = [
|
||||
platformSelect.id,
|
||||
botTokenInput.id,
|
||||
chatIdInput.id,
|
||||
allowedUsersInput.id,
|
||||
];
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(4);
|
||||
ids.forEach((id) => expect(id).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 4. ScheduleTab — aria-label on name + htmlFor/id associations via useId()
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — aria-label + htmlFor/id label associations (WCAG 1.3.1)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
async function renderAndOpenForm() {
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
}
|
||||
|
||||
it('Schedule name input has aria-label="Schedule name"', async () => {
|
||||
await renderAndOpenForm();
|
||||
const nameInput = screen.getByRole("textbox", { name: /^schedule name$/i });
|
||||
expect(nameInput.getAttribute("aria-label")).toBe("Schedule name");
|
||||
});
|
||||
|
||||
it("Cron Expression label is associated with the input via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const cronInput = screen.getByLabelText("Cron Expression");
|
||||
expect(cronInput.tagName.toLowerCase()).toBe("input");
|
||||
expect((cronInput as HTMLInputElement).type).toBe("text");
|
||||
});
|
||||
|
||||
it("Timezone label is associated with the select via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const timezoneSelect = screen.getByLabelText("Timezone");
|
||||
expect(timezoneSelect.tagName.toLowerCase()).toBe("select");
|
||||
});
|
||||
|
||||
it("Prompt / Task label is associated with the textarea via htmlFor/id", async () => {
|
||||
await renderAndOpenForm();
|
||||
const promptTextarea = screen.getByLabelText(/prompt \/ task/i);
|
||||
expect(promptTextarea.tagName.toLowerCase()).toBe("textarea");
|
||||
});
|
||||
|
||||
it("all form control ids are unique and non-empty", async () => {
|
||||
await renderAndOpenForm();
|
||||
|
||||
const cronInput = screen.getByLabelText("Cron Expression");
|
||||
const timezoneSelect = screen.getByLabelText("Timezone");
|
||||
const promptTextarea = screen.getByLabelText(/prompt \/ task/i);
|
||||
|
||||
const ids = [cronInput.id, timezoneSelect.id, promptTextarea.id];
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
ids.forEach((id) => expect(id).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
@ -53,6 +53,12 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
const [selectedChats, setSelectedChats] = useState<Set<string>>(new Set());
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
|
||||
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
||||
const platformId = useId();
|
||||
const botTokenId = useId();
|
||||
const chatIdId = useId();
|
||||
const allowedUsersId = useId();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [chRes, adRes] = await Promise.all([
|
||||
@ -208,8 +214,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
{showForm && (
|
||||
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Platform</label>
|
||||
<label htmlFor={platformId} className="text-[10px] text-zinc-500 block mb-1">Platform</label>
|
||||
<select
|
||||
id={platformId}
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value)}
|
||||
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300"
|
||||
@ -220,8 +227,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<label htmlFor={botTokenId} className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
|
||||
<input
|
||||
id={botTokenId}
|
||||
type="password"
|
||||
value={formBotToken}
|
||||
onChange={(e) => setFormBotToken(e.target.value)}
|
||||
@ -231,7 +239,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<label htmlFor={chatIdId} className="text-[10px] text-zinc-500">Chat IDs</label>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering || !formBotToken}
|
||||
@ -261,6 +269,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
)}
|
||||
{(discoveredChats.length === 0 || showManualInput) && (
|
||||
<input
|
||||
id={chatIdId}
|
||||
value={formChatId}
|
||||
onChange={(e) => setFormChatId(e.target.value)}
|
||||
placeholder="-100123456789, -100987654321"
|
||||
@ -285,10 +294,11 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
|
||||
</label>
|
||||
<input
|
||||
id={allowedUsersId}
|
||||
value={formAllowedUsers}
|
||||
onChange={(e) => setFormAllowedUsers(e.target.value)}
|
||||
placeholder="123456789, 987654321"
|
||||
|
||||
@ -296,7 +296,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={fieldId} className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{cloneElement(children as ReactElement, { id: fieldId })}
|
||||
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -192,6 +192,7 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-zinc-800/40">
|
||||
<input
|
||||
aria-label="New file path"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createFile()}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
@ -67,6 +67,11 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
const [error, setError] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
||||
const cronId = useId();
|
||||
const timezoneId = useId();
|
||||
const promptId = useId();
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
|
||||
@ -198,6 +203,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
<div className="p-3 border-b border-zinc-800/50 bg-zinc-900/50 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Schedule name"
|
||||
placeholder="Schedule name (e.g., Daily security scan)"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
@ -205,8 +211,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
|
||||
<label htmlFor={cronId} className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
|
||||
<input
|
||||
id={cronId}
|
||||
type="text"
|
||||
value={formCron}
|
||||
onChange={(e) => setFormCron(e.target.value)}
|
||||
@ -217,8 +224,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
|
||||
<label htmlFor={timezoneId} className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
|
||||
<select
|
||||
id={timezoneId}
|
||||
value={formTimezone}
|
||||
onChange={(e) => setFormTimezone(e.target.value)}
|
||||
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-1 py-1 text-zinc-200"
|
||||
@ -237,8 +245,9 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
|
||||
<label htmlFor={promptId} className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
|
||||
<textarea
|
||||
id={promptId}
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
placeholder="What should the agent do on this schedule?"
|
||||
|
||||
@ -232,6 +232,7 @@ export function SkillsTab({ data }: Props) {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Install plugin from source URL"
|
||||
value={customSource}
|
||||
onChange={(e) => setCustomSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user