Wygenerowany modal otwiera się poprawnie, ma cień, zaokrąglone rogi i animację wejścia. Naciśnięcie klawisza Tab przenosi fokus na link w stopce pod przyciemnionym tłem, Escape nie robi nic, a czytnik ekranu dalej czyta stronę spod okna. Wizualnie komponent jest gotowy. Dla osoby, która nie używa myszy, jest to ślepy zaułek, z którego wychodzi się dopiero przeładowaniem strony.
To najczęstszy rozjazd między tym, co model generuje, a tym, co jest komponentem produkcyjnym. Model dostał prompt o wyglądzie i wygląd dostarczył. Zachowanie klawiatury nie jest widoczne na zrzucie ekranu, więc nie trafia do promptu, nie trafia do kodu i nie trafia do odbioru.
Anatomia wygenerowanego modala i cztery brakujące zachowania
Typowy wynik generowania wygląda mniej więcej tak:
<div class="overlay" onclick="close()">
<div class="modal">
<h2>Usunąć projekt?</h2>
<p>Tej operacji nie można cofnąć.</p>
<button onclick="confirm()">Usuń</button>
<button onclick="close()">Anuluj</button>
</div>
</div>
Kod jest krótki, czytelny i przechodzi review osoby, która patrzy tylko na strukturę. Brakuje w nim czterech rzeczy, z których każda osobno wystarcza, żeby modal był niedostępny:
- Brak roli i nazwy. Dla technologii asystujących to zwykły
div. Nic nie sygnalizuje, że otworzyło się okno dialogowe, i nic nie podaje jego tytułu. - Fokus zostaje na przycisku wywołującym. Okno jest na wierzchu, kursor klawiatury został pod spodem. Pierwszy Tab prowadzi w losowe miejsce dokumentu, nie do treści okna.
- Fokus wychodzi poza okno. Nic nie zamyka cyklu tabulacji, więc po kilku naciśnięciach użytkownik ląduje w nawigacji, której nie widzi, bo przykrywa ją półprzezroczyste tło.
- Escape nie jest obsługiwany. Zamknięcie jest podpięte tylko pod
onclick, czyli pod mysz i pod dotyk.
Do tego dochodzi onclick na warstwie tła. Kliknięcie w tło zamknie okno, ale to samo zdarzenie odpali się przy kliknięciu wewnątrz modala, jeśli nie zatrzymano propagacji. Model zwykle o tym nie pamięta, bo problem widać dopiero przy testowaniu, nie przy czytaniu kodu.
Semantyka: element dialog kontra div z atrybutami ARIA
Są dwie drogi. Natywny otwierany metodą showModal() oraz div z ręcznie dopisanymi atrybutami ARIA. Modele generują częściej wariant drugi, bo tak wygląda większość kodu, na którym się uczyły.
| Zachowanie | div + role=”dialog” | |
|---|---|---|
| Rola i tryb modalny | Z definicji, bez atrybutów | Trzeba dopisać role="dialog" i aria-modal="true" |
| Pułapka fokusu | Wbudowana w przeglądarkę | Trzeba napisać samodzielnie |
| Escape | Wbudowany, emituje zdarzenie cancel |
Trzeba obsłużyć w listenerze |
| Odcięcie tła od czytnika ekranu | Automatyczne (drzewo top layer) | Trzeba użyć inert na reszcie strony |
| Powrót fokusu po zamknięciu | Zwykle tak, ale sprawdź w swoich przeglądarkach | Trzeba zapisać i przywrócić ręcznie |
| Warstwa tła | Pseudoelement ::backdrop |
Dodatkowy element w DOM |
Wniosek praktyczny: jeśli nie ma twardego powodu, żeby budować okno od zera, wybierz element natywny. Cztery z sześciu zachowań dostajesz bez linijki kodu, a każde z nich to potencjalny błąd w implementacji ręcznej.
Dwie pułapki przy elemencie natywnym. Po pierwsze, show() to nie to samo co showModal() – tylko druga metoda włącza tryb modalny, pułapkę fokusu i tło. Po drugie, dopisywanie aria-modal="true" do natywnego jest zbędne, a bywa szkodliwe, bo w części kombinacji przeglądarka plus czytnik ekranu zmienia sposób odczytu treści okna. Modele dokładają ten atrybut odruchowo. Usuwaj go przy przeglądzie.
Poprawka semantyczna: rola, nazwa i opis okna
Wariant z div, kiedy naprawdę jest potrzebny, wymaga trzech rzeczy naraz: roli, nazwy dostępnej i powiązania z opisem.
<div class="overlay" data-overlay>
<div class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="dlg-title"
aria-describedby="dlg-desc">
<h2 id="dlg-title">Usunąć projekt?</h2>
<p id="dlg-desc">Tej operacji nie można cofnąć.</p>
<button data-confirm>Usuń</button>
<button data-cancel>Anuluj</button>
</div>
</div>
role="dialog" nadaje semantykę. aria-modal="true" mówi technologii asystującej, żeby traktowała resztę drzewa jako niedostępną – ale sam atrybut niczego nie blokuje w przeglądarce, więc bez inert nie wystarcza. aria-labelledby daje oknu nazwę czytaną przy otwarciu. Bez niej czytnik ogłosi samo słowo „dialog”.
Dla okna, które wymusza decyzję i nie ma innego wyjścia niż jeden z przycisków, właściwą rolą jest alertdialog. Różnica jest praktyczna: przy tej roli czytniki ogłaszają treść okna od razu, bez czekania na przejście fokusem po zawartości.
Poprawka fokusu: wejście, pułapka, powrót
Trzy operacje, każda w innym momencie cyklu życia okna.
let lastFocused = null;
const FOCUSABLE = [
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
'select:not([disabled])', 'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
function openModal(dialog) {
lastFocused = document.activeElement;
dialog.hidden = false;
// 1. wejście: fokus do okna, nie do przycisku pod spodem
const first = dialog.querySelector('[autofocus]')
|| dialog.querySelector(FOCUSABLE)
|| dialog;
if (first === dialog) dialog.tabIndex = -1;
first.focus();
// 2. tło poza zasięgiem klawiatury i czytnika
document.querySelectorAll('body > *:not(.overlay)')
.forEach(el => el.inert = true);
dialog.addEventListener('keydown', onKeydown);
}
function onKeydown(e) {
if (e.key === 'Escape') { e.preventDefault(); closeModal(e.currentTarget); return; }
if (e.key !== 'Tab') return;
// 3. pułapka fokusu: zapętl pierwszy i ostatni element
const items = [...e.currentTarget.querySelectorAll(FOCUSABLE)]
.filter(el => el.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}
function closeModal(dialog) {
dialog.removeEventListener('keydown', onKeydown);
dialog.hidden = true;
document.querySelectorAll('body > *').forEach(el => el.inert = false);
// 4. powrót fokusu na element wywołujący
if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
lastFocused = null;
}
Cztery szczegóły, które w wygenerowanym kodzie zwykle wypadają. Lista elementów fokusowalnych jest liczona przy każdym naciśnięciu Tab, nie raz przy otwarciu – inaczej okno z dynamiczną treścią (walidacja, doładowany wiersz, przycisk pojawiający się po zaznaczeniu zgody) rozjedzie się z rzeczywistością. Filtr offsetParent !== null odrzuca elementy ukryte przez CSS, które nadal siedzą w DOM. Przed przywróceniem fokusu sprawdzamy, czy element wywołujący nadal istnieje, bo modal mógł usunąć wiersz tabeli razem z przyciskiem, który go otworzył – wtedy zamiast wyjątku trzeba przenieść fokus na nagłówek sekcji. I ostatnie: inert zdejmujemy ze wszystkich elementów, nie tylko z tych, którym go nadaliśmy, jeśli w aplikacji może być otwarty więcej niż jeden modal naraz. Przy zagnieżdżonych oknach lepiej trzymać stos poprzednich stanów.
Atrybut inert jest wspierany w bieżących wersjach Chrome, Firefoxa i Safari. Próg wsparcia sprawdź na caniuse.com dla własnej macierzy przeglądarek – jeśli musisz obsłużyć starsze wydania, potrzebny jest polyfill albo ręczne ustawianie aria-hidden="true" na kontenerze tła. Sam aria-hidden nie usuwa elementów z kolejności tabulacji, więc bez pułapki fokusu nie załatwia sprawy.
Mapowanie poprawek na kryteria WCAG 2.2
WCAG 2.2 jest rekomendacją W3C od 5 października 2023 roku. Poniższa tabela wiąże każdą poprawkę z kryterium, na które odpowiada. Numeracja i poziomy pochodzą ze specyfikacji W3C; nazwy podano w wersji roboczej po polsku, bo tłumaczenia bywają różne w różnych dokumentach.
| Poprawka | Kryterium | Poziom | Co konkretnie sprawdza audytor |
|---|---|---|---|
| Zamknięcie i potwierdzenie z klawiatury | 2.1.1 Klawiatura | A | Czy każdą akcję okna da się wykonać bez myszy |
| Pułapka fokusu z możliwością wyjścia | 2.1.2 Brak pułapki na klawiaturę | A | Czy z okna da się wyjść samą klawiaturą (Escape lub przycisk) |
| Przeniesienie fokusu przy otwarciu i powrót przy zamknięciu | 2.4.3 Kolejność fokusu | A | Czy kolejność zachowuje sens i kontekst po zamknięciu |
| Widoczny pierścień fokusu w oknie | 2.4.7 Widoczny fokus | AA | Czy outline nie został wyzerowany przez reset CSS |
| Fokus nieprzykryty przez okno lub sticky header | 2.4.11 Fokus niezasłonięty (minimum) | AA (nowe w 2.2) | Czy element z fokusem jest w całości niezakryty |
| role, aria-modal, aria-labelledby | 4.1.2 Nazwa, rola, wartość | A | Czy okno ma rolę i nazwę dostępną |
| Powiązanie tytułu i opisu z oknem | 1.3.1 Informacje i relacje | A | Czy relacja tytuł-okno wynika z kodu, nie z układu |
| Brak akcji wywołanej samym przeniesieniem fokusu | 3.2.1 Po otrzymaniu fokusu | A | Czy fokus na polu nie otwiera i nie zamyka okna sam z siebie |
Jedno zastrzeżenie, żeby nie sprzedawać mitu: w WCAG nie ma kryterium, które wprost nakazuje obsługę klawisza Escape w modalu. Escape jest tu środkiem do spełnienia 2.1.2, bo daje przewidywalne wyjście, i jest wzorcem opisanym w ARIA Authoring Practices Guide. Formalnie wystarczyłby osiągalny fokusem przycisk zamknięcia. Praktycznie brak Escape jest pierwszą rzeczą, którą zgłasza każdy użytkownik klawiatury.
Test klawiaturą: dziesięć kroków przed odbiorem
Test zajmuje około dwóch minut na komponent i nie wymaga żadnego narzędzia poza przeglądarką. Automat tego nie wychwyci – axe i Lighthouse sprawdzą atrybuty, nie kolejność fokusu.
- Odłóż mysz poza zasięg ręki. To nie żart, bez tego test się nie uda.
- Dotrzyj Tabem do przycisku otwierającego i naciśnij Enter, potem powtórz ze spacją.
- Sprawdź, gdzie jest pierścień fokusu zaraz po otwarciu. Ma być wewnątrz okna.
- Przejdź Tabem przez całą zawartość okna, licząc elementy. Po ostatnim fokus wraca na pierwszy.
- Powtórz w drugą stronę przez Shift+Tab. Cykl ma być zamknięty w obie strony.
- Naciśnij Escape. Okno się zamyka.
- Sprawdź, gdzie wylądował fokus po zamknięciu. Ma być na przycisku, który okno otworzył.
- Otwórz ponownie i przewiń stronę pod spodem strzałkami oraz kółkiem. Tło nie powinno się przewijać.
- Powtórz cały cykl przy powiększeniu strony do 200 procent i przy oknie przeglądarki zwężonym do 320 pikseli.
- Włącz czytnik ekranu (NVDA na Windowsie, VoiceOver na macOS) i sprawdź, czy przy otwarciu słychać rolę i tytuł okna, a nie samo słowo „dialog”.
Krok ostatni odpada najczęściej, bo wymaga instalacji i nauki dwóch skrótów. Bez niego zostają błędy, których nie widać: modal ze zdublowaną nazwą, przycisk zamknięcia opisany jako „button” i formularz ogłaszany dwa razy.
Co zmienić w promptcie i w definicji ukończenia
Poprawianie każdego wygenerowanego okna ręcznie kosztuje więcej niż jednorazowe przesunięcie wymagań na wejście procesu. Dwie zmiany dają większość efektu.
Pierwsza: w promptcie do komponentów interaktywnych opisuj zachowanie, nie tylko wygląd. Zdanie w rodzaju „użyj natywnego elementu dialog z showModal, przenieś fokus na pierwszy element interaktywny, przywróć fokus na element wywołujący przy zamknięciu, obsłuż Escape, ustaw inert na reszcie strony” daje kod, który przechodzi test klawiaturą za pierwszym razem. Bez tego zdania model dostarczy sam wygląd, bo o to został poproszony.
Druga: dopisz test klawiaturą do listy kontrolnej odbioru komponentu, obok przeglądu kodu i sprawdzenia responsywności. Dziesięć kroków powyżej nadaje się do skopiowania wprost do szablonu zadania.
Następny krok jest konkretny: wybierz w projekcie jeden komponent nakładkowy – modal, szufladę boczną albo menu użytkownika – przejdź przez dziesięć kroków testu i policz, ile z nich przechodzi. Jeśli oblewa więcej niż dwa kroki, ten sam błąd siedzi w każdym oknie w aplikacji, bo wszystkie wyszły z tego samego promptu. Wtedy naprawiaj wzorzec w jednym komponencie współdzielonym, nie osobno w każdym widoku.