Responzivní tabulky pomocí ARIA rolí

Skutečná <table> je vždy první volba a postup, jak ji na úzké obrazovce přeskládat, popisuje záznam Responzivní přístupné tabulky. Někdy ale tabulkové prvky použít nejde, třeba když tabulku vygeneruje knihovna komponent nebo když se řádky skládají do mřížky. Pak je potřeba tabulkovou sémantiku dodat rolemi ARIA a s tím přicházejí pasti, hlavně při responzivním rozložení. Jde o údaje zapsané v kódu dvakrát a o rozdíl mezi skrytím jen vizuálním a skrytím pro čtečku.

1. Kdy má tabulka z prvků div smysl

První pravidlo ARIA říká, že kde existuje nativní prvek se správnou sémantikou, použije se ten. Tabulka z prvků <div> s rolemi je výjimka pro případy, kdy nativní tabulku opravdu použít nejde:

  • Komponenta, kterou nemůžeme měnit, generuje prvky <div> a tabulková sémantika se musí doplnit rolemi.
  • Řádky se při zmenšení skládají do mřížky nebo karet způsobem, který tabulkové hodnoty display nezvládají.
  • Tabulku vykresluje skript po částech, například virtualizovaný seznam s tisíci řádků, kde v kódu není vždy celá tabulka.

Ve všech ostatních případech je nativní <table> jednodušší, robustnější a nemusí se u ní nic doplňovat.

2. Tabulka z prvků div s rolemi

Role odpovídají prvkům, které nahrazují:

Nativní prvek Role Poznámka
<table> table Název přes aria-label nebo aria-labelledby, místo <caption>.
<thead>, <tbody> rowgroup Seskupení řádků nadpisů a řádků s daty.
<tr> row Jeden řádek.
<th scope="col"> columnheader Nadpis sloupce.
<th scope="row"> rowheader Údaj, který pojmenovává celý řádek, obvykle první sloupec.
<td> cell Datová buňka.
<div role="table" aria-label="Přehled dokumentů">
    <div role="rowgroup">
        <div role="row">
            <div role="columnheader">Název dokumentu</div>
            <div role="columnheader">Typ</div>
            <div role="columnheader">Datum</div>
        </div>
    </div>
    <div role="rowgroup">
        <div role="row">
            <div role="rowheader">Smlouva_o_dilo.pdf</div>
            <div role="cell">Smlouva</div>
            <div role="cell">30. 10. 2026</div>
        </div>
    </div>
</div>

Buňky mají být přímými potomky řádku. Obalový <div> bez role odečítače většinou přeskočí, ale přímý vztah je spolehlivější a snáz se kontroluje. Název souboru nebo jiný údaj, který záznam pojmenovává, patří do rowheader, protože odečítač při pohybu po buňkách pak ví, o který záznam jde.

Oproti nativní tabulce tu nevzniká past, kterou popisuje záznam Responzivní přístupné tabulky. Prvek <div> žádnou vlastní roli nemá, takže mu ji změna display nemůže odebrat, a tabulka zůstane tabulkou při jakémkoli rozložení.

Pokud tabulka zobrazuje jen část řádků nebo sloupců, například při stránkování, je potřeba odečítači navíc říct celkový počet pomocí aria-rowcount a aria-colcount. Ty vysvětluje článek Tabulka a grid prakticky: aria-colcount, aria-rowcount a další atributy.

3. Past: skrytí jen vizuálně nestačí

U responzivní tabulky se snadno stane, že se tentýž údaj zapíše do kódu dvakrát, jednou pro široké a jednou pro úzké rozložení, a nepoužívaná kopie se skryje třídou visually-hidden. Ta ale prvek skrývá jen vizuálně. Odečítač ho dál čte, a tabulka má proto o sloupec nebo řádek víc, než je vidět. Hodnoty se pak čtou pod nadpisy, které k nim nepatří, a poslední sloupec zůstane bez nadpisu. Stejně se chová druhý, jen vizuálně skrytý řádek s nadpisy sloupců.

Jednotlivé techniky skrytí se liší tím, komu prvek zmizí:

Technika Vidí ji oko Vidí ji odečítač
display:none nebo atribut hidden ne ne
visibility:hidden ne ne
třída visually-hidden, viz Skrytý text pro čtečky obrazovky (sr-only) ne ano
aria-hidden="true", viz Skrytí prvku před čtečkami obrazovky (aria-hidden) ano ne

Správné řešení je zapsat každý údaj do kódu jen jednou a na úzké obrazovce ho přesunout pomocí CSS, jak ukazuje další část. Kdyby duplicita přesto byla nutná, musí se nepoužívaná kopie skrýt přes display:none, protože jen tak zmizí i ze stromu přístupnosti.

Pozor na aria-hidden u buňky tabulky. Odečítač pak buňku neuvidí a řádek bude mít míň buněk než nadpisů, takže se ostatní hodnoty přiřadí ke špatným sloupcům. Atribut aria-hidden shrnuje článek Další parametry prakticky: aria-describedby, aria-hidden a aria-disabled.

Zkontrolovat to jde odečítačem. Po vstupu do tabulky NVDA oznámí počet sloupců a řádků. Pokud se liší od toho, co tabulka skutečně obsahuje, je v kódu duplicita.

4. Přeskládání tabulky s více sloupci

Každý řádek je grid a buňky jsou jeho přímými potomky. Při zužování se mění jen definice mřížky a umístění buněk přes grid-area. Pořadí v kódu i obsah zůstávají stejné a žádný údaj se neduplikuje. U pěti sloupců se vyplatí dva kroky místo jednoho:

  • Na široké obrazovce je všech pět údajů vedle sebe v jednom řádku a nad nimi viditelný řádek s nadpisy sloupců.
  • Na střední šířce je každý záznam karta. V prvním řádku je název a tlačítko, pod nimi další údaje ve třech sloupcích, každý s popiskem.
  • Na úzké obrazovce je všechno pod sebou v jednom sloupci.
.docs__row {
    display: grid;
    grid-template-columns: minmax(0, 3fr) minmax(0, 1.6fr) minmax(0, 1.5fr) minmax(0, 1fr) 7rem;
    grid-template-areas: "name type date size action";
}
.docs__name   { grid-area: name; }
.docs__type   { grid-area: type; }
.docs__date   { grid-area: date; }
.docs__size   { grid-area: size; }
.docs__action { grid-area: action; }

@media (max-width: 40em) {
    .docs__row--head {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip-path: inset(50%);
    }
    .docs__body .docs__row {
        grid-template-columns: repeat(3, minmax(0, 1fr));
        grid-template-areas:
            "name name action"
            "type date size";
    }
    .docs__body [data-label]::before {
        display: block;
        content: attr(data-label);
        content: attr(data-label) / "";
    }
}

@media (max-width: 28em) {
    .docs__body .docs__row {
        grid-template-columns: minmax(0, 1fr);
        grid-template-areas: "name" "type" "date" "size" "action";
    }
}

Řádek s nadpisy sloupců se na střední a úzké obrazovce nesmí skrýt přes display:none, protože by zmizel i odečítači. Schová se jen vizuálně, takže nadpisy zůstávají v DOM a vizuálně je nahrazují popisky z data-label. Druhý zápis content s lomítkem a prázdným řetězcem za ním nastavuje popisku prázdný alternativní text, aby ho odečítač nečetl podruhé po nadpisu sloupce.

Přesouvání buněk přes grid-area může rozejít vizuální pořadí s pořadím v kódu, v jakém buňky prochází odečítač a klávesnice. Souvislost vysvětluje záznam Pořadí prvků v kódu a vizuálně. V ukázce se přesouvá jen tlačítko, které je na kartě vizuálně v prvním řádku, ale v kódu až za údaji pod ním. U karty s víc ovladatelnými prvky by to mohlo způsobit nelogické pořadí fokusu, takže u nich je lepší pořadí v kódu a na obrazovce držet pohromadě.

Responzivní ARIA tabulka

5. Řazení sloupců

Řaditelný sloupec má v záhlaví tlačítko a atribut aria-sort je na prvku columnheader, ne na tlačítku. Přístupné řazení sloupců má takhle vypadat:

<div role="columnheader" aria-sort="descending">
    <button type="button">Datum</button>
</div>

Hodnotu ascending nebo descending má jen sloupec, podle kterého se právě řadí, ostatním se atribut vynechá. Odečítač ale změnu aria-sort po stisku tlačítka sám nepřečte, protože se změnil rodič fokusovaného prvku, ne prvek samotný. Po kliknutí je proto potřeba do skryté oblasti s role="status" zapsat krátkou stavovou zprávu pro čtečky obrazovky, například „Seřazeno podle data, od nejnovějšího“. Zpráva se tvoří jen po akci uživatele, nikdy při načtení stránky. Způsob oznamování popisuje i článek Live regiony a notifikace prakticky: aria-live, aria-atomic, aria-relevant a aria-busy.

Proč to takhle je

  • U tabulky z prvků <div> musí být role v kódu, protože žádný z těch prvků sémantiku tabulky sám nenese, a proto ji ani změna display neodebere.
  • Každý údaj je v kódu jen jednou, protože odečítač čte všechno, co není skryté přes display:none, hidden nebo aria-hidden. Třída visually-hidden skrývá jen vizuálně.
  • Přeskládání dělá mřížka přes grid-area, takže pořadí v kódu zůstává stejné a nevzniká druhá kopie dat.
  • Popisek přes ::before má prázdný alternativní text, protože odečítač ho už zná z nadpisu sloupce.
  • Změna řazení se oznamuje ručně, protože aria-sort je na rodiči tlačítka a čtečky jeho změnu po stisku nehlásí.

Pravidla

  • První pravidlo ARIA říká, že kde existuje nativní prvek se správnou sémantikou, použije se ten, a role se přidávají jen tam, kde to jinak nejde.
  • WCAG 1.3.1 (Informace a vztahy) vyžaduje, aby vztah mezi nadpisem sloupce a hodnotou buňky zůstal zachovaný i po změně vizuálního rozvržení.
  • WCAG 1.3.2 (Smysluplná posloupnost) vyžaduje, aby pořadí obsahu v kódu zůstalo logické i po přeskládání pomocí CSS.
  • WCAG 1.4.10 (Reflow) požaduje, aby šel obsah číst bez posouvání ve dvou směrech najednou.
  • WCAG 4.1.3 (Stavové zprávy) vyžaduje, aby se změny stavu, například nové řazení, oznámily odečítači bez přesunu fokusu.

Štítky