Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM
Wydarzenia formularzy
Formularze w Parku Jurajskim służą do rejestracji gości, zarządzania personelem i systemów bezpieczeństwa. Podobnie w aplikacjach webowych formularze są kluczowe dla interakcji z użytkownikami.
Wyobraź sobie formularz rezerwacji wycieczki do zagrody T-Rexa. Bez JavaScriptu przeglądarka po kliknięciu "Wyślij" przeładowuje całą stronę, a gość dowiaduje się o literówce w adresie e-mail dopiero wtedy, gdy potwierdzenie nie dotrze. Wydarzenia formularzy pozwalają przejąć ten proces i reagować, zanim cokolwiek opuści przeglądarkę.
Obsługa formularzy
Najważniejszym sygnałem jest submit. Formularz wysyła go, gdy gość kliknie przycisk wysyłania albo naciśnie Enter w polu tekstowym. Listener podpinamy do samego formularza, a nie do przycisku, żeby złapać oba sposoby.
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 - wysłanie formularza
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});Obsługa wysłania ma zawsze cztery fazy w tej samej kolejności. Najpierw przechwytujemy wydarzenie submit, potem wywołujemy preventDefault(), żeby zatrzymać domyślne wysłanie i przeładowanie strony, następnie sprawdzamy dane, a na końcu wysyłamy je sami. Obiekt FormData zbiera wartości pól według ich atrybutów name, a pola tekstowe i liczbowe oddają je jako tekst, dlatego liczbę gości przepuszczamy przez parseInt().
Wysyłkę możesz zrealizować funkcją fetch(), którą znasz z lekcji o programowaniu asynchronicznym. Oto przykładowa wersja submitBooking(), która przesyła rezerwację jako 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('Rezerwacja odrzucona:', response.status);
10 return;
11 }
12 console.log('Rezerwacja przyjęta!');
13}Strona nie przeładowuje się ani razu, a gość zostaje na formularzu. Pamiętaj, że fetch() zgłasza błąd tylko przy problemie z siecią. Odpowiedź z kodem 400 czy 500 to dla niego sukces transportu, dlatego sami sprawdzamy response.ok.
Walidacja w czasie rzeczywistym
Wydarzenie input odpala przy każdej zmianie wartości pola: każdym wpisanym znaku, wklejeniu czy usunięciu tekstu. Zanim go użyjemy, potrzebujemy funkcji sprawdzającej adres e-mail.
1function isValidEmail(email) {
2 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
3}To uproszczony strażnik: wymaga znaku @ i kropki w domenie, ale nie sprawdza, czy skrzynka istnieje. Teraz podłączamy go do pola, żeby klasa CSS zmieniała się na bieżąco.
1// Input - zmiana wartości w polu
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});Gość widzi czerwoną lub zieloną ramkę już podczas pisania, a wartość pola pozostaje nietknięta, bo zmieniamy tylko jego klasę.
Wydarzenie change
Wydarzenie change odpala rzadziej, dopiero gdy zmiana zostaje zatwierdzona. Przy liście <select> dzieje się to od razu po wyborze opcji, a przy polu tekstowym dopiero po jego opuszczeniu.
1// Change - zatwierdzona zmiana (select: od razu po wyborze, pole tekstowe: po opuszczeniu)
2ticketTypeSelect.addEventListener('change', (e) => {
3 const selectedType = e.target.value;
4 updateTicketPrice(selectedType);
5 showTicketTypeInfo(selectedType);
6});Do przeliczania ceny biletu change pasuje idealnie, bo reagujemy na decyzję gościa, a nie na każde jego wahanie. Moja zasada: input do podpowiedzi na żywo, change do reakcji na wybór, a submit do ostatecznej kontroli. Najprostsze reguły, jak required czy type="email", zostaw atrybutom HTML. Przeglądarka sama zablokuje wysłanie niepoprawnego formularza i nawet nie wyśle wtedy submit.
Kontekst this w event handlerach
Ważnym aspektem event handlerów jest kontekst this. W tradycyjnej funkcji (function expression), this odnosi się do elementu, na którym zarejestrowano event listener - czyli do elementu, do którego "przypięliśmy" handler.
1const submitBtn = document.getElementById('submit-btn');
2
3// Tradycyjna funkcja - this wskazuje na element z listenerem
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); // element, który faktycznie kliknięto
8
9 // this to element z listenerem, event.target to element kliknięty
10 this.textContent = 'Wysłano!';
11 this.disabled = true;
12});Wartość this jest tu równa event.currentTarget, dlatego przycisk może zmienić własny tekst i się zablokować. event.target wskazuje element, w który faktycznie kliknięto, i przy przycisku z samym tekstem to ten sam element. Funkcja strzałkowa zachowuje się inaczej.
1// Arrow function - this NIE wskazuje na element!
2submitBtn.addEventListener('click', (event) => {
3 console.log(this); // this z otaczającego kodu, np. window (w module ES: undefined)
4 // Arrow functions nie mają własnego this
5 // Używaj event.currentTarget zamiast this
6 event.currentTarget.textContent = 'Wysłano!';
7});Funkcja strzałkowa nie ma własnego this, więc bierze je z otaczającego kodu. W zwykłym skrypcie na najwyższym poziomie będzie to window, a w module ES wartość undefined. Tu na pewno nie dostaniesz przycisku, dlatego w strzałkach sięgamy po event.currentTarget.
"Pamiętaj," ostrzega Ray, "arrow functions nie mają własnego this. Jeśli potrzebujesz odwołać się do elementu przez this, używaj tradycyjnych funkcji. Jeśli wolisz arrow functions, używaj event.currentTarget."
Ja wybieram strzałki z event.currentTarget, bo taki kod działa tak samo niezależnie od tego, gdzie go przeniesiesz. W piaskownicy poniżej czeka formularz rezerwacji wycieczki ze wszystkimi trzema wydarzeniami, a do tego z focus i blur, które odpalają, gdy pole zyskuje i traci fokus. W kolejnej lekcji nauczysz się sprzątać po listenerach, gdy formularz znika ze strony.
Pamiętaj: formularz to brama do parku, a submit z preventDefault() to strażnik, który sprawdza przepustkę, zanim ktokolwiek przejdzie dalej.
Kod do tej lekcji: index.html
1<!DOCTYPE html>
2<html lang="pl">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Wydarzenia formularzy - Rezerwacja wycieczki</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>Rezerwacja Wycieczki</h1>
189 <p class="subtitle">Park Jurajski - Odkryj świat dinozaurów</p>
190
191 <form id="booking-form">
192 <div class="form-group">
193 <label for="name">Imię i nazwisko <span class="required">*</span></label>
194 <input type="text" id="name" name="name" required>
195 <div class="error-message" id="name-error">Wprowadź pełne imię i nazwisko (min. 3 znaki)</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">Wprowadź poprawny adres email</div>
202 <div class="input-hint">Na ten adres wyślemy potwierdzenie rezerwacji</div>
203 </div>
204
205 <div class="form-group">
206 <label for="phone">Telefon kontaktowy <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">Wprowadź poprawny numer telefonu</div>
209 </div>
210
211 <div class="form-group">
212 <label for="date">Data wycieczki <span class="required">*</span></label>
213 <input type="date" id="date" name="date" required>
214 <div class="error-message" id="date-error">Wybierz datę z przyszłości</div>
215 </div>
216
217 <div class="form-group">
218 <label for="tour-type">Typ wycieczki <span class="required">*</span></label>
219 <select id="tour-type" name="tour-type" required>
220 <option value="">Wybierz typ wycieczki</option>
221 <option value="standard" data-price="150">Standardowa (2h) - 150 zł</option>
222 <option value="vip" data-price="300">VIP z przewodnikiem (4h) - 300 zł</option>
223 <option value="night" data-price="250">Nocna wycieczka (3h) - 250 zł</option>
224 <option value="family" data-price="500">Rodzinna (4h, max 4 osoby) - 500 zł</option>
225 </select>
226 </div>
227
228 <div class="form-group">
229 <label for="guests">Liczba osób <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 osób</div>
232 </div>
233
234 <div class="form-group">
235 <label for="comments">Dodatkowe uwagi</label>
236 <textarea id="comments" name="comments" rows="4" maxlength="500"></textarea>
237 <div class="char-counter" id="char-counter">0 / 500 znaków</div>
238 </div>
239
240 <div class="price-info">
241 <div style="color: #bdc3c7; margin-bottom: 10px;">Całkowity koszt:</div>
242 <div class="price-value" id="total-price">0 zł</div>
243 </div>
244
245 <button type="submit" class="submit-btn" id="submit-btn" disabled>
246 Zarezerwuj wycieczkę
247 </button>
248 </form>
249
250 <div class="success-message" id="success-message">
251 <h2>Rezerwacja potwierdzona!</h2>
252 <p>Dziękujemy za rezerwację. Szczegóły zostały wysłane na podany adres email.</p>
253 </div>
254
255 <div class="event-log" id="event-log">
256 <strong>Log zdarzeń formularza:</strong>
257 </div>
258 </div>
259
260 <script>
261 console.log('System rezerwacji Parku Jurajskiego - ONLINE');
262
263 // Referencje do elementów
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 // Funkcja logowania
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 // Funkcje walidacji
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} zł`;
329 logEvent(`Zaktualizowano cenę: ${total} zł`);
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 - walidacja w czasie rzeczywistym
343 nameInput.addEventListener('input', function(e) {
344 const isValid = validateName(e.target.value);
345 showError(nameInput, 'name-error', !isValid);
346 logEvent(`Wpisywanie imienia: ${e.target.value.length} znaków`);
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(`Wpisywanie 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 - zmiana wartości
364 dateInput.addEventListener('change', function(e) {
365 const isValid = validateDate(e.target.value);
366 showError(dateInput, 'date-error', !isValid);
367 logEvent(`Wybrano datę: ${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(`Wybrano typ wycieczki: ${selectedText}`);
374 updateTotalPrice();
375 checkFormValidity();
376 });
377
378 guestsInput.addEventListener('change', function(e) {
379 logEvent(`Zmieniono liczbę osób: ${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 na: ${this.name || this.id}`);
389 });
390
391 input.addEventListener('blur', function() {
392 logEvent(`Blur z: ${this.name || this.id}`);
393 });
394 });
395
396 // Licznik znaków dla 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} znaków`;
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('Formularz zawiera błędy. Popraw je przed wysłaniem.');
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') || 'Brak'
427 };
428
429 logEvent(`Wysyłanie formularza...\nDane: ${JSON.stringify(data, null, 2)}`);
430
431 submitBtn.textContent = 'Rezerwuję...';
432 submitBtn.disabled = true;
433
434 setTimeout(() => {
435 form.style.display = 'none';
436 successMessage.style.display = 'block';
437 logEvent('Rezerwacja pomyślnie zarejestrowana!');
438
439 console.log('Dane rezerwacji:', data);
440
441 setTimeout(() => {
442 form.style.display = 'block';
443 successMessage.style.display = 'none';
444 form.reset();
445 submitBtn.textContent = 'Zarezerwuj wycieczkę';
446 totalPriceDisplay.textContent = '0 zł';
447 allInputs.forEach(input => input.classList.remove('valid', 'invalid'));
448 checkFormValidity();
449 }, 3000);
450 }, 2000);
451 });
452
453 // Ustaw minimalną datę na jutro
454 const tomorrow = new Date();
455 tomorrow.setDate(tomorrow.getDate() + 1);
456 dateInput.min = tomorrow.toISOString().split('T')[0];
457
458 logEvent('System rezerwacji załadowany');
459 </script>
460</body>
461</html>Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego odnosi się 'this' w tradycyjnej funkcji event handlera?
2. Co należy zrobić, aby zapobiec domyślnemu przeładowaniu strony przy wysyłaniu formularza?
Zadania praktyczne w grze
- Edytor kodu
W pliku index.html jest pasek #navbar, a style.css chowa go, gdy ma klasę hidden. Skrypt zapamiętuje ostatnią pozycję przewinięcia (window.scrollY). Uzupełnij luki: ___BLANK1___ to zdarzenie przewijania strony nasłuchiwane na window, a ___BLANK2___ to operator porównania, który rozpoznaje przewijanie w dół (nowa pozycja jest większa od poprzedniej). Pasek ma się chować przy przewijaniu w dół i wracać przy przewijaniu w górę.
- Klikanie w kolejności
Ułóż kod do walidacji input w czasie rzeczywistym.
- Układanie w pionie
Ułóż kolejność obsługi zdarzenia submit formularza: