Popover API i natywny <dialog>: koniec modali budowanych ręcznie od zera
Popover API i natywny <dialog>: koniec modali budowanych ręcznie od zera
W artykule o dostępności komponentów pokazywałem modal, który wizualnie znikał przez display: none, ale w drzewie dostępności wciąż tam był, bo ktoś ukrył go niepoprawnie. To był tylko jeden z pięciu problemów, które modal budowany od zera na <div> musi rozwiązać ręcznie, żeby w ogóle uchodzić za dostępny: pułapka fokusu (Tab nie może wyprowadzić użytkownika poza modal), zamykanie klawiszem Escape, zamykanie kliknięciem poza treścią, oddanie fokusu z powrotem elementowi, który modal otworzył, i wyrenderowanie się faktycznie nad resztą strony, niezależnie od tego, ile komponentów po drodze ma overflow: hidden albo własny z-index.
jsx
// Wygląda jak modal – realnie rozwiązuje żaden z pięciu problemów powyżejconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Ten kod nie ma pułapki fokusu – Tab swobodnie wyprowadza użytkownika klawiatury z powrotem do reszty strony, mimo że wizualnie modal wciąż zasłania wszystko. Nie zamyka się Escape'em. Nie oddaje fokusu triggerowi po zamknięciu. I zależy od tego, że żaden rodzic w drzewie DOM nie ma overflow: hidden ani niższego z-index niż jakiś inny element strony – założenie, które w dużej aplikacji regularnie okazuje się fałszywe. Biblioteki takie jak Radix czy Headless UI istnieją właśnie po to, żeby rozwiązać to raz, dobrze, w JavaScripcie. Od kilku lat część tego problemu przeglądarka rozwiązuje sama, bez jednej linijki JS.
Top layer: warstwa, której z-index nie dotyczy
Kluczowy mechanizm, na którym opiera się zarówno <dialog>, jak i atrybut popover, to top layer – wewnętrzna warstwa renderowania przeglądarki, fizycznie ponad całą resztą dokumentu, całkowicie niezależna od z-index, overflow czy stackingu kontekstów. Element promowany do top layer nie „ma bardzo wysoki z-index” – on nie jest już częścią normalnego stosu warstw strony, więc żaden rodzic z overflow: hidden nie może go przyciąć, i żaden sąsiedni element z absurdalnym z-index: 9999 nie może go przykryć. To ten sam mechanizm, którego przeglądarka od dawna używała wewnętrznie dla natywnych elementów typu <video> w trybie pełnego ekranu – Popover API i <dialog> po prostu udostępniają go programistom.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Na pewno usunąć ten element?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Anuluj</button><button type="submit" value="confirm" onClick={onConfirm}>Usuń</button></form></dialog>);};// gdzie indziej w komponencie:// dialogRef.current.showModal();
Wywołanie .showModal() (nie .show() – to częsta pomyłka, .show() otwiera dialog niemodalny, bez tła i bez pułapki fokusu) uruchamia jednocześnie cztery rzeczy za darmo: promocję do top layer, wyrenderowanie pseudo-elementu ::backdrop przyciemniającego resztę strony, pułapkę fokusu (Tab cyklicznie krąży tylko wewnątrz dialogu) i uczynienie reszty dokumentu inert – elementy poza dialogiem przestają być fokusowalne i znikają z interakcji dla czytnika ekranu, mimo że wciąż są widoczne pod przyciemnionym tłem. Zamknięcie przez Escape albo przez <form method="dialog"> (natywny mechanizm – kliknięcie przycisku submit wewnątrz takiego formularza zamyka dialog i ustawia dialog.returnValue na value klikniętego przycisku, bez preventDefault i bez handlera w JS) automatycznie oddaje fokus elementowi, który dialog otworzył. Żadna z tych czterech rzeczy nie wymaga napisania jej samodzielnie.
Popover API: to samo bez modalności
<dialog> zakłada, że reszta strony ma zostać zablokowana. To słuszne dla potwierdzenia usunięcia, ale zbyt ciężkie dla menu rozwijanego, tooltipa czy toasta – rzeczy, które mają się pokazać nad resztą treści, zamknąć się samodzielnie po kliknięciu obok, ale nie powinny blokować interakcji z resztą strony. Do tego służy atrybut popover, deklaratywnie, bez jednej linijki JavaScriptu:
html
<button popovertarget="user-menu">Konto</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Ustawienia</a><button popovertarget="user-menu" popovertargetaction="hide">Wyloguj</button></div>
Sama relacja popovertarget/id wystarcza, żeby przeglądarka obsłużyła całą resztę: promocję do top layer, light dismiss (kliknięcie gdziekolwiek poza popoverem albo Escape zamyka go automatycznie, bez nasłuchiwania na click na document), i poprawne relacje ARIA między triggerem a treścią (przeglądarka sama dowiąże aria-expanded i aria-controls na podstawie tej pary atrybutów). To dokładnie to, co wcześniej wymagało biblioteki w rodzaju Floating UI czy ręcznego nasłuchiwania na kliknięcia poza elementem z dodatkowym sprawdzaniem event.target.closest().
Fundamentalna różnica względem <dialog> w trybie modalnym: popover nie czyni reszty strony inert i nie ma pułapki fokusu. To świadoma decyzja specyfikacji, nie brak funkcji – menu użytkownika nie powinno blokować możliwości przescrollowania czy kliknięcia gdzie indziej na stronie, tak jak robi to prawdziwy modal. Pomylenie tych dwóch narzędzi w złą stronę – użycie popover tam, gdzie treść naprawdę musi zablokować resztę interfejsu (np. potwierdzenie nieodwracalnej operacji) – daje interfejs, w którym użytkownik klawiatury może w każdej chwili wytabować się z „modala”, który wcale go nie blokował.
Pułapka, o której dokumentacja czasem milczy: kliknięcie w tło <dialog> nie zamyka go automatycznie
Mimo że ::backdrop renderuje się za darmo, kliknięcie w nie nie zamyka dialogu bez dodatkowego kodu – to jedna z niewielu rzeczy, które trzeba dopisać ręcznie. Mechanizm, który to umożliwia, opiera się na konkretnym fakcie o hit-testingu: sam element <dialog> w trybie modalnym wypełnia obszar widoczny na hit-testing niezależnie od tego, jak mały wizualnie jest jego box z treścią – więc kliknięcie w przyciemnione tło wciąż trafia w event.target === dialogElement, a kliknięcie w treść wewnątrz trafia w konkretny element potomny.
jsx
const handleBackdropClick = (event) => {// event.target to sam <dialog> tylko wtedy, gdy klik trafił w tło,// nie w żaden element z treści wewnątrz niegoif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Warto zapamiętać ten trik osobno, bo intuicyjnie „przecież tło się samo renderuje, to pewnie samo się też zamyka” – nie zamyka, i to jedyny element z tego zestawu, który wymaga własnoręcznie napisanej linijki JS.
Pułapki i dobre praktyki
.show()to nie to samo co.showModal()..show()otwiera dialog niemodalny – bez::backdrop, bez pułapki fokusu, bezinertna reszcie strony. Dla realnego modala zawsze potrzebujesz.showModal().- Domyślne style
<dialog>trzeba zresetować świadomie, tak samo jak przy każdym natywnym elemencie z artykułu o dostępności (<button>,<ul>) – przeglądarka nadaje mu domyślnyborder,paddingi wyśrodkowanie, które w praktyce niemal zawsze nadpisujesz własnym CSS. - Popover nie zastępuje
<dialog>tam, gdzie modalność jest wymagana. Brak pułapki fokusu i brakinertto zamierzone ograniczenie popovera, nie luka do obejścia – jeśli treść musi zablokować resztę strony, właściwym narzędziem zawsze jest<dialog>.showModal(). - Wsparcie:
<dialog>jest bezpieczne od dawna,popoverjest nowszy.<dialog>(łącznie zshowModal()) ma solidne wsparcie od 2022 roku. Atrybutpopoverosiągnął Baseline (szeroko dostępne) później, w 2024 roku – wciąż warto sprawdzić minimalną wspieraną wersję Safari w projektach z długim ogonem starszych urządzeń, zanim całkowicie zrezygnujesz z biblioteki JS jako fallbacku.
Podsumowanie
Modal budowany ręcznie na <div> nie jest zły dlatego, że deweloper się nie postarał – jest zły dlatego, że próbuje odtworzyć w JavaScripcie mechanizm, który przeglądarka teraz oferuje natywnie i za darmo: top layer niezależny od z-index, pułapkę fokusu, inert na reszcie strony, ::backdrop, oddanie fokusu triggerowi. <dialog> i popover to nie dwa warianty tego samego – to dwa narzędzia po przeciwnych stronach tej samej granicy: modalność, która świadomie blokuje resztę interfejsu, i lekka, tymczasowa treść, która świadomie jej nie blokuje. Wybór między nimi to nie kwestia stylu, tylko odpowiedzi na pytanie, czy użytkownik powinien w danym momencie móc zrobić cokolwiek innego na stronie – a to pytanie warto zadać sobie przed napisaniem pierwszej linijki kodu, nie po fakcie, gdy okaże się, że menu blokuje scrollowanie, albo modal potwierdzenia usunięcia konta wcale nie blokuje Tab-a.