saavutettavuuscss ja asettelureact

Popover API ja natiivi <dialog>: loppu käsin tyhjästä rakennetuille modaaleille

Popover API ja natiivi <dialog>: loppu käsin tyhjästä rakennetuille modaaleille

Näytin komponenttien saavutettavuutta käsittelevässä artikkelissa modaalin, joka katosi visuaalisesti display: none-määrityksellä, mutta oli silti saavutettavuuspuussa, koska joku piilotti sen väärin. Se oli vain yksi viidestä ongelmasta, jotka tyhjästä <div>-elementeillä rakennetun modaalin täytyy ratkaista käsin, jotta se ylipäätään olisi saavutettava: fokusloukku (Tab ei saa pystyä viemään käyttäjää modaalin ulkopuolelle), sulkeminen Escape-näppäimellä, sulkeminen klikkaamalla sisällön ulkopuolelle, fokuksen palauttaminen elementille, joka avasi modaalin, ja renderöityminen todella muun sivun yläpuolelle, riippumatta siitä, kuinka monella matkan varrella olevalla komponentilla on overflow: hidden tai oma z-index.

jsx

// Näyttää modaililta – ei oikeasti ratkaise yhtäkään yllä olevista viidestä ongelmasta
const NaiveModal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal">{children}</div>
</div>
);
};

Tässä koodissa ei ole fokusloukkua – Tab vie näppäimistökäyttäjän vapaasti takaisin muualle sivulle, vaikka modaali visuaalisesti yhä peittää kaiken. Se ei sulkeudu Escapella. Se ei palauta fokusta laukaisijalle sulkemisen jälkeen. Ja se nojaa siihen, ettei yhdelläkään DOM-puun vanhemmalla ole overflow: hidden-määritystä eikä matalampaa z-index-arvoa kuin jollain toisella sivun elementillä – oletus, joka osoittautuu isossa sovelluksessa säännöllisesti vääräksi. Kirjastot kuten Radix tai Headless UI ovat olemassa juuri tämän ratkaisemiseksi kerralla, kunnolla, JavaScriptissä. Muutaman vuoden ajan selain on ratkaissut osan tästä ongelmasta itse, ilman ainuttakaan riviä JS:ää.

Top layer: kerros, johon z-index ei vaikuta

Keskeinen mekanismi, johon sekä <dialog> että popover-attribuutti nojaavat, on top layer – selaimen sisäinen renderöintikerros, fyysisesti koko muun dokumentin yläpuolella, täysin riippumaton z-index-arvosta, overflow-määrityksestä tai pinoamiskonteksteista. Elementillä, joka ylennetään top layeriin, ei ole "hyvin korkeaa z-indexiä" – se ei enää ole osa sivun normaalia kerrospinoa, joten mikään overflow: hidden-vanhempi ei voi leikata sitä, eikä mikään naapurielementti absurdilla z-index: 9999-arvolla voi peittää sitä. Tämä on sama mekanismi, jota selain on pitkään käyttänyt sisäisesti natiiveille elementeille kuten <video> koko näytön tilassa – Popover API ja <dialog> vain tuovat sen kehittäjien saataville.

jsx

const ConfirmDialog = ({ onConfirm }) => {
const dialogRef = useRef(null);
return (
<dialog ref={dialogRef} className="confirm-dialog">
<p>Haluatko varmasti poistaa tämän kohteen?</p>
<form method="dialog" className="confirm-dialog__actions">
<button type="submit" value="cancel">Peruuta</button>
<button type="submit" value="confirm" onClick={onConfirm}>
Poista
</button>
</form>
</dialog>
);
};
// muualla komponentissa:
// dialogRef.current.showModal();

Kutsu .showModal() (ei .show() – tämä on yleinen sekaannus, .show() avaa ei-modaalisen dialogin, ilman taustaa ja ilman fokusloukkua) käynnistää samalla kertaa neljä asiaa ilmaiseksi: ylennyksen top layeriin, muuta sivua tummentavan ::backdrop-pseudoelementin renderöinnin, fokusloukun (Tab kiertää syklisesti vain dialogin sisällä) ja koko muun dokumentin muuttamisen inert-tilaan – dialogin ulkopuoliset elementit lakkaavat olemasta fokusoitavissa ja katoavat ruudunlukijan vuorovaikutuksesta, vaikka ne ovat yhä näkyvissä tummennetun taustan alla. Sulkeminen Escapella tai <form method="dialog">-elementillä (natiivi mekanismi – tällaisen lomakkeen sisällä olevan submit-painikkeen klikkaaminen sulkee dialogin ja asettaa dialog.returnValue-arvon klikatun painikkeen value-arvoksi, ilman preventDefault-kutsua ja ilman JS-käsittelijää) palauttaa fokuksen automaattisesti elementille, joka avasi dialogin. Mikään näistä neljästä asiasta ei vaadi itse kirjoittamista.

Popover API: sama asia ilman modaalisuutta

<dialog> olettaa, että muu sivu pitää lukita. Tämä on perusteltua poiston vahvistukselle, mutta liian raskasta pudotusvalikolle, työkaluvihjeelle tai toastille – asioille, joiden pitää näkyä muun sisällön yläpuolella, sulkeutua itsestään klikattaessa vierestä, mutta jotka eivät saa estää vuorovaikutusta muun sivun kanssa. Tähän on popover-attribuutti, deklaratiivisesti, ilman ainuttakaan riviä JavaScriptia:

html

<button popovertarget="user-menu">Tili</button>
<div id="user-menu" popover>
<a href="/profile">Profiili</a>
<a href="/settings">Asetukset</a>
<button popovertarget="user-menu" popovertargetaction="hide">Kirjaudu ulos</button>
</div>

Pelkkä popovertarget/id-suhde riittää siihen, että selain hoitaa kaiken muun: ylennyksen top layeriin, light dismissin (klikkaus mihin tahansa popoverin ulkopuolelle tai Escape sulkee sen automaattisesti, ilman click-kuuntelijaa document-oliossa) ja oikeat ARIA-suhteet laukaisijan ja sisällön välillä (selain liittää itse aria-expanded- ja aria-controls-attribuutit tämän attribuuttiparin perusteella). Tämä on täsmälleen se, mikä aiemmin vaati kirjaston kuten Floating UI tai käsin kirjoitetun kuuntelijan klikkauksille elementin ulkopuolella lisätarkistuksella event.target.closest().

Perustavanlaatuinen ero modaalitilassa olevaan <dialog>-elementtiin: popover ei tee muusta sivusta inert-tilaista eikä siinä ole fokusloukkua. Tämä on spesifikaation tietoinen päätös, ei puuttuva ominaisuus – käyttäjävalikon ei pitäisi estää sivun vierittämistä tai klikkaamista muualle, kuten oikea modaali tekee. Näiden kahden työkalun sekoittaminen väärään suuntaan – popover-attribuutin käyttö silloin, kun sisällön todella pitää estää muu käyttöliittymä (esim. peruuttamattoman toiminnon vahvistus) – tuottaa käyttöliittymän, jossa näppäimistökäyttäjä voi milloin tahansa tabata ulos "modaalista", joka ei koskaan oikeasti estänyt häntä.

Sudenkuoppa, josta dokumentaatio joskus vaikenee: klikkaus <dialog>-elementin taustaan ei sulje sitä automaattisesti

Vaikka ::backdrop renderöityy ilmaiseksi, sen klikkaaminen ei sulje dialogia ilman lisäkoodia – tämä on yksi harvoista asioista, jotka pitää kirjoittaa itse. Mekanismi, joka tämän mahdollistaa, perustuu konkreettiseen faktaan hit-testauksesta: itse <dialog>-elementti modaalitilassa täyttää hit-testauksessa näkyvän alueen riippumatta siitä, kuinka pieni sen sisältölaatikko visuaalisesti on – joten klikkaus tummennettuun taustaan osuu silti kohteeseen event.target === dialogElement, ja klikkaus sisällä olevaan sisältöön osuu tiettyyn lapsielementtiin.

jsx

const handleBackdropClick = (event) => {
// event.target on itse <dialog> vain silloin, kun klikkaus osui taustaan,
// ei mihinkään sen sisällä olevaan sisältöelementtiin
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
// <dialog ref={dialogRef} onClick={handleBackdropClick}>

Tämä temppu kannattaa muistaa erikseen, koska intuitiivisesti ajattelee "tausta renderöityy itsestään, joten se varmaan sulkeutuu myös itsestään" – ei sulkeudu, ja tämä on ainoa tästä kokonaisuudesta, joka vaatii omin käsin kirjoitetun rivin JS:ää.

Sudenkuopat ja hyvät käytännöt

  • .show() ei ole sama asia kuin .showModal(). .show() avaa ei-modaalisen dialogin – ilman ::backdrop-elementtiä, ilman fokusloukkua, ilman inert-tilaa muulle sivulle. Todelliseen modaaliin tarvitset aina .showModal()-metodin.
  • <dialog>-elementin oletustyylit täytyy nollata tietoisesti, aivan kuten minkä tahansa saavutettavuusartikkelissa mainitun natiivin elementin kohdalla (<button>, <ul>) – selain antaa sille oletus-border-, padding- ja keskitysmäärityksen, jotka käytännössä lähes aina ylikirjoitat omalla CSS:llä.
  • Popover ei korvaa <dialog>-elementtiä siellä, missä modaalisuus on vaadittu. Fokusloukun ja inert-tilan puuttuminen on popoverin tarkoituksellinen rajoitus, ei kierrettävä aukko – jos sisällön täytyy estää muu sivu, oikea työkalu on aina <dialog>.showModal().
  • Tuki: <dialog> on ollut turvallinen pitkään, popover on tuoreempi. <dialog>-elementillä (mukaan lukien showModal()) on ollut vankka tuki vuodesta 2022 lähtien. popover-attribuutti saavutti Baseline-statuksen (laajasti saatavilla) myöhemmin, vuonna 2024 – kannattaa silti tarkistaa tuettu Safari-versio projekteissa, joissa on paljon vanhempia laitteita, ennen kuin luovut kokonaan JS-kirjastosta varajärjestelmänä.

Yhteenveto

Käsin <div>-elementeillä rakennettu modaali ei ole huono siksi, ettei kehittäjä yrittänyt tarpeeksi – se on huono siksi, että se yrittää toistaa JavaScriptissä mekanismin, jonka selain nyt tarjoaa natiivisti ja ilmaiseksi: z-index-arvosta riippumattoman top layerin, fokusloukun, inert-tilan muulle sivulle, ::backdrop-elementin, fokuksen palauttamisen laukaisijalle. <dialog> ja popover eivät ole kaksi muunnelmaa samasta asiasta – ne ovat kaksi työkalua saman rajan vastakkaisilla puolilla: modaalisuus, joka tietoisesti estää muun käyttöliittymän, ja kevyt, väliaikainen sisältö, joka tietoisesti ei estä sitä. Valinta niiden välillä ei ole tyylikysymys, vaan vastaus siihen, pitäisikö käyttäjän juuri sillä hetkellä pystyä tekemään mitä tahansa muuta sivulla – ja tämä kysymys kannattaa esittää itselleen ennen ensimmäisen koodirivin kirjoittamista, ei jälkikäteen, kun huomataan, että valikko estää vierittämisen tai tilin poiston vahvistusmodaali ei ollenkaan estä Tab-näppäintä.