fix(canvas): WCAG SC 1.3.1 — programmatic label/input association in InputField

Adds useId() to the InputField helper in CreateWorkspaceDialog so every
<label> is wired to its <input> via htmlFor/id. Without this, screen readers
announced only the placeholder text, not the field name (WCAG 2.1 SC 1.3.1
Level A violation, build 4JIwTGVMjDGNLO8iMGJeC).

Affected fields: Name (required), Role, Budget limit (USD), Template.
The Hermes provider fields were already correctly wired.

Adds 6 new tests in CreateWorkspaceDialog.a11y.test.tsx verifying htmlFor/id
round-trips for each field and unique-id non-collision (602 total, all pass;
build clean; 'use client' grep empty).

Note: #554 (hydration error UI) and #556 (tier radio arrow-key nav) are
confirmed fixed in commit 76defba — audit cycle 2 was run against the
pre-fix build. #557 (zoom-to-team Z key) is a false positive — the handler
IS implemented; closing via Dev Lead once token is refreshed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 01:28:55 +00:00
parent 2152323cd1
commit c064200164
2 changed files with 76 additions and 2 deletions

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useId } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
@ -368,9 +368,13 @@ function InputField({
type?: string;
helper?: string;
}) {
// useId() generates a stable, unique ID for the label↔input association,
// satisfying WCAG 2.1 SC 1.3.1 (Info and Relationships, Level A).
const inputId = useId();
return (
<div>
<label className="text-[11px] text-zinc-400 block mb-1">
<label htmlFor={inputId} className="text-[11px] text-zinc-400 block mb-1">
{label}{" "}
{required && (
<>
@ -382,6 +386,7 @@ function InputField({
)}
</label>
<input
id={inputId}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}

View File

@ -161,3 +161,72 @@ describe("CreateWorkspaceDialog — accessibility", () => {
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
});
});
// ── WCAG 2.1 SC 1.3.1 — Programmatic label association (Issue #558) ──────────
//
// Every <input> rendered by the InputField helper must have a matching <label>
// via htmlFor/id so screen readers announce the field name, not just the
// placeholder. useId() in InputField generates stable unique IDs per render.
describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () => {
it("Name input has a <label> whose htmlFor matches the input id", async () => {
await openDialog();
const nameInput = screen.getByPlaceholderText("e.g. SEO Agent") as HTMLInputElement;
expect(nameInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${nameInput.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Name");
});
it("Role input has a <label> whose htmlFor matches the input id", async () => {
await openDialog();
const roleInput = screen.getByPlaceholderText("e.g. SEO Specialist") as HTMLInputElement;
expect(roleInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${roleInput.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Role");
});
it("Budget limit input has a <label> whose htmlFor matches the input id", async () => {
await openDialog();
const budgetInput = screen.getByPlaceholderText("e.g. 100") as HTMLInputElement;
expect(budgetInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${budgetInput.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Budget limit");
});
it("Template input has a <label> whose htmlFor matches the input id", async () => {
await openDialog();
const templateInput = screen.getByPlaceholderText(
"e.g. seo-agent (from workspace-configs-templates/)"
) as HTMLInputElement;
expect(templateInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${templateInput.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Template");
});
it("each InputField generates a distinct id (no id collisions)", async () => {
await openDialog();
const inputs = [
screen.getByPlaceholderText("e.g. SEO Agent"),
screen.getByPlaceholderText("e.g. SEO Specialist"),
screen.getByPlaceholderText("e.g. 100"),
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
] as HTMLInputElement[];
const ids = inputs.map((i) => i.id).filter(Boolean);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length); // no duplicates
expect(ids.length).toBe(4);
});
it("Name label text contains the required asterisk indicator", async () => {
await openDialog();
const nameInput = screen.getByPlaceholderText("e.g. SEO Agent") as HTMLInputElement;
const label = document.querySelector(`label[for="${nameInput.id}"]`);
// aria-hidden asterisk * is present for visual required indicator
expect(label?.querySelector("[aria-hidden='true']")?.textContent).toBe("*");
});
});