Merge pull request #875 from Molecule-AI/fix/canvas-a11y-configtab-detailstab-htmlfor
fix(canvas): htmlFor/id association in ConfigTab + DetailsTab inputs
This commit is contained in:
commit
fc7317d5cd
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 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 from source url/i,
|
||||
});
|
||||
expect(input).toBeDefined();
|
||||
expect(input.getAttribute("aria-label")).toBe("Install 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 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"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useId } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
|
||||
@ -170,6 +170,14 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// Stable IDs for bare label↔control pairs (WCAG 1.3.1)
|
||||
const descriptionId = useId();
|
||||
const tierId = useId();
|
||||
const runtimeId = useId();
|
||||
const effortId = useId();
|
||||
const taskBudgetId = useId();
|
||||
const sandboxBackendId = useId();
|
||||
|
||||
const isDirty = rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml;
|
||||
|
||||
if (loading) {
|
||||
@ -214,8 +222,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<Section title="General">
|
||||
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Description</label>
|
||||
<label htmlFor={descriptionId} className="text-[10px] text-zinc-500 block mb-1">Description</label>
|
||||
<textarea
|
||||
id={descriptionId}
|
||||
value={config.description}
|
||||
onChange={(e) => update("description", e.target.value)}
|
||||
rows={3}
|
||||
@ -225,8 +234,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Tier</label>
|
||||
<label htmlFor={tierId} className="text-[10px] text-zinc-500 block mb-1">Tier</label>
|
||||
<select
|
||||
id={tierId}
|
||||
value={config.tier}
|
||||
onChange={(e) => update("tier", parseInt(e.target.value, 10))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -242,8 +252,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<Section title="Runtime">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<select
|
||||
id={runtimeId}
|
||||
value={config.runtime || ""}
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -273,11 +284,12 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
|
||||
<Section title="Claude Settings" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={effortId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Effort
|
||||
<span className="ml-1 text-zinc-600">(output_config.effort — Opus 4.7+)</span>
|
||||
</label>
|
||||
<select
|
||||
id={effortId}
|
||||
value={config.effort || ""}
|
||||
onChange={(e) => update("effort", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
@ -292,11 +304,12 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
<label htmlFor={taskBudgetId} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Task Budget (tokens)
|
||||
<span className="ml-1 text-zinc-600">(output_config.task_budget.total — 0 = unset)</span>
|
||||
</label>
|
||||
<input
|
||||
id={taskBudgetId}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
@ -334,8 +347,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
|
||||
<Section title="Sandbox" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Backend</label>
|
||||
<label htmlFor={sandboxBackendId} className="text-[10px] text-zinc-500 block mb-1">Backend</label>
|
||||
<select
|
||||
id={sandboxBackendId}
|
||||
value={config.sandbox?.backend || "docker"}
|
||||
onChange={(e) => updateNested("sandbox" as keyof ConfigData, "backend", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useId, cloneElement, type ReactElement } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { StatusDot } from "../StatusDot";
|
||||
@ -300,10 +300,11 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{children}
|
||||
<label htmlFor={fieldId} className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
|
||||
{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 from source URL"
|
||||
value={customSource}
|
||||
onChange={(e) => setCustomSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user