HTML

Přístupný combobox a autocomplete

Combobox, který při psaní nabízí a filtruje možnosti, je nejsložitější rozbalovací prvek, co ARIA nabízí. Kombinuje textové pole, seznam návrhů a několik možných typů automatického doplňování. Bez správné obsluhy nejde jen o to, že čtečka neřekne, co se děje. Bez klávesnice může být prvek úplně nepoužitelný.

1. Základní kostra: tři role a jejich propojení

<label for="mesto">Město</label>
<input
    id="mesto"
    role="combobox"
    aria-expanded="false"
    aria-controls="mesto-listbox"
    aria-autocomplete="list"
    autocomplete="off"
>
<ul id="mesto-listbox" role="listbox" aria-label="Návrhy měst" hidden></ul>

autocomplete="off" na vstupu vypíná prohlížečovo vlastní (nesouvisející) automatické doplňování formulářů, ať nekoliduje s vlastním seznamem návrhů.

2. Otevření popupu a filtrování při psaní

const input = document.getElementById('mesto');
const listbox = document.getElementById('mesto-listbox');
const mesta = ['Praha', 'Plzeň', 'Brno', 'Ostrava', 'Olomouc'];

input.addEventListener('input', () => {
    const dotaz = input.value.trim().toLowerCase();
    const shoda = dotaz ? mesta.filter(m => m.toLowerCase().startsWith(dotaz)) : [];
    listbox.innerHTML = shoda.map((m, i) =>
        `<li role="option" id="mesto-opt-${i}">${m}</li>`
    ).join('');
    const otevrit = shoda.length > 0;
    input.setAttribute('aria-expanded', String(otevrit));
    listbox.hidden = !otevrit;
    input.removeAttribute('aria-activedescendant');
});

3. Obsluha klávesnicí: šipky, Enter, Escape

Šipky nepřesouvají DOM fokus na jednotlivé volby. Ten zůstává celou dobu na textovém poli, aby šlo dál psát. Místo toho se při každém stisku šipky jen přepíše aria-activedescendant na input a vizuálně zvýrazní odpovídající <li>. Čtečka díky tomu ohlásí, jako by byl fokus na té volbě, i když se DOM fokus nikam skutečně nepřesunul:

let aktivniIndex = -1;

input.addEventListener('keydown', (e) => {
    const volby = listbox.querySelectorAll('[role="option"]');
    if (!volby.length) return;

    if (e.key === 'ArrowDown') {
        e.preventDefault();
        aktivniIndex = Math.min(aktivniIndex + 1, volby.length - 1);
        nastavAktivni(volby, aktivniIndex);
    } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        aktivniIndex = Math.max(aktivniIndex - 1, 0);
        nastavAktivni(volby, aktivniIndex);
    } else if (e.key === 'Enter' && aktivniIndex >= 0) {
        e.preventDefault();
        vyberVolbu(volby[aktivniIndex]);
    } else if (e.key === 'Escape') {
        zavriPopup();
    }
});

function nastavAktivni(volby, index) {
    volby.forEach(v => v.setAttribute('aria-selected', 'false'));
    volby[index].setAttribute('aria-selected', 'true');
    input.setAttribute('aria-activedescendant', volby[index].id);
    volby[index].scrollIntoView({ block: 'nearest' });
}

function vyberVolbu(volba) {
    input.value = volba.textContent;
    zavriPopup();
}

function zavriPopup() {
    listbox.hidden = true;
    input.setAttribute('aria-expanded', 'false');
    input.removeAttribute('aria-activedescendant');
    aktivniIndex = -1;
}

Klik myší na volbu musí vést ke stejné funkci jako Enter na klávesnici:

listbox.addEventListener('click', (e) => {
    const volba = e.target.closest('[role="option"]');
    if (volba) vyberVolbu(volba);
});

4. Vícenásobný výběr (rozšíření)

Pokud má combobox dovolovat výběr víc hodnot najednou, třeba výběr víc štítků do jednoho pole, listbox dostane navíc aria-multiselectable="true" a výběr se řídí jinak: potvrzení volby jen přepne její aria-selected, popup zůstane otevřený a vybrané hodnoty se sbírají do vlastního seznamu vedle textového pole. Textové pole samo pak „hodnotu“ v obvyklém smyslu nenese.

<ul id="mesto-listbox" role="listbox" aria-multiselectable="true" aria-label="Návrhy měst" hidden></ul>
function prepniVolbu(volba) {
    const vybrano = volba.getAttribute('aria-selected') === 'true';
    volba.setAttribute('aria-selected', String(!vybrano));
    // popup zůstává otevřený, textové pole se nemaže
}

Proč to takhle je

  • aria-expanded, aria-controls a aria-autocomplete patří na textové pole od prvního vykreslení, i ve výchozím sbaleném stavu bez návrhů, ať čtečka dopředu ví, co pole umí.
  • aria-activedescendant se aktualizuje při každém pohybu šipkami, ne až při potvrzení výběru. Jinak by čtečka hlásila starou, neplatnou volbu.
  • Klik myší na volbu musí vyvolat stejnou funkci jako Enter na klávesnici: dvě cesty ke stejnému výsledku, ne dvě různé implementace, které se časem rozejdou.

Pravidla

  • WCAG 4.1.2 (Název, role, hodnota) — aria-expanded a aria-selected musí vždy odpovídat skutečnému stavu.
  • WCAG 1.3.1 (Informace a vztahy) — vztah pole a popupu (aria-controls) i fokusu a volby (aria-activedescendant).
  • WCAG 2.1.1 (Klávesnice) — šipky, Enter a Escape musí fungovat bez myši.
  • WCAG 2.4.3 (Pořadí zaměření) — fokus po výběru volby nebo po Escape se vrací zpátky do textového pole, ne nikam jinam.

To je celé: tři role dají comboboxu tvar, input/keydown obsluha řídí filtrování i pohyb po návrzích, aria-expanded a aria-activedescendant drží čtečku v obraze o obojím.

Zdroje

Štítky