Review Date: 2025-10-25 Components Reviewed: Form, Field, Input, Select, Textarea WCAG Version: 2.1 Level AA Files Analyzed:
form.tsxfields.tsxinputs.tsxselect.tsxtextarea.tsxform.types.ts
The Form component and its sub-components demonstrate strong accessibility foundations with proper semantic HTML, ARIA attributes, and keyboard support. However, there are several critical issues that need attention to achieve full WCAG 2.1 AA compliance.
Issues Found: 4 errors, 3 warnings, 2 recommendations
Severity: Error
Files Affected: inputs.tsx:142, select.tsx:139, textarea.tsx:121
WCAG Criteria: 4.1.2 Name, Role, Value (Level A)
All three input components use aria-disabled instead of the native disabled attribute. This is a significant accessibility violation because aria-disabled alone doesn't prevent user interaction—keyboard users can still focus and type in these inputs.
inputs.tsx (Line 142):
// ❌ Bad - aria-disabled doesn't prevent interaction
aria-disabled={isInputDisabled}select.tsx (Line 139):
// ❌ Bad
aria-disabled={disabled}textarea.tsx (Line 121):
// ❌ Bad
aria-disabled={disabled}Use the native disabled attribute, which provides both functionality AND proper semantics:
// ✅ Good - Input component
<FP
as="input"
// ... other props
disabled={isInputDisabled} // Native disabled attribute
// Remove aria-disabled - it's redundant with native disabled
aria-readonly={readOnly}
aria-required={required}
aria-invalid={isInvalid}
aria-describedby={ariaDescribedBy}
{...props}
/>// ✅ Good - Select component
<UI
as="select"
// ... other props
disabled={disabled} // Native disabled attribute
// Remove aria-disabled
required={required}
aria-required={required}
{...props}
/>// ✅ Good - Textarea component
<UI
as="textarea"
// ... other props
disabled={disabled} // Native disabled attribute
// Remove aria-disabled
aria-required={required}
readOnly={readOnly}
{...props}
/>Screen readers announce the disabled state from the native attribute, AND the browser prevents interaction. Using only aria-disabled creates a "fake" disabled state that doesn't actually work.
Severity: Error
Files Affected: select.tsx, textarea.tsx
WCAG Criteria: 3.3.1 Error Identification (Level A), 4.1.2 Name, Role, Value (Level A)
The TypeScript interfaces define validationState, errorMessage, and hintText props, but the Select and Textarea components don't implement aria-invalid or aria-describedby to expose these to assistive technologies. The Input component implements this correctly, but Select and Textarea don't.
Current Select implementation (incomplete):
// select.tsx - missing validation ARIA
export interface SelectProps {
validationState?: ValidationState // ✅ Defined in types
errorMessage?: string // ✅ Defined in types
hintText?: string // ✅ Defined in types
}
// ❌ But not implemented in component!
<UI
as="select"
// Missing: aria-invalid, aria-describedby
{...props}
/>// select.tsx
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
(
{
id,
validationState = 'none',
errorMessage,
hintText,
// ... other props
},
ref
) => {
// Determine aria-invalid based on validation state
const isInvalid = validationState === 'invalid';
// Generate describedby IDs for error and hint text
const describedByIds: string[] = [];
if (errorMessage && id) {
describedByIds.push(`${id}-error`);
}
if (hintText && id) {
describedByIds.push(`${id}-hint`);
}
const ariaDescribedBy =
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
return (
<UI
as="select"
id={id}
aria-invalid={isInvalid}
aria-describedby={ariaDescribedBy}
aria-required={required}
disabled={disabled}
// ... other props
/>
);
}
);Apply the same pattern as above to textarea.tsx.
When validation errors occur, screen reader users need to be notified. Without aria-invalid and aria-describedby, they have no way to discover errors.
Severity: Error
File: select.tsx:27
WCAG Criteria: 4.1.2 Name, Role, Value (Level A)
// ❌ Bad - role="option" is redundant on native <option>
export const Option = ({ selectValue, selectLabel }: SelectOptionsProps) => {
return (
<option role="option" value={selectValue}>
{selectLabel || selectValue}
</option>
)
}// ✅ Good - remove redundant role
export const Option = ({ selectValue, selectLabel }: SelectOptionsProps) => {
return (
<option value={selectValue}>
{selectLabel || selectValue}
</option>
)
}Native HTML elements have implicit ARIA roles. Adding explicit roles can confuse assistive technologies and validators. The first rule of ARIA is "don't use ARIA if native HTML works."
Severity: Error (when multiple forms on page)
File: form.tsx:164
WCAG Criteria: 2.4.6 Headings and Labels (Level AA), 4.1.2 Name, Role, Value (Level A)
// form.tsx (Line 164)
<UI
as="form"
role="form" // ⚠️ Also redundant - native form has implicit role
aria-busy={isBusy}
// ❌ Missing: aria-label or aria-labelledby
{...props}
>The JSDoc examples show aria-label usage, but the component doesn't require or encourage providing an accessible name. When multiple forms exist on a page, screen reader users can't distinguish between them.
export interface FormProps extends Omit<React.ComponentProps<'form'>, 'className'> {
/**
* Accessible name for the form (required for distinguishing multiple forms)
* @example "Contact form", "Login form", "Search form"
*/
'aria-label'?: string
'aria-labelledby'?: string
// ... other props
}const Form = React.forwardRef<HTMLFormElement, FormProps>(
({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }, ref) => {
// Warn in development if no accessible name provided
if (process.env.NODE_ENV !== 'production') {
if (!ariaLabel && !ariaLabelledBy) {
console.warn(
'Form component should have an accessible name via aria-label or aria-labelledby'
);
}
}
return (
<UI
as="form"
// Remove redundant role="form"
aria-busy={isBusy}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...props}
/>
);
}
);Screen reader users who navigate by landmarks need to know which form is which. "Form" alone isn't descriptive enough.
Severity: Warning
File: form.tsx:164
WCAG Criteria: 4.1.2 Name, Role, Value (Level A)
// ⚠️ Redundant - native <form> already has role="form"
<UI
as="form"
role="form" // Remove this
aria-busy={isBusy}
{...props}
/>Remove role="form" - the native <form> element already has an implicit role of "form".
Severity: Warning
Files: inputs.tsx:78, inputs.tsx:134
WCAG Criteria: 3.2.1 On Focus (Level A)
The Input component supports autoFocus={true}, which can cause unexpected context changes and disorient users, especially screen reader users.
// inputs.tsx
autoFocus={autoFocus} // ⚠️ Can violate WCAG 3.2.1Consider adding a JSDoc warning:
/**
* Auto-focus on mount
* ⚠️ WARNING: Use sparingly. Can violate WCAG 3.2.1 if it causes unexpected context changes.
* Only use when user clearly expects focus (e.g., search page, modal dialogs)
* @default false
* @see https://www.w3.org/WAI/WCAG21/Understanding/on-focus.html
*/
autoFocus?: booleanSeverity: Warning
File: fields.tsx:38
WCAG Criteria: 3.3.2 Labels or Instructions (Level A)
The Field component accepts a labelFor prop but doesn't validate that:
labelForis provided- A matching child input exists
This can lead to unlabeled inputs if developers forget to connect them properly.
Current implementation:
// fields.tsx
<label htmlFor={labelFor}>{label}</label> // labelFor might be undefined
{children}Add TypeScript validation or runtime warning:
export interface FieldProps {
/**
* ID of the associated form control (REQUIRED for accessibility)
* Must match the id of the child input/select/textarea
*/
labelFor: string // Make required, not optional
// ... other props
}WCAG Criteria: 3.3.1 Error Identification (Level A)
Helps users with screen readers discover all validation errors at once.
// Recommended pattern for form-level error summary
{errors.length > 0 && (
<div role="alert" aria-live="assertive" id="form-errors">
<h2>Please correct the following errors:</h2>
<ul>
{errors.map((error, index) => (
<li key={index}>
<a href={`#${error.fieldId}`}>{error.message}</a>
</li>
))}
</ul>
</div>
)}WCAG Criteria: 1.3.5 Identify Input Purpose (Level AA)
The Input component supports autoComplete, but WCAG 2.1 AA requires autocomplete attributes for personal data inputs.
Add JSDoc guidance:
/**
* Autocomplete attribute for browser autofill
* REQUIRED by WCAG 2.1 AA (1.3.5) for inputs collecting user information:
* - "email" for email addresses
* - "tel" for phone numbers
* - "given-name", "family-name" for names
* - "street-address", "address-level1", etc. for addresses
* @see https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html
*/
autoComplete?: stringThe form components demonstrate several excellent accessibility practices:
- Uses native
<form>,<input>,<select>,<textarea>, and<label>elements - Leverages built-in browser accessibility features
- All components are keyboard accessible
onEnterhandlers enable keyboard-only form submission- Textarea properly handles Shift+Enter for new lines
- All components properly forward refs using
React.forwardRef - Enables parent components to manage focus programmatically
- Well-defined prop interfaces with JSDoc comments
- Extends native HTML element props for maximum flexibility
- Provides type safety for accessibility attributes
- Input component correctly implements
aria-invalid - Properly associates error/hint text with
aria-describedby - Generates unique IDs for error messages
- Excellent JSDoc comments with examples
- References to WCAG success criteria in code comments
- Multiple usage examples demonstrating accessibility patterns
npm install --save-dev eslint-plugin-jsx-a11yAdd to .eslintrc:
{
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/label-has-associated-control": "error"
}
}npm install --save-dev jest-axeExample test:
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
import Form from './form';
expect.extend(toHaveNoViolations);
describe('Form Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<Form aria-label="Contact form">
<Form.Field label="Email" labelFor="email">
<Form.Input id="email" type="email" autoComplete="email" />
</Form.Field>
<Form.Field label="Message" labelFor="message">
<Form.Textarea id="message" name="message" />
</Form.Field>
</Form>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should announce validation errors', async () => {
const { container } = render(
<Form aria-label="Contact form">
<Form.Field label="Email" labelFor="email">
<Form.Input
id="email"
type="email"
validationState="invalid"
errorMessage="Please enter a valid email"
/>
<div id="email-error" role="alert">
Please enter a valid email
</div>
</Form.Field>
</Form>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});- Tab through all form fields without mouse
- Verify tab order matches visual order
- Test Enter key submission
- Test Shift+Enter in textarea (creates new line)
- Verify disabled inputs cannot receive focus
- Check focus indicators are visible (3:1 contrast)
- Test with NVDA (Windows) or VoiceOver (Mac)
- Verify all labels are announced
- Check error messages are announced when inputs become invalid
- Verify disabled/required states are announced
- Test form landmark navigation
- Verify form has an accessible name
- Verify errors are announced immediately when they occur
- Check
aria-invalidupdates dynamically - Test that error messages are associated with inputs
- Verify error color has sufficient contrast
- Check errors are indicated by more than color alone
- Test Enter key submission from text inputs
- Verify aria-busy announces during submission
- Test form submission with validation errors
- Check success/error messages are announced
These fixes provide the most significant accessibility improvements with minimal effort:
-
Replace
aria-disabledwith nativedisabledin Input, Select, and Textarea- Time: 5 minutes
- Impact: Critical - fixes keyboard trap vulnerability
-
Remove redundant
role="form"androle="option"- Time: 2 minutes
- Impact: High - eliminates ARIA violations
-
Add validation ARIA to Select and Textarea (copy pattern from Input)
- Time: 15 minutes
- Impact: Critical - enables error announcement for screen readers
-
Add
aria-labelto FormProps TypeScript interface- Time: 5 minutes
- Impact: Medium - improves form landmark navigation
Total implementation time: ~30 minutes for major compliance improvements
- Replace
aria-disabledwith nativedisabledattribute - Add validation ARIA to Select and Textarea components
- Remove redundant ARIA roles
- Add form accessible name requirement
- Make
labelForrequired in Field component - Add autoFocus JSDoc warning
- Add autocomplete JSDoc guidance
- Add error summary pattern example to docs
- Add runtime warnings for missing accessibility attributes
- WCAG 2.1 Quick Reference
- ARIA Authoring Practices Guide - Forms
- WebAIM: Creating Accessible Forms
- MDN: ARIA Form Role
Overall Assessment: The form components are well-architected with strong accessibility foundations. The critical issues identified are straightforward to fix and mostly involve replacing ARIA attributes with native HTML attributes. Once these issues are addressed, the components will be fully WCAG 2.1 AA compliant.
Reviewer Confidence: High - All issues have clear fixes with code examples provided.
Next Steps:
- Address the 4 critical errors
- Run automated tests with jest-axe
- Perform manual keyboard and screen reader testing
- Update Storybook examples to demonstrate accessible usage patterns