Cours Next.js · Module 6: Formulaires et Server Actions

Utilisabilité et accessibilité des formulaires

20 min de lecture
Dans cette leçon7

Concevoir des formulaires à la fois conviviaux (utilisabilité) et accessibles aux personnes ayant divers handicaps (accessibilité) est un aspect essentiel de la construction d'applications web modernes. Dans ce module, nous verrons comment créer des formulaires faciles à utiliser pour tous les utilisateurs, quelles que soient leurs capacités et les appareils qu'ils utilisent.

Pourquoi l'utilisabilité et l'accessibilité sont-elles importantes ?

Bénéfices pour l'entreprise

  1. Conversion accrue - les formulaires faciles à utiliser ont des taux de conversion plus élevés
  2. Portée plus large - les formulaires accessibles touchent plus d'utilisateurs
  3. Conformité légale - dans de nombreux pays, l'accessibilité des sites web est exigée par la loi (par ex. ADA aux États-Unis, WCAG dans l'UE)
  4. Meilleur SEO - les pages accessibles se classent souvent mieux dans les résultats de recherche

Bénéfices pour les utilisateurs

  1. Meilleure efficacité - les utilisateurs peuvent remplir les formulaires plus vite et plus facilement
  2. Moins de frustration - les formulaires bien conçus réduisent les erreurs et la frustration
  3. Plus d'autonomie - les personnes en situation de handicap peuvent utiliser les formulaires par elles-mêmes
  4. Expérience cohérente - les formulaires bien conçus fonctionnent de façon cohérente sur divers appareils et navigateurs

Principes de base de l'utilisabilité des formulaires

1. Structure claire

  • Regroupe les champs liés - utilise <fieldset> et <legend> pour regrouper les champs
  • Ordre logique - organise les champs dans un ordre intuitif
  • Labels appropriés - chaque champ doit avoir un label clair

2. Minimiser la charge cognitive

  • Ne demande que les informations nécessaires - chaque champ supplémentaire réduit la conversion
  • Découpe les longs formulaires en étapes - les formulaires multi-étapes sont plus faciles à traiter
  • Montre la progression - dans les formulaires multi-étapes, informe l'utilisateur de sa progression

3. Communication claire

  • Champs requis clairement indiqués - marque les champs requis de façon cohérente et visible
  • Labels précis - les labels doivent indiquer clairement quelle information est attendue
  • Indications et exemples - pour les champs complexes, ajoute des exemples ou des indications

4. Soumission efficace des données

  • Validation en temps réel - informe des erreurs pendant le remplissage du formulaire
  • Préserve les données en cas d'erreur - ne réinitialise pas tout le formulaire après une erreur
  • Confirme les actions - informe l'utilisateur du succès ou de l'échec de l'envoi du formulaire

Principes d'accessibilité des formulaires

1. Principes structurels HTML

  • Associe correctement les labels aux champs - utilise <label for="id"> lié à l'id du champ
  • Regroupe les champs - utilise <fieldset> et <legend> pour les groupes de champs, en particulier les cases à cocher et les boutons radio
  • Ordre de tabulation logique - assure-toi que l'ordre de tabulation est logique et intuitif

2. ARIA (Accessible Rich Internet Applications)

  • attributs aria-label et aria-labelledby - pour décrire les éléments sans label visible
  • aria-describedby - pour relier des descriptions supplémentaires aux champs
  • aria-required - pour marquer les champs requis
  • aria-invalid - pour marquer les champs comportant des erreurs
  • role="alert" - pour les messages d'erreur qui doivent être lus immédiatement

3. Contraste et lisibilité

  • Contraste suffisant - le rapport de contraste entre le texte et le fond doit être d'au moins 4,5:1
  • Taille de texte appropriée - le texte doit être lisible sans zoom
  • Design responsive - les formulaires doivent être utilisables sur diverses tailles d'écran

4. Support clavier

  • Support clavier complet - toutes les fonctions doivent être accessibles au clavier
  • Focus visible - le focus clavier doit être clairement visible
  • Raccourcis clavier - pour les formulaires avancés, des raccourcis clavier peuvent être ajoutés

Implémenter des formulaires accessibles en React

Formulaire accessible de base

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    // Supprimer l'erreur après l'édition du champ
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 = 'Le nom complet est requis';
41    }
42
43    if (!formData.email.trim()) {
44      newErrors.email = "L'email est requis";
45    } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
46      newErrors.email = 'Fournis une adresse email valide';
47    }
48
49    if (formData.contactPreference === 'phone' && !formData.phone.trim()) {
50      newErrors.phone = 'Le numéro de téléphone est requis quand tu choisis le contact par téléphone';
51    }
52
53    if (!formData.message.trim()) {
54      newErrors.message = 'Le message est requis';
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      // Déplacer le focus vers le premier champ comportant une erreur
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      // Simuler l'envoi du formulaire
77      await new Promise(resolve => setTimeout(resolve, 1500));
78      console.log('Formulaire envoyé :', formData);
79
80      setIsSuccess(true);
81      // Déplacer le focus vers le message de succès
82      const successMessage = document.getElementById('success-message');
83      if (successMessage) {
84        successMessage.focus();
85      }
86
87      // Réinitialiser le formulaire
88      setFormData({
89        name: '',
90        email: '',
91        phone: '',
92        message: '',
93        contactPreference: 'email',
94        newsletter: false
95      });
96    } catch (error) {
97      setErrors({
98        form: "Une erreur s'est produite lors de l'envoi du formulaire. Réessaie plus tard."
99      });
100      // Déplacer le focus vers le message d'erreur
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">Contacte-nous</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} // Permet à l'élément de recevoir le focus
120        >
121          Merci d'avoir envoyé le formulaire ! Nous te répondrons dès que 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} // Permet à l'élément de recevoir le 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            Nom complet <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">Méthode de contact préférée</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                Télé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            Numéro de téléphone {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            Inscris-moi à la newsletter
273          </label>
274        </div>
275
276        <div className="pt-2">
277          <p className="text-xs text-gray-500">
278            Les champs marqués d'un <span className="text-red-500" aria-hidden="true">*</span> sont requis
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 ? 'Envoi...' : 'Envoyer le message'}
289        </button>
290      </form>
291    </div>
292  );
293}

Éléments clés d'accessibilité dans l'exemple ci-dessus :

  1. Labels correctement associés aux champs - chaque label a un attribut htmlFor lié à l'id du champ
  2. Regroupement avec fieldset et legend - pour les boutons radio
  3. Attributs ARIA - aria-required, aria-invalid, aria-describedby
  4. Visibilité dynamique - le champ téléphone n'apparaît que lorsque c'est nécessaire
  5. Messages d'erreur avec role="alert" - pour les lecteurs d'écran
  6. Gestion du focus - déplacement du focus vers la première erreur ou le message de succès

Pratiques avancées pour les formulaires accessibles

1. Gestion du focus

Une bonne gestion du focus est essentielle pour les utilisateurs au clavier et de lecteurs d'écran :

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  // Focus automatique sur le premier champ du formulaire au montage
9  useEffect(() => {
10    if (firstInputRef.current) {
11      firstInputRef.current.focus();
12    }
13  }, []);
14
15  // Fonction d'exemple pour déplacer le focus vers la première erreur
16  const moveToFirstError = () => {
17    if (errorMessageRef.current) {
18      errorMessageRef.current.focus();
19      errorMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
20    }
21  };
22
23  // Fonction d'exemple pour déplacer le focus vers le message de succès
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      {/* Message d'erreur pouvant recevoir le focus */}
34      <div
35        ref={errorMessageRef}
36        tabIndex={-1}
37        role="alert"
38        className="bg-red-100 p-3 rounded"
39        style={{ display: 'none' }} // Normalement contrôlé par l'état
40      >
41        Des erreurs sont survenues dans le formulaire
42      </div>
43
44      {/* Premier champ du formulaire */}
45      <div>
46        <label htmlFor="first-field">Prénom</label>
47        <input
48          ref={firstInputRef}
49          id="first-field"
50          type="text"
51        />
52      </div>
53
54      {/* Message de succès */}
55      <div
56        ref={successMessageRef}
57        tabIndex={-1}
58        role="status"
59        className="bg-green-100 p-3 rounded"
60        style={{ display: 'none' }} // Normalement contrôlé par l'état
61      >
62        Le formulaire a été envoyé avec succès !
63      </div>
64
65      {/* Boutons pour démontrer le déplacement du focus */}
66      <div className="mt-4 space-x-2">
67        <button type="button" onClick={moveToFirstError}>
68          Afficher l'erreur
69        </button>
70        <button type="button" onClick={moveToSuccessMessage}>
71          Afficher le succès
72        </button>
73      </div>
74    </form>
75  );
76}

2. Champs à autocomplétion

Des champs d'autocomplétion bien implémentés peuvent nettement améliorer l'utilisabilité d'un formulaire :

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  // Filtrer les suggestions en fonction de la saisie actuelle
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  // Gérer le changement de valeur
28  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29    const userInput = e.target.value;
30    onChange(userInput);
31    filterSuggestions(userInput);
32  };
33
34  // Gérer le clic sur une suggestion
35  const handleSuggestionClick = (suggestion: string) => {
36    onChange(suggestion);
37    setShowSuggestions(false);
38    setActiveSuggestionIndex(0);
39  };
40
41  // Gérer la navigation au clavier
42  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
43    // Flèche bas - passer à la suggestion suivante
44    if (e.key === 'ArrowDown') {
45      e.preventDefault();
46      setActiveSuggestionIndex(prev =>
47        prev < filteredSuggestions.length - 1 ? prev + 1 : prev
48      );
49    }
50    // Flèche haut - revenir à la suggestion précédente
51    else if (e.key === 'ArrowUp') {
52      e.preventDefault();
53      setActiveSuggestionIndex(prev => (prev > 0 ? prev - 1 : 0));
54    }
55    // Entrée - sélectionner la suggestion active
56    else if (e.key === 'Enter' && showSuggestions) {
57      e.preventDefault();
58      if (filteredSuggestions[activeSuggestionIndex]) {
59        handleSuggestionClick(filteredSuggestions[activeSuggestionIndex]);
60      }
61    }
62    // Échap - fermer la liste des suggestions
63    else if (e.key === 'Escape') {
64      setShowSuggestions(false);
65    }
66  };
67
68  // ID de la liste des suggestions (pour la relier via 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          // Délai pour permettre le clic sur une suggestion
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      {/* Liste des suggestions */}
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}

Éléments clés d'accessibilité dans l'autocomplétion :

  1. Rôles ARIA - aria-autocomplete="list", role="listbox", role="option"
  2. aria-activedescendant - pour marquer la suggestion active
  3. aria-controls - relie le champ à la liste des suggestions
  4. Support clavier - flèches, Entrée, Échap

3. Sélecteurs de date et champs masqués

Les champs de date et autres saisies complexes doivent être accessibles et faciles à utiliser :

1import { useState, useRef, ChangeEvent } from 'react';
2
3function AccessibleDateInput() {
4  const [date, setDate] = useState('');
5  const inputRef = useRef<HTMLInputElement>(null);
6
7  // Formater la date au format JJ-MM-AAAA
8  const formatDate = (input: string) => {
9    // Supprimer tous les caractères non numériques
10    const digitsOnly = input.replace(/\D/g, '');
11
12    // Ajouter les tirets aux bons endroits
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    // Conserver la position du curseur après le formatage
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  // Validation de la date
38  const validateDate = (date: string) => {
39    // Vérifier le format JJ-MM-AAAA
40    if (!/^\d{2}-\d{2}-\d{4}$/.test(date)) return false;
41
42    const [day, month, year] = date.split('-').map(Number);
43
44    // Vérifier la plage des mois
45    if (month < 1 || month > 12) return false;
46
47    // Vérifier le nombre de jours dans le mois
48    const daysInMonth = new Date(year, month, 0).getDate();
49    if (day < 1 || day > daysInMonth) return false;
50
51    return true;
52  };
53
54  // Générer des instructions pour les lecteurs d'écran
55  const dateInstructions = 'Saisis la date au format JJ-MM-AAAA, par exemple 01-06-2023 pour le 1er juin 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          La date saisie est invalide. {dateInstructions}
78        </p>
79      )}
80    </div>
81  );
82}

4. Composants de formulaire accessibles réutilisables

Créer une bibliothèque de composants de formulaire accessibles et réutilisables peut nettement simplifier la construction de formulaires cohérents et accessibles dans toute l'application :

1// components/FormElements.tsx
2import { InputHTMLAttributes, TextareaHTMLAttributes, ReactNode } from 'react';
3
4// Composant Label
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// Composant de saisie de texte
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// Composant de saisie de texte multiligne
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// Composant de groupe de boutons radio ou cases à cocher
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}

Exemple d'utilisation des composants réutilisables :

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    // Supprimer l'erreur après l'édition
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    // Supprimer l'erreur après l'édition
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      // Supprimer l'erreur après l'édition
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 = 'Le nom est requis';
67    }
68
69    if (!formData.email.trim()) {
70      newErrors.email = "L'email est requis";
71    } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
72      newErrors.email = 'Fournis une adresse email valide';
73    }
74
75    if (!formData.feedback.trim()) {
76      newErrors.feedback = 'Un retour est requis';
77    }
78
79    if (!formData.satisfaction) {
80      newErrors.satisfaction = 'Sélectionne un niveau de satisfaction';
81    }
82
83    if (formData.interests.length === 0) {
84      newErrors.interests = 'Sélectionne au moins une catégorie';
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      // Envoyer le formulaire
96      console.log('Données du formulaire :', formData);
97      alert('Formulaire envoyé avec succès !');
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">Enquête utilisateur</h1>
104
105      <form onSubmit={handleSubmit} noValidate>
106        <TextInput
107          id="name"
108          label="Nom complet"
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="Nous ne partagerons jamais ton adresse email"
124        />
125
126        <Textarea
127          id="feedback"
128          label="Ton retour"
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="Niveau de satisfaction"
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: 'Très satisfait' },
146            { id: 'satisfaction-satisfied', value: 'satisfied', label: 'Satisfait' },
147            { id: 'satisfaction-neutral', value: 'neutral', label: 'Neutre' },
148            { id: 'satisfaction-dissatisfied', value: 'dissatisfied', label: 'Insatisfait' },
149            { id: 'satisfaction-very-dissatisfied', value: 'very-dissatisfied', label: 'Très insatisfait' }
150          ]}
151        />
152
153        <OptionGroup
154          legend="Centres d'intérêt"
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: 'Technologie' },
163            { id: 'interests-design', value: 'design', label: 'Design' },
164            { id: 'interests-business', value: 'business', label: 'Affaires' },
165            { id: 'interests-health', value: 'health', label: 'Santé' },
166            { id: 'interests-education', value: 'education', label: 'Éducation' }
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            Envoyer l'enquête
176          </button>
177        </div>
178      </form>
179    </div>
180  );
181}

Tester l'accessibilité des formulaires

Implémenter des formulaires accessibles n'est que la moitié du travail. Il faut aussi les tester :

1. Tests d'accessibilité automatisés

Tu peux utiliser des outils comme @axe-core/react pour tester l'accessibilité pendant le développement :

1// Dans le fichier de test (par ex. 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// Ajouter le matcher à Jest
8expect.extend(toHaveNoViolations);
9
10describe('ContactForm', () => {
11  it("ne devrait pas avoir de violations d'accessibilité", async () => {
12    const { container } = render(<ContactForm />);
13    const results = await axe(container);
14    expect(results).toHaveNoViolations();
15  });
16});

2. Tests d'accessibilité manuels

Les tests automatisés ne détectent pas tous les problèmes. Des tests manuels sont aussi nécessaires :

  1. Test de la navigation au clavier :

    • Peux-tu naviguer dans le formulaire uniquement au clavier (Tab, Maj+Tab, Entrée, espace) ?
    • Le focus est-il clairement visible sur chaque champ ?
    • Peux-tu activer les boutons et les liens au clavier ?
  2. Test avec des lecteurs d'écran :

    • NVDA or JAWS (Windows)
    • VoiceOver (macOS)
    • TalkBack (Android)
    • VoiceOver (iOS)
  3. Test du zoom :

    • Le formulaire est-il utilisable lorsque le texte est zoomé à 200 % ?
    • La disposition du formulaire s'adapte-t-elle au zoom ?

3. Liste de contrôle d'accessibilité des formulaires

Avant de déployer un formulaire, vérifie-le selon les critères suivants :

  1. Structurel :

    • Tous les champs du formulaire ont-ils des labels associés ?
    • Les boutons radio et cases à cocher sont-ils regroupés avec <fieldset> et <legend> ?
    • Les attributs ARIA sont-ils utilisés correctement ?
  2. Interactivité :

    • Peux-tu naviguer dans le formulaire au clavier ?
    • Peux-tu activer tous les contrôles au clavier ?
    • Le focus clavier est-il visible ?
  3. Retour d'information :

    • Les erreurs de validation sont-elles clairement communiquées ?
    • Les messages d'erreur sont-ils reliés aux champs via aria-describedby ?
    • Le statut de soumission du formulaire est-il clairement communiqué ?
  4. Visuel :

    • Le contraste texte/fond est-il suffisant (minimum 4,5:1) ?
    • Le formulaire est-il utilisable sur diverses tailles d'écran ?
    • Les indicateurs visuels (par ex. les couleurs) sont-ils complétés par d'autres indices ?

Résumé

Créer des formulaires utilisables et accessibles est un aspect clé des applications web modernes. Des formulaires bien conçus contribuent à augmenter la conversion, à améliorer l'expérience utilisateur et à rendre tes services accessibles à un public plus large.

Principaux principes à retenir :

  1. Structure claire - organisation logique des champs, labels clairs, regroupement des champs liés
  2. Accessibilité au clavier - support clavier complet, focus visible, ordre de tabulation logique
  3. ARIA correct - utilisation des attributs ARIA pour améliorer l'expérience des utilisateurs de lecteurs d'écran
  4. Communication efficace - labels clairs, textes d'aide, messages d'erreur précis
  5. Validation - retour immédiat, instructions claires, validation côté client et serveur
  6. Tests - à la fois automatisés et manuels, avec divers dispositifs d'assistance

En construisant des formulaires selon ces principes, tu peux créer une expérience accessible et conviviale qui servira tous les visiteurs de ton application.

Code de cette leçon : App.tsx
1import React, { useState, useRef, useEffect } from 'react';
2
3// Accessibilite des formulaires - ARIA, labels, gestion du focus
4
5interface A11yIssue {
6  id: string;
7  label: string;
8  bad: string;
9  good: string;
10  description: string;
11}
12
13const a11yIssues: A11yIssue[] = [
14  {
15    id: 'labels',
16    label: 'Label + htmlFor',
17    bad: '<input placeholder="Email" />',
18    good: '<label htmlFor="email">Email</label>\n<input id="email" />',
19    description: 'Chaque champ doit avoir un label associe. Les lecteurs d\'ecran lisent le label.',
20  },
21  {
22    id: 'aria',
23    label: 'aria-describedby',
24    bad: '<input />\n<span>Min. 8 caracteres</span>',
25    good: '<input aria-describedby="hint" />\n<span id="hint">Min. 8 caracteres</span>',
26    description: 'aria-describedby relie le champ a la description d\'erreur ou a une indication.',
27  },
28  {
29    id: 'error',
30    label: 'aria-invalid + role="alert"',
31    bad: '<input style={{border:"red"}} />\n<span>Erreur !</span>',
32    good: '<input aria-invalid="true" />\n<span role="alert">Erreur !</span>',
33    description: 'aria-invalid et role="alert" informent les lecteurs d\'ecran des erreurs.',
34  },
35  {
36    id: 'focus',
37    label: 'Focus management',
38    bad: '// Aucune gestion du focus\n// L\'utilisateur ne sait pas ou est l\'erreur',
39    good: '// Apres erreur - focus sur le premier input invalide\nfirstErrorRef.current?.focus()',
40    description: 'Apres une erreur de validation, deplace le focus sur le premier element invalide.',
41  },
42];
43
44export default function AccessibilityDemo() {
45  const [selected, setSelected] = useState<A11yIssue>(a11yIssues[0]);
46  const [showDemo, setShowDemo] = useState(false);
47  const [name, setName] = useState('');
48  const [email, setEmail] = useState('');
49  const [errors, setErrors] = useState<Record<string, string>>({});
50  const nameRef = useRef<HTMLInputElement>(null);
51
52  const handleSubmit = (e: React.FormEvent) => {
53    e.preventDefault();
54    const errs: Record<string, string> = {};
55    if (!name) errs.name = 'Le nom est requis';
56    if (!email) errs.email = 'L\'email est requis';
57    else if (!/\S+@\S+\.\S+/.test(email)) errs.email = 'Email invalide';
58    setErrors(errs);
59
60    // Gestion du focus - deplace vers le premier input invalide
61    if (Object.keys(errs).length > 0 && errs.name) {
62      nameRef.current?.focus();
63    }
64  };
65
66  const inputStyle: React.CSSProperties = {
67    width: '100%', padding: '10px', background: '#0d1117',
68    border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
69    fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
70  };
71
72  return (
73    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
74      <h1 style={{ color: '#64ffda' }}>Accessibilite des formulaires (a11y)</h1>
75
76      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
77        {a11yIssues.map(issue => (
78          <button key={issue.id} onClick={() => setSelected(issue)} style={{
79            background: selected.id === issue.id ? '#1565c0' : '#1a2744',
80            color: '#fff', border: `1px solid ${selected.id === issue.id ? '#64ffda' : '#333'}`,
81            borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
82          }}>
83            {issue.label}
84          </button>
85        ))}
86      </div>
87
88      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
89        <div style={{ background: '#3e1010', borderRadius: 8, padding: 12, border: '1px solid #f44336' }}>
90          <div style={{ color: '#f44336', fontSize: 12, marginBottom: 8 }}>Mauvais (inaccessible) :</div>
91          <pre style={{ color: '#ffcdd2', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{selected.bad}</pre>
92        </div>
93        <div style={{ background: '#0d2818', borderRadius: 8, padding: 12, border: '1px solid #4caf50' }}>
94          <div style={{ color: '#4caf50', fontSize: 12, marginBottom: 8 }}>Bon (accessible) :</div>
95          <pre style={{ color: '#c8e6c9', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{selected.good}</pre>
96        </div>
97      </div>
98      <p style={{ color: '#aaa', fontSize: 13, marginBottom: 16 }}>{selected.description}</p>
99
100      <button onClick={() => setShowDemo(!showDemo)} style={{
101        background: '#9c27b0', color: '#fff', border: 'none', borderRadius: 6,
102        padding: '8px 16px', cursor: 'pointer', fontFamily: 'monospace', marginBottom: 16
103      }}>
104        {showDemo ? 'Masquer' : 'Afficher'} le formulaire demo accessible
105      </button>
106
107      {showDemo && (
108        <form onSubmit={handleSubmit} style={{ maxWidth: 400 }} aria-label="Formulaire d'inscription">
109          <div style={{ marginBottom: 12 }}>
110            <label htmlFor="a11y-name" style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
111              Nom *
112            </label>
113            <input
114              ref={nameRef} id="a11y-name" value={name}
115              onChange={e => setName(e.target.value)}
116              aria-invalid={!!errors.name} aria-describedby={errors.name ? 'name-error' : undefined}
117              style={{ ...inputStyle, borderColor: errors.name ? '#f44336' : '#333' }}
118            />
119            {errors.name && <div id="name-error" role="alert" style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{errors.name}</div>}
120          </div>
121
122          <div style={{ marginBottom: 12 }}>
123            <label htmlFor="a11y-email" style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
124              Email *
125            </label>
126            <input
127              id="a11y-email" type="email" value={email}
128              onChange={e => setEmail(e.target.value)}
129              aria-invalid={!!errors.email} aria-describedby="email-hint"
130              style={{ ...inputStyle, borderColor: errors.email ? '#f44336' : '#333' }}
131            />
132            <div id="email-hint" style={{ color: '#888', fontSize: 11, marginTop: 4 }}>
133              {errors.email ? <span role="alert" style={{ color: '#f44336' }}>{errors.email}</span> : 'Format : nom@domaine.fr'}
134            </div>
135          </div>
136
137          <button type="submit" style={{
138            background: '#4caf50', color: '#fff', border: 'none', borderRadius: 6,
139            padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
140          }}>
141            Envoyer (avec focus management)
142          </button>
143        </form>
144      )}
145    </div>
146  );
147}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel est le modèle de gestion des erreurs recommandé dans les Server Actions de Next.js ?

  2. 2. Le hook useActionState (anciennement useFormState) dans les Server Actions sert à :

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un hook personnalisé réutilisable useForm avec validation, traitement de la soumission et gestion des erreurs

  • Éditeur de code

    Crée un formulaire de téléversement de photo avec aperçu, drag&drop, barre de progression et validation du type/de la taille

  • Éditeur de code

    Crée un formulaire où des champs apparaissent de façon conditionnelle selon les choix précédents (par ex. mode de paiement -> champs différents)

  • Tri vertical

    Ordonne les éléments dans le bon ordre : Configurer le provider → Créer la connexion → Protéger les routes → Gérer les sessions

  • Clique dans l'ordre

    Clique sur les éléments dans l'ordre pour construire la syntaxe <form action={serverAction}>

  • Clique dans l'ordre

    Clique sur les éléments dans l'ordre pour construire la chaîne de validation Zod : z.string().email().min(5)

Articles utiles