dostępnośćcss i układyreact

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żej
const 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 niego
if (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, bez inert na 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ślny border, padding i 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 brak inert to 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, popover jest nowszy. <dialog> (łącznie z showModal()) ma solidne wsparcie od 2022 roku. Atrybut popover osią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.