Aller au contenu principal
Technique8 février 202610 min

Web Components et accessibilité : construire des composants réutilisables accessibles

En Bref : L'essentiel à retenir

  • Le Shadow DOM empêche aria-labelledby et aria-describedby de référencer des éléments situés en dehors de l'arbre shadow, cassant le lien entre labels et champs.
  • Les Custom Elements sans rôle ARIA explicite sont annoncés comme des éléments génériques par les lecteurs d'écran (NVDA, VoiceOver, JAWS).
  • La délégation de focus (delegatesFocus: true) et un tabindex correctement géré sont indispensables pour que la navigation clavier traverse le Shadow DOM.
  • Tester chaque Web Component isolément avec un lecteur d'écran et au clavier avant de l'intégrer dans une page reste la vérification la plus fiable.
JavaScriptARIAComposantsHTMLDev

Les Web Components promettent des composants réutilisables, encapsulés, framework-agnostic. Shadow DOM, Custom Elements, templates : l'architecture est séduisante.

Sauf que l'encapsulation qui protège vos styles protège aussi vos composants des mécanismes d'accessibilité du navigateur. Un aria-labelledby qui pointe vers un élément hors du shadow tree ? Ignoré. Un Custom Element sans rôle ? Invisible pour un lecteur d'écran. Un focus qui ne traverse pas la boundary ? Piège clavier.

Ce guide couvre les problèmes concrets et leurs solutions, testées avec NVDA, VoiceOver et JAWS.


Pourquoi les Web Components posent des problèmes d'accessibilité

L'accessibilité HTML repose sur des conventions : les éléments natifs (<button>, <input>, <select>) portent un rôle, un nom et un état compris par les technologies d'assistance. Quand vous créez un <fancy-button>, le navigateur ne sait pas que c'est un bouton.

Le Shadow DOM ajoute une couche de complexité. Il crée une frontière d'encapsulation que les références ARIA ne traversent pas.

Trois problèmes reviennent systématiquement :

  1. Les Custom Elements sont des boîtes noires pour l'arbre d'accessibilité. Sans rôle explicite, <date-picker> est annoncé comme "group" ou ignoré.
  2. Les ID ne traversent pas le Shadow DOM. aria-labelledby="external-label" ne résout rien si le label est dans le Light DOM et le champ dans le Shadow DOM.
  3. Le focus clavier peut rester bloqué si la délégation de focus n'est pas configurée.

Ce sont les critères RGAA 7.1 (scripts accessibles) et 7.3 (contrôle au clavier) qui s'appliquent.


Custom Elements : rôles et noms accessibles

Le problème

Un Custom Element sans configuration ARIA est opaque :

CODE
<!-- Mauvais : aucun rôle, aucun nom accessible -->
<star-rating value="4"></star-rating>

Un lecteur d'écran annonce : "group" — ou rien du tout.

La solution

Appliquer les rôles et propriétés ARIA sur l'élément hôte via ElementInternals (la méthode moderne) ou directement avec setAttribute.

CODE
// Avec ElementInternals (recommandé)
class StarRating extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.internals.role = 'slider';
    this.internals.ariaValueMin = '0';
    this.internals.ariaValueMax = '5';
    this.internals.ariaLabel = 'Note';
  }

  set value(val) {
    this.internals.ariaValueNow = String(val);
    this.internals.ariaValueText = `${val} étoiles sur 5`;
  }
}
customElements.define('star-rating', StarRating);

ElementInternals présente un avantage : les propriétés ARIA définies via .internals ne polluent pas le DOM visible. L'attribut role n'apparaît pas dans le HTML inspecté, mais il est bien présent dans l'arbre d'accessibilité.

[!TIP] Avant de créer un Custom Element interactif, vérifiez si un élément HTML natif ne suffit pas. C'est la première règle d'ARIA : ne pas utiliser ARIA quand le HTML natif fait le travail.

Compatibilité

ElementInternals est supporté dans Chrome 77+, Firefox 93+, Safari 16.4+. Pour les navigateurs plus anciens, un fallback avec setAttribute reste nécessaire.


Shadow DOM : le piège des références ARIA cross-root

C'est le problème le plus fréquent et le moins intuitif.

Ce qui casse

CODE
<!-- Light DOM -->
<label id="email-label">Adresse email</label>
<email-input></email-input>
CODE
// Shadow DOM de <email-input>
class EmailInput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <!-- aria-labelledby pointe vers un ID du Light DOM -->
      <!-- CASSÉ : l'ID n'est pas résolvable ici -->
      <input type="email" aria-labelledby="email-label" />
    `;
  }
}

Le <input> n'aura aucun nom accessible. Le critère RGAA 11.1 (étiquette de champ) est en échec.

Les solutions qui fonctionnent

Option 1 : Slot — garder le label dans le même scope

CODE
<!-- Light DOM -->
<email-input>
  <label slot="label">Adresse email</label>
</email-input>
CODE
// Shadow DOM
shadow.innerHTML = `
  <div>
    <slot name="label"></slot>
    <input type="email" aria-label="" />
  </div>
`;

// Synchroniser le label avec le champ
connectedCallback() {
  const label = this.querySelector('[slot="label"]');
  const input = this.shadowRoot.querySelector('input');
  if (label && input) {
    const id = 'input-' + Math.random().toString(36).slice(2);
    input.id = id;
    label.setAttribute('for', id);
  }
}

Attention : le for/id fonctionne parce que le <label> slotté reste techniquement dans le Light DOM. C'est une des rares exceptions à la frontière du Shadow DOM.

Option 2 : aria-label directement sur l'input interne

Plus simple, mais le label n'est pas visible :

CODE
shadow.innerHTML = `
  <input type="email" aria-label="Adresse email" />
`;

Acceptable si un label visuel existe par ailleurs (via slot ou design).

Option 3 : tout dans le Shadow DOM

La solution la plus robuste — label et champ cohabitent :

CODE
shadow.innerHTML = `
  <label for="email-field">Adresse email</label>
  <input type="email" id="email-field" />
`;

Pas de problème cross-root. Le maillage label/input est entièrement contenu.


Focus et navigation clavier dans le Shadow DOM

Le focus clavier traverse le Shadow DOM, mais pas toujours de manière prévisible. Sans configuration, un utilisateur qui navigue au clavier peut sauter par-dessus les éléments interactifs d'un Web Component — ou s'y retrouver piégé (critère RGAA 12.8).

delegatesFocus

L'option delegatesFocus: true dans attachShadow() change le comportement : quand l'élément hôte reçoit le focus, celui-ci est automatiquement délégué au premier élément focusable du shadow tree.

CODE
class SearchBox extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({
      mode: 'open',
      delegatesFocus: true
    });
    shadow.innerHTML = `
      <input type="search" aria-label="Rechercher" />
      <button type="submit" aria-label="Lancer la recherche">
        <!-- icône SVG -->
      </button>
    `;
  }
}

Sans delegatesFocus, il faut gérer le focus manuellement avec tabindex sur l'élément hôte et une logique de redirection dans le focus event.

Ordre de tabulation

Les éléments à l'intérieur du Shadow DOM s'insèrent dans l'ordre de tabulation naturel à la position de l'élément hôte. Si l'élément hôte est entre un lien et un bouton dans le Light DOM, les éléments focusables du shadow tree apparaîtront entre ces deux éléments dans le tab order.

C'est le comportement attendu. Le problème survient quand on utilise tabindex avec des valeurs positives à l'intérieur du Shadow DOM — l'ordre devient imprévisible.

Règle simple : tabindex="0" ou tabindex="-1" uniquement. Jamais de valeurs positives.


Gérer les états et les mises à jour en temps réel

Un Web Component interactif change d'état : un accordéon s'ouvre, un menu se déplie, un champ passe en erreur. Ces changements doivent être reflétés dans l'arbre d'accessibilité.

CODE
class AccordionPanel extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <button
        aria-expanded="false"
        aria-controls="panel-content"
      >
        <slot name="heading"></slot>
      </button>
      <div id="panel-content" role="region" hidden>
        <slot></slot>
      </div>
    `;
  }

  connectedCallback() {
    const btn = this.shadowRoot.querySelector('button');
    const panel = this.shadowRoot.querySelector('[role="region"]');
    btn.addEventListener('click', () => {
      const expanded = btn.getAttribute('aria-expanded') === 'true';
      btn.setAttribute('aria-expanded', String(!expanded));
      panel.hidden = expanded;
    });
  }
}

Les attributs aria-expanded et aria-controls fonctionnent correctement à l'intérieur du Shadow DOM tant que l'élément cible (panel-content) est dans le même shadow root. C'est la même logique que pour aria-labelledby : la résolution d'ID est scopée.

Pour les notifications de changement d'état vers le Light DOM, utilisez des événements personnalisés :

CODE
this.dispatchEvent(new CustomEvent('accordion-toggle', {
  bubbles: true,
  composed: true, // traverse le Shadow DOM
  detail: { expanded: !expanded }
}));

Le flag composed: true permet à l'événement de traverser la frontière du Shadow DOM. Sans lui, l'événement reste confiné.


Tester l'accessibilité de vos Web Components

Les tests automatisés ne suffisent pas. L'arbre d'accessibilité construit à partir du Shadow DOM peut surprendre, et seul un test manuel confirme le comportement réel.

Checklist de test par composant

  • Le composant a un rôle identifiable dans l'arbre d'accessibilité
  • Le nom accessible est annoncé correctement (vérifier dans Chrome DevTools > Accessibility)
  • La navigation clavier fonctionne : Tab entre dans le composant, Tab en sort
  • Les états (expanded, selected, checked) sont annoncés au changement
  • Aucune référence ARIA cross-root n'est utilisée
  • Le composant fonctionne avec NVDA + Firefox et VoiceOver + Safari

Outils

Chrome DevTools expose l'arbre d'accessibilité complet, Shadow DOM inclus. Ouvrez l'onglet Accessibility dans les DevTools, cochez "Enable full-page accessibility tree", et inspectez votre composant.

Pour les tests automatisés en CI/CD, axe-core détecte la plupart des problèmes ARIA dans le Shadow DOM (depuis la version 4.4+). Mais il ne teste ni le focus, ni l'annonce réelle par un lecteur d'écran.

Lancez un audit automatique sur votre page pour identifier les problèmes détectables, puis complétez avec un test manuel au clavier et au lecteur d'écran.


Ce qui va changer : Cross-Root ARIA

La spécification Cross-Root ARIA Reflection (en cours au W3C) vise à résoudre le problème des références ARIA cross-root. Elle permettrait à un aria-labelledby dans le Shadow DOM de pointer vers un ID dans le Light DOM via un mécanisme de réflexion.

Concrètement, un attribut comme shadowrootreflects ou exportparts étendu aux ARIA refs pourrait connecter les deux arbres. Mais la spec est encore en draft, aucun navigateur ne l'implémente, et le timeline reste incertain.

En attendant, les solutions décrites dans cet article (slots, labels internes, ElementInternals) restent la référence.


Conclusion

Les Web Components sont une technologie puissante. Mais le Shadow DOM crée des frontières que l'accessibilité ne traverse pas automatiquement. Les trois règles à retenir : garder les références ARIA dans le même scope, déléguer le focus explicitement, et tester avec un vrai lecteur d'écran.

Pour les composants plus classiques (modales, accordéons, onglets), consultez notre guide sur les modales accessibles.

Votre site est-il conforme ?

Ne prenez pas de risques avec l'accessibilité. Lancez un audit complet de votre site en quelques minutes et obtenez un rapport détaillé des corrections à apporter.