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

Delegacja wydarzeń (Event Delegation)

11 min czytania
W tej lekcji8

"W Parku Jurajskim," Ray Arnold wskazuje na centralny panel monitoringu, "nie instalujemy osobnego systemu alarmowego przy każdym dinozaurze. To byłoby kosztowne i nieefektywne. Zamiast tego mamy centralny system monitoringu sektorów, który obsługuje wszystkie zagrody w swoim obszarze."

Ray otwiera schemat systemu. "Event delegation działa na tej samej zasadzie. Zamiast przypisywać setki event listenerów do poszczególnych elementów, przypisujemy jeden listener do ich wspólnego rodzica. Ten rodzic 'deleguje' obsługę do właściwych elementów."

Czym jest event delegation?

Event delegation to technika wykorzystująca propagację wydarzeń (bubbling) do obsługi zdarzeń na wielu elementach przez jeden listener przypisany do ich wspólnego przodka. To jedna z najważniejszych i najwydajniejszych technik w JavaScript.

Problem: Wiele event listenerów

"Zobaczmy problem," mówi Ray, pokazując kod, w którym każda karta dinozaura dostaje własny listener w pętli forEach.

1// NIEEFEKTYWNE - osobny listener dla każdej karty
2const dinosaurCards = document.querySelectorAll('.dinosaur-card');
3
4console.log(`Przypisuję listenery do ${dinosaurCards.length} kart dinozaurów`);
5
6dinosaurCards.forEach((card, index) => {
7  card.addEventListener('click', function() {
8    const dinoId = this.dataset.dinoId;
9    const dinoName = this.dataset.dinoName;
10
11    console.log(`Kliknięto dinozaura: ${dinoName} (ID: ${dinoId})`);
12    showDinosaurDetails(dinoId);
13  });
14
15  console.log(`Listener ${index + 1}/${dinosaurCards.length} dodany`);
16});
17
18// Problemy tego podejścia:
19// 1. Jeśli mamy 100 kart = 100 listenerów w pamięci
20// 2. Dynamicznie dodane karty NIE będą miały listenerów
21// 3. Trzeba ręcznie usuwać listenery przy usuwaniu kart
22// 4. Większe zużycie pamięci i wolniejsza strona

Działa, ale sto kart to sto listenerów, a karta dodana później zostaje głucha, bo pętla wykonała się raz.

Rozwiązanie: Event Delegation

"Teraz obserwuj," Ray pokazuje lepsze rozwiązanie: jeden listener na galerii sam ustala, w którą kartę trafiło kliknięcie.

1// EFEKTYWNE - jeden listener na kontenerze
2const dinosaurGallery = document.getElementById('dinosaur-gallery');
3
4dinosaurGallery.addEventListener('click', function(event) {
5  // Znajdź najbliższą kartę dinozaura (nawet jeśli kliknięto w dziecko karty)
6  const card = event.target.closest('.dinosaur-card');
7
8  // Jeśli nie kliknięto w kartę dinozaura, zignoruj
9  if (!card) return;
10
11  // Upewnij się, że karta jest dzieckiem naszej galerii
12  if (!dinosaurGallery.contains(card)) return;
13
14  const dinoId = card.dataset.dinoId;
15  const dinoName = card.dataset.dinoName;
16
17  console.log(`Kliknięto dinozaura: ${dinoName} (ID: ${dinoId})`);
18  showDinosaurDetails(dinoId);
19});
20
21// Zalety tego podejścia:
22// 1. Tylko JEDEN listener niezależnie od liczby kart
23// 2. Automatycznie obsługuje dynamicznie dodane karty
24// 3. Lepsze zarządzanie pamięcią
25// 4. Łatwiejsze utrzymanie kodu

Kliknięcie w obrazek karty bąbelkuje aż do galerii: event.target to element, w który kliknięto, a event.currentTarget to element z listenerem, czyli galeria. Metoda closest() idzie od target w górę i zwraca pasującą kartę albo null, a contains() sprawdza, czy karta leży w galerii. Cała technika to cztery kroki: znajdź kontener, dodaj na nim listener, sprawdź event.target i wykonaj akcję dla pasującego elementu.

Praktyczny przykład - System kart dinozaurów

"Zbudujmy kompletny system," mówi Ray. Oto struktura karty i początek listenera, który rozpoznaje przyciski szczegółów i karmienia.

1// HTML struktura:
2// <div id="dino-gallery">
3//   <div class="dinosaur-card" data-dino-id="1" data-dino-name="T-Rex">
4//     <img src="trex.jpg" alt="T-Rex">
5//     <h3>Tyrannosaurus Rex</h3>
6//     <div class="card-actions">
7//       <button class="view-btn">Szczegóły</button>
8//       <button class="feed-btn">Nakarm</button>
9//       <button class="favorite-btn">★</button>
10//     </div>
11//   </div>
12//   ... więcej kart
13// </div>
14
15const gallery = document.getElementById('dino-gallery');
16
17gallery.addEventListener('click', function(event) {
18  const target = event.target;
19
20  // Obsługa przycisku szczegółów
21  if (target.classList.contains('view-btn')) {
22    const card = target.closest('.dinosaur-card');
23    const dinoId = card.dataset.dinoId;
24    const dinoName = card.dataset.dinoName;
25
26    console.log(`Otwieranie szczegółów dla: ${dinoName}`);
27    openDinosaurModal(dinoId);
28    return; // Zatrzymaj dalsze sprawdzanie
29  }
30
31  // Obsługa przycisku karmienia
32  if (target.classList.contains('feed-btn')) {
33    const card = target.closest('.dinosaur-card');
34    const dinoId = card.dataset.dinoId;
35    const dinoName = card.dataset.dinoName;
36
37    console.log(`Rozpoczynam karmienie: ${dinoName}`);
38    feedDinosaur(dinoId);
39    return;
40  }

Atrybut data-dino-id czytamy jako dataset.dinoId, bo myślniki zamieniają się na camelCase. Każda gałąź kończy się return, więc jedno kliknięcie uruchamia jedną akcję. Dalej ulubione i sama karta.

1  // Obsługa przycisku ulubione
2  if (target.classList.contains('favorite-btn')) {
3    const card = target.closest('.dinosaur-card');
4    const dinoId = card.dataset.dinoId;
5    const dinoName = card.dataset.dinoName;
6
7    // Toggle stanu ulubionego
8    target.classList.toggle('active');
9
10    if (target.classList.contains('active')) {
11      console.log(`Dodano do ulubionych: ${dinoName}`);
12      addToFavorites(dinoId);
13      target.textContent = '★';
14    } else {
15      console.log(`Usunięto z ulubionych: ${dinoName}`);
16      removeFromFavorites(dinoId);
17      target.textContent = '☆';
18    }
19    return;
20  }
21
22  // Obsługa kliknięcia w samą kartę (ale nie w przyciski)
23  const card = target.closest('.dinosaur-card');
24  if (card && gallery.contains(card)) {
25    const dinoId = card.dataset.dinoId;
26    const dinoName = card.dataset.dinoName;
27
28    console.log(`Kliknięto kartę: ${dinoName}`);
29    highlightCard(card);
30  }
31});

Kartę sprawdzamy na końcu, inaczej kliknięcie w przycisk podświetlałoby ją przy okazji. Sprawdzenie classList.contains wystarcza, bo w przyciskach jest sam tekst. Ikona w środku przejęłaby kliknięcie i warunek by zawiódł. Na koniec pomocnicy.

1function openDinosaurModal(dinoId) {
2  console.log(`Ładowanie danych dinozaura ID: ${dinoId}`);
3  // Logika otwierania modala
4}
5
6function feedDinosaur(dinoId) {
7  console.log(`Aktywacja systemu karmienia dla dinozaura ID: ${dinoId}`);
8  console.log('Sprawdzanie bezpieczeństwa...');
9  console.log('Otwieranie podajnika...');
10  console.log('Karmienie zakończone');
11}
12
13function addToFavorites(dinoId) {
14  const favorites = JSON.parse(localStorage.getItem('favoriteDinosaurs') || '[]');
15  if (!favorites.includes(dinoId)) {
16    favorites.push(dinoId);
17    localStorage.setItem('favoriteDinosaurs', JSON.stringify(favorites));
18    console.log('Zapisano w ulubionych');
19  }
20}
21
22function removeFromFavorites(dinoId) {
23  let favorites = JSON.parse(localStorage.getItem('favoriteDinosaurs') || '[]');
24  favorites = favorites.filter(id => id !== dinoId);
25  localStorage.setItem('favoriteDinosaurs', JSON.stringify(favorites));
26  console.log('Usunięto z ulubionych');
27}
28
29function highlightCard(card) {
30  // Usuń highlight z innych kart
31  document.querySelectorAll('.dinosaur-card.highlighted').forEach(c => {
32    c.classList.remove('highlighted');
33  });
34
35  // Dodaj highlight do klikniętej karty
36  card.classList.add('highlighted');
37  console.log('Karta podświetlona');
38}

Pomocnicy nie wiedzą nic o wydarzeniach. Zapis || '[]' ratuje pierwszą wizytę, gdy getItem() zwraca null.

Dynamiczne dodawanie elementów

"Największa zaleta delegacji," Ray dodaje nowe elementy do interfejsu, "to automatyczna obsługa dynamicznie dodawanych elementów."

1const gallery = document.getElementById('dino-gallery');
2
3// Event delegation - działa dla wszystkich kart, nawet przyszłych
4gallery.addEventListener('click', function(event) {
5  const card = event.target.closest('.dinosaur-card');
6  if (!card) return;
7
8  const dinoName = card.dataset.dinoName;
9  console.log(`Kliknięto: ${dinoName}`);
10});
11
12// Funkcja dodająca nowe karty dynamicznie
13function addNewDinosaur(dinosaur) {
14  const newCard = document.createElement('div');
15  newCard.className = 'dinosaur-card';
16  newCard.dataset.dinoId = dinosaur.id;
17  newCard.dataset.dinoName = dinosaur.name;
18
19  newCard.innerHTML = `
20    <img src="${dinosaur.image}" alt="${dinosaur.name}">
21    <h3>${dinosaur.name}</h3>
22    <p>${dinosaur.species}</p>
23    <div class="card-actions">
24      <button class="view-btn">Szczegóły</button>
25      <button class="feed-btn">Nakarm</button>
26      <button class="favorite-btn">☆</button>
27    </div>
28  `;
29
30  gallery.appendChild(newCard);
31  console.log(`Dodano nowego dinozaura: ${dinosaur.name}`);
32
33  // NIE TRZEBA dodawać event listenerów!
34  // Delegation automatycznie obsłuży nową kartę!
35}
36
37// Przykład użycia
38setTimeout(() => {
39  addNewDinosaur({
40    id: '15',
41    name: 'Spinosaurus',
42    species: 'Spinosaurus aegyptiacus',
43    image: 'spinosaurus.jpg'
44  });
45
46  // Nowa karta już działa z kliknięciami!
47}, 3000);

W funkcji addNewDinosaur() nie ma ani jednego addEventListener, a Spinosaurus dodany po trzech sekundach od razu reaguje na kliknięcia, bo bąbelkują one do listenera czekającego na galerii od początku.

Zaawansowana delegacja - filtry i warunki

"Czasami," wyjaśnia Ray, "potrzebujemy bardziej precyzyjnej kontroli." Każdy przycisk panelu mówi przez data-action, co ma zrobić, a listener rozdziela zadania.

1const parkDashboard = document.getElementById('park-dashboard');
2
3parkDashboard.addEventListener('click', function(event) {
4  const target = event.target;
5
6  // Delegacja z atrybutem data-action
7  const action = target.dataset.action;
8
9  if (action) {
10    console.log(`Akcja: ${action}`);
11
12    switch(action) {
13      case 'open-gate':
14        handleOpenGate(target);
15        break;
16
17      case 'close-gate':
18        handleCloseGate(target);
19        break;
20
21      case 'start-feeding':
22        handleStartFeeding(target);
23        break;
24
25      case 'emergency-protocol':
26        handleEmergencyProtocol(target);
27        break;
28
29      case 'view-stats':
30        handleViewStats(target);
31        break;
32
33      default:
34        console.log('Nieznana akcja:', action);
35    }
36
37    return;
38  }

Nowa akcja to nowy case, a nie nowy listener. Gdy element nie ma akcji, listener sprawdza klasy metodą matches() i znaczniki przez tagName, który zwraca nazwę wielkimi literami, stąd 'A'.

1  // Delegacja z klasami
2  if (target.matches('.enclosure-zone')) {
3    const enclosureId = target.dataset.enclosureId;
4    console.log(`Kliknięto strefę zagrody: ${enclosureId}`);
5    showEnclosureDetails(enclosureId);
6    return;
7  }
8
9  // Delegacja z tagami
10  if (target.tagName === 'A' && target.classList.contains('internal-link')) {
11    event.preventDefault();
12    const href = target.getAttribute('href');
13    console.log(`Nawigacja wewnętrzna: ${href}`);
14    navigateInternal(href);
15    return;
16  }
17});
18
19function handleOpenGate(button) {
20  const gateId = button.dataset.gateId;
21  const enclosureId = button.dataset.enclosureId;
22
23  console.log(`Otwieranie bramy ${gateId} w zagrodzie ${enclosureId}`);
24  console.log('Sprawdzanie statusu bezpieczeństwa...');
25  console.log('Brama otwarta');
26}
27
28function handleCloseGate(button) {
29  const gateId = button.dataset.gateId;
30  console.log(`Zamykanie bramy ${gateId}`);
31  console.log('Brama zamknięta i zabezpieczona');
32}
33
34function handleStartFeeding(button) {
35  const enclosureId = button.dataset.enclosureId;
36  const feedType = button.dataset.feedType;
37
38  console.log(`Rozpoczęcie karmienia w zagrodzie ${enclosureId}`);
39  console.log(`Typ pokarmu: ${feedType}`);
40}
41
42function handleEmergencyProtocol(button) {
43  const protocolId = button.dataset.protocolId;
44
45  console.log('ALARM! Aktywacja protokołu awaryjnego');
46  console.log(`Protokół: ${protocolId}`);
47  console.log('Zamykanie wszystkich bram...');
48  console.log('Powiadamianie personelu...');
49}

Wywołanie preventDefault() blokuje zwykłą nawigację linku, żeby panel przełączył widok sam. Każdy handler czyta dane przycisku przez dataset: znaczniki mówią, co zrobić, funkcje wiedzą jak.

Wydajność - porównanie

Ray pokazuje testy wydajności. "Liczby nie kłamią, ale każdy musi je zmierzyć u siebie." Funkcje console.time() i console.timeEnd() mierzą czas między wywołaniami z tą samą etykietą.

1// Test 1: Bez delegation (osobne listenery)
2console.time('Przypisywanie bez delegation');
3
4const cards1 = document.querySelectorAll('.card');
5cards1.forEach(card => {
6  card.addEventListener('click', function() {
7    console.log('Kliknięto');
8  });
9});
10
11console.timeEnd('Przypisywanie bez delegation');
12// Czas rośnie razem z liczbą kart: 100 kart = 100 wywołań addEventListener
13
14// Test 2: Z delegation (jeden listener)
15console.time('Przypisywanie z delegation');
16
17const container = document.getElementById('container');
18container.addEventListener('click', function(event) {
19  if (event.target.matches('.card')) {
20    console.log('Kliknięto');
21  }
22});
23
24console.timeEnd('Przypisywanie z delegation');
25// Jedno wywołanie addEventListener, niezależnie od liczby kart
26
27// Zużycie pamięci:
28// Bez delegation: osobny listener i osobna funkcja dla każdej karty
29// Z delegation: jeden listener na kontenerze
30
31console.log('Liczba listenerów dla 100 kart:');
32console.log('Bez delegation: 100');
33console.log('Z delegation: 1');
34console.log('Dokładny czas i pamięć zmierz w DevTools swojej przeglądarki');

Celowo nie podajemy milisekund ani kilobajtów, bo zależą od przeglądarki i sprzętu. Pewne jest jedno: bez delegacji listenerów przybywa z każdą kartą, a z delegacją zostaje jeden.

matches() vs closest() - wybór metody

"Dwie najważniejsze metody w delegacji," wyjaśnia Ray. Obie przyjmują selektor CSS, ale patrzą w różne strony drzewa.

1const container = document.getElementById('enclosure-list');
2
3container.addEventListener('click', function(event) {
4  const target = event.target;
5
6  // matches() - sprawdza czy SAM element pasuje
7  if (target.matches('.enclosure-item')) {
8    console.log('Kliknięto BEZPOŚREDNIO w .enclosure-item');
9    // Zadziała tylko jeśli kliknięto dokładnie w element z klasą
10  }
11
12  // closest() - szuka w górę do pierwszego pasującego
13  const item = target.closest('.enclosure-item');
14  if (item) {
15    console.log('Kliknięto w .enclosure-item LUB jego dziecko');
16    // Zadziała jeśli kliknięto w element lub jego dzieci
17  }
18});
19
20// HTML:
21// <div class="enclosure-item">
22//   <h3>Zagroda T-Rex</h3>           ← matches() NIE, closest() TAK
23//   <p>Status: Aktywna</p>           ← matches() NIE, closest() TAK
24//   <button>Szczegóły</button>       ← matches() NIE, closest() TAK
25// </div>                             ← matches() TAK, closest() TAK
26
27// WNIOSEK: Używaj closest() dla delegation - jest bardziej elastyczne

Metoda matches() sprawdza tylko sam element, a closest() zaczyna od niego i idzie w górę. Dlatego polecam closest() jako domyślny wybór w delegacji.

Kompletny system zarządzania parkiem

"Połączmy wszystko w działający system," mówi Ray. Jeden listener na dashboardzie przekazuje kliknięcie kolejnym metodom klasy.

1class JurassicParkManagementSystem {
2  constructor() {
3    this.selectedEnclosures = new Set();
4    this.initialize();
5  }
6
7  initialize() {
8    console.log('Inicjalizacja systemu zarządzania parkiem');
9    this.setupDelegation();
10  }
11
12  setupDelegation() {
13    // Jeden główny listener dla całego dashboardu
14    const dashboard = document.getElementById('park-dashboard');
15
16    dashboard.addEventListener('click', (event) => {
17      this.handleDashboardClick(event);
18    });
19
20    console.log('System delegation aktywny');
21  }
22
23  handleDashboardClick(event) {
24    const target = event.target;
25
26    // Obsługa różnych typów kliknięć
27    this.handleActionButton(target) ||
28    this.handleTabNavigation(target) ||
29    this.handleStatusIndicator(target) ||
30    this.handleEnclosureSelection(target);
31  }

Każda metoda zwraca true, gdy obsłużyła kliknięcie, a operator || zatrzymuje się na pierwszym true. Dlatego przyciski pytamy najpierw, a całą kartę na końcu, jak w galerii wyżej. Funkcja strzałkowa zachowuje this jako instancję klasy.

1  handleEnclosureSelection(target) {
2    const enclosure = target.closest('.enclosure-card');
3    if (!enclosure) return false;
4
5    const enclosureId = enclosure.dataset.enclosureId;
6    const enclosureName = enclosure.dataset.enclosureName;
7
8    console.log(`Wybrano zagrodę: ${enclosureName} (ID: ${enclosureId})`);
9
10    // Toggle selekcji
11    if (this.selectedEnclosures.has(enclosureId)) {
12      this.selectedEnclosures.delete(enclosureId);
13      enclosure.classList.remove('selected');
14      console.log('Usunięto z selekcji');
15    } else {
16      this.selectedEnclosures.add(enclosureId);
17      enclosure.classList.add('selected');
18      console.log('Dodano do selekcji');
19    }
20
21    this.updateSelectionDisplay();
22    return true;
23  }
24
25  handleActionButton(target) {
26    const action = target.dataset.action;
27    if (!action) return false;
28
29    const enclosure = target.closest('.enclosure-card');
30    const enclosureId = enclosure?.dataset.enclosureId;
31
32    console.log(`Akcja: ${action} dla zagrody: ${enclosureId}`);
33
34    const actions = {
35      'feed': () => this.feedDinosaur(enclosureId),
36      'clean': () => this.cleanEnclosure(enclosureId),
37      'maintenance': () => this.scheduleMaintenance(enclosureId),
38      'emergency': () => this.activateEmergency(enclosureId)
39    };
40
41    const handler = actions[action];
42    if (handler) {
43      handler();
44      return true;
45    }
46
47    return false;
48  }

Zbiór Set trzyma zaznaczenia bez duplikatów, obiekt actions zastępuje switch, a ?. chroni przed błędem, gdy przycisk nie leży w karcie. Zakładki i wskaźniki statusu działają tak samo.

1  handleTabNavigation(target) {
2    if (!target.classList.contains('tab-button')) return false;
3
4    const tabName = target.dataset.tab;
5    console.log(`Przełączanie na kartę: ${tabName}`);
6
7    // Usuń active ze wszystkich tabów
8    document.querySelectorAll('.tab-button').forEach(tab => {
9      tab.classList.remove('active');
10    });
11
12    // Dodaj active do klikniętego
13    target.classList.add('active');
14
15    // Pokaż odpowiednią zawartość
16    this.showTabContent(tabName);
17    return true;
18  }
19
20  handleStatusIndicator(target) {
21    if (!target.classList.contains('status-indicator')) return false;
22
23    const enclosure = target.closest('.enclosure-card');
24    const enclosureId = enclosure?.dataset.enclosureId;
25
26    console.log(`Pokazywanie szczegółów statusu dla zagrody: ${enclosureId}`);
27    this.showStatusDetails(enclosureId);
28    return true;
29  }

Żaden z tych elementów nie ma własnego listenera, bo ich kliknięcia docierają do dashboardu. Na końcu metody wykonawcze i start systemu.

1  feedDinosaur(enclosureId) {
2    console.log(`Karmienie dinozaura w zagrodzie: ${enclosureId}`);
3  }
4
5  cleanEnclosure(enclosureId) {
6    console.log(`Czyszczenie zagrody: ${enclosureId}`);
7  }
8
9  scheduleMaintenance(enclosureId) {
10    console.log(`Planowanie konserwacji dla zagrody: ${enclosureId}`);
11  }
12
13  activateEmergency(enclosureId) {
14    console.log(`ALARM! Protokół awaryjny dla zagrody: ${enclosureId}`);
15  }
16
17  showTabContent(tabName) {
18    console.log(`Ładowanie zawartości karty: ${tabName}`);
19  }
20
21  showStatusDetails(enclosureId) {
22    console.log(`Szczegóły statusu zagrody: ${enclosureId}`);
23  }
24
25  updateSelectionDisplay() {
26    const count = this.selectedEnclosures.size;
27    const display = document.getElementById('selection-counter');
28
29    if (display) {
30      display.textContent = `Wybrano: ${count} zagród`;
31    }
32
33    console.log(`Aktualnie wybrane zagrody: ${Array.from(this.selectedEnclosures).join(', ')}`);
34  }
35}
36
37// Inicjalizacja systemu
38const parkSystem = new JurassicParkManagementSystem();

Jeden listener pilnuje całego dashboardu, niezależnie od liczby zagród.

Podsumowanie

"Event delegation," kończy Ray, "to nie tylko optymalizacja. To fundamentalna technika w nowoczesnym JavaScript. Jeden listener obsługujący tysiące elementów - to jak centralny system monitoringu obsługujący cały park. Wydajne, skalowalne i łatwe w utrzymaniu."

Kluczowe zasady:

  • Używaj closest() do znajdowania docelowych elementów
  • Sprawdzaj czy element jest dzieckiem kontenera z listenerem
  • Używaj data-atrybutów dla akcji i identyfikatorów
  • Pamiętaj o return po obsłużeniu wydarzenia
  • Delegacja działa automatycznie z dynamicznymi elementami
  • Oszczędza pamięć i poprawia wydajność

W piaskownicy listener dragstart wisi na całym dokumencie, więc dinozaury dokładane co dziesięć sekund od razu dają się przeciągać. Następna lekcja: wydarzenia myszy i klawiatury.

Pamiętaj: jeden czujnik w centrum kontroli sektora słyszy każdą zagrodę, także tę zbudowaną wczoraj.

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>Drag & Drop - Przenoszenie Dinozaurów</title>
7    <style>
8        body {
9            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10            background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
11            margin: 0;
12            padding: 20px;
13            color: white;
14        }
15
16        .container {
17            max-width: 1200px;
18            margin: 0 auto;
19        }
20
21        h1 {
22            text-align: center;
23            margin-bottom: 30px;
24        }
25
26        .instructions {
27            background: rgba(52, 152, 219, 0.2);
28            border: 1px solid #3498db;
29            border-radius: 10px;
30            padding: 20px;
31            margin-bottom: 30px;
32            text-align: center;
33        }
34
35        .enclosures-container {
36            display: grid;
37            grid-template-columns: 1fr 1fr;
38            gap: 30px;
39            margin-bottom: 30px;
40        }
41
42        .enclosure {
43            background: rgba(0, 0, 0, 0.6);
44            border: 3px dashed #7f8c8d;
45            border-radius: 15px;
46            padding: 20px;
47            min-height: 300px;
48            transition: all 0.3s ease;
49        }
50
51        .enclosure.drag-over {
52            border-color: #3498db;
53            background: rgba(52, 152, 219, 0.2);
54            border-style: solid;
55            transform: scale(1.02);
56        }
57
58        .enclosure h3 {
59            text-align: center;
60            margin-bottom: 20px;
61            color: #ecf0f1;
62        }
63
64        .herbivore-enclosure {
65            border-color: #27ae60;
66        }
67
68        .carnivore-enclosure {
69            border-color: #e74c3c;
70        }
71
72        .herbivore-enclosure.drag-over {
73            border-color: #2ecc71;
74            background: rgba(46, 204, 113, 0.2);
75        }
76
77        .carnivore-enclosure.drag-over {
78            border-color: #e74c3c;
79            background: rgba(231, 76, 60, 0.2);
80        }
81
82        .dinosaur-card {
83            background: linear-gradient(45deg, #34495e, #2c3e50);
84            border-radius: 10px;
85            padding: 15px;
86            margin: 10px 0;
87            cursor: grab;
88            transition: all 0.3s ease;
89            border: 2px solid transparent;
90            user-select: none;
91        }
92
93        .dinosaur-card:hover {
94            transform: translateY(-3px);
95            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
96        }
97
98        .dinosaur-card.dragging {
99            opacity: 0.5;
100            transform: rotate(5deg);
101            cursor: grabbing;
102        }
103
104        .dinosaur-card.herbivore {
105            border-color: #27ae60;
106        }
107
108        .dinosaur-card.carnivore {
109            border-color: #e74c3c;
110        }
111
112        .dino-header {
113            display: flex;
114            align-items: center;
115            gap: 10px;
116            margin-bottom: 10px;
117        }
118
119        .dino-emoji {
120            font-size: 24px;
121        }
122
123        .dino-name {
124            font-weight: bold;
125            color: #ecf0f1;
126        }
127
128        .dino-info {
129            font-size: 14px;
130            color: #bdc3c7;
131            margin: 5px 0;
132        }
133
134        .dino-diet {
135            display: inline-block;
136            padding: 3px 8px;
137            border-radius: 12px;
138            font-size: 12px;
139            font-weight: bold;
140        }
141
142        .herbivore .dino-diet {
143            background: #27ae60;
144            color: white;
145        }
146
147        .carnivore .dino-diet {
148            background: #e74c3c;
149            color: white;
150        }
151
152        .drag-instructions {
153            text-align: center;
154            color: #bdc3c7;
155            font-style: italic;
156            margin: 20px 0;
157        }
158
159        .stats-panel {
160            background: rgba(0, 0, 0, 0.8);
161            border-radius: 10px;
162            padding: 20px;
163            margin-top: 30px;
164        }
165
166        .stats-grid {
167            display: grid;
168            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
169            gap: 20px;
170        }
171
172        .stat-item {
173            background: rgba(255, 255, 255, 0.1);
174            padding: 15px;
175            border-radius: 8px;
176            text-align: center;
177        }
178
179        .stat-value {
180            font-size: 24px;
181            font-weight: bold;
182            color: #3498db;
183        }
184
185        .stat-label {
186            color: #bdc3c7;
187            font-size: 14px;
188            margin-top: 5px;
189        }
190
191        .danger-warning {
192            background: #e74c3c;
193            color: white;
194            padding: 10px;
195            border-radius: 8px;
196            margin: 10px 0;
197            text-align: center;
198            font-weight: bold;
199            display: none;
200        }
201
202        @media (max-width: 768px) {
203            .enclosures-container {
204                grid-template-columns: 1fr;
205            }
206        }
207    </style>
208</head>
209<body>
210    <div class="container">
211        <h1>Przenoszenie Dinozaurów Między Zagrodami</h1>
212
213        <div class="instructions">
214            <strong>Zadanie:</strong> Przeciągnij dinozaury do odpowiednich zagród na podstawie ich diety.<br>
215            <span style="color: #27ae60;">■</span> Roślinożercy → Zagroda bezpieczna |
216            <span style="color: #e74c3c;">■</span> Mięsożercy → Zagroda wysokiego bezpieczeństwa
217        </div>
218
219        <div class="enclosures-container">
220            <!-- Zagroda dla roślinożerców -->
221            <div class="enclosure herbivore-enclosure" data-diet="herbivore" id="herbivore-enclosure">
222                <h3>Zagroda Bezpieczna (Roślinożercy)</h3>
223                <div class="drag-instructions">Przeciągnij tutaj roślinożerców</div>
224
225                <!-- Początkowe dinozaury roślinożerne -->
226                <div class="dinosaur-card herbivore" draggable="true" data-dino="triceratops" data-diet="herbivore">
227                    <div class="dino-header">
228                        <span class="dino-emoji">◆</span>
229                        <span class="dino-name">Triceratops</span>
230                    </div>
231                    <div class="dino-info">Długość: 9m | Era: Kreda</div>
232                    <span class="dino-diet">Roślinożerca</span>
233                </div>
234
235                <div class="dinosaur-card herbivore" draggable="true" data-dino="brachiosaurus" data-diet="herbivore">
236                    <div class="dino-header">
237                        <span class="dino-emoji">◆</span>
238                        <span class="dino-name">Brachiosaurus</span>
239                    </div>
240                    <div class="dino-info">Długość: 23m | Era: Jura</div>
241                    <span class="dino-diet">Roślinożerca</span>
242                </div>
243            </div>
244
245            <!-- Zagroda dla mięsożerców -->
246            <div class="enclosure carnivore-enclosure" data-diet="carnivore" id="carnivore-enclosure">
247                <h3>Zagroda Wysokiego Bezpieczeństwa (Mięsożercy)</h3>
248                <div class="drag-instructions">Przeciągnij tutaj mięsożerców</div>
249                <div class="danger-warning" id="danger-warning">
250                    UWAGA! Mieszanie mięsożerców i roślinożerców jest niebezpieczne!
251                </div>
252
253                <!-- Początkowe dinozaury mięsożerne -->
254                <div class="dinosaur-card carnivore" draggable="true" data-dino="trex" data-diet="carnivore">
255                    <div class="dino-header">
256                        <span class="dino-emoji">◆</span>
257                        <span class="dino-name">Tyrannosaurus Rex</span>
258                    </div>
259                    <div class="dino-info">Długość: 13m | Era: Kreda</div>
260                    <span class="dino-diet">Mięsożerca</span>
261                </div>
262
263                <div class="dinosaur-card carnivore" draggable="true" data-dino="velociraptor" data-diet="carnivore">
264                    <div class="dino-header">
265                        <span class="dino-emoji">◆</span>
266                        <span class="dino-name">Velociraptor</span>
267                    </div>
268                    <div class="dino-info">Długość: 2m | Era: Kreda</div>
269                    <span class="dino-diet">Mięsożerca</span>
270                </div>
271            </div>
272        </div>
273
274        <!-- Panel statystyk -->
275        <div class="stats-panel">
276            <h3>Statystyki Zagród</h3>
277            <div class="stats-grid">
278                <div class="stat-item">
279                    <div class="stat-value" id="herbivore-count">2</div>
280                    <div class="stat-label">Roślinożercy</div>
281                </div>
282                <div class="stat-item">
283                    <div class="stat-value" id="carnivore-count">2</div>
284                    <div class="stat-label">Mięsożercy</div>
285                </div>
286                <div class="stat-item">
287                    <div class="stat-value" id="total-moves">0</div>
288                    <div class="stat-label">Całkowite przeniesienia</div>
289                </div>
290                <div class="stat-item">
291                    <div class="stat-value" id="safety-status">OK</div>
292                    <div class="stat-label">Status bezpieczeństwa</div>
293                </div>
294            </div>
295        </div>
296    </div>
297
298    <script>
299        let draggedElement = null;
300        let totalMoves = 0;
301
302        // Pobieramy referencje do elementów
303        const herbivoreEnclosure = document.getElementById('herbivore-enclosure');
304        const carnivoreEnclosure = document.getElementById('carnivore-enclosure');
305        const dangerWarning = document.getElementById('danger-warning');
306        const herbivoreCount = document.getElementById('herbivore-count');
307        const carnivoreCount = document.getElementById('carnivore-count');
308        const totalMovesCount = document.getElementById('total-moves');
309        const safetyStatus = document.getElementById('safety-status');
310
311        // Event listenery dla przeciąganego elementu
312        document.addEventListener('dragstart', function(e) {
313            if (e.target.classList.contains('dinosaur-card')) {
314                draggedElement = e.target;
315                e.target.classList.add('dragging');
316
317                console.log(`Rozpoczęto przeciąganie: ${e.target.dataset.dino}`);
318
319                // Ustawiamy dane dla transferu
320                e.dataTransfer.setData('text/plain', e.target.dataset.dino);
321                e.dataTransfer.effectAllowed = 'move';
322            }
323        });
324
325        document.addEventListener('dragend', function(e) {
326            if (e.target.classList.contains('dinosaur-card')) {
327                e.target.classList.remove('dragging');
328                draggedElement = null;
329            }
330        });
331
332        // Event listenery dla obszarów drop
333        document.querySelectorAll('.enclosure').forEach(enclosure => {
334            enclosure.addEventListener('dragover', function(e) {
335                e.preventDefault(); // Pozwalamy na drop
336                e.dataTransfer.dropEffect = 'move';
337
338                if (!this.classList.contains('drag-over')) {
339                    this.classList.add('drag-over');
340                }
341            });
342
343            enclosure.addEventListener('dragleave', function(e) {
344                // Sprawdzamy czy opuszczamy rzeczywiście enclosure
345                if (!this.contains(e.relatedTarget)) {
346                    this.classList.remove('drag-over');
347                }
348            });
349
350            enclosure.addEventListener('drop', function(e) {
351                e.preventDefault();
352                this.classList.remove('drag-over');
353
354                if (draggedElement) {
355                    const draggedDiet = draggedElement.dataset.diet;
356                    const targetDiet = this.dataset.diet;
357                    const dinoName = draggedElement.querySelector('.dino-name').textContent;
358
359                    // Sprawdzamy czy dinozaur pasuje do zagrody
360                    if (draggedDiet === targetDiet) {
361                        // Przenosimy element
362                        this.appendChild(draggedElement);
363                        totalMoves++;
364
365                        console.log(`${dinoName} przeniesiony do odpowiedniej zagrody!`);
366
367                        // Animacja sukcesu
368                        const movedCard = draggedElement; // dragend wyzeruje draggedElement przed upływem sekundy
369                        movedCard.style.border = '2px solid #27ae60';
370                        setTimeout(() => {
371                            movedCard.style.border = '';
372                        }, 1000);
373
374                    } else {
375                        // Nieprawidłowe przeniesienie
376                        console.log(`BŁĄD: ${dinoName} nie może być w tej zagrodzie!`);
377
378                        // Animacja błędu
379                        const refusedCard = draggedElement;
380                        refusedCard.style.border = '2px solid #e74c3c';
381                        refusedCard.style.background = 'rgba(231, 76, 60, 0.3)';
382
383                        setTimeout(() => {
384                            refusedCard.style.border = '';
385                            refusedCard.style.background = '';
386                        }, 1000);
387
388                        // Pokazujemy ostrzeżenie
389                        alert(`OSTRZEŻENIE!\n\n${dinoName} nie może być umieszczony w tej zagrodzie!\n${draggedDiet === 'carnivore' ? 'Mięsożercy' : 'Roślinożercy'} muszą być w odpowiedniej zagrodzie dla bezpieczeństwa parku.`);
390                    }
391
392                    updateStats();
393                }
394            });
395        });
396
397        // Funkcja aktualizacji statystyk
398        function updateStats() {
399            const herbivores = document.querySelectorAll('#herbivore-enclosure .dinosaur-card').length;
400            const carnivores = document.querySelectorAll('#carnivore-enclosure .dinosaur-card').length;
401
402            herbivoreCount.textContent = herbivores;
403            carnivoreCount.textContent = carnivores;
404            totalMovesCount.textContent = totalMoves;
405
406            // Sprawdzamy bezpieczeństwo
407            const herbivoreEnclosureHasCarnivores = document.querySelectorAll('#herbivore-enclosure .carnivore').length > 0;
408            const carnivoreEnclosureHasHerbivores = document.querySelectorAll('#carnivore-enclosure .herbivore').length > 0;
409
410            const isDangerous = herbivoreEnclosureHasCarnivores || carnivoreEnclosureHasHerbivores;
411
412            if (isDangerous) {
413                safetyStatus.textContent = '!';
414                safetyStatus.style.color = '#e74c3c';
415                dangerWarning.style.display = 'block';
416            } else {
417                safetyStatus.textContent = 'OK';
418                safetyStatus.style.color = '#27ae60';
419                dangerWarning.style.display = 'none';
420            }
421
422            // Sprawdzamy czy wszystkie dinozaury są w odpowiednich zagrodach
423            const allInCorrectEnclosures = !isDangerous;
424            if (allInCorrectEnclosures && totalMoves > 0) {
425                setTimeout(() => {
426                    alert('Gratulacje!\n\nWszystkie dinozaury są teraz w odpowiednich zagrodach.\nPark jest bezpieczny!');
427                }, 500);
428            }
429        }
430
431        // Dodajemy nowe dinozaury po kilku sekundach
432        function addNewDinosaur() {
433            const newDinos = [
434                {
435                    name: 'Stegosaurus',
436                    emoji: '',
437                    diet: 'herbivore',
438                    length: '9m',
439                    era: 'Jura'
440                },
441                {
442                    name: 'Allosaurus',
443                    emoji: '',
444                    diet: 'carnivore',
445                    length: '9m',
446                    era: 'Jura'
447                }
448            ];
449
450            const randomDino = newDinos[Math.floor(Math.random() * newDinos.length)];
451
452            const newDinoCard = document.createElement('div');
453            newDinoCard.className = `dinosaur-card ${randomDino.diet}`;
454            newDinoCard.draggable = true;
455            newDinoCard.dataset.dino = randomDino.name.toLowerCase();
456            newDinoCard.dataset.diet = randomDino.diet;
457
458            newDinoCard.innerHTML = `
459                <div class="dino-header">
460                    <span class="dino-emoji">${randomDino.emoji}</span>
461                    <span class="dino-name">${randomDino.name}</span>
462                </div>
463                <div class="dino-info">Długość: ${randomDino.length} | Era: ${randomDino.era}</div>
464                <span class="dino-diet">${randomDino.diet === 'herbivore' ? 'Roślinożerca' : 'Mięsożerca'}</span>
465            `;
466
467            // Dodajemy do nieprawidłowej zagrody dla wyzwania
468            if (randomDino.diet === 'herbivore') {
469                carnivoreEnclosure.appendChild(newDinoCard);
470            } else {
471                herbivoreEnclosure.appendChild(newDinoCard);
472            }
473
474            updateStats();
475
476            console.log(`Dodano nowego dinozaura: ${randomDino.name}`);
477        }
478
479        // Dodajemy nowego dinozaura co 10 sekund
480        setInterval(addNewDinosaur, 10000);
481
482        // Początkowa aktualizacja statystyk
483        updateStats();
484
485        // Touch events dla urządzeń mobilnych (podstawowa obsługa)
486        let touchItem = null;
487        let touchOffset = { x: 0, y: 0 };
488
489        document.addEventListener('touchstart', function(e) {
490            if (e.target.classList.contains('dinosaur-card')) {
491                touchItem = e.target;
492                const touch = e.touches[0];
493                const rect = touchItem.getBoundingClientRect();
494
495                touchOffset.x = touch.clientX - rect.left;
496                touchOffset.y = touch.clientY - rect.top;
497
498                touchItem.style.position = 'fixed';
499                touchItem.style.zIndex = '1000';
500                touchItem.classList.add('dragging');
501
502                e.preventDefault();
503            }
504        });
505
506        document.addEventListener('touchmove', function(e) {
507            if (touchItem) {
508                const touch = e.touches[0];
509                touchItem.style.left = (touch.clientX - touchOffset.x) + 'px';
510                touchItem.style.top = (touch.clientY - touchOffset.y) + 'px';
511
512                e.preventDefault();
513            }
514        });
515
516        document.addEventListener('touchend', function(e) {
517            if (touchItem) {
518                const touch = e.changedTouches[0];
519                const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
520                const enclosure = elementBelow ? elementBelow.closest('.enclosure') : null;
521
522                if (enclosure) {
523                    draggedElement = touchItem;
524
525                    // Symulujemy drop event
526                    const dropEvent = new Event('drop');
527                    enclosure.dispatchEvent(dropEvent);
528                }
529
530                // Reset styles
531                touchItem.style.position = '';
532                touchItem.style.left = '';
533                touchItem.style.top = '';
534                touchItem.style.zIndex = '';
535                touchItem.classList.remove('dragging');
536
537                touchItem = null;
538                draggedElement = null;
539            }
540        });
541    </script>
542</body>
543</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. Jakie są główne zalety event delegation (delegacji wydarzeń)?

  2. 2. Jaka jest różnica między event.target a event.currentTarget?

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.html jest lista kart dinozaurów #dino-list i przycisk, który dodaje nową kartę po załadowaniu strony. Zastosuj delegację zdarzeń: w miejscu ___BLANK1___ wpisz zmienną kontenera, na którym ma wisieć jedyny listener click, a w miejscu ___BLANK2___ metodę, która od event.target szuka najbliższego elementu pasującego do selektora .dino-card. Kliknięcie karty (także jej środka i karty dodanej później) ma pokazać nazwę dinozaura w #selected-dino, a karta z rezerwy poza listą ma zostać bez reakcji.

  • Układanie w pionie

    Ułóż kroki konfiguracji event delegation:

  • Układanie w poziomie

    Ułóż elementy delegacji zdarzeń:

Przydatne artykuły