We use cookies to enhance your experience on the site
CodeWorlds

Usability and accessibility in forms

Designing forms that are both user-friendly (usability) and accessible to people with various disabilities (accessibility) is a key aspect of building modern web applications. In this module we'll discuss how to create forms that are easy to use for all users, regardless of their abilities and the devices they use.

Why are usability and accessibility important?

Business benefits

  1. Increased conversion - forms that are easy to use have higher conversion rates
  2. Wider reach - accessible forms reach more users
  3. Legal compliance - in many countries website accessibility is required by law (e.g., ADA in the US, WCAG in the EU)
  4. Improved SEO - accessible pages often rank better in search results

Benefits for users

  1. Better performance - users can fill out forms faster and more easily
  2. Less frustration - well-designed forms reduce errors and frustration
  3. Greater independence - people with disabilities can use forms on their own
  4. Consistent experience - well-designed forms work consistently across various devices and browsers

Basic principles of form usability

1. Clear structure

  • Group related fields - use
    <fieldset>
    and
    <legend>
    to group fields
  • Logical order - organize fields in an intuitive order
  • Appropriate labels - each field should have a clear label

2. Minimize cognitive load

  • Ask only for necessary information - every additional field reduces conversion
  • Break long forms into steps - multi-step forms are easier to process
  • Show progress - in multi-step forms, inform the user of progress

3. Clear communication

  • Clearly marked required fields - mark required fields consistently and visibly
  • Precise labels - labels should clearly communicate what information is expected
  • Hints and examples - for complex fields, add examples or hints

4. Effective data submission

  • Real-time validation - inform about errors while filling out the form
  • Preserve data on errors - don't reset the entire form after an error occurs
  • Confirm actions - inform the user of the success or failure of form submission

Form accessibility principles

1. HTML structural principles

  • Correctly associate labels with fields - use
    <label for="id">
    linked to the field's
    id
  • Group fields - use
    <fieldset>
    and
    <legend>
    for groups of fields, especially checkboxes and radio buttons
  • Logical tab order - ensure the tab order is logical and intuitive

2. ARIA (Accessible Rich Internet Applications)

  • aria-label and aria-labelledby attributes - for describing elements without visible labels
  • aria-describedby - for linking additional descriptions with fields
  • aria-required - for marking required fields
  • aria-invalid - for marking fields with errors
  • role="alert" - for error messages that should be read immediately

3. Contrast and readability

  • Sufficient contrast - the contrast ratio between text and background should be at least 4.5:1
  • Appropriate text size - text should be readable without zooming in
  • Responsive design - forms should be usable on various screen sizes

4. Keyboard support

  • Full keyboard support - all functions should be accessible via keyboard
  • Visible focus - keyboard focus should be clearly visible
  • Keyboard shortcuts - for advanced forms, keyboard shortcuts can be added

Implementing accessible forms in React

Basic accessible form

1import { useState, FormEvent, ChangeEvent } from 'react';
2
3export default function AccessibleForm() {
4  const [formData, setFormData] = useState({
5    name: '',
6    email: '',
7    phone: '',
8    message: '',
9    contactPreference: 'email',
10    newsletter: false
11  });
12  
13  const [errors, setErrors] = useState<Record<string, string>>({});
14  const [isSubmitting, setIsSubmitting] = useState(false);
15  const [isSuccess, setIsSuccess] = useState(false);
16  
17  const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
18    const { name, value, type } = e.target;
19    const checked = type === 'checkbox' ? (e.target as HTMLInputElement).checked : undefined;
20    
21    setFormData(prev => ({
22      ...prev,
23      [name]: checked !== undefined ? checked : value
24    }));
25    
26    // Remove error after editing the field
27    if (errors[name]) {
28      setErrors(prev => {
29        const newErrors = { ...prev };
30        delete newErrors[name];
31        return newErrors;
32      });
33    }
34  };
35
36  const validateForm = () => {
37    const newErrors: Record<string, string> = {};
38
39    if (!formData.name.trim()) {
40      newErrors.name = 'Full name is required';
41    }
42
43    if (!formData.email.trim()) {
44      newErrors.email = 'Email is required';
45    } else if (!/^S+@S+.S+$/.test(formData.email)) {
46      newErrors.email = 'Please provide a valid email address';
47    }
48
49    if (formData.contactPreference === 'phone' && !formData.phone.trim()) {
50      newErrors.phone = 'Phone number is required when choosing phone contact';
51    }
52    
53    if (!formData.message.trim()) {
54      newErrors.message = 'Message is required';
55    }
56    
57    setErrors(newErrors);
58    return Object.keys(newErrors).length === 0;
59  };
60  
61  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
62    e.preventDefault();
63    
64    if (!validateForm()) {
65      // Move focus to the first field with an error
66      const firstErrorField = document.getElementById(Object.keys(errors)[0]);
67      if (firstErrorField) {
68        firstErrorField.focus();
69      }
70      return;
71    }
72
73    setIsSubmitting(true);
74
75    try {
76      // Simulate form submission
77      await new Promise(resolve => setTimeout(resolve, 1500));
78      console.log('Form sent:', formData);
79
80      setIsSuccess(true);
81      // Move focus to the success message
82      const successMessage = document.getElementById('success-message');
83      if (successMessage) {
84        successMessage.focus();
85      }
86      
87      // Reset form
88      setFormData({
89        name: '',
90        email: '',
91        phone: '',
92        message: '',
93        contactPreference: 'email',
94        newsletter: false
95      });
96    } catch (error) {
97      setErrors({
98        form: 'An error occurred while sending the form. Please try again later.'
99      });
100      // Move focus to the error message
101      const errorMessage = document.getElementById('form-error');
102      if (errorMessage) {
103        errorMessage.focus();
104      }
105    } finally {
106      setIsSubmitting(false);
107    }
108  };
109  
110  return (
111    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
112      <h1 className="text-2xl font-bold mb-6">Contact us</h1>
113      
114      {isSuccess && (
115        <div 
116          id="success-message" 
117          className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded" 
118          role="alert"
119          tabIndex={-1} // Allows the element to receive focus
120        >
121          Thank you for submitting the form! We'll respond as soon as possible.
122        </div>
123      )}
124      
125      {errors.form && (
126        <div 
127          id="form-error" 
128          className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" 
129          role="alert"
130          tabIndex={-1} // Allows the element to receive focus
131        >
132          {errors.form}
133        </div>
134      )}
135      
136      <form onSubmit={handleSubmit} noValidate className="space-y-4">
137        <div>
138          <label htmlFor="name" className="block text-sm font-medium text-gray-700">
139            Full name <span className="text-red-500" aria-hidden="true">*</span>
140          </label>
141          <input
142            type="text"
143            id="name"
144            name="name"
145            value={formData.name}
146            onChange={handleChange}
147            aria-required="true"
148            aria-invalid={Boolean(errors.name)}
149            aria-describedby={errors.name ? 'name-error' : undefined}
150            className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
151          />
152          {errors.name && (
153            <p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
154              {errors.name}
155            </p>
156          )}
157        </div>
158        
159        <div>
160          <label htmlFor="email" className="block text-sm font-medium text-gray-700">
161            Email <span className="text-red-500" aria-hidden="true">*</span>
162          </label>
163          <input
164            type="email"
165            id="email"
166            name="email"
167            value={formData.email}
168            onChange={handleChange}
169            aria-required="true"
170            aria-invalid={Boolean(errors.email)}
171            aria-describedby={errors.email ? 'email-error' : undefined}
172            className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
173          />
174          {errors.email && (
175            <p id="email-error" className="mt-1 text-sm text-red-600" role="alert">
176              {errors.email}
177            </p>
178          )}
179        </div>
180        
181        <fieldset>
182          <legend className="text-sm font-medium text-gray-700">Preferred contact method</legend>
183          <div className="mt-2 space-y-2">
184            <div className="flex items-center">
185              <input
186                type="radio"
187                id="email-contact"
188                name="contactPreference"
189                value="email"
190                checked={formData.contactPreference === 'email'}
191                onChange={handleChange}
192                className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
193              />
194              <label htmlFor="email-contact" className="ml-2 block text-sm text-gray-700">
195                Email
196              </label>
197            </div>
198            <div className="flex items-center">
199              <input
200                type="radio"
201                id="phone-contact"
202                name="contactPreference"
203                value="phone"
204                checked={formData.contactPreference === 'phone'}
205                onChange={handleChange}
206                className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
207              />
208              <label htmlFor="phone-contact" className="ml-2 block text-sm text-gray-700">
209                Phone
210              </label>
211            </div>
212          </div>
213        </fieldset>
214        
215        <div className={formData.contactPreference === 'phone' ? 'block' : 'hidden'}>
216          <label htmlFor="phone" className="block text-sm font-medium text-gray-700">
217            Phone number {formData.contactPreference === 'phone' && <span className="text-red-500" aria-hidden="true">*</span>}
218          </label>
219          <input
220            type="tel"
221            id="phone"
222            name="phone"
223            value={formData.phone}
224            onChange={handleChange}
225            aria-required={formData.contactPreference === 'phone'}
226            aria-invalid={Boolean(errors.phone)}
227            aria-describedby={errors.phone ? 'phone-error' : 'phone-hint'}
228            className={`mt-1 block w-full rounded-md shadow-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
229          />
230          <p id="phone-hint" className="mt-1 text-xs text-gray-500">
231            Format: 123-456-789
232          </p>
233          {errors.phone && (
234            <p id="phone-error" className="mt-1 text-sm text-red-600" role="alert">
235              {errors.phone}
236            </p>
237          )}
238        </div>
239        
240        <div>
241          <label htmlFor="message" className="block text-sm font-medium text-gray-700">
242            Message <span className="text-red-500" aria-hidden="true">*</span>
243          </label>
244          <textarea
245            id="message"
246            name="message"
247            rows={4}
248            value={formData.message}
249            onChange={handleChange}
250            aria-required="true"
251            aria-invalid={Boolean(errors.message)}
252            aria-describedby={errors.message ? 'message-error' : undefined}
253            className={`mt-1 block w-full rounded-md shadow-sm ${errors.message ? 'border-red-500' : 'border-gray-300'}`}
254          />
255          {errors.message && (
256            <p id="message-error" className="mt-1 text-sm text-red-600" role="alert">
257              {errors.message}
258            </p>
259          )}
260        </div>
261        
262        <div className="flex items-center">
263          <input
264            type="checkbox"
265            id="newsletter"
266            name="newsletter"
267            checked={formData.newsletter}
268            onChange={handleChange}
269            className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
270          />
271          <label htmlFor="newsletter" className="ml-2 block text-sm text-gray-700">
272            Sign me up for the newsletter
273          </label>
274        </div>
275        
276        <div className="pt-2">
277          <p className="text-xs text-gray-500">
278            Fields marked with <span className="text-red-500" aria-hidden="true">*</span> are required
279          </p>
280        </div>
281        
282        <button
283          type="submit"
284          disabled={isSubmitting}
285          aria-disabled={isSubmitting}
286          className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
287        >
288          {isSubmitting ? 'Sending...' : 'Send message'}
289        </button>
290      </form>
291    </div>
292  );
293}

Key accessibility elements in the above example:

  1. Correctly associated labels with fields - each label has an
    htmlFor
    attribute linked to the field's
    id
  2. Grouping with fieldset and legend - for radio buttons
  3. ARIA attributes -
    aria-required
    ,
    aria-invalid
    ,
    aria-describedby
  4. Dynamic visibility - the phone field appears only when needed
  5. Error messages with role="alert" - for screen readers
  6. Focus management - moving focus to the first error or the success message

Advanced practices for accessible forms

1. Focus management

Proper focus management is crucial for keyboard users and screen readers:

1import { useRef, useEffect } from 'react';
2
3function FocusManagementExample() {
4  const firstInputRef = useRef<HTMLInputElement>(null);
5  const errorMessageRef = useRef<HTMLDivElement>(null);
6  const successMessageRef = useRef<HTMLDivElement>(null);
7  
8  // Autofocus on the first form field on mount
9  useEffect(() => {
10    if (firstInputRef.current) {
11      firstInputRef.current.focus();
12    }
13  }, []);
14
15  // Example function to move focus to the first error
16  const moveToFirstError = () => {
17    if (errorMessageRef.current) {
18      errorMessageRef.current.focus();
19      errorMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
20    }
21  };
22
23  // Example function to move focus to the success message
24  const moveToSuccessMessage = () => {
25    if (successMessageRef.current) {
26      successMessageRef.current.focus();
27      successMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
28    }
29  };
30  
31  return (
32    <form>
33      {/* Error message with ability to receive focus */}
34      <div
35        ref={errorMessageRef}
36        tabIndex={-1}
37        role="alert"
38        className="bg-red-100 p-3 rounded"
39        style={{ display: 'none' }} // Normally controlled by state
40      >
41        Errors occurred in the form
42      </div>
43
44      {/* First form field */}
45      <div>
46        <label htmlFor="first-field">First name</label>
47        <input 
48          ref={firstInputRef} 
49          id="first-field" 
50          type="text" 
51        />
52      </div>
53      
54      {/* Success message */}
55      <div
56        ref={successMessageRef}
57        tabIndex={-1}
58        role="status"
59        className="bg-green-100 p-3 rounded"
60        style={{ display: 'none' }} // Normally controlled by state
61      >
62        The form was sent successfully!
63      </div>
64
65      {/* Buttons to demonstrate focus movement */}
66      <div className="mt-4 space-x-2">
67        <button type="button" onClick={moveToFirstError}>
68          Show error
69        </button>
70        <button type="button" onClick={moveToSuccessMessage}>
71          Show success
72        </button>
73      </div>
74    </form>
75  );
76}

2. Autocomplete fields

Well-implemented autocomplete fields can significantly improve form usability:

1import { useState, useRef, KeyboardEvent } from 'react';
2
3interface Props {
4  suggestions: string[];
5  label: string;
6  id: string;
7  value: string;
8  onChange: (value: string) => void;
9}
10
11function AccessibleAutocomplete({ suggestions, label, id, value, onChange }: Props) {
12  const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
13  const [showSuggestions, setShowSuggestions] = useState(false);
14  const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
15  const suggestionsListRef = useRef<HTMLUListElement>(null);
16  
17  // Filter suggestions based on current input
18  const filterSuggestions = (input: string) => {
19    const filtered = suggestions.filter(suggestion =>
20      suggestion.toLowerCase().includes(input.toLowerCase())
21    );
22    setFilteredSuggestions(filtered);
23    setShowSuggestions(true);
24    setActiveSuggestionIndex(0);
25  };
26  
27  // Handle value change
28  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29    const userInput = e.target.value;
30    onChange(userInput);
31    filterSuggestions(userInput);
32  };
33  
34  // Handle suggestion click
35  const handleSuggestionClick = (suggestion: string) => {
36    onChange(suggestion);
37    setShowSuggestions(false);
38    setActiveSuggestionIndex(0);
39  };
40  
41  // Handle keyboard navigation
42  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
43    // Arrow down - move to next suggestion
44    if (e.key === 'ArrowDown') {
45      e.preventDefault();
46      setActiveSuggestionIndex(prev => 
47        prev < filteredSuggestions.length - 1 ? prev + 1 : prev
48      );
49    }
50    // Arrow up - move to previous suggestion
51    else if (e.key === 'ArrowUp') {
52      e.preventDefault();
53      setActiveSuggestionIndex(prev => (prev > 0 ? prev - 1 : 0));
54    }
55    // Enter - select active suggestion
56    else if (e.key === 'Enter' && showSuggestions) {
57      e.preventDefault();
58      if (filteredSuggestions[activeSuggestionIndex]) {
59        handleSuggestionClick(filteredSuggestions[activeSuggestionIndex]);
60      }
61    }
62    // Escape - close suggestions list
63    else if (e.key === 'Escape') {
64      setShowSuggestions(false);
65    }
66  };
67
68  // ID for the suggestions list (to link with ARIA)
69  const listId = `${id}-suggestions`;
70  
71  return (
72    <div className="relative">
73      <label htmlFor={id} className="block text-sm font-medium text-gray-700">
74        {label}
75      </label>
76      <input
77        type="text"
78        id={id}
79        value={value}
80        onChange={handleInputChange}
81        onKeyDown={handleKeyDown}
82        onFocus={() => filterSuggestions(value)}
83        onBlur={() => {
84          // Delay to allow suggestion click
85          setTimeout(() => setShowSuggestions(false), 200);
86        }}
87        aria-autocomplete="list"
88        aria-controls={listId}
89        aria-activedescendant={
90          showSuggestions && filteredSuggestions.length > 0
91            ? `${id}-suggestion-${activeSuggestionIndex}`
92            : undefined
93        }
94        className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
95      />
96      
97      {/* Suggestions list */}
98      {showSuggestions && filteredSuggestions.length > 0 && (
99        <ul
100          id={listId}
101          ref={suggestionsListRef}
102          role="listbox"
103          className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5"
104        >
105          {filteredSuggestions.map((suggestion, index) => (
106            <li
107              key={index}
108              id={`${id}-suggestion-${index}`}
109              role="option"
110              aria-selected={index === activeSuggestionIndex}
111              onClick={() => handleSuggestionClick(suggestion)}
112              className={`cursor-default select-none relative py-2 px-4 ${index === activeSuggestionIndex ? 'bg-blue-100' : ''}`}
113            >
114              {suggestion}
115            </li>
116          ))}
117        </ul>
118      )}
119    </div>
120  );
121}

Key accessibility elements in autocomplete:

  1. ARIA roles -
    aria-autocomplete="list"
    ,
    role="listbox"
    ,
    role="option"
  2. aria-activedescendant - to mark the active suggestion
  3. aria-controls - links the field with the suggestions list
  4. Keyboard support - arrows, Enter, Escape

3. Date pickers and masked inputs

Date fields and other complex inputs should be accessible and easy to use:

1import { useState, useRef, ChangeEvent } from 'react';
2
3function AccessibleDateInput() {
4  const [date, setDate] = useState('');
5  const inputRef = useRef<HTMLInputElement>(null);
6  
7  // Format date in DD-MM-YYYY format
8  const formatDate = (input: string) => {
9    // Remove all non-digit characters
10    const digitsOnly = input.replace(/D/g, '');
11
12    // Add dashes in the appropriate places
13    let formatted = '';
14    for (let i = 0; i < digitsOnly.length && i < 8; i++) {
15      if (i === 2 || i === 4) formatted += '-';
16      formatted += digitsOnly[i];
17    }
18    
19    return formatted;
20  };
21  
22  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
23    const input = e.target.value;
24    const formatted = formatDate(input);
25    setDate(formatted);
26    
27    // Zachowanie pozycji kursora po formatowaniu
28    if (inputRef.current) {
29      const cursorPosition = e.target.selectionStart || 0;
30      const diffLength = formatted.length - input.length;
31      setTimeout(() => {
32        inputRef.current?.setSelectionRange(cursorPosition + diffLength, cursorPosition + diffLength);
33      }, 0);
34    }
35  };
36  
37  // Date validation
38  const validateDate = (date: string) => {
39    // Check DD-MM-YYYY format
40    if (!/^d{2}-d{2}-d{4}$/.test(date)) return false;
41
42    const [day, month, year] = date.split('-').map(Number);
43
44    // Check month range
45    if (month < 1 || month > 12) return false;
46
47    // Check number of days in the month
48    const daysInMonth = new Date(year, month, 0).getDate();
49    if (day < 1 || day > daysInMonth) return false;
50
51    return true;
52  };
53
54  // Generate instructions for screen readers
55  const dateInstructions = 'Enter the date in DD-MM-YYYY format, for example 01-06-2023 for June 1, 2023.';
56
57  return (
58    <div>
59      <label htmlFor="date-input" className="block text-sm font-medium text-gray-700">
60        Date
61      </label>
62      <input
63        ref={inputRef}
64        type="text"
65        id="date-input"
66        value={date}
67        onChange={handleChange}
68        aria-describedby="date-instructions"
69        aria-invalid={date !== '' && !validateDate(date)}
70        className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
71      />
72      <p id="date-instructions" className="mt-1 text-xs text-gray-500">
73        {dateInstructions}
74      </p>
75      {date !== '' && !validateDate(date) && (
76        <p className="mt-1 text-sm text-red-600" role="alert">
77          The entered date is invalid. {dateInstructions}
78        </p>
79      )}
80    </div>
81  );
82}

4. Reusable accessible form components

Creating a library of reusable, accessible form components can significantly simplify building consistent and accessible forms across the entire application:

1// components/FormElements.tsx
2import { InputHTMLAttributes, TextareaHTMLAttributes, ReactNode } from 'react';
3
4// Label component
5interface LabelProps {
6  htmlFor: string;
7  children: ReactNode;
8  required?: boolean;
9}
10
11export function FormLabel({ htmlFor, children, required }: LabelProps) {
12  return (
13    <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
14      {children}
15      {required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
16    </label>
17  );
18}
19
20// Text input component
21interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
22  id: string;
23  label: string;
24  error?: string;
25  hint?: string;
26  required?: boolean;
27}
28
29export function TextInput({ 
30  id, 
31  label, 
32  error, 
33  hint, 
34  required, 
35  ...props 
36}: TextInputProps) {
37  const errorId = error ? `${id}-error` : undefined;
38  const hintId = hint ? `${id}-hint` : undefined;
39  const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
40  
41  return (
42    <div className="mb-4">
43      <FormLabel htmlFor={id} required={required}>{label}</FormLabel>
44      <input
45        id={id}
46        type="text"
47        aria-invalid={Boolean(error)}
48        aria-required={required}
49        aria-describedby={describedBy}
50        className={`mt-1 block w-full rounded-md ${error ? 'border-red-500' : 'border-gray-300'}`}
51        {...props}
52      />
53      {hint && !error && (
54        <p id={hintId} className="mt-1 text-xs text-gray-500">
55          {hint}
56        </p>
57      )}
58      {error && (
59        <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
60          {error}
61        </p>
62      )}
63    </div>
64  );
65}
66
67// Multi-line text input component
68interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
69  id: string;
70  label: string;
71  error?: string;
72  hint?: string;
73  required?: boolean;
74}
75
76export function Textarea({ 
77  id, 
78  label, 
79  error, 
80  hint, 
81  required, 
82  ...props 
83}: TextareaProps) {
84  const errorId = error ? `${id}-error` : undefined;
85  const hintId = hint ? `${id}-hint` : undefined;
86  const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
87  
88  return (
89    <div className="mb-4">
90      <FormLabel htmlFor={id} required={required}>{label}</FormLabel>
91      <textarea
92        id={id}
93        aria-invalid={Boolean(error)}
94        aria-required={required}
95        aria-describedby={describedBy}
96        className={`mt-1 block w-full rounded-md ${error ? 'border-red-500' : 'border-gray-300'}`}
97        {...props}
98      />
99      {hint && !error && (
100        <p id={hintId} className="mt-1 text-xs text-gray-500">
101          {hint}
102        </p>
103      )}
104      {error && (
105        <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
106          {error}
107        </p>
108      )}
109    </div>
110  );
111}
112
113// Radio or checkbox group component
114interface OptionItem {
115  id: string;
116  value: string;
117  label: string;
118}
119
120interface CheckboxOrRadioGroupProps {
121  legend: string;
122  name: string;
123  options: OptionItem[];
124  type: 'checkbox' | 'radio';
125  values: string[];
126  onChange: (value: string, checked: boolean) => void;
127  error?: string;
128  required?: boolean;
129}
130
131export function OptionGroup({ 
132  legend, 
133  name, 
134  options, 
135  type, 
136  values, 
137  onChange, 
138  error, 
139  required 
140}: CheckboxOrRadioGroupProps) {
141  const errorId = error ? `${name}-error` : undefined;
142  
143  return (
144    <fieldset className="mb-4">
145      <legend className="text-sm font-medium text-gray-700">
146        {legend}
147        {required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
148      </legend>
149      
150      <div className="mt-2 space-y-2" role={type === 'radio' ? 'radiogroup' : undefined} aria-required={required}>
151        {options.map(option => (
152          <div key={option.id} className="flex items-center">
153            <input
154              type={type}
155              id={option.id}
156              name={name}
157              value={option.value}
158              checked={type === 'radio' ? values[0] === option.value : values.includes(option.value)}
159              onChange={(e) => onChange(option.value, e.target.checked)}
160              aria-describedby={errorId}
161              className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
162            />
163            <label htmlFor={option.id} className="ml-2 block text-sm text-gray-700">
164              {option.label}
165            </label>
166          </div>
167        ))}
168      </div>
169      
170      {error && (
171        <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
172          {error}
173        </p>
174      )}
175    </fieldset>
176  );
177}

Example usage of reusable components:

1import { useState } from 'react';
2import { TextInput, Textarea, OptionGroup } from '../components/FormElements';
3
4export default function SurveyForm() {
5  const [formData, setFormData] = useState({
6    name: '',
7    email: '',
8    feedback: '',
9    satisfaction: '',
10    interests: [] as string[]
11  });
12  
13  const [errors, setErrors] = useState<Record<string, string>>({});
14  
15  const handleInputChange = (field: string, value: string) => {
16    setFormData(prev => ({ ...prev, [field]: value }));
17    
18    // Remove error after editing
19    if (errors[field]) {
20      setErrors(prev => {
21        const newErrors = { ...prev };
22        delete newErrors[field];
23        return newErrors;
24      });
25    }
26  };
27  
28  const handleCheckboxChange = (value: string, checked: boolean) => {
29    setFormData(prev => {
30      if (checked) {
31        return { ...prev, interests: [...prev.interests, value] };
32      } else {
33        return { ...prev, interests: prev.interests.filter(item => item !== value) };
34      }
35    });
36    
37    // Remove error after editing
38    if (errors.interests) {
39      setErrors(prev => {
40        const newErrors = { ...prev };
41        delete newErrors.interests;
42        return newErrors;
43      });
44    }
45  };
46  
47  const handleRadioChange = (value: string, checked: boolean) => {
48    if (checked) {
49      setFormData(prev => ({ ...prev, satisfaction: value }));
50      
51      // Remove error after editing
52      if (errors.satisfaction) {
53        setErrors(prev => {
54          const newErrors = { ...prev };
55          delete newErrors.satisfaction;
56          return newErrors;
57        });
58      }
59    }
60  };
61  
62  const validateForm = () => {
63    const newErrors: Record<string, string> = {};
64    
65    if (!formData.name.trim()) {
66      newErrors.name = 'Name is required';
67    }
68    
69    if (!formData.email.trim()) {
70      newErrors.email = 'Email is required';
71    } else if (!/^S+@S+.S+$/.test(formData.email)) {
72      newErrors.email = 'Please provide a valid email address';
73    }
74    
75    if (!formData.feedback.trim()) {
76      newErrors.feedback = 'Feedback is required';
77    }
78
79    if (!formData.satisfaction) {
80      newErrors.satisfaction = 'Please select a satisfaction level';
81    }
82
83    if (formData.interests.length === 0) {
84      newErrors.interests = 'Select at least one category';
85    }
86    
87    setErrors(newErrors);
88    return Object.keys(newErrors).length === 0;
89  };
90  
91  const handleSubmit = (e: React.FormEvent) => {
92    e.preventDefault();
93    
94    if (validateForm()) {
95      // Submit the form
96      console.log('Form data:', formData);
97      alert('Form sent successfully!');
98    }
99  };
100  
101  return (
102    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
103      <h1 className="text-2xl font-bold mb-6">User survey</h1>
104      
105      <form onSubmit={handleSubmit} noValidate>
106        <TextInput
107          id="name"
108          label="Full name"
109          required
110          value={formData.name}
111          onChange={(e) => handleInputChange('name', e.target.value)}
112          error={errors.name}
113        />
114        
115        <TextInput
116          id="email"
117          label="Email"
118          type="email"
119          required
120          value={formData.email}
121          onChange={(e) => handleInputChange('email', e.target.value)}
122          error={errors.email}
123          hint="We'll never share your email address"
124        />
125        
126        <Textarea
127          id="feedback"
128          label="Your feedback"
129          required
130          value={formData.feedback}
131          onChange={(e) => handleInputChange('feedback', e.target.value)}
132          error={errors.feedback}
133          rows={4}
134        />
135        
136        <OptionGroup
137          legend="Satisfaction level"
138          name="satisfaction"
139          type="radio"
140          required
141          values={formData.satisfaction ? [formData.satisfaction] : []}
142          onChange={handleRadioChange}
143          error={errors.satisfaction}
144          options={[
145            { id: 'satisfaction-very-satisfied', value: 'very-satisfied', label: 'Very satisfied' },
146            { id: 'satisfaction-satisfied', value: 'satisfied', label: 'Satisfied' },
147            { id: 'satisfaction-neutral', value: 'neutral', label: 'Neutral' },
148            { id: 'satisfaction-dissatisfied', value: 'dissatisfied', label: 'Dissatisfied' },
149            { id: 'satisfaction-very-dissatisfied', value: 'very-dissatisfied', label: 'Very dissatisfied' }
150          ]}
151        />
152        
153        <OptionGroup
154          legend="Interests"
155          name="interests"
156          type="checkbox"
157          required
158          values={formData.interests}
159          onChange={handleCheckboxChange}
160          error={errors.interests}
161          options={[
162            { id: 'interests-technology', value: 'technology', label: 'Technology' },
163            { id: 'interests-design', value: 'design', label: 'Design' },
164            { id: 'interests-business', value: 'business', label: 'Business' },
165            { id: 'interests-health', value: 'health', label: 'Health' },
166            { id: 'interests-education', value: 'education', label: 'Education' }
167          ]}
168        />
169        
170        <div className="mt-6">
171          <button
172            type="submit"
173            className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
174          >
175            Send survey
176          </button>
177        </div>
178      </form>
179    </div>
180  );
181}

Testing form accessibility

Merely implementing accessible forms is only half the battle. Testing them is also necessary:

1. Automated accessibility testing

You can use tools like

@axe-core/react
to test accessibility during development:

1// In the test file (e.g., FormComponent.test.tsx)
2import React from 'react';
3import { render } from '@testing-library/react';
4import { axe, toHaveNoViolations } from 'jest-axe';
5import ContactForm from '../ContactForm';
6
7// Add matcher to Jest
8expect.extend(toHaveNoViolations);
9
10describe('ContactForm', () => {
11  it('should not have accessibility violations', async () => {
12    const { container } = render(<ContactForm />);
13    const results = await axe(container);
14    expect(results).toHaveNoViolations();
15  });
16});

2. Manual accessibility testing

Automated tests don't catch all problems. Manual testing is also required:

  1. Keyboard navigation testing:

    • Can you navigate the form using only the keyboard (Tab, Shift+Tab, Enter, space)?
    • Is focus clearly visible on every field?
    • Can you activate buttons and links with the keyboard?
  2. Testing with screen readers:

    • NVDA or JAWS (Windows)
    • VoiceOver (macOS)
    • TalkBack (Android)
    • VoiceOver (iOS)
  3. Zoom testing:

    • Is the form usable when text is zoomed to 200%?
    • Does the form layout adapt to zooming?

3. Form accessibility checklist

Before deploying a form, check it against the following criteria:

  1. Structural:

    • Do all form fields have associated labels?
    • Are radio and checkbox fields grouped with
      <fieldset>
      and
      <legend>
      ?
    • Are ARIA attributes used correctly?
  2. Interactivity:

    • Can you navigate the form via keyboard?
    • Can you activate all controls with the keyboard?
    • Is keyboard focus visible?
  3. Feedback:

    • Are validation errors clearly communicated?
    • Are error messages linked to fields via
      aria-describedby
      ?
    • Is the form submission status clearly communicated?
  4. Visual:

    • Is the text/background contrast sufficient (minimum 4.5:1)?
    • Is the form usable at various screen sizes?
    • Are visual indicators (e.g., colors) complemented by other cues?

Summary

Creating usable and accessible forms is a key aspect of modern web applications. Properly designed forms contribute to increased conversion, improved user experience, and provide access to your services to a wider audience.

Main principles to remember:

  1. Clear structure - logical field organization, clear labels, grouping of related fields
  2. Keyboard accessibility - full keyboard support, visible focus, logical tab order
  3. Correct ARIA - using ARIA attributes to improve screen reader users' experience
  4. Effective communication - clear labels, helper texts, precise error messages
  5. Validation - immediate feedback, clear instructions, validation both on client and server
  6. Testing - both automated and manual, with various assistive devices

By building forms following these principles, you can create an accessible and user-friendly experience that will serve all visitors to your application.

Go to CodeWorlds