diff --git a/docs/accessibility.md b/docs/accessibility.md index 9047724..16ad65c 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -16,6 +16,10 @@ automatically, what was fixed, and what still needs a manual pass. | Screen reader | Command selector is a labelled group of toggle buttons | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` | | Screen reader | Copy results are announced through a polite live region | `src/App.test.tsx`, `tests/visual/accessibility.spec.ts` | | Screen reader | Audit prerequisites are a labelled fieldset of checkboxes, each with its own accessible name | `src/App.test.tsx` | +| Screen reader | Every form field carries an `aria-describedby` pointing to visible inline help text (#100) | `src/App.test.tsx` | +| Screen reader | Focus moves to the command section heading after a workflow tab switch (#101) | `src/App.test.tsx` | +| Screen reader | Validation errors are announced via an `aria-live="assertive"` region (#102) | `src/App.test.tsx` | +| Screen reader | "Copied" label remains visible for at least 2 000 ms to give AT time to announce it (#103) | `src/App.test.tsx` | | Zoom | No horizontal page scroll and no clipped controls at 640px and 320px CSS widths | `tests/visual/accessibility.spec.ts` | These checks read the accessibility tree and computed styles. They do not @@ -52,3 +56,7 @@ browser and screen reader pair per platform. | 8 | The limits workflow needed a discoverable, ready-to-run command reference. | Fixed: added a focusable reference section and a build-and-run example with keyboard-focusable command steps. | `src/App.tsx`, `src/styles.css`, `tests/visual/accessibility.spec.ts` | | 9 | Scrollable `pre` blocks are not keyboard-focusable in every browser, so long lines may be unreachable by keyboard on some browsers. | Open. | `src/styles.css` | | 10 | The stored visual baselines in `tests/visual/__screenshots__` predate the current layout and do not match a fresh render. | Open. Baselines should be regenerated on the CI platform, not locally. | `tests/visual/` | +| 11 | Form fields had no programmatic help text, so users relying on screen readers could not learn what each field expects without trial and error. | Fixed (#100): every required and optional input, select, and checkbox now carries an `aria-describedby` id pointing to a visible `.field-help` element. | `src/App.tsx` | +| 12 | Switching workflow tabs left focus on the tab button, so screen reader users had to navigate forwards to find where the form changed. | Fixed (#101): after any tab switch, focus moves to the "From intent to command" `h2`, giving AT users an unambiguous entry point. | `src/App.tsx` | +| 13 | Validation failures were silent; a screen reader user copying a command with an empty required field received no feedback. | Fixed (#102): an `aria-live="assertive"` region announces the first unsatisfied constraint, and the error clears when the field is filled. | `src/App.tsx` | +| 14 | The "Copied" label reset after 1 600 ms, which is shorter than the announcement latency of many screen readers and Braille displays. | Fixed (#103): the reset timeout is now 3 000 ms, giving assistive technology enough time to read the change. | `src/App.tsx` | diff --git a/src/App.test.tsx b/src/App.test.tsx index 05fa906..ef17a5b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1003,3 +1003,197 @@ describe("Session-only command history UI", () => { }); }); }); + +// Issue #100 — inline help text associated with each form field +describe("Inline help text for form fields (#100)", () => { + it("associates help text with the path input via aria-describedby", () => { + render(); + const pathInput = screen.getByRole("textbox", { name: /project or contract path/i }); + const describedBy = pathInput.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + const helpEl = document.getElementById(describedBy!); + expect(helpEl).not.toBeNull(); + expect(helpEl!.textContent).toMatch(/path/i); + }); + + it("associates help text with the shell select via aria-describedby", () => { + render(); + const shellSelect = screen.getByRole("combobox", { name: /shell target/i }); + const describedBy = shellSelect.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + const helpEl = document.getElementById(describedBy!); + expect(helpEl).not.toBeNull(); + expect(helpEl!.textContent?.length).toBeGreaterThan(0); + }); + + it("associates help text with the format select when coverage workflow is active", () => { + render(); + const formatSelect = screen.getByRole("combobox", { name: /report format/i }); + const describedBy = formatSelect.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + const helpEl = document.getElementById(describedBy!); + expect(helpEl).not.toBeNull(); + expect(helpEl!.textContent?.length).toBeGreaterThan(0); + }); + + it("associates help text with fn and ramp inputs in the limits workflow", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "limits" })); + const fnInput = screen.getByRole("textbox", { name: /^function$/i }); + expect(fnInput.getAttribute("aria-describedby")).toBe("fn-help"); + expect(document.getElementById("fn-help")).not.toBeNull(); + const rampInput = screen.getByRole("textbox", { name: /ramp parameter/i }); + expect(rampInput.getAttribute("aria-describedby")).toBe("ramp-help"); + expect(document.getElementById("ramp-help")).not.toBeNull(); + }); +}); + +// Issue #101 — focus moves to the command section heading after a mode switch +describe("Focus moves to workflow heading on mode switch (#101)", () => { + it("focuses the command section heading when switching to the limits workflow", () => { + render(); + const limitsButton = screen.getByRole("button", { name: "limits" }); + fireEvent.click(limitsButton); + const heading = screen.getByRole("heading", { name: /from intent to command/i }); + expect(heading).toHaveFocus(); + }); + + it("focuses the command section heading when switching back to coverage", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "limits" })); + fireEvent.click(screen.getByRole("button", { name: "coverage" })); + const heading = screen.getByRole("heading", { name: /from intent to command/i }); + expect(heading).toHaveFocus(); + }); + + it("focuses the command section heading when switching to audit", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "audit" })); + const heading = screen.getByRole("heading", { name: /from intent to command/i }); + expect(heading).toHaveFocus(); + }); +}); + +// Issue #102 — validation errors announced through a live region +describe("Validation errors announced via live region (#102)", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("renders an assertive live region for validation errors", () => { + render(); + const alerts = document.querySelectorAll('[aria-live="assertive"]'); + expect(alerts.length).toBeGreaterThan(0); + }); + + it("announces an error when copy is attempted with an empty path", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + // Clear the path field + const pathInput = screen.getByRole("textbox", { name: /project or contract path/i }); + fireEvent.change(pathInput, { target: { value: "" } }); + + fireEvent.click(screen.getByRole("button", { name: /^Copy$/ })); + + await waitFor(() => { + const alertRegion = document.querySelector('[aria-live="assertive"]'); + expect(alertRegion?.textContent).toMatch(/path.*required/i); + }); + // Clipboard should not be called when validation fails + expect(writeText).not.toHaveBeenCalled(); + }); + + it("clears the error message when the path field is filled in", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + const pathInput = screen.getByRole("textbox", { name: /project or contract path/i }); + fireEvent.change(pathInput, { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: /^Copy$/ })); + + await waitFor(() => { + const alertRegion = document.querySelector('[aria-live="assertive"]'); + expect(alertRegion?.textContent).toMatch(/required/i); + }); + + fireEvent.change(pathInput, { target: { value: "." } }); + + await waitFor(() => { + const alertRegion = document.querySelector('[aria-live="assertive"]'); + expect(alertRegion?.textContent).toBe(""); + }); + }); +}); + +// Issue #103 — copied status persists long enough for assistive technology +describe("Copied status persistence for assistive technology (#103)", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("shows the Copied label immediately after copy and resets after 3 s", async () => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Copy$/ })); + }); + + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + + // Still present at 2 s — not yet reset + act(() => { vi.advanceTimersByTime(2000); }); + expect(screen.queryByRole("button", { name: "Copied" })).toBeInTheDocument(); + + // Reverted after 3 s + act(() => { vi.advanceTimersByTime(1100); }); + expect(screen.queryByRole("button", { name: /^Copy$/ })).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("announces the copy result in the polite live region", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^Copy$/ })); + }); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion?.textContent).toMatch(/copied/i); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index d179133..9478640 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -326,7 +326,11 @@ export default function App() { const [categoryFilter, setCategoryFilter] = useState(null); const [selectedModule, setSelectedModule] = useState(null); const [showBackToTop, setShowBackToTop] = useState(false); + // #102 — validation errors announced via live region + const [validationError, setValidationError] = useState(""); const moduleSearchRef = useRef(null); + // #101 — focus the command section heading after a mode switch + const commandHeadingRef = useRef(null); // Resetting from the empty state removes the button that had focus, so move // focus back to the search box instead of dropping it to (#123). @@ -592,7 +596,7 @@ export default function App() { window.setTimeout(() => { setCopied(false); setAnnouncement(""); - }, 1600); + }, 3000); }; // Issue #144: Copy command with explanatory comments @@ -605,7 +609,7 @@ export default function App() { window.setTimeout(() => { setCopiedWithComments(false); setAnnouncement(""); - }, 1600); + }, 3000); }; // Issue #145: Copy command as reusable shell script @@ -618,7 +622,7 @@ export default function App() { window.setTimeout(() => { setCopiedAsScript(false); setAnnouncement(""); - }, 1600); + }, 3000); }; const copyStep = async (index: number, stepCommand: string) => { @@ -628,7 +632,7 @@ export default function App() { window.setTimeout(() => { setCopiedStep(null); setAnnouncement(""); - }, 1600); + }, 3000); }; // #146 — download generated command as shell script @@ -682,6 +686,36 @@ export default function App() { } }; + // #101 — move focus to the command section heading when the workflow tab changes + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + commandHeadingRef.current?.focus(); + }, [command]); + + // #102 — validate required fields and announce errors + const validateAndCopy = async () => { + if (!path.trim()) { + setValidationError("Project or contract path is required."); + return; + } + if (command === "limits") { + if (!fn.trim()) { + setValidationError("Function name is required."); + return; + } + if (!ramp.trim()) { + setValidationError("Ramp parameter is required."); + return; + } + } + setValidationError(""); + await copy(); + }; + const fieldClasses = "field-input"; return ( @@ -692,6 +726,10 @@ export default function App() {

{announcement}

+ {/* #102 — validation errors announced via assertive live region */} +

+ {validationError} +