Diário
Engenharia3 min de leitura

O menu que se recusava a ser clicado

Um menu desenhava-se na perfeição, ficava exatamente onde devia, e engolia todos os cliques. O culpado era uma animação de entrada que nunca acabava mesmo. Uma visita curta aos contextos de empilhamento em CSS, aos modos de preenchimento de animação, e a como caçar erros que não se veem.

AEA equipa de engenharia do Bookatu

Lançámos um menu que não podia ser clicado. Abria bem. Desenhava-se no sítio certo, totalmente visível, com o z-index correto e sem erros na consola. Os cliques atravessavam-no e iam parar ao painel de baixo, como se o menu fosse um fantasma. Se alguma vez lutou com um erro destes, já conhece a parte desnorteante: está tudo com bom aspeto, e nada funciona.

Descobrir quem está mesmo por cima

A primeira resposta honesta veio do navegador, não de ler código. O document.elementFromPoint diz-lhe que elemento recebe mesmo um clique numa coordenada. Apontámo-lo para o centro de um item do menu, e devolveu um cartão que ficava mais à frente na página. O menu estava visualmente por cima e fisicamente por baixo.

js
// Who really gets the click at this point?const item = document.querySelector('[role="menuitem"]');const r = item.getBoundingClientRect();const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);console.log(top === item || item.contains(top)); // false -> something covers it // Walk the ancestors and list everything that creates a stacking contextfor (let el = item; el; el = el.parentElement) {  const cs = getComputedStyle(el);  const reasons = [];  if (cs.transform !== 'none') reasons.push('transform');  if (cs.filter !== 'none') reasons.push('filter');  if (cs.backdropFilter !== 'none') reasons.push('backdrop-filter');  if (cs.opacity !== '1') reasons.push('opacity');  if (cs.position !== 'static' && cs.zIndex !== 'auto') reasons.push('z-index');  if (reasons.length) console.log(el.className, reasons);}

A travessia encontrou-o. O cabeçalho que continha o menu reportava um transform, apesar de nenhuma folha de estilos lho ter dado. O único transform em toda a volta era uma animação de entrada, um pequeno aparecer e subir que tinha terminado meio segundo depois de a página carregar.

Os modos de preenchimento sobrevivem às animações

animation-fill-mode: both quer dizer que o elemento mantém o primeiro keyframe antes de a animação começar e fica com o último keyframe depois de ela acabar. Fica, para sempre. Se algum keyframe animar transform, a animação terminada continua a aplicar um transform, e um elemento com transform passa a ser um contexto de empilhamento. O z-index de todos os descendentes fica agora preso lá dentro, a competir só com os irmãos locais, nunca com o resto da página.

A nossa página tinha painéis a seguir ao cabeçalho que usavam backdrop-filter, que cria o seu próprio contexto de empilhamento. Duas caixas seladas, e a ordem de pintura entre elas decidida pela ordem na árvore e não pelo z-index que íamos subindo à toa. O menu podia ter z-index de um milhão. Dentro da caixa dele, ninguém lá fora o ouvia.

E aqui está a armadilha dentro da armadilha. Mudámos o último keyframe para transform: none, a raciocinar que o valor retido ficaria então inofensivo. O estilo calculado continuava a reportar uma matriz. Os navegadores resolvem o transform de uma animação aplicada como um valor de matriz, e a matriz identidade continua a contar como um transform para efeitos de empilhamento. O contexto de empilhamento sobreviveu à nossa correção.

css
/* BAD: the finished animation keeps applying its last keyframe forever.   The header remains a stacking context for the life of the page. */.enter {  animation: rise 0.5s ease-out both;} /* GOOD: fill backwards only. The element returns to its natural state   after the animation, which is identical to the final keyframe anyway. */.enter {  animation: rise 0.5s ease-out backwards;} @keyframes rise {  from { opacity: 0; transform: translateY(10px); }  to   { opacity: 1; transform: none; }}

A regra que escrevemos

As animações de entrada em contentores preenchem para trás, nunca em ambos os sentidos. O preenchimento para trás cobre a única lacuna que interessa, os frames antes de uma animação com atraso começar. Assim que a animação acaba, o elemento desliga-se dela por completo e deixa de ser um contexto de empilhamento. O resultado visual é idêntico, porque uma entrada bem feita acaba no estado natural do elemento.

Um menu a abrir corretamente por cima de painéis de vidro fosco num painel escuro
Depois da correção: o menu pinta por cima dos painéis foscos e, mais a propósito, apanha os próprios cliques.
  • Confie mais no elementFromPoint do que nos seus olhos. A ordem visual e a ordem de deteção de cliques podem discordar, e só uma delas fica com o clique.
  • Quando o z-index deixa de fazer sentido, pare de o aumentar e comece a caçar contextos de empilhamento. A travessia dos antepassados acima demora um minuto.
  • Audite o que as suas animações terminadas deixam para trás. O getComputedStyle numa página parada diz-lhe o que continua aplicado.
  • Transforms preenchidos são transforms, mesmo quando o keyframe diz none.

A animação durava meio segundo. O efeito secundário dela não tinha hora de fim nenhuma.

contexto de empilhamento cssanimation fill modez-index não funcionadepuração de frontendtransform e empilhamento em css
Pronto para pôr isto em prática?

O Bookatu dá-lhe um site de marcações com a sua marca, sinais, subscrições, vales-presente e lembretes, com 0% de comissão nas suas marcações.

Começar grátis