Cours JavaScript et TypeScript · Module 8: Événements et DOM
Les événements de formulaire
À 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. À quoi fait référence « this » dans une fonction gestionnaire d'événement classique ?
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 :