Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM
Usuwanie event listenerów i czyszczenie pamięci
Czasami musimy usunąć event listenery, żeby nie zajmowały pamięci lub żeby przestały reagować na wydarzenia. To jak wyłączanie niepotrzebnych systemów alarmowych w parku.
"Najgorszy alarm to taki, który pilnuje pustej zagrody," mówi Ray Arnold. "Zagrodę rozebrano miesiąc temu, a czujnik wciąż ciągnie prąd i od czasu do czasu budzi całą ochronę." W aplikacji wygląda to podobnie: listener, którego nikt nie zdjął, dalej reaguje na wydarzenia i trzyma w pamięci wszystko, czego dotyka.
Właściwe zarządzanie event listenerami
Do usunięcia listenera służy removeEventListener(). Przyjmuje te same argumenty co addEventListener(): typ wydarzenia i funkcję obsługi.
1// Funkcja do obsługi kliknięć
2function handleEmergencyClick() {
3 console.log('Aktywacja protokołu awaryjnego!');
4 evacuatePark();
5}
6
7const emergencyButton = document.getElementById('emergency-btn');
8
9// Dodanie event listenera
10emergencyButton.addEventListener('click', handleEmergencyClick);
11
12// Usunięcie event listenera (musi być ta sama funkcja!)
13emergencyButton.removeEventListener('click', handleEmergencyClick);Przycisk alarmowy jest z powrotem głuchy, bo przeglądarka znalazła na liście dokładnie tę funkcję, którą wcześniej podpięliśmy. Tak wygląda pełny cykl życia listenera: dodanie, wywołanie wydarzenia, wykonanie funkcji i na końcu usunięcie. Sam przycisk nigdzie nie znika, znika tylko jego czujnik.
Pułapka: funkcja anonimowa
Najczęstszy błąd przy sprzątaniu wygląda niewinnie. Dwie identyczne funkcje strzałkowe wydają się tym samym, ale dla JavaScriptu są dwoma różnymi obiektami.
1// To NIE usunie listenera - dwie różne funkcje o tym samym kodzie
2emergencyButton.addEventListener('click', () => evacuatePark());
3emergencyButton.removeEventListener('click', () => evacuatePark());Listener zostaje na miejscu, bo JavaScript porównuje referencje do funkcji, a nie ich kod źródłowy. Jeśli planujesz usunięcie, zapisz funkcję pod nazwą albo w zmiennej. Pamiętaj też o fladze fazy przechwytywania: listener dodany z true usuniesz tylko wywołaniem, które także przekazuje true.
Listener czasowy
Czasem czujnik ma działać tylko przez określony czas, na przykład podczas próbnego alarmu. Wtedy zdejmujemy go po upływie limitu w setTimeout().
1// Przykład z czasowym event listenerem
2function temporaryEventHandler() {
3 console.log('Ten event listener będzie aktywny tylko przez 10 sekund');
4}
5
6const tempButton = document.getElementById('temp-button');
7tempButton.addEventListener('click', temporaryEventHandler);
8
9// Automatyczne usunięcie po 10 sekundach
10setTimeout(() => {
11 tempButton.removeEventListener('click', temporaryEventHandler);
12 console.log('Event listener został usunięty');
13}, 10000);Po dziesięciu sekundach kliknięcia w przycisk nie robią już nic, a przycisk dalej stoi na stronie. Gdy listener ma zadziałać tylko raz, prościej jest użyć opcji { once: true } z lekcji o listenerach, bo przeglądarka zdejmie go sama po pierwszym wywołaniu.
AbortController - jeden wyłącznik dla wielu czujników
Gdy listenerów jest kilka, pilnowanie wszystkich referencji robi się męczące. Wtedy pomaga AbortController: tworzysz kontroler, przekazujesz jego signal w opcjach listenera, a jedno wywołanie abort() zdejmuje wszystko naraz. Zobaczmy to na nieskończonym przewijaniu katalogu dinozaurów.
1const scrollController = new AbortController();
2
3window.addEventListener('scroll', () => {
4 const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
5
6 if (scrollTop + clientHeight >= scrollHeight - 100) {
7 loadMoreDinosaurs();
8 }
9}, { signal: scrollController.signal });
10
11// Katalog się skończył - wyłączamy czujnik jednym ruchem
12function onCatalogEnd() {
13 scrollController.abort();
14}Właściwość scrollTop mówi, ile przewinięto, clientHeight to wysokość widocznego okna, a scrollHeight wysokość całej strony. Gdy do końca zostaje mniej niż 100 pikseli, ładujemy kolejną partię. Zauważ, że funkcja obsługi nie ma nazwy, a mimo to da się ją usunąć, bo przeglądarka powiązała ją z sygnałem kontrolera. Ten sam signal możesz przekazać do dowolnej liczby listenerów. Zużyty kontroler już nie działa, więc do nowych listenerów tworzysz nowy.
Skąd biorą się wycieki pamięci
Wyciek pamięci to sytuacja, w której niepotrzebny obiekt nie może zostać zwolniony, bo coś wciąż na niego wskazuje. Silnik JavaScriptu sprząta tylko to, do czego nie prowadzi już żadna referencja. Przy wydarzeniach typowy scenariusz wygląda tak: listener wisi na window albo document, które żyją tak długo jak strona, a jego funkcja odwołuje się do usuniętej karty. Karta zniknęła z ekranu, ale nie z pamięci, bo listener wciąż ją trzyma. To samo robi zapomniana zmienna, która nadal wskazuje na usunięty element.
Sprzątanie w czterech krokach
Bezpieczne usuwanie elementu z czujnikiem to zawsze ta sama procedura, jak wygaszanie zagrody według checklisty.
1let raptorCard = document.getElementById('raptor-card');
2
3function handleCardClick() {
4 console.log('Karta raptora kliknięta');
5}
6
7raptorCard.addEventListener('click', handleCardClick);
8
9// Sprzątanie, gdy karta ma zniknąć
10raptorCard.removeEventListener('click', handleCardClick);
11raptorCard.remove();
12raptorCard = null;Ta procedura to dobry nawyk: zapisz referencję do funkcji handlera, wywołaj removeEventListener() z tą samą referencją, usuń element z DOM, a na końcu wyzeruj zmienne przypisaniem null. Współczesne silniki zwalniają usunięty element razem z jego własnymi listenerami, gdy nic innego już do niego nie prowadzi, więc dla pamięci najważniejsze są zmienne i listenery na window albo document, które wciąż odwołują się do karty.
Moja kolejność wyboru narzędzi: { once: true } dla jednorazowych reakcji, AbortController, gdy komponent ma kilka listenerów, a removeEventListener(), gdy zdejmujesz jeden konkretny czujnik. W piaskownicy poniżej możesz dodawać i usuwać listenery, obserwując, co się dzieje. W lekcji o komponentach zbierzemy całe sprzątanie w jednej metodzie destroy().
Pamiętaj: każdy czujnik, który włączasz w parku, musi mieć zaplanowane wyłączenie.
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>Usuwanie Event Listeneró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 min-height: 100vh;
15 }
16
17 .container {
18 max-width: 800px;
19 margin: 0 auto;
20 }
21
22 h1 {
23 text-align: center;
24 margin-bottom: 30px;
25 }
26
27 .demo-section {
28 background: rgba(0, 0, 0, 0.7);
29 border-radius: 15px;
30 padding: 30px;
31 margin-bottom: 20px;
32 border: 2px solid #34495e;
33 }
34
35 h2 {
36 color: #3498db;
37 margin-bottom: 20px;
38 }
39
40 .button-group {
41 display: grid;
42 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
43 gap: 15px;
44 margin: 20px 0;
45 }
46
47 button {
48 padding: 12px 20px;
49 border: none;
50 border-radius: 8px;
51 font-size: 16px;
52 font-weight: bold;
53 cursor: pointer;
54 transition: all 0.3s ease;
55 }
56
57 .btn-start {
58 background: linear-gradient(45deg, #27ae60, #2ecc71);
59 color: white;
60 }
61
62 .btn-stop {
63 background: linear-gradient(45deg, #e74c3c, #c0392b);
64 color: white;
65 }
66
67 .btn-action {
68 background: linear-gradient(45deg, #3498db, #2980b9);
69 color: white;
70 }
71
72 button:hover {
73 transform: translateY(-2px);
74 box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
75 }
76
77 button:disabled {
78 background: #7f8c8d;
79 cursor: not-allowed;
80 transform: none;
81 }
82
83 .dino-box {
84 background: rgba(52, 152, 219, 0.2);
85 border: 2px solid #3498db;
86 border-radius: 10px;
87 padding: 30px;
88 text-align: center;
89 margin: 20px 0;
90 cursor: pointer;
91 transition: all 0.3s ease;
92 }
93
94 .dino-box:hover {
95 transform: scale(1.05);
96 box-shadow: 0 10px 30px rgba(52, 152, 219, 0.3);
97 }
98
99 .dino-box.inactive {
100 opacity: 0.5;
101 border-color: #7f8c8d;
102 cursor: not-allowed;
103 }
104
105 .dino-emoji {
106 font-size: 80px;
107 display: block;
108 margin-bottom: 10px;
109 }
110
111 .status {
112 display: inline-block;
113 padding: 5px 15px;
114 border-radius: 20px;
115 margin-top: 10px;
116 font-weight: bold;
117 }
118
119 .status.active {
120 background: #27ae60;
121 }
122
123 .status.inactive {
124 background: #e74c3c;
125 }
126
127 .log {
128 background: #000;
129 border-radius: 8px;
130 padding: 15px;
131 max-height: 200px;
132 overflow-y: auto;
133 font-family: 'Courier New', monospace;
134 font-size: 13px;
135 margin-top: 20px;
136 }
137
138 .log-entry {
139 padding: 3px 0;
140 border-bottom: 1px solid #333;
141 }
142
143 .log-entry.add {
144 color: #2ecc71;
145 }
146
147 .log-entry.remove {
148 color: #e74c3c;
149 }
150
151 .log-entry.event {
152 color: #3498db;
153 }
154
155 .timer-display {
156 font-size: 48px;
157 text-align: center;
158 margin: 20px 0;
159 color: #3498db;
160 font-weight: bold;
161 }
162
163 .info-box {
164 background: rgba(241, 196, 15, 0.1);
165 border: 1px solid #f1c40f;
166 border-radius: 8px;
167 padding: 15px;
168 margin: 20px 0;
169 color: #f1c40f;
170 }
171 </style>
172</head>
173<body>
174 <div class="container">
175 <h1>Usuwanie Event Listenerów</h1>
176
177 <!-- Demo 1: Podstawowe dodawanie/usuwanie -->
178 <div class="demo-section">
179 <h2>Demo 1: Klikalne pole dinozaura</h2>
180 <p>Kliknij "Start Listenera" aby aktywować pole, następnie "Stop Listenera" aby je dezaktywować.</p>
181
182 <div class="button-group">
183 <button id="start-listener" class="btn-start">Start Listenera</button>
184 <button id="stop-listener" class="btn-stop">Stop Listenera</button>
185 </div>
186
187 <div id="dino-box" class="dino-box inactive">
188 <span class="dino-emoji">◆</span>
189 <h3>T-Rex</h3>
190 <p>Kliknij mnie, jeśli jestem aktywny!</p>
191 <span class="status inactive" id="dino-status">NIEAKTYWNY</span>
192 </div>
193
194 <div class="log" id="demo1-log"></div>
195 </div>
196
197 <!-- Demo 2: Timer z automatycznym usuwaniem -->
198 <div class="demo-section">
199 <h2>Demo 2: Tymczasowy Timer</h2>
200 <p>Timer będzie aktywny tylko przez 10 sekund, potem automatycznie się wyłączy.</p>
201
202 <div class="button-group">
203 <button id="start-timer" class="btn-start">Start Timer (10s)</button>
204 <button id="stop-timer" class="btn-stop">Stop Timer</button>
205 </div>
206
207 <div class="timer-display" id="timer-display">00:00</div>
208
209 <div class="info-box" id="timer-info">
210 Naciśnij "Start Timer" aby rozpocząć odliczanie. Event listener zostanie automatycznie usunięty po 10 sekundach.
211 </div>
212
213 <div class="log" id="demo2-log"></div>
214 </div>
215
216 <!-- Demo 3: Wiele listenerów -->
217 <div class="demo-section">
218 <h2>Demo 3: Zarządzanie wieloma listenerami</h2>
219 <p>Kontroluj różne typy wydarzeń niezależnie.</p>
220
221 <div class="button-group">
222 <button id="add-click" class="btn-start">Dodaj Click</button>
223 <button id="remove-click" class="btn-stop">Usuń Click</button>
224 <button id="add-hover" class="btn-start">Dodaj Hover</button>
225 <button id="remove-hover" class="btn-stop">Usuń Hover</button>
226 </div>
227
228 <div id="multi-dino" class="dino-box">
229 <span class="dino-emoji">◆</span>
230 <h3>Triceratops</h3>
231 <p>Testuj różne eventy!</p>
232 <div>
233 <span class="status inactive" id="click-status">Click: OFF</span>
234 <span class="status inactive" id="hover-status">Hover: OFF</span>
235 </div>
236 </div>
237
238 <div class="log" id="demo3-log"></div>
239 </div>
240 </div>
241
242 <script>
243 console.log('System zarządzania Event Listenerami - START');
244
245 // DEMO 1: Podstawowe dodawanie/usuwanie
246 const dinoBox = document.getElementById('dino-box');
247 const dinoStatus = document.getElementById('dino-status');
248 const demo1Log = document.getElementById('demo1-log');
249
250 function logDemo1(message, type = 'event') {
251 const entry = document.createElement('div');
252 entry.className = `log-entry ${type}`;
253 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
254 demo1Log.appendChild(entry);
255 demo1Log.scrollTop = demo1Log.scrollHeight;
256 }
257
258 // Funkcja obsługi kliknięcia - MUSI być nazwana, aby móc ją usunąć
259 function handleDinoClick() {
260 console.log('T-Rex został kliknięty!');
261 logDemo1('T-Rex został kliknięty!', 'event');
262 dinoBox.style.transform = 'scale(1.1)';
263 setTimeout(() => {
264 dinoBox.style.transform = 'scale(1)';
265 }, 200);
266 }
267
268 // Start listener
269 document.getElementById('start-listener').addEventListener('click', function() {
270 dinoBox.addEventListener('click', handleDinoClick);
271 dinoBox.classList.remove('inactive');
272 dinoStatus.textContent = 'AKTYWNY';
273 dinoStatus.className = 'status active';
274 logDemo1('Event listener DODANY', 'add');
275 console.log('Event listener dodany do T-Rex');
276 });
277
278 // Stop listener
279 document.getElementById('stop-listener').addEventListener('click', function() {
280 dinoBox.removeEventListener('click', handleDinoClick);
281 dinoBox.classList.add('inactive');
282 dinoStatus.textContent = 'NIEAKTYWNY';
283 dinoStatus.className = 'status inactive';
284 logDemo1('Event listener USUNIĘTY', 'remove');
285 console.log('Event listener usunięty z T-Rex');
286 });
287
288 // DEMO 2: Timer z automatycznym usuwaniem
289 const timerDisplay = document.getElementById('timer-display');
290 const timerInfo = document.getElementById('timer-info');
291 const demo2Log = document.getElementById('demo2-log');
292
293 let timerInterval = null;
294 let timerTimeout = null;
295 let seconds = 0;
296
297 function logDemo2(message, type = 'event') {
298 const entry = document.createElement('div');
299 entry.className = `log-entry ${type}`;
300 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
301 demo2Log.appendChild(entry);
302 demo2Log.scrollTop = demo2Log.scrollHeight;
303 }
304
305 function updateTimer() {
306 seconds++;
307 const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
308 const secs = (seconds % 60).toString().padStart(2, '0');
309 timerDisplay.textContent = `${mins}:${secs}`;
310 }
311
312 function stopTimer() {
313 if (timerInterval) {
314 clearInterval(timerInterval);
315 timerInterval = null;
316 logDemo2('Timer ZATRZYMANY', 'remove');
317 timerInfo.textContent = 'Timer został zatrzymany.';
318 timerInfo.style.borderColor = '#e74c3c';
319 }
320 if (timerTimeout) {
321 clearTimeout(timerTimeout);
322 timerTimeout = null;
323 }
324 }
325
326 document.getElementById('start-timer').addEventListener('click', function() {
327 stopTimer(); // Zatrzymaj poprzedni timer
328 seconds = 0;
329 timerDisplay.textContent = '00:00';
330
331 timerInterval = setInterval(updateTimer, 1000);
332 logDemo2('Timer URUCHOMIONY (automatyczny stop za 10s)', 'add');
333 timerInfo.textContent = 'Timer aktywny! Automatyczne wyłączenie za 10 sekund...';
334 timerInfo.style.borderColor = '#f1c40f';
335
336 // Automatyczne usunięcie po 10 sekundach
337 timerTimeout = setTimeout(() => {
338 stopTimer();
339 logDemo2('Timer automatycznie WYŁĄCZONY po 10 sekundach', 'remove');
340 timerInfo.textContent = 'Timer został automatycznie wyłączony po 10 sekundach. Event listener został usunięty!';
341 timerInfo.style.borderColor = '#27ae60';
342 console.log('Timer automatycznie zatrzymany i wyczyszczony');
343 }, 10000);
344 });
345
346 document.getElementById('stop-timer').addEventListener('click', stopTimer);
347
348 // DEMO 3: Wiele listenerów
349 const multiDino = document.getElementById('multi-dino');
350 const clickStatus = document.getElementById('click-status');
351 const hoverStatus = document.getElementById('hover-status');
352 const demo3Log = document.getElementById('demo3-log');
353
354 function logDemo3(message, type = 'event') {
355 const entry = document.createElement('div');
356 entry.className = `log-entry ${type}`;
357 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
358 demo3Log.appendChild(entry);
359 demo3Log.scrollTop = demo3Log.scrollHeight;
360 }
361
362 // Funkcje obsługi - muszą być nazwane
363 function handleMultiClick() {
364 logDemo3('Triceratops KLIKNIĘTY!', 'event');
365 console.log('Click event');
366 }
367
368 function handleMultiMouseEnter() {
369 logDemo3('Najechano na Triceratops', 'event');
370 multiDino.style.borderColor = '#2ecc71';
371 }
372
373 function handleMultiMouseLeave() {
374 multiDino.style.borderColor = '#3498db';
375 }
376
377 // Dodawanie Click Listenera
378 document.getElementById('add-click').addEventListener('click', function() {
379 multiDino.addEventListener('click', handleMultiClick);
380 clickStatus.textContent = 'Click: ON';
381 clickStatus.className = 'status active';
382 logDemo3('Click listener DODANY', 'add');
383 });
384
385 // Usuwanie Click Listenera
386 document.getElementById('remove-click').addEventListener('click', function() {
387 multiDino.removeEventListener('click', handleMultiClick);
388 clickStatus.textContent = 'Click: OFF';
389 clickStatus.className = 'status inactive';
390 logDemo3('Click listener USUNIĘTY', 'remove');
391 });
392
393 // Dodawanie Hover Listenerów
394 document.getElementById('add-hover').addEventListener('click', function() {
395 multiDino.addEventListener('mouseenter', handleMultiMouseEnter);
396 multiDino.addEventListener('mouseleave', handleMultiMouseLeave);
397 hoverStatus.textContent = 'Hover: ON';
398 hoverStatus.className = 'status active';
399 logDemo3('Hover listeners DODANE', 'add');
400 });
401
402 // Usuwanie Hover Listenerów
403 document.getElementById('remove-hover').addEventListener('click', function() {
404 multiDino.removeEventListener('mouseenter', handleMultiMouseEnter);
405 multiDino.removeEventListener('mouseleave', handleMultiMouseLeave);
406 hoverStatus.textContent = 'Hover: OFF';
407 hoverStatus.className = 'status inactive';
408 multiDino.style.borderColor = '#3498db';
409 logDemo3('Hover listeners USUNIĘTE', 'remove');
410 });
411
412 // Cleanup przy zamknięciu strony
413 window.addEventListener('beforeunload', function() {
414 stopTimer();
415 console.log('Wszystkie event listenery wyczyszczone przed zamknięciem');
416 });
417
418 logDemo1('System gotowy do pracy', 'add');
419 logDemo2('System gotowy do pracy', 'add');
420 logDemo3('System gotowy do pracy', 'add');
421 </script>
422</body>
423</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. Co może spowodować wyciek pamięci związany z event listenerami?
2. Dlaczego ważne jest usuwanie event listenerów, gdy element jest usuwany z DOM?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż elementy użycia AbortController do usunięcia event listenera:
- Edytor kodu
W pliku index.html jest galeria #gallery z trzema miniaturami (atrybut data-full wskazuje dużą wersję) i ukryty lightbox #lightbox (style.css pokazuje go z klasą open). Uzupełnij luki w skrypcie: ___BLANK1___ to zdarzenie kliknięcia, a ___BLANK2___ to właściwość obiektu event wskazująca element, który kliknięto (sprawdzamy, czy to IMG). Kliknięcie zdjęcia ma otworzyć lightbox z dużą wersją tego zdjęcia i podpisem z alt, a kliknięcie w odstęp między zdjęciami nie może go otwierać.
- Układanie w pionie
Ułóż fazy życia event listenera:
- Edytor kodu
W pliku index.html jest przewijana lista #dino-list (style.css daje jej wysokość 300 px i overflow-y: auto). Skrypt ładuje po 10 dinozaurów. Uzupełnij luki: ___BLANK1___ to metoda, która dodaje nowy element li na koniec listy, a ___BLANK2___ to właściwość z wysokością całej zawartości listy (scrollTop to przewinięte piksele, clientHeight wysokość widocznego okna). Gdy do końca listy zostanie mniej niż 50 pikseli, lista ma dociągnąć kolejne 10 elementów, a wcześniej nic nie dokładać.
- Edytor kodu
W pliku index.html jest przycisk #menu-toggle i menu #main-menu. Na wąskim ekranie style.css pokazuje menu tylko wtedy, gdy ma klasę open (na ekranie szerszym niż 900 px menu jest zawsze widoczne, a przycisk ukryty). Uzupełnij luki: ___BLANK1___ to zdarzenie kliknięcia, a ___BLANK2___ to metoda classList, która dodaje klasę open, gdy jej nie ma, i usuwa ją, gdy jest (zwraca przy tym true albo false). Pierwsze kliknięcie ma rozwinąć menu i ustawić aria-expanded="true", drugie zwinąć je i ustawić aria-expanded="false".
- Edytor kodu
W pliku index.html jest pole #dino-search i lista podpowiedzi #suggestions, a skrypt ma tablicę gatunków dinozaurów. Uzupełnij luki: ___BLANK1___ to zdarzenie, które pole wysyła po każdej zmianie wpisanego tekstu, a ___BLANK2___ to metoda tekstu, która sprawdza, czy nazwa zaczyna się od wpisanego fragmentu. Po wpisaniu "tri" lista ma pokazać tylko Triceratops (wielkość liter nie ma znaczenia), puste pole i tekst bez dopasowań czyszczą listę, a kliknięcie podpowiedzi wpisuje ją do pola.
- Edytor kodu
W pliku index.html jest panel z akapitem #status, a skrypt ma jeden listener keydown na dokumencie. Uzupełnij luki: ___BLANK1___ to metoda obiektu event, która blokuje domyślny zapis strony przez przeglądarkę przy Ctrl+S, ___BLANK2___ to wartość event.key dla skrótu Ctrl+O (mała litera), a ___BLANK3___ to wartość event.key klawisza Esc. Ctrl+S ma zapisać, Ctrl+O otworzyć raport, Esc zamknąć panel, a samo S bez Ctrl nie może niczego zmieniać.
- Edytor kodu
W pliku index.html są trzy zakładki (role="tab") z panelami i listener keydown na kontenerze #dino-tabs. Strzałka w prawo przesuwa fokus na następną zakładkę, a Enter wybiera zakładkę z fokusem. Uzupełnij luki: ___BLANK1___ to wartość event.key strzałki w lewo (przesuwa fokus na poprzednią zakładkę, z pierwszej na ostatnią), a ___BLANK2___ to warunek, który jest prawdziwy po naciśnięciu spacji (sprawdź event.key albo event.code). Enter i spacja mają pokazać panel zakładki z fokusem, a strzałki i spacja nie mogą przewijać strony.
- Układanie w poziomie
Ułóż elementy handlera zatrzymującego propagację zdarzenia:
- Układanie w pionie
Ułóż kroki czyszczenia event listenerów przy usuwaniu elementu z DOM: