Popover API og native <dialog>: slut med modaler bygget manuelt fra bunden
Popover API og native <dialog>: slut med modaler bygget manuelt fra bunden
I artiklen om komponenters tilgængelighed viste jeg en modal, der visuelt forsvandt via display: none, men stadig fandtes i tilgængelighedstræet, fordi nogen havde skjult den forkert. Det var kun ét ud af fem problemer, en modal bygget fra bunden på en <div> selv skal løse for overhovedet at kunne kaldes tilgængelig: en fokusfælde (Tab må ikke kunne føre brugeren ud af modalen), lukning med Escape-tasten, lukning ved klik uden for indholdet, tilbagelevering af fokus til det element, der åbnede modalen, og en faktisk rendering over resten af siden, uanset hvor mange komponenter undervejs har overflow: hidden eller deres egen z-index.
jsx
// Ligner en modal – løser reelt ingen af de fem problemer ovenforconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Denne kode har ingen fokusfælde – Tab fører frit tastaturbrugeren tilbage til resten af siden, selvom modalen visuelt stadig dækker alt. Den lukker ikke med Escape. Den leverer ikke fokus tilbage til triggeren efter lukning. Og den afhænger af, at ingen forælder i DOM-træet har overflow: hidden eller en lavere z-index end et andet element på siden – en antagelse, der i en stor applikation jævnligt viser sig at være falsk. Biblioteker som Radix eller Headless UI findes netop for at løse dette én gang, ordentligt, i JavaScript. I de senere år har browseren selv løst en del af dette problem, uden en eneste linje JS.
Top layer: laget, som z-index ikke gælder for
Den centrale mekanisme, både <dialog> og attributten popover bygger på, er top layer – et internt renderingslag i browseren, fysisk oven over resten af dokumentet, fuldstændig uafhængigt af z-index, overflow eller stacking-kontekster. Et element, der forfremmes til top layer, "har ikke bare en meget høj z-index" – det er ikke længere en del af sidens almindelige lagstak, så ingen forælder med overflow: hidden kan beskære det, og intet nabo-element med en absurd z-index: 9999 kan dække det. Det er den samme mekanisme, browseren i lang tid har brugt internt til native elementer som <video> i fuldskærmstilstand – Popover API og <dialog> gør den blot tilgængelig for udviklere.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Er du sikker på, at elementet skal slettes?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Annuller</button><button type="submit" value="confirm" onClick={onConfirm}>Slet</button></form></dialog>);};// et andet sted i komponenten:// dialogRef.current.showModal();
Kaldet af .showModal() (ikke .show() – en almindelig fejl, .show() åbner en ikke-modal dialog, uden baggrund og uden fokusfælde) sætter fire ting i gang på én gang, gratis: forfremmelse til top layer, rendering af pseudo-elementet ::backdrop, som dæmper resten af siden, en fokusfælde (Tab cirkulerer kun rundt inde i dialogen), og gør resten af dokumentet inert – elementer uden for dialogen holder op med at kunne få fokus og forsvinder fra interaktion for en skærmlæser, selvom de stadig er synlige under det dæmpede tæppe. Lukning med Escape eller via <form method="dialog"> (en native mekanisme – klik på en submit-knap inde i en sådan formular lukker dialogen og sætter dialog.returnValue til værdien af den klikkede knap, uden preventDefault og uden en JS-handler) giver automatisk fokus tilbage til det element, der åbnede dialogen. Ingen af disse fire ting kræver, at du skriver den selv.
Popover API: det samme uden modalitet
<dialog> går ud fra, at resten af siden skal blokeres. Det er rimeligt for en sletningsbekræftelse, men for tungt til en dropdown-menu, et tooltip eller en toast – ting, der skal vises over resten af indholdet, lukke sig selv ved klik ved siden af, men ikke bør blokere interaktion med resten af siden. Det er, hvad attributten popover er til, deklarativt, uden en eneste linje JavaScript:
html
<button popovertarget="user-menu">Konto</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Indstillinger</a><button popovertarget="user-menu" popovertargetaction="hide">Log ud</button></div>
Selve relationen popovertarget/id er nok til, at browseren håndterer resten: forfremmelse til top layer, light dismiss (klik hvor som helst uden for popoveren eller Escape lukker den automatisk, uden at lytte efter click på document), og korrekte ARIA-relationer mellem trigger og indhold (browseren binder selv aria-expanded og aria-controls sammen ud fra det par attributter). Det er præcis det, der tidligere krævede et bibliotek som Floating UI eller manuel lytning efter klik uden for elementet med en ekstra kontrol via event.target.closest().
Den grundlæggende forskel i forhold til <dialog> i modal tilstand: en popover gør ikke resten af siden inert og har ingen fokusfælde. Det er en bevidst beslutning i specifikationen, ikke en manglende funktion – en brugermenu bør ikke blokere muligheden for at scrolle eller klikke andre steder på siden, sådan som en rigtig modal gør. At forveksle de to værktøjer i den forkerte retning – at bruge popover, hvor indholdet reelt skal blokere resten af grænsefladen (fx bekræftelse af en uigenkaldelig handling) – giver en grænseflade, hvor en tastaturbruger til enhver tid kan tabbe sig ud af "modalen", som slet ikke blokerede dem.
Faldgruben, dokumentationen nogle gange forbigår i tavshed: klik på baggrunden af <dialog> lukker den ikke automatisk
Selvom ::backdrop renderes gratis, lukker et klik på den ikke dialogen uden ekstra kode – det er en af de få ting, du selv skal tilføje. Mekanismen, der muliggør det, bygger på en konkret kendsgerning om hit-testing: selve <dialog>-elementet i modal tilstand udfylder hele det synlige område ved hit-testing, uanset hvor lille dets indholdsboks visuelt er – så et klik på det dæmpede tæppe rammer stadig event.target === dialogElement, mens et klik på indholdet indeni rammer det konkrete underelement.
jsx
const handleBackdropClick = (event) => {// event.target er selve <dialog>-elementet kun, hvis klikket// ramte baggrunden, ikke noget element fra indholdet indeniif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Det er værd at huske dette trick for sig, fordi man intuitivt tænker "baggrunden renderer sig jo selv, så den lukker vel også sig selv" – det gør den ikke, og det er det eneste element i dette sæt, der kræver en selvskrevet linje JS.
Faldgruber og god praksis
.show()er ikke det samme som.showModal()..show()åbner en ikke-modal dialog – uden::backdrop, uden fokusfælde, udeninertpå resten af siden. Til en reel modal har du altid brug for.showModal().- Standardstilene for
<dialog>skal nulstilles bevidst, ligesom ved ethvert andet nativt element fra artiklen om tilgængelighed (<button>,<ul>) – browseren giver den en standardborder,paddingog centrering, som du i praksis næsten altid overskriver med din egen CSS. - Popover erstatter ikke
<dialog>, hvor modalitet er påkrævet. Manglende fokusfælde og manglendeinerter en bevidst begrænsning ved popover, ikke et hul, der skal lukkes – hvis indholdet skal blokere resten af siden, er det rette værktøj altid<dialog>.showModal(). - Understøttelse:
<dialog>har været sikker længe,popoverer nyere.<dialog>(inklusiveshowModal()) har haft solid understøttelse siden 2022. Attributtenpopoveropnåede Baseline-status (bredt tilgængelig) senere, i 2024 – det er stadig værd at tjekke den mindst understøttede Safari-version i projekter med en lang hale af ældre enheder, før du helt dropper et JS-bibliotek som fallback.
Opsummering
En modal bygget manuelt på en <div> er ikke dårlig, fordi udvikleren ikke har gjort sig umage – den er dårlig, fordi den forsøger at genskabe i JavaScript en mekanisme, browseren nu tilbyder nativt og gratis: top layer uafhængig af z-index, en fokusfælde, inert på resten af siden, ::backdrop, tilbagelevering af fokus til triggeren. <dialog> og popover er ikke to varianter af det samme – det er to værktøjer på hver sin side af den samme grænse: modalitet, der bevidst blokerer resten af grænsefladen, og let, midlertidigt indhold, der bevidst ikke gør det. Valget mellem dem er ikke et spørgsmål om stil, men et svar på spørgsmålet, om brugeren i det pågældende øjeblik skal kunne foretage sig noget som helst andet på siden – og det spørgsmål er værd at stille sig selv, inden den første linje kode skrives, ikke bagefter, når det viser sig, at menuen blokerer scrolling, eller at bekræftelsesmodalen til kontosletning slet ikke blokerer Tab.