From 037215d35be9901e3cfac35c11f5724fa7c02a8b Mon Sep 17 00:00:00 2001 From: UFObject247 Date: Wed, 21 Oct 2026 21:19:28 +0100 Subject: [PATCH] feat: inline help text, focus-on-switch, validation live region, longer copy status (#100, #101, #102, #103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #100: Associate inline help text with every form field. All required and optional inputs, selects, and checkboxes now carry aria-describedby pointing to a visible .field-help element, making each field's purpose and expected value discoverable by screen readers without trial and error. - #101: Move focus to the workflow heading after a mode switch. Clicking the coverage / limits / audit tab buttons now moves focus to the "From intent to command" h2, giving keyboard and screen-reader users an unambiguous entry point into the updated form. - #102: Announce validation errors through a live region. The Copy button now validates required fields before writing to the clipboard. The first unsatisfied constraint is announced via an aria-live="assertive" region; the error clears when the field is filled. - #103: Make the copied status persist long enough for assistive technology. Reset timeout extended from 1 600 ms to 3 000 ms so screen readers and Braille displays have enough time to announce the state change. Adds focused tests for each behaviour and updates docs/accessibility.md with new automated-check entries and findings 11–14. --- docs/accessibility.md | 8 ++ src/App.test.tsx | 194 ++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 86 ++++++++++++++++--- 3 files changed, 275 insertions(+), 13 deletions(-) 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} +