From 2c07162c29b1a2670396a9520bb9ae96dc40dc53 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Wed, 27 May 2026 22:38:37 +0100 Subject: [PATCH 1/2] chore: improve screen reader support for input fields --- src/app/components/accessibility/README.md | 8 ++ src/app/components/auth/FormInput.tsx | 46 +++++++++-- .../auth/__tests__/FormInput.test.tsx | 65 +++++++++++++++ src/components/forms/FormInput.tsx | 54 ++++++++++-- .../forms/__tests__/FormInput.test.tsx | 82 +++++++++++++++++++ 5 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 src/app/components/auth/__tests__/FormInput.test.tsx create mode 100644 src/components/forms/__tests__/FormInput.test.tsx diff --git a/src/app/components/accessibility/README.md b/src/app/components/accessibility/README.md index 6612133a..6838498c 100644 --- a/src/app/components/accessibility/README.md +++ b/src/app/components/accessibility/README.md @@ -286,6 +286,14 @@ console.log(issues); 9. **Test with real assistive technologies** 10. **Include focus indicators** for all interactive elements +### Form Input Screen Reader Support + +- Use the shared `FormInput` components for new text fields, selects, and textareas where possible. +- Pass visible `label` text so the component can create an explicit `label`/`id` relationship. +- Pass `helperText` for persistent instructions; validation errors are linked to fields automatically. +- Use `required` for required fields so both browser validation and `aria-required` stay in sync. +- Keep decorative icons non-interactive; password visibility controls expose their current state to assistive technology. + ## Browser Support - Chrome/Edge 90+ diff --git a/src/app/components/auth/FormInput.tsx b/src/app/components/auth/FormInput.tsx index 30899178..a24e70c4 100644 --- a/src/app/components/auth/FormInput.tsx +++ b/src/app/components/auth/FormInput.tsx @@ -2,13 +2,14 @@ import { motion } from 'framer-motion'; import { Eye, EyeOff } from 'lucide-react'; -import { useState, InputHTMLAttributes, forwardRef } from 'react'; +import { useId, useState, InputHTMLAttributes, forwardRef } from 'react'; import { FieldError } from '../../../components/forms/FormError'; interface FormInputProps extends InputHTMLAttributes { label: string; error?: string; icon?: React.ReactNode; + helperText?: React.ReactNode; } export const FormInput = forwardRef( @@ -18,6 +19,10 @@ export const FormInput = forwardRef( error, icon, type, + id, + required, + helperText, + 'aria-describedby': ariaDescribedBy, onDrag, onDragStart, onDragEnd, @@ -32,10 +37,16 @@ export const FormInput = forwardRef( void onDragEnd; void onAnimationStart; void onAnimationEnd; + const generatedId = useId(); const [showPassword, setShowPassword] = useState(false); const [isFocused, setIsFocused] = useState(false); const inputType = type === 'password' && showPassword ? 'text' : type; + const inputId = id ?? `auth-input-${generatedId}`; + const helperTextId = helperText ? `${inputId}-helper` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = + [ariaDescribedBy, helperTextId, errorId].filter(Boolean).join(' ') || undefined; return ( ( transition={{ duration: 0.3 }} className="w-full" > - ); }, diff --git a/src/app/components/auth/__tests__/FormInput.test.tsx b/src/app/components/auth/__tests__/FormInput.test.tsx new file mode 100644 index 00000000..df97d530 --- /dev/null +++ b/src/app/components/auth/__tests__/FormInput.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FormInput } from '../FormInput'; + +describe('Auth FormInput', () => { + it('associates label, helper text, and required state with the input', () => { + render( + , + ); + + const input = screen.getByRole('textbox', { + name: 'Email', + description: 'Use the email connected to your account.', + }); + + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-required', 'true'); + expect(input).toHaveAttribute('aria-invalid', 'false'); + }); + + it('links validation errors to the input for screen readers', () => { + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Email' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email address.'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription( + 'Use the email connected to your account. Enter a valid email address.', + ); + }); + + it('exposes password visibility controls with name, state, and controlled input', () => { + render(); + + const input = screen.getByLabelText('Password'); + const toggle = screen.getByRole('button', { name: 'Show Password' }); + + expect(input).toHaveAttribute('type', 'password'); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(toggle).toHaveAttribute('aria-controls', input.id); + + fireEvent.click(toggle); + + expect(input).toHaveAttribute('type', 'text'); + expect(screen.getByRole('button', { name: 'Hide Password' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); +}); diff --git a/src/components/forms/FormInput.tsx b/src/components/forms/FormInput.tsx index d340c7d8..0a371dc1 100644 --- a/src/components/forms/FormInput.tsx +++ b/src/components/forms/FormInput.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { useId } from 'react'; import { useFormContext } from 'react-hook-form'; import { LucideIcon } from 'lucide-react'; @@ -12,6 +12,7 @@ interface FormInputProps as?: 'input' | 'textarea' | 'select'; children?: React.ReactNode; // For select options rows?: number; // Explicitly add rows for textarea + helperText?: React.ReactNode; } /** @@ -25,8 +26,13 @@ export const FormInput: React.FC = ({ children, className = '', rows, + id, + required, + helperText, + 'aria-describedby': ariaDescribedBy, ...props }) => { + const generatedId = useId(); const { register, formState: { errors }, @@ -34,6 +40,11 @@ export const FormInput: React.FC = ({ const error = errors[name]; const isError = !!error; + const inputId = id ?? `${name}-${generatedId}`; + const helperTextId = helperText ? `${inputId}-helper` : undefined; + const errorId = isError ? `${inputId}-error` : undefined; + const describedBy = + [ariaDescribedBy, helperTextId, errorId].filter(Boolean).join(' ') || undefined; const baseStyles = ` w-full pl-${Icon ? '10' : '4'} pr-4 py-2.5 @@ -46,12 +57,18 @@ export const FormInput: React.FC = ({ return (
-