- En el sitio web de BBC UK, el botón More fallaba al procesar clics solo en un entorno específico de trabajo desde casa; lo que parecía un bug común de UI en realidad era un problema del sistema de coordenadas con varios monitores.
- Cuando un monitor externo estaba colocado arriba y a la izquierda del monitor principal,
screenX y screenY podían ser negativos en el evento click de Chrome y Firefox.
- El código existente identificaba un clic de puntero con
event.screenX > 0 || event.screenY > 0, por lo que no reconocía los clics con coordenadas negativas como clics de mouse.
- La corrección fue simple: en vez de verificar si
screenX y screenY eran mayores que 0, comprobar que no fueran 0, quedando como event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0).
- Aunque había pasado por pruebas unitarias, Puppeteer, pruebas manuales y pruebas con tecnologías de asistencia, un bug así podía permanecer por la ambigüedad de la especificación UI Events y las suposiciones sobre coordenadas en configuraciones multimonitor.
Un bug de navegación de la BBC que solo se reproducía en un entorno específico
- La barra de navegación del sitio web de BBC UK abre un menú cuando el usuario activa el botón More.
- Este botón usa el evento
click, que puede generarse no solo con el mouse, sino también con el tacto y con las teclas Enter y Space del teclado.
- Un integrante del equipo tenía el problema solo cuando usaba su laptop de trabajo en casa; con la misma laptop en la oficina funcionaba correctamente.
- Incluso en casa, fallaba únicamente cuando la ventana del navegador estaba en el monitor externo; en la pantalla de la laptop, el botón funcionaba bien.
- Cuando ocurría el problema, en vez de que el handler de JavaScript abriera el menú, este se abría mediante el comportamiento fallback sin JavaScript.
- En Safari no aparecía el mismo problema.
La condición de reproducción era la posición del monitor
- El equipo fue acotando las condiciones de reproducción al verificar qué elemento del entorno doméstico causaba el problema.
- El monitor externo estaba colocado arriba de la pantalla de la laptop, y al cambiar esa disposición en la configuración del sistema operativo, el problema dejaba de ocurrir.
- Otro integrante del equipo también pudo reproducir el bug al configurar la disposición de monitores del sistema operativo de la misma manera.
- Las condiciones identificadas al inicio de la investigación fueron dos:
- En Safari no ocurría el problema.
- El problema ocurría cuando el monitor externo estaba arriba y a la izquierda del monitor principal.
Coordenadas negativas en screenX y screenY
- Al inspeccionar con
console.log el evento click del botón More, los valores de screenX y screenY aparecían como negativos en Chrome y Firefox.
- Un evento
click, sin importar qué entrada lo haya generado, es un tipo de PointerEvent, por lo que el objeto del evento incluye información del puntero de mouse o táctil que produjo el clic.
screenX y screenY representan, en píxeles, las coordenadas del punto donde se hizo clic en la pantalla.
- En la especificación DOM UI Events no parecía haber información concreta sobre si esas propiedades podían ser negativas.
- La diferencia entre Safari y Chrome/Firefox muestra que, en configuraciones multimonitor, cada navegador puede tener una forma distinta de representar las coordenadas de pantalla.
- Este problema de interoperabilidad fue reportado al equipo de WebKit.
Diferencias entre navegadores en coordenadas multimonitor
- En una configuración multimonitor, el sistema de coordenadas de pantalla del navegador trata varios monitores como si fueran una sola pantalla grande.
- Si hay dos monitores de 800 px colocados horizontalmente, el rango de la coordenada x podría ir de 0 a 1600.
- En Safari, el rango de coordenadas parece comenzar siempre en el monitor superior izquierdo, como un rango positivo.
- En Chrome y Firefox, las coordenadas parecen calcularse tomando como referencia el monitor principal, por lo que en pantallas ubicadas arriba o a la izquierda del monitor principal pueden aparecer coordenadas negativas.
- Este bug solo ocurría cuando
screenX y screenY eran negativos.
El código problemático y la corrección
- En el código problemático,
isInvokedByMouse verificaba si screenX y screenY eran positivos para intentar confirmar que el evento click había sido generado por un puntero de mouse o táctil.
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...
}
};
- Este código asumía que
screenX y screenY de un evento click generado por un puntero serían positivos.
- Cuando un usuario hacía clic en el botón
More desde un monitor con coordenadas de pantalla negativas, el handler del evento no reconocía el clic y se caía al comportamiento predeterminado del enlace More.
- La corrección fue comprobar que
screenX y screenY no fueran 0, en lugar de verificar si eran mayores que 0.
const isInvokedByMouse = event =>
event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
- Con este cambio, incluso los usuarios con disposiciones multimonitor poco comunes pudieron usar la barra de navegación del sitio web de la BBC.
Problemas de diseño pendientes y refactorización posterior
- Aunque la corrección en sí fue sencilla, el código todavía tenía partes extrañas.
- No era necesario comprobar si
click se había generado con mouse o teclado, y el handler de eventos se había vuelto complejo porque también procesaba eventos keydown.
- Hay que tener cuidado con las suposiciones que se hacen sobre el comportamiento de las API, y el hecho de que la especificación no fuera clara sobre si
screenX y screenY podían ser negativos también ayudó a ocultar el problema.
- Este código había pasado por pruebas unitarias, pruebas con Puppeteer y pruebas manuales con varios navegadores, dispositivos y herramientas de tecnologías de asistencia, pero el bug no se detectó.
- Como actualización del 19 de noviembre de 2024, el componente de navegación fue refactorizado posteriormente y el handler de eventos del botón
menu también cambió de forma importante.
- El artículo posterior responde preguntas frecuentes y explica la refactorización: How I refactored the BBC navigation bar and a follow-up FAQ
1 comentarios
Opiniones de Hacker News
Para complementar para quienes no hicieron clic hasta el reporte de bug de WebKit: un desarrollador de WebKit le preguntó a la BBC por qué sería útil poder detectar si el evento venía del teclado, y el autor respondió que necesitaban interoperabilidad por casos de uso relacionados con accesibilidad.
El botón de menú de la barra de navegación del sitio web de BBC Reino Unido se comporta de forma ligeramente distinta cuando se abre con un puntero y cuando se abre con el teclado. Un evento de clic siempre abre el menú, pero si se abre con el puntero, el foco se mueve al contenedor del menú; si se abre con el teclado, el foco se mueve al primer enlace del menú sin la animación de apertura. El evento
clickes independiente del dispositivo, así que es bueno para crear una experiencia de usuario con teclado, y en el teclado solo se invoca con Space o Enter. Si se usakeydown, hay que comprobar manualmente si es Space/Enter.Fuente: https://bugs.webkit.org/show_bug.cgi?id=281430
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;yconst isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);; en la superficie parece que intentan clasificar el evento como una de dos cosas: mouse o teclado.En realidad aparecen cuatro categorías: es mouse y no teclado, es teclado y no mouse, es ambos, no es ninguno. Como en el bug original, “no es ninguno” se maneja de forma inadecuada, y también queda la duda de si “es ambos” funciona correctamente. El código debería tratar de forma intencional que “si es teclado” y “si es mouse” son booleanos separados, o estructurarse para que
eventSourcedevuelva categorías mutuamente excluyentes como"keyboard","mouse","not sure".Es mejor ajustarse al comportamiento predeterminado y diseñar un componente que funcione en ambos casos de uso. En accesibilidad no hay que intentar hacerse el listo. Al final terminó siendo una solución casi de hack, y ese enfoque inevitablemente se rompe o produce efectos secundarios. La razón por la que en el contexto de accesibilidad hay pocos buenos controles para tratar las cosas de forma distinta es que, para empezar, no es un área pensada para tratarse de forma distinta.
Al mismo tiempo, también quiere la comodidad de enlazarse a un solo evento.
clickpermite eso, pero como no hay forma de saber si el evento se produjo por un clic de mouse o por entrada de teclado, en Chrome están usando una heurística inestable: si la posición del mouse esscreenX=0,screenY=0, se considera que es un clic en el origen o un disparo por teclado. Habiendo trabajado en proyectos de accesibilidad, me parece una idea bastante mala; si la hubiera visto en un PR, habría pedido reescribirla. Sería ideal que los navegadores tuvieran el mismo comportamiento, pero el verdadero problema parece ser quescreenXyscreenYtienen muy poco significado en unclickgenerado por teclado.Idealmente no se debería emitir un
MouseEvent, sino un evento más general aplicable tanto a teclado como a mouse, por ejemplo algo como"trigger", que además proporcione información sobre el origen del disparo. Como eso no está en la especificación actual y si se necesita una solución inmediata, sería mucho más estable y menos hacky enlazar tambiénkeydowny, cuando ocurra unclickjunto con unkeydownen el mismo elemento, considerarlo entrada de teclado.screenXyscreenY, pero sigo preguntándome por quéscreenXdebería devolver coordenadas reales de pantalla en vez de una posición interna del renderer o de la página renderizada, comolayerX,layerY.La necesidad del autor también podría satisfacerse con la posición del renderer, sin filtrar la ubicación de la ventana del navegador a todos los sitios web visitados.
don’tes un typo que hace que el significado sea el opuesto de la intención.En la parte que dice “solo había que cambiar
isInvokedByMouse, que comprobaba siscreenXyscreenYeran mayores que 0, para que comprobara si no eran 0”, me pregunto qué pasa, aunque sea extremadamente raro, si el usuario realmente hace clic con el mouse en la posición 0,0.No estoy familiarizado con JS: ¿la comprobación
!= 0es de verdad la mejor o la única forma? Al releer, parece que la frase que dice que el manejador de eventos también procesakeydown, lo cual lo vuelve complejo y debería refactorizarse más adelante, pero que por ahora esta corrección basta, aborda en cierta medida esta parte.instanceof MouseEvent, pero eso también se siente riesgoso o como un hack.Me pregunto por qué dependen de una heurística así. Puede ser porque
toggleMenuse usa desde varios manejadores de eventos, o puede haber otras circunstancias propias de la base de código. Sin ver el panorama completo es difícil juzgar. La respuesta parece estar aquí: https://news.ycombinator.com/item?id=42174177event.name == 'click'. Entonces no entiendo por qué quieren filtrar algunos eventos de clic normales.Lo he usado antes para elegir qué layout mostrar. Si solo quieres escuchar entrada táctil, puedes hacerlo y luego llamar a
preventDefaulten el evento para evitar que el navegador genere a continuación un eventoclick. O simplemente puedes ahorrarte el trabajo y escribir un manejador de clics.Es muy valioso que la BBC haya encontrado un bug desagradable mientras invertía en accesibilidad. Pero ¿por qué la industria todavía no logra hacer bien un dropdown que se abra de forma consistente para todos los usuarios?
¿La accesibilidad es tan difícil? ¿La BBC debería haber usado un framework web o un componente web que ya se encargara de estas cosas? Como desarrollador full-stack más orientado al backend, me da cierta cautela tocar componentes del navegador. Hay muchos matices en el comportamiento y las implementaciones llevan mucho tiempo probadas. Por ejemplo, crear un cuadro de texto personalizado sin investigar a fondo el comportamiento de los cuadros de texto en cada plataforma parece una receta para fallar. Incluso en sitios de grandes empresas veo seguido que copiar/pegar se rompe y que faltan caracteres. No entiendo por qué en 2024 se rompen los cuadros de texto, y React ya empieza a sentirse arrogante
Personalmente, habría intentado resolverlo con plantillas del lado del servidor, un framework CSS como Bulma y un mínimo de JS. No es adecuado para sitios que exigen un branding personalizado muy pulido, pero los cuadros de texto funcionan bien y el costo de desarrollo no es excesivo. No estoy seguro de si cumpliría con el estándar de accesibilidad de la BBC
Un ejemplo real son los modales. Si no tienes discapacidad visual, puedes ver una caja blanca flotando con componentes de UI adentro, encima de un área gris de “no tocar”. Si usas un lector de pantalla, no hay garantía de que recibas esa información. Cuando te mueves con Tab por los elementos de la UI y vuelves a la parte superior de la caja, ¿cierto lector de pantalla te lo indicará? ¿Listará los elementos interactivos disponibles? ¿Los listará en el mismo orden que otros lectores de pantalla? ¿Y en un teléfono, o en una Mac? ¿El lector de pantalla y el navegador reportarán correctamente los elementos de entrada, o permitirán silenciosamente que el usuario salga del modal y vuelva al resto del sitio?
En accesibilidad no puedes confiar en que el sistema operativo, el navegador y el lector de pantalla cooperen o se comporten de manera razonable en la situación correcta. En 2019 tuve que reportar un bug en VoiceOver + Safari donde un margin CSS negativo hacía que el lector de pantalla leyera un bloque de texto RTL en orden invertido. Visualmente se veía como
9/10/2019, pero en el lector de pantalla sonaba como “ten slash nine slash two-thousand-and-nineteen”, y como solución temporal tuve que marcar el texto comoaria-hiddeny agregar una etiquetapinvisible con el orden correcto. Por eso, cuando ves código raro relacionado con accesibilidad, a veces de verdad no hay una mejor forma. Incluso si das vuelta por completo la base de código y pones la accesibilidad como prioridad máxima, puede romperse de forma difícil de entender en cuanto se actualiza JAWS o VoiceOverEn general está bien, pero los archivos
reset.cssexisten por una razón, y aquí parece posible que hayan usado un enfoque más extremo para intentar evitar por completo ese tipo de problemas. Estoy tratando de inferir sus decisionesEsto parece un bug autoinfligido derivado de una heurística equivocada. Se asumió que valores positivos de
screenX/Ysignificaban un evento de mouse, y la falta de rastreo/logs hizo que la investigación fuera más complicadaEn vez de revisar
pointerType, que es la propiedad más apropiada sugerida por otros comentarios, me sorprende un poco que la solución del autor sea agregar más heurísticas frágiles. Es como si, a partir de las dos pistas finales, hubiera concluido que al revisar las coordenadasscreenXyscreenYhay que comprobar no solo valores positivos, sino también negativospointerId === -1y luego hacer fallback ascreenX === 0Hace unos 4 años, cuando este código se escribió por primera vez, no todos los navegadores usaban PointerEvent para
clickPara empezar, no entiendo por qué un sitio web puede obtener la posición del mouse en el sistema de coordenadas de la pantalla
window.screenX/window.screenYy que la ubicación de un clic también pueda reportarse en ese sistema de coordenadas suena absurdo en escritorioTOR Browser parece camuflar
screenXyscreenYpara evitar fingerprinting. Me pregunto si alguien ha visto un buen caso de uso para esta función. Se me ocurren cosas como una aplicación de doble ventana con ventanas que interactúan entre sí, o un sitio cuyo comportamiento cambia según su posición en una pantalla virtualEjemplo: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
No entiendo por qué revisan coordenadas en vez de revisar
event.type. Aun así, el artículo en sí es un buen rompecabezas, y me resulta familiar verme frente a código que no escribí preguntándome “¿por qué importa que las coordenadas del clic no sean 0?”, “¿no bastaría con verificar queevent.targetsea el botón que se intenta activar?”, “¿por qué usar JavaScript si se puede hacer lo mismo con las etiquetasdetails/summary?”Para empezar, ¿por qué filtrar por coordenadas de pantalla? ¿Qué pasa si el usuario usa un dispositivo de entrada alternativo que no tiene pantalla?
El evento
clickpor sí solo ya es una señal suficiente de que el usuario intentó activar el menú. No entiendo por qué reinventan la ruedaisInvokedByMouseverificaba si las coordenadasscreenXoscreenYeran positivas para determinar si el eventoclickfue invocado por un mouse o puntero táctil, y no por el tecladoIntentaban detectar si la activación venía del teclado o del mouse, y el autor asumió que las coordenadas de pantalla de un evento de mouse siempre serían positivas
Publiqué otra entrada en el blog para explicar el contexto que la gente quería entender y responder preguntas. Expliqué por qué al principio comprobé
screenX === 0, por qué quería comportamientos distintos según la entrada fuera de teclado o de mouse, y cómo refactoricé para evitar más incidentes.Espero que sirva: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...
¿Cuál sería la forma correcta de comprobar si fue un clic de mouse o un clic de teclado? Creo que me darían ganas de establecer una bandera a nivel de módulo según el evento más reciente: si
mousedownfue más reciente,isKeyboard=false,isMouse=true; sikeydownfue más reciente, al revés.Entonces ya no harían falta las funciones
isInvokedByMouseeisInvokedByKeyboard. ¿Hay una forma mejor? Depender de las coordenadas de pantalla para esto me parece muy sospechoso y un hack.event.detail[1] es 0 en los “clics” de teclado y 1 en los clics de puntero.1: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/det...
Muy interesante, pero no entiendo por qué el navegador reporta coordenadas distintas según el monitor. Pensaba que el navegador trataba la página web como si ocupara toda la pantalla, sin importar en qué display estuviera.
¿Hay alguna razón para que una API web tenga esta información? Parece un riesgo de seguridad, filtración de información y rastreo.
¿No es un problema de capacidad de desarrollo? Deberían haber usado coordenadas del viewport, no coordenadas de pantalla, y leerlas con
.clientXy.clientY. No entiendo por qué sería un bug que haya valores negativos en el espacio de la pantalla.https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...