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 depreventDefault(). 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 chamamospreventDefault(), 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
useRefguardaria 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()venceupreventDefault()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.
