Przejdź do treści

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ć:

podstawy.js
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łem

Fazy 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:

  1. Przechwytywanie (capturing) – od window w dół, przez document, html, body… aż do elementu klikniętego. Uruchamiają się tylko nasłuchiwacze dodane z { capture: true }.
  2. Cel (target) – nasłuchiwacze na samym klikniętym elemencie.
  3. Bąbelkowanie (bubbling) – z powrotem w górę, aż do window. Uruchamiają się zwykłe nasłuchiwacze (domyślne capture: false) na każdym przodku.
Zagnieżdżone prostokąty window, document, body, ul, li, button; po lewej strzałka w dół podpisana przechwytywanie, po prawej strzałka w górę podpisana bąbelkowanie, pod spodem faza celu
Rys. 1. Zdarzenie schodzi w dół (faza 1), trafia w cel (faza 2) i wraca w górę (faza 3). Zwykłe nasłuchiwacze działają w fazie 3.

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ę.

Sprawdź się

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'));
Fazy zdarzenia – kliknij przycisk, a potem zaznacz capture
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 });
Podgląd

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 zawieraZmienia się w trakcie fazy?
event.targetelement, w który kliknięto (najgłębszy)nie – zawsze ten sam
event.currentTargetelement, na którym wisi ten nasłuchiwacztak – inny w każdym nasłuchiwaczu
this (w zwykłej funkcji)to samo co currentTargetw strzałce this jest czymś innym – używaj currentTarget
target vs currentTarget
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()}”`;
});
Podgląd

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-*.
Lista zadań z delegacją – jeden nasłuchiwacz na ul
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();
Podgląd

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.

Sprawdź się

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.

preventDefault.js
// 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ż nie

Formularze: input, change, submit

Trzy zdarzenia, które w lekcji 13 były wspomniane, a teraz wracają w pełnym świetle:

ZdarzenieKiedyNa czymZastosowanie
inputprzy każdej zmianie wartości (każdy znak, każdy ruch suwaka)pola tekstowe, range, select, textarea, checkboxpodgląd na żywo, licznik znaków, filtrowanie listy
changegdy zmiana jest „zatwierdzona”: opuszczenie pola tekstowego, wybór z select, kliknięcie checkbox/radiowszystkie kontrolkiwalidacja po zakończeniu wpisywania, przeliczenie po wyborze
submitwysłanie formularza: Enter w polu lub klik przycisku type="submit"tylko <form>, nigdy przyciskjedna 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.

Formularz: input na żywo, submit bez przeładowania
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 = '';
});
Podgląd

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 zawieraPrzykłady
event.keyznak lub nazwa klawisza z uwzględnieniem układu i Shift'a', 'A', 'ż', 'Enter', 'ArrowLeft', 'Escape', ' ' (spacja)
event.codefizyczny klawisz, niezależny od układu'KeyA', 'Digit1', 'Space', 'ShiftLeft'
ctrlKey, shiftKey, altKey, metaKeyczy modyfikator był wciśnięty (metaKey = Cmd na Macu, Win na PC)e.ctrlKey && e.key === 's'
repeattrue, gdy zdarzenie pochodzi z trzymania klawiszaignorowanie powtórzeń w grze
keyCode, which, charCodeprzestarzałe – liczby typu 13, 37; działają, ale nie pisz nowego kodu z nimizamiast 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.

Tester klawiszy – kliknij w podgląd i naciskaj klawisze
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)' : '');
});
Podgląd

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:

Galeria sterowana strzałkami (←/→, Home, End) – kliknij w podgląd
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);
Podgląd

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ć:

ZdarzenieBąbelkujeUruchamia się też przy przejściu na dzieckoUżyj do
mouseover / mouseouttaktak (irytujące migotanie)delegacji: najechanie na dowolny <li> w liście
mouseenter / mouseleavenieniepojedynczego elementu: pauza slidera na najechanie (lekcja 28)
pointerdown / pointermove / pointeruptak–jednej obsługi myszy, dotyku i rysika; e.pointerType mówi, co to było
dblclick, contextmenu, wheeltak–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:

index.html
<!-- 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:

gotowosc.js
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:

CustomEvent – koszyk powiadamia nagłówek
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);
});
Podgląd

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ładniaCo robiPrzykład
el.addEventListener(typ, fn, opcje)podpina fn; opcje: once, passive, capture, signalbtn.addEventListener('click', f, { once: true })
el.removeEventListener(typ, fn)odpina – ta sama referencjabtn.removeEventListener('click', f)
fazy: capture → target → bubblezwykłe nasłuchiwacze działają w bubbling; { capture: true } w capturinge.eventPhase → 1, 2, 3
e.targetelement, w który naprawdę klikniętoe.target.tagName
e.currentTargetelement z tym nasłuchiwaczemw delegacji: lista
e.target.closest(sel)najbliższy przodek (lub sam element) pasujący do selektora, albo nulle.target.closest('li')
delegacjajeden nasłuchiwacz na rodzicu + closest + datasetlista.addEventListener('click', …)
e.preventDefault()odwołuje akcję przeglądarki (link, submit, znak w polu)w submit, keydown
e.stopPropagation()zatrzymuje bąbelkowanie – używaj rzadkomodal: klik w treść nie zamyka
input / change / submitkażda zmiana / zatwierdzona zmiana / wysłanie formularza (na <form>)form.addEventListener('submit', …)
new FormData(form), form.elements.nazwadane formularza; kontrolka po nameObject.fromEntries(new FormData(form))
e.key, e.codeznak/nazwa klawisza; fizyczny klawisz'Enter', 'ArrowLeft' / 'KeyW'
e.ctrlKey, e.shiftKey, e.altKey, e.metaKey, e.repeatmodyfikatory i powtórzeniee.ctrlKey && e.key === 's'
mouseenter/mouseleave vs mouseover/mouseoutbez bąbelkowania / z bąbelkowaniempauza slidera / delegacja hover
pointerdown/move/upmysz + dotyk + rysik w jednyme.pointerType
DOMContentLoaded, load, deferDOM gotowy / wszystko pobrane / skrypt odroczony<script src defer>
new CustomEvent(nazwa, { detail, bubbles }) + dispatchEventwłasne zdarzenie z danymikoszyk:zmiana

Zadania

Zadanie 1
łatwe

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
Zakładki z delegacją
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]);
});
Podgląd
Zadanie 2
średnie

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
Sortowana tabela ocen
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));
});
Podgląd
Zadanie 3
średnie

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:

  • input na 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 na input cał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 zdarzenie submit, w odróżnieniu od form.submit()).
Pokaż rozwiązanie
Komentarz z licznikiem
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();
Podgląd
Zadanie 4
trudne

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ą (keydown na document): 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
Szubienica – kliknij w podgląd i pisz
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();
Podgląd

Test powtórkowy

Test powtórkowy

Fazy zdarzeń, delegacja, preventDefault, formularze i klawiatura. Jedna poprawna odpowiedź.

0/0poprawnych
Sprawdź się

Użytkownik klika <span> wewnątrz <li> wewnątrz <ul>. Nasłuchiwacz click jest tylko na ul. Co zawiera event.target?

Sprawdź się

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'));
Sprawdź się

Które zdanie poprawnie opisuje różnicę między preventDefault() a stopPropagation()?

Sprawdź się

Formularz po kliknięciu „Wyślij” wciąż przeładowuje stronę. Który kod jest poprawnym rozwiązaniem?

Sprawdź się

Które zdarzenie uruchamia się przy KAŻDYM wpisanym znaku w polu tekstowym?

Sprawdź się

Jak nowocześnie sprawdzić, że naciśnięto Enter?

Sprawdź się

Skrypt w <head> kończy się błędem Cannot read properties of null (reading 'addEventListener'). Co jest najbardziej prawdopodobną przyczyną i poprawką?

Sprawdź się

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());
Sprawdź się

Które zdarzenie myszy NIE bąbelkuje i nie odpala się ponownie przy przejściu kursora na element potomny?

Sprawdź się

Które stwierdzenie o delegacji zdarzeń jest FAŁSZYWE?