- Sur le site de la BBC UK, le bouton More ne traitait pas les clics uniquement dans certains environnements de télétravail : ce qui ressemblait à un bug UI banal était en réalité un problème de système de coordonnées multi-écrans
- Quand un moniteur externe est placé au-dessus ou à gauche de l’écran principal, les événements
click de Chrome et Firefox peuvent avoir des valeurs screenX et screenY négatives
- Le code existant déterminait un clic pointeur avec
event.screenX > 0 || event.screenY > 0, et ne reconnaissait donc pas les clics à coordonnées négatives comme des clics de souris
- La correction a été simple : au lieu de vérifier si
screenX et screenY étaient supérieurs à 0, il suffisait de vérifier qu’ils étaient non nuls, soit event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0)
- Malgré des tests unitaires, des tests Puppeteer, des tests manuels et des tests avec technologies d’assistance, ce type de bug peut subsister à cause de l’ambiguïté de la spécification UI Events et d’hypothèses sur les coordonnées en environnement multi-écrans
Un bug de navigation BBC reproductible seulement dans un environnement précis
- La barre de navigation du site BBC UK ouvre un menu lorsque l’utilisateur active le bouton More
- Ce bouton utilise l’événement
click, qui peut être déclenché non seulement par la souris, mais aussi par le tactile, ainsi que par Enter et Space au clavier
- Un membre de l’équipe ne rencontrait le problème qu’à son domicile avec son ordinateur portable professionnel, alors que le même portable fonctionnait normalement au bureau
- Même chez lui, l’échec ne se produisait que lorsque la fenêtre du navigateur se trouvait sur un moniteur externe ; sur l’écran du portable, le bouton fonctionnait normalement
- Lorsque le problème survenait, le gestionnaire JavaScript n’ouvrait pas le menu et l’ouverture se faisait via le comportement de fallback sans JavaScript
- Le même problème n’apparaissait pas dans Safari
La condition de reproduction : la position du moniteur
- L’équipe a progressivement réduit les conditions de reproduction en identifiant l’élément déclencheur dans l’environnement domestique
- Le moniteur externe était placé au-dessus de l’écran du portable, et modifier cette disposition dans les réglages de l’OS faisait disparaître le problème
- Un autre membre de l’équipe a pu reproduire le bug en configurant lui aussi la disposition des écrans de la même façon dans l’OS
- Deux conditions avaient été identifiées au début de l’enquête
- Le problème ne se produisait pas dans Safari
- Le problème se produisait lorsque le moniteur externe était au-dessus et à gauche du moniteur principal
Coordonnées négatives pour screenX et screenY
- En observant l’événement
click du bouton More avec console.log, l’équipe a constaté que les valeurs de screenX et screenY étaient négatives dans Chrome et Firefox
- Quel que soit le mode d’entrée qui le déclenche, un événement
click est un type de PointerEvent, si bien que l’objet événement inclut des informations sur la souris ou le pointeur tactile à l’origine du clic
screenX et screenY représentent, en pixels, les coordonnées du point cliqué à l’écran
- La spécification DOM UI Events ne semblait pas indiquer clairement si ces propriétés pouvaient être négatives
- La différence entre Safari d’un côté et Chrome/Firefox de l’autre montre que la représentation des coordonnées d’écran peut varier selon les navigateurs en configuration multi-écrans
- Ce problème d’interopérabilité a été signalé à l’équipe WebKit
Différences entre navigateurs dans la gestion des coordonnées multi-écrans
- En configuration multi-écrans, le système de coordonnées d’écran du navigateur traite plusieurs moniteurs comme s’il s’agissait d’un seul grand écran
- Avec deux moniteurs de 800 px disposés horizontalement, la plage de coordonnées en x peut aller de 0 à 1600
- Dans Safari, la plage de coordonnées semble toujours rester dans un intervalle positif démarrant au moniteur le plus en haut à gauche
- Dans Chrome et Firefox, les coordonnées semblent être calculées par rapport au moniteur principal ; des coordonnées négatives peuvent donc apparaître sur un écran situé au-dessus ou à gauche de celui-ci
- Dans ce bug précis, le problème ne survenait que lorsque
screenX et screenY étaient négatifs
Le code réellement en cause et la correction
- Dans le code problématique,
isInvokedByMouse essayait de vérifier si l’événement click provenait d’une souris ou d’un pointeur tactile en testant si screenX et screenY étaient positifs
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;
const isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);
// ...
const toggleMenu = event => {
// ...
if (isInvokedByMouse(event) || isInvokedByKeyboard(event)) {
event.preventDefault();
// Do stuff to open the menu and move the focus...
}
};
- Ce code supposait que
screenX et screenY seraient positifs pour un événement click déclenché par un pointeur
- Si l’utilisateur cliquait sur le bouton
More depuis un moniteur dont les coordonnées d’écran étaient négatives, le gestionnaire d’événement ne reconnaissait pas le clic, et le lien More retombait sur son comportement par défaut
- La correction a consisté à ne plus vérifier si
screenX et screenY étaient supérieurs à 0, mais simplement s’ils étaient non nuls
const isInvokedByMouse = event =>
event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
- Avec ce changement, les utilisateurs ayant une disposition multi-écrans inhabituelle peuvent à nouveau utiliser la barre de navigation du site de la BBC
Questions de conception restantes et refactorisation ultérieure
- Même si la correction elle-même était simple, le code gardait encore des aspects étranges
- Il n’était pas nécessaire de vérifier si
click venait de la souris ou du clavier, et le fait que le gestionnaire traite aussi les événements keydown avait ajouté de la complexité
- Il faut être prudent avec les hypothèses faites sur le comportement d’une API, et le fait que la spécification ne soit pas claire sur la possibilité de valeurs négatives pour
screenX et screenY a aussi masqué le problème
- Ce code était pourtant passé par des tests unitaires, des tests Puppeteer et des tests manuels sur plusieurs navigateurs, appareils et outils de technologies d’assistance, sans que le bug soit détecté
- Selon une mise à jour du 19 novembre 2024, le composant de navigation a ensuite été refactoré, et le gestionnaire d’événement du bouton
menu a lui aussi été largement modifié
- L’article de suivi explique la manière dont la refactorisation a été menée et répond aux questions les plus fréquentes : How I refactored the BBC navigation bar and a follow-up FAQ
1 commentaires
Avis sur Hacker News
Pour compléter pour celles et ceux qui n’auraient pas cliqué jusqu’au rapport de bug WebKit : un développeur WebKit a demandé à la BBC pourquoi il serait utile de pouvoir détecter si un événement venait du clavier, et l’auteur a répondu que l’interopérabilité était nécessaire en raison de cas d’usage liés à l’accessibilité.
Le bouton de menu de la barre de navigation du site britannique de la BBC se comporte légèrement différemment selon qu’on l’ouvre au pointeur ou au clavier. Un événement de clic ouvre toujours le menu, mais lorsqu’il est ouvert au pointeur, le focus passe au conteneur du menu ; lorsqu’il est ouvert au clavier, le focus passe au premier lien du menu, sans animation d’ouverture. L’événement
clickest indépendant du périphérique, ce qui est utile pour créer une expérience utilisateur au clavier, et au clavier il n’est déclenché qu’avec Espace ou Entrée. Aveckeydown, il faut vérifier soi-même s’il s’agit d’Espace ou d’Entrée.Source : https://bugs.webkit.org/show_bug.cgi?id=281430
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;etconst isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);; en surface, on dirait qu’il tente de classer l’événement comme venant soit de la souris, soit du clavier.En réalité, on obtient quatre catégories : souris mais pas clavier, clavier mais pas souris, les deux, ni l’un ni l’autre. Comme dans le bug d’origine, le cas « ni l’un ni l’autre » est mal géré, et on peut aussi se demander si le cas « les deux » fonctionne correctement. Le code devrait traiter explicitement le fait que « vient du clavier » et « vient de la souris » sont deux booléens distincts, ou être structuré de sorte que
eventSourcerenvoie des catégories mutuellement exclusives comme"keyboard","mouse"ou"not sure".Il vaut mieux s’aligner sur le comportement par défaut et concevoir un composant qui fonctionne dans les deux cas d’usage. En accessibilité, il ne faut pas essayer d’être trop malin. On finit par arriver à une solution proche du hack, et ce genre d’approche finit inévitablement par casser ou produire des effets de bord. S’il existe peu de bons leviers pour traiter les choses différemment dans un contexte d’accessibilité, c’est parce que ce n’est pas un domaine conçu pour être traité différemment au départ.
En même temps, elle veut aussi la commodité de ne se lier qu’à un seul événement.
clickrend cela possible, mais comme il n’y a aucun moyen de savoir si l’événement a été déclenché par un clic de souris ou par une saisie clavier, Chrome utilise une heuristique fragile consistant à considérer que si la position de la souris estscreenX=0,screenY=0, il s’agit soit d’un clic à l’origine, soit d’un déclenchement au clavier. Pour avoir travaillé sur des projets d’accessibilité, je trouve que c’est une assez mauvaise idée, et si j’avais vu cela dans une PR, j’aurais demandé de le réécrire. Il serait bien sûr idéal que les navigateurs aient le même comportement, mais le vrai problème me semble être quescreenXetscreenYn’ont presque aucun sens pour unclickdéclenché au clavier.Idéalement, on n’émettrait pas de
MouseEvent, mais un événement plus général applicable à la fois au clavier et à la souris, par exemple quelque chose comme"trigger", avec des informations sur l’origine du déclenchement. Comme cela n’existe pas dans la spécification actuelle et qu’il faut une solution tout de suite, il serait beaucoup plus fiable et moins bricolé de se lier aussi àkeydown, puis de considérer qu’il s’agit d’une entrée clavier si unclickse produit avec unkeydownsur le même élément.screenXetscreenY, mais je me demande toujours pourquoiscreenXdevrait renvoyer les coordonnées réelles de l’écran plutôt qu’une position interne au moteur de rendu ou dans la page rendue, commelayerXetlayerY.Le besoin de l’auteur pourrait être satisfait avec une position dans le moteur de rendu, sans divulguer la position de la fenêtre du navigateur à tous les sites web visités.
don’tn’est pas une coquille qui inverse le sens voulu.À propos du passage disant qu’« il suffisait de remplacer la vérification de
isInvokedByMouse, qui testait siscreenXetscreenYétaient supérieurs à 0, par une vérification qu’ils ne sont pas égaux à 0 », je me demande ce qui se passe, même si c’est extrêmement rare, si l’utilisateur fait réellement un clic de souris à la position 0,0.Je ne suis pas très familier avec JS : est-ce que vérifier
!= 0est vraiment la meilleure, voire la seule, méthode ? En relisant, la phrase disant que le gestionnaire d’événements traite aussikeydown, ce qui le rend complexe et nécessitera une refactorisation ultérieure, mais que ce correctif suffit pour l’instant, semble couvrir en partie ce point.instanceof MouseEvent, mais cela aussi paraît risqué ou hacky.Je me demande pourquoi ils s’appuient sur une telle heuristique. C’est peut-être parce que
toggleMenuest utilisé par plusieurs gestionnaires d’événements, ou pour d’autres raisons propres à la base de code. Difficile de juger sans voir l’ensemble. La réponse semble être ici : https://news.ycombinator.com/item?id=42174177event.name == 'click'. Dans ce cas, je ne comprends pas pourquoi ils cherchent à filtrer certains événements de clic parfaitement valides.Je l’ai déjà utilisé pour choisir quelle mise en page afficher. Si vous voulez uniquement écouter les entrées tactiles, vous pouvez faire cela puis appeler
preventDefaultdans l’événement afin d’empêcher le navigateur de générer ensuite un événementclick. Ou bien vous pouvez simplement vous épargner l’effort et écrire un gestionnaire de clic.Que la BBC ait découvert un bug désagréable en investissant dans l’accessibilité mérite d’être salué. Mais pourquoi l’industrie n’arrive-t-elle toujours pas à produire correctement des menus déroulants qui s’ouvrent de façon cohérente pour tous les utilisateurs ?
L’accessibilité est-elle si difficile que ça ? La BBC aurait-elle dû utiliser un framework web ou des composants web qui gèrent déjà ce genre de choses ? En tant que développeur full-stack plutôt orienté backend, je suis prudent dès qu’il faut toucher aux composants de navigateur. Leur comportement comporte beaucoup de subtilités, et les implémentations ont été éprouvées pendant longtemps. Par exemple, créer une zone de texte personnalisée sans étudier en profondeur le comportement des zones de texte selon les plateformes me semble propice à l’échec. Même sur des sites de grandes entreprises, je vois souvent le copier/coller casser et des caractères disparaître. Je ne comprends pas pourquoi les zones de texte cassent encore en 2024, et React me paraît désormais arrogant.
Personnellement, j’aurais essayé de faire ça avec des templates côté serveur, un framework CSS comme Bulma, et un minimum de JS. Ce n’est pas adapté aux sites qui exigent un branding personnalisé très léché, mais les zones de texte fonctionnent bien et le coût de développement n’est pas excessif. Je ne suis pas sûr que cela satisfasse les critères d’accessibilité de la BBC.
Un exemple concret : les modales. Si vous n’avez pas de déficience visuelle, vous voyez une boîte blanche flottant au-dessus d’une zone grise « à ne pas toucher », avec des composants d’interface à l’intérieur. Si vous utilisez un lecteur d’écran, rien ne garantit que cette information vous parvienne. Quand vous naviguez entre les éléments d’interface au clavier et que vous revenez en haut de la boîte, est-ce qu’un lecteur d’écran donné vous l’indiquera ? Listera-t-il les éléments interactifs disponibles ? Les listera-t-il dans le même ordre qu’un autre lecteur d’écran ? Et sur téléphone, sur Mac ? Le lecteur d’écran et le navigateur signaleront-ils correctement les éléments de saisie, ou permettront-ils silencieusement à l’utilisateur de sortir de la modale et de revenir au reste du site ?
En matière d’accessibilité, on ne peut pas faire confiance au système d’exploitation, au navigateur et au lecteur d’écran pour coopérer ou se comporter raisonnablement dans les situations appropriées. En 2019, j’ai dû signaler un bug dans VoiceOver + Safari où une margin CSS négative faisait lire par le lecteur d’écran un bloc de texte RTL dans le mauvais ordre. Visuellement, on voyait
9/10/2019, mais au lecteur d’écran cela sonnait comme « ten slash nine slash two-thousand-and-nineteen », et comme solution temporaire il a fallu mettre le texte enaria-hidden, puis ajouter une balisepinvisible dans le bon ordre. Donc quand on voit du code bizarre lié à l’accessibilité, il arrive vraiment qu’il n’y ait pas de meilleure solution. Même si vous retournez complètement la codebase et faites de l’accessibilité la priorité absolue, une mise à jour de JAWS ou de VoiceOver peut tout casser d’une manière difficile à comprendre.Dans l’ensemble ça va, mais il y a une raison pour laquelle les fichiers
reset.cssexistent, et ici il semble possible qu’ils aient utilisé une approche plus extrême pour contourner entièrement ce genre de problèmes. J’essaie de déduire leurs décisions.Ça ressemble à un bug auto-infligé dû à une mauvaise heuristique. Ils ont supposé que des valeurs
screenX/Ypositives indiquaient un événement souris, et le manque de traçage/journalisation a encore compliqué l’enquête.Au lieu de vérifier
pointerType, la propriété plus appropriée proposée par d’autres commentaires, je suis un peu surpris que la solution de l’auteur consiste à rajouter d’autres heuristiques fragiles. À partir des deux derniers indices, il en conclut en quelque sorte qu’en vérifiant les coordonnéesscreenXetscreenY, il faut prendre en compte non seulement les valeurs positives, mais aussi les négatives.pointerId === -1, puis retomber surscreenX === 0en fallback.À l’époque où ce code a été écrit, il y a environ quatre ans, tous les navigateurs n’utilisaient pas PointerEvent pour
click.Je ne comprends même pas pourquoi un site web peut obtenir la position de la souris dans le repère de l’écran.
window.screenX/window.screenY, et que la position du clic puisse aussi être rapportée dans ce repère, paraît absurde sur desktop.TOR Browser semble maquiller
screenXetscreenYpour éviter le fingerprinting. Je me demande si quelqu’un a déjà vu un bon cas d’usage pour cette fonctionnalité. Les seules choses qui me viennent à l’esprit sont une application à double fenêtre où les fenêtres interagissent entre elles, ou un site dont le comportement change selon sa position dans un écran virtuel.Exemple : https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
Je ne comprends pas pourquoi ils vérifient les coordonnées plutôt que
event.type. Cela dit, l’article est un bon casse-tête, et je me reconnais dans la situation où l’on regarde du code qu’on n’a pas écrit en se demandant : « pourquoi est-ce important que les coordonnées du clic ne soient pas 0 ? », « pourquoi ne pas simplement vérifier queevent.targetest le bouton à activer ? », « pourquoi utiliser JavaScript alors que les balisesdetails/summarypeuvent faire la même chose ? »Pourquoi filtrer par coordonnées d’écran dès le départ ? Que se passe-t-il si l’utilisateur emploie un périphérique d’entrée alternatif sans écran ?
L’événement
clicksuffit à indiquer que l’utilisateur a voulu activer le menu. Je ne comprends pas pourquoi ils réinventent la roue.isInvokedByMousevérifiait si les coordonnéesscreenXouscreenYétaient positives afin de déterminer si l’événementclickavait été déclenché par une souris ou un pointeur tactile, et non par le clavier.Ils essayaient de détecter s’il s’agissait d’une activation au clavier ou à la souris, et l’auteur a supposé que les coordonnées écran d’un événement souris seraient toujours positives.
J’ai publié un autre billet de blog pour expliquer le contexte qui intriguait les gens et répondre aux questions. J’y explique pourquoi j’avais vérifié
screenX === 0dès le départ, pourquoi je voulais un comportement différent selon que l’entrée venait du clavier ou de la souris, et comment j’ai refactorisé pour éviter d’autres incidents.J’espère que ce sera utile : https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...
Quelle est la bonne façon de déterminer s’il s’agit d’un clic de souris ou d’un clic au clavier ? J’aurais tendance à définir un flag au niveau du module en fonction du dernier événement survenu : si
mousedownest le plus récent,isKeyboard=false,isMouse=true, et sikeydownest le plus récent, l’inverse.Dans ce cas, on n’a plus besoin des fonctions
isInvokedByMouseetisInvokedByKeyboard. Y a-t-il une meilleure approche ? S’appuyer sur les coordonnées de l’écran pour ça me paraît très suspect, et ressemble à un hack.event.detail[1] vaut 0 pour un « clic » au clavier, et 1 pour un clic au pointeur.1 : https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/det...
C’est très intéressant, mais je ne comprends pas pourquoi le navigateur renvoie des coordonnées différentes selon le moniteur. Je pensais que le navigateur traitait la page web comme si elle était en plein écran, quel que soit l’écran sur lequel elle se trouve.
Y a-t-il une raison pour qu’une API Web dispose de ce type d’information ? Ça ressemble à un risque de sécurité, de fuite d’informations et de pistage.
Ce n’est pas plutôt un problème de compétence en développement ? Il fallait utiliser les coordonnées du viewport, pas celles de l’écran, et les lire via
.clientXet.clientY. Je ne vois pas en quoi des valeurs négatives dans l’espace écran seraient un bug.https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...