Skip to main content

Chapitre 86 : Accessibilité et focus management avancé

Gestion du focus sur les conteneurs, isolation d'interfaces via l'attribut inert et stratégies de navigation pour les applications complexes.

L'accessibilité (a11y) ne se limite pas à l'ajout d'attributs aria-*. Pour un développeur expert, elle consiste à orchestrer le flux de navigation de l'utilisateur, particulièrement pour ceux utilisant des technologies d'assistance (lecteurs d'écran) ou la navigation au clavier. Le "Focus Management" est l'art de contrôler quel élément reçoit l'attention du navigateur et comment cette attention est déplacée.

L'attribut inert et l'isolation d'interface

1. Quoi

L'attribut inert est un attribut global HTML qui, lorsqu'il est présent, indique au navigateur que l'élément et tous ses descendants sont totalement inactifs.

Concrètement, un élément marqué comme inert :

  • Ne peut pas recevoir le focus (est retiré de l'ordre de tabulation).
  • N'est pas visible pour les technologies d'assistance (retiré de l'arbre d'accessibilité).
  • Ne peut pas être cliqué ou déclencher d'événements de souris.

2. Pourquoi

Dans les interfaces modernes (modales, tiroirs de navigation/drawers, tooltips complexes), il est fréquent de superposer une couche d'interface sur le contenu principal. Sans inert, un utilisateur naviguant avec la touche Tab pourrait "sortir" de la modale et continuer à naviguer dans le contenu en arrière-plan, créant une confusion cognitive majeure et des bugs d'interaction.

L'utilisation de inert est largement supérieure à la gestion manuelle de tabindex="-1" sur chaque lien et bouton du background, car elle est native, performante et exhaustive.

3. Comment

A. Syntaxe de base

L'attribut est un booléen. Sa présence suffit à activer l'effet.

<main inert>

<button>Je suis invisible pour le focus</button>
<a href="/home">Lien inactif</a>
</main>

B. Cas concret : Implémentation d'une modale accessible

Dans un scénario réel, inert est piloté dynamiquement via JavaScript pour isoler le reste de l'application lors de l'ouverture d'un dialogue.


<div id="app-root">
<nav id="main-nav">...</nav>
<main id="main-content">

<button id="open-modal">Ouvrir les paramètres</button>
</main>
</div>


<div id="modal-container" hidden>
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Paramètres</h2>
<button id="close-modal">Fermer</button>
<input type="text" placeholder="Nom d'utilisateur" />
</div>
</div>
const openBtn = document.getElementById('open-modal');
const closeBtn = document.getElementById('close-modal');
const modal = document.getElementById('modal-container');
const appRoot = document.getElementById('app-root');

openBtn.addEventListener('click', () => {
modal.hidden = false;
// On rend tout le reste de l'app "inerte"
appRoot.setAttribute('inert', '');
// On déplace le focus vers le premier élément interactif de la modale
closeBtn.focus();
});

closeBtn.addEventListener('click', () => {
modal.hidden = true;
// On restaure l'interactivité
appRoot.removeAttribute('inert');
// On redonne le focus au bouton qui a ouvert la modale
openBtn.focus();
});

C. Limitations

  • Support Navigateur : Bien que supporté par tous les navigateurs modernes, les versions très anciennes nécessitent un polyfill.
  • Sémantique : inert ne remplace pas aria-hidden="true". Bien que inert retire l'élément de l'arbre d'accessibilité, l'utilisation combinée avec aria-modal="true" sur le dialogue est la norme pour garantir que le lecteur d'écran comprenne le contexte.

4. Zone de Danger

Erreur commune : Utiliser pointer-events: none en CSS pour empêcher les clics sur le background.

  • Problème : pointer-events: none n'empêche pas la navigation au clavier (Tab). L'utilisateur peut toujours entrer dans le background.

Bonne pratique : Utiliser l'attribut inert pour bloquer à la fois la souris, le clavier et les lecteurs d'écran.

Cycle de gestion du focus et attribut inert

Focus Trapping et Gestion des Conteneurs

1. Quoi

Le Focus Trap (piège à focus) est un pattern d'UX consistant à forcer le focus à rester à l'intérieur d'un conteneur spécifique tant qu'une condition n'est pas remplie (ex: fermeture d'une modale).

Bien que le piège soit géré en JavaScript, le CSS joue un rôle crucial dans la signalétique visuelle via :focus-within et :focus-visible.

2. Pourquoi

Sans focus trap, même avec inert sur le background, si l'utilisateur arrive à la fin des éléments interactifs de la modale et appuie sur Tab, le focus pourrait disparaître ou revenir au début du document (selon le navigateur), perdant ainsi l'utilisateur.

3. Comment

A. Synergie CSS :focus-within

Le pseudo-sélecteur :focus-within permet de styliser un conteneur dès que l'un de ses enfants reçoit le focus. C'est essentiel pour créer des indicateurs visuels de "zone active".

.form-group {
border: 2px solid #ccc;
padding: 1rem;
transition: border-color 0.2s ease;
}

/* Le conteneur s'illumine dès qu'un input interne est focus */
.form-group:focus-within {
border-color: #007bff;
box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.25);
}

B. Implémentation d'un Focus Trap robuste

Pour un expert, le focus trap doit gérer les touches Tab et Shift + Tab.

function setupFocusTrap(element) {
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;

if (e.shiftKey) { // Shift + Tab
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else { // Tab
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
});
}

4. Zone de Danger

Erreur commune : Forcer le focus sur un élément via .focus() sans vérifier si l'élément est visible ou accessible.

  • Problème : Cela peut provoquer des sauts de page brusques et désorienter l'utilisateur.

Bonne pratique : Utiliser preventScroll: true dans les options de .focus() pour éviter les sauts de page non désirés.

element.focus({ preventScroll: true });

Focus Management dans le Routing (SPA)

1. Quoi

Dans une Single Page Application (SPA), le changement de page ne recharge pas le document. Par conséquent, le focus reste sur l'élément qui a déclenché la navigation (le lien), alors que cet élément a potentiellement disparu du DOM ou que le contenu a changé.

2. Pourquoi

Si le focus n'est pas géré lors d'un changement de route :

  1. Le focus "tombe" dans le vide (retour au body).
  2. L'utilisateur clavier doit retabuler tout le header/nav pour atteindre le contenu de la nouvelle page.
  3. Les lecteurs d'écran ne signalent pas que la page a changé.

3. Comment

La stratégie experte consiste à déplacer le focus vers un "point d'ancrage" (souvent le titre H1 de la nouvelle page) lors de chaque transition de route.

A. Préparation du titre (CSS/HTML)

Le titre H1 n'est pas focusable par défaut. Il faut lui ajouter tabindex="-1". Cela permet de lui donner le focus via JS sans l'ajouter à l'ordre de tabulation normal.

<h1 id="page-title" tabindex="-1" style="outline: none;">
Titre de la nouvelle page
</h1>

B. Logique de transition

// Exemple simplifié pour un routeur
async function navigateTo(url) {
await router.push(url);

// Attendre que le DOM soit mis à jour
requestAnimationFrame(() => {
const title = document.getElementById('page-title');
if (title) {
title.focus({ preventScroll: true });
// Optionnel : on peut ajouter une classe CSS pour un flash visuel
title.classList.add('focus-highlight');
setTimeout(() => title.classList.remove('focus-highlight'), 1000);
}
});
}

4. Zone de Danger

Erreur commune : Mettre tabindex="0" sur le titre H1 pour le rendre focusable.

  • Problème : L'utilisateur clavier rencontrera le titre en tabulant, ce qui est redondant et irritant pour une navigation fluide.

Bonne pratique : Utiliser tabindex="-1". L'élément est focusable programmatiquement, mais ignoré par la touche Tab.

Questions clés

1. Quelle est la différence fondamentale entre inert et display: none ?

Découvrir la réponse

display: none retire l'élément du rendu visuel et de l'arbre d'accessibilité. inert laisse l'élément visible (le rendu CSS n'est pas affecté), mais le rend totalement inactif et invisible pour les technologies d'assistance. inert est donc idéal pour les overlays où le contenu arrière doit rester visible mais inaccessible.

2. Pourquoi utiliser :focus-within plutôt que :focus sur un conteneur ?

Découvrir la réponse

:focus ne s'active que si le conteneur lui-même est focusable (ex: un div avec tabindex). :focus-within s'active si le conteneur OU n'importe lequel de ses descendants reçoit le focus. C'est l'outil parfait pour styliser des groupes de formulaires.

3. Comment gérer le retour du focus après la fermeture d'une modale ?

Découvrir la réponse

Il est impératif de stocker l'élément qui avait le focus avant l'ouverture (document.activeElement) et de lui redonner le focus via .focus() dès que la modale est fermée. Cela permet à l'utilisateur de reprendre sa navigation exactement là où il s'était arrêté.

4. Pourquoi le tabindex="-1" est-il crucial pour le routing SPA ?

Découvrir la réponse

Il permet de déplacer le focus vers un élément non interactif (comme un <h1>) via JavaScript, informant ainsi le lecteur d'écran du changement de contexte, sans pour autant polluer le flux de navigation au clavier de l'utilisateur.

Mise en pratique

Exercice 1 : Reproduction guidée

Objectif : Créer un composant "Card" qui change de couleur de bordure et affiche un badge "Édition" uniquement lorsque l'un des champs de saisie à l'intérieur est focus.

  • Utilisez un conteneur .card.
  • Ajoutez deux <input> et un <button> à l'intérieur.
  • Le badge doit être caché par défaut (opacity: 0) et devenir visible via :focus-within.
Découvrir la solution commentée
<div class="card">
<span class="badge">Édition en cours...</span>
<input type="text" placeholder="Nom" />
<input type="text" placeholder="Email" />
<button>Sauvegarder</button>
</div>

<style>
.card {
border: 2px solid #ddd;
padding: 20px;
position: relative;
transition: all 0.3s ease;
}

.badge {
position: absolute;
top: -10px;
right: 10px;
background: #007bff;
color: white;
font-size: 12px;
padding: 2px 8px;
opacity: 0;
transition: opacity 0.3s ease;
}

/* On cible le conteneur quand un enfant a le focus */
.card:focus-within {
border-color: #007bff;
box-shadow: 0 0 10px rgba(0,123,255,0.2);
}

.card:focus-within .badge {
opacity: 1;
}
</style>

Exercice 2 : Adaptation

Objectif : Implémenter un système de "Side Panel" (panneau latéral) qui utilise l'attribut inert pour isoler le contenu principal.

  • Créez un bouton "Ouvrir Panel".
  • Créez un panneau latéral (aside) et un contenu principal (main).
  • Au clic sur le bouton, le main doit devenir inert et le focus doit être déplacé vers le premier lien du panneau latéral.
  • Ajoutez un bouton "Fermer" dans le panneau pour restaurer l'état initial.
Découvrir la solution commentée
<button id="btn-open">Ouvrir Panel</button>

<main id="main-content">
<h1>Contenu Principal</h1>
<p>Ceci sera inerte quand le panel sera ouvert.</p>
<a href="#">Lien 1</a>
</main>

<aside id="side-panel" hidden>
<button id="btn-close">Fermer</button>
<nav>
<a href="#">Lien Panel A</a>
<a href="#">Lien Panel B</a>
</nav>
</aside>

<script>
const btnOpen = document.getElementById('btn-open');
const btnClose = document.getElementById('btn-close');
const main = document.getElementById('main-content');
const panel = document.getElementById('side-panel');
const firstLink = panel.querySelector('a');

btnOpen.addEventListener('click', () => {
panel.hidden = false;
main.setAttribute('inert', ''); // Isolation complète
firstLink.focus(); // Transfert de focus
});

btnClose.addEventListener('click', () => {
panel.hidden = true;
main.removeAttribute('inert'); // Restauration
btnOpen.focus(); // Retour au déclencheur
});
</script>

Exercice 3 : Conception

Objectif : Créer un gestionnaire de focus pour une application de type "Dashboard" avec un système de navigation par onglets (Tabs).

Contraintes :

  1. Lorsque l'utilisateur change d'onglet, le contenu de l'onglet précédent doit être masqué et celui du nouvel onglet doit devenir actif.
  2. Le focus doit être déplacé vers le titre de la section de l'onglet actif pour les utilisateurs de lecteurs d'écran.
  3. Implémentez une gestion visuelle via CSS pour indiquer quel onglet est "actif" et lequel a le "focus".
Découvrir la solution commentée
<div class="tabs-container">
<div class="tab-list" role="tablist">
<button class="tab-btn active" data-target="tab1" role="tab" aria-selected="true">Profil</button>
<button class="tab-btn" data-target="tab2" role="tab" aria-selected="false">Sécurité</button>
</div>

<div class="tab-content">
<section id="tab1" class="tab-panel" role="tabpanel">
<h2 tabindex="-1">Paramètres du Profil</h2>
<p>Contenu du profil...</p>
</section>
<section id="tab2" class="tab-panel" role="tabpanel" hidden>
<h2 tabindex="-1">Paramètres de Sécurité</h2>
<p>Contenu de la sécurité...</p>
</section>
</div>
</div>

<style>
.tab-btn { padding: 10px; cursor: pointer; border: 1px solid #ccc; }
.tab-btn.active { background: #007bff; color: white; }
.tab-btn:focus-visible { outline: 3px solid orange; }
.tab-panel h2:focus { outline: none; } /* On cache l'outline car c'est un focus programmé */
</style>

<script>
const tabs = document.querySelectorAll('.tab-btn');
const panels = document.querySelectorAll('.tab-panel');

tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetId = tab.getAttribute('data-target');

// 1. Mise à jour visuelle des onglets
tabs.forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');

// 2. Gestion des panels
panels.forEach(p => {
p.hidden = true;
});
const activePanel = document.getElementById(targetId);
activePanel.hidden = false;

// 3. Focus Management Expert
// On déplace le focus vers le titre du panel pour informer le lecteur d'écran
const title = activePanel.querySelector('h2');
title.focus({ preventScroll: true });
});
});
</script>