Klasa contents na tbody wycina wiersze z drzewa dostępności. Znaczniki tabeli zostają bez zmian
Klasa contents, próg reguły axe, brak scope i aria-sort: cztery wady tabel w kodzie z modelu, komplet rol ARIA dla siatki z divów i kontrola przed odbiorem.
Responsywna tabela cenowa dostała wrapper z klasą contents wokół wiersza, żeby komórki zawijały się na telefonie. Znaczniki zostały bez zmian: <table>, <thead>, <th> w każdej kolumnie. W drzewie dostępności Chrome'a z tej tabeli zniknął cały <tbody>, a razem z nim powiązanie każdej kwoty z nazwą pakietu: czytnik ekranu przeczyta „1 299 zł” bez kolumny „Rozliczenie roczne” i bez wiersza „Pakiet Pro”.
Jedno słowo w atrybucie class to tylko jedna z czterech wad, które w generowanym kodzie tabel wychodzą razem i maskują się nawzajem. Pozostałe trzy: znaczniki, które wyglądają poprawnie, ale nic nie wiążą; siatka z divów z jedną rolą ARIA zamiast kompletu; sortowanie bez stanu. Każda ma inny objaw w narzędziu i inny koszt naprawy, a tylko jedna zgłasza się sama w automacie.
Co znika z drzewa, a co naprawiono w 2023 roku
Zacznijmy od tego, co się zmieniło, bo tu połowa dostępnych w sieci porad jest już nieprawdziwa. Ostrzeżenie „nie dawaj display: flex na tabelę, bo zabijesz semantykę” krąży po artykułach od 2018 r. i w aktualnych przeglądarkach nie obowiązuje. Adrian Roselli w aktualizacji swojego wpisu z 7 października 2023 r. napisał, że tabele z własnościami display działają już w przeglądarkach Chromium, Gecko i WebKit, a Apple naprawiło swoje błędy w Safari 17. Ta sama aktualizacja wskazuje jedną wartość, która nadal sprawia kłopot: display: contents.
Potwierdza to dokumentacja. MDN w sekcji o dostępności pisze, że bieżące implementacje w części przeglądarek usuwają z drzewa dostępności każdy element z wartością display: contents, zostawiając w nim potomków, i że jest to zachowanie niezgodne ze specyfikacją CSS. Osobna uwaga na tej samej stronie dotyczy zmiany display tabeli na block, grid lub flex i jest sformułowana warunkowo, przez „w niektórych przeglądarkach” — czyli dokładnie dla tych, które zostały w waszej macierzy wsparcia ze starszym silnikiem.
Skąd display: contents bierze się w generowanym kodzie, pokazuje przykład z otwarcia. Model dostaje polecenie „niech wiersze zawijają się na telefonie”, opakowuje grupę komórek dodatkowym divem i gasi ten wrapper, żeby nie psuł siatki. W Tailwindzie odpowiada temu klasa contents, którą dokumentacja opisuje jako tworzenie kontenera-widma, którego dzieci zachowują się jak bezpośrednie dzieci rodzica. W kodzie to jedno słowo w atrybucie class, w drzewie dostępności — zniknięcie wiersza albo całego tbody.
| Wartość na elemencie tabeli | Stan w aktualnych silnikach | Co zrobić w kodzie |
|---|---|---|
| display: flex, grid, block, inline-block | naprawione w Chromium, Gecko i WebKit (Safari 17) | można stosować, ale sprawdzić w macierzy przeglądarek projektu |
| display: contents | element nadal wypada z drzewa w części przeglądarek, wbrew specyfikacji | usunąć z elementów tabeli; układ zrobić na samej tabeli lub na komórkach |
| display: none, hidden | element i potomkowie poza drzewem, zgodnie z oczekiwaniem | świadome ukrycie; do zawijania wierszy nie służy |
Stan z pierwszego wiersza tej tabeli zmieniał się w ciągu sześciu lat kilka razy, osobno dla każdego silnika, więc nie przyjmujcie go na słowo z artykułu — ani z tego, ani z żadnego innego. Dwie minuty w drzewie dostępności na docelowej przeglądarce dają odpowiedź dla waszego projektu i waszej wersji silnika.
Dlaczego axe milczy przy tabeli 2 na 6
Wada z poprzedniej sekcji nie zgłasza się w automacie, ale są w tabelach wady, przy których automat milczy z innego powodu: progu. Regułą, która w axe pilnuje powiązania komórek z nagłówkami, jest td-has-header. Dokumentacja reguły w Deque University podaje, że sprawdza ona niepuste komórki <td> tylko w tabelach o szerokości co najmniej trzech i wysokości co najmniej trzech komórek, z wpływem oznaczonym jako krytyczny i mapowaniem na kryterium 1.3.1. Tabela porównawcza dwóch planów w sześciu wierszach jest pod tym progiem. Nie ma zgłoszenia, bo reguła się nie uruchomiła, a nie dlatego, że tabela jest w porządku.
Skala problemu w sieci jest znana z pomiaru, nie z wrażenia. Raport WebAIM Million z danych z lutego 2026 r. zarejestrował 948 225 tabel na 143 575 stronach głównych i policzył, że poprawne znaczniki tabeli danych miało 181 188 z nich, czyli 19 proc. Pozostałe cztery piąte to tabele, w których dane są, a relacji między komórką a nagłówkiem nie ma. Generator nie wymyślił tego wzorca, tylko nauczył się go z sieci.
Wniosek dla przeglądu kodu jest jeden: zielony wynik axe nad tabelą nie dowodzi niczego poza tym, że uruchomione reguły nie znalazły swojego wzorca. Dowodem jest drzewo dostępności, a nie raport skanera.
Siatka z divów potrzebuje całego zestawu rol, nie jednej
Trzecia postać problemu to brak tabeli w ogóle: dane leżą w divach ułożonych display: grid, bo tak łatwiej dostać responsywny układ. Dopisanie do kontenera role="table" nie wystarcza. Wzorzec tabeli w ARIA Authoring Practices wymaga, żeby wiersze były potomkami tabeli albo elementu z rolą rowgroup, a każda komórka — potomkiem wiersza, przy czym komórka dostaje rolę columnheader, rowheader albo cell. Brak jednego ogniwa w tym łańcuchu rozsypuje całość.
Tę konkretną pomyłkę automat łapie. Reguła aria-required-children w axe sprawdza, czy elementy z rolą ARIA zawierają wymagane role potomków, i ma wpływ oznaczony jako krytyczny przy kryterium 1.3.1. Rola rodzica bez rol dzieci to zgłoszenie, nie cisza — w przeciwieństwie do przypadku z poprzedniej sekcji.
Pełny komplet wygląda tak:
<div role="table" aria-label="Cennik pakietów">
<div role="rowgroup">
<div role="row">
<span role="columnheader">Pakiet</span>
<span role="columnheader">Rozliczenie roczne</span>
</div>
</div>
<div role="rowgroup">
<div role="row">
<span role="rowheader">Pakiet Pro</span>
<span role="cell">1 299 zł</span>
</div>
</div>
</div>
Przed wpisaniem tego do projektu warto przeczytać zastrzeżenie samego autora wzorca. Roselli pisze wprost: „If you are not able to test with a screen reader, maybe don't do this” — jeśli nie masz czym sprawdzić wyniku czytnikiem, lepiej tego nie robić. Rekonstrukcja tabeli z rol ARIA to dwadzieścia linii, które trzeba utrzymać przy każdej zmianie struktury, a nawigacja po takiej tabeli w czytniku i tak bywa gorsza niż po natywnej.
Tańsza droga jest zwykle ta sama: zostawić prawdziwy <table> i ostylować jego potomków, zamiast odtwarzać tabelę z divów. W Tailwindzie klasy table, table-row, table-cell i table-header-group pozwalają dołożyć układ tabelaryczny bez zdejmowania natywnej semantyki, a na wąskim ekranie łatwiej przestawić same komórki.
scope, caption i nagłówek wiersza: trzy znaczniki poza zasięgiem generatora
Jeżeli tabela zostaje tabelą, zostają trzy braki w samych znacznikach, które model popełnia niemal zawsze, bo wizualnie nie zmieniają nic. Dokument W3C objaśniający kryterium 1.3.1 Informacje i relacje, poziom A, wymienia dla tabel techniki H51 (znaczniki tabeli dla danych tabelarycznych), H39 (element caption jako powiązanie podpisu z tabelą), H63 (atrybut scope wiążący komórki nagłówkowe z danymi) i H43 (powiązanie przez id i headers), a jako porażkę F91 — tabelę z nagłówkami, które nie zostały oznaczone poprawnie.
Pierwszy brak: nagłówek wiersza. Generator daje <th> w górnym rzędzie i <td> w pierwszej kolumnie, więc nazwa pakietu jest dla czytnika zwykłą daną, a nie etykietą wiersza. Drugi: brak scope, który przy tabeli z dwoma wymiarami nagłówków rozstrzyga kierunek powiązania. Trzeci: podpis zrobiony jako <p> albo <h3> nad tabelą zamiast <caption> w środku — wizualnie identyczny, programowo niepowiązany.
<table>
<caption>Cennik pakietów, ceny netto za rok</caption>
<thead>
<tr>
<th scope="col">Pakiet</th>
<th scope="col">Rozliczenie roczne</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Pakiet Pro</th>
<td>1 299 zł</td>
</tr>
</tbody>
</table>
Różnica w kodzie to cztery atrybuty i jeden znacznik. Różnica w odsłuchu jest taka, że komórka czytana jest jako „Pakiet Pro, Rozliczenie roczne, 1 299 zł”, a nie jako sama kwota.
Klikalny nagłówek bez aria-sort nie mówi, w którą stronę posortował
Sortowanie kolumny model generuje chętnie i prawie zawsze bez stanu. W <th> ląduje przycisk z nazwą kolumny i strzałką wstawioną jako znak albo ikona, a kierunek sortowania istnieje wyłącznie w warstwie wizualnej. Wzorzec APG mówi, że aria-sort ustawia się na komórce nagłówkowej sortowanej kolumny lub wiersza, z odpowiednią wartością, i to jest jedyne miejsce, z którego czytnik dowie się o kierunku.
<th scope="col" aria-sort="ascending">
<button type="button">Cena</button>
</th>
Dwie rzeczy, które trzeba zrobić obok tego atrybutu. Pierwsza: aria-sort zostaje tylko na jednej kolumnie i aktualizuje się przy każdym kliknięciu, a z pozostałych nagłówków trzeba go zdjąć — generowany kod lubi ustawić go na starcie i nie ruszać. Druga: strzałka jako znak tekstowy w przycisku trafia do nazwy dostępnej przycisku, więc albo odcinamy ją przez aria-hidden, albo robimy ją tłem.
Przy tabeli, która nie trzyma wszystkich wierszy w DOM, dochodzą liczniki. APG zawęża ich użycie do sytuacji, w których część wierszy lub kolumn jest ukryta albo nieobecna w DOM, i wtedy każe ustawić aria-rowcount lub aria-colcount na łączną liczbę oraz podać pozycję komórki w aria-rowindex lub aria-colindex. Tabela z pełnym zestawem wierszy w DOM tych atrybutów nie potrzebuje i dopisanie ich na zapas jest samo w sobie błędem.
Trzy narzędzia, trzy różne odpowiedzi
Żadne z dostępnych narzędzi nie łapie wszystkich tych wad naraz, a każde łapie inny ich podzbiór. Stąd bierze się zielony wynik przy zepsutej tabeli.
Drzewo dostępności w DevToolsach odpowiada na pytanie „co widzi czytnik” najszybciej: panel Accessibility i widok pełnego drzewa pokazują wyliczoną rolę każdego elementu, więc brak columnheader albo zniknięty wiersz widać od razu, bez instalowania czegokolwiek. Wadą jest to, że nikt tego nie zrobi przy każdym wdrożeniu.
Do CI nadaje się migawka drzewa. Playwright generuje ją przez locator.ariaSnapshot() i porównuje przez expect(locator).toMatchAriaSnapshot(), a format to YAML z węzłami w postaci - rola "nazwa", z zagnieżdżeniem oddanym wcięciem i dopasowaniem wrażliwym na kolejność. Migawkę generujecie z działającej strony i wrzucacie do repozytorium — nie przepisujecie z pamięci, bo wtedy test sprawdza wasze wyobrażenie o drzewie, a nie drzewo. Test na tabeli jest wart tyle, ile pilnuje: kiedy ktoś doda wrapper z contents albo zamieni <th> na <td>, migawka przestaje się zgadzać.
| Wada | axe w domyślnej konfiguracji | Drzewo w DevToolsach | Migawka ARIA w CI |
|---|---|---|---|
| Brak powiązania komórek z nagłówkami w tabeli poniżej 3 na 3 | nie zgłasza, reguła się nie uruchamia | widać | widać przy zmianie |
Pierwsza kolumna jako td zamiast th scope="row" |
zwykle nie zgłasza | widać | widać przy zmianie |
Wrapper z display: contents na elemencie tabeli |
nie zgłasza | widać | widać przy zmianie |
Siatka z divów z role="table" bez rol potomków |
zgłasza, wpływ krytyczny | widać | widać przy zmianie |
Brak aria-sort na sortowanej kolumnie |
nie zgłasza | widać jako brak stanu | widać, jeśli migawka obejmuje nagłówek |
Druga kolumna tej tabeli to powód, dla którego protokół odbioru nie może kończyć się na jednym skanie automatem: cztery z pięciu pozycji przechodzą przez axe bez zgłoszenia, a wszystkie są widoczne w drzewie w kilka sekund.
Procedura odbioru: kto sprawdza tabelę i kiedy
Sprawdzenie, które zajmuje dwie minuty, ale nie jest nigdzie zapisane, nie wykonuje się samo — w agencji wypada z procesu przy pierwszym napiętym terminie. Jeżeli ta kontrola ma przeżyć więcej niż jeden projekt, musi być spisana jako krok odbioru z nazwą roli, która go wykonuje; o tym, jak opisać taki krok, żeby ktoś inny faktycznie go wykonał, jest osobny materiał w Procesowni. Powtarzalna kontrola przed oddaniem i późniejsze poprawki po stronie klienta to też pozycja w rozliczeniu, a nie przysługa: ebook o przejściu z pojedynczych projektów na stałą obsługę omawia konstrukcję, w której taki przegląd jest częścią abonamentu zamiast darmowego dodatku do projektu.
Lista kontrolna dla jednej tabeli, do przejścia przed wysłaniem linku klientowi:
- Otwórz drzewo dostępności na docelowej przeglądarce i sprawdź, czy tabela ma rolę
table, a górny rząd rolecolumnheader. - Policz wiersze w drzewie i porównaj z liczbą wierszy na ekranie; różnica oznacza wrapper wycięty z drzewa.
- Przeszukaj znaczniki tabeli pod kątem
display: contentsi klasycontentsnatable,thead,tbodyitr. - Sprawdź, czy pierwsza kolumna z nazwą obiektu to
<th scope="row">, a nie<td>. - Sprawdź
scope="col"w nagłówkach kolumn i<caption>w środku tabeli zamiast akapitu nad nią. - Przy sortowaniu:
aria-sortna dokładnie jednej kolumnie, aktualizowany po kliknięciu, strzałka odcięta od nazwy dostępnej. - Przy tabeli z divów: rola na kontenerze,
rowgroup,rowi rola na każdej komórce, bez luki w łańcuchu. - Przy tabeli wirtualizowanej lub stronicowanej:
aria-rowcountiaria-rowindexustawione; przy pełnym zestawie wierszy w DOM — nieustawione. - Wygeneruj migawkę ARIA tabeli i dołącz ją do testów, żeby następna zmiana układu zgłosiła się sama.
Decyzja, którą trzeba podjąć przed napisaniem pierwszej linii, jest jedna: tabela czy siatka. Prawdziwy <table> ze stylami na potomkach kosztuje kilka atrybutów i działa bez ARIA. Siatka z divów kosztuje komplet rol, test czytnikiem i utrzymanie tego kompletu przy każdej zmianie struktury — i w zamian nie daje nic, czego nie da się uzyskać na tabeli. Jeżeli w projekcie jest już siatka, której nie przepiszecie w tym sprincie, zacznijcie od pozycji trzeciej z listy: contents na elemencie tabeli to jedno słowo do usunięcia i największy pojedynczy ubytek w drzewie.