</> HTML5Advent
ENFRESDEITPT

// html · Web Platform Advent #18

O elemento HTML dialog: modais reais sem biblioteca

O elemento HTML dialog oferece um modal de verdade: camada superior, página inerte, backdrop e tratamento do Esc, apenas com marcação simples. Veja showModal vs show, form method=dialog, returnValue, o atributo closedby e o hábito a evitar.

Um editor de código em modo escuro a mostrar um ficheiro index.html com as etiquetas doctype e head

Todos os programadores front-end já construíram um modal à mão: uma div com posição fixa, uma guerra de z-index, um listener de clique no exterior, um tratador do Esc e alguns malabarismos com o foco para que o teclado não se perca por trás da sobreposição. O elemento <dialog> substitui tudo isso por uma etiqueta e uma chamada de método. Eis o que ele faz na realidade, e os dois ou três detalhes que decidem se a sua caixa de diálogo se comporta como deve.

A caixa de diálogo funcional mais pequena

Uma caixa de diálogo é marcação inerte até que a abra a partir do JavaScript:

<dialog id="confirm">
  <p>Eliminar este ficheiro?</p>
  <button autofocus>Cancelar</button>
</dialog>

<button id="open">Eliminar</button>
const dialog = document.getElementById("confirm");
document.getElementById("open")
  .addEventListener("click", () => dialog.showModal());

Essa única chamada a showModal() faz consideravelmente mais do que tornar um elemento visível.

showModal() versus show(): a diferença que conta

Estes dois métodos não são variações sobre o mesmo tema. Produzem coisas genuinamente diferentes.

O showModal() abre uma caixa de diálogo modal. O navegador coloca-a na camada superior, por isso ela é desenhada acima de toda a página sem envolver qualquer z-index. Desenha um pseudo-elemento ::backdrop por trás dela. Torna todo o resto inerte, o que significa que o resto do documento não pode ser clicado nem alcançado com o tabulador. E define implicitamente aria-modal="true".

O show() abre uma caixa de diálogo não modal. A página continua interativa, não há camada superior, não há backdrop, e aria-modal é false.

A inércia é a parte que as pessoas subestimam. Tornar o fundo não clicável é fácil; torná-lo inacessível ao tabulador é a parte que os modais feitos à mão quase sempre erram, e aqui o navegador trata disso por si.

O hábito a abandonar: o atributo open

Um <dialog> tem também um atributo booleano open, e recorrer a ele é o erro mais comum com este elemento.

<!-- funciona, mas isto NÃO é um modal -->
<dialog open>Sou não modal, independentemente do que esperava.</dialog>

Uma caixa de diálogo mostrada através do atributo open é sempre não modal. Sem camada superior, sem backdrop, sem página inerte. A MDN recomenda usar show() ou showModal() em vez do atributo, e descreve alternar open à mão como não sendo a prática recomendada. Se o seu modal parece correto mas a página por trás dele continua a responder aos cliques, é quase de certeza por isto.

Um wireframe desenhado à mão em papel, com retângulos cruzados a representar imagens e linhas pautadas a representar texto corrido
Um wireframe de interface desenhado à mão, com retângulos cruzados a representar imagens e linhas pautadas a representar texto. As caixas de diálogo de confirmação costumam ser esboçadas nesta fase e reconstruídas de raiz em cada projeto.

form method="dialog": a funcionalidade sem equivalente

Esta é a parte do <dialog> que nada mais replica, e é a razão pela qual uma caixa de diálogo de confirmação pode não precisar de JavaScript algum para além do que a abre.

<dialog id="confirm">
  <form method="dialog">
    <p>Eliminar este ficheiro?</p>
    <button value="cancel" autofocus>Cancelar</button>
    <button value="delete">Eliminar</button>
  </form>
</dialog>

Quando um formulário dentro de uma caixa de diálogo é submetido com method="dialog", a caixa fecha-se em vez de enviar um pedido. Os estados dos controlos do formulário são guardados mas não submetidos, e o returnValue da caixa de diálogo passa a ter o valor do botão que foi ativado.

Depois basta ler a resposta:

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "delete") {
    // o utilizador confirmou
  }
});

Dois botões, um atributo, e fica a saber qual foi carregado. Sem tratadores de clique, sem invólucro de promessa, sem variável de estado.

A armadilha dos campos obrigatórios

Um comportamento surpreende as pessoas à primeira vez e vale a pena conhecê-lo antes que lhe custe uma tarde.

Se a caixa de diálogo contiver um formulário com campos obrigatórios, a submissão continua a executar a validação. Um botão simples não consegue fechar a caixa enquanto esses campos forem inválidos, o que significa que o seu botão Cancelar deixa de funcionar precisamente quando o utilizador mais precisa dele. As respostas documentadas são adicionar formnovalidate ao botão que dispensa a caixa, ou chamar dialog.close() a partir do seu próprio código:

<button value="cancel" formnovalidate>Cancelar</button>

O Esc e o atributo closedby

A dispensa pelo teclado não é uniforme, e a diferença segue a divisão entre modal e não modal.

Uma caixa de diálogo aberta com showModal() pode ser dispensada com Esc por predefinição. Uma caixa de diálogo não modal não se dispensa com Esc por predefinição.

O atributo closedby torna isto explícito em vez de implícito:

<dialog closedby="any">     <!-- Esc, clique no exterior, ou o seu código -->
<dialog closedby="closerequest">  <!-- Esc ou o seu código -->
<dialog closedby="none">    <!-- apenas o seu código -->

O closedby="any" é o que lhe dá o light-dismiss, o comportamento de clique no exterior que normalmente se escreve à mão. O closedby="none" destina-se à rara caixa de diálogo que não pode ser dispensada acidentalmente, e traz consigo uma obrigação: tem de fornecer uma saída visível.

Acertar na acessibilidade

O elemento entrega-lhe a maior parte disto, mas três pontos ficam do seu lado.

Use o autofocus de forma deliberada. Com showModal(), o foco recai sobre o primeiro elemento focável aninhado. Muitas vezes não é aquele que quer. Coloque autofocus no elemento com que o utilizador deve interagir de imediato, ou no botão de fechar se nada mais for mais urgente.

Forneça sempre um botão de fechar. A forma mais robusta de garantir que todos os utilizadores conseguem sair da caixa de diálogo é um botão explícito, não apenas o Esc ou um clique no exterior.

Não coloque tabindex na própria caixa de diálogo. Ela não é interativa e não recebe o foco. O seu conteúdo é que recebe.

Estilizar o backdrop

A camada escurecida por trás de um modal é um pseudo-elemento real que pode estilizar:

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
  backdrop-filter: blur(2px);
}

Só existe para caixas de diálogo abertas com showModal(), o que é outra forma de verificar de relance se está mesmo em modo modal.

dialog ou popover?

Ambos são desenhados na camada superior, por isso a questão surge constantemente. A distinção tem a ver com a interrupção.

Use <dialog> com showModal() quando o utilizador tem de resolver algo antes de continuar: uma confirmação, uma escolha obrigatória, um formulário que bloqueia o fluxo. Bloquear a página é precisamente o objetivo.

Use a Popover API para interfaces de sobreposição transitórias que não devem interromper: menus, tooltips, cartões, notificações. Aí a página deve continuar utilizável.

Um teste útil: se fosse errado o utilizador ignorá-lo e seguir em frente, é uma caixa de diálogo. Se ignorá-lo for uma resposta perfeitamente razoável, é um popover.

Em resumo

O elemento <dialog> transforma um pedaço genuinamente difícil de interface em marcação. Abra-o com showModal() e obtém a camada superior, um backdrop, uma página inerte e o tratamento do Esc sem escrever nada disso. Use form method="dialog" e returnValue e obtém a resposta do utilizador sem um único tratador de clique. Evite o atributo open, lembre-se do formnovalidate no seu botão de cancelar, e coloque o autofocus você mesmo.

O que fica é uma caixa de diálogo de confirmação em cerca de quinze linhas de HTML que se comporta corretamente para utilizadores de teclado e de leitores de ecrã, o que é mais do que a maioria das que foram construídas à mão e que ela substitui.

Perguntas frequentes

Qual é a diferença entre show() e showModal()?
O showModal() abre uma caixa de diálogo modal: o navegador coloca-a na camada superior, desenha um ::backdrop por trás dela, torna o resto da página inerte para que nada no exterior possa ser clicado ou alcançado com o tabulador, e define implicitamente aria-modal="true". O show() abre uma caixa de diálogo não modal: o resto da página continua interativo, não há camada superior nem backdrop, e aria-modal é false. Se quer um modal a sério, showModal() é o método certo.
Devo abrir uma caixa de diálogo com o atributo open?
Não. A MDN afirma claramente que é recomendado usar o método show() ou showModal() em vez do atributo open. Uma caixa de diálogo mostrada através do atributo open é sempre não modal, por isso não obtém nada da camada superior, do backdrop ou da inércia que provavelmente pretendia. Alternar o atributo à mão é explicitamente descrito como não sendo a prática recomendada.
O que faz o form method="dialog"?
Um formulário dentro de uma caixa de diálogo com method="dialog" fecha a caixa quando é submetido, em vez de enviar um pedido. Os estados dos controlos do formulário são guardados mas não submetidos, e a propriedade returnValue da caixa de diálogo passa a ter o valor do botão que foi ativado. Isso diz-lhe que botão o utilizador carregou sem qualquer JavaScript.
A tecla Esc fecha uma caixa de diálogo?
Para um modal aberto com showModal(), sim: pode ser dispensado com Esc por predefinição. Para uma caixa de diálogo não modal, não, o Esc não a dispensa por predefinição. O atributo closedby permite alterar isto: closerequest permite o Esc, any permite também um clique no exterior, e none significa que só o seu próprio código a pode fechar.
Porque é que a minha caixa de diálogo não fecha quando o formulário tem um campo obrigatório?
Porque o formulário continua a executar a validação. Se a caixa de diálogo contiver campos obrigatórios, um botão de submissão simples não a consegue fechar enquanto esses campos forem inválidos. Adicione formnovalidate ao botão destinado a dispensar a caixa de diálogo, ou chame dialog.close() a partir do seu próprio código.