Bac à sable
Les filtres
Sur des filtres, le nombre de résultats change souvent à l'écran sans que le lecteur d'écran le dise. Ici, chaque libellé donne ce nombre avant le choix, et une zone de statut le redit après.
- Technique
- Boutons radio et :has()
- Navigateurs
- Chrome 105+, Firefox 121+, Safari 15.4+
- JavaScript
- 13 lignes, pour le compteur
Démo
Une recette peut avoir plusieurs catégories : la quiche est à la fois une entrée et un plat. Coche Plats : la liste et le compteur passent à 2 recettes.
5 recettes, toutes catégories
- Entrées Velouté de potiron
- EntréesPlats Quiche aux poireaux
- Plats Gratin dauphinois
- Desserts Tarte aux pommes
- Desserts Mousse au chocolat
Anatomie
fieldsetRegroupe les filtres.legendNomme le groupe : « Catégorie ».labeletinput type="radio"Un filtre, avec son nombre de résultats dans le libellé.p role="status"Le compteur de recettes affichées, relu par le lecteur d'écran après chaque choix. Masqué parhiddentant que le script n'a pas tourné.span.visually-hiddenLa catégorie choisie, pour le lecteur d'écran seulement. Le filtre coché la montre déjà à l'écran.ulLa liste filtrée.li data-categoriesUn résultat, avec la liste de ses catégories séparées par des espaces.[data-categories~="main"]en trouve une.
Code HTML
<div class="filters">
<!-- search : un repère de recherche autour des filtres -->
<search>
<!-- Filtres : un groupe de boutons radio,
le nombre de recettes dans chaque libellé -->
<fieldset>
<legend>Catégorie</legend>
<label>
<input
type="radio"
name="category"
value="all"
checked>
<span>Toutes</span> (5)
</label>
<label>
<input
type="radio"
name="category"
value="starter">
<span>Entrées</span> (2)
</label>
<label>
<input
type="radio"
name="category"
value="main">
<span>Plats</span> (2)
</label>
<label>
<input
type="radio"
name="category"
value="dessert">
<span>Desserts</span> (2)
</label>
</fieldset>
</search>
<!-- Zone de statut : masquée tant que le script n'a pas tourné -->
<p
class="filters__total"
role="status"
hidden>
<span>5 recettes</span>
<span class="visually-hidden">, toutes catégories</span>
</p>
<!-- Résultats : chaque recette liste ses catégories,
séparées par des espaces -->
<ul>
<li data-categories="starter">…</li>
<li data-categories="starter main">…</li>
<li data-categories="main">…</li>
<li data-categories="dessert">…</li>
<li data-categories="dessert">…</li>
</ul>
</div>
<script>
// Compteur : réécrit à chaque choix,
// le lecteur d'écran le lit après le filtre
const filters = document.querySelector('.filters');
const total = filters.querySelector('[role="status"]');
const [count, category] = total.children;
total.hidden = false;
filters.addEventListener('change', (e) => {
const value = e.target.value;
const selector = value === 'all' ? 'li' : `li[data-categories~="${value}"]`;
const n = filters.querySelectorAll(selector).length;
count.textContent = n > 1 ? `${n} recettes` : `${n} recette`;
category.textContent = value === 'all'
? ', toutes catégories'
: `, catégorie ${e.target.nextElementSibling.textContent}`;
});
</script>/* Une règle par catégorie.
~= trouve un mot dans une liste séparée par des espaces */
.filters:has(input[value="starter"]:checked)
li:not([data-categories~="starter"]),
.filters:has(input[value="main"]:checked)
li:not([data-categories~="main"]),
.filters:has(input[value="dessert"]:checked)
li:not([data-categories~="dessert"]) {
display: none;
}
/* Texte lu par le lecteur d'écran, absent de l'écran (sr-only) */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}Usages
Recommandé pour
- Un seul filtre actif à la fois, avec des options qui tiennent à l'écran. C'est le cas des filtres de ce blog.
- Une liste déjà chargée dans la page, filtrée par le navigateur.
Déconseillé pour
- Signaler le filtre actif par la couleur seule.
- Un compteur qui change à l'écran sans
role="status". Personne ne l'annonce au lecteur d'écran. - Un compteur affiché quand le script ne tourne pas : il reste figé sur le total pendant que la liste change.
- Masquer les boutons radio sans reporter le focus visible sur leur libellé.
- Recharger la page dès qu'on coche une option, sans bouton pour valider : le focus repart tout en haut.
- Une liste longue ou paginée, filtrée dans le navigateur. Filtre-la plutôt côté serveur, avec un
formenGETet un bouton « Filtrer », et donne le nouveau total dans le titre de la page, que le lecteur d'écran lit au chargement.
Accessibilité
Rôles et attributs ARIA
Le groupe, les noms et les états viennent du HTML. Un seul rôle ajouté, status, sur le compteur.
| Attribut | Élément | Description |
|---|---|---|
Rôle search (implicite) | search | Crée un repère de recherche autour des filtres : le lecteur d'écran le liste avec les autres repères de la page. |
Rôle group (implicite) | fieldset | Nommé par sa legend, « Catégorie », que le lecteur d'écran annonce en entrant dans les filtres. |
Rôle radio (implicite) | input type="radio" | Nom donné par le label, nombre compris : « Plats (2) ». Le navigateur expose aussi l'état coché. |
name="category" | input type="radio" | Relie les 4 boutons radio en un groupe : un seul reste coché, et le lecteur d'écran annonce la position, « 3 sur 4 ». |
checked | input value="all" | « Toutes » est coché au chargement : Tab entre dans le groupe sur ce filtre, annoncé « sélectionné ». |
role="status" | p | Apporte aria-live="polite" et aria-atomic="true". Le lecteur d'écran attend la fin de l'annonce du filtre, puis lit tout le compteur. |
hidden | p | Sans JavaScript, le lecteur d'écran ne reçoit aucun compteur, plutôt qu'un nombre faux. Le script retire l'attribut au chargement. |
class="visually-hidden" | span | Lu avec le compteur, absent de l'écran. Il change le texte à chaque choix, même quand 2 filtres gardent le même nombre de recettes (Entrées et Plats : 2). |
display: none | li | Retire les résultats masqués de l'arbre d'accessibilité : après le choix Plats, le lecteur d'écran annonce « liste, 2 éléments ». |
Navigation au clavier
| Touche | Action |
|---|---|
| Tab | Entre dans le groupe, sur le filtre coché |
| Flèches | Passent au filtre suivant ou précédent et le cochent, en boucle |
| Espace | Coche le filtre qui a le focus, s'il ne l'est pas encore |
| Tab depuis le groupe | Quitte les filtres pour la suite de la page |
Ordre de tabulation
- Le filtre coché. Les autres filtres s'atteignent avec les flèches, sans arrêt de tabulation de plus.
Avec un lecteur d'écran
Exemple sur Chrome avec VoiceOver. Les mots changent d'un lecteur d'écran à l'autre.
| Action | Annonce |
|---|---|
| Tab sur les filtres | « Catégorie, groupe », puis « Toutes (5), sélectionné, bouton radio, 1 sur 4 » |
| 2 fois flèche droite | « Plats (2), sélectionné, bouton radio, 3 sur 4 », puis « 2 recettes, catégorie Plats » |
| Flèche suivante (VO + →) jusqu'à la liste | « liste, 2 éléments » |
Critères RGAA concernés
- Critère 3.1 : Dans chaque page web, l’information ne doit pas être donnée uniquement par la couleur. Cette règle est-elle respectée ?
- Le filtre actif se repère sans la couleur, par la pastille du bouton radio.
- Critère 7.5 : Dans chaque page web, les messages de statut sont-ils correctement restitués par les technologies d’assistance ?
- Le nombre de recettes affichées est un message de statut, restitué grâce à
role="status". - Critère 10.8 : Pour chaque page web, les contenus cachés ont-ils vocation à être ignorés par les technologies d’assistance ?
- Les résultats masqués sont ignorés par les technologies d'assistance.
- Critère 11.5 : Dans chaque formulaire, les champs de même nature sont-ils regroupés, si nécessaire ?
- Les filtres de même nature sont regroupés dans un
fieldset. - Critère 11.6 : Dans chaque formulaire, chaque regroupement de champs de même nature a-t-il une légende ?
- Le regroupement a une légende, « Catégorie ».
- Critère 11.7 : Dans chaque formulaire, chaque légende associée à un regroupement de champs de même nature est-elle pertinente ?
- Cette légende dit sur quoi portent les filtres.
Autres références
WCAG 2.2
- Understanding SC 1.3.1: Info and Relationships (en anglais)
- Understanding SC 1.4.1: Use of Color (en anglais)
- Understanding SC 3.3.2: Labels or Instructions (en anglais)
- Understanding SC 4.1.3: Status Messages (en anglais)
EN 301 549
- Accessibility requirements for ICT products and services (V4.1.1, PDF) (en anglais)Clauses 9.1.3.1, 9.1.4.1, 9.3.3.2, 9.4.1.3
Pattern APG
- Radio Group Pattern (en anglais)
Au-delà de l'APG
L'APG ne décrit pas de filtres. Ces nouveautés du HTML et du CSS servent la démo.
| Nouveauté | Apport | Navigateurs | Dans la démo |
|---|---|---|---|
search | Crée un repère de recherche autour des filtres, que le lecteur d'écran liste avec les autres repères. | Chrome 118+, Firefox 118+, Safari 17+ | Oui |
:has() | Filtre la liste en CSS, d'après le bouton radio coché, sans script. | Chrome 105+, Firefox 121+, Safari 15.4+ | Oui |
Variations
Choix multiple
Des cases à cocher pour combiner plusieurs catégories. Une règle masque tous les résultats dès qu'une case est cochée, une autre réaffiche ceux des catégories cochées. Le script compte alors les recettes affichées, et la partie réservée au lecteur d'écran nomme les cases cochées.
5 recettes, toutes catégories
- Entrées Velouté de potiron
- EntréesPlats Quiche aux poireaux
- Plats Gratin dauphinois
- Desserts Tarte aux pommes
- Desserts Mousse au chocolat
<!-- HTML -->
<label>
<input
type="checkbox"
name="category"
value="main">
<span>Plats</span> (2)
</label>
/* CSS */
/* Une case cochée masque tout… */
.filters:has(:checked) li {
display: none;
}
/* … sauf les recettes des catégories cochées */
.filters:has([value="main"]:checked) li[data-categories~="main"] {
display: block;
}
<script>
// Compteur : les recettes affichées,
// et les cases cochées pour le lecteur d'écran
filters.addEventListener('change', () => {
const checked = [...filters.querySelectorAll(':checked')];
const names = checked.map((box) => box.nextElementSibling.textContent);
const n = [...filters.querySelectorAll('li')]
.filter((li) => getComputedStyle(li).display !== 'none').length;
count.textContent = n > 1 ? `${n} recettes` : `${n} recette`;
category.textContent = checked.length
? `, ${names.join(' et ')}`
: ', toutes catégories';
});
</script>Filtres en étiquettes
Le même HTML, avec le bouton radio masqué visuellement et le libellé stylé en étiquette. Une coche devant l'étiquette active remplace la pastille, et label:has(:focus-visible) garde le focus visible.
5 recettes, toutes catégories
- Entrées Velouté de potiron
- EntréesPlats Quiche aux poireaux
- Plats Gratin dauphinois
- Desserts Tarte aux pommes
- Desserts Mousse au chocolat
.filters input {
position: absolute;
opacity: 0;
}
.filters label:has(:checked) {
background: var(--accent);
color: var(--on-accent);
}
/* Une coche remplace la pastille, hors du nom accessible.
La 1re déclaration sert de repli aux navigateurs
qui ignorent le texte alternatif. */
.filters label:has(:checked)::before {
content: "✓";
content: "✓" / "";
margin-right: 6px;
}
.filters label:has(:focus-visible) {
outline: 3px solid var(--highlight);
}Compteur réservé au lecteur d'écran
Quand la maquette n'a pas de place pour le compteur, la classe .visually-hidden passe sur tout le p role="status". Je garde le compteur visible dès que je peux : le nombre sert aussi à qui voit l'écran.
5 recettes, toutes catégories
- Entrées Velouté de potiron
- EntréesPlats Quiche aux poireaux
- Plats Gratin dauphinois
- Desserts Tarte aux pommes
- Desserts Mousse au chocolat
Le compteur est bien là : seul le lecteur d'écran le lit.
<p
class="filters__total visually-hidden"
role="status"
hidden>
<span>5 recettes</span><span>, toutes catégories</span>
</p>