Aller au contenu
← 1001 Widgets
N° 014 · ●○○

Accordéon FAQ

Des questions qui s'ouvrent une à une, au clic comme au clavier. Le bon exercice pour apprendre à montrer et cacher du contenu sans perdre personne en route.

HTMLCSSJS
Ce qu'on apprend
  • Relier un bouton à sa zone avec aria-expanded
  • Animer une hauteur inconnue avec CSS Grid
  • Naviguer au clavier avec les flèches
Pourquoi ce langage

Pourquoi un accordéon ? Parce que toutes les FAQ en ont un, et que la plupart sont mal faits : des div cliquables que le clavier ignore, et des lecteurs d'écran qui ne savent pas si la réponse est ouverte.

Ici, chaque question est un vrai bouton. Il dit s'il est ouvert avec aria-expanded, et il pointe vers sa réponse avec aria-controls.

Pourquoi en JavaScript simple ? L'ouverture se fait en CSS, avec une astuce de grille qui anime même une hauteur inconnue. Le JavaScript ne fait que basculer un attribut.

Comment ça marche
  1. LE HTML

    Un bouton dans un titre, une zone reliée par aria-controls.

    <div class="item">
      <h3><button class="question" aria-expanded="false" aria-controls="r1">Combien de temps ?</button></h3>
      <div class="reponse" id="r1" role="region">
        <div><p>Deux à six semaines.</p></div>
      </div>
    </div>
  2. LE CSS

    grid-template-rows passe de 0fr à 1fr : la hauteur s'anime sans connaître la taille du texte.

    .reponse { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .35s; }
    .reponse > div { overflow: hidden; }
    .item.ouvert .reponse { grid-template-rows: 1fr; }
  3. OUVRIR ET FERMER

    On change l'attribut et la classe ensemble : l'état visible et l'état annoncé restent d'accord.

    function basculer(bouton, ouvrir) {
      bouton.setAttribute('aria-expanded', String(ouvrir));
      bouton.closest('.item').classList.toggle('ouvert', ouvrir);
    }
  4. UN SEUL ÉCOUTEUR

    La délégation suffit pour toutes les questions, même celles ajoutées plus tard.

    const boutons = [...document.querySelectorAll('.question')];
    document.querySelector('.faq').addEventListener('click', e => {
      const b = e.target.closest('.question');
      if (!b) return;
      basculer(b, b.getAttribute('aria-expanded') !== 'true');
    });
  5. LE CLAVIER

    Flèches pour passer d'une question à l'autre, Début et Fin pour les extrémités.

    document.querySelector('.faq').addEventListener('keydown', e => {
      const i = boutons.indexOf(document.activeElement);
      if (i < 0) return;
      const cible = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: boutons.length - 1 }[e.key];
      if (cible === undefined) return;
      e.preventDefault();
      boutons[(cible + boutons.length) % boutons.length].focus();
    });
Pièges

PIÈGE 1 : Une div cliquable

Piège

Mauvais : <div onclick="ouvrir()">. Le clavier ne peut pas l'atteindre, et un lecteur d'écran n'annonce rien.

Fix

Correct : un vrai <button>, qui gère seul le focus, Entrée et Espace.

PIÈGE 2 : Animer height: auto

Piège

Mauvais : transition sur height de 0 à auto. Le navigateur ne sait pas animer vers auto, la réponse apparaît d'un coup.

Fix

Correct : grid-template-rows de 0fr à 1fr, avec overflow: hidden sur l'enfant.

PIÈGE 3 : Oublier l'état annoncé

Piège

Mauvais : ne changer que la classe CSS. Visuellement c'est ouvert, mais aria-expanded dit toujours false.

Fix

Correct : mettre à jour aria-expanded dans la même fonction que la classe.

PERSONNALISER
  • Une case « une seule question ouverte » : avant d'ouvrir, fermez toutes les autres.
  • Une icône qui tourne : un + dans un carré, remplacé par un − quand la question est ouverte.
  • Un lien direct vers une question : lisez location.hash au chargement et ouvrez la question correspondante.
OPTIMISATION
  • L'élément natif <details> et <summary> fait déjà l'essentiel sans JavaScript : partez de lui si vous n'avez pas besoin d'animation.
  • Avec beaucoup de questions, ajoutez un champ de recherche qui filtre avant l'affichage.
VERS REACT

L'index de la question ouverte va dans un useState.

Chaque question devient un composant qui reçoit ouvert et onToggle en props.

useId génère les identifiants qui relient le bouton et sa réponse.

Commentaires
0/1000

Chargement des commentaires…