Accueil / Articles / Accordeons React navigables : plier avec CSS Grid, et non en désinstallant

Accordeons React navigables : plier avec CSS Grid, et non en désinstallant

Créez des accordions React imbriqués qui conservent le contenu dans le DOM pour l’indexation, animez la hauteur à l’aide des lignes de grille, restreignez le fait qu’un seul élément soit ouvert à la fois par niveau et réinitialisez l’état lors de la fermeture.

1798 mots

La plupart des tutoriels d’accordéon ne rendent la section que lorsqu’elle est ouverte. Cela convient pour un modal, mais sur une page riche en contenu comme un portfolio, un centre de documentation ou des FAQ, cela signifie que le texte présent dans chaque section fermée n’existe pas dans le document lorsque un moteur de recherche interprète la page. Ce guide permet de reconstruire un ensemble de sections imbriquées rétractables de manière à ce que rien ne quitte le DOM, que la hauteur s’ajuste en douceur sans utiliser de nombres spéciaux, que le principe « une seule section ouverte » fonctionne correctement à tous les niveaux d’imbrication, que la réouverture d’une section commence à zéro, et que le point de clic se situe là où s’arrête le texte du titre.

Le scénario : onze sections, à trois niveaux de profondeur

Imaginons un site personnel comprenant onze sections de niveau supérieur qui se plient : à propos, disponibilité, expérience, portfolio, formation, langues, compétences, laboratoire, téléchargements, localisation et contacts. Plusieurs d’entre elles contiennent à leur tour d’autres sections. Rien que la section « Contacts » comporte trois niveaux de profondeur, avec des profils publics, des publications et des plateformes d’offres d’emploi qui se divisent chacune en leurs propres sous-groupes.

Lorsque toutes les sections sont dépliées, la page devient un mur impressionnant de texte. Une fois pliées, elle est facile à parcourir. Faire en sorte que les sections soient pliables est évidemment la meilleure solution. La question est de savoir comment y parvenir.

Le modèle enseigné dans presque tous les tutoriels est le rendu conditionnel :

{isOpen && (
  <div className="content">
    {children}
  </div>
)}

Cela fonctionne, et avec un élément conteneur, on peut même y ajouter des animations. Mais cela va à l’encontre de l’objectif principal d’une page, qui est justement d’être facilement trouvée.

Ce que fait réellement le montage conditionnel

{isOpen && ...} ne cache rien. Lorsque isOpen est faux, React ne crée jamais cet arbre enfant, il n’y a donc rien dans le DOM à cacher ou à afficher.

Pour les modaux et les menus déroulants, c’est exactement le comportement souhaité ; une boîte de dialogue fermée n’a pas sa place dans le document. Pour une page de contenu, c’est l’inverse. Sur un portfolio, les sections pliées constituent l’essentiel : des années d’expérience, des descriptions de projets, des listes de technologies, des responsabilités et des résultats. Chaque terme que pourrait rechercher un recruteur se trouve à l’intérieur de quelque chose qui commence par être fermé.

Google exécute bien du JavaScript, donc la situation est moins grave qu’avant. Mais la page est rendue dans son état initial. Le robot de recherche ne clique pas sur les flèches. Tout ce qui est désinstallé au chargement n’apparaît pas, aux fins d’indexation, sur la page.

Pliage avec des styles plutôt que rendu

La solution consiste à considérer l’état « collapsed » comme une question de mise en forme et non comme une décision de rendu. Le contenu est affiché une seule fois et reste ainsi ; seule sa hauteur visible change.

La méthode traditionnelle pour animer la hauteur est l’attribut max-height, ce qui vous oblige à deviner une valeur supérieure à celle de la section la plus haute et à accepter un timing inégal, car la transition s’effectue sur toute la plage estimée plutôt que sur la hauteur réelle. CSS Grid propose une approche plus propre, comme illustré ici avec les classes Tailwind :

<div
  className={`grid transition-all duration-300 ${
    isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
  }`}
>
  <div className="overflow-hidden">{children}</div>
</div>

Pourquoi le truc des grid-rows fonctionne

Une trame de grille de taille 1fr s’adapte à la hauteur naturelle de son contenu, tandis qu’une trame de taille 0fr se réduit à zéro. Les navigateurs peuvent interpréter des valeurs intermédiaires, ce qui assure une transition fluide sans nécessiter de limite maximale. L’élément externe overflow-hidden est essentiel : sans lui, le contenu déborderait de la ligne à hauteur zéro au lieu d’être coupé.

Le code HTML reste identique tant que l’élément est ouvert que fermé. Seules la hauteur calculée et l’opacité diffèrent, de sorte que chaque mot reste dans le document et accessible pour l’indexation.

Un bémol : animer grid-template-rows est une fonctionnalité relativement récente des navigateurs. Les anciens moteurs se contenteront de basculer brusquement entre les états ; par conséquent, si vous devez les prendre en charge, prévoyez une solution de secours ou acceptez l’absence d’animation sur ces navigateurs. Vérifiez les données de compatibilité actuelles en fonction de votre trafic réel.

Éviter que le contenu plié ne figure dans l’ordre des onglets

Le fait de conserver du contenu dans le DOM a un effet secondaire en matière d’accessibilité qu’il convient de gérer. Les liens et les boutons situés à l’intérieur d’un panneau visuellement plié peuvent toujours recevoir le focus par clavier, et les lecteurs d’écran peuvent encore les annoncer. En ajoutant l’attribut inert au panneau lorsqu’il est fermé (ou du moins aria-hidden ainsi que rendre les contrôles internes impossibles à cibler), on conserve le texte dans le document pour les outils de balayage tout en l’empêchant d’interagir. Associez le bouton de titre à aria-expanded afin que les technologies d’assistance connaissent l’état du panneau. Le navigateur propose également hidden="until-found", qui permet au contenu de rester recherchable via la fonction « chercher dans la page » ; il vaut la peine d’être évalué, mais son affichage et ses animations diffèrent de l’approche basée sur des grilles.

Définition du champ d’application de la règle « un seul élément ouvert à la fois »

Avec un fonctionnement par pliage, l’exigence suivante est un comportement classique d’accordéon : l’ouverture d’une section ferme les autres. Pour une liste plate, il s’agit simplement d’un état contenant la clé de fermeture en haut, ainsi que d’une vérification d’égalité dans chaque élément.

Les sections imbriquées viennent immédiatement contredire cela. Une seule règle globale ferme la section parente dès que l’on ouvre une sous-section, car cette dernière est elle aussi pliable et la règle ne peut pas faire la différence. Lorsque l’on développe un sous-groupe, comme les plateformes d’offres d’emploi d’une entreprise, la section qui le contient se ferme automatiquement juste en dessous du curseur.

Cette contrainte nécessite un champ d’application : l’exclusivité s’applique au sein d’un ensemble de frères et sœurs sous le même parent, et non sur toute la page. Chaque élément pliable appartient au groupe de son parent tout en créant également un nouveau groupe pour ses propres enfants. Ce groupe est une petite structure contenant la clé de fermeture et son mécanisme de mise à jour :

type CollapseGroup = {
  openKey: string | null;
  setOpenKey: (key: string | null) => void;
};

Cette forme est partagée via un contexte React, où null signifie « pas à l’intérieur d’un groupe » :

const CollapseGroupContext = createContext<CollapseGroup | null>(null);

Chaque nœud lit le contexte de son parent pour déterminer s’il est ouvert, puis enrobe ses enfants dans un fournisseur nouveau. À trois niveaux de nesting, l’état se trouve dans trois contextes indépendants plutôt que dans une seule structure plate avec des clés composées comme contact/profiles/boards. Cela permet de garder chaque niveau simple et rend la profondeur illimitée sans nécessiter de suivi supplémentaire.

Réinitialisation de l’état imbriqué lors de la fermeture d’un parent

Un problème d’ergonomie plus subtil apparaît seulement après avoir utilisé la page pendant un certain temps. Ouvrez une section, puis une sous-section, puis une sous-sous-section. Fermez la section de niveau supérieur et lisez autre chose. Lorsque vous rouvrez cette même section plus tard, elle redevient exactement dans l’état à trois niveaux que vous aviez laissé.

Préserver l’état peut sembler judicieux, mais il est déroutant. Rouverir quelque chose donne l’impression de recommencer depuis zéro, et l’interface contredit cette attente. Vous avez oublié où vous en étiez ; l’interface, elle, ne l’a pas oublié.

La solution consiste à faire en sorte que la fermeture d’un nœud clarifie tout ce qui se trouve en dessous de lui. Le fournisseur possède la clé ouverte de son groupe :

const CollapseGroupProvider = ({ isOpen, children }) => {
  const [openKey, setOpenKey] = useState<string | null>(null);

et un effet efface cette clé chaque fois que le nœud propre au fournisseur est fermé :

  useEffect(() => {
    if (!isOpen) setOpenKey(null);
  }, [isOpen]);  // ...
};

Pourquoi la cascade s’occupe d’elle-même

Lorsqu’un nœud de premier niveau est fermé, son fournisseur réinitialise la sélection de deuxième niveau en null. Chaque nœud de deuxième niveau étant alors fermé, cela déclenche les effets de leurs fournisseurs, qui à leur tour effacent le niveau trois, et ainsi de suite. La réinitialisation se propage à n’importe quelle profondeur sans nécessiter de parcours explicite de l’arbre.

Le compromis est que chaque niveau est traité lors d’une passe de rendu distincte, car les effets s’exécutent après le rendu. Pour un petit nombre de niveaux, cela reste imperceptible. Si vous constatez que cela provoque des clignotements visibles dans un arbre très profond, une alternative consiste à réinstaller le fournisseur enfant en modifiant son key lorsque le parent se ferme, ce qui supprime l’état imbriqué en une seule étape. Dans tous les cas, comme le contenu reste installé, seules les clés ouvertes sont réinitialisées ; le contenu DOM lui-même n’est jamais détruit.

Réduire une cible de clic trop grande

Un problème mineur peut prendre beaucoup de temps à diagnostiquer. Chaque bouton en-tête étiré sur toute la ligne :

className="flex w-full items-center justify-start gap-3 py-1 ..."

Ainsi, toute la ligne réagit aux clics, y compris la zone vide après le titre, qui s’étend presque sur toute l’écran. Tenter de sélectionner du texte ou de cliquer dans la marge peut faire plier ou déplier inopinément une section.

En passant de w-full à w-fit, la taille du bouton s’adapte à son contenu : l’emoji, le titre et le symbole fléché.

className="flex w-fit items-center justify-start gap-3 py-1 ..."

Le choix explicite de w-fit, plutôt que simplement la suppression de w-full, est intentionnel. Un <button> avec display: flex et une largeur automatique dépend de la manière dont chaque navigateur taille intrinsèquement les contrôles de formulaire, et être explicite évite de compter sur un comportement identique partout.

Il n’y a pas non plus de régression sur les petits écrans. Une largeur fit-content est limitée à l’espace disponible, sans jamais dépasser le conteneur, de sorte qu’un titre long sur un téléphone se reformate toujours de la même manière qu’auparavant.

Test de la cascade d’états

Une logique basée sur des états imbriqués déclenchés par des effets semble correcte lors de la revue mais échoue en pratique. Avant de livrer le produit, il est utile de rendre l’interface avec React dans jsdom et de vérifier les cas importants :

  • Ouvrir L1, puis L2, puis L3 laisse les trois ouverts.
  • Fermer L1 ferme les trois.
  • Réouvrir L1 n’ouvre que le niveau 1, tandis que les niveaux 2 et 3 restent fermés.
  • Ouvrir un élément frère de L1 laisse toute la branche L1 fermée.
  • Fermer et rouvrir toute la section ferme tout ce qui se trouve en dessous.

C’est le troisième cas pour lequel le mécanisme de réinitialisation existe, et c’est aussi celui qui a le plus de chances d’être erroné si l’on se fie uniquement à l’apparence du code. Il est également utile d’ajouter une vérification pour s’assurer que le contenu plié reste présent dans le markup rendu, car c’est cette propriété sur laquelle repose tout le design.

Conclusion

Aucune de ces options n’apparaît sur une capture d’écran. Les visiteurs ne remarqueront pas que le texte plié reste dans le DOM, que la réouverture d’un bloc efface tout ce qui a été modifié, ou que l’en-tête cesse de répondre aux clics là où se termine le texte. Lorsque c’est bien fait, la seule impression est qu’il n’y a rien d’irritant, et les moteurs de recherche voient la page dans son intégralité.

  • Désactiver les éléments qui ne devraient pas exister une fois fermés, tels que les modaux et les menus ; plier le contenu avec CSS pour ceux qui doivent toujours faire partie de la page.
  • Animer la hauteur à l’aide de grid-template-rows entre 0fr et 1fr, en utilisant un enfant avec overflow-hidden, plutôt que de deviner une valeur pour max-height.
  • Rendre les panneaux pliés inert afin que le contenu caché soit indexable mais pas cliquable.
  • Restreignez l’état de l’accordeon aux groupes frères, en maintenant un seul contexte par niveau, et effacez l’état des enfants lorsque le parent se ferme.
  • Ajustez la taille des en-têtes interactifs en fonction de leur contenu, et testez les transitions d’état plutôt que de les observer visuellement.
  • Pour des techniques similaires permettant de rendre le contenu hors écran de manière économique tout en le rendant indexable, consultez skipping offscreen rendering with content-visibility.