Rendre le formulaire de dépense accessible
Objectif : Associer chaque champ à une étiquette et relier ses erreurs aux contrôles concernés.
Un formulaire de dépense peut sembler évident à l’écran et rester difficile à utiliser si ses champs ne sont pas identifiés correctement. Une étiquette visible doit désigner chaque contrôle, et une erreur doit être rattachée au champ auquel elle correspond.
La structure générale du tableau de bord, avec ses régions et ses titres, est déjà définie dans la leçon précédente. Vous allez maintenant appliquer les mêmes principes aux champs de saisie, sans remplacer les contrôles natifs par des composants personnalisés.
Relier chaque étiquette à son champ
Dans React, l’attribut s’écrit htmlFor sur le label et correspond à l’id du champ. Cette relation permet notamment aux technologies d’assistance d’identifier le nom du contrôle ; elle rend aussi le texte du label cliquable. Donnez à chaque champ un identifiant unique et gardez le texte du label visible : un texte indicatif placé dans un placeholder ne le remplace pas.
Le composant ci-dessous constitue le fichier complet src/components/FormulaireDepense.tsx pour l’étape de saisie. Il reçoit les catégories et une fonction qui transmet les valeurs au parent, comme dans le flux établi précédemment. Les valeurs du formulaire restent locales ; le composant ne crée pas une seconde copie de la liste des dépenses.
import { useState, type FormEvent } from "react";
type Categorie = {
id: string;
nom: string;
};
type ValeursDepense = {
description: string;
montant: number;
categorie: string;
date: string;
};
type FormulaireDepenseProps = {
categories: Categorie[];
onAjouter: (depense: ValeursDepense) => void;
};
type Erreurs = Partial<Record<keyof ValeursDepense, string>>;
export default function FormulaireDepense({
categories,
onAjouter,
}: FormulaireDepenseProps) {
const [erreurs, setErreurs] = useState<Erreurs>({});
function ajouterDepense(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const donnees = new FormData(event.currentTarget);
const description = String(donnees.get("description") ?? "").trim();
const montantTexte = String(donnees.get("montant") ?? "");
const montant = Number(montantTexte);
const categorie = String(donnees.get("categorie") ?? "");
const date = String(donnees.get("date") ?? "");
const nouvellesErreurs: Erreurs = {};
if (!description) nouvellesErreurs.description = "Saisissez une description.";
if (!montantTexte || !Number.isFinite(montant) || montant <= 0) {
nouvellesErreurs.montant = "Saisissez un montant supérieur à zéro.";
}
if (!categorie) nouvellesErreurs.categorie = "Choisissez une catégorie.";
if (!date) nouvellesErreurs.date = "Choisissez une date.";
setErreurs(nouvellesErreurs);
if (Object.keys(nouvellesErreurs).length > 0) return;
onAjouter({ description, montant, categorie, date });
event.currentTarget.reset();
}
return (
<section id="saisie" className="atelier-depense" aria-labelledby="titre-saisie">
<h2 id="titre-saisie">Ajouter une dépense</h2>
<form className="atelier-depense__formulaire" onSubmit={ajouterDepense} noValidate>
<label htmlFor="description">Description</label>
<input id="description" name="description" required aria-invalid={Boolean(erreurs.description)} aria-describedby={erreurs.description ? "erreur-description" : undefined} />
{erreurs.description && <p id="erreur-description">{erreurs.description}</p>}
<label htmlFor="montant">Montant</label>
<input id="montant" name="montant" type="number" min="0.01" step="0.01" required aria-invalid={Boolean(erreurs.montant)} aria-describedby={erreurs.montant ? "erreur-montant" : undefined} />
{erreurs.montant && <p id="erreur-montant">{erreurs.montant}</p>}
<label htmlFor="categorie">Catégorie</label>
<select id="categorie" name="categorie" required defaultValue="" aria-invalid={Boolean(erreurs.categorie)} aria-describedby={erreurs.categorie ? "erreur-categorie" : undefined}>
<option value="" disabled>Choisissez une catégorie</option>
{categories.map((categorie) => <option key={categorie.id} value={categorie.id}>{categorie.nom}</option>)}
</select>
{erreurs.categorie && <p id="erreur-categorie">{erreurs.categorie}</p>}
<label htmlFor="date">Date</label>
<input id="date" name="date" type="date" required aria-invalid={Boolean(erreurs.date)} aria-describedby={erreurs.date ? "erreur-date" : undefined} />
{erreurs.date && <p id="erreur-date">{erreurs.date}</p>}
<button type="submit">Ajouter la dépense</button>
</form>
</section>
);
}Associer les messages d’erreur
Quand une erreur existe, le champ reçoit aria-invalid="true" et aria-describedby pointe vers l’identifiant du message correspondant. Lorsque le champ est valide, le lien vers l’erreur disparaît. Cette association évite de rattacher toutes les erreurs à tous les contrôles et aide à comprendre ce qu’il faut corriger.
La validation est déclenchée à l’envoi : les erreurs sont calculées, puis le composant transmet les données au parent uniquement si elles sont absentes. Les attributs required, min et step documentent aussi les contraintes dans le HTML. Ici, noValidate laisse le composant présenter ses propres messages au lieu d’afficher les bulles natives du navigateur.
Vérifiez le formulaire en laissant un champ vide et en envoyant les autres valeurs. Le champ concerné doit être marqué invalide, son message doit apparaître dans la page et son identifiant doit être la valeur de aria-describedby. Vérifiez également qu’un envoi valide appelle onAjouter une seule fois et réinitialise les champs.
À retenir
- Associez chaque label à l’identifiant unique de son contrôle avec htmlFor et id.
- Ne remplacez pas une étiquette visible par un placeholder.
- Rattachez chaque erreur à son champ avec aria-describedby.
- Utilisez aria-invalid uniquement quand le champ comporte une erreur.
- Conservez les contraintes natives et vérifiez le comportement avec des valeurs invalides et valides.
La prochaine leçon portera sur l’utilisation complète du tableau de bord au clavier, notamment l’ordre de parcours et la visibilité du focus.