O elemento `<dialog>` substitui overlays de div

Prévia grátis 5 min de leitura

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"]
  1. 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.
  2. Backdrop: um pseudo-elemento ::backdrop aparece atrás do diálogo, mas acima de todo o resto. Você o estiliza com CSS, sem <div> extra.
  3. Foco preso: Tab e Shift+Tab percorrem os elementos focáveis dentro do diálogo sem escapar para a página atrás.
  4. Tecla Escape: apertar Escape dispara um evento cancel e fecha o diálogo por padrão.
  5. 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 ::backdrop e como estilizá-lo com CSS.
  • Aula 3: gerenciamento de foco, ou seja, o que é "prender" o foco, o autofocus e 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.
Aula 1 de 7 · grátis

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.

Digite para buscar...