O elemento `<dialog>` substitui overlays de div
Antes de o HTML ganhar o <dialog>, todo modal na web era uma <div> com position: fixed, um fundo feito de outra <div> e um monte de JavaScript para cuidar de foco, eventos de teclado e anúncios de leitor de tela. Essa abordagem funciona, mas reimplementa um comportamento que o elemento <dialog> oferece nativamente.
Dois modos: não modal e modal
Um elemento <dialog> tem dois modos de abertura. O primeiro, dialog.show(), o abre como um painel não modal: ele aparece na tela, mas o resto da página continua interativo. O segundo, dialog.showModal(), o abre como um diálogo modal. O navegador bloqueia a interação com tudo atrás dele, acrescenta um pseudo-elemento ::backdrop e mantém o foco do teclado dentro do diálogo.
// Não-modal: página continua interativa
dialog.show()
// Modal: página fica inerte, backdrop aparece, foco preso
dialog.showModal()
Esta aplicação usa showModal() em todo lugar. O modal de boas-vindas, a discussão e a paleta de comandos precisam bloquear a página atrás deles.
O que o navegador dá de graça
Quando você chama showModal(), o navegador faz cinco coisas que um overlay de <div> não faz sem JavaScript:
flowchart LR
A["showModal()"] --> B["Adiciona ao top layer"]
A --> C["Renderiza ::backdrop"]
A --> D["Prende foco Tab"]
A --> E["Escape fecha"]
A --> F["Marca página inerte"]
- Top layer: o diálogo vai para o top layer do navegador, acima de todo outro elemento e de qualquer
z-index, sem truques de stacking context. - Backdrop: um pseudo-elemento
::backdropaparece atrás do diálogo, mas acima de todo o resto. Você o estiliza com CSS, sem<div>extra. - Foco preso: Tab e Shift+Tab percorrem os elementos focáveis dentro do diálogo sem escapar para a página atrás.
- Tecla Escape: apertar Escape dispara um evento
cancele fecha o diálogo por padrão. - Inércia: tudo atrás do diálogo fica inerte: sem foco, sem clique e invisível para leitores de tela.
Um overlay de <div> não oferece nada disso. Você precisa implementar cada item em JavaScript. Acertar os cinco ao mesmo tempo, sobretudo a inércia e o foco preso para leitores de tela, é mais difícil do que parece.
O diálogo mais simples desta aplicação: o modal de boas-vindas
app/views/shared/_welcome_modal.html.erb é um <dialog> que abre sozinho para um usuário logado que ainda não o viu:
<dialog id="welcome-modal"
class="p-0 m-auto w-full max-w-xl bg-transparent backdrop:bg-black/50"
aria-labelledby="welcome-modal-heading"
data-controller="welcome-modal"
data-action="close->welcome-modal#markSeen click->welcome-modal#backdrop">
<div class="relative flex flex-col gap-6 rounded-2xl border border-border-subtle bg-card p-8 shadow-2xl">
<button type="button" data-action="click->welcome-modal#dismiss" class="...">
<svg aria-hidden="true"><!-- ícone X --></svg>
<span class="sr-only"><%= t("welcome_modal.close_aria") %></span>
</button>
<h2 id="welcome-modal-heading"><%= t("welcome_modal.heading") %></h2>
<%# ... conteúdo do corpo ... %>
</div>
</dialog>
O controller Stimulus chama showModal() no connect. A persistência está ligada ao evento nativo close do próprio diálogo, por meio de data-action="close->welcome-modal#markSeen click->welcome-modal#backdrop", e não ao clique do botão de fechar:
// app/javascript/controllers/welcome_modal_controller.js (reduzido à lógica de fechamento/persistência)
export default class extends Controller {
connect() {
this.marked = false
this.show(0)
this.element.showModal()
}
// Toda rota de fechamento cai aqui: os botões, o backdrop, e Escape.
markSeen() {
if (this.marked) return
this.marked = true
fetch(this.urlValue, {
method: "PATCH",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify({ user_preference: { has_seen_welcome_modal: true } })
})
}
dismiss() {
this.element.close()
}
backdrop(event) {
if (event.target === this.element) this.dismiss()
}
}
Essa escolha importa. Um <dialog> também fecha com Escape, que nunca chega a um handler de clique. Ligar a persistência só aos botões faria o modal voltar na visita seguinte para quem o dispensou com Escape. Por isso o markSeen escuta o evento close do próprio diálogo, que dispara seja qual for o jeito de fechar.
O controller não cuida de foco preso, de Escape nem de desenhar o fundo. O navegador faz tudo isso porque o código chamou showModal() em vez de show().
Fechando: close() e o evento close
dialog.close() tira o diálogo do top layer, dispara um evento close e devolve o foco ao elemento que o tinha antes do showModal(). Essa última parte acontece sozinha. Com um overlay de <div>, você precisa guardar o elemento que tinha foco e devolvê-lo à mão ao fechar; com <dialog>, o navegador acompanha isso por você.
Para onde este curso vai
Esta primeira aula apresentou o <dialog> como substituto de overlays de <div>. Ela mostrou o que o navegador oferece nativamente (top layer, fundo, foco preso, Escape, inércia) e como o diálogo mais simples desta aplicação o usa. As seis aulas seguintes vão além:
- Aula 2: o pseudo-elemento
::backdrope como estilizá-lo com CSS. - Aula 3: gerenciamento de foco, ou seja, o que é "prender" o foco, o
autofocuse a devolução do foco ao fechar. - Aula 4: navegação por teclado, com Escape, ordem de Tab e como impedir a rolagem da página de trás.
- Aula 5: leitores de tela, com roles, labels e live regions de ARIA.
- Aula 6: o padrão reutilizável do controller Stimulus
ui--dialog. - Aula 7: juntando tudo, o ciclo completo de um diálogo acessível.
Continue com a aula 2
Estilizando o pseudo-elemento `::backdrop`
Estilização do ::backdrop com Tailwind, três padrões de fundo da aplicação, tokens de cor e animação do fundo.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.