Como criar um componente de caixa de diálogo

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

Nesta postagem, quero compartilhar minhas ideias 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 fonte.

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

Se preferir vídeos, confira a versão desta postagem no YouTube:

Visão geral

O elemento <dialog> é ótimo para informações ou ações contextuais na página. 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

  • Chrome: 37.
  • Borda: 79.
  • Firefox: 98.
  • Safari: 15.4.

Origem

Descobri que o elemento estava faltando algumas coisas. Portanto, neste desafio de GUI, adicionei os itens de experiência do desenvolvedor que eu esperava: eventos adicionais, dispensa leve, animações personalizadas e um tipo mini e mega.

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 referência.

Tradicionalmente, um elemento de caixa de diálogo tem muitas semelhanças com um modal, e os nomes geralmente são intercambiáveis. Usei o elemento de caixa de diálogo para pequenos pop-ups de caixa de diálogo (mini) e caixas de diálogo de página inteira (mega). Chamei de "mega" e "mini", com as duas caixas de diálogo ligeiramente adaptadas para casos de uso diferentes. 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 de caixa de diálogo são usados para coletar algumas informações de interação. Os formulários em elementos de caixa de diálogo são feitos para serem usados 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 inseridos pelo usuário. Além disso, os botões em um formulário que usa 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>

Caixa de diálogo Mega

Uma caixa de diálogo mega 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 dá um título ao modal e oferece um botão de fechamento. O artigo é sobre entradas e informações de formulários. 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 gerenciador de eventos inline onclick. O atributo autofocus vai receber o foco quando a caixa de diálogo for aberta, e a prática recomendada é colocá-lo no botão de cancelamento, não no botão de confirmação. Isso garante que a confirmação seja deliberada e não acidental.

Minicaixa de diálogo

A caixa de diálogo mini é muito semelhante à caixa de diálogo mega, mas não 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 de diálogo oferece uma base sólida para um elemento de viewport completo que pode coletar dados e interação do usuário. Esses recursos essenciais podem gerar interações muito interessantes e poderosas no seu site ou app.

Acessibilidade

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

Restaurar o foco

Como fizemos manualmente em Como criar um componente de barra lateral, é importante que abrir e fechar algo coloque o foco nos botões de abrir e fechar relevantes. Quando o menu lateral é aberto, o foco é colocado no botão de fechar. Quando o botão de fechar é pressionado, o foco é restaurado no botão que o abriu.

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

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

Foco de captura

O elemento de diálogo gerencia inert para você no documento. Antes do inert, o JavaScript era usado para detectar o foco saindo de um elemento, momento em que ele é interceptado e colocado de volta.

Compatibilidade com navegadores

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 112.
  • Safari: 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 um elemento

Por padrão, o elemento de caixa de diálogo vai 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 por padrão, use o atributo autofocus. Como descrito anteriormente, acho que a prática recomendada é colocar isso no botão de cancelamento, e não no botã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 potencialmente interruptivo. 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. O caminho fácil é alcançado ao não mudar a propriedade de exibição da caixa de diálogo e trabalhar com as limitações dela. Eu 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.

Criar estilo com Open Props

Para acelerar as cores adaptáveis e a consistência geral do design, eu coloquei descaradamente minha biblioteca de variáveis CSS Open Props. Além das variáveis gratuitas fornecidas, 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 personalizar a caixa de diálogo e a demonstração sem precisar de muitos estilos para oferecer suporte e melhorar a aparência.

Como estilizar o elemento <dialog>

Possuir a propriedade de exibição

O comportamento padrão de mostrar e ocultar um elemento de diálogo alterna a propriedade de exibição de block para none. Isso significa que não é possível animar a entrada e a saída, apenas a entrada. 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 adequada do usuário. 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 seguintes estilos:

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

Agora, a caixa de diálogo fica invisível e não pode ser aberta para interação. Mais tarde, vou adicionar um pouco de 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 possam acessar a caixa de diálogo oculta.

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

Caixa de diálogo &quot;Mega&quot; mostrando o tema 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 claras e escuras do sistema, eu queria personalizar o elemento da caixa de diálogo mais do que isso. O Open Props oferece algumas cores de superfície que se adaptam automaticamente às preferências do sistema claro e escuro, semelhante ao uso do color-scheme. Elas são ótimas para criar camadas em um design, e eu adoro usar cores para ajudar a mostrar visualmente essa aparência de superfícies de camadas. A cor de plano de fundo é var(--surface-1). Para ficar sobre essa 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 considero isso um extra para um elemento de caixa de diálogo, mas muito importante para criar um design de caixa de diálogo atraente e bem projetado.

Dimensionamento de caixas de diálogo responsivas

A caixa de diálogo delega o tamanho ao conteúdo por padrão, o que geralmente é ótimo. Meu objetivo aqui é restringir o max-inline-size a um tamanho legível (--size-content-3 = 60ch) ou 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 seja um elemento de caixa de diálogo 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;
}

Note que max-block-size aparece duas vezes. O primeiro usa 80vh, uma unidade de viewport física. O que eu realmente quero é manter a caixa de diálogo dentro do fluxo relativo, para usuários internacionais. Por isso, uso a unidade dvb lógica, mais recente e com suporte parcial na segunda declaração para quando ela se tornar mais estável.

Posicionamento da caixa de diálogo "Mega"

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 plano de fundo precisa cobrir tudo, fornecendo um efeito de sombra para ajudar a garantir que essa caixa de diálogo esteja na frente e que o conteúdo por trás esteja inacessível. O contêiner de diálogo pode ser centralizado sobre esse plano de fundo e ter qualquer forma que o conteúdo exigir.

Os estilos a seguir fixam o elemento de diálogo na janela, esticando-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 caixa de diálogo mega para dispositivos móveis

Em pequenas janelas de visualização, eu estilo esse mega modal de página inteira de forma um pouco diferente. Defino a margem inferior como 0, o que traz o conteúdo da caixa de diálogo para a parte inferior 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 dos desenvolvedores de ferramentas sobrepondo o espaçamento da margem
  na megacaixa de diálogo para computador e dispositivo móvel enquanto aberta.

Posicionamento da caixa de diálogo pequena

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 de JavaScript. Confira a técnica que uso neste link, mas acho que ela está fora do escopo deste artigo. Sem o JavaScript, a mini caixa de diálogo aparece no centro da tela, assim como a caixa de diálogo grande.

Destaque seu canal

Por fim, adicione um pouco de estilo à caixa de diálogo para que ela pareça uma superfície macia acima da página. A suavidade é alcançada arredondando os cantos da caixa de diálogo. A profundidade é alcançada com um dos elementos de sombra criados cuidadosamente pelo Open Props:

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

Personalizar o pseudoelemento de pano de fundo

Escolhi trabalhar com o plano de fundo de forma muito leve, adicionando apenas um efeito de desfoque com backdrop-filter à caixa de diálogo:

Compatibilidade com navegadores

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 103.
  • Safari: 18.

Origem

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

Também coloquei uma transição em backdrop-filter, na esperança de que os navegadores permitam a transição do elemento de plano 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 de avatares coloridos.

Extras de estilo

Chamo essa seção de "extras" porque ela tem mais a ver com a demonstração do elemento de caixa de diálogo do que com o elemento de 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 por trás dela, o que não é o que eu quero:

Normalmente, overscroll-behavior seria minha solução usual, mas de acordo com a especificação, ela não tem efeito na caixa de diálogo porque não é uma porta de rolagem, ou seja, não é um controle deslizante, então não há nada a ser impedido. Poderia usar o JavaScript para detectar os novos eventos deste guia, como "fechado" e "aberto", e alternar overflow: hidden no documento ou esperar que :has() seja estável em todos os navegadores:

Compatibilidade com navegadores

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Origem

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

Agora, quando uma caixa de diálogo grande está aberta, o documento HTML tem overflow: hidden.

O layout <form>

Além de ser um elemento muito importante para coletar as informações de interação do usuário, eu o uso aqui para definir o cabeçalho, o rodapé e os elementos do artigo. Com esse layout, pretendo articular o artigo filho como uma área rolável. Eu consigo 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 tamanho de linha fixos é o que permite que o elemento do artigo seja limitado e role quando ele 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.

Como 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 de fechamento fácil de encontrar. Ele também recebe uma cor de superfície para parecer estar atrás do conteúdo do artigo da caixa de diálogo. Esses requisitos levam a um contêiner flexbox, itens alinhados verticalmente que são espaçados nas bordas e alguns paddings e lacunas para dar espaço ao título e aos botões de fechamento:

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.

Estilo do botão "Fechar" do cabeçalho

Como a demonstração está usando os botões do Open Props, o botão de fechar é personalizado em um botão central com ícone redondo, assim:

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.

Como 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 máximos para ele mesmo, que fornecem restrições para que esse elemento de artigo seja alcançado se 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);
  }
}

O rodapé tem a função de 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. Ele usa um layout de flexbox de união com gap para fornecer espaço entre os botões. Os elementos do 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 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 animar para dentro e para fora.

O Open Props vem com muitas animações de chave para uso, o que facilita a orquestração e torna o código mais legível. Estas são as metas de animação e a abordagem em camadas que usei:

  1. O movimento reduzido é a transição padrão, um simples desbotamento de opacidade.
  2. Se o movimento estiver ok, as animações de deslizar e de escala serão adicionadas.
  3. O layout responsivo para dispositivos móveis da caixa de diálogo mega é ajustado para deslizar para fora.

Uma transição padrão segura e significativa

Embora o Open Props tenha frames-chave para desbotamento, prefiro essa abordagem em camadas de transições como padrão com animações de frame-chave como upgrades em potencial. Anteriormente, já estilizamos 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 aceitar o movimento, as caixas de diálogo mega e mini vão deslizar para cima como entrada e aumentar de tamanho como saída. É possível fazer isso com a consulta de mídia prefers-reduced-motion e alguns objetos abertos:

@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;
  }
}

Como adaptar a animação de saída para dispositivos móveis

Na seção de estilização, o estilo de caixa de diálogo gigante é adaptado para que dispositivos móveis se pareçam mais com uma página de ação, como se um pequeno pedaço de papel deslizasse da parte de baixo da tela e ainda estivesse preso a ela. A animação de saída de dimensionamento não se encaixa bem nesse novo design, e 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á muitas coisas a serem adicionadas com 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 vêm do desejo de dispensar rapidamente (clicando no plano de fundo da caixa de diálogo), da animação e de alguns eventos adicionais para um melhor tempo de recebimento dos dados do formulário.

Como adicionar a dispensação de luz

Essa tarefa é simples e uma ótima adição a um elemento de caixa de diálogo que não está sendo animado. A interação é alcançada observando cliques no elemento de diálogo e aproveitando o evento bubbling para avaliar o que foi clicado. Ela só vai close() se for o elemento mais alto:

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

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

Observe 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 de 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 uso isso aqui para gerenciar a adição do atributo inert na caixa de diálogo fechada. Na demonstração, uso isso para modificar a lista de avatares se o usuário enviou 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. A partir daqui, defina a caixa de diálogo como inert e envie o evento closing. A próxima tarefa é esperar que as animações e transições terminem de ser executadas na caixa de diálogo e, em seguida, 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 Toast, retorna uma promessa com base na conclusão das promessas de animação e transição. É por isso que dialogClose é uma função assíncrona. Ela pode, então, await a promessa retornada e avançar 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 de caixa de diálogo integrado não oferece um evento de abertura, como faz com o fechamento. Eu uso um MutationObserver para fornecer insights sobre a mudança de atributos da caixa de diálogo. Neste observador, vou observar as mudanças no atributo aberto e gerenciar os eventos personalizados de acordo com isso.

Assim como fizemos com os eventos de fechamento e de fechamento, crie dois novos eventos chamados opening e opened. Onde antes ouvíamos o evento de fechamento da caixa de diálogo, desta vez usamos 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, verifique 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 e defina o foco em um elemento que solicite autofocus ou o primeiro elemento button encontrado na caixa de diálogo. Por fim, semelhante ao evento de fechamento e fechado, envie o evento de abertura imediatamente, aguarde o término das animações e envie o evento aberto.

Adicionar um evento removido

Em apps 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.

Você pode fazer isso com outro observador de mutação. Dessa vez, em vez de observar atributos em um elemento de caixa de diálogo, vamos observar os filhos do elemento do corpo e observar se os elementos de caixa de diálogo estão sendo removidos.


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ção é 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 personalizado removido será enviado.

Como remover o atributo de carregamento

Para evitar que a animação de saída seja reproduzida quando adicionada à página ou no carregamento da página, um atributo de carregamento foi adicionado à caixa de diálogo. 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 e ocultamos uma animação que distraía o usuário.

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

Saiba mais sobre o problema de impedir animações de keyframe no carregamento da página aqui.

Tudo junto

Confira a 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 espera ser chamada e transmitida a um elemento de diálogo que quer adicionar esses novos eventos e funcionalidades:

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 são atualizadas com descarte leve, correções de carregamento de animação e mais eventos para trabalhar.

Como detectar os novos eventos personalizados

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

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

Confira dois exemplos de como processar 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 de diálogo, usei 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.

Aviso dialog.returnValue: contém a string de encerramento 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 esteja em branco e pronta para um novo envio.

Conclusão

Agora que você sabe como eu fiz, como você faria? 🙂

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

Crie uma demonstração, envie links para mim e vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Recursos