Como criar um componente de caixa de diálogo

Uma visão geral básica de como criar mini e mega modais adaptáveis a cores, responsivos e acessíveis com o elemento <dialog>.

Nesta postagem, quero compartilhar minha opinião sobre como criar mini e mega modais adaptáveis a cores, responsivos e acessíveis com o elemento <dialog>. Teste a demonstração e confira a origem.

Demonstração das caixas de diálogo mega e mini nos temas claro e escuro.

Se você preferir o vídeo, aqui está uma versão do YouTube desta postagem:

Visão geral

O elemento <dialog> é ótimo para informações ou ações contextuais in-page. Considere quando a experiência do usuário pode se beneficiar de uma ação na mesma página em vez da ação de várias páginas: talvez porque o formulário seja pequeno ou a única ação necessária do usuário seja confirmar ou cancelar.

O elemento <dialog> se tornou estável em vários navegadores:

Compatibilidade com navegadores

  • 37
  • 79
  • 98
  • 15,4

Origem

Achei que faltavam algumas coisas no elemento. Por isso, neste Desafio de GUI, eu adiciono os itens de experiência do desenvolvedor que eu espero: outros eventos, dispensa de luz, animações personalizadas e um mini e megatipo.

Marcação

Os elementos essenciais de um elemento <dialog> são modestos. O elemento ficará oculto automaticamente e tem estilos integrados para se sobrepor ao conteúdo.

<dialog>
  …
</dialog>

Podemos melhorar essa linha de base.

Tradicionalmente, um elemento de caixa de diálogo compartilha muito com um modal, e geralmente os nomes são intercambiáveis. Tomei a liberdade de usar o elemento de caixa de diálogo para caixas de diálogo pequenas (mini) e de página inteira (mega). Eu as nomeei "mega" e "mini", com as duas caixas de diálogo ligeiramente adaptadas para diferentes casos de uso. Adicionei um atributo modal-mode para permitir que você especifique o tipo:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

Captura de tela das caixas de diálogo &quot;Min&quot; e &quot;mega&quot; nos temas claro e escuro.

Nem sempre, mas geralmente os elementos da caixa de diálogo são usados para coletar algumas informações de interação. Os formulários dentro de elementos de caixa de diálogo são feitos para funcionar juntos. É recomendável fazer com que um elemento de formulário envolva o conteúdo da caixa de diálogo para que o JavaScript possa acessar os dados que o usuário inseriu. Além disso, os botões em um formulário que usam method="dialog" podem fechar uma caixa de diálogo sem JavaScript e transmitir dados.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

Megacaixa de diálogo

Uma megacaixa de diálogo tem três elementos dentro do formulário: <header>, <article> e <footer>. Eles servem como contêineres semânticos e destinos de estilo para a apresentação da caixa de diálogo. O cabeçalho intitula o modal e oferece um botão "Fechar". O artigo é sobre entradas de formulário e informações. O rodapé contém um <menu> de botões de ação.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

O primeiro botão de menu tem autofocus e um manipulador de eventos inline onclick. O atributo autofocus vai receber foco quando a caixa de diálogo for aberta. A prática recomendada é colocar isso no botão de cancelamento, não no de confirmação. Isso garante que a confirmação seja deliberada e não acidental.

Minicaixa de diálogo

A minicaixa de diálogo é muito semelhante à megacaixa de diálogo, só tem um elemento <header>. Isso permite que ele seja menor e mais inline.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

O elemento da caixa de diálogo fornece uma base sólida para um elemento completo da janela de visualização que pode coletar dados e interações do usuário. Esses elementos essenciais podem gerar algumas interações muito interessantes e poderosas no seu site ou app.

Acessibilidade

O elemento da caixa de diálogo tem uma acessibilidade integrada muito boa. Em vez de adicionar esses recursos, como de costume, muitos já estão lá.

Restaurando o foco

Como fizemos manualmente em Como criar um componente de navegação lateral, é importante que abrir e fechar algo coloque o foco nos botões de abrir e fechar relevantes. Quando essa navegação lateral é aberta, o foco é colocado no botão "Fechar". Quando o botão "Fechar" é pressionado, o foco é restaurado no botão que o abriu.

Com o elemento da caixa de diálogo, este é o comportamento padrão integrado:

Infelizmente, se você quiser animar a caixa de diálogo, essa funcionalidade será perdida. Na seção JavaScript, vou restaurar essa funcionalidade.

Foco de detecção

O elemento da caixa de diálogo gerencia inert para você no documento. Antes de inert, o JavaScript era usado para monitorar o foco que sai de um elemento e, nesse momento, ele intercepta e o coloca de volta.

Compatibilidade com navegadores

  • 102
  • 102
  • 112
  • 15.5

Origem

Depois de inert, qualquer parte do documento pode ficar "congelada", desde que não seja mais um alvo de foco ou seja interativa com um mouse. Em vez de capturar o foco, ele é guiado para a única parte interativa do documento.

Abrir e focar automaticamente em um elemento

Por padrão, o elemento da caixa de diálogo atribuirá o foco ao primeiro elemento focalizável na marcação da caixa de diálogo. Se esse não for o melhor elemento para o usuário usar como padrão, use o atributo autofocus. Conforme descrito anteriormente, acho que a prática recomendada é usar o botão "Cancel", e não o de confirmação. Isso garante que a confirmação seja deliberada e não acidental.

Fechar com a tecla Esc

É importante facilitar o fechamento desse elemento que pode causar interrupções. Felizmente, o elemento de caixa de diálogo processará a tecla de escape para você, liberando-o da carga de orquestração.

Estilos

Há um caminho fácil para estilizar o elemento da caixa de diálogo e um caminho difícil. Para conseguir o caminho mais fácil, não há mudanças na propriedade de exibição da caixa de diálogo nem o trabalho com as limitações. Sigo o caminho difícil para fornecer animações personalizadas para abrir e fechar a caixa de diálogo, assumindo a propriedade display e muito mais.

Como definir o estilo com propriedades abertas

Para acelerar as cores adaptáveis e a consistência geral do design, coloquei de maneira descarada minha biblioteca de variáveis CSS Open Props. Além das variáveis fornecidas sem custo financeiro, também importo um arquivo normalize e alguns botões, ambos fornecidos pelo Open Props como importações opcionais. Essas importações me ajudam a me concentrar na personalização da caixa de diálogo e da demonstração, sem precisar de muitos estilos para oferecer suporte e melhorar a aparência.

Definir o estilo do elemento <dialog>

Possuir a propriedade de exibição

O comportamento padrão de mostrar e ocultar de um elemento de caixa de diálogo alterna a propriedade de exibição de block para none. Infelizmente, isso significa que não pode ser animado para dentro e para fora, apenas dentro. Eu quero animar tanto para dentro quanto para fora, e a primeira etapa é definir minha própria propriedade display:

dialog {
  display: grid;
}

Quando você muda e, portanto, tem o valor da propriedade de exibição, como mostrado no snippet de CSS acima, uma quantidade considerável de estilos precisa ser gerenciada para facilitar a experiência do usuário adequada. Primeiro, o estado padrão de uma caixa de diálogo é fechado. É possível representar esse estado visualmente e impedir que a caixa de diálogo receba interações com os estilos abaixo:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

Agora a caixa de diálogo fica invisível, e não é possível interagir quando ela não está aberta. Mais tarde, vou adicionar JavaScript para gerenciar o atributo inert na caixa de diálogo, garantindo que os usuários de teclado e leitor de tela também não consigam acessar a caixa de diálogo oculta.

Aplicar um tema de cor adaptável ao diálogo

Grande caixa de diálogo mostrando os temas claro e escuro, demonstrando as cores da superfície.

Embora o color-scheme ative seu documento em um tema de cores adaptável fornecido pelo navegador para as preferências do sistema claro e escuro, eu queria personalizar o elemento da caixa de diálogo mais do que isso. As propriedades abertas oferecem algumas cores de superfície que se adaptam automaticamente às preferências claras e escuras do sistema, semelhante ao uso do color-scheme. Eles são ótimos para criar camadas em um design, e eu adoro usar cores para ajudar a oferecer suporte visual para essa aparência de superfícies de camada. A cor do plano de fundo é var(--surface-1). Para ficar em cima dessa camada, use var(--surface-2):

dialog {
  …
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

Cores mais adaptáveis serão adicionadas posteriormente para elementos filhos, como cabeçalho e rodapé. Eu os considero como um elemento extra para o diálogo, mas são muito importantes para criar um design atrativo e bem projetado.

Dimensionamento responsivo de caixas de diálogo

Por padrão, a caixa de diálogo delega o tamanho dela ao conteúdo, o que geralmente é ótimo. Meu objetivo aqui é restringir o max-inline-size a um tamanho legível (--size-content-3 = 60ch) ou a 90% da largura da janela de visualização. Isso garante que a caixa de diálogo não fique de ponta a ponta em um dispositivo móvel e não seja tão larga em uma tela de computador a ponto de ser difícil de ler. Em seguida, adiciono um max-block-size para que a caixa de diálogo não exceda a altura da página. Isso também significa que precisamos especificar onde está a área rolável da caixa de diálogo, caso ela seja um elemento alto.

dialog {
  …
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

Percebeu como eu tenho max-block-size duas vezes? O primeiro usa 80vh, uma unidade física da janela de visualização. O que eu realmente quero é manter a caixa de diálogo dentro do fluxo relativo, para usuários internacionais. Portanto, uso a unidade dvb lógica, mais recente e apenas parcialmente compatível na segunda declaração para quando ela se tornar mais estável.

Posicionamento de megacaixas de diálogo

Para ajudar no posicionamento de um elemento da caixa de diálogo, vale a pena dividir as duas partes: o pano de fundo em tela cheia e o contêiner da caixa de diálogo. O pano de fundo precisa cobrir tudo, fornecendo um efeito de sombra para ajudar a indicar que essa caixa de diálogo está na frente e o conteúdo atrás dela precisa estar inacessível. O contêiner da caixa de diálogo pode se centralizar sobre esse pano de fundo e assumir a forma que o conteúdo exigir.

Os estilos abaixo fixam o elemento da caixa de diálogo na janela, estendendo-o para cada canto e usam margin: auto para centralizar o conteúdo:

dialog {
  …
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Estilos de megacaixa de diálogo para dispositivos móveis

Em pequenas janelas de visualização, estilo esse mega modal de página inteira de forma um pouco diferente. Eu defino a margem inferior como 0, o que leva o conteúdo da caixa de diálogo para a parte de baixo da janela de visualização. Com alguns ajustes de estilo, posso transformar a caixa de diálogo em uma actionsheet mais próxima dos polegares do usuário:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

Captura de tela do DevTools sobrepondo o espaçamento de margem 
  na caixa de diálogo do Mega para computadores e dispositivos móveis quando aberto.

Posicionamento da minicaixa de diálogo

Ao usar uma janela de visualização maior, como em um computador desktop, optei por posicionar as mini caixas de diálogo sobre o elemento que as chama. Para fazer isso, preciso do JavaScript. Você pode encontrar a técnica que eu uso aqui, mas acho que está além do escopo deste artigo. Sem o JavaScript, a minicaixa de diálogo aparece no centro da tela, como uma megacaixa de diálogo.

Destaque os dados

Por fim, dê um toque especial à caixa de diálogo para que ela pareça uma superfície macia bem acima da página. A suavidade é alcançada arredondando os cantos do diálogo. A profundidade é alcançada com um dos acessórios de sombra cuidadosamente elaborados pela Open Props:

dialog {
  …
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

Personalizar o pseudoelemento do pano de fundo

Escolhi trabalhar bem com o pano de fundo, adicionando apenas um efeito de desfoque com backdrop-filter à caixa de diálogo mega:

Compatibilidade com navegadores

  • 76
  • 79
  • 103
  • 9

Origem

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

Também optei por colocar uma transição em backdrop-filter, na esperança de que os navegadores possam permitir a transição do elemento do pano de fundo no futuro:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

Captura de tela da mega caixa de diálogo sobrepondo um plano de fundo desfocado com avatares coloridos.

Extras de estilo

Chamo essa seção de "extras" porque ela tem mais a ver com a demonstração do elemento da caixa de diálogo do que com o elemento da caixa de diálogo em geral.

Contenção de rolagem

Quando a caixa de diálogo é mostrada, o usuário ainda pode rolar a página atrás dela, o que eu não quero:

Normalmente, overscroll-behavior seria minha solução habitual, mas de acordo com a especificação, ele não tem efeito na caixa de diálogo porque não é uma porta de rolagem, ou seja, não é um botão de rolagem, por isso não há nada a ser evitado. É possível usar o JavaScript para acompanhar os novos eventos deste guia, como "fechado" e "aberto", e alternar overflow: hidden no documento ou esperar que o :has() fique estável em todos os navegadores:

Compatibilidade com navegadores

  • 105
  • 105
  • 121
  • 15,4

Origem

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

Agora, quando uma megacaixa de diálogo for aberta, o documento HTML terá overflow: hidden.

O layout <form>

Além de ser um elemento muito importante para coletar as informações de interação do usuário, ele é usado aqui para posicionar os elementos de cabeçalho, rodapé e artigo. Com esse layout, pretendo articular o artigo filho como uma área rolável. Faço isso com grid-template-rows. O elemento do artigo recebe a 1fr, e o próprio formulário tem a mesma altura máxima do elemento da caixa de diálogo. A definição dessa altura e do tamanho da linha firmes é o que permite que o elemento do artigo seja restrito e rolado quando transbordar:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

Captura de tela do DevTools sobrepondo as informações de layout de grade sobre as linhas.

Estilizar a caixa de diálogo <header>

O papel desse elemento é fornecer um título para o conteúdo da caixa de diálogo e oferecer um botão "Fechar" fácil de encontrar. Ele também recebe uma cor de superfície para fazer com que pareça ficar atrás do conteúdo do artigo na caixa de diálogo. Esses requisitos levam a um contêiner flexbox, itens alinhados verticalmente com espaço às bordas e alguns padding e lacunas para dar espaço ao título e aos botões de fechar:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

Captura de tela do Chrome Devtools sobrepondo informações de layout flexbox no cabeçalho da caixa de diálogo.

Definir o estilo do botão "Fechar" do cabeçalho

Como a demonstração usa os botões "Open Props", o botão "Fechar" é personalizado como um botão centralizado em um ícone redondo, da seguinte forma:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

Captura de tela do Chrome Devtools sobrepondo informações de dimensionamento e padding no botão &quot;Fechar&quot; do cabeçalho.

Estilizar a caixa de diálogo <article>

O elemento do artigo tem um papel especial nessa caixa de diálogo: é um espaço destinado a ser rolado no caso de uma caixa de diálogo alta ou longa.

Para isso, o elemento de formulário pai estabeleceu alguns limites para si mesmo, o que fornece restrições para que esse elemento de artigo atinja se ele ficar muito alto. Defina overflow-y: auto para que as barras de rolagem sejam exibidas apenas quando necessário. Contenha a rolagem dentro dela com overscroll-behavior: contain, e o restante será estilos de apresentação personalizados:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

A função do rodapé é conter menus de botões de ação. O Flexbox é usado para alinhar o conteúdo ao final do eixo inline do rodapé e, em seguida, um pouco de espaçamento para dar espaço aos botões.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Captura de tela do Chrome Devtools sobrepondo informações de layout flexbox no elemento de rodapé.

O elemento menu é usado para conter os botões de ação da caixa de diálogo. Ela usa um layout flexbox de encapsulamento com gap para fornecer espaço entre os botões. Os elementos de menu têm padding, como um <ul>. Também removo esse estilo, já que não preciso dele.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Captura de tela do Chrome Devtools sobrepondo informações de flexbox nos elementos do menu de rodapé.

Animação

Os elementos das caixas de diálogo geralmente são animados porque entram e saem da janela. Oferecer às caixas de diálogo algum movimento de apoio para essa entrada e saída ajuda os usuários a se orientarem no fluxo.

Normalmente, o elemento da caixa de diálogo só pode ser animado para dentro, não para fora. Isso ocorre porque o navegador alterna a propriedade display no elemento. Anteriormente, o guia define a exibição como grade e nunca a define como nenhuma. Isso desbloqueia a capacidade de animação para dentro e para fora.

Os recursos abertos vêm com muitas animações de frames-chave para uso, o que torna a orquestração fácil e legível. Aqui estão as metas de animação e a abordagem em camadas que segui:

  1. O movimento reduzido é a transição padrão, com uma simples opacidade que aparece.
  2. Se o movimento estiver correto, serão adicionadas animações de escala e deslize.
  3. O layout responsivo para dispositivos móveis da megacaixa de diálogo é ajustado para deslizar para fora.

Uma transição padrão segura e significativa

Embora as propriedades abertas tenham frames-chave para aparecer e desaparecer, prefiro essa abordagem de transições em camadas como padrão, com animações de frame-chave como possíveis upgrades. Anteriormente, já ajustamos a visibilidade da caixa de diálogo com opacidade, orquestrando 1 ou 0, dependendo do atributo [open]. Para fazer a transição entre 0% e 100%, informe ao navegador a duração e o tipo de easing que você quer:

dialog {
  transition: opacity .5s var(--ease-3);
}

Adicionar movimento à transição

Se o usuário concordar com o movimento, as caixas de diálogo mega e mini precisam deslizar para cima como entrada e ser expandidas como saída. É possível fazer isso com a consulta de mídia prefers-reduced-motion e algumas propriedades abertas:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Adaptação da animação de saída para dispositivos móveis

No início da seção de estilo, o estilo da megacaixa de diálogo foi adaptado para que os dispositivos móveis sejam mais como uma folha de ações, como se um pequeno pedaço de papel tivesse passado de baixo para cima na tela e ainda estivesse anexado à parte de baixo. A animação de saída do escalonamento horizontal não se encaixa bem nesse novo design. Podemos adaptá-la com algumas consultas de mídia e alguns Open Props:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

Há vários fatores a serem adicionados com o JavaScript:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

Essas adições são provenientes do desejo de dispensar a iluminação (clicar no plano de fundo da caixa de diálogo), animação e alguns outros eventos para melhorar o tempo ao receber os dados do formulário.

Adicionando luz dispensada

Essa tarefa é simples e é um ótimo complemento para um elemento de caixa de diálogo que não está sendo animado. A interação é alcançada observando os cliques no elemento da caixa de diálogo e aproveitando o balanço de eventos para avaliar o que foi clicado, e só vai close() se for o elemento mais acima:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

Atenção: dialog.close('dismiss'). O evento é chamado e uma string é fornecida. Essa string pode ser recuperada por outro JavaScript para receber insights sobre como a caixa de diálogo foi fechada. Você descobrirá que também forneci strings de fechamento toda vez que chamo a função usando vários botões para fornecer contexto ao app sobre a interação do usuário.

Adicionando eventos fechados e fechados

O elemento da caixa de diálogo vem com um evento de fechamento: ele é emitido imediatamente quando a função close() da caixa de diálogo é chamada. Como estamos animando esse elemento, é bom ter eventos para antes e depois da animação, para uma mudança que capture os dados ou redefina o formulário da caixa de diálogo. Eu o uso aqui para gerenciar a adição do atributo inert à caixa de diálogo fechada e, na demonstração, uso esse atributo para modificar a lista de avatares se o usuário tiver enviado uma nova imagem.

Para isso, crie dois novos eventos chamados closing e closed. Em seguida, detecte o evento de fechamento integrado na caixa de diálogo. Aqui, defina a caixa de diálogo como inert e envie o evento closing. A próxima tarefa é aguardar a execução das animações e transições terminar na caixa de diálogo e enviar o evento closed.

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  …
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

A função animationsComplete, que também é usada em Como criar um componente de aviso, retorna uma promessa com base na conclusão da animação e das promessas de transição. É por isso que dialogClose é uma função assíncrona. Ele pode então await retornar a promessa e seguir em frente com confiança para o evento fechado.

Adicionar eventos abertos e de abertura

Esses eventos não são tão fáceis de adicionar, já que o elemento da caixa de diálogo integrado não fornece um evento aberto, como acontece com o fechamento. Uso um MutationObserver para fornecer insights sobre a mudança de atributos da caixa de diálogo. Neste observador, vou monitorar as mudanças no atributo aberto e gerenciar os eventos personalizados adequadamente.

Assim como iniciamos os eventos de fechamento e fechamento, crie dois novos eventos chamados opening e opened. Onde ouvimos anteriormente o evento de fechamento da caixa de diálogo, desta vez use um observador de mutação criado para observar os atributos da caixa de diálogo.

…
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  …
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

A função de callback do observador de mutações será chamada quando os atributos da caixa de diálogo forem alterados, fornecendo a lista de alterações como uma matriz. Itere nas mudanças no atributo, procurando o attributeName como aberto. Em seguida, confira se o elemento tem o atributo ou não: isso informa se a caixa de diálogo foi aberta ou não. Se ele tiver sido aberto, remova o atributo inert, defina o foco para um elemento que solicite autofocus ou o primeiro elemento button encontrado na caixa de diálogo. Por fim, de maneira semelhante ao evento de fechamento e fechamento, envie o evento de abertura imediatamente, aguarde a conclusão das animações e envie o evento aberto.

Adicionar um evento removido

Em aplicativos de página única, as caixas de diálogo geralmente são adicionadas e removidas com base em rotas ou outras necessidades e estados do aplicativo. Pode ser útil limpar eventos ou dados quando uma caixa de diálogo é removida.

É possível conseguir isso com outro observador de mutações. Desta vez, em vez de observar atributos em um elemento da caixa de diálogo, vamos observar os filhos do elemento do corpo e observar a remoção desses elementos.

…
const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  …
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

O callback do observador de mutações é chamado sempre que filhos são adicionados ou removidos do corpo do documento. As mutações específicas que estão sendo observadas são para removedNodes que têm o nodeName de uma caixa de diálogo. Se uma caixa de diálogo for removida, os eventos de clique e fechamento serão removidos para liberar memória, e o evento de remoção personalizado será despachado.

Como remover o atributo de carregamento

Um atributo de carregamento foi adicionado à caixa de diálogo para evitar que a animação da caixa de diálogo reproduza a animação de saída quando adicionada à página ou no carregamento da página. O script a seguir aguarda a execução das animações de caixas de diálogo terminar e remove o atributo. Agora, a caixa de diálogo pode ser animada para dentro e para fora, e ocultamos uma animação que desvia a atenção.

export default async function (dialog) {
  …
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Saiba mais sobre como impedir animações de frame-chave no carregamento de página aqui.

Tudo junto

Veja dialog.js na íntegra, agora que explicamos cada seção individualmente:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Como usar o módulo dialog.js

A função exportada do módulo precisa ser chamada e transmitir um elemento da caixa de diálogo que quer que esses novos eventos e funcionalidades sejam adicionados:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Assim, as duas caixas de diálogo serão atualizadas com dispensa de luz, correções de carregamento de animação e mais eventos para trabalhar.

Como detectar os novos eventos personalizados

Cada elemento atualizado da caixa de diálogo agora pode detectar cinco novos eventos, como estes:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

Aqui estão dois exemplos de como lidar com esses eventos:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

Na demonstração que criei com o elemento da caixa de diálogo, uso esse evento fechado e os dados do formulário para adicionar um novo elemento de avatar à lista. O momento certo é que a caixa de diálogo tenha concluído a animação de saída e, em seguida, alguns scripts sejam animados no novo avatar. Graças aos novos eventos, a orquestração da experiência do usuário pode ser mais suave.

Observe que dialog.returnValue contém a string de fechamento transmitida quando o evento close() da caixa de diálogo é chamado. É fundamental no evento dialogClosed saber se a caixa de diálogo foi fechada, cancelada ou confirmada. Se for confirmado, o script vai capturar os valores do formulário e redefinir o formulário. A redefinição é útil para que, quando a caixa de diálogo for mostrada novamente, ela fique em branco e pronta para um novo envio.

Conclusão

Agora que você sabe como eu fiz isso, o que você faria‽ 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web.

Crie uma demonstração, envie um tweet para mim (link em inglês) e eu vou adicionar o conteúdo à seção de remixes da comunidade abaixo.

Remixes da comunidade

Recursos