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

Tworzenie interaktywnych komponentów

6 min czytania ·
W tej lekcji4

W centrum kontroli Parku Jurajskiego każdy panel na ekranie to osobny komponent - karta dinozaura, okno alertu, formularz raportu. Każdy z nich reaguje na działania operatora: kliknięcia, klawisze, najazd myszą. Budowanie takich interaktywnych komponentów to jeden z najczęstszych scenariuszy użycia event listenerów w prawdziwych aplikacjach.

W tej lekcji nauczysz się tworzyć samodzielne, wielokrotnego użytku komponenty UI, które zarządzają własnymi wydarzeniami - od okien modalnych po panele z dynamiczną zawartością.

Problem pojawia się wtedy, gdy komponent znika. Panel zamknięty przez operatora powinien zabrać ze sobą swoje czujniki, inaczej po godzinie pracy centrum kontroli nasłuchuje na setki okien, których nikt już nie widzi.

Komponent: Okno modalne (Modal)

Modal to okno wyświetlane na wierzchu strony, blokujące interakcję z resztą interfejsu. W naszym parku użyjemy go do wyświetlania szczegółów dinozaura po kliknięciu jego karty.

Tworzenie struktury i rejestracja wydarzeń

Funkcja createDinosaurModal() przyjmuje dane dinozaura i buduje z nich gotowy element. Zaczyna od struktury HTML.

function createDinosaurModal(dinosaurData) {
  // Tworzenie struktury modala
  const modal = document.createElement('div');
  modal.className = 'dinosaur-modal';
  modal.innerHTML = `
    <div class="modal-content">
      <div class="modal-header">
        <h2>${dinosaurData.name}</h2>
        <button class="close-btn">&times;</button>
      </div>
      <div class="modal-body">
        <img src="${dinosaurData.image}" alt="${dinosaurData.name}">
        <p><strong>Era:</strong> ${dinosaurData.era}</p>
        <p><strong>Dieta:</strong> ${dinosaurData.diet}</p>
        <p><strong>Długość:</strong> ${dinosaurData.length}m</p>
        <p><strong>Waga:</strong> ${dinosaurData.weight}kg</p>
        <p>${dinosaurData.description}</p>
      </div>
      <div class="modal-footer">
        <button class="book-tour-btn">Zarezerwuj wycieczkę</button>
        <button class="add-favorite-btn">Dodaj do ulubionych</button>
      </div>
    </div>
  `;

Element powstaje w pamięci i nie ma go jeszcze na stronie. Szablon wypełniamy danymi z bazy laboratorium, ale gdyby pochodziły od użytkowników, bezpieczniej byłoby wstawiać je przez textContent, bo innerHTML zinterpretuje każdy znacznik. Teraz pierwsze listenery.

  // Pobierz elementy interaktywne z modala
  const closeBtn = modal.querySelector('.close-btn');
  const bookTourBtn = modal.querySelector('.book-tour-btn');
  const addFavoriteBtn = modal.querySelector('.add-favorite-btn');

  // Zamykanie modala przyciskiem X
  closeBtn.addEventListener('click', () => {
    closeModal(modal);
  });

  // Zamykanie kliknięciem w tło (overlay)
  modal.addEventListener('click', (e) => {
    if (e.target === modal) {
      closeModal(modal);
    }
  });

Wywołanie modal.querySelector() szuka tylko wewnątrz tego modala, więc działa także przed wstawieniem go na stronę. Kliknięcie w treść bąbelkuje do listenera na modal, ale wtedy e.target wskazuje wewnętrzny element. Warunek e.target === modal jest prawdziwy tylko przy kliknięciu w samo tło. Zostały akcje i klawiatura.

  // Rezerwacja wycieczki
  bookTourBtn.addEventListener('click', () => {
    openBookingForm(dinosaurData.id);
  });

  // Dodawanie do ulubionych ze zmianą stanu przycisku
  addFavoriteBtn.addEventListener('click', () => {
    addToFavorites(dinosaurData.id);
    addFavoriteBtn.textContent = 'Dodano do ulubionych!';
    addFavoriteBtn.disabled = true;
  });

  // Zamykanie klawiszem Escape
  const handleEscape = (e) => {
    if (e.key === 'Escape') {
      closeModal(modal);
      document.removeEventListener('keydown', handleEscape);
    }
  };
  document.addEventListener('keydown', handleEscape);

  return modal;
}

function closeModal(modal) {
  modal.classList.add('closing'); // Animacja zamykania
  setTimeout(() => modal.remove(), 300);
}

Funkcja zwraca gotowy modal, a wywołujący wstawia go na stronę, na przykład przez document.body.appendChild(). Funkcja closeModal() dodaje klasę animacji i usuwa element po 300 milisekundach.

Zwróć uwagę na kilka kluczowych praktyk:

  • Zamykanie na Escape - dodajemy listener na document i usuwamy go po zamknięciu klawiszem Escape (przy innych sposobach zamknięcia trzeba go zdjąć osobno)
  • Kliknięcie w tło - sprawdzamy e.target === modal, żeby odróżnić kliknięcie w overlay od kliknięcia w treść modala
  • Zmiana stanu przycisku - po dodaniu do ulubionych blokujemy przycisk, żeby zapobiec wielokrotnemu kliknięciu

Znajdź słaby punkt: jeśli operator zamknie modal krzyżykiem albo kliknięciem w tło, listener handleEscape zostaje na document. Trzyma w domknięciu usunięty modal, czyli dokładnie ten wyciek, o którym mówiliśmy przy sprzątaniu. Rozwiązanie poznasz na końcu lekcji.

Komponent: Przełącznik (Toggle Switch)

Prosty komponent do włączania i wyłączania systemów parku. Przyjmuje etykietę i funkcję onChange, którą wywoła po każdej zmianie stanu:

function createToggle(label, onChange) {
  const container = document.createElement('div');
  container.className = 'toggle-container';
  container.innerHTML = `
    <span class="toggle-label">${label}</span>
    <button class="toggle-btn" data-active="false">OFF</button>
  `;

  const btn = container.querySelector('.toggle-btn');

  btn.addEventListener('click', () => {
    const isActive = btn.dataset.active === 'true';
    const newState = !isActive;

    btn.dataset.active = String(newState);
    btn.textContent = newState ? 'ON' : 'OFF';
    btn.classList.toggle('active', newState);

    // Wywołaj callback z nowym stanem
    onChange(newState);
  });

  return container;
}

// Użycie - panel sterowania ogrodzeniami
const fenceToggle = createToggle('Ogrodzenie elektryczne', (active) => {
  console.log(`Ogrodzenie ${active ? 'AKTYWNE' : 'WYŁĄCZONE'}`);
});
document.getElementById('controls').appendChild(fenceToggle);

Atrybuty data-* przechowują wyłącznie tekst, dlatego porównujemy z 'true' i zapisujemy String(newState). Drugi argument classList.toggle() wymusza stan klasy zamiast go odwracać. Przełącznik nie wie, że steruje ogrodzeniem, tylko informuje o zmianie przez callback, więc ten sam komponent obsłuży bramy i oświetlenie.

Komponent: Tabs (Zakładki)

Zakładki to niezbędny element panelu monitoringu, pozwalający przełączać między widokami różnych stref parku. Najpierw tworzymy kontener z dwiema częściami:

function createTabs(tabsData) {
  const container = document.createElement('div');
  container.className = 'tabs-container';

  // Nagłówki zakładek
  const header = document.createElement('div');
  header.className = 'tabs-header';

  // Treści zakładek
  const body = document.createElement('div');
  body.className = 'tabs-body';

Nagłówek zbierze przyciski, a ciało panele z treścią. W pętli każda zakładka dostaje przycisk, panel i własny listener.

  tabsData.forEach((tab, index) => {
    // Przycisk zakładki
    const tabBtn = document.createElement('button');
    tabBtn.className = 'tab-btn' + (index === 0 ? ' active' : '');
    tabBtn.textContent = tab.title;
    tabBtn.dataset.index = index;

    // Panel z treścią
    const tabPanel = document.createElement('div');
    tabPanel.className = 'tab-panel' + (index === 0 ? ' active' : '');
    tabPanel.innerHTML = tab.content;

    // Kliknięcie w zakładkę
    tabBtn.addEventListener('click', () => {
      // Dezaktywuj wszystkie
      header.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
      body.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));

      // Aktywuj wybraną
      tabBtn.classList.add('active');
      tabPanel.classList.add('active');
    });

    header.appendChild(tabBtn);
    body.appendChild(tabPanel);
  });

  container.appendChild(header);
  container.appendChild(body);
  return container;
}

Każdy listener pamięta w domknięciu swoją parę tabBtn i tabPanel, więc nie potrzebujemy identyfikatorów. Pierwsza zakładka startuje aktywna dzięki warunkowi index === 0. Przy dużej liczbie zakładek możesz zastąpić te listenery jednym na nagłówku, jak w lekcji o delegacji. Teraz użycie:

// Użycie
const monitorTabs = createTabs([
  { title: 'Strefa A', content: '<p>T-Rex: status OK</p>' },
  { title: 'Strefa B', content: '<p>Raptory: aktywne</p>' },
  { title: 'Strefa C', content: '<p>Brachiozaur: spokojny</p>' },
]);

Funkcja tylko zwraca gotowy kontener, więc monitorTabs trzeba jeszcze wstawić na stronę przez appendChild().

Wzorzec: Sprzątanie komponentu

Każdy komponent powinien mieć metodę destroy(), która usuwa wszystkie event listenery i zapobiega wyciekom pamięci:

class ParkWidget {
  constructor(element) {
    this.element = element;
    this.handlers = [];
    this.init();
  }

  addListener(target, event, handler) {
    target.addEventListener(event, handler);
    this.handlers.push({ target, event, handler });
  }

  destroy() {
    // Usunięcie wszystkich zarejestrowanych listenerów
    this.handlers.forEach(({ target, event, handler }) => {
      target.removeEventListener(event, handler);
    });
    this.handlers = [];
    this.element.remove();
  }

  init() {
    this.addListener(this.element, 'click', (e) => {
      console.log('Widget kliknięty');
    });
    this.addListener(document, 'keydown', (e) => {
      if (e.key === 'Escape') this.destroy();
    });
  }
}

Przechowywanie referencji do wszystkich listenerów i ich zbiorowe usuwanie w destroy() to najlepsza praktyka przy budowaniu komponentów - szczególnie ważna w dynamicznych aplikacjach, gdzie komponenty są często tworzone i niszczone.

Metoda addListener() zapisuje każdą trójkę: cel, typ i funkcję, więc destroy() może zdjąć także listener z document. To rozwiązanie słabego punktu modala. Pełny cykl życia komponentu ma cztery etapy: utworzenie elementu przez createElement, dodanie go do DOM przez appendChild, rejestrację listenerów na zamknięcie i Escape, a na końcu destroy(), który usuwa listenery i sam element.

Moja rada: zamiast tablicy możesz użyć jednego AbortController na komponent i przekazywać jego signal do każdego listenera. Wtedy destroy() sprowadza się do abort() i usunięcia elementu. W piaskownicy poniżej otworzysz i zamkniesz modal dinozaura. W kolejnej lekcji zbierzemy dobre praktyki obsługi wydarzeń w jedną listę.

Pamiętaj: komponent jest jak wybieg w parku, który otwierasz razem z czujnikami i zamykasz dopiero wtedy, gdy wyłączysz ostatni z nich.

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Tworzenie Interaktywnych Komponentów</title>
    <style>
        body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
        .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; }
        .modal.show { display: flex; align-items: center; justify-content: center; }
        .modal-content { background: #34495e; padding: 30px; border-radius: 15px; max-width: 500px; width: 90%; }
        .close-btn { float: right; font-size: 24px; cursor: pointer; }
        button { padding: 12px 24px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 10px; }
        button:hover { background: #2980b9; }
    </style>
</head>
<body>
    <h1>Interaktywne Komponenty</h1>
    <button id="open-modal">Otwórz Modal Dinozaura</button>
    <div id="modal" class="modal">
        <div class="modal-content">
            <span class="close-btn">&times;</span>
            <h2>T-Rex</h2>
            <p>Tyrannosaurus Rex - król dinozaurów!</p>
            <button id="like-btn">Polub</button>
        </div>
    </div>
    <script>
        const modal = document.getElementById('modal');
        const openBtn = document.getElementById('open-modal');
        const closeBtn = document.querySelector('.close-btn');

        function openModal() {
            modal.classList.add('show');
            console.log('Modal otwarty');
        }

        function closeModal() {
            modal.classList.remove('show');
            console.log('Modal zamknięty');
        }

        openBtn.addEventListener('click', openModal);
        closeBtn.addEventListener('click', closeModal);
        modal.addEventListener('click', (e) => {
            if (e.target === modal) closeModal();
        });

        document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') closeModal();
        });

        document.getElementById('like-btn').addEventListener('click', () => {
            alert('Polubiłeś T-Rexa!');
        });

        console.log('System modali gotowy');
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

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. Jak w komponencie modala odróżnić kliknięcie w tło (overlay) od kliknięcia w treść?

  2. 2. Dlaczego komponent powinien w metodzie destroy() usuwać swoje event listenery?

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj etapy cyklu życia komponentu modala (od góry):

Przydatne artykuły