Pular para o conteúdo principal
engenhariareactjavascript
A Roda do Mouse Está Editando Silenciosamente Seus Campos Numéricos

A Roda do Mouse Está Editando Silenciosamente Seus Campos Numéricos

A maioria dos bugs de UI é cosmética. Alguns deles mudam números que o usuário não pretendia mudar, e se esses números são alavancagem, tamanho de ordem ou um preço, o bug deixa de ser cosmético.

Estes são dois que enfrentamos, ambos causados por comportamentos do navegador e de bibliotecas que são tecnicamente documentados e praticamente invisíveis. As correções são pequenas. Encontrá-las não foi.

Pontos-chave

  • Um <input type="number"> em foco incrementa ou decrementa quando você rola a roda sobre ele. Em um campo de dinheiro, isso reescreve o valor silenciosamente.
  • Corrija com blur(), não com preventDefault(): tirar o foco cancela o incremento e deixa a rolagem da página e do modal completamente intacta.
  • Um popover do Radix movido por portal para document.body não consegue manter o foco dentro de um dialog do Radix, porque o escopo de foco do dialog puxa o foco de volta para fora. O campo parece vivo e engole cada tecla digitada.
  • Passe o próprio elemento do dialog como container de portal do popover para que ele faça parte da subárvore com foco preso.
  • Essa correção invalida handlers de clique-fora feitos à mão: uma vez que o conteúdo é movido por portal, ele deixa de ser um descendente no DOM do wrapper contra o qual você media.

1. A roda é um gesto de edição, e ninguém avisou o usuário

Inputs numéricos nativos têm um comportamento que remonta à especificação: quando o campo está em foco, rolar a roda do mouse sobre ele altera o valor para cima ou para baixo conforme o atributo step.

Em um seletor de quantidade de um carrinho de compras, isso é um aborrecimento leve. Nos nossos formulários é algo completamente diferente, porque os campos em questão são alavancagem, valor de trading, porcentagem de participação no lucro e preço de listagem. O usuário clica em um campo, rola a página para ler o resto do formulário, e o número que ele já tinha definido mudou. Nada anuncia isso. O formulário ainda valida. O bot inicia com um valor que o usuário nunca escolheu.

A correção ingênua é anexar um handler de wheel em cada input e chamar preventDefault(). Isso funciona, e também quebra a rolagem: o usuário não consegue mais rolar a página enquanto o cursor estiver por acaso sobre um campo numérico, o que parece quebrado de uma forma completamente diferente.

A correção melhor usa a pré-condição da especificação. O incremento só acontece quando o campo está em foco, então remover o foco é suficiente:

const onWheel = (e) => {
  const active = document.activeElement;
  if (
    active instanceof HTMLInputElement &&
    active.type === "number" &&
    (active === e.target || active.contains(e.target))
  ) {
    active.blur();   // kills the increment, leaves scrolling intact
  }
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });

Três escolhas deliberadas nesse trecho:

  • blur() em vez de preventDefault(). Não estamos cancelando a rolagem, estamos removendo a condição que a torna uma edição. A página continua rolando normalmente.
  • capture: true. O listener observa o evento no caminho de descida, então ele ainda dispara mesmo se algum componente interno interromper a propagação.
  • passive: true. Seguro justamente porque nunca chamamos preventDefault(), e mantém o listener fora do caminho crítico da rolagem.

Um único listener global, montado uma vez, cobre todos os inputs numéricos do app, inclusive os que ainda não existem. A alternativa, remendar cada campo, falha no momento em que alguém adiciona um novo formulário e esquece.

2. Um popover dentro de um dialog no qual não dá para digitar

O segundo se apresentou como um relatório de bug impossível: uma caixa de busca dentro de um dropdown, dentro de um modal, que visivelmente ganhava foco e depois ignorava tudo o que era digitado nela.

A causa são dois comportamentos corretos colidindo.

Os popovers do Radix movem seu conteúdo por portal para document.body por padrão, o que é correto em uma página normal, porque escapa de contextos de overflow e de empilhamento. Os dialogs do Radix prendem o foco dentro de sua própria subárvore, o que também é correto, e é o que faz um modal ser um modal.

Junte os dois e o conteúdo do popover fica, em termos de DOM, fora do dialog. O escopo de foco do dialog verifica cada evento de foco que chega mais ou menos assim:

container.contains(target) ? allow : focus(lastFocusedElement)

A caixa de busca não está contida no dialog, então todo evento de foco que caía nela era imediatamente desfeito. O campo renderizava, mostrava um cursor por um instante e perdia o foco antes que uma tecla pudesse ser registrada. Parecia vivo e engolia tudo.

A correção é parar de mentir sobre onde o popover mora. O portal do Radix aceita um container, então renderizamos uma div vazia, de altura zero, dentro do dialog e movemos o popover por portal para dentro dela:

// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>

Dois detalhes fáceis de errar:

  • Estado, não uma ref. O nó host não existe na primeira renderização, e o portal precisa de uma nova renderização assim que ele passa a existir. Um useRef guardaria o nó, mas nunca dispararia essa renderização.
  • O host não pode afetar o layout. Ele é vazio e de altura zero, então pode ficar em qualquer lugar conveniente na marcação do dialog sem deslocar nada.

3. A correção que quebra silenciosamente o código ao lado

Esta é a parte que eu mais destacaria, porque é onde uma correção limpa vira uma regressão.

Antes de migrar para um popover movido por portal, o dropdown tinha um handler de clique-fora feito à mão: manter uma ref no wrapper, ouvir os cliques no document, fechar se o clique não fosse dentro.

Uma vez que o conteúdo é movido por portal, ele deixa de ser um descendente no DOM daquele wrapper, mesmo que esteja visualmente dentro dele. Então o handler agora enxerga cliques na própria caixa de busca do dropdown como cliques fora, e fecha justamente aquilo que o usuário está tentando usar.

Remover esse handler não foi limpeza, foi parte da correção. A regra geral:

Mover algo por portal quebra toda suposição baseada em contenção no DOM. Handlers de clique-fora, buscas com closest(), delegação de eventos e seletores CSS de descendentes, todos mudam de significado silenciosamente. Quando você move algo por portal, vá procurar o código que assumia contenção.

O que esses dois têm em comum

Os dois bugs ficam na lacuna entre "a biblioteca está se comportando corretamente" e "o usuário está passando por um mau momento". Nada lançou exceção. Nada foi para o log. Nos dois casos o componente parecia certo em uma captura de tela.

As lições gerais que eu guardaria:

  • Saiba quais gestos a plataforma trata como edições. A roda sobre um input numérico em foco é um deles; o colar com o clique do meio no Linux é outro. Se um campo guarda dinheiro, audite-os.
  • Prefira remover uma pré-condição a cancelar um evento. blur() venceu preventDefault() porque atacou a causa em vez de suprimir o sintoma, e deixou intocado o comportamento não relacionado.
  • Resolva de uma vez, globalmente, quando o modo de falha é "alguém esquece". Uma correção por campo é uma correção com data de validade.
  • Quando você move algo por portal, audite suposições de contenção. A árvore do DOM e a árvore visual param de concordar, e qualquer código que as confundia agora está errado.

Leitura Relacionada

Crie uma conta

Coloque seu conhecimento em prática e abra uma conta Freya hoje.

Registre-se agora
Este conteúdo é apenas para fins educacionais e não constitui aconselhamento financeiro. O trading de criptomoedas envolve riscos significativos. Nunca invista mais do que você pode se dar ao luxo de perder.