Projektowanie formularzy, które są zarówno przyjazne dla użytkownika (usability), jak i dostępne dla osób z różnymi niepełnosprawnościami (accessibility), jest kluczowym aspektem tworzenia nowoczesnych aplikacji webowych. W tym module omówimy, jak tworzyć formularze, które są łatwe w użyciu dla wszystkich użytkowników, niezależnie od ich możliwości i urządzeń, z których korzystają.
<fieldset> i <legend> do grupowania pól<label for="id"> powiązanego z id pola<fieldset> i <legend> dla grup pól, zwłaszcza dla checkboxów i radio buttonów1import { 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 // Usuwanie błędu po edycji pola
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 = 'Imię i nazwisko jest wymagane';
41 }
42
43 if (!formData.email.trim()) {
44 newErrors.email = 'Email jest wymagany';
45 } else if (!/^S+@S+.S+$/.test(formData.email)) {
46 newErrors.email = 'Podaj prawidłowy adres email';
47 }
48
49 if (formData.contactPreference === 'phone' && !formData.phone.trim()) {
50 newErrors.phone = 'Numer telefonu jest wymagany przy wyborze kontaktu telefonicznego';
51 }
52
53 if (!formData.message.trim()) {
54 newErrors.message = 'Wiadomość jest wymagana';
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 // Przesunięcie fokusu do pierwszego pola z błędem
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 // Symulacja wysyłki formularza
77 await new Promise(resolve => setTimeout(resolve, 1500));
78 console.log('Formularz wysłany:', formData);
79
80 setIsSuccess(true);
81 // Przesunięcie fokusu do komunikatu sukcesu
82 const successMessage = document.getElementById('success-message');
83 if (successMessage) {
84 successMessage.focus();
85 }
86
87 // Resetowanie formularza
88 setFormData({
89 name: '',
90 email: '',
91 phone: '',
92 message: '',
93 contactPreference: 'email',
94 newsletter: false
95 });
96 } catch (error) {
97 setErrors({
98 form: 'Wystąpił błąd podczas wysyłania formularza. Spróbuj ponownie później.'
99 });
100 // Przesunięcie fokusu do komunikatu błędu
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">Skontaktuj się z nami</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} // Pozwala na otrzymanie fokusu
120 >
121 Dziękujemy za wysłanie formularza! Odpowiemy najszybciej jak to możliwe.
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} // Pozwala na otrzymanie fokusu
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 Imię i nazwisko <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">Preferowana forma kontaktu</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 Telefon
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 Numer telefonu {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 Wiadomość <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 Zapisz mnie do newslettera
273 </label>
274 </div>
275
276 <div className="pt-2">
277 <p className="text-xs text-gray-500">
278 Pola oznaczone <span className="text-red-500" aria-hidden="true">*</span> są wymagane
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 ? 'Wysyłanie...' : 'Wyślij wiadomość'}
289 </button>
290 </form>
291 </div>
292 );
293}Kluczowe elementy dostępności w powyższym przykładzie:
htmlFor powiązany z id polaaria-required, aria-invalid, aria-describedbyPrawidłowe zarządzanie fokusem jest kluczowe dla użytkowników klawiatury i czytników ekranu:
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 // Autofokus na pierwsze pole formularza przy montażu
9 useEffect(() => {
10 if (firstInputRef.current) {
11 firstInputRef.current.focus();
12 }
13 }, []);
14
15 // Przykładowa funkcja do przenoszenia fokusu do pierwszego błędu
16 const moveToFirstError = () => {
17 if (errorMessageRef.current) {
18 errorMessageRef.current.focus();
19 errorMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
20 }
21 };
22
23 // Przykładowa funkcja do przenoszenia fokusu do komunikatu sukcesu
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 {/* Komunikat błędu z możliwością otrzymania fokusu */}
34 <div
35 ref={errorMessageRef}
36 tabIndex={-1}
37 role="alert"
38 className="bg-red-100 p-3 rounded"
39 style={{ display: 'none' }} // Normalnie kontrolowane przez stan
40 >
41 Wystąpiły błędy w formularzu
42 </div>
43
44 {/* Pierwsze pole formularza */}
45 <div>
46 <label htmlFor="first-field">Imię</label>
47 <input
48 ref={firstInputRef}
49 id="first-field"
50 type="text"
51 />
52 </div>
53
54 {/* Komunikat sukcesu */}
55 <div
56 ref={successMessageRef}
57 tabIndex={-1}
58 role="status"
59 className="bg-green-100 p-3 rounded"
60 style={{ display: 'none' }} // Normalnie kontrolowane przez stan
61 >
62 Formularz został wysłany pomyślnie!
63 </div>
64
65 {/* Przyciski do demonstracji przenoszenia fokusu */}
66 <div className="mt-4 space-x-2">
67 <button type="button" onClick={moveToFirstError}>
68 Pokaż błąd
69 </button>
70 <button type="button" onClick={moveToSuccessMessage}>
71 Pokaż sukces
72 </button>
73 </div>
74 </form>
75 );
76}Dobrze zaimplementowane pola z auto-kompletacją mogą znacząco poprawić użyteczność formularzy:
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 // Filtruj sugestie na podstawie aktualnego wprowadzenia
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 // Obsługa zmiany wartości
28 const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29 const userInput = e.target.value;
30 onChange(userInput);
31 filterSuggestions(userInput);
32 };
33
34 // Obsługa kliknięcia na sugestię
35 const handleSuggestionClick = (suggestion: string) => {
36 onChange(suggestion);
37 setShowSuggestions(false);
38 setActiveSuggestionIndex(0);
39 };
40
41 // Obsługa nawigacji za pomocą klawiatury
42 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
43 // Strzałka w dół - przejście do następnej sugestii
44 if (e.key === 'ArrowDown') {
45 e.preventDefault();
46 setActiveSuggestionIndex(prev =>
47 prev < filteredSuggestions.length - 1 ? prev + 1 : prev
48 );
49 }
50 // Strzałka w górę - przejście do poprzedniej sugestii
51 else if (e.key === 'ArrowUp') {
52 e.preventDefault();
53 setActiveSuggestionIndex(prev => (prev > 0 ? prev - 1 : 0));
54 }
55 // Enter - wybór aktywnej sugestii
56 else if (e.key === 'Enter' && showSuggestions) {
57 e.preventDefault();
58 if (filteredSuggestions[activeSuggestionIndex]) {
59 handleSuggestionClick(filteredSuggestions[activeSuggestionIndex]);
60 }
61 }
62 // Escape - zamknięcie listy sugestii
63 else if (e.key === 'Escape') {
64 setShowSuggestions(false);
65 }
66 };
67
68 // ID dla listy sugestii (do powiązania z 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 // Opóźnienie, aby umożliwić kliknięcie sugestii
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 {/* Lista sugestii */}
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}Kluczowe elementy dostępności w auto-kompletacji:
aria-autocomplete="list", role="listbox", role="option"Pola daty i inne złożone inputy powinny być dostępne i łatwe w użyciu:
1import { useState, useRef, ChangeEvent } from 'react';
2
3function AccessibleDateInput() {
4 const [date, setDate] = useState('');
5 const inputRef = useRef<HTMLInputElement>(null);
6
7 // Formatowanie daty w formacie DD-MM-YYYY
8 const formatDate = (input: string) => {
9 // Usunięcie wszystkich znaków innych niż cyfry
10 const digitsOnly = input.replace(/D/g, '');
11
12 // Dodanie kresek w odpowiednich miejscach
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 // Walidacja daty
38 const validateDate = (date: string) => {
39 // Sprawdzenie formatu DD-MM-YYYY
40 if (!/^d{2}-d{2}-d{4}$/.test(date)) return false;
41
42 const [day, month, year] = date.split('-').map(Number);
43
44 // Sprawdzenie zakresu miesiąca
45 if (month < 1 || month > 12) return false;
46
47 // Sprawdzenie liczby dni w miesiącu
48 const daysInMonth = new Date(year, month, 0).getDate();
49 if (day < 1 || day > daysInMonth) return false;
50
51 return true;
52 };
53
54 // Generowanie instrukcji dla czytników ekranu
55 const dateInstructions = 'Wprowadź datę w formacie DD-MM-RRRR, na przykład 01-06-2023 dla 1 czerwca 2023.';
56
57 return (
58 <div>
59 <label htmlFor="date-input" className="block text-sm font-medium text-gray-700">
60 Data
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 Wprowadzona data jest nieprawidłowa. {dateInstructions}
78 </p>
79 )}
80 </div>
81 );
82}Tworzenie biblioteki reużywalnych, dostępnych komponentów formularza może znacząco ułatwić budowanie spójnych i dostępnych formularzy w całej aplikacji:
1// components/FormElements.tsx
2import { InputHTMLAttributes, TextareaHTMLAttributes, ReactNode } from 'react';
3
4// Komponent etykiety
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// Komponent pola tekstowego
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// Komponent pola tekstowego wieloliniowego
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// Komponent grupy radio lub checkbox
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}Przykład użycia reużywalnych komponentów:
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 // Usunięcie błędu po edycji
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 // Usunięcie błędu po edycji
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 // Usunięcie błędu po edycji
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 = 'Imię jest wymagane';
67 }
68
69 if (!formData.email.trim()) {
70 newErrors.email = 'Email jest wymagany';
71 } else if (!/^S+@S+.S+$/.test(formData.email)) {
72 newErrors.email = 'Podaj prawidłowy adres email';
73 }
74
75 if (!formData.feedback.trim()) {
76 newErrors.feedback = 'Opinia jest wymagana';
77 }
78
79 if (!formData.satisfaction) {
80 newErrors.satisfaction = 'Wybierz poziom zadowolenia';
81 }
82
83 if (formData.interests.length === 0) {
84 newErrors.interests = 'Wybierz co najmniej jedną kategorię';
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 // Przesłanie formularza
96 console.log('Dane formularza:', formData);
97 alert('Formularz wysłany pomyślnie!');
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">Ankieta użytkownika</h1>
104
105 <form onSubmit={handleSubmit} noValidate>
106 <TextInput
107 id="name"
108 label="Imię i nazwisko"
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="Nigdy nie udostępnimy Twojego adresu email"
124 />
125
126 <Textarea
127 id="feedback"
128 label="Twoja opinia"
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="Poziom zadowolenia"
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: 'Bardzo zadowolony' },
146 { id: 'satisfaction-satisfied', value: 'satisfied', label: 'Zadowolony' },
147 { id: 'satisfaction-neutral', value: 'neutral', label: 'Neutralny' },
148 { id: 'satisfaction-dissatisfied', value: 'dissatisfied', label: 'Niezadowolony' },
149 { id: 'satisfaction-very-dissatisfied', value: 'very-dissatisfied', label: 'Bardzo niezadowolony' }
150 ]}
151 />
152
153 <OptionGroup
154 legend="Zainteresowania"
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: 'Technologia' },
163 { id: 'interests-design', value: 'design', label: 'Wzornictwo' },
164 { id: 'interests-business', value: 'business', label: 'Biznes' },
165 { id: 'interests-health', value: 'health', label: 'Zdrowie' },
166 { id: 'interests-education', value: 'education', label: 'Edukacja' }
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 Wyślij ankietę
176 </button>
177 </div>
178 </form>
179 </div>
180 );
181}Samo zaimplementowanie dostępnych formularzy to tylko połowa sukcesu. Konieczne jest również ich testowanie:
Możesz użyć narzędzi takich jak
@axe-core/react do testowania dostępności w trakcie rozwoju:1// W pliku testowym (np. 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// Dodaj matcher do 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});Automatyczne testy nie wychwycają wszystkich problemów. Konieczne jest również manualne testowanie:
Testowanie nawigacji klawiaturą:
Testowanie z czytnikami ekranu:
Testowanie powiększenia:
Przed wdrożeniem formularza, sprawdź go pod kątem następujących kryteriów:
Strukturalne:
<fieldset> i <legend>?Interakcyjność:
Feedback:
aria-describedby?Wizualne:
Tworzenie użytecznych i dostępnych formularzy to kluczowy aspekt nowoczesnych aplikacji webowych. Prawidłowo zaprojektowane formularze przyczyniają się do zwiększenia konwersji, poprawy doświadczenia użytkownika i zapewniają dostęp do twoich usług szerszemu gronu odbiorców.
Główne zasady, o których należy pamiętać:
Budując formularze zgodnie z tymi zasadami, możesz stworzyć dostępne i przyjazne dla użytkownika doświadczenie, które będzie służyć wszystkim odwiedzającym Twoją aplikację.