Cours Next.js · Module 6: Formulaires et Server Actions
Utilisabilité et accessibilité des formulaires
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
- Conversion accrue - les formulaires faciles à utiliser ont des taux de conversion plus élevés
- Portée plus large - les formulaires accessibles touchent plus d'utilisateurs
- 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)
- Meilleur SEO - les pages accessibles se classent souvent mieux dans les résultats de recherche
Bénéfices pour les utilisateurs
- Meilleure efficacité - les utilisateurs peuvent remplir les formulaires plus vite et plus facilement
- Moins de frustration - les formulaires bien conçus réduisent les erreurs et la frustration
- Plus d'autonomie - les personnes en situation de handicap peuvent utiliser les formulaires par elles-mêmes
- 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'iddu 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 :
- Labels correctement associés aux champs - chaque label a un attribut
htmlForlié à l'iddu champ - Regroupement avec fieldset et legend - pour les boutons radio
- Attributs ARIA -
aria-required,aria-invalid,aria-describedby - Visibilité dynamique - le champ téléphone n'apparaît que lorsque c'est nécessaire
- Messages d'erreur avec role="alert" - pour les lecteurs d'écran
- 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 :
- Rôles ARIA -
aria-autocomplete="list",role="listbox",role="option" - aria-activedescendant - pour marquer la suggestion active
- aria-controls - relie le champ à la liste des suggestions
- 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 :
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 ?
Test avec des lecteurs d'écran :
- NVDA or JAWS (Windows)
- VoiceOver (macOS)
- TalkBack (Android)
- VoiceOver (iOS)
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 :
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 ?
Interactivité :
- Peux-tu naviguer dans le formulaire au clavier ?
- Peux-tu activer tous les contrôles au clavier ?
- Le focus clavier est-il visible ?
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é ?
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 :
- Structure claire - organisation logique des champs, labels clairs, regroupement des champs liés
- Accessibilité au clavier - support clavier complet, focus visible, ordre de tabulation logique
- ARIA correct - utilisation des attributs ARIA pour améliorer l'expérience des utilisateurs de lecteurs d'écran
- Communication efficace - labels clairs, textes d'aide, messages d'erreur précis
- Validation - retour immédiat, instructions claires, validation côté client et serveur
- 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. Quel est le modèle de gestion des erreurs recommandé dans les Server Actions de Next.js ?
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)