Temat 27 · 2 × 45 min
Zdarzenia zaawansowane i delegacja
W lekcji 12 nauczyłeś się podpinać click pod przycisk. To wystarcza na egzamin, ale nie na prawdziwą aplikację: lista zadań ma sto elementów i każdy ma trzy przyciski, galeria ma reagować na strzałki, formularz ma walidować się w trakcie pisania i wysyłać bez przeładowania strony. Ta lekcja pokazuje, jak zdarzenia naprawdę wędrują po drzewie DOM – i jak to wykorzystać, żeby pisać mniej kodu, który działa w więcej sytuacjach.
Przypomnienie i trzeci argument addEventListener
Podstawy znasz: element.addEventListener(typ, funkcja). Funkcja dostaje obiekt zdarzenia z informacjami (type, target, pozycja myszy, klawisz). Dwie rzeczy, które warto doprecyzować:
function obsluz(event) {
console.log(event.type); // 'click'
}
btn.addEventListener('click', obsluz); // dodanie – funkcja, NIE obsluz()
btn.removeEventListener('click', obsluz); // usunięcie – ta SAMA referencja, inaczej nic się nie stanie
// trzeci argument: obiekt opcji
btn.addEventListener('click', obsluz, { once: true }); // uruchomi się raz i sam się odpnie
window.addEventListener('scroll', obsluz, { passive: true }); // obiecujesz nie wywoływać preventDefault – płynniejsze przewijanie
el.addEventListener('click', obsluz, { capture: true }); // faza przechwytywania (za chwilę)
// odpinanie grupowe przez AbortController (lekcja 30 pokaże go przy fetch)
const kontroler = new AbortController();
btn.addEventListener('click', obsluz, { signal: kontroler.signal });
kontroler.abort(); // odpina wszystkie nasłuchiwacze z tym sygnałemFazy zdarzenia: przechwytywanie, cel, bąbelkowanie
Gdy klikasz przycisk, nie klikasz „tylko” przycisku. Przeglądarka tworzy zdarzenie i przeprowadza je przez drzewo DOM w trzech fazach:
- Przechwytywanie (capturing) – od
windoww dół, przezdocument,html,body… aż do elementu klikniętego. Uruchamiają się tylko nasłuchiwacze dodane z{ capture: true }. - Cel (target) – nasłuchiwacze na samym klikniętym elemencie.
- Bąbelkowanie (bubbling) – z powrotem w górę, aż do
window. Uruchamiają się zwykłe nasłuchiwacze (domyślnecapture: false) na każdym przodku.
Konsekwencja, która zmienia sposób pisania kodu: kliknięcie w <button> jest też kliknięciem w <li>, <ul>, <body> i document. Nasłuchiwacz na ul dowie się o kliknięciu każdego przycisku w środku – nawet dodanego za godzinę.
Użytkownik klika przycisk. W jakiej kolejności pojawią się napisy w konsoli?
<div id="zewn"><div id="wewn"><button id="btn">OK</button></div></div>
zewn.addEventListener('click', () => console.log('zewn'));
wewn.addEventListener('click', () => console.log('wewn'), { capture: true });
btn.addEventListener('click', () => console.log('btn'));
document.addEventListener('click', () => console.log('document'));<label><input type="checkbox" id="capture"> nasłuchiwacze z <code>capture: true</code></label>
<div id="zewn" class="box">zewn
<div id="wewn" class="box">wewn
<button id="btn" type="button">btn</button>
</div>
</div>
<pre id="log"></pre>.box { padding: 14px; margin: 8px 0; border: 2px solid #15171c; border-radius: 8px; background: #eef3ff; }
#wewn { background: #dde7ff; }
button { padding: 6px 14px; }
#log { background: #15171c; color: #f2d338; padding: 8px; min-height: 60px; border-radius: 6px; }const log = document.getElementById('log');
const capture = document.getElementById('capture');
const elementy = [document, document.body, document.getElementById('zewn'), document.getElementById('wewn'), document.getElementById('btn')];
const nazwy = ['document', 'body', 'zewn', 'wewn', 'btn'];
// podpinamy ten sam zestaw nasłuchiwaczy dwa razy: bez i z capture
elementy.forEach((el, i) => {
el.addEventListener('click', (e) => {
if (e.target !== document.getElementById('btn')) return; // interesuje nas tylko klik w przycisk
log.textContent += `${nazwy[i]} (bubbling, faza ${e.eventPhase})\n`;
});
el.addEventListener('click', (e) => {
if (!capture.checked || e.target !== document.getElementById('btn')) return;
log.textContent += `${nazwy[i]} (capture, faza ${e.eventPhase})\n`;
}, { capture: true });
});
// czyścimy log na początku każdego kliknięcia – w fazie capture na window, która jest ZAWSZE pierwsza
window.addEventListener('click', () => { log.textContent = ''; }, { capture: true });event.eventPhase zwraca 1 (capturing), 2 (at target), 3 (bubbling). Na samym celu oba nasłuchiwacze dostają fazę 2; współczesne przeglądarki (zgodnie ze specyfikacją DOM) uruchamiają tam najpierw nasłuchiwacze z capture: true, a potem zwykłe – niezależnie od kolejności dodania.
event.target a event.currentTarget
Skoro jeden nasłuchiwacz na ul dostaje kliknięcia ze wszystkich dzieci, musi wiedzieć, w co naprawdę kliknięto:
| Właściwość | Co zawiera | Zmienia się w trakcie fazy? |
|---|---|---|
event.target | element, w który kliknięto (najgłębszy) | nie – zawsze ten sam |
event.currentTarget | element, na którym wisi ten nasłuchiwacz | tak – inny w każdym nasłuchiwaczu |
this (w zwykłej funkcji) | to samo co currentTarget | w strzałce this jest czymś innym – używaj currentTarget |
<ul id="menu">
<li>Strona główna</li>
<li>Oferta <strong>(nowość)</strong></li>
<li>Kontakt</li>
</ul>
<p id="wynik">Kliknij element listy.</p>#menu li { padding: 6px 10px; cursor: pointer; border-radius: 6px; }
#menu li:hover { background: #fff6c2; }const menu = document.getElementById('menu');
const wynik = document.getElementById('wynik');
menu.addEventListener('click', (e) => {
// Kliknięcie w <strong> da target = STRONG, choć „logicznie” chodzi o <li>
wynik.textContent =
`target: <${e.target.tagName.toLowerCase()}>, ` +
`currentTarget: <${e.currentTarget.tagName.toLowerCase()}>, ` +
`closest('li'): „${e.target.closest('li')?.textContent.trim()}”`;
});Kliknij w pogrubione „(nowość)” – target to <strong>, nie <li>. Dlatego w delegacji prawie nigdy nie porównuje się target bezpośrednio; używa się event.target.closest('selektor'), które idzie w górę od klikniętego elementu i zwraca pierwszego pasującego przodka (albo sam element), albo null.
Delegacja zdarzeń
Delegacja (event delegation) to wzorzec: zamiast podpinać nasłuchiwacz do każdego elementu, podpinasz jeden do wspólnego rodzica i w nim sprawdzasz, co było celem. Zyski:
- jeden nasłuchiwacz zamiast setek – mniej pamięci, prostszy kod,
- działa dla elementów dodanych później (lista zadań, wyniki wyszukiwania, wiersze tabeli z API),
- nie musisz nic odpinać przy usuwaniu elementów,
- logika jest w jednym miejscu, sterowana atrybutami
data-*.
<form id="dodawanie">
<input id="nowe" placeholder="Nowe zadanie" required>
<button type="submit">Dodaj</button>
</form>
<ul id="lista">
<li><span>Powtórzyć lekcję 26</span> <button data-akcja="gotowe" type="button">✓</button> <button data-akcja="usun" type="button">✕</button></li>
<li><span>Napisać funkcję zapisz()</span> <button data-akcja="gotowe" type="button">✓</button> <button data-akcja="usun" type="button">✕</button></li>
</ul>
<p id="licznik"></p>#lista { list-style: none; padding: 0; }
#lista li { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid #ddd; }
#lista li span { flex: 1; }
#lista li.gotowe span { text-decoration: line-through; opacity: 0.5; }
#lista button { border: 1px solid #15171c; background: #fff; border-radius: 6px; cursor: pointer; }const lista = document.getElementById('lista');
const licznik = document.getElementById('licznik');
const formularz = document.getElementById('dodawanie');
const pole = document.getElementById('nowe');
function odswiezLicznik() {
const wszystkie = lista.children.length;
const gotowe = lista.querySelectorAll('li.gotowe').length;
licznik.textContent = `Zrobione ${gotowe} z ${wszystkie}`;
}
// JEDEN nasłuchiwacz obsługuje wszystkie przyciski – także te w zadaniach dodanych później
lista.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-akcja]');
if (!btn) return; // kliknięto gdzie indziej (np. w tekst) – nic nie robimy
const li = btn.closest('li');
switch (btn.dataset.akcja) {
case 'gotowe':
li.classList.toggle('gotowe');
break;
case 'usun':
li.remove();
break;
}
odswiezLicznik();
});
formularz.addEventListener('submit', (e) => {
e.preventDefault(); // formularz NIE przeładuje strony
const li = document.createElement('li');
li.innerHTML = `<span></span> <button data-akcja="gotowe" type="button">✓</button> <button data-akcja="usun" type="button">✕</button>`;
li.querySelector('span').textContent = pole.value.trim(); // textContent – bezpieczne dla wpisanego tekstu
lista.append(li);
formularz.reset();
pole.focus();
odswiezLicznik();
});
odswiezLicznik();Zwróć uwagę na trzy elementy wzorca: closest('button[data-akcja]') (znajdź przycisk, nawet jeśli kliknięto ikonę w nim), wczesny return, gdy kliknięcie nie dotyczy nas, i dataset.akcja jako „co zrobić”. Nowe <li> nie ma żadnego nasłuchiwacza – i nie potrzebuje.
Lista ma 50 elementów <li>, a użytkownik może dodawać nowe. Które rozwiązanie obsłuży kliknięcie w każdy – stary i nowy – element?
stopPropagation – rzadko i świadomie
event.stopPropagation() zatrzymuje dalszą wędrówkę zdarzenia (nie trafi do przodków). event.stopImmediatePropagation() dodatkowo blokuje pozostałe nasłuchiwacze na tym samym elemencie. Kuszące, ale groźne: jeśli w przycisku w liście zatrzymasz propagację, delegacja na ul przestanie go widzieć, zamykanie menu po kliknięciu „gdziekolwiek” na document się nie uruchomi, a skrypt analityczny nic nie policzy. Zasada: zamiast zatrzymywać zdarzenie, sprawdzaj w nasłuchiwaczu, czy cię dotyczy (closest, wczesny return). Uzasadniony wyjątek to np. okno modalne, gdzie klik w treść nie ma zamykać okna, a klik w tło – tak; nawet tam lepiej porównać e.target === tlo.
Akcje domyślne i preventDefault()
Wiele zdarzeń ma wbudowaną reakcję przeglądarki: link przechodzi pod adres, submit wysyła formularz i przeładowuje stronę, prawy przycisk otwiera menu kontekstowe, keydown ze spacją przewija stronę, kliknięcie checkboxa go zaznacza. event.preventDefault() odwołuje tę reakcję – zdarzenie dalej bąbelkuje, tylko przeglądarka nic z nim nie robi.
// link, który otwiera własny podgląd zamiast przechodzić pod adres
link.addEventListener('click', (e) => {
e.preventDefault();
pokazPodglad(link.href);
});
// formularz obsłużony w JS – bez przeładowania strony
form.addEventListener('submit', (e) => {
e.preventDefault();
wyslijFetchem(new FormData(form)); // lekcja 31
});
// tylko cyfry w polu (keydown pozwala zablokować znak, zanim się pojawi)
pole.addEventListener('keydown', (e) => {
const sterujacy = e.key.length > 1 || e.ctrlKey || e.metaKey; // Backspace, Tab, strzałki, Ctrl+V…
if (!sterujacy && !/[0-9]/.test(e.key)) e.preventDefault();
});
console.log(e.defaultPrevented); // true, jeśli ktoś wcześniej wywołał preventDefault
console.log(e.cancelable); // nie każde zdarzenie da się odwołać – np. 'scroll' już nieFormularze: input, change, submit
Trzy zdarzenia, które w lekcji 13 były wspomniane, a teraz wracają w pełnym świetle:
| Zdarzenie | Kiedy | Na czym | Zastosowanie |
|---|---|---|---|
input | przy każdej zmianie wartości (każdy znak, każdy ruch suwaka) | pola tekstowe, range, select, textarea, checkbox | podgląd na żywo, licznik znaków, filtrowanie listy |
change | gdy zmiana jest „zatwierdzona”: opuszczenie pola tekstowego, wybór z select, kliknięcie checkbox/radio | wszystkie kontrolki | walidacja po zakończeniu wpisywania, przeliczenie po wyborze |
submit | wysłanie formularza: Enter w polu lub klik przycisku type="submit" | tylko <form>, nigdy przycisk | jedna obsługa dla Entera i kliknięcia |
Słuchaj submit na formularzu, nie click na przycisku – wtedy Enter w polu też zadziała, a walidacja HTML5 (required, type="email", min) uruchomi się przed twoim kodem: jeśli pole jest niepoprawne, zdarzenie submit w ogóle nie powstanie.
<form id="rejestracja" novalidate>
<label>Login (3–12 znaków)<br><input name="login" minlength="3" maxlength="12" required></label>
<p class="podpowiedz" id="login-info"></p>
<label>Hasło<br><input name="haslo" type="password" required></label>
<label>Powtórz hasło<br><input name="haslo2" type="password" required></label>
<p class="podpowiedz" id="haslo-info"></p>
<label><input name="regulamin" type="checkbox"> Akceptuję regulamin</label>
<button type="submit">Zarejestruj</button>
</form>
<p id="wynik"></p>label { display: block; margin-top: 8px; }
input:not([type=checkbox]) { padding: 6px; width: 240px; }
.podpowiedz { margin: 2px 0 0; font-size: 13px; color: crimson; min-height: 1.2em; }
.podpowiedz.ok { color: green; }
#wynik { font-weight: 700; }const form = document.getElementById('rejestracja');
const loginInfo = document.getElementById('login-info');
const hasloInfo = document.getElementById('haslo-info');
const wynik = document.getElementById('wynik');
// form.elements daje dostęp do kontrolek po atrybucie name
const { login, haslo, haslo2, regulamin } = form.elements;
// 'input' na całym formularzu (bąbelkuje!) – jedna funkcja odświeża wszystkie podpowiedzi
form.addEventListener('input', (e) => {
if (e.target === login) {
const ok = login.value.trim().length >= 3;
loginInfo.textContent = ok ? `Login „${login.value.trim()}” wygląda dobrze` : `Za krótki – brakuje znaków: ${3 - login.value.trim().length}`;
loginInfo.classList.toggle('ok', ok);
}
if (e.target === haslo || e.target === haslo2) {
const zgodne = haslo.value !== '' && haslo.value === haslo2.value;
hasloInfo.textContent = haslo2.value === '' ? '' : zgodne ? 'Hasła zgodne' : 'Hasła się różnią';
hasloInfo.classList.toggle('ok', zgodne);
}
});
// 'change' na checkboxie – zatwierdzona zmiana
regulamin.addEventListener('change', () => {
console.log('Regulamin:', regulamin.checked ? 'zaakceptowany' : 'odznaczony');
});
form.addEventListener('submit', (e) => {
e.preventDefault(); // zostajemy na stronie
const bledy = [];
if (login.value.trim().length < 3) bledy.push('za krótki login');
if (haslo.value !== haslo2.value || haslo.value === '') bledy.push('hasła niezgodne');
if (!regulamin.checked) bledy.push('brak akceptacji regulaminu');
if (bledy.length) {
wynik.style.color = 'crimson';
wynik.textContent = 'Popraw: ' + bledy.join(', ');
return;
}
// FormData zbiera wszystkie pola z atrybutem name – gotowe do wysłania (lekcja 31)
const dane = Object.fromEntries(new FormData(form));
wynik.style.color = 'green';
wynik.textContent = `Zarejestrowano użytkownika ${dane.login}`;
console.log('Dane z FormData:', dane);
form.reset();
loginInfo.textContent = hasloInfo.textContent = '';
});Atrybut novalidate wyłączyłem celowo, żeby pokazać walidację w JS; bez niego przeglądarka sama zablokowałaby submit dla pustych pól required. W praktyce łączysz oba mechanizmy (lekcja 18): HTML5 jako pierwsza linia, JS dla reguł, których atrybutami nie zapiszesz (zgodność haseł).
Klawiatura: key, code i modyfikatory
Zdarzenia klawiatury to keydown (naciśnięcie, powtarza się przy trzymaniu), keyup (puszczenie) i przestarzałe keypress – nie używaj go. Najważniejsze właściwości:
| Właściwość | Co zawiera | Przykłady |
|---|---|---|
event.key | znak lub nazwa klawisza z uwzględnieniem układu i Shift | 'a', 'A', 'ż', 'Enter', 'ArrowLeft', 'Escape', ' ' (spacja) |
event.code | fizyczny klawisz, niezależny od układu | 'KeyA', 'Digit1', 'Space', 'ShiftLeft' |
ctrlKey, shiftKey, altKey, metaKey | czy modyfikator był wciśnięty (metaKey = Cmd na Macu, Win na PC) | e.ctrlKey && e.key === 's' |
repeat | true, gdy zdarzenie pochodzi z trzymania klawisza | ignorowanie powtórzeń w grze |
keyCode, which, charCode | przestarzałe – liczby typu 13, 37; działają, ale nie pisz nowego kodu z nimi | zamiast keyCode === 13 → key === 'Enter' |
Kiedy key, a kiedy code? Do skrótów i tekstu (Enter, Escape, litera, którą użytkownik widzi na klawiszu) – key. Do sterowania w grach (WASD ma być w tym samym miejscu na klawiaturze francuskiej, gdzie fizycznie jest ZQSD) – code.
<p>Kliknij tutaj, a potem naciśnij dowolny klawisz (spróbuj Shift+a, ż, strzałki, Ctrl+K).</p>
<table>
<tr><th>key</th><td id="key">–</td></tr>
<tr><th>code</th><td id="code">–</td></tr>
<tr><th>modyfikatory</th><td id="mod">–</td></tr>
</table>th { text-align: left; padding-right: 12px; } td { font-family: monospace; }document.addEventListener('keydown', (e) => {
// skrót Ctrl+K: blokujemy akcję przeglądarki (np. pasek wyszukiwania)
if (e.ctrlKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
console.log('Skrót Ctrl+K przechwycony');
}
document.getElementById('key').textContent = JSON.stringify(e.key);
document.getElementById('code').textContent = e.code;
const mod = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => e[m]).join(' + ');
document.getElementById('mod').textContent = (mod || 'brak') + (e.repeat ? ' (powtórzenie)' : '');
});Gdzie podpinać nasłuchiwacz? Skróty globalne – na document (albo window), bo zdarzenia klawiatury bąbelkują od elementu z fokusem. Ale wtedy pamiętaj: gdy użytkownik pisze w polu tekstowym, strzałka w lewo ma przesuwać kursor, nie slajd galerii. Sprawdź, skąd przyszło zdarzenie:
<figure>
<div id="slajd" class="slajd">1</div>
<figcaption id="opis">Slajd 1 z 5 – użyj strzałek</figcaption>
</figure>
<input placeholder="Tu strzałki nie zmieniają slajdu – przesuwają kursor">.slajd { width: 220px; height: 110px; display: grid; place-items: center; font-size: 48px; font-weight: 700; color: #fff; border-radius: 12px; transition: background 0.2s; }
figure { margin: 0 0 10px; }
input { width: 100%; padding: 6px; box-sizing: border-box; }const kolory = ['#2563eb', '#dc2626', '#16a34a', '#9333ea', '#ea580c'];
let indeks = 0;
const slajd = document.getElementById('slajd');
const opis = document.getElementById('opis');
function pokaz(nowy) {
indeks = (nowy + kolory.length) % kolory.length; // zawijanie w obie strony (lekcja 17)
slajd.textContent = indeks + 1;
slajd.style.background = kolory[indeks];
opis.textContent = `Slajd ${indeks + 1} z ${kolory.length} – użyj strzałek`;
}
document.addEventListener('keydown', (e) => {
// gdy użytkownik pisze w polu, nie przechwytujemy klawiszy
if (e.target.matches('input, textarea, select, [contenteditable]')) return;
const akcje = {
ArrowRight: () => pokaz(indeks + 1),
ArrowLeft: () => pokaz(indeks - 1),
Home: () => pokaz(0),
End: () => pokaz(kolory.length - 1),
};
const akcja = akcje[e.key];
if (akcja) {
e.preventDefault(); // strzałki nie przewiną strony
akcja();
}
});
pokaz(0);Obiekt akcje zamiast łańcucha if…else if to przyjemny wzorzec: dodanie nowego skrótu to jedna linia.
Mysz i wskaźnik: mouseover vs mouseenter, pointer*
Kilka par, które warto odróżniać:
| Zdarzenie | Bąbelkuje | Uruchamia się też przy przejściu na dziecko | Użyj do |
|---|---|---|---|
mouseover / mouseout | tak | tak (irytujące migotanie) | delegacji: najechanie na dowolny <li> w liście |
mouseenter / mouseleave | nie | nie | pojedynczego elementu: pauza slidera na najechanie (lekcja 28) |
pointerdown / pointermove / pointerup | tak | – | jednej obsługi myszy, dotyku i rysika; e.pointerType mówi, co to było |
dblclick, contextmenu, wheel | tak | – | podwójne kliknięcie, prawy przycisk (z preventDefault – własne menu), kółko |
Współrzędne: e.clientX/Y (względem okna), e.pageX/Y (względem dokumentu, z przewinięciem), e.offsetX/Y (względem elementu). e.button to 0 (lewy), 1 (środkowy), 2 (prawy).
Ładowanie strony: DOMContentLoaded, load, defer
Skrypt w <head> bez defer wykonuje się, zanim istnieje <body> – getElementById zwraca null i kod wysypuje się na null.addEventListener. Trzy poprawne rozwiązania, od najlepszego:
<!-- 1. Najlepsze: defer – pobiera równolegle, uruchamia po zbudowaniu DOM, zachowuje kolejność -->
<script src="app.js" defer></script>
<!-- 2. Moduły ES są domyślnie odroczone jak defer (lekcja 32) -->
<script type="module" src="app.js"></script>
<!-- 3. Klasyka: skrypt na końcu body – elementy wyżej już istnieją -->
<body>
…
<script src="app.js"></script>
</body>Jeśli nie kontrolujesz, gdzie skrypt trafi (np. wstawka w CMS), owiń kod w nasłuchiwacz:
document.addEventListener('DOMContentLoaded', () => {
// DOM zbudowany – elementy istnieją, ale obrazy i style mogą się jeszcze pobierać
});
window.addEventListener('load', () => {
// wszystko pobrane: obrazy, czcionki, arkusze – rzadko potrzebne (np. pomiar rozmiaru obrazka)
});W placach zabaw na tej stronie kod JS jest zawsze wstawiany na końcu <body>, więc nie musisz o tym pamiętać. Na egzaminie INF.03 też najczęściej umieścisz <script> na końcu – ale defer jest poprawny i nowocześniejszy.
Własne zdarzenia
Możesz tworzyć własne typy zdarzeń – przydatne, gdy komponent (np. koszyk) chce powiadomić resztę strony o zmianie, nie znając jej:
<button id="dodaj" type="button">Dodaj do koszyka</button>
<span id="naglowek">Koszyk: 0 szt.</span>const koszyk = { sztuk: 0 };
const btn = document.getElementById('dodaj');
btn.addEventListener('click', () => {
koszyk.sztuk++;
// bubbles: true – zdarzenie powędruje do document, więc można je złapać gdziekolwiek wyżej
btn.dispatchEvent(new CustomEvent('koszyk:zmiana', { bubbles: true, detail: { sztuk: koszyk.sztuk } }));
});
// nagłówek nie wie nic o przycisku – słucha zdarzenia na document
document.addEventListener('koszyk:zmiana', (e) => {
document.getElementById('naglowek').textContent = `Koszyk: ${e.detail.sztuk} szt.`;
console.log('Odebrano koszyk:zmiana, detail =', e.detail);
});detail to miejsce na dane. Nazwy z dwukropkiem lub prefiksem (koszyk:zmiana) zapobiegają kolizjom z przyszłymi zdarzeniami przeglądarki. Ten mechanizm to w miniaturze to, co frameworki nazywają „emitowaniem zdarzeń” z komponentu.
Ściąga
| Składnia | Co robi | Przykład |
|---|---|---|
el.addEventListener(typ, fn, opcje) | podpina fn; opcje: once, passive, capture, signal | btn.addEventListener('click', f, { once: true }) |
el.removeEventListener(typ, fn) | odpina – ta sama referencja | btn.removeEventListener('click', f) |
| fazy: capture → target → bubble | zwykłe nasłuchiwacze działają w bubbling; { capture: true } w capturing | e.eventPhase → 1, 2, 3 |
e.target | element, w który naprawdę kliknięto | e.target.tagName |
e.currentTarget | element z tym nasłuchiwaczem | w delegacji: lista |
e.target.closest(sel) | najbliższy przodek (lub sam element) pasujący do selektora, albo null | e.target.closest('li') |
| delegacja | jeden nasłuchiwacz na rodzicu + closest + dataset | lista.addEventListener('click', …) |
e.preventDefault() | odwołuje akcję przeglądarki (link, submit, znak w polu) | w submit, keydown |
e.stopPropagation() | zatrzymuje bąbelkowanie – używaj rzadko | modal: klik w treść nie zamyka |
input / change / submit | każda zmiana / zatwierdzona zmiana / wysłanie formularza (na <form>) | form.addEventListener('submit', …) |
new FormData(form), form.elements.nazwa | dane formularza; kontrolka po name | Object.fromEntries(new FormData(form)) |
e.key, e.code | znak/nazwa klawisza; fizyczny klawisz | 'Enter', 'ArrowLeft' / 'KeyW' |
e.ctrlKey, e.shiftKey, e.altKey, e.metaKey, e.repeat | modyfikatory i powtórzenie | e.ctrlKey && e.key === 's' |
mouseenter/mouseleave vs mouseover/mouseout | bez bąbelkowania / z bąbelkowaniem | pauza slidera / delegacja hover |
pointerdown/move/up | mysz + dotyk + rysik w jednym | e.pointerType |
DOMContentLoaded, load, defer | DOM gotowy / wszystko pobrane / skrypt odroczony | <script src defer> |
new CustomEvent(nazwa, { detail, bubbles }) + dispatchEvent | własne zdarzenie z danymi | koszyk:zmiana |
Zadania
Zakładki jednym nasłuchiwaczem
HTML: <nav id="zakladki"> z trzema przyciskami data-panel="opis" | "sklad" | "opinie" oraz trzy <section id="opis">, <section id="sklad">, <section id="opinie">. Na starcie widoczna jest tylko pierwsza sekcja, a jej przycisk ma klasę aktywna. Napisz jeden nasłuchiwacz na nav, który po kliknięciu pokazuje właściwą sekcję (hidden), przenosi klasę aktywna i ignoruje kliknięcia poza przyciskami. Dodatkowo: strzałki ←/→, gdy fokus jest na przycisku zakładki, przełączają na sąsiednią.
Pokaż rozwiązanie
<nav id="zakladki">
<button type="button" data-panel="opis" class="aktywna">Opis</button>
<button type="button" data-panel="sklad">Skład</button>
<button type="button" data-panel="opinie">Opinie</button>
</nav>
<section id="opis">Sok jabłkowy 100 %, tłoczony na zimno.</section>
<section id="sklad" hidden>Jabłka. Nic więcej.</section>
<section id="opinie" hidden>„Najlepszy sok w szkolnym sklepiku” – Kuba, 3TP</section>#zakladki button { padding: 8px 14px; border: 1px solid #15171c; background: #fff; cursor: pointer; }
#zakladki button.aktywna { background: #f2d338; font-weight: 700; }
section { padding: 12px; border: 1px solid #15171c; margin-top: -1px; }const nav = document.getElementById('zakladki');
const przyciski = [...nav.querySelectorAll('button[data-panel]')];
function pokaz(btn) {
for (const p of przyciski) {
const aktywny = p === btn;
p.classList.toggle('aktywna', aktywny);
document.getElementById(p.dataset.panel).hidden = !aktywny; // sekcja o id = data-panel
}
btn.focus();
}
// jeden nasłuchiwacz klików na całej nawigacji
nav.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-panel]');
if (btn) pokaz(btn);
});
// strzałki – też na nav, bo keydown bąbelkuje od przycisku z fokusem
nav.addEventListener('keydown', (e) => {
const btn = e.target.closest('button[data-panel]');
if (!btn || !['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
const i = przyciski.indexOf(btn);
const krok = e.key === 'ArrowRight' ? 1 : -1;
pokaz(przyciski[(i + krok + przyciski.length) % przyciski.length]);
});Tabela ocen sortowana kliknięciem w nagłówek
Masz tabelę <table id="oceny"> z nagłówkami Uczeń, Matematyka, Informatyka, Średnia i 5 wierszami danych w <tbody>. Kolumna „Średnia” ma być wyliczana skryptem na starcie. Kliknięcie w nagłówek <th data-kolumna="0..3"> sortuje wiersze po tej kolumnie (tekst – alfabetycznie z localeCompare, liczby – rosnąco); drugie kliknięcie w ten sam nagłówek odwraca kierunek. Aktywny nagłówek dostaje klasę sort-asc lub sort-desc. Użyj jednego nasłuchiwacza na <thead>.
Pokaż rozwiązanie
<table id="oceny">
<thead>
<tr><th data-kolumna="0">Uczeń</th><th data-kolumna="1">Matematyka</th><th data-kolumna="2">Informatyka</th><th data-kolumna="3">Średnia</th></tr>
</thead>
<tbody>
<tr><td>Zofia</td><td>5</td><td>4</td><td></td></tr>
<tr><td>Adam</td><td>3</td><td>6</td><td></td></tr>
<tr><td>Łukasz</td><td>4</td><td>4</td><td></td></tr>
<tr><td>Ewa</td><td>6</td><td>5</td><td></td></tr>
<tr><td>Celina</td><td>2</td><td>3</td><td></td></tr>
</tbody>
</table>table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 6px 10px; text-align: left; }
th { cursor: pointer; background: #f3f3f3; user-select: none; }
th.sort-asc::after { content: ' ▲'; } th.sort-desc::after { content: ' ▼'; }const tabela = document.getElementById('oceny');
const tbody = tabela.tBodies[0];
let aktualna = { kolumna: null, rosnaco: true };
// 1. wylicz średnie na starcie
for (const tr of tbody.rows) {
const m = Number(tr.cells[1].textContent);
const i = Number(tr.cells[2].textContent);
tr.cells[3].textContent = ((m + i) / 2).toFixed(1);
}
function sortuj(kolumna) {
// ten sam nagłówek → odwróć kierunek; inny → rosnąco
aktualna = { kolumna, rosnaco: aktualna.kolumna === kolumna ? !aktualna.rosnaco : true };
const wiersze = [...tbody.rows];
const liczbowa = kolumna > 0;
wiersze.sort((a, b) => {
const x = a.cells[kolumna].textContent;
const y = b.cells[kolumna].textContent;
const wynik = liczbowa ? Number(x) - Number(y) : x.localeCompare(y, 'pl');
return aktualna.rosnaco ? wynik : -wynik;
});
tbody.append(...wiersze); // append istniejących węzłów = przeniesienie w nowej kolejności
for (const th of tabela.tHead.querySelectorAll('th')) {
th.classList.remove('sort-asc', 'sort-desc');
}
tabela.tHead.querySelector(`th[data-kolumna="${kolumna}"]`).classList.add(aktualna.rosnaco ? 'sort-asc' : 'sort-desc');
}
// 2. jeden nasłuchiwacz na thead
tabela.tHead.addEventListener('click', (e) => {
const th = e.target.closest('th[data-kolumna]');
if (th) sortuj(Number(th.dataset.kolumna));
});Licznik znaków i blokada wysyłki
Formularz komentarza: <textarea name="tresc" maxlength="200">, licznik <p id="licznik">0 / 200</p>, <select name="ocena"> z opcjami 1–5 (pierwsza pusta: „– wybierz –”), przycisk „Opublikuj” (type="submit"). Wymagania:
inputna textarea aktualizuje licznik; powyżej 180 znaków licznik jest pomarańczowy, przy 200 – czerwony,- przycisk jest
disabled, dopóki treść ma mniej niż 10 znaków lub nie wybrano oceny (reaguj nainputcałego formularza), submit(bez przeładowania) dokleja komentarz do<ul id="komentarze">jako<li>z gwiazdkami ('★'.repeat(ocena)) i tekstem, po czym czyści formularz,- Ctrl+Enter w textarea wysyła formularz (
form.requestSubmit()– uruchamia walidację i zdarzeniesubmit, w odróżnieniu odform.submit()).
Pokaż rozwiązanie
<form id="komentarz">
<textarea name="tresc" maxlength="200" rows="3" placeholder="Twój komentarz (min. 10 znaków)…"></textarea>
<p id="licznik">0 / 200</p>
<select name="ocena">
<option value="">– wybierz ocenę –</option>
<option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
<button type="submit" disabled>Opublikuj</button>
</form>
<ul id="komentarze"></ul>textarea { width: 100%; box-sizing: border-box; padding: 6px; }
#licznik { margin: 4px 0; font-size: 13px; color: #555; }
#licznik.uwaga { color: darkorange; } #licznik.limit { color: crimson; font-weight: 700; }
button:disabled { opacity: 0.5; }const form = document.getElementById('komentarz');
const { tresc, ocena } = form.elements;
const przycisk = form.querySelector('button[type=submit]');
const licznik = document.getElementById('licznik');
const lista = document.getElementById('komentarze');
const MAX = Number(tresc.maxLength);
function odswiez() {
const n = tresc.value.length;
licznik.textContent = `${n} / ${MAX}`;
licznik.className = n >= MAX ? 'limit' : n > 180 ? 'uwaga' : '';
przycisk.disabled = tresc.value.trim().length < 10 || ocena.value === '';
}
// 'input' bąbelkuje z textarea i select – jedna funkcja na cały formularz
form.addEventListener('input', odswiez);
form.addEventListener('submit', (e) => {
e.preventDefault();
const li = document.createElement('li');
li.textContent = `${'★'.repeat(Number(ocena.value))} ${tresc.value.trim()}`;
lista.prepend(li); // najnowszy na górze
form.reset();
odswiez();
tresc.focus();
});
// Ctrl+Enter (lub Cmd+Enter na Macu) wysyła – requestSubmit uruchamia zdarzenie submit
tresc.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !przycisk.disabled) {
e.preventDefault();
form.requestSubmit();
}
});
odswiez();Szubienica sterowana klawiaturą
Gra w wisielca (szubienicę):
- tablica haseł (np.
['JAVASCRIPT', 'DELEGACJA', 'PRZEGLADARKA', 'FORMULARZ']), losowane na start i po „Nowa gra”, <p id="haslo">pokazuje_ _ _ _z odkrytymi literami,<p id="uzyte">– użyte litery,<p id="status">–Pozostało prób: 6/Wygrana!/Przegrana – hasło: …,- litery wpisujesz klawiaturą (
keydownnadocument): przyjmuj tylko A–Z (/^[a-z]$/i), ignoruj powtórki i klawisze po zakończeniu gry; Enter po zakończeniu rozpoczyna nową grę, - dodatkowo ekranowa klawiatura: 26 przycisków generowanych w pętli w
<div id="klawisze">, obsługiwana jednym nasłuchiwaczem (delegacja), użyte przyciski dostajądisabled, - obie drogi (fizyczna i ekranowa) wywołują tę samą funkcję
zgaduj(litera).
Pokaż rozwiązanie
<p id="haslo"></p>
<p id="status"></p>
<p id="uzyte"></p>
<div id="klawisze"></div>
<button id="nowa" type="button">Nowa gra</button>#haslo { font-family: monospace; font-size: 28px; letter-spacing: 4px; margin: 0; }
#klawisze { display: grid; grid-template-columns: repeat(13, 1fr); gap: 4px; margin: 8px 0; }
#klawisze button { padding: 6px 0; border: 1px solid #15171c; border-radius: 4px; background: #fff; cursor: pointer; }
#klawisze button:disabled { opacity: 0.35; cursor: default; }const HASLA = ['JAVASCRIPT', 'DELEGACJA', 'PRZEGLADARKA', 'FORMULARZ', 'KLAWIATURA'];
const PROBY = 6;
const litery = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const hasloEl = document.getElementById('haslo');
const statusEl = document.getElementById('status');
const uzyteEl = document.getElementById('uzyte');
const klawisze = document.getElementById('klawisze');
let haslo, uzyte, bledy, koniec;
// ekranowa klawiatura – raz, w pętli
for (const l of litery) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = l;
b.dataset.litera = l;
klawisze.append(b);
}
function nowaGra() {
haslo = HASLA[Math.floor(Math.random() * HASLA.length)];
uzyte = new Set();
bledy = 0;
koniec = false;
for (const b of klawisze.children) b.disabled = false;
render();
}
function render() {
hasloEl.textContent = [...haslo].map((l) => (uzyte.has(l) ? l : '_')).join(' ');
uzyteEl.textContent = uzyte.size ? `Użyte: ${[...uzyte].join(', ')}` : 'Wpisz literę z klawiatury lub kliknij przycisk.';
const wygrana = [...haslo].every((l) => uzyte.has(l));
if (wygrana) {
statusEl.textContent = 'Wygrana! Enter = nowa gra';
koniec = true;
} else if (bledy >= PROBY) {
statusEl.textContent = `Przegrana – hasło: ${haslo}. Enter = nowa gra`;
koniec = true;
} else {
statusEl.textContent = `Pozostało prób: ${PROBY - bledy}`;
}
}
// WSPÓLNA logika dla obu dróg wprowadzania
function zgaduj(litera) {
if (koniec || uzyte.has(litera)) return;
uzyte.add(litera);
if (!haslo.includes(litera)) bledy++;
klawisze.querySelector(`[data-litera="${litera}"]`).disabled = true;
render();
}
// fizyczna klawiatura
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && koniec) return nowaGra();
if (/^[a-z]$/i.test(e.key)) zgaduj(e.key.toUpperCase());
});
// ekranowa – delegacja
klawisze.addEventListener('click', (e) => {
const b = e.target.closest('button[data-litera]');
if (b) zgaduj(b.dataset.litera);
});
document.getElementById('nowa').addEventListener('click', nowaGra);
nowaGra();Test powtórkowy
Test powtórkowy
Fazy zdarzeń, delegacja, preventDefault, formularze i klawiatura. Jedna poprawna odpowiedź.
Użytkownik klika <span> wewnątrz <li> wewnątrz <ul>. Nasłuchiwacz click jest tylko na ul. Co zawiera event.target?
W jakiej kolejności pojawią się napisy po kliknięciu przycisku?
document.addEventListener('click', () => console.log('A'), { capture: true });
document.body.addEventListener('click', () => console.log('B'));
btn.addEventListener('click', () => console.log('C'));Które zdanie poprawnie opisuje różnicę między preventDefault() a stopPropagation()?
Formularz po kliknięciu „Wyślij” wciąż przeładowuje stronę. Który kod jest poprawnym rozwiązaniem?
Które zdarzenie uruchamia się przy KAŻDYM wpisanym znaku w polu tekstowym?
Jak nowocześnie sprawdzić, że naciśnięto Enter?
Skrypt w <head> kończy się błędem Cannot read properties of null (reading 'addEventListener'). Co jest najbardziej prawdopodobną przyczyną i poprawką?
Co się stanie po wykonaniu tego kodu i kliknięciu przycisku dwa razy?
function f() { console.log('klik'); }
btn.addEventListener('click', f, { once: true });
btn.removeEventListener('click', () => f());Które zdarzenie myszy NIE bąbelkuje i nie odpala się ponownie przy przejściu kursora na element potomny?
Które stwierdzenie o delegacji zdarzeń jest FAŁSZYWE?