Chapitre 1022 minDébutant
Coder un formulaire accessible en HTML et React
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- partir des éléments natifs plutôt que des attributs ARIA ;
- relier le libellé, l'aide et le message d'erreur à leur champ en HTML ;
- écrire un composant React de champ réutilisable et accessible ;
- valider au bon moment et annoncer l'envoi et les erreurs aux lecteurs d'écran.
3 exercices corrigés
Un formulaire accessible est un formulaire dont chaque champ a un libellé relié, une aide et un message d'erreur reliés eux aussi, annoncés aux lecteurs d'écran, et qui se remplit entièrement au clavier : c'est la traduction dans le code des décisions ergonomiques de la maquette. Ce chapitre montre ces techniques en HTML, puis les applique au formulaire de réservation de Prêt'Matos dans un composant React : validation au bon moment, récapitulatif des erreurs, protection contre le double envoi et annonce de l'envoi.
Le principe : partir des composants natifs
Chaque plateforme fournit des composants qui sont déjà accessibles au clavier et aux lecteurs d'écran : en HTML, <button>, <a href>, <input>, <select>, <label> ; en React Native, Pressable, TextInput ; en WinForms, Button, TextBox, ComboBox (chapitre 11). Ils savent recevoir le focus, réagir à la touche Entrée ou Espace, et dire au lecteur d'écran ce qu'ils sont. Un <div> cliquable, lui, ne sait rien faire de tout cela : il faudrait tout recoder, et on oublie toujours quelque chose.
En HTML : relier le champ, l'aide et l'erreur
Le formulaire du chapitre 6 relie déjà chaque libellé à son champ (for et id) et chaque aide (aria-describedby). Quand une erreur apparaît, trois choses changent sur le champ concerné :
<label for="fin">Date de retour</label>
<p id="fin-aide">15 jours au maximum, premier et dernier jour compris.</p>
<input type="date" id="fin" name="fin" required
aria-invalid="true" aria-describedby="fin-aide fin-erreur">
<p id="fin-erreur" class="champ-erreur">
<span aria-hidden="true">⚠</span>
Choisissez une date de retour au plus tard le 26/03/2027 : un prêt dure 15 jours au maximum.
</p>
aria-invalid="true"fait annoncer le champ comme invalide ;aria-describedbycite l'aide et l'erreur : le lecteur d'écran lit les deux quand le champ reçoit le focus ;- le message est un texte ; l'icône est décorative (
aria-hidden="true"), et la couleur rouge s'ajoute, sans porter seule l'information.
Le récapitulatif placé en haut du formulaire après un envoi refusé reçoit le focus, ce qui le fait lire ; ses liens mènent aux champs :
<div class="recapitulatif" tabindex="-1">
<h2>2 erreurs empêchent l'envoi</h2>
<ul>
<li><a href="#debut">Indiquez la date de retrait.</a></li>
<li><a href="#fin">Choisissez une date de retour au plus tard le 26/03/2027 : un prêt dure 15 jours au maximum.</a></li>
</ul>
</div>
tabindex="-1" permet de donner le focus au bloc par script, sans l'ajouter à l'ordre de tabulation. Le cours HTML et CSS détaille ces attributs dans les messages d'erreur accessibles.
En React : le formulaire de réservation de Prêt'Matos
Le front de Prêt'Matos est écrit en React. L'équipe a commencé par un composant Champ réutilisable, qui applique une fois pour toutes les règles précédentes : chaque formulaire de l'application s'en sert, ce qui garantit aussi la cohérence (critère d'homogénéité).
// src/composants/Champ.tsx
import { useId, type InputHTMLAttributes } from 'react';
type ChampProps = InputHTMLAttributes<HTMLInputElement> & {
libelle: string;
aide?: string;
erreur?: string;
};
export function Champ({ libelle, aide, erreur, id, ...autres }: ChampProps) {
const idAuto = useId();
const idChamp = id ?? idAuto;
const idAide = `${idChamp}-aide`;
const idErreur = `${idChamp}-erreur`;
// L'aide, puis l'erreur, sont lues par le lecteur d'écran quand le champ reçoit le focus.
const decritPar = [aide && idAide, erreur && idErreur].filter(Boolean).join(' ');
return (
<div className="champ">
<label htmlFor={idChamp}>{libelle}</label>
{aide && <p id={idAide} className="champ-aide">{aide}</p>}
<input
{...autres}
id={idChamp}
aria-invalid={erreur ? true : undefined}
aria-describedby={decritPar || undefined}
/>
{erreur && (
<p id={idErreur} className="champ-erreur">
<span aria-hidden="true">⚠ </span>
{erreur}
</p>
)}
</div>
);
}
useId fabrique un identifiant unique quand on n'en fournit pas : deux champs « Motif » sur la même page ne se marcheront pas dessus. Les props transmises par ...autres (type, value, onChange, required…) arrivent sur le vrai <input>, avant les attributs calculés, qui ne peuvent donc pas être écrasés par erreur.
Les règles de validation vivent dans une fonction à part, sans React : elle se teste seule (tests unitaires avec Vitest) et le serveur applique les mêmes règles.
// src/regles/validerReservation.ts
export type Saisie = { debut: string; fin: string; motif: string };
export type Erreurs = { debut?: string; fin?: string };
const DUREE_MAX = 15; // jours, premier et dernier compris
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
// Les dates circulent au format AAAA-MM-JJ, celui de <input type="date">.
function numeroDeJour(dateIso: string): number {
const [annee, mois, jour] = dateIso.split('-').map(Number);
return Date.UTC(annee, mois - 1, jour) / MS_PAR_JOUR;
}
function ajouterJours(dateIso: string, jours: number): string {
return new Date((numeroDeJour(dateIso) + jours) * MS_PAR_JOUR).toISOString().slice(0, 10);
}
// « 2027-03-26 » devient « 26/03/2027 », le format des utilisateurs.
export function enFrancais(dateIso: string): string {
const [annee, mois, jour] = dateIso.split('-');
return `${jour}/${mois}/${annee}`;
}
export function validerReservation(s: Saisie, aujourdhui: string): Erreurs {
const erreurs: Erreurs = {};
if (s.debut === '') {
erreurs.debut = 'Indiquez la date de retrait.';
} else if (s.debut < aujourdhui) {
erreurs.debut = `Choisissez une date de retrait à partir du ${enFrancais(aujourdhui)}.`;
}
if (s.fin === '') {
erreurs.fin = 'Indiquez la date de retour.';
} else if (s.debut !== '' && s.fin < s.debut) {
erreurs.fin = 'Choisissez une date de retour égale ou postérieure à la date de retrait.';
} else if (s.debut !== '' && numeroDeJour(s.fin) - numeroDeJour(s.debut) + 1 > DUREE_MAX) {
const dateMax = enFrancais(ajouterJours(s.debut, DUREE_MAX - 1));
erreurs.fin = `Choisissez une date de retour au plus tard le ${dateMax} : un prêt dure 15 jours au maximum.`;
}
return erreurs;
}
Avec aujourdhui = '2027-03-08', un retrait le 2027-03-12 et un retour le 2027-03-27 donnent l'erreur « Choisissez une date de retour au plus tard le 26/03/2027 : un prêt dure 15 jours au maximum. » ; un retour le 2027-03-26 est accepté. Les dates au format AAAA-MM-JJ se comparent directement comme des chaînes, puisque l'année vient en premier.
Le formulaire assemble le tout et applique le calendrier de validation du chapitre 6 : à la sortie d'un champ rempli, à l'envoi, et au fil de la frappe seulement pour effacer une erreur corrigée.
// src/pages/Reserver.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Champ } from '../composants/Champ';
import { validerReservation, type Erreurs, type Saisie } from '../regles/validerReservation';
type Resultat = { ok: true } | { ok: false; message: string };
type Props = {
materiel: string;
aujourdhui: string; // AAAA-MM-JJ
envoyer: (saisie: Saisie) => Promise<Resultat>; // en cas de succès, le parent affiche la confirmation
};
export function Reserver({ materiel, aujourdhui, envoyer }: Props) {
const [saisie, setSaisie] = useState<Saisie>({ debut: '', fin: '', motif: '' });
const [erreurs, setErreurs] = useState<Erreurs>({});
const [envoiEnCours, setEnvoiEnCours] = useState(false);
const [echec, setEchec] = useState('');
const [tentativesRefusees, setTentativesRefusees] = useState(0);
const recapitulatif = useRef<HTMLDivElement>(null);
// Après un envoi refusé, le focus va sur le récapitulatif : le lecteur d'écran le lit.
useEffect(() => {
if (tentativesRefusees > 0) recapitulatif.current?.focus();
}, [tentativesRefusees]);
function modifier(champ: keyof Saisie, valeur: string) {
const nouvelle = { ...saisie, [champ]: valeur };
setSaisie(nouvelle);
// Une erreur déjà affichée disparaît dès que la saisie est corrigée.
if (champ !== 'motif' && erreurs[champ]) {
setErreurs({ ...erreurs, [champ]: validerReservation(nouvelle, aujourdhui)[champ] });
}
}
// À la sortie d'un champ rempli, on vérifie ce champ seulement.
function verifier(champ: 'debut' | 'fin') {
if (saisie[champ] === '') return; // un champ vide n'est signalé qu'à l'envoi
setErreurs({ ...erreurs, [champ]: validerReservation(saisie, aujourdhui)[champ] });
}
async function soumettre(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
if (envoiEnCours) return; // pas de double envoi
const nouvelles = validerReservation(saisie, aujourdhui);
setErreurs(nouvelles);
if (Object.keys(nouvelles).length > 0) {
setTentativesRefusees((n) => n + 1);
return;
}
setEchec('');
setEnvoiEnCours(true);
const resultat = await envoyer(saisie);
setEnvoiEnCours(false);
if (!resultat.ok) setEchec(resultat.message); // la saisie est conservée
}
const liste = (['debut', 'fin'] as const).filter((champ) => erreurs[champ]);
return (
<main>
<h1>Réserver {materiel}</h1>
<p>Tous les champs sont obligatoires, sauf le motif.</p>
{tentativesRefusees > 0 && liste.length > 0 && (
<div ref={recapitulatif} tabIndex={-1} className="recapitulatif">
<h2>{liste.length === 1 ? "1 erreur empêche l'envoi" : `${liste.length} erreurs empêchent l'envoi`}</h2>
<ul>
{liste.map((champ) => (
<li key={champ}>
<a
href={`#${champ}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(champ)?.focus();
}}
>
{erreurs[champ]}
</a>
</li>
))}
</ul>
</div>
)}
{echec && <p role="alert" className="alerte">{echec}</p>}
<form noValidate onSubmit={soumettre}>
<fieldset>
<legend>Période du prêt</legend>
<Champ
id="debut"
type="date"
libelle="Date de retrait"
aide="Au stock, bâtiment B, de 8 h à 17 h."
required
min={aujourdhui}
value={saisie.debut}
erreur={erreurs.debut}
onChange={(e) => modifier('debut', e.target.value)}
onBlur={() => verifier('debut')}
/>
<Champ
id="fin"
type="date"
libelle="Date de retour"
aide="15 jours au maximum, premier et dernier jour compris."
required
min={saisie.debut || aujourdhui}
value={saisie.fin}
erreur={erreurs.fin}
onChange={(e) => modifier('fin', e.target.value)}
onBlur={() => verifier('fin')}
/>
</fieldset>
<Champ
id="motif"
libelle="Motif (facultatif)"
aide="Par exemple : présentation chez un client. 100 caractères au maximum."
maxLength={100}
value={saisie.motif}
onChange={(e) => modifier('motif', e.target.value)}
/>
<button type="submit" aria-disabled={envoiEnCours}>
{envoiEnCours ? 'Envoi en cours…' : 'Confirmer la réservation'}
</button>
</form>
{/* Présente dès le départ, vide : le lecteur d'écran annonce ce qui y apparaît. */}
<p role="status" className="statut">
{envoiEnCours ? 'Envoi de la réservation…' : ''}
</p>
</main>
);
}
| Décision de la maquette | Où elle se trouve dans le code |
|---|---|
| Libellé, aide et erreur reliés au champ | Composant Champ : htmlFor, aria-describedby, aria-invalid |
| Pas d'erreur pendant la frappe, sauf pour effacer une erreur corrigée | modifier ne revalide que si une erreur est déjà affichée |
| Vérification à la sortie d'un champ rempli | onBlur appelle verifier, qui ignore un champ vide |
| Récapitulatif affiché seulement après un envoi refusé, et qui reçoit alors le focus | Condition tentativesRefusees > 0, useEffect sur tentativesRefusees, tabIndex={-1} |
| Liens du récapitulatif qui mènent au champ | onClick qui place le focus sur le champ |
| Messages de la liste validée par la Product Owner | validerReservation, testée à part |
| Pas de double envoi, sans perdre le focus | aria-disabled et le test if (envoiEnCours) return |
| Envoi annoncé aux lecteurs d'écran | Région role="status" présente dès le départ |
| Échec du serveur annoncé, saisie conservée | role="alert", l'état saisie n'est pas réinitialisé |
Exercices
Exercice 1 sur 3 Corriger un champ HTML Réussi À revoir
Ce champ a été écrit à la va-vite. Relève ses défauts et réécris-le.
<input type="text" placeholder="Téléphone" style="border-color: red">
<span style="color: red">Invalide</span>
Voir la réponseMasquer la réponse de l'exercice 1
Les défauts : pas de libellé (le placeholder disparaît à la frappe et n'est pas fiable pour les lecteurs d'écran) ; erreur signalée par la couleur ; message vague ; message non relié au champ ; type inadapté (pas de clavier téléphonique sur mobile) ; pas d'aide sur le format ; pas d'autocomplete.
<label for="tel">Téléphone</label>
<p id="tel-aide">10 chiffres, par exemple 06 12 34 56 78.</p>
<input type="tel" id="tel" name="tel" autocomplete="tel"
aria-invalid="true" aria-describedby="tel-aide tel-erreur">
<p id="tel-erreur" class="champ-erreur">
<span aria-hidden="true">⚠</span>
Saisissez un numéro à 10 chiffres, par exemple 06 12 34 56 78.
</p>
Le style d'erreur (bordure épaisse, couleur contrastée) passe par une classe CSS, en plus du texte.
Tu avais trouvé ?
Exercice 2 sur 3 Un composant React à revoir Réussi À revoir
Un développeur propose ce composant de bouton d'annulation pour « Mes réservations ». Que lui reproches-tu ?
<div className="icone-corbeille" onClick={() => annuler(reservation.id)} />
Voir la réponseMasquer la réponse de l'exercice 2
- Ce n'est pas un bouton : un
<div>ne reçoit pas le focus et ne réagit ni à Entrée ni à Espace. Un utilisateur au clavier ne peut pas annuler. - Il n'a pas de nom : un lecteur d'écran n'annonce rien, ou « groupe ».
- Le nom doit dire quelle réservation : toutes les corbeilles de la liste se ressemblent.
- La cible est sans doute trop petite si elle n'a que la taille de l'icône.
<button
type="button"
className="bouton-icone"
aria-label={`Annuler la réservation de ${reservation.materiel} du ${reservation.debutFr}`}
onClick={() => demanderConfirmation(reservation)}
>
<IconeCorbeille aria-hidden="true" />
</button>
Le clic ouvre la confirmation ECR6 au lieu d'annuler directement (chapitre 9), et la classe bouton-icone garantit une cible d'au moins 44 × 44 pixels sur écran tactile.
Tu avais trouvé ?
Exercice 3 sur 3 Valider au bon moment Réussi À revoir
Dans le composant Reserver du chapitre, avec aujourdhui = '2027-03-08', que se passe-t-il dans chacun de ces cas ? Réponds en lisant le code. Chaque cas part d'un formulaire vide, sauf le cas 3, qui suit le cas 2.
- Julie clique dans « Date de retrait », puis passe au champ suivant sans rien saisir.
- Elle saisit un retrait au 12/03 et un retour au 30/03, puis quitte le champ « Date de retour ».
- Elle corrige la date de retour au 26/03.
- Elle saisit un retour au 26/03, laisse la date de retrait vide et clique sur « Confirmer la réservation ».
Voir la réponseMasquer la réponse de l'exercice 3
- Rien ne s'affiche :
verifierignore un champ vide ; il sera signalé à l'envoi. verifier('fin')affiche sous le champ : « Choisissez une date de retour au plus tard le 26/03/2027 : un prêt dure 15 jours au maximum. » Le récapitulatif n'apparaît pas : aucun envoi n'a encore été refusé.- Comme une erreur est déjà affichée sur ce champ,
modifierrevalide à chaque changement : la date est valide, l'erreur disparaît aussitôt. soumettrevalide tout le formulaire : l'erreur « Indiquez la date de retrait. » apparaît sous le champ et dans le récapitulatif, qui reçoit le focus grâce auuseEffect. Rien n'est envoyé.
Tu avais trouvé ?