Cours JavaScript et TypeScript · Module 8: Événements et DOM

Les événements de formulaire

6 min de lecture

À Jurassic Park, les formulaires servent à enregistrer les visiteurs, à gérer le personnel et à piloter les systèmes de sécurité. De la même façon, dans les applications web, les formulaires sont essentiels pour interagir avec les utilisateurs.

Imagine un formulaire de réservation pour une visite de l'enclos du T-Rex. Sans JavaScript, un clic sur « Envoyer » pousse le navigateur à recharger toute la page, et le visiteur ne découvre la faute de frappe dans son adresse e-mail que lorsque la confirmation n'arrive jamais. Les événements de formulaire permettent de prendre la main sur ce processus et de réagir avant que quoi que ce soit ne quitte le navigateur.

Gérer les formulaires

Le signal le plus important est submit. Le formulaire l'envoie quand le visiteur clique sur le bouton d'envoi ou appuie sur Entrée dans un champ texte. On attache l'écouteur au formulaire lui-même, et non au bouton, pour attraper les deux cas.

1const ticketBookingForm = document.getElementById('booking-form');
2const emailInput = document.getElementById('email');
3const phoneInput = document.getElementById('phone');
4const ticketTypeSelect = document.getElementById('ticket-type');
5
6// Submit - envoi du formulaire
7ticketBookingForm.addEventListener('submit', (e) => {
8  e.preventDefault();
9
10  const formData = new FormData(ticketBookingForm);
11  const bookingData = {
12    name: formData.get('name'),
13    email: formData.get('email'),
14    phone: formData.get('phone'),
15    ticketType: formData.get('ticket-type'),
16    visitDate: formData.get('visit-date'),
17    guestCount: parseInt(formData.get('guest-count'))
18  };
19
20  if (validateBookingData(bookingData)) {
21    submitBooking(bookingData);
22  }
23});

La gestion d'un envoi comporte toujours quatre phases, dans le même ordre. D'abord, on intercepte l'événement submit, puis on appelle preventDefault() pour arrêter l'envoi par défaut et le rechargement de la page, ensuite on vérifie les données, et enfin on les envoie nous-mêmes. L'objet FormData rassemble les valeurs des champs d'après leurs attributs name, et les champs texte et numériques les rendent sous forme de texte, c'est pourquoi on fait passer le nombre de visiteurs par parseInt().

Tu peux réaliser l'envoi avec la fonction fetch(), que tu connais depuis la leçon sur la programmation asynchrone. Voici une version possible de submitBooking(), qui transmet la réservation au format JSON.

1async function submitBooking(bookingData) {
2  const response = await fetch('/api/bookings', {
3    method: 'POST',
4    headers: { 'Content-Type': 'application/json' },
5    body: JSON.stringify(bookingData)
6  });
7
8  if (!response.ok) {
9    console.error('Réservation refusée :', response.status);
10    return;
11  }
12  console.log('Réservation acceptée !');
13}

La page ne se recharge pas une seule fois, et le visiteur reste sur le formulaire. N'oublie pas que fetch() ne signale une erreur qu'en cas de problème réseau. Pour lui, une réponse avec le code 400 ou 500 est un transport réussi, c'est pourquoi on vérifie nous-mêmes response.ok.

Validation en temps réel

L'événement input se déclenche à chaque changement de la valeur du champ : chaque caractère tapé, chaque collage ou suppression de texte. Avant de l'utiliser, il nous faut une fonction qui vérifie l'adresse e-mail.

1function isValidEmail(email) {
2  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
3}

C'est un gardien simplifié : il exige un signe @ et un point dans le domaine, mais il ne vérifie pas si la boîte aux lettres existe. Maintenant, on le branche sur le champ pour que la classe CSS change au fil de la saisie.

1// Input - changement de la valeur du champ
2emailInput.addEventListener('input', (e) => {
3  const email = e.target.value;
4  if (isValidEmail(email)) {
5    e.target.classList.remove('invalid');
6    e.target.classList.add('valid');
7  } else {
8    e.target.classList.remove('valid');
9    e.target.classList.add('invalid');
10  }
11});

Le visiteur voit un cadre rouge ou vert pendant qu'il tape, et la valeur du champ reste intacte, car on ne change que sa classe.

L'événement change

L'événement change se déclenche plus rarement, seulement quand le changement est validé. Pour une liste <select>, cela arrive dès le choix d'une option, et pour un champ texte, seulement quand on le quitte.

1// Change - changement validé (select : dès le choix, champ texte : après l'avoir quitté)
2ticketTypeSelect.addEventListener('change', (e) => {
3  const selectedType = e.target.value;
4  updateTicketPrice(selectedType);
5  showTicketTypeInfo(selectedType);
6});

Pour recalculer le prix du billet, change convient parfaitement, car on réagit à la décision du visiteur, et non à chacune de ses hésitations. Ma règle : input pour les indications en direct, change pour réagir à un choix, et submit pour le contrôle final. Laisse les règles les plus simples, comme required ou type="email", aux attributs HTML. Le navigateur bloquera lui-même l'envoi d'un formulaire invalide et n'enverra même pas submit dans ce cas.

Le contexte this dans les gestionnaires d'événements

Un aspect important des gestionnaires d'événements est le contexte this. Dans une fonction classique (function expression), this désigne l'élément sur lequel l'écouteur a été enregistré - c'est-à-dire l'élément auquel on a "accroché" le gestionnaire.

1const submitBtn = document.getElementById('submit-btn');
2
3// Fonction classique - this pointe vers l'élément qui porte l'écouteur
4submitBtn.addEventListener('click', function(event) {
5  console.log(this);          // <button id="submit-btn">
6  console.log(this === event.currentTarget); // true
7  console.log(event.target);  // l'élément sur lequel on a réellement cliqué
8
9  // this est l'élément qui porte l'écouteur, event.target l'élément cliqué
10  this.textContent = 'Envoyé !';
11  this.disabled = true;
12});

Ici, this est égal à event.currentTarget, c'est pourquoi le bouton peut changer son propre texte et se désactiver. event.target désigne l'élément sur lequel on a réellement cliqué, et pour un bouton qui ne contient que du texte, c'est le même élément. Une fonction fléchée se comporte autrement.

1// Fonction fléchée - this NE pointe PAS vers l'élément !
2submitBtn.addEventListener('click', (event) => {
3  console.log(this); // this du code environnant, par ex. window (dans un module ES : undefined)
4  // Les fonctions fléchées n'ont pas leur propre this
5  // Utilise event.currentTarget à la place de this
6  event.currentTarget.textContent = 'Envoyé !';
7});

Une fonction fléchée n'a pas son propre this, elle le prend donc dans le code environnant. Au niveau le plus haut d'un script ordinaire, ce sera window, et dans un module ES, la valeur undefined. Ici, tu n'obtiendras certainement pas le bouton, c'est pourquoi, dans les fonctions fléchées, on se tourne vers event.currentTarget.

« Attention », prévient Ray, « les fonctions fléchées n'ont pas leur propre this. Si tu as besoin de désigner l'élément avec this, utilise des fonctions classiques. Si tu préfères les fonctions fléchées, utilise event.currentTarget. »

Moi, je choisis les fonctions fléchées avec event.currentTarget, car un tel code fonctionne de la même façon, où que tu le déplaces. Dans le bac à sable ci-dessous t'attend le formulaire de réservation d'une visite avec les trois événements, ainsi que focus et blur, qui se déclenchent quand un champ gagne et perd le focus. Dans la prochaine leçon, tu apprendras à faire le ménage derrière les écouteurs quand un formulaire disparaît de la page.

Retiens bien : le formulaire est le portail d'entrée du parc, et submit avec preventDefault() est le gardien qui vérifie le laissez-passer avant que quiconque n'aille plus loin.

Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Événements de formulaire - Réservation de visite</title>
7    <style>
8        * {
9            margin: 0;
10            padding: 0;
11            box-sizing: border-box;
12        }
13
14        body {
15            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
17            padding: 20px;
18            color: white;
19            min-height: 100vh;
20            display: flex;
21            align-items: center;
22            justify-content: center;
23        }
24
25        .form-container {
26            max-width: 600px;
27            width: 100%;
28            background: rgba(0, 0, 0, 0.8);
29            border-radius: 15px;
30            padding: 40px;
31            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
32        }
33
34        h1 {
35            text-align: center;
36            margin-bottom: 10px;
37            color: #ecf0f1;
38        }
39
40        .subtitle {
41            text-align: center;
42            color: #bdc3c7;
43            margin-bottom: 30px;
44        }
45
46        .form-group {
47            margin-bottom: 25px;
48        }
49
50        label {
51            display: block;
52            margin-bottom: 8px;
53            color: #ecf0f1;
54            font-weight: 500;
55        }
56
57        .required {
58            color: #e74c3c;
59        }
60
61        input, select, textarea {
62            width: 100%;
63            padding: 12px 15px;
64            border: 2px solid #34495e;
65            border-radius: 8px;
66            background: rgba(255, 255, 255, 0.1);
67            color: white;
68            font-size: 16px;
69            transition: all 0.3s ease;
70        }
71
72        input:focus, select:focus, textarea:focus {
73            outline: none;
74            border-color: #3498db;
75            background: rgba(255, 255, 255, 0.15);
76        }
77
78        input.valid {
79            border-color: #27ae60;
80            background: rgba(39, 174, 96, 0.1);
81        }
82
83        input.invalid {
84            border-color: #e74c3c;
85            background: rgba(231, 76, 60, 0.1);
86        }
87
88        .error-message {
89            color: #e74c3c;
90            font-size: 14px;
91            margin-top: 5px;
92            display: none;
93        }
94
95        .input-hint {
96            color: #95a5a6;
97            font-size: 13px;
98            margin-top: 5px;
99        }
100
101        .char-counter {
102            text-align: right;
103            color: #95a5a6;
104            font-size: 12px;
105            margin-top: 5px;
106        }
107
108        .submit-btn {
109            width: 100%;
110            padding: 15px;
111            background: linear-gradient(45deg, #27ae60, #2ecc71);
112            color: white;
113            border: none;
114            border-radius: 8px;
115            font-size: 18px;
116            font-weight: bold;
117            cursor: pointer;
118            transition: all 0.3s ease;
119            margin-top: 20px;
120        }
121
122        .submit-btn:hover:not(:disabled) {
123            transform: translateY(-2px);
124            box-shadow: 0 10px 25px rgba(39, 174, 96, 0.3);
125        }
126
127        .submit-btn:disabled {
128            background: #7f8c8d;
129            cursor: not-allowed;
130        }
131
132        .success-message {
133            background: linear-gradient(45deg, #27ae60, #2ecc71);
134            color: white;
135            padding: 20px;
136            border-radius: 10px;
137            text-align: center;
138            display: none;
139            animation: slideIn 0.5s ease;
140        }
141
142        @keyframes slideIn {
143            from {
144                opacity: 0;
145                transform: translateY(-20px);
146            }
147            to {
148                opacity: 1;
149                transform: translateY(0);
150            }
151        }
152
153        .price-info {
154            background: rgba(52, 152, 219, 0.2);
155            border: 1px solid #3498db;
156            border-radius: 8px;
157            padding: 15px;
158            margin: 20px 0;
159            text-align: center;
160        }
161
162        .price-value {
163            font-size: 32px;
164            font-weight: bold;
165            color: #3498db;
166        }
167
168        .event-log {
169            background: rgba(0, 0, 0, 0.6);
170            border-radius: 8px;
171            padding: 15px;
172            margin-top: 20px;
173            max-height: 200px;
174            overflow-y: auto;
175            font-family: 'Courier New', monospace;
176            font-size: 12px;
177        }
178
179        .log-entry {
180            padding: 3px 0;
181            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
182            color: #95a5a6;
183        }
184    </style>
185</head>
186<body>
187    <div class="form-container">
188        <h1>Réservation de visite</h1>
189        <p class="subtitle">Jurassic Park - Découvre le monde des dinosaures</p>
190
191        <form id="booking-form">
192            <div class="form-group">
193                <label for="name">Full name <span class="required">*</span></label>
194                <input type="text" id="name" name="name" required>
195                <div class="error-message" id="name-error">Enter full name (min. 3 characters)</div>
196            </div>
197
198            <div class="form-group">
199                <label for="email">Email <span class="required">*</span></label>
200                <input type="email" id="email" name="email" required>
201                <div class="error-message" id="email-error">Enter a valid email address</div>
202                <div class="input-hint">We will send reservation confirmation to this address</div>
203            </div>
204
205            <div class="form-group">
206                <label for="phone">Téléphone de contact <span class="required">*</span></label>
207                <input type="tel" id="phone" name="phone" placeholder="+48 123 456 789" required>
208                <div class="error-message" id="phone-error">Enter a valid phone number</div>
209            </div>
210
211            <div class="form-group">
212                <label for="date">Date de la visite <span class="required">*</span></label>
213                <input type="date" id="date" name="date" required>
214                <div class="error-message" id="date-error">Choose a future date</div>
215            </div>
216
217            <div class="form-group">
218                <label for="tour-type">Type de visite <span class="required">*</span></label>
219                <select id="tour-type" name="tour-type" required>
220                    <option value="">Choisis un type de visite</option>
221                    <option value="standard" data-price="150">Standard (2h) - 150 €</option>
222                    <option value="vip" data-price="300">VIP avec guide (4h) - 300 €</option>
223                    <option value="night" data-price="250">Visite de nuit (3h) - 250 €</option>
224                    <option value="family" data-price="500">Famille (4h, max 4 personnes) - 500 €</option>
225                </select>
226            </div>
227
228            <div class="form-group">
229                <label for="guests">Number of guests <span class="required">*</span></label>
230                <input type="number" id="guests" name="guests" min="1" max="20" value="1" required>
231                <div class="input-hint">Min. 1, max. 20 guests</div>
232            </div>
233
234            <div class="form-group">
235                <label for="comments">Remarques supplémentaires</label>
236                <textarea id="comments" name="comments" rows="4" maxlength="500"></textarea>
237                <div class="char-counter" id="char-counter">0 / 500 characters</div>
238            </div>
239
240            <div class="price-info">
241                <div style="color: #bdc3c7; margin-bottom: 10px;">Total cost:</div>
242                <div class="price-value" id="total-price">0 €</div>
243            </div>
244
245            <button type="submit" class="submit-btn" id="submit-btn" disabled>
246                Book a tour
247            </button>
248        </form>
249
250        <div class="success-message" id="success-message">
251            <h2>Réservation confirmée !</h2>
252            <p>Merci pour ta réservation. Les détails ont été envoyés à l'adresse email indiquée.</p>
253        </div>
254
255        <div class="event-log" id="event-log">
256            <strong>Form event log:</strong>
257        </div>
258    </div>
259
260    <script>
261        console.log('Système de réservation du Parc Jurassique - ONLINE');
262
263        // References to elements
264        const form = document.getElementById('booking-form');
265        const nameInput = document.getElementById('name');
266        const emailInput = document.getElementById('email');
267        const phoneInput = document.getElementById('phone');
268        const dateInput = document.getElementById('date');
269        const tourTypeSelect = document.getElementById('tour-type');
270        const guestsInput = document.getElementById('guests');
271        const commentsTextarea = document.getElementById('comments');
272        const charCounter = document.getElementById('char-counter');
273        const totalPriceDisplay = document.getElementById('total-price');
274        const submitBtn = document.getElementById('submit-btn');
275        const successMessage = document.getElementById('success-message');
276        const eventLog = document.getElementById('event-log');
277
278        // Fonction de logging
279        function logEvent(message) {
280            const entry = document.createElement('div');
281            entry.className = 'log-entry';
282            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
283            eventLog.appendChild(entry);
284            eventLog.scrollTop = eventLog.scrollHeight;
285            console.log(message);
286        }
287
288        // Functions de validation
289        function validateName(name) {
290            return name.trim().length >= 3;
291        }
292
293        function validateEmail(email) {
294            const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
295            return regex.test(email);
296        }
297
298        function validatePhone(phone) {
299            const cleaned = phone.replace(/\s/g, '');
300            return cleaned.length >= 9;
301        }
302
303        function validateDate(date) {
304            const selected = new Date(date);
305            const today = new Date();
306            today.setHours(0, 0, 0, 0);
307            return selected > today;
308        }
309
310        function showError(input, errorId, show) {
311            const errorElement = document.getElementById(errorId);
312            if (show) {
313                errorElement.style.display = 'block';
314                input.classList.add('invalid');
315                input.classList.remove('valid');
316            } else {
317                errorElement.style.display = 'none';
318                input.classList.remove('invalid');
319                input.classList.add('valid');
320            }
321        }
322
323        function updateTotalPrice() {
324            const selectedOption = tourTypeSelect.options[tourTypeSelect.selectedIndex];
325            const pricePerPerson = parseInt(selectedOption.dataset.price) || 0;
326            const guests = parseInt(guestsInput.value) || 0;
327            const total = pricePerPerson * guests;
328            totalPriceDisplay.textContent = `${total} €`;
329            logEvent(`Updated price: ${total} €`);
330        }
331
332        function checkFormValidity() {
333            const isValid = validateName(nameInput.value) &&
334                          validateEmail(emailInput.value) &&
335                          validatePhone(phoneInput.value) &&
336                          validateDate(dateInput.value) &&
337                          tourTypeSelect.value !== '' &&
338                          guestsInput.value >= 1;
339            submitBtn.disabled = !isValid;
340        }
341
342        // INPUT EVENT - validation en temps réel
343        nameInput.addEventListener('input', function(e) {
344            const isValid = validateName(e.target.value);
345            showError(nameInput, 'name-error', !isValid);
346            logEvent(`Typing name: ${e.target.value.length} characters`);
347            checkFormValidity();
348        });
349
350        emailInput.addEventListener('input', function(e) {
351            const isValid = validateEmail(e.target.value);
352            showError(emailInput, 'email-error', !isValid);
353            logEvent(`Saisie de l'email: ${e.target.value}`);
354            checkFormValidity();
355        });
356
357        phoneInput.addEventListener('input', function(e) {
358            const isValid = validatePhone(e.target.value);
359            showError(phoneInput, 'phone-error', !isValid);
360            checkFormValidity();
361        });
362
363        // CHANGE EVENT - value change
364        dateInput.addEventListener('change', function(e) {
365            const isValid = validateDate(e.target.value);
366            showError(dateInput, 'date-error', !isValid);
367            logEvent(`Date selected: ${e.target.value}`);
368            checkFormValidity();
369        });
370
371        tourTypeSelect.addEventListener('change', function(e) {
372            const selectedText = e.target.options[e.target.selectedIndex].text;
373            logEvent(`Type de visite sélectionné: ${selectedText}`);
374            updateTotalPrice();
375            checkFormValidity();
376        });
377
378        guestsInput.addEventListener('change', function(e) {
379            logEvent(`Changed number of guests: ${e.target.value}`);
380            updateTotalPrice();
381        });
382
383        // FOCUS/BLUR EVENTS
384        const allInputs = [nameInput, emailInput, phoneInput, dateInput, tourTypeSelect, guestsInput, commentsTextarea];
385
386        allInputs.forEach(input => {
387            input.addEventListener('focus', function() {
388                logEvent(`Focus sur: ${this.name || this.id}`);
389            });
390
391            input.addEventListener('blur', function() {
392                logEvent(`Blur de: ${this.name || this.id}`);
393            });
394        });
395
396        // Compteur de characters pour le textarea
397        commentsTextarea.addEventListener('input', function(e) {
398            const length = e.target.value.length;
399            const maxLength = e.target.maxLength;
400            charCounter.textContent = `${length} / ${maxLength} characters`;
401
402            if (length > maxLength - 50) {
403                charCounter.style.color = '#e74c3c';
404            } else {
405                charCounter.style.color = '#95a5a6';
406            }
407        });
408
409        // SUBMIT EVENT
410        form.addEventListener('submit', function(e) {
411            e.preventDefault();
412
413            if (submitBtn.disabled) {
414                alert('Form contains errors. Fix them before submitting.');
415                return;
416            }
417
418            const formData = new FormData(form);
419            const data = {
420                name: formData.get('name'),
421                email: formData.get('email'),
422                phone: formData.get('phone'),
423                date: formData.get('date'),
424                tourType: formData.get('tour-type'),
425                guests: formData.get('guests'),
426                comments: formData.get('comments') || 'Aucun'
427            };
428
429            logEvent(`Submitting form...\nDonnées: ${JSON.stringify(data, null, 2)}`);
430
431            submitBtn.textContent = 'Booking...';
432            submitBtn.disabled = true;
433
434            setTimeout(() => {
435                form.style.display = 'none';
436                successMessage.style.display = 'block';
437                logEvent('Reservation successfully registered!');
438
439                console.log('Données de réservation:', data);
440
441                setTimeout(() => {
442                    form.style.display = 'block';
443                    successMessage.style.display = 'none';
444                    form.reset();
445                    submitBtn.textContent = 'Book a tour';
446                    totalPriceDisplay.textContent = '0 €';
447                    allInputs.forEach(input => input.classList.remove('valid', 'invalid'));
448                    checkFormValidity();
449                }, 3000);
450            }, 2000);
451        });
452
453        // Set minimum date to tomorrow
454        const tomorrow = new Date();
455        tomorrow.setDate(tomorrow.getDate() + 1);
456        dateInput.min = tomorrow.toISOString().split('T')[0];
457
458        logEvent('Reservation system loaded');
459    </script>
460</body>
461</html>

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. 1. À quoi fait référence « this » dans une fonction gestionnaire d'événement classique ?

  2. 2. Que faut-il faire pour empêcher le rechargement par défaut de la page lors de l'envoi d'un formulaire ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Le fichier index.html contient une barre #navbar, et style.css la cache quand elle a la classe hidden. Le script mémorise la dernière position de défilement (window.scrollY). Complète les blancs : ___BLANK1___ est l'événement de défilement de la page écouté sur window, et ___BLANK2___ est l'opérateur de comparaison qui détecte le défilement vers le bas (la nouvelle position est plus grande que la précédente). La barre doit se cacher quand on descend et revenir quand on remonte.

  • Clique dans l'ordre

    Place le code pour la validation d'un champ en temps réel.

  • Tri vertical

    Place dans l'ordre la gestion de l'événement submit d'un formulaire :

Articles utiles