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:
parent
2152323cd1
commit
c064200164
@ -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)}
|
||||
|
||||
@ -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("*");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user