Un menu de navigation à plusieurs niveaux, ouvert au clavier, referme son sous-menu tout seul dès que l’utilisateur appuie une seconde fois sur Tab. Aucune ligne de code ne semble gérer explicitement cette fermeture. Le comportement n’apparaît que dans certains sous-menus, ceux dont le contenu dépasse une certaine hauteur et déclenche l’apparition d’une barre de défilement interne.
Le composant en cause utilise ResizeObserver pour ajuster dynamiquement la position d’un sous-menu déroulant lorsque son contenu change de taille, une technique courante pour éviter qu’un menu ne déborde de la fenêtre. L’observateur surveille la taille du conteneur du menu et redéclenche un calcul de positionnement, et dans ce cas précis, une reconstruction partielle du DOM interne du sous-menu, à chaque changement détecté.
Le code à l’origine du symptôme
const observer = new ResizeObserver((entries) => {
for (const entree of entries) {
repositionnerMenu(entree.target);
sousMenu.innerHTML = construireContenu(donneesMenu);
}
});
observer.observe(sousMenu);
La fonction repositionnerMenu() est une opération légitime de recalcul de position, sans effet sur le focus. La ligne suivante, en revanche, réécrit entièrement le contenu HTML du sous-menu via innerHTML à chaque déclenchement de l’observateur, y compris lorsque le changement de taille résulte simplement de l’ouverture d’un lien ou du passage du focus sur un élément adjacent. Réécrire le DOM détruit les nœuds existants, y compris celui qui portait le focus actif, et le navigateur reporte alors le focus sur le <body> par défaut, faute de nœud restant à cet emplacement.
Pourquoi trois recalculs à l’ouverture

Sur ce composant, l’ouverture d’un sous-menu déclenchait successivement trois observations de redimensionnement : une lors de l’ajout initial du menu au DOM, une seconde lors de l’application de l’animation CSS de hauteur, et une troisième lors de l’apparition de la barre de défilement interne une fois le contenu rendu. Chacune de ces trois observations relançait la reconstruction complète du contenu, et donc la perte potentielle de focus, si l’utilisateur avait déjà commencé à naviguer au clavier avant que les trois cycles ne se stabilisent.
Le symptôme observé par l’utilisateur (« le menu se ferme tout seul au second Tab ») correspondait en réalité à une perte de focus vers le <body>, suivie par la fermeture du menu déclenchée par un gestionnaire d’événement focusout mal configuré, qui interprétait cette perte de focus comme une sortie volontaire du menu par l’utilisateur.
Diagnostiquer ce genre de cas
- Ajouter un point d’arrêt ou un
console.log(document.activeElement)juste avant et juste après chaque déclenchement de l’observateur - Confirmer que l’élément actif change de valeur (souvent vers
body) exactement au moment du redimensionnement observé, pas au moment de l’appui sur une touche - Vérifier si une reconstruction de DOM (
innerHTML, remplacement de nœud) a lieu dans le gestionnaire de l’observateur - Isoler le gestionnaire
focusoutdu menu pour vérifier s’il referme le menu sur toute perte de focus, y compris celles provoquées par le code lui-même
Le correctif
const observer = new ResizeObserver((entries) => {
for (const entree of entries) {
repositionnerMenu(entree.target);
}
});
observer.observe(sousMenu);
// Le contenu n'est construit qu'à l'ouverture, jamais à chaque redimensionnement
function ouvrirSousMenu() {
sousMenu.innerHTML = construireContenu(donneesMenu);
sousMenu.hidden = false;
}
Séparer la responsabilité de positionnement (déclenchée à chaque redimensionnement) de la responsabilité de construction du contenu (déclenchée une seule fois à l’ouverture) élimine la reconstruction répétée du DOM, et donc la perte de focus associée. Le gestionnaire focusout a également été corrigé pour vérifier que le nouvel élément focalisé se trouve réellement en dehors du menu avant de le refermer, via event.relatedTarget.
Prévenir la récidive
- Ne jamais reconstruire le contenu DOM d’un composant interactif dans le gestionnaire d’un
ResizeObserverdestiné au positionnement - Toujours vérifier
event.relatedTargetdans un gestionnairefocusoutavant de considérer que l’utilisateur a quitté un composant - Tester l’ouverture d’un menu au clavier avec un contenu suffisamment long pour déclencher une barre de défilement, cas souvent oublié en recette
Ce genre de bug ne se voit jamais en testant à la souris : il faut naviguer réellement au clavier, avec Tab et les flèches, pour le faire apparaître.
En résumé
Le focus qui saute inopinément d’un menu vers le corps du document trahit presque toujours une reconstruction du DOM à un moment où l’utilisateur y a déjà positionné son focus. ResizeObserver n’est coupable de rien en soi : c’est le mélange entre une opération de repositionnement, légitime à chaque redimensionnement, et une opération de reconstruction de contenu, qui ne devrait avoir lieu qu’une seule fois à l’ouverture, qui provoque la perte de focus observée.