Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Usability i accessibility w formularzach

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ą.

Dlaczego usability i accessibility są ważne?

Korzyści biznesowe

  1. Zwiększenie konwersji - formularze, które są łatwe w użyciu, mają wyższe współczynniki konwersji
  2. Szerszy zasięg - dostępne formularze docierają do większej liczby użytkowników
  3. Zgodność prawna - w wielu krajach dostępność stron internetowych jest wymagana przez prawo (np. ADA w USA, WCAG w UE)
  4. Poprawa SEO - dostępne strony często mają lepsze pozycje w wynikach wyszukiwania

Korzyści dla użytkowników

  1. Lepsza wydajność - użytkownicy mogą szybciej i łatwiej wypełniać formularze
  2. Mniejsza frustracja - dobrze zaprojektowane formularze zmniejszają liczbę błędów i frustracji
  3. Większa niezależność - osoby z niepełnosprawnościami mogą samodzielnie korzystać z formularzy
  4. Spójne doświadczenie - dobrze zaprojektowane formularze działają spójnie na różnych urządzeniach i w różnych przeglądarkach

Podstawowe zasady użyteczności formularzy

1. Przejrzysta struktura

  • Grupuj powiązane pola - używaj
    <fieldset>
    i
    <legend>
    do grupowania pól
  • Logiczna kolejność - organizuj pola w intuicyjnej kolejności
  • Odpowiednie etykiety - każde pole powinno mieć jasną etykietę

2. Minimalizacja obciążenia poznawczego

  • Pytaj tylko o niezbędne informacje - każde dodatkowe pole zmniejsza konwersję
  • Podziel długie formularze na kroki - wieloetapowe formularze są łatwiejsze do przyswojenia
  • Pokazuj postęp - w wieloetapowych formularzach informuj użytkownika o postępie

3. Jasna komunikacja

  • Wyraźnie wymaganych pól - oznaczaj pola wymagane w sposób spójny i widoczny
  • Precyzyjne etykiety - etykiety powinny jasno komunikować, jakie informacje są oczekiwane
  • Podpowiedzi i przykłady - dla skomplikowanych pól dodawaj przykłady lub podpowiedzi

4. Efektywne przesyłanie danych

  • Walidacja w czasie rzeczywistym - informuj o błędach podczas wypełniania formularza
  • Zachowaj dane przy błędach - nie resetuj całego formularza po wystąpieniu błędu
  • Potwierdzaj akcje - informuj użytkownika o powodzeniu lub niepowodzeniu wysyłki formularza

Zasady dostępności formularzy

1. Strukturalne zasady HTML

  • Prawidłowe powiązanie etykiet z polami - używaj
    <label for="id">
    powiązanego z
    id
    pola
  • Grupowanie pól - używaj
    <fieldset>
    i
    <legend>
    dla grup pól, zwłaszcza dla checkboxów i radio buttonów
  • Logiczna kolejność tabulacji - upewnij się, że kolejność tabulacji jest logiczna i intuicyjna

2. ARIA (Accessible Rich Internet Applications)

  • Atrybuty aria-label i aria-labelledby - do opisywania elementów bez widocznych etykiet
  • aria-describedby - do powiązania dodatkowych opisów z polami
  • aria-required - dla oznaczania pól wymaganych
  • aria-invalid - dla oznaczania pól z błędami
  • role="alert" - dla komunikatów błędów, które powinny być natychmiast odczytane

3. Kontrast i czytelność

  • Wystarczający kontrast - stosunek kontrastu między tekstem a tłem powinien wynosić co najmniej 4.5:1
  • Odpowiedni rozmiar tekstu - tekst powinien być czytelny bez powiększania
  • Responsywny projekt - formularze powinny być używalne na różnych rozmiarach ekranu

4. Obsługa klawiatury

  • Pełna obsługa klawiatury - wszystkie funkcje powinny być dostępne za pomocą klawiatury
  • Widoczny fokus - fokus klawiatury powinien być wyraźnie widoczny
  • Skróty klawiaturowe - dla zaawansowanych formularzy można dodać skróty klawiaturowe

Implementacja dostępnych formularzy w React

Podstawowy dostępny formularz

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    // 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:

  1. Prawidłowe powiązanie etykiet z polami - każda etykieta ma atrybut
    htmlFor
    powiązany z
    id
    pola
  2. Grupowanie z fieldset i legend - dla radio buttonów
  3. Atrybuty ARIA -
    aria-required
    ,
    aria-invalid
    ,
    aria-describedby
  4. Dynamiczna widoczność - pole telefonu pojawia się tylko wtedy, gdy jest potrzebne
  5. Komunikaty błędów z role="alert" - dla czytników ekranu
  6. Zarządzanie fokusem - przesuwanie fokusu do pierwszego błędu lub komunikatu sukcesu

Zaawansowane praktyki dla dostępnych formularzy

1. Zarządzanie fokusem

Prawidł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}

2. Pola z auto-kompletacją

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:

  1. ARIA roles -
    aria-autocomplete="list"
    ,
    role="listbox"
    ,
    role="option"
  2. aria-activedescendant - do oznaczania aktywnej sugestii
  3. aria-controls - powiązanie pola z listą sugestii
  4. Obsługa klawiatury - strzałki, Enter, Escape

3. Data pickers i inputy z maskami

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}

4. Reużywalne komponenty dostępnych formularzy

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}

Testowanie dostępności formularzy

Samo zaimplementowanie dostępnych formularzy to tylko połowa sukcesu. Konieczne jest również ich testowanie:

1. Automatyczne testowanie dostępności

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});

2. Manualne testowanie dostępności

Automatyczne testy nie wychwycają wszystkich problemów. Konieczne jest również manualne testowanie:

  1. Testowanie nawigacji klawiaturą:

    • Czy można poruszać się po formularzu używając tylko klawiatury (Tab, Shift+Tab, Enter, spacja)?
    • Czy fokus jest wyraźnie widoczny na każdym polu?
    • Czy można aktywować przyciski i linki za pomocą klawiatury?
  2. Testowanie z czytnikami ekranu:

    • NVDA lub JAWS (Windows)
    • VoiceOver (macOS)
    • TalkBack (Android)
    • VoiceOver (iOS)
  3. Testowanie powiększenia:

    • Czy formularz jest używalny przy powiększeniu tekstu do 200%?
    • Czy układ formularza dostosowuje się do powiększenia?

3. Lista kontrolna dostępności formularzy

Przed wdrożeniem formularza, sprawdź go pod kątem następujących kryteriów:

  1. Strukturalne:

    • Czy wszystkie pola formularza mają powiązane etykiety?
    • Czy pola radio i checkbox są zgrupowane za pomocą
      <fieldset>
      i
      <legend>
      ?
    • Czy atrybuty ARIA są użyte poprawnie?
  2. Interakcyjność:

    • Czy można poruszać się po formularzu za pomocą klawiatury?
    • Czy można aktywować wszystkie kontrolki za pomocą klawiatury?
    • Czy fokus klawiatury jest widoczny?
  3. Feedback:

    • Czy błędy walidacji są jasno komunikowane?
    • Czy komunikaty o błędach są powiązane z polami za pomocą
      aria-describedby
      ?
    • Czy status wysyłki formularza jest jasno komunikowany?
  4. Wizualne:

    • Czy kontrast tekstu i tła jest wystarczający (minimum 4.5:1)?
    • Czy formularz jest używalny na różnych rozmiarach ekranu?
    • Czy wizualne wskaźniki (np. kolory) są uzupełnione innymi wskaźnikami?

Podsumowanie

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ć:

  1. Przejrzysta struktura - logiczna organizacja pól, jasne etykiety, grupowanie powiązanych pól
  2. Dostępność klawiatury - pełna obsługa klawiatury, widoczny fokus, logiczna kolejność tabulacji
  3. Poprawne ARIA - używanie atrybutów ARIA do poprawy doświadczenia użytkowników czytników ekranu
  4. Efektywna komunikacja - jasne etykiety, pomocnicze teksty, precyzyjne komunikaty błędów
  5. Walidacja - natychmiastowa informacja zwrotna, jasne instrukcje, walidacja zarówno na kliencie, jak i na serwerze
  6. Testowanie - zarówno automatyczne, jak i manualne, z różnymi urządzeniami wspierającymi

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ę.

Przejdź do CodeWorlds