Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Wydarzenia formularzy

5 min czytania

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. 1. Do czego odnosi się 'this' w tradycyjnej funkcji event handlera?

  2. 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:

Przydatne artykuły