Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 addEventListener et lire l'objet event ;
  • expliquer la propagation et utiliser la délégation d'événements ;
  • lire un formulaire avec FormData et 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

html
<button type="button" id="actualiser">Actualiser</button>
js
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.

js
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énementSe produit quand…Élément typique
clickOn clique, ou on appuie sur Entrée ou Espace sur un boutonbutton, lien
inputLa valeur d'un champ change, à chaque frappeinput, textarea
changeLa valeur est validée : case cochée, option choisie, champ quitté après modificationselect, checkbox
submitLe formulaire est envoyé (bouton d'envoi ou Entrée dans un champ)form
keydownUne touche est enfoncéedocument, champ
focus / blurUn élément reçoit ou perd le focus (ne se propagent pas)champ, bouton
focusin / focusoutPareil, mais se propagent aux parentsformulaire
pointerdown, pointerupSouris, doigt ou stylet appuie, se relèvezone de dessin
DOMContentLoadedLe HTML est entièrement ludocument
loadLa page et toutes ses images sont chargéeswindow

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é :

js
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éthodeRôle
event.targetL'élément où l'événement s'est produit (le bouton cliqué, le champ modifié)
event.currentTargetL'élément qui porte l'écouteur en cours d'exécution
event.keyLa 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.

documenttabletbody#liste-piecestrtdbutton « + »event.target2. la cible1. captureécouteur « click » posé ici3. bouillonnementtd → tr → tbodyLe clic part du bouton et remonte : td, tr, puis tbody, où l'écouteur le reçoit.L'écouteur retrouve le bouton avec event.target.closest('button').Un seul écouteur suffit pour toutes les lignes, même celles créées plus tard.
Un clic descend jusqu'à sa cible puis remonte : l'écouteur du tbody reçoit les clics de tous ses boutons

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é :

js
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.

html
<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>
js
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', pas 4. Convertis-les (chapitre 2).
  • Un champ sans attribut name est ignoré.
  • Une case à cocher non cochée n'apparaît pas ; cochée, elle vaut 'on' (ou son attribut value). Pour un booléen, lis plutôt form.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 :

CoucheOutilRôle
HTMLrequired, type="email", min, max, maxlength, patternContrôle immédiat, sans code, avec des messages traduits par le navigateur (formulaires HTML)
JavaScriptTes fonctions de validationRègles métier (référence déjà utilisée), messages précis et cohérents, affichage soigné
ServeurLe code de l'APILa 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) :

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.

html
<label for="ref">Référence</label>
<input id="ref" name="ref" required aria-describedby="erreur-ref">
<p id="erreur-ref" class="erreur"></p>
js
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 :

js
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.

SymboleSignificationExempleCorrespond à
^ / $Début / fin du texte^\d{5}$69003, pas 690033
\dUn chiffre (0 à 9)\d\d42
\wUne lettre non accentuée, un chiffre ou _\w+ram_16
\sUn espace, une tabulation, un saut de ligne\d\s?\d4 2, 42
.N'importe quel caractère (sauf saut de ligne)a.cabc, 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|ba 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 :

js
// 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éeMotifAccepteRefuse
Code postal français/^\d{5}$/690036900, 690033
Téléphone fixe ou mobile français/^0[1-9](\s?\d{2}){4}$/04 78 12 34 56, 047812345600 78 12 34 56, +33478123456
Adresse e-mail (forme simple)/^[^\s@]+@[^\s@]+\.[^\s@]+$/julie@reparinfo.frjulie@reparinfo, julie reparinfo.fr
Immatriculation SIV/^[A-Z]{2}-\d{3}-[A-Z]{2}$/AB-123-CDAB123CD, ab-123-cd
Nom avec accents/^\p{L}[\p{L}' -]{1,39}$/uHélène LefèvreH, 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.

js
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) :

js
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.

  1. Un numéro de facture : FAC-, l'année sur 4 chiffres, un tiret, un numéro sur 4 chiffres (FAC-2026-0001).
  2. Un code de réduction : 6 à 10 caractères, lettres majuscules ou chiffres uniquement.
  3. Une heure au format HH:MM, de 00:00 à 23:59.
Voir la réponseMasquer la réponse de l'exercice 2
js
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.

js
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 :

js
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
js
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
js
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é ?

Chapitre terminé !

Fais les 5 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : localStorage et sessionStorage : stocker des données dans le navigateurRevoir le sommaire du cours