Aller au contenu
Me contacter

Toutes les briques

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.

Catégorie
  • Entrées Velouté de potiron
  • EntréesPlats Quiche aux poireaux
  • Plats Gratin dauphinois
  • Desserts Tarte aux pommes
  • Desserts Mousse au chocolat

Anatomie

  1. fieldset Regroupe les filtres.
  2. legend Nomme le groupe : « Catégorie ».
  3. label et input type="radio" Un filtre, avec son nombre de résultats dans le libellé.
  4. p role="status" Le compteur de recettes affichées, relu par le lecteur d'écran après chaque choix. Masqué par hidden tant que le script n'a pas tourné.
  5. span.visually-hidden La catégorie choisie, pour le lecteur d'écran seulement. Le filtre coché la montre déjà à l'écran.
  6. ul La liste filtrée.
  7. li data-categories Un résultat, avec la liste de ses catégories séparées par des espaces. [data-categories~="main"] en trouve une.

Code HTML

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>
CSS
/* 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 form en GET et 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émentDescription
Rôle search (implicite)searchCré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)fieldsetNommé 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 ».
checkedinput value="all"« Toutes » est coché au chargement : Tab entre dans le groupe sur ce filtre, annoncé « sélectionné ».
role="status"pApporte aria-live="polite" et aria-atomic="true". Le lecteur d'écran attend la fin de l'annonce du filtre, puis lit tout le compteur.
hiddenpSans 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"spanLu 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: noneliRetire 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

ToucheAction
TabEntre dans le groupe, sur le filtre coché
FlèchesPassent au filtre suivant ou précédent et le cochent, en boucle
EspaceCoche le filtre qui a le focus, s'il ne l'est pas encore
Tab depuis le groupeQuitte les filtres pour la suite de la page

Ordre de tabulation

  1. 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.

ActionAnnonce
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

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éApportNavigateursDans la démo
searchCré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.

Catégories
  • Entrées Velouté de potiron
  • EntréesPlats Quiche aux poireaux
  • Plats Gratin dauphinois
  • Desserts Tarte aux pommes
  • Desserts Mousse au chocolat
Code
<!-- 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.

Catégorie
  • Entrées Velouté de potiron
  • EntréesPlats Quiche aux poireaux
  • Plats Gratin dauphinois
  • Desserts Tarte aux pommes
  • Desserts Mousse au chocolat
Code
.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.

Catégorie
  • 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.

Code
<p
  class="filters__total visually-hidden"
  role="status"
  hidden>
  <span>5 recettes</span><span>, toutes catégories</span>
</p>