1 points par GN⁺ 2024-11-19 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2024-11-19
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 click est 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. Avec keydown, 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

    • Ce qui est intéressant, c’est qu’une lecture naïve en anglais du code et de la description du bug WebKit ne correspond pas vraiment à la structure réelle du code. Le code concerné est const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0; et const 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 eventSource renvoie des catégories mutuellement exclusives comme "keyboard", "mouse" ou "not sure".
    • Je ne pense pas que ce soit un bug. La première erreur des développeurs a été de vouloir créer une expérience utilisateur différente entre clavier et souris.
      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.
    • Je trouve cet article confus. Si je comprends bien, la BBC veut un comportement légèrement différent selon qu’il s’agit d’un « clic » à la souris ou d’un « clic » au clavier, et souhaite qu’au clavier le focus soit placé sur le premier lien du menu sans animation.
      En même temps, elle veut aussi la commodité de ne se lier qu’à un seul événement. click rend 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 est screenX=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 que screenX et screenY n’ont presque aucun sens pour un click dé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 un click se produit avec un keydown sur le même élément.
    • Je comprends pourquoi l’auteur a besoin de screenX et screenY, mais je me demande toujours pourquoi screenX devrait renvoyer les coordonnées réelles de l’écran plutôt qu’une position interne au moteur de rendu ou dans la page rendue, comme layerX et layerY.
      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.
    • Dans « nous ne voulons pas que le focus et l’animation se comportent légèrement différemment selon que l’utilisateur a “cliqué” avec le pointeur ou “cliqué” avec le clavier pour ouvrir le menu », je me demande si don’t n’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 si screenX et screenY é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 != 0 est vraiment la meilleure, voire la seule, méthode ? En relisant, la phrase disant que le gestionnaire d’événements traite aussi keydown, 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.

    • La consultation de la position à l’écran ressemble à une heuristique destinée à déterminer la nature de l’événement. Intuitivement, j’aurais tendance à utiliser 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 toggleMenu est 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=42174177
    • Dans le code corrigé, ils vérifient déjà event.name == 'click'. Dans ce cas, je ne comprends pas pourquoi ils cherchent à filtrer certains événements de clic parfaitement valides.
    • Pas vraiment. On peut faire une sélection via media query selon que le périphérique d’entrée principal est un dispositif de pointage, et même s’il est de haute précision, puis filtrer sur cette base.
      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 preventDefault dans l’événement afin d’empêcher le navigateur de générer ensuite un événement click. 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.

    • Je n’ai pas réponse à toutes les questions, mais à « l’accessibilité est-elle si difficile que ça », je peux répondre clairement oui.
      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 en aria-hidden, puis ajouter une balise p invisible 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.
    • D’accord. Cela dit, beaucoup de problèmes viennent au final du fait que les agents utilisateur personnalisent ces éléments de façon très douteuse.
      Dans l’ensemble ça va, mais il y a une raison pour laquelle les fichiers reset.css existent, 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/Y positives 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ées screenX et screenY, il faut prendre en compte non seulement les valeurs positives, mais aussi les négatives.

    • En réalité, c’est ce qui est prévu. Nous allons bientôt fusionner le code pour utiliser pointerId === -1, puis retomber sur screenX === 0 en 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.

    • J’ai cherché pourquoi, mais je n’ai pas trouvé grand-chose. Le fait qu’un site web puisse connaître la position de la fenêtre du navigateur via 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 screenX et screenY pour é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.
    • C’est utile quand on crée un jeu dont les graphismes sont composés de plusieurs petites fenêtres de navigateur qui interagissent entre elles.
      Exemple : https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
    • Parce que c’était facile à implémenter pendant les 10 jours alloués au développement de JavaScript en 1995, et que la rétrocompatibilité a ensuite fait son œuvre :(
    • Si vous réagissez à un événement de clic, vous pouvez vouloir connaître les coordonnées de l’endroit cliqué. C’est surtout utilisé pour les opérations cliquer-glisser, afin de calculer le delta entre les événements et mettre à jour la position de l’objet déplacé.
      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 que event.target est le bouton à activer ? », « pourquoi utiliser JavaScript alors que les balises details/summary peuvent faire la même chose ? »
    • C’est utilisé pour des CAPTCHA sans JavaScript. Ça fonctionne bien, et au clic ça n’envoie que les coordonnées x et y du clic de souris.
  • 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 click suffit à indiquer que l’utilisateur a voulu activer le menu. Je ne comprends pas pourquoi ils réinventent la roue.

    • D’après l’article, isInvokedByMouse vérifiait si les coordonnées screenX ou screenY étaient positives afin de déterminer si l’événement click avait é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 === 0 dè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 mousedown est le plus récent, isKeyboard=false, isMouse=true, et si keydown est le plus récent, l’inverse.
    Dans ce cas, on n’a plus besoin des fonctions isInvokedByMouse et isInvokedByKeyboard. 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.

  • 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 .clientX et .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/...