Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM
Projekt końcowy: Interaktywny panel kontrolny Parku Jurajskiego
W tej lekcji4
"To jest moment prawdy," ogłasza Ray Arnold, główny inżynier systemów Parku Jurajskiego, wskazując na ciemny, wyłączony ekran centralnego panelu kontrolnego. "Nasz główny system monitoringu uległ awarii podczas ostatniej burzy. Musisz odbudować interaktywny panel kontrolny od zera, wykorzystując wszystko, czego nauczyłeś się o wydarzeniach i interakcjach w JavaScript."
W tym projekcie stworzysz kompletny, interaktywny panel kontrolny Parku Jurajskiego. Panel będzie obsługiwał klikanie, przeciąganie, zdarzenia klawiatury, formularze, delegację wydarzeń, propagację, throttling, custom events i odpowiednie zarządzanie pamięcią. To prawdziwy test wszystkich umiejętności z tego modułu - od podstawowych event listenerów po zaawansowane wzorce architektoniczne.
Cel projektu
Zbudujesz panel kontrolny z następującymi funkcjonalnościami:
- Mapa parku z klikalnymi sektorami (delegacja wydarzeń)
- System alarmowy reagujący na skróty klawiszowe
- Formularz rejestracji incydentów z walidacją
- Monitor aktywności dinozaurów z throttlingiem
- Event Bus do komunikacji między modułami
- Poprawne czyszczenie pamięci przy zamykaniu paneli
Wymagania funkcjonalne
1. Mapa parku z delegacją wydarzeń i propagacją
Zamiast dodawać event listener do każdego sektora osobno, wykorzystaj delegację wydarzeń - jeden listener na kontenerze mapy obsługuje kliknięcia we wszystkich sektorach. Pamiętaj też, że kliknięcie w dinozaura bąbelkuje przez sektor do mapy, więc w delegowanym listenerze najpierw sprawdź dinozaura i zakończ obsługę przez return, żeby nie aktywować jednocześnie sektora.
// Struktura HTML mapy parku
// <div id="park-map">
// <div class="sector" data-sector="A" data-danger="high">
// <span class="dino" data-dino-id="1">T-Rex</span>
// <span class="dino" data-dino-id="2">Velociraptor</span>
// </div>
// <div class="sector" data-sector="B" data-danger="low">
// <span class="dino" data-dino-id="3">Triceratops</span>
// </div>
// </div>
class ParkMap {
constructor(mapElement) {
this.mapElement = mapElement;
this.selectedSector = null;
this.setupDelegation();
}
setupDelegation() {
// Jeden listener obsługuje wszystkie kliknięcia na mapie
this.mapElement.addEventListener('click', (event) => {
const dinoElement = event.target.closest('.dino');
if (dinoElement) {
// Kliknięto w dinozaura - obsłuż go i zakończ, żeby nie zaznaczać sektora
// (stopPropagation() nic by tu nie dało: zdarzenie przeszło już przez sektor)
this.handleDinoClick(dinoElement);
return;
}
const sectorElement = event.target.closest('.sector');
if (sectorElement) {
this.handleSectorClick(sectorElement);
}
});
}
handleSectorClick(sectorEl) {
// Odznacz poprzedni sektor
if (this.selectedSector) {
this.selectedSector.classList.remove('selected');
}
sectorEl.classList.add('selected');
this.selectedSector = sectorEl;
const sectorId = sectorEl.dataset.sector;
const danger = sectorEl.dataset.danger;
console.log(`Sektor ${sectorId} wybrany [Zagrożenie: ${danger}]`);
// Emituj custom event z informacją o wybranym sektorze
const sectorEvent = new CustomEvent('sector:selected', {
detail: { sectorId, danger },
bubbles: true
});
sectorEl.dispatchEvent(sectorEvent);
}
handleDinoClick(dinoEl) {
const dinoId = dinoEl.dataset.dinoId;
const dinoName = dinoEl.textContent;
console.log(`Wybrany dinozaur: ${dinoName} (ID: ${dinoId})`);
// Custom event z danymi dinozaura
const dinoEvent = new CustomEvent('dino:selected', {
detail: { dinoId, dinoName },
bubbles: true
});
dinoEl.dispatchEvent(dinoEvent);
}
// Dynamiczne dodawanie nowych sektorów - dzięki delegacji
// nie trzeba dodawać nowych listenerów
addSector(sectorId, danger) {
const sector = document.createElement('div');
sector.className = 'sector';
sector.dataset.sector = sectorId;
sector.dataset.danger = danger;
sector.textContent = `Sektor ${sectorId}`;
this.mapElement.appendChild(sector);
// Event listener już działa automatycznie!
}
}2. System alarmowy z wydarzeniami klawiatury i myszy
Stwórz system reagujący na skróty klawiszowe do szybkiego uruchamiania procedur awaryjnych. System musi obsługiwać kombinacje klawiszy, rozróżniać kontekst (czy użytkownik pisze w formularzu, czy steruje panelem) i reagować na różne typy wydarzeń myszy.
class AlarmSystem {
constructor() {
this.alarmLevel = 0; // 0-4 (0 = brak, 4 = krytyczny)
this.isActive = true;
this.keyHandlers = new Map();
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.boundHandleKeyUp = this.handleKeyUp.bind(this);
this.pressedKeys = new Set();
this.registerShortcuts();
this.attachListeners();
}
registerShortcuts() {
// Ctrl+1 do Ctrl+4: Poziomy alarmu
this.keyHandlers.set('ctrl+1', () => this.setAlarmLevel(1));
this.keyHandlers.set('ctrl+2', () => this.setAlarmLevel(2));
this.keyHandlers.set('ctrl+3', () => this.setAlarmLevel(3));
this.keyHandlers.set('ctrl+4', () => this.setAlarmLevel(4));
// Escape: Wyłącz alarm
this.keyHandlers.set('escape', () => this.setAlarmLevel(0));
// Ctrl+Shift+E: Ewakuacja
this.keyHandlers.set('ctrl+shift+e', () => this.triggerEvacuation());
// Ctrl+Shift+L: Zablokuj wszystkie zagrody
this.keyHandlers.set('ctrl+shift+l', () => this.lockAllEnclosures());
}
attachListeners() {
document.addEventListener('keydown', this.boundHandleKeyDown);
document.addEventListener('keyup', this.boundHandleKeyUp);
}
handleKeyDown(event) {
// Ignoruj skróty, gdy użytkownik pisze w formularzu
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
this.pressedKeys.add(event.key.toLowerCase());
// Buduj kombinację klawiszy
const combo = this.buildKeyCombo(event);
const handler = this.keyHandlers.get(combo);
if (handler) {
event.preventDefault(); // Zapobiegaj domyślnej akcji przeglądarki
handler();
}
}
handleKeyUp(event) {
this.pressedKeys.delete(event.key.toLowerCase());
}
buildKeyCombo(event) {
const parts = [];
if (event.ctrlKey || event.metaKey) parts.push('ctrl');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
const key = event.key.toLowerCase();
if (!['control', 'shift', 'alt', 'meta'].includes(key)) {
parts.push(key);
}
return parts.join('+');
}
setAlarmLevel(level) {
const previousLevel = this.alarmLevel;
this.alarmLevel = level;
console.log(`Poziom alarmu: ${previousLevel} → ${level}`);
// Emituj custom event o zmianie poziomu alarmu
document.dispatchEvent(new CustomEvent('alarm:levelChanged', {
detail: { previousLevel, currentLevel: level }
}));
}
triggerEvacuation() {
console.log('EWAKUACJA URUCHOMIONA!');
this.setAlarmLevel(4);
document.dispatchEvent(new CustomEvent('alarm:evacuation'));
}
lockAllEnclosures() {
console.log('Blokowanie wszystkich zagród...');
document.dispatchEvent(new CustomEvent('alarm:lockdown'));
}
// Czyszczenie pamięci - KRYTYCZNE
destroy() {
document.removeEventListener('keydown', this.boundHandleKeyDown);
document.removeEventListener('keyup', this.boundHandleKeyUp);
this.keyHandlers.clear();
this.pressedKeys.clear();
this.isActive = false;
console.log('AlarmSystem: Listenery usunięte, pamięć wyczyszczona');
}
}3. Formularz rejestracji incydentów z walidacją
Zbuduj formularz obsługujący zdarzenia submit, input, change, focus i blur. Formularz powinien walidować dane w czasie rzeczywistym i zapobiegać wysłaniu niepoprawnych danych.
class IncidentForm {
constructor(formElement) {
this.form = formElement;
this.errors = new Map();
this.boundHandlers = [];
this.setupValidation();
this.setupSubmit();
}
setupValidation() {
// Walidacja pola "opis" przy każdym wpisywanym znaku
const descriptionInput = this.form.querySelector('#description');
const descHandler = (event) => {
const value = event.target.value;
if (value.length < 10) {
this.setError('description', 'Opis musi mieć minimum 10 znaków');
} else if (value.length > 500) {
this.setError('description', 'Opis nie może przekraczać 500 znaków');
} else {
this.clearError('description');
}
};
descriptionInput.addEventListener('input', descHandler);
this.boundHandlers.push({ element: descriptionInput, type: 'input', handler: descHandler });
// Walidacja selecta "sektor" przy zmianie
const sectorSelect = this.form.querySelector('#sector');
const sectorHandler = (event) => {
if (!event.target.value) {
this.setError('sector', 'Wybierz sektor');
} else {
this.clearError('sector');
}
};
sectorSelect.addEventListener('change', sectorHandler);
this.boundHandlers.push({ element: sectorSelect, type: 'change', handler: sectorHandler });
// Focus/blur efekty wizualne
const allInputs = this.form.querySelectorAll('input, select, textarea');
allInputs.forEach(input => {
const focusHandler = () => input.parentElement.classList.add('focused');
const blurHandler = () => {
input.parentElement.classList.remove('focused');
// Waliduj pole po opuszczeniu
input.dispatchEvent(new Event('input'));
};
input.addEventListener('focus', focusHandler);
input.addEventListener('blur', blurHandler);
this.boundHandlers.push(
{ element: input, type: 'focus', handler: focusHandler },
{ element: input, type: 'blur', handler: blurHandler }
);
});
}
setupSubmit() {
const submitHandler = (event) => {
event.preventDefault(); // Zapobiegaj domyślnemu wysłaniu formularza
if (this.errors.size > 0) {
console.log('Formularz zawiera błędy:', [...this.errors.values()]);
this.highlightErrors();
return;
}
const formData = new FormData(this.form);
const incident = Object.fromEntries(formData.entries());
console.log('Incydent zgłoszony:', incident);
// Custom event z danymi incydentu
document.dispatchEvent(new CustomEvent('incident:reported', {
detail: incident
}));
this.form.reset();
};
this.form.addEventListener('submit', submitHandler);
this.boundHandlers.push({ element: this.form, type: 'submit', handler: submitHandler });
}
setError(field, message) {
this.errors.set(field, message);
const errorEl = this.form.querySelector(`#${field}-error`);
if (errorEl) errorEl.textContent = message;
}
clearError(field) {
this.errors.delete(field);
const errorEl = this.form.querySelector(`#${field}-error`);
if (errorEl) errorEl.textContent = '';
}
highlightErrors() {
this.errors.forEach((message, field) => {
const input = this.form.querySelector(`#${field}`);
if (input) {
input.classList.add('error');
setTimeout(() => input.classList.remove('error'), 2000);
}
});
}
// Czyszczenie WSZYSTKICH listenerów
destroy() {
this.boundHandlers.forEach(({ element, type, handler }) => {
element.removeEventListener(type, handler);
});
this.boundHandlers = [];
this.errors.clear();
console.log('IncidentForm: Wszystkie listenery usunięte');
}
}4. Monitor aktywności z throttlingiem i Event Bus
Stwórz system monitorowania aktywności dinozaurów, który wykorzystuje throttling do ograniczania częstotliwości aktualizacji oraz Event Bus do komunikacji między wszystkimi modułami panelu.
// Event Bus - centralny system komunikacji
class EventBus {
constructor() {
this.listeners = new Map();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
// Zwróć funkcję do odsubskrybowania
return () => this.off(event, callback);
}
off(event, callback) {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.listeners.delete(event);
}
}
}
emit(event, data) {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach(cb => {
try {
cb(data);
} catch (error) {
console.error(`Błąd w listenerze "${event}":`, error);
}
});
}
}
// Czyszczenie wszystkich subskrypcji
clear() {
this.listeners.clear();
console.log('EventBus: Wszystkie subskrypcje wyczyszczone');
}
}
// Throttle utility
function throttle(fn, delay) {
let lastCall = 0;
let timeoutId = null;
const throttled = function(...args) {
const now = Date.now();
const remaining = delay - (now - lastCall);
if (remaining <= 0) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
lastCall = now;
fn.apply(this, args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn.apply(this, args);
}, remaining);
}
};
// Metoda do czyszczenia
throttled.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return throttled;
}
// Monitor aktywności z throttlingiem
class ActivityMonitor {
constructor(eventBus, displayElement) {
this.eventBus = eventBus;
this.display = displayElement;
this.unsubscribers = [];
this.throttledFunctions = [];
this.setupMonitoring();
}
setupMonitoring() {
// Throttlowane aktualizacje pozycji myszy na mapie
const updatePosition = throttle((data) => {
this.display.querySelector('.position').textContent =
`Kursor: (${data.x}, ${data.y})`;
}, 100); // Maksymalnie 10 aktualizacji na sekundę
this.throttledFunctions.push(updatePosition);
// Nasłuchuj na zdarzenia z EventBus
const unsub1 = this.eventBus.on('dino:moved', (data) => {
this.logActivity(`Dinozaur ${data.name} przemieścił się do (${data.x}, ${data.y})`);
});
const unsub2 = this.eventBus.on('alarm:levelChanged', (data) => {
this.logActivity(`Alarm: poziom ${data.previousLevel} → ${data.currentLevel}`);
});
const unsub3 = this.eventBus.on('incident:reported', (data) => {
this.logActivity(`Nowy incydent w sektorze ${data.sector}: ${data.description}`);
});
this.unsubscribers.push(unsub1, unsub2, unsub3);
// Mousemove na mapie z throttlingiem
const mapElement = document.getElementById('park-map');
if (mapElement) {
const mouseMoveHandler = (event) => {
const rect = mapElement.getBoundingClientRect();
updatePosition({
x: Math.round(event.clientX - rect.left),
y: Math.round(event.clientY - rect.top)
});
};
mapElement.addEventListener('mousemove', mouseMoveHandler);
this.unsubscribers.push(() => {
mapElement.removeEventListener('mousemove', mouseMoveHandler);
});
}
}
logActivity(message) {
const entry = document.createElement('div');
entry.className = 'log-entry';
const time = new Date().toLocaleTimeString();
entry.textContent = `[${time}] ${message}`;
this.display.querySelector('.log').prepend(entry);
// Ogranicz liczbę wpisów w logu
const entries = this.display.querySelectorAll('.log-entry');
if (entries.length > 50) {
entries[entries.length - 1].remove();
}
}
// Czyszczenie WSZYSTKIEGO
destroy() {
// Odsubskrybuj z EventBus
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
// Anuluj throttlowane funkcje
this.throttledFunctions.forEach(fn => fn.cancel());
this.throttledFunctions = [];
console.log('ActivityMonitor: Pełne czyszczenie zakończone');
}
}Wskazówki implementacyjne
Zacznij od stworzenia centralnego EventBus, który będzie łączył wszystkie moduły. Następnie buduj poszczególne komponenty w kolejności:
// 1. Inicjalizacja Event Bus
const eventBus = new EventBus();
// 2. Mapa parku z delegacją
const parkMap = new ParkMap(document.getElementById('park-map'));
// 3. System alarmowy
const alarmSystem = new AlarmSystem();
// 4. Formularz incydentów
const incidentForm = new IncidentForm(document.getElementById('incident-form'));
// 5. Monitor aktywności
const activityMonitor = new ActivityMonitor(
eventBus,
document.getElementById('activity-panel')
);
// 6. Połącz moduły przez EventBus
// Nasłuchuj custom events z DOM i przekieruj do EventBus
document.addEventListener('sector:selected', (e) => {
eventBus.emit('sector:selected', e.detail);
});
document.addEventListener('alarm:levelChanged', (e) => {
eventBus.emit('alarm:levelChanged', e.detail);
});
document.addEventListener('incident:reported', (e) => {
eventBus.emit('incident:reported', e.detail);
});
// 7. Czyszczenie przy zamykaniu (np. przy zmianie widoku w SPA)
function destroyPanel() {
parkMap.destroy?.();
alarmSystem.destroy();
incidentForm.destroy();
activityMonitor.destroy();
eventBus.clear();
console.log('Panel kontrolny zamknięty - wszystkie zasoby zwolnione');
}Pamiętaj o kluczowych zasadach:
- Delegacja wydarzeń zamiast wielu listenerów na pojedynczych elementach
event.stopPropagation()tylko wtedy, gdy kliknięcie naprawdę nie powinno dotrzeć do przodków (w delegacji zwykle wystarczyreturn)event.preventDefault()dla formularzy i skrótów klawiszowych- Throttling dla wydarzeń wysokiej częstotliwości (mousemove, scroll)
- Przechowywanie referencji do handlerów, aby móc je później usunąć
- Metoda
destroy()w każdym module do czyszczenia pamięci
Powodzenia!
"Systemy Parku Jurajskiego nie wybaczają błędów," mówi Ray Arnold, zapalając kolejnego papierosa. "Wyciek pamięci w monitoringu zagród oznacza, że po kilku godzinach system się zawiesi. Nieusunięty event listener może blokować garbage collector i zużywać coraz więcej RAM-u. A źle obsłużona propagacja wydarzeń może sprawić, że kliknięcie przycisku ewakuacji otworzy bramę zagrody z T-Rexem zamiast ją zamknąć."
"Zbuduj ten panel tak, jakby od niego zależało życie ludzi w parku. Bo w pewnym sensie - tak właśnie jest."
Otwórz edytor i stwórz swój interaktywny panel kontrolny!
Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<title>Panel Kontrolny - Park Jurajski</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }
h1 { text-align: center; color: #e94560; margin-bottom: 20px; }
.panel { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; max-width: 900px; margin: 0 auto; }
.section { background: rgba(255,255,255,0.05); padding: 20px; border-radius: 10px; border: 1px solid #333; }
.section h2 { color: #0f3460; font-size: 16px; margin-bottom: 10px; color: #e94560; }
.sector { padding: 10px; margin: 5px 0; border-radius: 6px; cursor: pointer; background: rgba(255,255,255,0.05); border: 1px solid #444; }
.sector:hover { background: rgba(233,69,96,0.2); }
.sector .dino { display: inline-block; background: #0f3460; padding: 3px 8px; border-radius: 4px; margin: 2px; cursor: pointer; font-size: 12px; }
.dino:hover { background: #e94560; }
.log { background: #000; padding: 10px; border-radius: 6px; height: 150px; overflow-y: auto; font-family: monospace; font-size: 12px; }
.log-entry { padding: 2px 0; border-bottom: 1px solid #222; }
input, select, textarea { padding: 8px; border-radius: 4px; border: 1px solid #444; background: #16213e; color: #eee; width: 100%; margin: 5px 0; }
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; background: #e94560; color: white; margin: 5px 2px; }
button:hover { background: #c0392b; }
.alert { padding: 8px; margin: 5px 0; border-radius: 4px; font-size: 13px; }
.alert-high { background: rgba(231,76,60,0.3); border-left: 3px solid #e74c3c; }
.alert-low { background: rgba(46,204,113,0.3); border-left: 3px solid #2ecc71; }
</style>
</head>
<body>
<h1>Panel Kontrolny Parku Jurajskiego</h1>
<div class="panel">
<!-- SEKCJA 1: Mapa parku z delegacją wydarzeń -->
<div class="section">
<h2>Mapa Parku</h2>
<div id="park-map">
<!-- TODO: Dodaj event listener na #park-map (delegacja) -->
<!-- Kliknięcie w .sector -> pokaż info o sektorze -->
<!-- Kliknięcie w .dino -> pokaż info o dinozaurze i zakończ obsługę przez return, żeby nie zaznaczać sektora -->
<div class="sector" data-sector="A" data-danger="high">
Sektor A (Wysokie zagrożenie)
<div><span class="dino" data-dino-id="1">T-Rex</span> <span class="dino" data-dino-id="2">Velociraptor</span></div>
</div>
<div class="sector" data-sector="B" data-danger="low">
Sektor B (Niskie zagrożenie)
<div><span class="dino" data-dino-id="3">Triceratops</span> <span class="dino" data-dino-id="4">Brachio</span></div>
</div>
<div class="sector" data-sector="C" data-danger="medium">
Sektor C (Średnie zagrożenie)
<div><span class="dino" data-dino-id="5">Stegosaurus</span></div>
</div>
</div>
</div>
<!-- SEKCJA 2: Formularz incydentów -->
<div class="section">
<h2>Rejestracja Incydentu</h2>
<form id="incident-form">
<!-- TODO: Dodaj event listener na submit z preventDefault -->
<!-- TODO: Walidacja pól przed wysłaniem -->
<input type="text" id="incident-title" name="title" placeholder="Tytuł incydentu" required>
<textarea id="description" name="description" placeholder="Opis incydentu (min. 10 znaków)"></textarea>
<div class="error" id="description-error"></div>
<select id="sector" name="sector">
<option value="">Wybierz sektor</option>
<option value="A">Sektor A</option>
<option value="B">Sektor B</option>
<option value="C">Sektor C</option>
</select>
<div class="error" id="sector-error"></div>
<select id="incident-severity" name="severity">
<option value="">Priorytet</option>
<option value="high">Wysoki</option>
<option value="medium">Średni</option>
<option value="low">Niski</option>
</select>
<button type="submit">Zgłoś incydent</button>
</form>
<div id="alerts"></div>
</div>
<!-- SEKCJA 3: Log zdarzeń -->
<div class="section" style="grid-column: 1 / -1;">
<h2>Log zdarzeń (skróty: Ctrl+1-4 = poziom alarmu, Esc = wyłącz alarm, Ctrl+Shift+E = ewakuacja, Ctrl+Shift+L = blokada)</h2>
<div class="log" id="event-log"></div>
</div>
<!-- SEKCJA 4: Monitor aktywności (ActivityMonitor z lekcji) -->
<div class="section" id="activity-panel" style="grid-column: 1 / -1;">
<h2>Monitor aktywności</h2>
<div class="position">Kursor: (0, 0)</div>
<div class="log"></div>
</div>
</div>
<script>
// === PROJEKT KOŃCOWY: Interaktywny panel kontrolny ===
// TODO 1: DELEGACJA WYDARZEŃ na mapie parku
// Dodaj JEDEN event listener na #park-map
// Użyj event.target.closest() do identyfikacji klikniętego elementu
// TODO 2: FORMULARZ z walidacją
// Dodaj listener na submit formularza
// Użyj preventDefault()
// Waliduj pola i dodaj alert do #alerts
// TODO 3: SKRÓTY KLAWISZOWE
// Dodaj listener na keydown
// Ctrl+1-4 = poziom alarmu, Esc = wyłącz alarm, Ctrl+Shift+E = ewakuacja, Ctrl+Shift+L = blokada
// TODO 4: FUNKCJA LOGOWANIA
// Stwórz funkcję logEvent(message), która dodaje wpis do #event-log
function logEvent(message) {
// TODO: Dodaj wpis do logu z timestamp
}
// TODO 5: CZYSZCZENIE PAMIĘCI
// Stwórz funkcję cleanup(), która usuwa wszystkie listenery
// Wywołaj ją np. przy zamknięciu panelu
console.log("Panel kontrolny gotowy do konfiguracji!");
</script>
</body>
</html>Hasła z dokumentacji w tej lekcji
Widzisz błąd w tej lekcji?