Chapitre 827 minDébutant
Événements et formulaires : addEventListener, validation et regex
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- réagir à un événement avec
addEventListeneret lire l'objetevent; - expliquer la propagation et utiliser la délégation d'événements ;
- lire un formulaire avec
FormDataet empêcher son envoi ; - valider une saisie avec une expression régulière ;
- afficher les erreurs à côté des champs, de façon accessible.
5 exercices corrigés1 schéma
Un événement est un signal que le navigateur émet quand quelque chose se passe : un clic, une touche, une saisie, l'envoi d'un formulaire. addEventListener associe à un élément une fonction qui s'exécutera à chaque fois que l'événement se produit. Ce chapitre montre comment réagir aux événements, comment un clic se propage dans l'arbre DOM, comment lire et valider un formulaire, et comment écrire les expressions régulières qui vérifient un format.
addEventListener : réagir à un clic
<button type="button" id="actualiser">Actualiser</button>
const bouton = document.querySelector('#actualiser');
bouton.addEventListener('click', () => {
console.log('Clic sur Actualiser');
});
addEventListener prend le nom de l'événement ('click', sans le préfixe on) et la fonction à appeler, l'écouteur (listener). La fonction n'est pas appelée tout de suite : elle est rangée, et le navigateur l'appellera à chaque clic. Pour passer une fonction déjà écrite, donne son nom sans parenthèses : bouton.addEventListener('click', actualiser).
Un même élément peut avoir plusieurs écouteurs pour le même événement. Pour en retirer un, removeEventListener doit recevoir la même fonction : une fonction anonyme écrite directement dans l'appel ne peut donc pas être retirée. L'option { once: true } retire l'écouteur après son premier déclenchement.
function actualiser() {
console.log('Actualisation');
}
bouton.addEventListener('click', actualiser);
bouton.removeEventListener('click', actualiser);
bouton.addEventListener('click', () => console.log('Une seule fois'), { once: true });
Les événements courants
| Événement | Se produit quand… | Élément typique |
|---|---|---|
click | On clique, ou on appuie sur Entrée ou Espace sur un bouton | button, lien |
input | La valeur d'un champ change, à chaque frappe | input, textarea |
change | La valeur est validée : case cochée, option choisie, champ quitté après modification | select, checkbox |
submit | Le formulaire est envoyé (bouton d'envoi ou Entrée dans un champ) | form |
keydown | Une touche est enfoncée | document, champ |
focus / blur | Un élément reçoit ou perd le focus (ne se propagent pas) | champ, bouton |
focusin / focusout | Pareil, mais se propagent aux parents | formulaire |
pointerdown, pointerup | Souris, doigt ou stylet appuie, se relève | zone de dessin |
DOMContentLoaded | Le HTML est entièrement lu | document |
load | La page et toutes ses images sont chargées | window |
Avec defer ou type="module", ton script s'exécute déjà après la lecture du HTML : tu n'as pas besoin d'attendre DOMContentLoaded.
L'objet event
L'écouteur reçoit en paramètre un objet événement qui décrit ce qui s'est passé :
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
console.log('Échap : on ferme le panneau');
}
});
champRecherche.addEventListener('input', (event) => {
console.log(event.target.value); // le texte actuel du champ
});
| Propriété ou méthode | Rôle |
|---|---|
event.target | L'élément où l'événement s'est produit (le bouton cliqué, le champ modifié) |
event.currentTarget | L'élément qui porte l'écouteur en cours d'exécution |
event.key | La touche, pour les événements clavier : 'Enter', 'Escape', 'a', 'ArrowDown' |
event.preventDefault() | Annule l'action par défaut du navigateur (envoi du formulaire, suivi du lien) |
event.stopPropagation() | Arrête la propagation vers les parents (à éviter sauf besoin précis) |
Propagation et délégation d'événements
Un clic sur un bouton ne concerne pas que le bouton. L'événement traverse l'arbre DOM en trois phases : il descend du document jusqu'à l'élément cliqué (capture), atteint la cible, puis remonte jusqu'au document (bouillonnement, bubbling). Par défaut, les écouteurs réagissent pendant la remontée : un écouteur posé sur un parent reçoit donc les clics de tous ses descendants.
Gestock affiche deux boutons, + et −, sur chaque ligne du tableau, et le tableau est reconstruit à chaque modification. Poser un écouteur sur chaque bouton obligerait à les reposer à chaque affichage. La délégation d'événements pose un seul écouteur sur le parent qui ne change jamais, le tbody, et regarde dans event.target quel bouton a été cliqué :
tbody.addEventListener('click', (event) => {
const bouton = event.target.closest('button[data-action]');
if (!bouton) return; // clic ailleurs dans le tableau : on ignore
const { action, ref } = bouton.dataset; // 'ajouter' ou 'retirer', et la référence
console.log(action, ref);
});
Pourquoi closest plutôt que event.target directement ? Si le bouton contient une icône ou un <span>, event.target est cet élément intérieur. closest('button[data-action]') remonte jusqu'au bouton, quel que soit l'endroit précis du clic.
Lire un formulaire : submit et FormData
Écoute l'événement submit du formulaire, pas le click du bouton : submit se déclenche aussi quand l'utilisateur appuie sur Entrée dans un champ. Par défaut, le navigateur envoie le formulaire et recharge la page ; event.preventDefault() l'en empêche, pour que ton script traite la saisie.
<form id="form-piece" novalidate>
<label for="ref">Référence</label>
<input id="ref" name="ref" required>
<label for="quantite">Quantité</label>
<input id="quantite" name="quantite" inputmode="numeric" required>
<button type="submit">Ajouter</button>
</form>
const form = document.querySelector('#form-piece');
form.addEventListener('submit', (event) => {
event.preventDefault();
const saisie = Object.fromEntries(new FormData(form));
console.log(saisie); // { ref: 'ssd-1t', quantite: '4' }
});
new FormData(form) lit tous les champs qui ont un attribut name, et Object.fromEntries en fait un objet { name: valeur }. Trois choses à savoir :
- Toutes les valeurs sont des chaînes :
'4', pas4. Convertis-les (chapitre 2). - Un champ sans attribut
nameest ignoré. - Une case à cocher non cochée n'apparaît pas ; cochée, elle vaut
'on'(ou son attributvalue). Pour un booléen, lis plutôtform.elements.nomDeLaCase.checked.
Après un enregistrement réussi, form.reset() vide les champs.
Valider un formulaire : HTML d'abord, JavaScript ensuite
La validation se fait en trois couches, qui ne se remplacent pas :
| Couche | Outil | Rôle |
|---|---|---|
| HTML | required, type="email", min, max, maxlength, pattern | Contrôle immédiat, sans code, avec des messages traduits par le navigateur (formulaires HTML) |
| JavaScript | Tes fonctions de validation | Règles métier (référence déjà utilisée), messages précis et cohérents, affichage soigné |
| Serveur | Le code de l'API | La seule validation qui protège réellement : le client peut toujours être contourné |
L'attribut novalidate sur le form désactive les bulles d'erreur natives du navigateur pour que ton script affiche ses propres messages, tout en laissant les attributs HTML lisibles par ton code et par les lecteurs d'écran.
La bonne pratique : écrire les règles dans une fonction pure qui reçoit la saisie et renvoie les erreurs, sans toucher au DOM. Elle se lit facilement et se teste en une ligne (chapitre 14). Voici celle de Gestock (fichier stock.js) :
export const REF_VALIDE = /^[A-Z]{3}-\d{2,3}$/;
// Transforme « 42,9 » ou « 42.9 » en nombre ; NaN si la saisie n'est pas un nombre.
export function lireNombre(saisie) {
const texte = String(saisie).trim().replace(',', '.');
return texte === '' ? NaN : Number(texte);
}
// Renvoie un objet { champ: message } : vide si la saisie est valide.
export function validerPiece(saisie, pieces) {
const erreurs = {};
const ref = saisie.ref.trim().toUpperCase();
if (!REF_VALIDE.test(ref)) {
erreurs.ref = 'Format attendu : trois lettres, un tiret, 2 ou 3 chiffres (ex. SSD-512).';
} else if (pieces.some((p) => p.ref === ref)) {
erreurs.ref = `La référence ${ref} existe déjà.`;
}
const nom = saisie.nom.trim();
if (nom.length < 2 || nom.length > 60) {
erreurs.nom = 'Le nom doit faire entre 2 et 60 caractères.';
}
const quantite = lireNombre(saisie.quantite);
if (!Number.isInteger(quantite) || quantite < 0) {
erreurs.quantite = 'La quantité est un nombre entier positif ou nul.';
}
const seuil = lireNombre(saisie.seuil);
if (!Number.isInteger(seuil) || seuil < 0) {
erreurs.seuil = "Le seuil d'alerte est un nombre entier positif ou nul.";
}
const prix = lireNombre(saisie.prix);
if (!(prix > 0)) {
erreurs.prix = 'Le prix est un nombre strictement positif.';
}
return erreurs;
}
Trois détails comptent. lireNombre traite la chaîne vide à part, parce que Number('') vaut 0 et qu'une quantité vide passerait pour « zéro ». Number.isInteger rejette à la fois NaN et les décimaux (2.5). Et !(prix > 0) est vrai pour un prix nul, négatif ou NaN, car toute comparaison avec NaN est fausse : écrire prix <= 0 laisserait passer NaN.
Les messages disent comment corriger, pas seulement que c'est faux. « Format attendu : trois lettres, un tiret, 2 ou 3 chiffres (ex. SSD-512) » vaut mieux que « Référence invalide ».
Afficher les erreurs à côté des champs
Chaque champ a, juste en dessous, un paragraphe réservé à son message, relié au champ par aria-describedby : un lecteur d'écran lit le message quand le champ reçoit le focus.
<label for="ref">Référence</label>
<input id="ref" name="ref" required aria-describedby="erreur-ref">
<p id="erreur-ref" class="erreur"></p>
function afficherErreurs(form, erreurs) {
for (const champ of form.querySelectorAll('input, select')) {
const message = erreurs[champ.name] ?? '';
const zone = form.querySelector(`#erreur-${champ.name}`);
if (zone) zone.textContent = message;
champ.setAttribute('aria-invalid', message ? 'true' : 'false');
}
const premier = form.querySelector('[aria-invalid="true"]');
if (premier) premier.focus();
}
La fonction écrit le message de chaque champ (ou l'efface), marque les champs fautifs avec aria-invalid="true" (que le CSS peut aussi utiliser : input[aria-invalid="true"] { border-color: … }), puis place le curseur dans le premier champ en erreur. Tu retrouveras ces attributs au chapitre 13.
Les expressions régulières en JavaScript
Une expression régulière (regex) est un motif qui décrit la forme d'un texte : « trois lettres majuscules, un tiret, deux ou trois chiffres ». En JavaScript, elle s'écrit entre deux barres obliques, suivies d'options :
const REF_VALIDE = /^[A-Z]{3}-\d{2,3}$/;
REF_VALIDE.test('SSD-512'); // true
REF_VALIDE.test('PAT-01'); // true
REF_VALIDE.test('ssd-512'); // false : minuscules
REF_VALIDE.test('SSD512'); // false : tiret manquant
REF_VALIDE.test('SSDX-512'); // false : quatre lettres
REF_VALIDE.test('RAM-1234'); // false : quatre chiffres
REF_VALIDE.test(' SSD-512'); // false : espace au début
Lis le motif de gauche à droite : ^ début du texte, [A-Z]{3} exactement trois caractères de A à Z, - un tiret, \d{2,3} deux ou trois chiffres, $ fin du texte.
| Symbole | Signification | Exemple | Correspond à |
|---|---|---|---|
^ / $ | Début / fin du texte | ^\d{5}$ | 69003, pas 690033 |
\d | Un chiffre (0 à 9) | \d\d | 42 |
\w | Une lettre non accentuée, un chiffre ou _ | \w+ | ram_16 |
\s | Un espace, une tabulation, un saut de ligne | \d\s?\d | 4 2, 42 |
. | N'importe quel caractère (sauf saut de ligne) | a.c | abc, a-c |
\. | Un point, littéralement | \.fr$ | finit par .fr |
[abc] | Un caractère parmi ceux listés | [A-Z] | une majuscule |
[^abc] | Un caractère qui n'est pas dans la liste | [^\s@] | tout sauf espace et @ |
{n}, {n,m}, {n,} | Exactement n, de n à m, au moins n fois | \d{2,3} | 16, 512 |
? | 0 ou 1 fois | \s? | un espace facultatif |
+ | 1 fois ou plus | \d+ | 7, 2026 |
* | 0 fois ou plus | \d* | rien, 12 |
a|b | a ou b | ^(oui|non)$ | oui, non |
( ) | Groupe, capturé dans le résultat | ([A-Z]{3})-(\d+) | voir match |
Les options après la barre finale : i ignore la casse (/^[a-z]{3}$/i accepte SSD), g cherche toutes les occurrences (pour match et replace), u active le mode Unicode, nécessaire pour \p{L} (« n'importe quelle lettre, accentuée ou non »).
Les méthodes à connaître :
// test : le texte correspond-il ?
/^\d{5}$/.test('69003'); // true
// match : extraire des morceaux grâce aux groupes
'SSD-512'.match(/^([A-Z]{3})-(\d{2,3})$/); // ['SSD-512', 'SSD', '512', …]
const m = 'ECR-156'.match(/^(?<famille>[A-Z]{3})-(?<numero>\d{2,3})$/);
m.groups.famille; // 'ECR'
m.groups.numero; // '156'
// match avec g : toutes les occurrences
'Prix : 42,90 € et 7,90 €'.match(/\d+,\d{2}/g); // ['42,90', '7,90']
// replace : remplacer
'04.78.12.34.56'.replace(/\./g, ' '); // '04 78 12 34 56'
Des motifs utiles, testés sur des exemples :
| Donnée | Motif | Accepte | Refuse |
|---|---|---|---|
| Code postal français | /^\d{5}$/ | 69003 | 6900, 690033 |
| Téléphone fixe ou mobile français | /^0[1-9](\s?\d{2}){4}$/ | 04 78 12 34 56, 0478123456 | 00 78 12 34 56, +33478123456 |
| Adresse e-mail (forme simple) | /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | julie@reparinfo.fr | julie@reparinfo, julie reparinfo.fr |
| Immatriculation SIV | /^[A-Z]{2}-\d{3}-[A-Z]{2}$/ | AB-123-CD | AB123CD, ab-123-cd |
| Nom avec accents | /^\p{L}[\p{L}' -]{1,39}$/u | Hélène Lefèvre | H, R2D2 |
L'attribut HTML pattern accepte le même langage, sans les barres ni ^…$ (ils sont implicites) : <input name="ref" pattern="[A-Z]{3}-\d{2,3}">.
Fil rouge : la recherche, les boutons et le formulaire de Gestock
Le fichier main.js de Gestock garde l'état de l'application dans un objet, pose les écouteurs, et appelle une fonction rafraichir après chaque changement. Les fonctions filtrerPieces, ajusterQuantite, validerPiece et creerPiece viennent de stock.js, afficherPieces, afficherResume et afficherErreurs de vue.js (le découpage en fichiers est expliqué au chapitre 11). Le formulaire ci-dessus était réduit à deux champs : la page index.html complète (champ de recherche, case à cocher, tableau, formulaire à six champs avec leurs zones d'erreur) et le fichier stock.js en entier sont donnés au chapitre 11.
const etat = { pieces: [], recherche: '', alertesSeules: false };
const tbody = document.querySelector('#liste-pieces');
const resume = document.querySelector('#resume');
const champRecherche = document.querySelector('#recherche');
const caseAlertes = document.querySelector('#alertes-seules');
const form = document.querySelector('#form-piece');
const message = document.querySelector('#message');
function rafraichir() {
afficherPieces(tbody, filtrerPieces(etat.pieces, etat.recherche, etat.alertesSeules));
afficherResume(resume, etat.pieces);
}
function modifierStock(pieces) {
etat.pieces = pieces;
rafraichir();
}
// La liste se filtre à chaque lettre tapée.
champRecherche.addEventListener('input', () => {
etat.recherche = champRecherche.value;
rafraichir();
});
caseAlertes.addEventListener('change', () => {
etat.alertesSeules = caseAlertes.checked;
rafraichir();
});
// Délégation : un seul écouteur pour tous les boutons + et − du tableau.
tbody.addEventListener('click', (event) => {
const b = event.target.closest('button[data-action]');
if (!b) return;
const { action, ref } = b.dataset;
modifierStock(ajusterQuantite(etat.pieces, ref, action === 'ajouter' ? 1 : -1));
});
form.addEventListener('submit', (event) => {
event.preventDefault();
const saisie = Object.fromEntries(new FormData(form));
const erreurs = validerPiece(saisie, etat.pieces);
afficherErreurs(form, erreurs);
if (Object.keys(erreurs).length > 0) {
message.textContent = 'Le formulaire contient des erreurs.';
return;
}
const piece = creerPiece(saisie);
modifierStock([...etat.pieces, piece]);
form.reset();
message.textContent = `Pièce ${piece.ref} ajoutée.`;
});
ajusterQuantite renvoie un nouveau tableau où seule la pièce visée change, sans descendre sous zéro ; creerPiece transforme la saisie validée en pièce (référence en majuscules, nombres convertis) :
export function ajusterQuantite(pieces, ref, delta) {
return pieces.map((p) =>
p.ref === ref ? { ...p, quantite: Math.max(0, p.quantite + delta) } : p,
);
}
export function creerPiece(saisie) {
return {
ref: saisie.ref.trim().toUpperCase(),
nom: saisie.nom.trim(),
categorie: saisie.categorie,
quantite: lireNombre(saisie.quantite),
seuil: lireNombre(saisie.seuil),
prix: lireNombre(saisie.prix),
};
}
Ce que l'on obtient, vérifié dans le navigateur avec le stock de départ : taper ssd dans la recherche ne laisse que la ligne SSD-512 ; cocher la case n'affiche que RAM-16 et CHG-65 ; un clic sur + de RAM-16 passe sa quantité à 4 ; le formulaire rempli avec ssd512, x, 2.5, rien et 0 affiche cinq messages et place le curseur dans le champ Référence ; rempli avec ven-12, Ventilateur 12 V, 4, 2 et 7,90, il ajoute la pièce VEN-12 à 7,90 €.
Exercices
Exercice 1 sur 5 target ou currentTarget ? Réussi À revoir
Un écouteur click est posé sur le tbody. L'utilisateur clique sur le texte « + » à l'intérieur d'un <span>, lui-même dans un <button>, dans une cellule du tableau. Que valent event.target et event.currentTarget ? Comment retrouver le bouton ?
Voir la réponseMasquer la réponse de l'exercice 1
event.target est le <span> : l'élément le plus profond sous le pointeur. event.currentTarget est le tbody : l'élément qui porte l'écouteur. Pour retrouver le bouton : event.target.closest('button'), qui remonte depuis le span jusqu'au premier button.
Tu avais trouvé ?
Exercice 2 sur 5 Écrire des regex Réussi À revoir
Écris une expression régulière pour chaque format, puis vérifie-la sur les exemples.
- Un numéro de facture :
FAC-, l'année sur 4 chiffres, un tiret, un numéro sur 4 chiffres (FAC-2026-0001). - Un code de réduction : 6 à 10 caractères, lettres majuscules ou chiffres uniquement.
- Une heure au format
HH:MM, de00:00à23:59.
Voir la réponseMasquer la réponse de l'exercice 2
const FACTURE = /^FAC-\d{4}-\d{4}$/;
FACTURE.test('FAC-2026-0001'); // true
FACTURE.test('FAC-26-0001'); // false
FACTURE.test('fac-2026-0001'); // false
const CODE = /^[A-Z0-9]{6,10}$/;
CODE.test('NOEL2026'); // true
CODE.test('NOEL'); // false : trop court
CODE.test('noel2026'); // false : minuscules
const HEURE = /^([01]\d|2[0-3]):[0-5]\d$/;
HEURE.test('09:30'); // true
HEURE.test('23:59'); // true
HEURE.test('24:00'); // false
HEURE.test('9:30'); // false : il faut deux chiffres
Pour l'heure, [01]\d couvre 00 à 19, 2[0-3] couvre 20 à 23, et les parenthèses limitent l'alternative | à cette partie du motif.
Tu avais trouvé ?
Exercice 3 sur 5 Pourquoi l'erreur passe ? Réussi À revoir
Un étudiant valide le prix avec ce code. Un utilisateur saisit abc et la pièce est enregistrée avec un prix NaN. Explique pourquoi et corrige.
const prix = Number(saisie.prix);
if (prix <= 0) {
erreurs.prix = 'Prix incorrect';
}
Voir la réponseMasquer la réponse de l'exercice 3
Number('abc') vaut NaN, et toute comparaison avec NaN est fausse : NaN <= 0 vaut false, donc aucune erreur n'est enregistrée.
Correction : tester la condition positive et la nier, ce qui attrape aussi NaN :
if (!(prix > 0)) {
erreurs.prix = 'Le prix est un nombre strictement positif.';
}
Ou explicitement : if (Number.isNaN(prix) || prix <= 0).
Tu avais trouvé ?
Exercice 4 sur 5 Un compteur de caractères Réussi À revoir
Sous un champ <textarea id="remarque" maxlength="200">, un paragraphe <p id="compteur"></p> doit afficher en permanence « 37 / 200 caractères » (avec le vrai nombre), et passer en classe limite au-delà de 180 caractères. Écris le code.
Voir la réponseMasquer la réponse de l'exercice 4
const zone = document.querySelector('#remarque');
const compteur = document.querySelector('#compteur');
function majCompteur() {
const n = zone.value.length;
compteur.textContent = `${n} / 200 caractères`;
compteur.classList.toggle('limite', n > 180);
}
zone.addEventListener('input', majCompteur);
majCompteur(); // affichage initial, avant toute frappe
L'événement input se déclenche à chaque modification, frappe, collage ou suppression comprises. L'appel final affiche « 0 / 200 caractères » dès le chargement.
Tu avais trouvé ?
Exercice 5 sur 5 Supprimer une pièce Réussi À revoir
On ajoute à chaque ligne de Gestock un bouton <button type="button" data-action="supprimer" data-ref="…">Supprimer</button>. Complète l'écouteur délégué du tbody pour que ce bouton supprime la pièce après une confirmation, sans écrire de nouvel addEventListener.
Voir la réponseMasquer la réponse de l'exercice 5
tbody.addEventListener('click', (event) => {
const b = event.target.closest('button[data-action]');
if (!b) return;
const { action, ref } = b.dataset;
if (action === 'supprimer') {
if (confirm(`Supprimer la pièce ${ref} ?`)) {
modifierStock(etat.pieces.filter((p) => p.ref !== ref));
}
return;
}
modifierStock(ajusterQuantite(etat.pieces, ref, action === 'ajouter' ? 1 : -1));
});
La délégation absorbe le nouveau bouton sans rien changer d'autre. filter crée un tableau sans la pièce, et l'état est remplacé comme pour les autres modifications. confirm suffit pour un outil interne ; une application soignée utilisera plutôt un élément <dialog>.
Tu avais trouvé ?