Popover API en de native <dialog>: einde van modals die handmatig van nul worden gebouwd
Popover API en de native <dialog>: einde van modals die handmatig van nul worden gebouwd
In het artikel over toegankelijke componenten liet ik een modal zien die visueel verdween via display: none, maar nog steeds in de accessibility-boom stond, omdat iemand hem verkeerd had verborgen. Dat was slechts één van vijf problemen die een modal die van nul is opgebouwd met een <div> handmatig moet oplossen om überhaupt als toegankelijk te gelden: een focus trap (Tab mag de gebruiker niet buiten de modal kunnen leiden), sluiten met de Escape-toets, sluiten door buiten de inhoud te klikken, focus teruggeven aan het element dat de modal opende, en daadwerkelijk boven de rest van de pagina renderen, ongeacht hoeveel componenten onderweg overflow: hidden of hun eigen z-index hebben.
jsx
// Ziet eruit als een modal – lost feitelijk geen van de vijf problemen hierboven opconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Deze code heeft geen focus trap – Tab leidt een toetsenbordgebruiker vrijelijk terug naar de rest van de pagina, ook al bedekt de modal visueel alles. Hij sluit niet met Escape. Hij geeft geen focus terug aan de trigger na het sluiten. En hij hangt ervan af dat geen enkele ouder in de DOM-boom overflow: hidden of een lagere z-index heeft dan een ander element op de pagina – een aanname die in een grote applicatie regelmatig onwaar blijkt. Libraries als Radix of Headless UI bestaan juist om dit één keer, goed, in JavaScript op te lossen. Sinds een paar jaar lost de browser een deel van dit probleem zelf op, zonder één regel JS.
Top layer: een laag waar z-index niet voor geldt
Het cruciale mechanisme waarop zowel <dialog> als het attribuut popover steunt, is de top layer – een interne renderlaag van de browser, fysiek boven de rest van het document, volledig onafhankelijk van z-index, overflow of stacking contexts. Een element dat naar de top layer wordt gepromoveerd, "heeft" niet zomaar een heel hoge z-index – het maakt geen deel meer uit van de normale laagstapel van de pagina, dus kan geen enkele ouder met overflow: hidden het afsnijden, en kan geen enkel naburig element met een absurde z-index: 9999 het bedekken. Dit is hetzelfde mechanisme dat de browser al lang intern gebruikte voor native elementen zoals <video> in volledige-schermmodus – Popover API en <dialog> stellen het simpelweg beschikbaar aan ontwikkelaars.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Dit item echt verwijderen?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Annuleren</button><button type="submit" value="confirm" onClick={onConfirm}>Verwijderen</button></form></dialog>);};// elders in het component:// dialogRef.current.showModal();
Het aanroepen van .showModal() (niet .show() – dat is een veelgemaakte fout, .show() opent een niet-modale dialog, zonder achtergrond en zonder focus trap) activeert tegelijk vier dingen gratis: promotie naar de top layer, het renderen van het pseudo-element ::backdrop dat de rest van de pagina verduistert, een focus trap (Tab cirkelt alleen binnen de dialog rond) en het inert maken van de rest van het document – elementen buiten de dialog worden niet meer focusbaar en verdwijnen uit de interactie voor een schermlezer, ook al blijven ze zichtbaar onder de verduisterde achtergrond. Sluiten via Escape of via <form method="dialog"> (een native mechanisme – het klikken op een submit-knop binnen zo'n formulier sluit de dialog en zet dialog.returnValue op de value van de aangeklikte knop, zonder preventDefault en zonder handler in JS) geeft automatisch focus terug aan het element dat de dialog opende. Geen van deze vier dingen hoef je zelf te schrijven.
Popover API: hetzelfde, zonder modaliteit
<dialog> gaat ervan uit dat de rest van de pagina geblokkeerd moet worden. Dat is terecht voor een verwijderingsbevestiging, maar te zwaar voor een dropdownmenu, een tooltip of een toast – dingen die boven de rest van de inhoud moeten verschijnen, zichzelf moeten sluiten bij een klik ernaast, maar interactie met de rest van de pagina niet zouden moeten blokkeren. Daarvoor dient het attribuut popover, declaratief, zonder één regel JavaScript:
html
<button popovertarget="user-menu">Account</button><div id="user-menu" popover><a href="/profile">Profiel</a><a href="/settings">Instellingen</a><button popovertarget="user-menu" popovertargetaction="hide">Uitloggen</button></div>
Alleen al de relatie popovertarget/id volstaat om de browser de rest te laten afhandelen: promotie naar de top layer, light dismiss (klikken waar dan ook buiten de popover, of Escape, sluit hem automatisch, zonder dat je zelf naar click op document hoeft te luisteren), en correcte ARIA-relaties tussen de trigger en de inhoud (de browser koppelt zelf aria-expanded en aria-controls op basis van dit koppel attributen). Dit is precies wat voorheen een library als Floating UI vereiste, of handmatig luisteren naar klikken buiten een element met extra controle via event.target.closest().
Het fundamentele verschil met <dialog> in modale modus: een popover maakt de rest van de pagina niet inert en heeft geen focus trap. Dat is een bewuste keuze van de specificatie, geen ontbrekende functie – een gebruikersmenu zou niet mogen blokkeren dat je verder kunt scrollen of ergens anders op de pagina kunt klikken, zoals een echte modal wel doet. Deze twee instrumenten in de verkeerde richting door elkaar halen – popover gebruiken waar inhoud daadwerkelijk de rest van de interface moet blokkeren (bijvoorbeeld de bevestiging van een onomkeerbare handeling) – levert een interface op waarin een toetsenbordgebruiker op elk moment kan wegtabben uit een "modal" die hem eigenlijk helemaal niet blokkeerde.
Een valkuil waar de documentatie soms over zwijgt: klikken op de achtergrond van <dialog> sluit hem niet automatisch
Ondanks dat ::backdrop gratis wordt gerenderd, sluit erop klikken de dialog niet zonder extra code – dit is een van de weinige dingen die je handmatig moet toevoegen. Het mechanisme dat dit mogelijk maakt, steunt op een concreet feit over hit-testing: het <dialog>-element zelf vult in modale modus het volledige zichtbare gebied voor hit-testing, ongeacht hoe klein zijn visuele box met inhoud is – dus een klik op de verduisterde achtergrond raakt nog steeds event.target === dialogElement, terwijl een klik op de inhoud erbinnen een specifiek onderliggend element raakt.
jsx
const handleBackdropClick = (event) => {// event.target is alleen de <dialog> zelf wanneer de klik de achtergrond raakte,// niet een element uit de inhoud erbinnenif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Deze truc is de moeite waard om apart te onthouden, want intuïtief denk je "de achtergrond rendert toch al vanzelf, dan sluit hij zichzelf vast ook vanzelf" – dat doet hij niet, en dit is het enige element uit deze reeks dat een zelfgeschreven regel JS vereist.
Valkuilen en goede praktijken
.show()is niet hetzelfde als.showModal()..show()opent een niet-modale dialog – zonder::backdrop, zonder focus trap, zonderinertop de rest van de pagina. Voor een echte modal heb je altijd.showModal()nodig.- De standaardstijlen van
<dialog>moet je bewust resetten, net als bij elk ander native element uit het artikel over toegankelijkheid (<button>,<ul>) – de browser geeft het een standaardborder,paddingen centrering, die je in de praktijk vrijwel altijd overschrijft met je eigen CSS. - Popover vervangt
<dialog>niet waar modaliteit vereist is. Het ontbreken van een focus trap en vaninertis een bewuste beperking van popover, geen te omzeilen gat – als inhoud de rest van de pagina moet blokkeren, is<dialog>.showModal()altijd het juiste instrument. - Ondersteuning:
<dialog>is al lang veilig,popoveris nieuwer.<dialog>(inclusiefshowModal()) heeft al sinds 2022 solide ondersteuning. Het attribuutpopoverbereikte pas later, in 2024, Baseline-status (breed beschikbaar) – het loont nog steeds om de minimaal ondersteunde Safari-versie te checken in projecten met een lange staart oudere apparaten, voordat je een JS-library als fallback volledig laat vallen.
Samenvatting
Een modal die handmatig met een <div> is opgebouwd, is niet slecht omdat de ontwikkelaar zijn best niet deed – hij is slecht omdat hij in JavaScript probeert na te bouwen wat de browser inmiddels native en gratis aanbiedt: een top layer onafhankelijk van z-index, een focus trap, inert op de rest van de pagina, ::backdrop, focus teruggeven aan de trigger. <dialog> en popover zijn geen twee varianten van hetzelfde – het zijn twee instrumenten aan weerszijden van dezelfde grens: modaliteit die de rest van de interface bewust blokkeert, en lichte, tijdelijke inhoud die dat bewust niet doet. De keuze tussen beide is geen kwestie van stijl, maar van het antwoord op de vraag of de gebruiker op dat moment nog iets anders op de pagina zou moeten kunnen doen – en die vraag stel je jezelf best voordat je de eerste regel code schrijft, niet achteraf, wanneer blijkt dat een menu het scrollen blokkeert, of dat een verwijderingsbevestiging Tab helemaal niet tegenhoudt.