- O botão More do site da BBC UK falhava ao processar cliques apenas em certos ambientes de trabalho em casa, e o que parecia um bug comum de UI era na verdade um problema de sistema de coordenadas em múltiplos monitores
- Quando um monitor externo ficava acima ou à esquerda do monitor principal, Chrome e Firefox podiam gerar valores negativos para
screenX e screenY em eventos de click
- O código existente identificava cliques de ponteiro com
event.screenX > 0 || event.screenY > 0, então cliques com coordenadas negativas não eram reconhecidos como cliques de mouse
- A correção foi simples: em vez de verificar se
screenX e screenY eram maiores que 0, passou a verificar se eram diferentes de 0, ficando no formato event.type === 'click' && (event.screenX!== 0 || event.screenY!== 0)
- Mesmo após testes unitários, Puppeteer, testes manuais e testes com tecnologias assistivas, esse tipo de bug pode permanecer por causa da ambiguidade da especificação UI Events e de suposições sobre coordenadas em múltiplos monitores
Bug de navegação da BBC reproduzido só em um ambiente específico
- A barra de navegação do site da BBC UK abre um menu quando o usuário ativa o botão More
- Esse botão usa o evento
click, que pode ser disparado não só pelo mouse, mas também por toque e pelas teclas Enter e Space
- Um integrante da equipe só enfrentava o problema ao usar o notebook de trabalho em casa; com o mesmo notebook no escritório, tudo funcionava normalmente
- Mesmo em casa, a falha só acontecia quando a janela do navegador estava no monitor externo; na tela do notebook, o botão funcionava normalmente
- Quando o problema ocorria, o handler em JavaScript não abria o menu e o comportamento que entrava em ação era o fallback sem JavaScript
- No Safari, o mesmo problema não aparecia
A condição de reprodução era a posição do monitor
- A equipe foi reduzindo as hipóteses para descobrir qual elemento do ambiente doméstico provocava o problema
- O monitor externo estava posicionado acima da tela do notebook, e ao mudar essa disposição nas configurações do sistema operacional o problema parava de acontecer
- Outro integrante da equipe também conseguiu reproduzir o bug ao ajustar a disposição dos monitores no sistema operacional da mesma forma
- No início da investigação, duas condições já tinham ficado claras
- No Safari, o problema não acontecia
- O problema ocorria quando o monitor externo estava acima e à esquerda do monitor principal
Coordenadas negativas em screenX e screenY
- Ao inspecionar com
console.log o evento click do botão More, os valores de screenX e screenY apareciam como negativos no Chrome e no Firefox
- O evento
click é um tipo de PointerEvent independentemente da forma de entrada que o gerou, então o objeto do evento inclui informações sobre o mouse ou o ponteiro de toque que originou o clique
screenX e screenY representam, em pixels, as coordenadas do ponto clicado na tela
- A DOM UI Events spec não parecia trazer informação específica sobre a possibilidade de esses atributos serem negativos
- A diferença entre Safari e Chrome/Firefox mostra que a forma de representar coordenadas de tela pode variar entre navegadores em configurações com múltiplos monitores
- Esse problema de interoperabilidade foi reportado à equipe do WebKit
Diferenças entre navegadores na forma de coordenadas em múltiplos monitores
- Em uma configuração com múltiplos monitores, o sistema de coordenadas de tela do navegador trata vários monitores como se fossem uma única tela grande
- Com dois monitores de 800px lado a lado, por exemplo, o intervalo do eixo x pode ir de 0 a 1600
- No Safari, o intervalo de coordenadas aparentemente sempre começa no monitor mais à esquerda e mais acima, ficando em um intervalo positivo
- No Chrome e no Firefox, as coordenadas parecem ser calculadas com base no monitor principal, então telas que ficam acima ou à esquerda dele podem gerar coordenadas negativas
- Neste bug, o problema só acontecia quando
screenX e screenY eram negativos
O código real do problema e a correção
- No código problemático,
isInvokedByMouse tentava verificar se o evento click tinha sido disparado por mouse ou por ponteiro de toque checando se screenX e screenY eram positivos
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...
}
};
- Esse código partia da suposição de que
screenX e screenY seriam positivos em eventos de click disparados por ponteiro
- Quando o usuário clicava no botão
More em um monitor com coordenadas negativas de tela, o handler não reconhecia aquilo como clique e deixava a execução cair no comportamento padrão do link More
- A correção foi trocar a checagem de maior que 0 por uma verificação de que os valores eram diferentes de 0
const isInvokedByMouse = event =>
event.type === 'click' && (event.screenX !== 0 || event.screenY !== 0);
- Com essa mudança, usuários com disposições incomuns de múltiplos monitores também puderam usar a barra de navegação do site da BBC
Problemas de design restantes e refatoração posterior
- Embora a correção em si tenha sido simples, ainda restavam partes estranhas no código
- Não havia necessidade de verificar se o
click tinha vindo do mouse ou do teclado, e o handler tratava até eventos keydown, o que aumentava a complexidade
- É preciso ter cuidado com as suposições feitas sobre o comportamento de uma API, e a falta de clareza da especificação sobre a possibilidade de valores negativos em
screenX e screenY também ajudou a esconder o problema
- O código havia passado por testes unitários, testes com Puppeteer e testes manuais em vários navegadores, dispositivos e ferramentas de tecnologia assistiva, mas o bug não foi detectado
- Em uma atualização de 19 de novembro de 2024, foi informado que o componente de navegação acabou sendo refatorado depois, e o handler de eventos do botão
menu também mudou bastante
- O texto de acompanhamento explica a refatoração e responde a perguntas frequentes: How I refactored the BBC navigation bar and a follow-up FAQ
1 comentários
Comentários do Hacker News
Para complementar para quem não clicou até o relatório de bug do WebKit: um desenvolvedor do WebKit perguntou à BBC por que seria útil conseguir detectar se o evento veio do teclado, e o autor respondeu que a interoperabilidade era necessária por causa de casos de uso relacionados a acessibilidade.
O botão de menu da barra de navegação do site britânico da BBC se comporta de forma ligeiramente diferente quando é aberto com um ponteiro e quando é aberto pelo teclado. O evento de clique sempre abre o menu, mas, quando aberto pelo ponteiro, o foco vai para o contêiner do menu; quando aberto pelo teclado, o foco vai para o primeiro link do menu sem a animação de abertura. O evento
clické independente do dispositivo, o que é bom para criar uma experiência de usuário com teclado, e no teclado ele só é invocado por Space ou Enter. Se usarkeydown, é preciso verificar manualmente se foi Space/Enter.Fonte: https://bugs.webkit.org/show_bug.cgi?id=281430
const isInvokedByMouse = event => event.screenX > 0 || event.screenY > 0;econst isInvokedByKeyboard = event => isEnterKey(event) || isSpaceKey(event);; superficialmente, parece que ele tenta classificar o evento como vindo ou do mouse ou do teclado.Na prática, surgem quatro categorias: é mouse e não é teclado, é teclado e não é mouse, é ambos, não é nenhum dos dois. Como no bug original, “não é nenhum dos dois” é tratado de forma inadequada, e também fico em dúvida se “é ambos” funciona corretamente. O código deveria lidar deliberadamente com o fato de “ser teclado” e “ser mouse” serem booleanos separados, ou ser estruturado para que
eventSourceretorne categorias mutuamente exclusivas, como"keyboard","mouse"e"not sure".É melhor seguir o comportamento padrão e projetar um componente que funcione nos dois casos de uso. Em acessibilidade, não se deve tentar ser esperto demais. No fim, virou uma solução quase um hack, e esse tipo de abordagem inevitavelmente quebra ou causa efeitos colaterais. O motivo de haver poucos bons ganchos para tratar as coisas de forma diferente no contexto de acessibilidade é que, para começo de conversa, essa não é uma área pensada para ser tratada de forma diferente.
Ao mesmo tempo, também quer a conveniência de se vincular a apenas um evento.
clickpermite isso, mas não há como saber se o evento foi gerado por um clique do mouse ou por entrada de teclado; então, no Chrome, se a posição do mouse éscreenX=0,screenY=0, eles usam uma heurística instável que considera isso um clique na origem ou um acionamento pelo teclado. Como alguém que já trabalhou em projetos de acessibilidade, acho uma ideia bem ruim e, se eu visse isso em um PR, pediria para reescrever. Seria ideal que os navegadores tivessem o mesmo comportamento, mas o problema real parece ser que, em umclickgerado pelo teclado,screenXescreenYtêm pouquíssimo significado.Idealmente, não se deveria emitir um
MouseEvent; deveria haver um evento mais genérico, aplicável tanto a teclado quanto a mouse, por exemplo algo como"trigger", que fornecesse a informação da origem do acionamento. Como isso não existe na especificação atual e é preciso uma solução agora, vincular também akeydowne, quando ocorrerclickjunto comkeydownno mesmo elemento, tratar como entrada de teclado seria muito mais estável e menos hacky.screenXescreenY, mas ainda me pergunto por quescreenXprecisa retornar coordenadas reais da tela, em vez de uma posição interna do renderizador ou da página renderizada, comolayerX,layerY.A necessidade do autor também poderia ser atendida com a posição no renderizador, sem vazar a posição da janela do navegador para todos os sites visitados.
don’té um erro de digitação que deixa o sentido oposto ao pretendido.No trecho “bastava mudar
isInvokedByMouse, que verificava sescreenXescreenYeram maiores que 0, para verificar se eram diferentes de 0”, fico curioso sobre o que aconteceria se, embora extremamente raro, o usuário realmente clicasse com o mouse na posição 0,0.Não sou familiarizado com JS; verificar
!= 0é mesmo a melhor ou a única forma? Relendo, a frase dizendo que o manipulador de eventos também lida comkeydown, então é complicado e precisará de mais refatoração depois, mas que por enquanto essa correção basta, parece tratar um pouco desse ponto.instanceof MouseEvent, mas isso também parece arriscado ou hacky.Fico me perguntando por que dependem dessa heurística. Talvez seja porque
toggleMenué usado por vários manipuladores de eventos, ou talvez haja outras circunstâncias específicas da base de código. Sem ver o quadro completo, é difícil julgar. A resposta parece estar aqui: https://news.ycombinator.com/item?id=42174177event.name == 'click'. Então não entendo por que tentariam filtrar alguns eventos de clique legítimos.Já usei isso antes para escolher qual layout mostrar. Se você quiser ouvir apenas entrada por toque, pode fazer isso e depois chamar
preventDefaultno evento para impedir que o navegador crie em seguida um eventoclick. Ou então simplesmente poupar o trabalho e escrever um manipulador de clique.É digno de reconhecimento que a BBC, ao investir em acessibilidade, tenha encontrado um bug desagradável. Mas por que a indústria ainda não consegue fazer direito um dropdown que abra de forma consistente para todos os usuários?
Acessibilidade é tão difícil assim? A BBC deveria ter usado um framework web ou web component que já lidasse com esse tipo de coisa? Como desenvolvedor full-stack mais focado em backend, mexer em componentes de navegador me deixa cauteloso. Há muitas sutilezas no comportamento, e as implementações foram testadas por muito tempo. Por exemplo, criar uma caixa de texto customizada sem investigar a fundo o comportamento das caixas de texto em cada plataforma parece uma receita para dar errado. Mesmo em sites de grandes empresas, vejo com frequência copiar/colar quebrado e caracteres desaparecendo. Não entendo por que caixas de texto quebram em 2024, e hoje o React me parece arrogante
Pessoalmente, eu teria tentado resolver com templates no lado do servidor, um framework CSS como Bulma e o mínimo de JS. Não é adequado para sites que exigem branding customizado superpolido, mas as caixas de texto funcionam bem e o custo de desenvolvimento não fica excessivo. Não tenho certeza se isso atenderia aos padrões de acessibilidade da BBC
Um exemplo real são os modais. Se você não tem deficiência visual, vê uma caixa branca com componentes de UI dentro, sobre uma área cinza de “não mexa”. Ao usar um leitor de tela, não há garantia de que você receba essa informação. Quando você navega pelos elementos da UI com Tab e volta ao topo da caixa, será que determinado leitor de tela informa isso? Ele lista os elementos interativos disponíveis? Lista na mesma ordem que outros leitores de tela? E no celular, no Mac? O leitor de tela e o navegador reportam corretamente os elementos de entrada, ou permitem silenciosamente que o usuário escape para fora do modal e volte ao restante do site?
Em acessibilidade, não dá para confiar que sistema operacional, navegador e leitor de tela vão cooperar ou agir de forma razoável nas situações apropriadas. Em 2019, tive de reportar um bug no VoiceOver + Safari em que uma margin CSS negativa fazia o leitor de tela ler um bloco de texto RTL com a ordem invertida. Visualmente aparecia como
9/10/2019, mas no leitor de tela soava como “ten slash nine slash two-thousand-and-nineteen”; como gambiarra, tive de marcar o texto comoaria-hiddene inserir uma tagpinvisível com a ordem correta. Então, quando você vê código estranho relacionado a acessibilidade, às vezes realmente não há uma maneira melhor. Mesmo que você vire a base de código inteira de cabeça para baixo e coloque acessibilidade como prioridade máxima, ela pode quebrar de forma difícil de entender no momento em que o JAWS ou o VoiceOver forem atualizadosEm geral é aceitável, mas arquivos
reset.cssexistem por um motivo, e aqui parece possível que tenham usado uma abordagem mais extrema para contornar completamente esse tipo de problema. Estou tentando inferir as decisões delesIsso parece um bug autoinfligido, resultado de uma heurística errada. Assumiram que valores positivos de
screenX/Yindicavam um evento de mouse, e a falta de rastreamento/logging tornou a investigação ainda mais complicadaEm vez de verificar
pointerType, uma propriedade mais adequada sugerida por outros comentários, fico um pouco surpreso que a solução do autor tenha sido acrescentar ainda mais heurísticas instáveis. É como concluir, a partir das duas pistas finais, que ao verificar as coordenadasscreenXescreenYera preciso checar não só valores positivos, mas também negativospointerId === -1e depois fazer fallback parascreenX === 0Cerca de 4 anos atrás, quando esse código foi escrito pela primeira vez, nem todos os navegadores usavam PointerEvent em
clickPara começo de conversa, não entendo por que um site consegue obter a posição do mouse no sistema de coordenadas da tela
window.screenX/window.screenYe de a posição do clique também poder ser reportada nesse sistema de coordenadas soa sem sentido no desktopO TOR Browser parece mascarar
screenXescreenYpara evitar fingerprinting. Fico curioso se alguém já viu um bom caso de uso para esse recurso. Só me vêm à mente uma aplicação com duas janelas interagindo entre si, ou um site cujo comportamento muda dependendo da posição na tela virtualEx.: https://youtu.be/3al8prbfK5o?si=loNtyqIfMFkppm5V
Não entendo por que verificar as coordenadas em vez de
event.type. Ainda assim, o texto em si é um bom quebra-cabeça, e me identifico com a situação de olhar para código que não escrevi e perguntar “por que é importante que as coordenadas do clique não sejam 0?”, “não bastaria verificar seevent.targeté o botão que se quer ativar?”, “por que usar JavaScript se dá para fazer a mesma coisa com as tagsdetails/summary?”Para começo de conversa, por que filtrar por coordenadas de tela? O que acontece se o usuário usa um dispositivo de entrada alternativo sem tela?
O evento
clickpor si só já é um sinal suficiente de que o usuário tentou ativar o menu. Não entendo por que reinventar a rodaisInvokedByMouseverificava se as coordenadasscreenXouscreenYeram positivas para determinar se o eventoclicktinha sido chamado por um mouse ou ponteiro de toque, e não pelo tecladoEles tentaram detectar se a ativação era por teclado ou por mouse, e o autor presumiu que as coordenadas de tela de um evento de mouse seriam sempre positivas
Publiquei mais um post no blog para explicar o contexto que as pessoas queriam entender e responder às perguntas. Expliquei por que eu verificava
screenX === 0desde o começo, por que queria comportamentos diferentes dependendo da entrada por teclado ou mouse, e como refatorei para evitar novos incidentesEspero que ajude: https://www.joshtumath.uk/posts/2024-11-18-how-i-refactored-...
Qual seria a forma correta de verificar se foi um clique do mouse ou do teclado? Acho que eu ficaria tentado a definir uma flag em nível de módulo com base no evento mais recente: se
mousedownfoi mais recente,isKeyboard=false,isMouse=true; sekeydownfoi mais recente, o inversoAssim, as funções
isInvokedByMouseeisInvokedByKeyboardnão seriam necessárias. Existe uma forma melhor? Depender de coordenadas da tela para isso me parece muito suspeito e uma gambiarraevent.detail[1] é 0 para “cliques” de teclado e 1 para cliques de ponteiro1: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/det...
Muito interessante, mas não entendo por que o navegador reporta coordenadas diferentes dependendo do monitor. Eu achava que o navegador tratava a página web como se ela estivesse em tela cheia, independentemente de qual display estivesse usando
Há algum motivo para uma API Web ter esse tipo de informação? Parece um risco de segurança, vazamento de informações e rastreamento
Não é uma questão de competência de desenvolvimento? Deveriam ter usado coordenadas do viewport, não coordenadas da tela, e lido isso com
.clientXe.clientY. Não entendo por que valores negativos no espaço da tela seriam um bughttps://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_view/...