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.
<fieldset> and <legend> to group fields<label for="id"> linked to the field's id<fieldset> and <legend> for groups of fields, especially checkboxes and radio buttons1import { 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:
htmlFor attribute linked to the field's idaria-required, aria-invalid, aria-describedbyProper 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}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:
aria-autocomplete="list", role="listbox", role="option"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}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}Merely implementing accessible forms is only half the battle. Testing them is also necessary:
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});Automated tests don't catch all problems. Manual testing is also required:
Keyboard navigation testing:
Testing with screen readers:
Zoom testing:
Before deploying a form, check it against the following criteria:
Structural:
<fieldset> and <legend>?Interactivity:
Feedback:
aria-describedby?Visual:
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:
By building forms following these principles, you can create an accessible and user-friendly experience that will serve all visitors to your application.