Feed accessible : role feed, défilement infini et RGAA
Le motif ARIA feed face au RGAA : pourquoi le défilement infini piège le clavier (critère 12.9), quand un bouton Charger plus suffit, aria-busy, aria-posinset et aria-setsize.
Un flux (feed) est une section défilante qui charge automatiquement de nouveaux contenus à mesure que l'utilisateur avance, présentés comme une série d'articles. Il n'existe pas d'élément HTML natif, et dans la plupart des cas le motif ARIA n'est même pas la bonne réponse : une simple liste d'<article> avec un bouton explicite « Charger plus » est préférable au role="feed". Ce rôle existe pour un flux authentique, où du contenu arrive PENDANT la lecture et où la position de lecture doit survivre aux insertions. C'est plus rare qu'il n'y paraît.
Quand ce motif est le mauvais choix
Le problème fondateur du flux est le défilement infini : si du contenu se charge sans fin au défilement, le pied de page devient inatteignable au clavier comme au lecteur d'écran, car de nouveaux éléments s'intercalent en permanence entre l'utilisateur et sa destination. La personne tabule vers le lien « Politique de confidentialité » et du contenu ne cesse d'apparaître devant. Un bouton « Charger plus » supprime entièrement le problème : l'utilisateur décide quand du contenu existe, et la fin de page reste atteignable.
| Besoin | Solution correcte |
|---|---|
| Liste de billets paginée ou avec bouton « Charger plus » | <ul> d'<article> + bouton, sans role="feed" |
| Liste statique | Une simple liste |
| Vrai flux auto-alimenté, où la position de lecture doit survivre aux insertions | role="feed" (ce motif) |
Critères RGAA applicables
- Critère 12.9 : absence de piège au clavier. Un défilement infini sans fin atteignable s'apparente à un piège au clavier : l'utilisateur ne peut jamais rejoindre ce qui suit le flux, le pied de page reculant à chaque chargement. Prévoir un bouton « Charger la suite » ou un lien d'évitement plutôt qu'un chargement au défilement.
- Critère 9.1 : structuration de l'information par des titres. Chaque article du flux doit porter un titre (
<hx>), car la navigation de titre en titre est le principal moyen de parcourir un flux au lecteur d'écran ;aria-labelledbypointe vers ce titre. - Critère 7.5 : messages de statut. « Chargement en cours » et « 12 nouveaux éléments » sont des messages de statut ; le test 7.5.1 attend
role="status"pour le résultat d'une action, annoncé sans déplacer le focus, dans une zone live séparée du flux. - Critère 7.1 : compatibilité des scripts avec les technologies d'assistance. Les rôles
feedetarticle, ainsi que l'étataria-busypendant les insertions, doivent être exposés à l'API d'accessibilité. - Critère 7.3 : script contrôlable au clavier et par tout dispositif de pointage. Le déplacement entre articles (Page suivante / Page précédente) et le chargement doivent être entièrement réalisables au clavier.
- Critère 13.8 : contenu en mouvement ou clignotant contrôlable. Un flux qui se rafraîchit automatiquement est un contenu en mouvement déclenché automatiquement ; l'utilisateur doit pouvoir l'arrêter et le relancer (bouton pause).
Interaction clavier attendue
| Touche | Action |
|---|---|
| Page suivante (Page Down) | Déplace le focus sur l'article suivant. |
| Page précédente (Page Up) | Déplace le focus sur l'article précédent. |
| Ctrl + Fin | Premier élément focalisable après le flux. |
| Ctrl + Début | Premier élément focalisable avant le flux. |
| Tab | Reste intact : il doit toujours permettre de traverser puis quitter le flux. |
Rôles et attributs ARIA
Commencez par la version qui suffit presque toujours : une liste, un bouton, une zone de statut. Pas de role="feed", rien à casser, pied de page atteignable.
<h1>Derniers billets</h1>
<ul class="billets">
<li>
<article aria-labelledby="billet-1-titre">
<h2 id="billet-1-titre"><a href="/blog/billet-1">RGAA et composants ARIA</a></h2>
<p>Résumé du billet…</p>
</article>
</li>
</ul>
<button type="button" id="charger-plus">Charger plus de billets</button>
<!-- Zone live SÉPARÉE, jamais aria-live sur la liste (critère 7.5). -->
<div id="billets-statut" role="status" class="sr-only"></div>
<footer>…toujours atteignable…</footer>
const bouton = document.getElementById('charger-plus');
const statut = document.getElementById('billets-statut');
bouton.addEventListener('click', async () => {
bouton.disabled = true;
statut.textContent = 'Chargement des billets…';
const billets = await chargerBillets();
for (const billet of billets) liste.append(rendreBillet(billet));
bouton.disabled = false;
// Dire ce qui s'est passé, SANS déplacer le focus : l'utilisateur lit.
statut.textContent = `${billets.length} billets supplémentaires chargés.`;
});
Pour un vrai flux : role="feed" sur le conteneur, nommé par aria-label ou aria-labelledby, avec aria-busy="true" pendant les insertions (remis à "false" à la fin, y compris en cas d'erreur). Chaque unité de contenu porte role="article", aria-labelledby vers son titre, aria-posinset (position) et aria-setsize (total, ou -1 si inconnu : c'est exactement à cela que cette valeur sert).
<h1 id="titre-flux">Activité récente</h1>
<!-- Un flux auto-rafraîchi doit pouvoir être mis en pause (critère 13.8). -->
<button type="button">Mettre en pause les mises à jour</button>
<div role="feed" aria-labelledby="titre-flux" aria-busy="false" id="flux-activite">
<article aria-labelledby="element-1-titre" aria-posinset="1" aria-setsize="-1" tabindex="0">
<h2 id="element-1-titre">Alice a commenté le rapport</h2>
<p>« La page contact mérite un second audit. »</p>
</article>
<article aria-labelledby="element-2-titre" aria-posinset="2" aria-setsize="-1" tabindex="0">
<h2 id="element-2-titre">Benoît a déposé 3 fichiers</h2>
<p>plans-v2.pdf, elevations.pdf, notes.md</p>
</article>
</div>
<div id="flux-statut" role="status" class="sr-only"></div>
const flux = document.getElementById('flux-activite');
const statut = document.getElementById('flux-statut');
const articles = () => [...flux.querySelectorAll('article')];
// Page Down / Page Up déplacent le focus d'article en article : c'est le
// modèle clavier du motif feed. Tab n'est jamais intercepté.
flux.addEventListener('keydown', (evenement) => {
const courant = evenement.target.closest('article');
if (!courant) return;
const elements = articles();
const indice = elements.indexOf(courant);
if (evenement.key === 'PageDown') { evenement.preventDefault(); elements[indice + 1]?.focus(); }
else if (evenement.key === 'PageUp') { evenement.preventDefault(); elements[indice - 1]?.focus(); }
});
async function chargerLaSuite() {
// Prévenir les technologies d'assistance que le DOM est en pleine mutation,
// pour qu'elles ne lisent pas un flux à moitié construit.
flux.setAttribute('aria-busy', 'true');
try {
const elements = await chargerActivite();
const decalage = articles().length;
elements.forEach((element, i) => {
const article = rendreArticle(element);
article.setAttribute('aria-posinset', String(decalage + i + 1));
article.setAttribute('aria-setsize', '-1'); // total inconnu
flux.append(article);
});
statut.textContent = `${elements.length} nouveaux éléments.`;
} finally {
// Toujours remis à false, même après une erreur réseau.
flux.setAttribute('aria-busy', 'false');
}
}
À ne pas faire :
<!-- Chargement automatique au défilement, sans fin : LE défaut du motif.
Le pied de page est inatteignable pour toujours (critère 12.9). -->
<div role="feed" data-defilement-infini>…</div>
<!-- aria-live sur le flux : chaque article inséré est lu en entier. Charger
vingt billets déclenche vingt lectures par-dessus l'activité en cours. -->
<div role="feed" aria-live="polite">…</div>
<!-- Article sans titre : la navigation de titre en titre ne rencontre rien
(critère 9.1), et aria-labelledby n'a rien à pointer. -->
<article tabindex="0"><p>Alice a commenté le rapport</p></article>
<!-- aria-setsize avec un total inventé : annonce « 1 sur 20 » dans un flux
sans fin connue. La valeur correcte pour un total inconnu est -1. -->
<article aria-posinset="1" aria-setsize="20">…</article>
// À ne pas faire : déplacer le focus vers les nouveaux éléments au chargement.
// L'utilisateur lisait l'élément 3 ; vingt éléments se chargent et le focus
// saute à l'élément 24. Il perd complètement sa position.
nouveauxArticles[0].focus();
// À ne pas faire : laisser aria-busy à "true" après une erreur réseau.
// Le flux est annoncé « occupé » indéfiniment. Toujours le remettre à false
// dans un finally.
Défauts fréquents et impact utilisateur
- Chargement automatique au défilement sans fin : le pied de page (mentions légales, politique de confidentialité) est inatteignable pour toujours ; l'utilisateur clavier tabule vers lui et du contenu ne cesse d'apparaître devant.
aria-liveposé sur le flux : chaque article inséré est lu en entier ; charger vingt billets déclenche vingt lectures par-dessus l'activité en cours de l'utilisateur.- Articles sans titre : la navigation de titre en titre ne rencontre rien ; le flux devient un bloc opaque impossible à parcourir au lecteur d'écran (critère 9.1).
aria-setsizeavec un total inventé : annonce « 1 sur 20 » dans un flux sans fin connue ; la valeur correcte pour un total inconnu est-1.- Focus déplacé vers les nouveaux éléments au chargement : l'utilisateur lisait l'élément 3, vingt éléments se chargent et le focus saute à l'élément 24 ; il perd complètement sa position.
aria-busyresté à"true"après une erreur réseau : le flux est annoncé « occupé » indéfiniment et l'utilisateur attend un contenu qui n'arrivera jamais.
Ce que les outils automatiques ne détectent pas
Un scanner automatique, le nôtre compris, repère des enfants invalides dans un role="feed" (seul article y est permis) et les noms accessibles manquants. Le défaut central du motif, lui, est invisible aux scanners :
- le pied de page inatteignable ne se constate qu'en tabulant réellement vers la fin de page pendant que le flux charge ;
- un
aria-busybloqué après erreur et un focus volé au chargement ne se détectent qu'en manipulant le flux (réseau coupé, chargement pendant la lecture) ; - l'absence de bouton de pause sur un flux auto-rafraîchi (critère 13.8) reste une vérification manuelle.
Vérifier ce composant
Protocole manuel, dans cet ordre :
- Le test du pied de page, celui qui compte : chargez la page, puis tabulez ou défilez vers le pied de page. Pouvez-vous l'atteindre ? Si du contenu ne cesse d'apparaître entre vous et le lien de confidentialité, le critère 12.9 est en échec. C'est le test que la plupart des flux réels ratent.
- Le test des titres : naviguez de titre en titre au lecteur d'écran (touche H sous NVDA). Vous devez atterrir sur chaque article. Si rien ne se passe, les articles n'ont pas de titre (critère 9.1).
- Le test du chargement : activez « Charger plus ». Le lecteur d'écran doit annoncer le résultat SANS que le focus bouge. S'il lit chaque article inséré,
aria-liveest posé sur le flux. - Le test de l'erreur : coupez le réseau (mode hors ligne des DevTools) et relancez un chargement.
aria-busyest-il resté à"true"? Le flux est alors annoncé comme chargeant pour toujours. - Le test du focus : chargez la suite pendant que le focus est sur l'élément 3. Il doit y rester.
Deux outils gratuits du site pour appuyer la vérification : le validateur de titres affiche l'arbre des titres de la page, où chaque article du flux doit apparaître (critère 9.1), et le simulateur de lecteur d'écran montre l'arbre d'accessibilité : rôles feed et article, noms et positions s'y lisent directement.
Approfondir avec les fiches critères
Vérifier ce composant sur votre site ?
Le scan repère les défauts détectables automatiquement (rôles incohérents, champs sans étiquette, attributs ARIA orphelins) ; le reste se vérifie à la main avec les protocoles de cette fiche.
Lancer un scan gratuit