Damian Słoński

Element Dialog, czyli modal przyszłości 💭

Kilka porad dotyczących jednej z podstawowych i często zaniedbywanych funkcjonalności w kontekście a11y.

  • web
  • accessibility
Element Dialog, czyli modal przyszłości 💭

Po co wychodzić poza treść? 🤔

Dodatkowe okienka "wyskakujące" przed główną treść strony są powszechnym elementem internetu. Wykorzystywane są do pokazywania dodatkowych, domyślnie ukrytych informacji, do ostrzeżeń przed podjęciem nieodwracalnej decyzji lub do zalewania użytkownika spamem reklam, newsletterów i zgód na przetwarzanie wszelkich możliwych do zdobycia danych.

Powinniśmy pamiętać, że z powodu, iż modal może całkowicie zakryć i "odciąć" użytkownika od pozostałych elementów interfejsu – jest bardzo potężnym narzędziem. Jak to potężne narzędzie – powinien być wykorzystywany z rozsądkiem, a nie zawsze, kiedy tylko to możliwe.

Jak to jednak zrobić poprawnie technicznie, uwzględniając możliwości współczesnych przeglądarek oraz dobre praktyki w zakresie accessibility i wydajności?

Jaki tag HTML wybrać? 🧱

Przez lata dla wyskakujących okienek stosowany był najczęściej element, który ma najbardziej uniwersalne zastosowanie, czyli <div>. Tag ten nie ma żadnego znaczenia semantycznego i może być stosowany w jakimkolwiek przypadku, kiedy potrzebujemy zgrupować kilka elementów w jedną całość, a nie mamy do tego bardziej semantycznego znacznika, jak np. <header>, <section> czy <nav>.

Jednak od niedawna, wszystkie współczesne przeglądarki internetowe wspierają już nowy znacznik, którego natywnym przeznaczeniem są wszelkiego rodzaju okienka, prezentowane użytkownikowi ponad resztą interfejsu. Jest to element <dialog>.

Semantyka i API przeglądarek

Element <dialog> podobnie jak <div> jest elementem blokowym, ale poza samą semantyką, w przeciwieństwie do tagów takich, jak np. wspomniane wcześniej header, section czy nav, ma kilka dodatkowych właściwości, które wyróżniają go na tle pozostałych elementów HTML.

1<dialog>
2 <p>Are you sure you want to remove your save games? 💾</p>
3 <button id="cancel" type="button">Cancel</button>
4 <button id="remove" type="button">Remove</button>
5</dialog>

Domyślnie element <dialog> jest ukryty.

Ukryty Dialog w drzewie DOM
Ukryty Dialog w drzewie DOM

Aby wyświetlić go użytkownikowi, powinno się korzystać z dedykowanej do tego metody open (modelessly) lub openModal (która wyświetla <dialog> w postaci modala i rozszerza go o kilka ciekawych właściwości), do zamykania służy natomiast metoda close.

1const dialog = document.querySelector("dialog")
2const openButton = document.querySelector("#open-modal")
3const closeButton = document.querySelector("#close-modal")
4
5openButton.addEventListener("click", () => {
6 dialog.showModal()
7})
8
9closeButton.addEventListener("click", () => {
10 dialog.close()
11})

Element <dialog> wyposażony jest w dodatkowy atrybut open, którym również możemy sterować w celu wyświetlania i ukrywania modala. Niestety obecnie jedynie przy wykorzystaniu metody showModal otrzymujemy wspomniane wcześniej, dodatkowe, unikalne właściwości, takie jak pseudoelement ::backdrop, który służy jako tło dla modala.

Po za metodą close, modal możemy zamknąć też submitując form, który znajduje się w środku elementu <dialog> i posiada atrybut method ustawiony na dialog.

1<dialog>
2 <form method="dialog">
3 ...
4 <button type="submit">Send</button>
5 </form>
6</dialog>

Accessibility

Dialog otwarty metodą showModal, w przeciwieństwie do innych elementów HTML wyposażony jest out of the box w funkcjonalność, która polega na automatycznym focusowaniu pierwszego "focusowalnego" elementu wewnątrz po otwarciu (więcej na ten temat możesz dowiedzieć się w innym moim poście: Atrakcyjny i funkcjonalny focus ♿️) oraz ma możliwości zamknięcia okienka za pomocą klawisza Esc.

❌ Twoje przeglądarka nie wspiera jeszcze tej funkcjonalności!
ℹ️ Sprawdź wsparcie: caniuse.com/dialog

Pseudoelement ::backdrop

Zarówno dla okienka, jak i dla tła, za które odpowiada pseudolement ::backdrop można oczywiście dodać w pełni customowe style.

1dialog {
2 padding: 64px;
3 border: none;
4 border-radius: 8px;
5 box-shadow: 0 0 8px 8px rgba(0 0 0 / 0.1);
6}
7
8dialog::backdrop {
9 background: rgb(0 0 0 / 0.5);
10 backdrop-filter: blur(2px);
11}

Problem ze scrollem wewnątrz 🖱

Zachowaniem, które często nie jest pożądane, a występuje domyślnie, jest możliwość scrollowania głównej części interfejsu w tle, po otwarciu modala, co czasami może utrudniać scrollowanie wewnątrz okienka.

Rozwiązaniem tego problemu jest warunkowe ustawienie overflow: hidden dla elementu <html>, co zablokuje możliwość scrollowania zawartości głównego okna przeglądarki (działa w większości przeglądarek). Przy wykorzystaniu nowego selektora :has, można to zrobić bez linijki JavaScriptu.

1html:has(dialog[open]) {
2 overflow: hidden;
3}

Pseudoklasa :has jest bardzo potężną, ale jednak nowością i wspierana jest tylko przez najnowsze Safari oraz Chrome. Rozwiązanie uniwersalne z wykorzystaniem JavaScript oraz specjalnego dla <dialog> eventu close:

1html[data-modal-open="true"] {
2 overflow: hidden;
3}
1const dialog = document.querySelector("dialog")
2const openButton = document.querySelector("#open-modal")
3const htmlElement = document.documentElement
4
5openButton.addEventListener("click", () => {
6 htmlElement.dataset.modalOpen = true
7 dialog.showModal()
8})
9
10dialog.addEventListener("close", () => {
11 htmlElement.dataset.modalOpen = false
12})

Warto pamiętać, że blokowanie scrolla w tle nie zawsze musi być dobrym rozwiązaniem, dlatego w większych aplikacjach warto się zastanowić, czy rozwiązanie to powinno być uniwersalne dla wszystkich modali, czy jednak stosowane indywidualnie.

Animowanie elementu <dialog>

Ze względu na fakt, iż widoczność modala sterowana jest za pomocą zmiany właściwości display, to użycie transition na opacity nie wchodzi w grę. Najprostszy sposób to dodanie animacji za pomocą właściwości animation, aczkolwiek animacja taka będzie pojawiać się jedynie na wejściu modala, a jego zamykanie będzie odbywać się już natychmiastowo.

1@keyframes fadeIn {
2 from {
3 opacity: 0;
4 }
5 to {
6 opacity: 1;
7 }
8}
9
10dialog,
11dialog::backdrop {
12 animation: show 0.2s ease-in;
13}

Może wydawać się, że jest to tylko częściowy i w pewien sposób wybrakowany efekt, ale według mnie jest naprawdę dobrym kompromisem pomiędzy aspektami wizualnymi a użytecznością.

Pamiętajmy, że animacje interfejsów powinny z zasady być raczej szybkie i subtelne, ponieważ ich zadaniem jest przede wszystkim pomagać użytkownikowi w korzystaniu z interfejsu, a nie przeszkadzać! Zbyt długie, powolne pojawianie się (a tym bardziej znikanie) elementów takich jak właśnie modal, nawet przy efektach zapierających dech w piersiach, może zapierać dech, ale za pierwszym czy drugim razem. Później w sporej większości przypadków, będzie to raczej zachowanie irytujące użytkownika, który chce dostać się do jakiegoś miejsca szybko i sprawnie.

Umiejscowienie w drzewie DOM i Portale 🌳

Dodatkową, ciekawą właściwością <dialog> (tylko w przypadku korzystania z metody showModal) jest fakt, iż ignoruje on całkowicie wartości z-index innych elementów i zawsze jest na samej górze.

Pomimo tego, dobrą praktyką jest, aby element z modalem (niezależnie od tego, czy jest to <dialog>, czy inny tag HTML) nie był zagnieżdżony głęboko w drzewie DOM. O ile w przypadku korzystania z czystego HTMLa i JavaScriptu nie jest to szczególnie trudne do osiągnięcia, o tyle w przypadku framework-ów opartych o architekturę komponentów (jak React czy Vue) nie jest to takie proste, aby wyciągnąć jakiś element "poza" zagnieżdżony komponent, w którym jest renderowany i w którym zarządzamy stanem.

1<body>
2 <main>
3 <section>
4 <form>...</form>
5 </section>
6 </main>
7 <dialog>...</dialog>
8</body>
1import * as React from "react"
2
3export default function App() {
4 <main>
5 <FormSection />
6 </main>
7)
8
9const FormSection = () => (
10 <section>
11 <form>...</form>
12 <Dialog /> {/* Dialog shouldn't render here */}
13 </section>
14)
15
16const Dialog = () => <dialog>...</dialog>

Do takich przypadków służą tzn. Portale. Dzięki nim możemy wskazać inny węzeł drzewa DOM, w którym komponent powinien się wyrenderować.

1<body>
2 <div id="root"></div>
3 <div id="modals"></div>
4</body>
1import * as React from "react"
2import ReactDOM from "react-dom"
3
4export default function App() {
5 <main>
6 <FormSection />
7 </main>
8)
9
10const FormSection = () => <section>...</section>
11
12const Dialog = () => {
13 const modalsNode = document.getElementById("modals")
14 return ReactDOM.createPortal(<dialog>...</dialog>, modalsNode)
15}

Wspominając wcześniejsze ograniczenia, możemy sterować widocznością modala za pomocą atrybutu open (bez wszystkich specjalnych funkcjonalności).

1import * as React from "react"
2import ReactDOM from "react-dom"
3
4const Dialog = ({ isOpen, onClose }) => {
5 return ReactDOM.createPortal(
6 <dialog open={isOpen}>
7 <button onClick={onClose}>Close</button>
8 </dialog>,
9 document.getElementById("modals")
10 )
11}
12
13export default function App() {
14 const [isModalOpen, setIsModalOpen] = React.useState(false)
15
16 const openDialog = () => {
17 setIsModalOpen(true)
18 }
19
20 const closeDialog = () => {
21 setIsModalOpen(false)
22 }
23
24 return (
25 <main>
26 <button onClick={openDialog}>Open</button>
27 <Dialog isOpen={isModalOpen} onClose={closeDialog} />
28 </main>
29 )
30}

Albo posłużyć się hookiem useEffect, który na podstawie propsów będzie triggerował odpowiednie metody (wersja full).

1import * as React from "react"
2import ReactDOM from "react-dom"
3
4const Dialog = ({ isOpen, onClose }) => {
5 const ref = React.useRef(null)
6
7 React.useEffect(() => {
8 if (isOpen) {
9 ref.current?.showModal()
10 } else {
11 ref.current?.close()
12 }
13 }, [isOpen])
14
15 return ReactDOM.createPortal(
16 <dialog ref={ref} onClose={onClose}>
17 <button onClick={onClose}>Close</button>
18 </dialog>,
19 document.getElementById("modals")
20 )
21}
22
23export default function App() {
24 const [isModalOpen, setIsModalOpen] = React.useState(false)
25
26 const openDialog = () => {
27 setIsModalOpen(true)
28 }
29
30 const closeDialog = () => {
31 setIsModalOpen(false)
32 }
33
34 return (
35 <main>
36 <button onClick={openDialog}>Open</button>
37 <Dialog isOpen={isModalOpen} onClose={closeDialog} />
38 </main>
39 )
40}

Podsumowanie 📋

Prawdopodobnie w przyszłości element <dialog> stanie się powszechnie wykorzystywany, podobnie jak dzisiaj <nav> czy <footer>. Na chwilę obecną jednak wsparcie przeglądarek może budzić uzasadnione obawy co do tego, czy jest to dobry moment na korzystanie z tego elementu. Rozwiązaniem wartym przemyślenia jest skorzystanie z <dialog> już dzisiaj, posiłkując się odpowiednim pollyfilem, który zadba o fallback w niewspierających przeglądarkach.

Natomiast jeśli z jakiś jeszcze powodów, całkowicie wstrzymujemy się obecnie z korzystania z <dialog>, to ze względu na dostępność, warto pamiętać o dodaniu atrybutów aria-modal oraz role z odpowiednimi wartościami do elementu, który służy za modal.

1<div aria-modal="true" role="dialog">...</div>