Il menu a tendina che si rifiutava di essere cliccato
Un menu veniva disegnato alla perfezione, stava esattamente dove doveva e si mangiava ogni clic. Il colpevole era un'animazione di ingresso che non finiva mai davvero. Un breve giro tra i contesti di impilamento CSS, le modalità di riempimento delle animazioni e come si dà la caccia ai bug che non si vedono.
Abbiamo rilasciato un menu a tendina che non si poteva cliccare. Si apriva bene. Veniva disegnato nel posto giusto, del tutto visibile, con lo z-index corretto e nessun errore in console. I clic gli passavano attraverso e finivano nel pannello sottostante, come se il menu fosse un fantasma. Se hai mai combattuto con un bug così, sai già qual è la parte che disorienta: sembra tutto giusto, e non funziona niente.
Scoprire chi sta davvero sopra
La prima risposta onesta è arrivata dal browser, non dalla lettura del codice. document.elementFromPoint dice quale elemento riceve davvero un clic a una certa coordinata. Gliel'abbiamo puntato al centro di una voce del menu, e ha restituito una card che stava più avanti nella pagina. Il menu era visivamente sopra e fisicamente sotto.
// 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);}La risalita degli antenati l'ha trovato. L'intestazione che conteneva il menu riportava un transform, anche se nessun foglio di stile gliene dava uno. L'unico transform nei paraggi era un'animazione di ingresso, una piccola dissolvenza verso l'alto finita mezzo secondo dopo il caricamento della pagina.
Le modalità di riempimento sopravvivono alle loro animazioni
animation-fill-mode: both vuol dire che l'elemento tiene il primo fotogramma prima che l'animazione parta e mantiene l'ultimo dopo che è finita. Lo mantiene, per sempre. Se un fotogramma qualsiasi anima transform, l'animazione conclusa applica comunque un transform, e un elemento con transform diventa un contesto di impilamento. Lo z-index di ogni suo discendente resta intrappolato lì dentro, in competizione solo con i fratelli locali e mai con il resto della pagina.
Nella nostra pagina, dopo l'intestazione c'erano pannelli che usavano backdrop-filter, che crea un contesto di impilamento proprio. Due scatole sigillate, e l'ordine di disegno tra loro deciso dall'ordine nell'albero, non dallo z-index che continuavamo ad alzare confusi. Il menu poteva anche avere z-index un milione. Dentro alla sua scatola, fuori non lo sentiva nessuno.
Ed ecco la trappola dentro la trappola. Abbiamo cambiato l'ultimo fotogramma in transform: none, ragionando che il valore mantenuto sarebbe stato innocuo. Lo stile calcolato riportava comunque una matrice. I browser risolvono il transform di un'animazione applicata come valore matrice, e la matrice identità conta comunque come un transform ai fini dell'impilamento. Il contesto di impilamento è sopravvissuto alla nostra correzione.
/* 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; }}La regola che ci siamo scritti
Le animazioni di ingresso sui contenitori riempiono backwards, mai both. Backwards copre l'unico buco che conta, i fotogrammi prima che parta un'animazione ritardata. Quando l'animazione finisce, l'elemento se ne stacca del tutto e smette di essere un contesto di impilamento. Il risultato visivo è identico, perché un ingresso fatto bene finisce sullo stato naturale dell'elemento.

- Fidati di elementFromPoint più che dei tuoi occhi. L'ordine visivo e l'ordine con cui si intercettano i clic possono non coincidere, e solo uno dei due si prende il clic.
- Quando lo z-index smette di avere senso, smetti di alzarlo e mettiti a cercare i contesti di impilamento. La risalita degli antenati qui sopra richiede un minuto.
- Controlla cosa lasciano dietro di sé le animazioni concluse. getComputedStyle su una pagina ferma ti dice cosa è ancora applicato.
- Un transform mantenuto è un transform, anche quando il fotogramma dice none.
L'animazione durava mezzo secondo. Il suo effetto collaterale non aveva proprio un'ora di fine.