Popover API og nativ <dialog>: slutt på modaler bygget manuelt fra bunnen
Popover API og nativ <dialog>: slutt på modaler bygget manuelt fra bunnen
I artikkelen om tilgjengelige komponenter viste jeg en modal som visuelt forsvant via display: none, men som fortsatt fantes i tilgjengelighetstreet, fordi noen skjulte den på feil måte. Det var bare ett av fem problemer som en modal bygget fra bunnen med <div> må løse manuelt for i det hele tatt å regnes som tilgjengelig: fokusfelle (Tab kan ikke føre brukeren ut av modalen), lukking med Escape-tasten, lukking ved klikk utenfor innholdet, å gi fokus tilbake til elementet som åpnet modalen, og å faktisk rendres over resten av siden, uavhengig av hvor mange komponenter underveis som har overflow: hidden eller sin egen z-index.
jsx
// Ser ut som en modal – løser reelt sett ingen av de fem problemene overconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Denne koden har ingen fokusfelle – Tab fører fritt tastaturbrukeren tilbake til resten av siden, selv om modalen visuelt fortsatt dekker alt. Den lukkes ikke med Escape. Den gir ikke fokus tilbake til triggeren etter lukking. Og den er avhengig av at ingen forelder i DOM-treet har overflow: hidden eller en lavere z-index enn et annet element på siden – en forutsetning som i en stor applikasjon jevnlig viser seg å være falsk. Biblioteker som Radix eller Headless UI eksisterer nettopp for å løse dette én gang, skikkelig, i JavaScript. De siste årene har nettleseren selv løst deler av dette problemet, uten en eneste linje JS.
Top layer: laget som z-index ikke gjelder for
Den avgjørende mekanismen som både <dialog> og attributtet popover bygger på, er top layer – et internt renderingslag i nettleseren, fysisk over resten av dokumentet, helt uavhengig av z-index, overflow eller stacking-kontekster. Et element som forfremmes til top layer «har» ikke en veldig høy z-index – det er rett og slett ikke lenger en del av sidens vanlige lagstabel, så ingen forelder med overflow: hidden kan beskjære det, og ingen nabo-element med en absurd z-index: 9999 kan dekke det. Det er den samme mekanismen som nettleseren lenge har brukt internt for native elementer som <video> i fullskjermmodus – Popover API og <dialog> gjør den bare tilgjengelig for utviklere.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Er du sikker på at du vil slette dette elementet?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Avbryt</button><button type="submit" value="confirm" onClick={onConfirm}>Slett</button></form></dialog>);};// et annet sted i komponenten:// dialogRef.current.showModal();
Å kalle .showModal() (ikke .show() – en vanlig forveksling; .show() åpner en ikke-modal dialog, uten bakgrunn og uten fokusfelle) setter samtidig i gang fire ting gratis: forfremmelse til top layer, rendring av pseudo-elementet ::backdrop som mørklegger resten av siden, en fokusfelle (Tab sirkulerer syklisk bare inne i dialogen), og at resten av dokumentet blir inert – elementer utenfor dialogen slutter å være fokuserbare og forsvinner fra interaksjon for skjermlesere, selv om de fortsatt er synlige under det mørklagte bakteppet. Lukking med Escape eller via <form method="dialog"> (en native mekanisme – å klikke en submit-knapp inne i et slikt skjema lukker dialogen og setter dialog.returnValue til value for den klikkede knappen, uten preventDefault og uten en handler i JS) gir automatisk fokus tilbake til elementet som åpnet dialogen. Ingen av disse fire tingene krever at du skriver dem selv.
Popover API: det samme uten modalitet
<dialog> forutsetter at resten av siden skal blokkeres. Det er riktig for en slettebekreftelse, men for tungt for en nedtrekksmeny, en tooltip eller en toast – ting som skal vises over resten av innholdet, lukke seg selv ved klikk utenfor, men som ikke skal blokkere interaksjon med resten av siden. Til dette finnes attributtet popover, deklarativt, uten en eneste linje JavaScript:
html
<button popovertarget="user-menu">Konto</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Innstillinger</a><button popovertarget="user-menu" popovertargetaction="hide">Logg ut</button></div>
Selve relasjonen popovertarget/id er nok til at nettleseren håndterer resten selv: forfremmelse til top layer, light dismiss (klikk hvor som helst utenfor popoveren eller Escape lukker den automatisk, uten å lytte etter click på document), og korrekte ARIA-relasjoner mellom trigger og innhold (nettleseren kobler selv sammen aria-expanded og aria-controls basert på dette attributtparet). Dette er nøyaktig det som tidligere krevde et bibliotek som Floating UI eller manuell lytting etter klikk utenfor elementet med ekstra sjekk av event.target.closest().
Den grunnleggende forskjellen fra <dialog> i modal modus: popover gjør ikke resten av siden inert, og har ingen fokusfelle. Dette er en bevisst beslutning i spesifikasjonen, ikke en manglende funksjon – en brukermeny skal ikke blokkere muligheten til å scrolle eller klikke andre steder på siden, slik en ekte modal gjør. Å forveksle disse to verktøyene i feil retning – å bruke popover der innholdet virkelig må blokkere resten av grensesnittet (for eksempel bekreftelse av en uigjenkallelig handling) – gir et grensesnitt der en tastaturbruker når som helst kan tabbe seg ut av en «modal» som aldri egentlig blokkerte dem.
Fellen dokumentasjonen noen ganger tier om: klikk på bakteppet til <dialog> lukker den ikke automatisk
Selv om ::backdrop rendres gratis, lukker et klikk på det ikke dialogen uten ekstra kode – dette er en av de få tingene som må skrives manuelt. Mekanismen som gjør dette mulig, bygger på et konkret faktum om hit-testing: selve elementet <dialog> i modal modus fyller hele det synlige området for hit-testing, uavhengig av hvor lite innholdsboksen dens visuelt er – så et klikk på det mørklagte bakteppet treffer fortsatt event.target === dialogElement, mens et klikk på innholdet inni treffer et konkret barneelement.
jsx
const handleBackdropClick = (event) => {// event.target er selve <dialog> kun når klikket traff bakteppet,// ikke noe element fra innholdet inni denif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Dette trikset er verdt å huske på egen hånd, fordi intuisjonen sier «bakteppet rendres jo av seg selv, da lukker det seg vel også av seg selv» – det gjør det ikke, og det er det eneste elementet i dette settet som krever en egenhendig skrevet linje JS.
Fallgruver og gode praksiser
.show()er ikke det samme som.showModal()..show()åpner en ikke-modal dialog – uten::backdrop, uten fokusfelle, uteninertpå resten av siden. For en reell modal trenger du alltid.showModal().- Standardstilene til
<dialog>må tilbakestilles bevisst, akkurat som med ethvert nativt element fra artikkelen om tilgjengelighet (<button>,<ul>) – nettleseren gir den en standardborder,paddingog sentrering, som du i praksis nesten alltid overstyrer med egen CSS. - Popover erstatter ikke
<dialog>der modalitet er påkrevd. Manglende fokusfelle og manglendeinerter en tilsiktet begrensning i popoveren, ikke et hull å omgå – hvis innholdet må blokkere resten av siden, er riktig verktøy alltid<dialog>.showModal(). - Støtte:
<dialog>har lenge vært trygt,popoverer nyere.<dialog>(inkludertshowModal()) har hatt solid støtte siden 2022. Attributtetpopovernådde Baseline (bredt tilgjengelig) senere, i 2024 – det er fortsatt verdt å sjekke minste støttede Safari-versjon i prosjekter med en lang hale av eldre enheter, før du helt dropper et JS-bibliotek som fallback.
Oppsummering
En modal bygget manuelt med <div> er ikke dårlig fordi utvikleren ikke anstrengte seg nok – den er dårlig fordi den forsøker å gjenskape i JavaScript en mekanisme som nettleseren nå tilbyr nativt og gratis: top layer uavhengig av z-index, fokusfelle, inert på resten av siden, ::backdrop, tilbakeføring av fokus til triggeren. <dialog> og popover er ikke to varianter av det samme – de er to verktøy på hver sin side av samme grense: modalitet som bevisst blokkerer resten av grensesnittet, og lett, midlertidig innhold som bevisst ikke gjør det. Valget mellom dem er ikke et spørsmål om stil, men et svar på spørsmålet om brukeren i det aktuelle øyeblikket bør kunne gjøre noe annet på siden – og det spørsmålet er verdt å stille seg selv før man skriver den første linjen kode, ikke i ettertid, når det viser seg at menyen blokkerer scrolling, eller at bekreftelsesmodalen for kontosletting ikke blokkerer Tab i det hele tatt.