Como criar um componente de caixa de diálogo

Uma visão geral básica de 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 de uma ação de várias páginas: talvez porque o formulário seja pequeno ou porque a única ação necessária do usuário seja confirmar ou cancelar.

O elemento <dialog> se tornou estável em todos os navegadores recentemente:

Compatibilidade com navegadores

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

Origem

Descobri que o elemento estava faltando algumas coisas. Portanto, neste desafio de GUI, adiciono os itens de experiência do desenvolvedor que espero: 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 será automaticamente oculto e terá estilos integrados para sobrepor seu 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 mini e mega 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 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 como alvos 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 intencional e não acidental.

Minicaixa de diálogo

A minicaixa de diálogo é muito semelhante à caixa de diálogo grande, mas não tem um elemento <header>. Isso permite que ele seja menor e mais alinhado.

<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 eu costumo fazer, muitos deles 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 ser "congelada", de modo que não sejam mais o foco do cursor ou interativas com um mouse. Em vez de prender o foco, ele é direcionado 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, 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 de escape

É importante facilitar o fechamento desse elemento potencialmente interruptivo. Felizmente, o elemento de caixa de diálogo vai processar a tecla de escape para você, liberando você da carga de orquestração.

Estilos

Há um caminho fácil para estilizar o elemento 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 adaptativas e a consistência geral do design, usei a biblioteca de variáveis CSS Open Props. Além das variáveis sem custo financeiro 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>

Como ter 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. Quero animar a entrada e a saída, e a primeira etapa é definir minha própria propriedade display:

dialog {
  display: grid;
}

Ao mudar e, portanto, assumir o valor da propriedade de exibição, conforme mostrado no exemplo 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.

Como definir um tema de cores adaptativo para a caixa de 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 (link em inglês) escolha um tema de cores adaptável fornecido pelo navegador para preferências de sistema claras e escuras, eu queria personalizar o elemento de 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 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);
  }
}

Mais cores adaptáveis serão adicionadas mais tarde para elementos filhos, como o cabeçalho e o 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 apareça de ponta a ponta em um dispositivo móvel e não seja tão larga na tela de um 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 a posicionar um elemento de caixa de diálogo, vale a pena dividir as duas partes: o plano de fundo de 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 janelas de visualização pequenas, estilizo essa megamodal de página inteira de maneira 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 mínima

Ao usar uma viewport maior, como em um computador desktop, escolhi posicionar as minicaixas de diálogo sobre o elemento que as chamou. 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 eu não 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 mega 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 1fr, e o formulário tem a mesma altura máxima que o elemento de 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 dos DevTools sobrepondo as informações do 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 de flexbox no cabeçalho da caixa de diálogo.

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

Como a demonstração usa 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 para o botão de fechar do cabeçalho.

Como estilizar a caixa de diálogo <article>

O elemento artigo tem um papel especial nessa caixa de diálogo: ele é 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 mostradas apenas quando necessário, tenham rolagem com overscroll-behavior: contain e o restante seja 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, para criar um 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 de diálogo geralmente são animados porque entram e saem da janela. Dar às caixas de diálogo uma animação 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 definia a exibição como grade e nunca a define como "nenhum". 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 efeito de entrada e saída 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 o tempo e o tipo de suavização que você quer:

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

Como adicionar movimento à transição

Se o usuário aceitar o movimento, as caixas de diálogo mega e mini vão deslizar para cima na entrada e aumentar na saída. Você pode fazer isso com a consulta de mídia prefers-reduced-motion e alguns Open Props:

@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 mega é adaptado para dispositivos móveis para ser mais parecido 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), animação e 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ê vai notar que também forneci strings de fechamento sempre que chamo a função de vários botões para fornecer contexto ao meu aplicativo sobre a interação do usuário.

Como adicionar eventos de interdição e encerrados

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 antes e depois da animação para que uma mudança capture os dados ou redefina o formulário 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.

Como adicionar eventos de abertura e abertos

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 encerramento, 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ção será chamada quando os atributos da caixa de diálogo forem alterados, fornecendo a lista de mudanças como uma matriz. Itere as mudanças de atributo, procurando que o attributeName seja 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 da caixa de diálogo 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 conclusão da execução das animações de caixa de diálogo e, em seguida, remove o atributo. Agora a caixa de diálogo pode ser animada e ocultamos uma animação que distraía.

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.

Todos juntos

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 tempo é bom, porque a caixa de diálogo concluiu a animação de saída e, em seguida, alguns scripts são animados no novo avatar. Graças aos novos eventos, a orquestração da experiência do usuário pode ser mais tranquila.

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 extrair 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