Dziennik
Technologia3 min czytania

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ć.

ZIZespół inżynierów Bookatu

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.

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);}

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ę.

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; }}

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.

Rozwijane menu otwierające się poprawnie nad matowymi szklanymi panelami w ciemnym panelu
Po poprawce: menu maluje się nad matowymi panelami i, co ważniejsze, łapie własne kliknięcia.
  • 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.

kontekst nakładania cssanimation-fill-modez-index nie działadebugowanie frontenducss transform a nakładanie
Gotowy, żeby to wykorzystać?

Bookatu daje Ci firmową stronę rezerwacji, zadatki, karnety, karty podarunkowe i przypomnienia, przy 0% prowizji od Twoich rezerwacji.

Zacznij za darmo