Rozwijane menu, które nie chciało dać się kliknąć
Menu renderowało się bez zarzutu, siedziało dokładnie tam, gdzie powinno, i połykało każde kliknięcie. Winowajcą była animacja wejścia, która nigdy naprawdę się nie kończyła. Krótka wycieczka po kontekstach nakładania w CSS, trybach wypełniania animacji i polowaniu na błędy, których nie widać.
Wypuściliśmy rozwijane menu, którego nie dało się kliknąć. Otwierało się bez problemu. Renderowało się we właściwym miejscu, w pełni widoczne, z poprawnym z-indexem, bez błędów w konsoli. Kliknięcia przelatywały przez nie prosto do panelu pod spodem, jakby menu było duchem. Jeśli kiedykolwiek walczyłeś z takim błędem, znasz już tę dezorientującą część: wszystko wygląda dobrze i nic nie działa.
Ustalanie, kto naprawdę jest na wierzchu
Pierwsza uczciwa odpowiedź przyszła od przeglądarki, a nie z czytania kodu. document.elementFromPoint mówi, który element naprawdę odbiera kliknięcie w danym punkcie. Wycelowaliśmy w środek pozycji menu, a on zwrócił kartę leżącą dalej na stronie. Menu było wizualnie na wierzchu i fizycznie pod spodem.
// 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);}Przejście po przodkach to znalazło. Nagłówek zawierający menu raportował transform, choć żaden arkusz stylów mu go nie dawał. Jedynym transformem gdziekolwiek w pobliżu była animacja wejścia, drobne pojawienie się z uniesieniem, które skończyło się pół sekundy po wczytaniu strony.
Tryby wypełniania przeżywają swoje animacje
animation-fill-mode: both znaczy, że element trzyma pierwszą klatkę kluczową przed startem animacji i zachowuje ostatnią po jej końcu. Zachowuje, na zawsze. Jeśli którakolwiek klatka animuje transform, skończona animacja nadal stosuje transform, a element z transformem staje się kontekstem nakładania. Z-index każdego potomka jest odtąd uwięziony w środku i konkuruje wyłącznie z lokalnym rodzeństwem, nigdy z resztą strony.
Na naszej stronie za nagłówkiem stały panele używające backdrop-filter, który tworzy własny kontekst nakładania. Dwa zaplombowane pudełka, a kolejność malowania między nimi rozstrzygana przez kolejność w drzewie, a nie przez z-index, który w zdumieniu podnosiliśmy coraz wyżej. Menu mogło mieć z-index równy milionowi. Wewnątrz swojego pudełka nikt z zewnątrz go nie słyszał.
A oto pułapka wewnątrz pułapki. Zmieniliśmy ostatnią klatkę kluczową na transform: none, uznając, że zachowana wartość będzie wtedy nieszkodliwa. Wyliczony styl nadal raportował macierz. Przeglądarki rozwiązują transform zastosowanej animacji jako wartość macierzową, a macierz jednostkowa nadal liczy się jako transform w kwestii nakładania. Kontekst nakładania przeżył naszą poprawkę.
/* 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; }}Reguła, którą zapisaliśmy
Animacje wejścia na kontenerach wypełniają wstecz, nigdy w obie strony. Wstecz pokrywa jedyną lukę, która ma znaczenie, czyli klatki przed startem opóźnionej animacji. Gdy animacja się kończy, element całkowicie się od niej odczepia i przestaje być kontekstem nakładania. Efekt wizualny jest identyczny, bo dobrze zbudowane wejście kończy się w naturalnym stanie elementu.

- Ufaj elementFromPoint bardziej niż własnym oczom. Kolejność wizualna i kolejność trafień mogą się nie zgadzać, a kliknięcie bierze tylko jedna z nich.
- Gdy z-index przestaje mieć sens, przestań go podnosić i zacznij polować na konteksty nakładania. Przejście po przodkach z powyższego kodu zajmuje minutę.
- Sprawdzaj, co zostawiają po sobie skończone animacje. getComputedStyle na spokojnej stronie mówi, co jest wciąż zastosowane.
- Wypełnione transformy to transformy, nawet gdy klatka mówi none.
Animacja trwała pół sekundy. Jej skutek uboczny nie miał czasu zakończenia w ogóle.