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:
3
.Jules/palette.md
Normal file
3
.Jules/palette.md
Normal 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.
|
||||
@@ -9,6 +9,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, id, className, ...props }, ref) => {
|
||||
const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
|
||||
@@ -19,9 +20,11 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={styles.input}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
{error && <span id={errorId} className={styles.error}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +40,7 @@ interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ label, error, id, className, ...props }, ref) => {
|
||||
const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
|
||||
@@ -47,9 +51,11 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${styles.input} ${styles.textarea}`}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
{error && <span id={errorId} className={styles.error}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
52
src/components/ui/__tests__/Input.test.tsx
Normal file
52
src/components/ui/__tests__/Input.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user