feat: Improve Input accessibility with ARIA attributes

Add `aria-invalid` and `aria-describedby` attributes to Input and Textarea components when an error is present.
This ensures screen readers announce the validation error when the input is focused.
Also added unit tests for these accessibility attributes.
This commit is contained in:
google-labs-jules[bot]
2026-01-24 10:08:34 +00:00
parent 77fd62447c
commit ffe37fad37
3 changed files with 63 additions and 2 deletions

3
.Jules/palette.md Normal file
View File

@@ -0,0 +1,3 @@
## 2024-05-24 - Accessible Input Validation
**Learning:** React 19 renders `aria-invalid={false}` as `aria-invalid="false"`, unlike older versions which might have omitted it. Explicitly handling this in tests is crucial. Also, ensuring DOM cleanup (`cleanup()`) in `afterEach` is vital when testing similar components with same labels across tests to avoid "finding the wrong element" false positives/negatives.
**Action:** Always include `afterEach(() => cleanup())` in `vitest` setup for DOM tests, and expect `aria-invalid="false"` (or explicitly handle `undefined` if omission is desired) when testing valid states in React 19.

View File

@@ -9,6 +9,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, id, className, ...props }, ref) => { ({ label, error, id, className, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-'); const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
const errorId = `${inputId}-error`;
return ( return (
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}> <div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
@@ -19,9 +20,11 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref} ref={ref}
id={inputId} id={inputId}
className={styles.input} className={styles.input}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props} {...props}
/> />
{error && <span className={styles.error}>{error}</span>} {error && <span id={errorId} className={styles.error}>{error}</span>}
</div> </div>
); );
} }
@@ -37,6 +40,7 @@ interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>( export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, id, className, ...props }, ref) => { ({ label, error, id, className, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-'); const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
const errorId = `${inputId}-error`;
return ( return (
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}> <div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
@@ -47,9 +51,11 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
ref={ref} ref={ref}
id={inputId} id={inputId}
className={`${styles.input} ${styles.textarea}`} className={`${styles.input} ${styles.textarea}`}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props} {...props}
/> />
{error && <span className={styles.error}>{error}</span>} {error && <span id={errorId} className={styles.error}>{error}</span>}
</div> </div>
); );
} }

View File

@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { render, screen, cleanup } from '@testing-library/react';
import { describe, it, expect, afterEach } from 'vitest';
import { Input, Textarea } from '../Input';
describe('Input', () => {
afterEach(() => {
cleanup();
});
it('renders with correct accessibility attributes when error is present', () => {
render(<Input label="Test Input" error="Invalid input" />);
const input = screen.getByLabelText('Test Input');
const error = screen.getByText('Invalid input');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.hasAttribute('aria-describedby')).toBe(true);
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toBe(error.id);
expect(error.id).toBeDefined();
expect(error.id).not.toBe('');
});
it('renders without accessibility error attributes when valid', () => {
render(<Input label="Test Input" />);
const input = screen.getByLabelText('Test Input');
// In React 19, aria-invalid={false} renders aria-invalid="false"
expect(input.getAttribute('aria-invalid')).toBe('false');
expect(input.hasAttribute('aria-describedby')).toBe(false);
});
});
describe('Textarea', () => {
it('renders with correct accessibility attributes when error is present', () => {
render(<Textarea label="Test Textarea" error="Invalid textarea" />);
const textarea = screen.getByLabelText('Test Textarea');
const error = screen.getByText('Invalid textarea');
expect(textarea.getAttribute('aria-invalid')).toBe('true');
expect(textarea.hasAttribute('aria-describedby')).toBe(true);
const describedBy = textarea.getAttribute('aria-describedby');
expect(describedBy).toBe(error.id);
expect(error.id).toBeDefined();
expect(error.id).not.toBe('');
});
});