Popover API och nativ <dialog>: slutet på manuellt byggda modaler
I artikeln om tillgängliga komponenter visade jag en modal som visuellt försvann genom display: none, men som ändå fanns kvar i tillgänglighetsträdet, eftersom någon dolde den på fel sätt. Det var bara ett av fem problem som en modal byggd från grunden med <div> måste lösa manuellt för att överhuvudtaget räknas som tillgänglig: fokusfälla (Tab får inte leda användaren ut ur modalen), stängning med Escape-tangenten, stängning genom klick utanför innehållet, att lämna tillbaka fokus till elementet som öppnade modalen, och att faktiskt renderas ovanpå resten av sidan, oavsett hur många komponenter på vägen som har overflow: hidden eller ett eget z-index.
jsx
// Ser ut som en modal – löser i praktiken inget av de fem problemen ovanconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Den här koden har ingen fokusfälla – Tab leder tangentbordsanvändaren fritt tillbaka till resten av sidan, trots att modalen visuellt täcker allt. Den stängs inte med Escape. Den lämnar inte tillbaka fokus till knappen som utlöste den. Och den förutsätter att ingen förälder i DOM-trädet har overflow: hidden eller ett lägre z-index än något annat element på sidan – ett antagande som i en stor applikation regelbundet visar sig falskt. Bibliotek som Radix eller Headless UI finns just för att lösa det här en gång, ordentligt, i JavaScript. Sedan några år tillbaka löser webbläsaren själv en del av problemet, utan en enda rad JS.
Top layer: lagret som z-index inte gäller för
Den avgörande mekanismen som både <dialog> och attributet popover bygger på är top layer – ett internt renderingslager i webbläsaren, fysiskt ovanför resten av dokumentet, helt oberoende av z-index, overflow eller stacking contexts. Ett element som befordras till top layer "har inte bara ett väldigt högt z-index" – det är inte längre en del av sidans normala lagerstack, så ingen förälder med overflow: hidden kan klippa bort det, och inget grannelement med ett absurt z-index: 9999 kan täcka över det. Det är samma mekanism som webbläsaren länge har använt internt för nativa element som <video> i helskärmsläge – Popover API och <dialog> gör den bara tillgänglig för utvecklare.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Är du säker på att du vill ta bort det här objektet?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Avbryt</button><button type="submit" value="confirm" onClick={onConfirm}>Ta bort</button></form></dialog>);};// någon annanstans i komponenten:// dialogRef.current.showModal();
Att anropa .showModal() (inte .show() – ett vanligt misstag, .show() öppnar en icke-modal dialog, utan bakgrund och utan fokusfälla) startar fyra saker samtidigt, gratis: befordran till top layer, rendering av pseudo-elementet ::backdrop som mörklägger resten av sidan, en fokusfälla (Tab cirkulerar bara inuti dialogen) och att resten av dokumentet görs inert – element utanför dialogen slutar vara fokuserbara och försvinner ur interaktionen för skärmläsare, trots att de fortfarande syns under den mörklagda bakgrunden. Stängning via Escape eller via <form method="dialog"> (en nativ mekanism – ett klick på en submit-knapp inuti ett sådant formulär stänger dialogen och sätter dialog.returnValue till värdet (value) på den klickade knappen, utan preventDefault och utan en JS-handler) lämnar automatiskt tillbaka fokus till elementet som öppnade dialogen. Inget av de här fyra sakerna kräver att du skriver det själv.
Popover API: samma sak utan modalitet
<dialog> förutsätter att resten av sidan ska blockeras. Det är rätt för en bekräftelse av en borttagning, men för tungt för en rullgardinsmeny, en tooltip eller en toast – saker som ska visas ovanpå resten av innehållet, stängas av sig själva vid klick bredvid, men inte ska blockera interaktion med resten av sidan. Det är vad attributet popover är till för, deklarativt, utan en enda rad JavaScript:
html
<button popovertarget="user-menu">Konto</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Inställningar</a><button popovertarget="user-menu" popovertargetaction="hide">Logga ut</button></div>
Enbart relationen popovertarget/id räcker för att webbläsaren ska hantera resten: befordran till top layer, light dismiss (ett klick var som helst utanför popovern eller Escape stänger den automatiskt, utan att lyssna på click på document), och korrekta ARIA-relationer mellan utlösaren och innehållet (webbläsaren kopplar själv ihop aria-expanded och aria-controls utifrån det paret attribut). Det är exakt det som tidigare krävde ett bibliotek som Floating UI eller manuell lyssning på klick utanför elementet med extra kontroll via event.target.closest().
Den grundläggande skillnaden mot <dialog> i modalt läge: en popover gör inte resten av sidan inert, och har ingen fokusfälla. Det är ett medvetet val i specifikationen, inte en saknad funktion – en användarmeny ska inte blockera möjligheten att scrolla eller klicka någon annanstans på sidan, så som en verklig modal gör. Att blanda ihop de två verktygen åt fel håll – att använda popover där innehållet verkligen måste blockera resten av gränssnittet (t.ex. en bekräftelse av en oåterkallelig åtgärd) – ger ett gränssnitt där en tangentbordsanvändare när som helst kan tabba sig ut ur "modalen" som aldrig blockerade dem över huvud taget.
Fallgropen som dokumentationen ibland tiger om: klick i bakgrunden på <dialog> stänger den inte automatiskt
Trots att ::backdrop renderas gratis stänger ett klick på den inte dialogen utan extra kod – det är en av få saker som måste läggas till manuellt. Mekanismen som gör det möjligt bygger på ett konkret faktum om hit-testing: själva <dialog>-elementet i modalt läge fyller hela det synliga området vid hit-testing, oavsett hur litet dess innehållsbox visuellt är – så ett klick i den mörklagda bakgrunden träffar ändå event.target === dialogElement, medan ett klick i innehållet inuti träffar ett specifikt underordnat element.
jsx
const handleBackdropClick = (event) => {// event.target är själva <dialog> bara när klicket träffade bakgrunden,// inte något innehållselement inuti denif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Det är värt att komma ihåg det tricket separat, för intuitionen säger "bakgrunden renderas ju av sig själv, då stänger den väl också sig själv" – det gör den inte, och det är det enda elementet i den här uppsättningen som kräver en egenhändigt skriven rad JS.
Fallgropar och god praxis
.show()är inte samma sak som.showModal()..show()öppnar en icke-modal dialog – utan::backdrop, utan fokusfälla, utaninertpå resten av sidan. För en riktig modal behöver du alltid.showModal().- Standardstilarna för
<dialog>måste nollställas medvetet, precis som för alla nativa element i artikeln om tillgänglighet (<button>,<ul>) – webbläsaren ger den en standardram, standardpadding och centrering, som du i praktiken nästan alltid skriver över med egen CSS. - Popover ersätter inte
<dialog>där modalitet krävs. Avsaknaden av fokusfälla ochinertär en avsedd begränsning hos popovern, inte en lucka att kringgå – om innehållet måste blockera resten av sidan är rätt verktyg alltid<dialog>.showModal(). - Stöd:
<dialog>har varit säker länge,popoverär nyare.<dialog>(inklusiveshowModal()) har haft solitt stöd sedan 2022. Attributetpopovernådde Baseline (brett tillgängligt) senare, 2024 – det är fortfarande värt att kontrollera lägsta stödda Safari-version i projekt med lång svans av äldre enheter innan du helt avstår från ett JS-bibliotek som fallback.
Sammanfattning
En modal byggd för hand med <div> är inte dålig för att utvecklaren inte anstängde sig – den är dålig för att den försöker återskapa i JavaScript en mekanism som webbläsaren nu erbjuder nativt och gratis: ett top layer oberoende av z-index, en fokusfälla, inert på resten av sidan, ::backdrop, återlämnande av fokus till utlösaren. <dialog> och popover är inte två varianter av samma sak – de är två verktyg på motsatta sidor av samma gräns: modalitet, som medvetet blockerar resten av gränssnittet, och lätt, tillfälligt innehåll, som medvetet inte gör det. Valet mellan dem är inte en stilfråga, utan svaret på frågan om användaren i det ögonblicket ska kunna göra något annat på sidan – och den frågan är värd att ställa innan du skriver den första raden kod, inte efteråt, när det visar sig att menyn blockerar scrollning, eller att bekräftelsemodalen för kontoborttagning inte alls blockerar Tab.