Como criar um componente de caixa de diálogo

Uma visão geral básica de como criar modais pequenos e grandes adaptáveis a cores, responsivos e acessíveis 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 veja a fonte.

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

Se preferir vídeo, confira uma 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 em 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> recentemente se tornou estável em todos os navegadores:

Browser Support

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

Source

Descobri que o elemento estava faltando algumas coisas. Por isso, 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 será ocultado automaticamente e terá estilos integrados para sobrepor seu conteúdo.

<dialog>
  …
</dialog>

Podemos melhorar esse valor de referência.

Tradicionalmente, um elemento de caixa de diálogo compartilha muito com um modal, e muitas vezes os nomes são intercambiáveis. Usei o elemento de caixa de diálogo para pop-ups pequenos (mini) e caixas de diálogo de página inteira (mega). Eu os chamei de mega e mini, com os dois diálogos ligeiramente adaptados 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 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 dentro dos elementos de caixa de diálogo são feitos para funcionar juntos. É uma boa ideia ter um elemento de formulário que 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 dentro de um formulário usando 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 grande

Um mega diálogo tem três elementos dentro do formulário: <header>, <article>, e <footer>. Eles servem como contêineres semânticos e como 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 fechar. 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 manipulador de eventos inline onclick. O atributo autofocus vai receber o foco quando a caixa de diálogo for aberta, e acho que é uma prática recomendada colocar isso no botão "Cancelar", não no botão "Confirmar". Isso garante que a confirmação seja deliberada e não acidental.

Minicaixa de diálogo

A caixa de diálogo mini é muito parecida com a mega, só que sem 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 caixa de diálogo oferece uma base sólida para um elemento de janela de visualização completa que pode coletar dados e interação do usuário. Esses elementos essenciais podem gerar interações muito interessantes e eficientes no seu site ou app.

Acessibilidade

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

Restaurar o foco

Assim como fizemos manualmente em Como criar um componente de barra lateral, é importante que a abertura e o fechamento adequados de algo foquem nos botões relevantes de abrir e fechar. Quando a barra lateral é aberta, o foco é colocado no botão de fechar. Quando o botão de fechar é pressionado, o foco é restaurado para o botão que o abriu.

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

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

Bloqueio de foco

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

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 112.
  • Safari: 15.5.

Source

Depois de inert, qualquer parte do documento pode ser "congelada" de modo que não seja mais um alvo de foco ou interativa 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 atribui 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 por padrão, use o atributo autofocus. Como descrito anteriormente, considero uma prática recomendada colocar isso no botão "Cancelar" e não no botão "Confirmar". Isso garante que a confirmação seja proposital 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 processa a tecla "Esc" para você, liberando você do fardo da orquestração.

Estilos

Há um caminho fácil e um difícil para estilizar o elemento de caixa de diálogo. O caminho fácil é alcançado não mudando a propriedade de exibição da caixa de diálogo e trabalhando 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.

Estilo com Open Props

Para acelerar as cores adaptáveis e a consistência geral do design, usei sem pudor 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, que o Open Props oferece como importações opcionais. Essas importações me ajudam a focar na personalização da caixa de diálogo e da demonstração sem precisar de muitos estilos para oferecer suporte e fazer com que pareça bom.

Estilizar o elemento <dialog>

Ser proprietário da 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 ele não pode ser animado para dentro e para fora, apenas para dentro. Quero animar a entrada e a saída. A primeira etapa é definir minha própria propriedade display:

dialog {
  display: grid;
}

Ao mudar, e portanto possuir, o valor da propriedade de exibição, conforme 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. Você pode 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 usada quando não está aberta. Mais tarde, vou adicionar um pouco de JavaScript para gerenciar o atributo inert na caixa de diálogo, garantindo que usuários de teclado e leitores de tela também não consigam acessar a caixa de diálogo oculta.

Como dar à caixa de diálogo um tema de cores adaptativo

Caixa de diálogo grande mostrando o tema claro e escuro, demonstrando as cores da superfície.

Embora color-scheme ative 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, de maneira semelhante ao uso do color-scheme. Eles são ótimos para criar camadas em um design, e eu adoro usar cores para ajudar a apoiar visualmente essa aparência de superfícies em camadas. A cor 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 depois para elementos filhos, como o cabeçalho e o rodapé. Considero esses elementos extras para um elemento de caixa de diálogo, mas muito importantes para criar um design de caixa de diálogo atraente e bem projetado.

Dimensionamento responsivo da caixa de diálogo

Por padrão, a caixa de diálogo delega o tamanho 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 90% da largura da janela de visualização. Isso garante que a caixa de diálogo não vá de ponta a ponta em um dispositivo móvel e não fique tão larga em uma tela de computador que dificulte a leitura. 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 fica 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;
}

Percebe como eu tenho max-block-size 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 lógica, mais recente e parcialmente compatível dvb na segunda declaração para quando ela se tornar mais estável.

Posicionamento da caixa de diálogo grande

Para ajudar a posicionar um elemento de caixa de diálogo, vale a pena dividir as duas partes dele: o plano 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 mostrar que a caixa de diálogo está na frente e o conteúdo atrás está inacessível. O contêiner de diálogo pode se centralizar sobre esse plano de fundo e assumir qualquer forma que o conteúdo exija.

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

Em janelas de visualização pequenas, eu estilizo esse mega modal de página inteira de maneira um pouco diferente. Defini a margem inferior como 0, o que traz 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 folha de ações, 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 das ferramentas de desenvolvimento sobrepondo o espaçamento da margem
  no megadiálogo para computador e dispositivos móveis enquanto ele está aberto.

Posicionamento da caixa de diálogo pequena

Ao usar uma janela de visualização maior, como em um computador desktop, posicionei as mini caixas de diálogo sobre o elemento que as chamou. Para fazer isso, preciso de JavaScript. Você pode encontrar a técnica que uso aqui, mas acho que ela não faz parte do escopo deste artigo. Sem o JavaScript, a minicaixa de diálogo aparece no centro da tela, assim como a megacaixa de diálogo.

Cause impacto

Por fim, adicione um toque especial à caixa de diálogo para que ela pareça uma superfície macia muito acima da página. A suavidade é alcançada arredondando os cantos da caixa de diálogo. A profundidade é alcançada com uma das propriedades de sombra cuidadosamente criadas do Open Props:

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

Como personalizar o pseudoelemento de pano de fundo

Optei por trabalhar de forma muito leve com o plano de fundo, adicionando apenas um efeito de desfoque com backdrop-filter ao mega diálogo:

Browser Support

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

Source

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

Também escolhi colocar 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 caixa de diálogo grande sobre um plano de fundo desfocado de avatares coloridos.

Extras de estilo

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

Restriçã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, não tem efeito na caixa de diálogo porque não é uma porta de rolagem, ou seja, não é um scroller, então não há nada a impedir. Posso usar JavaScript para observar os novos eventos deste guia, como "closed" e "opened", e alternar overflow: hidden no documento ou esperar que :has() fique estável em todos os navegadores:

Browser Support

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

Source

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 apresentar os elementos de cabeçalho, rodapé e artigo. Com esse layout, pretendo articular o filho do artigo como uma área rolável. Faço isso com grid-template-rows. O elemento de artigo recebe 1fr, e o formulário tem a mesma altura máxima do elemento de caixa de diálogo. Definir essa altura e esse tamanho de linha fixos é o que permite que o elemento do artigo seja restrito e role quando transborda:

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

Captura de tela das ferramentas de desenvolvimento sobrepondo as informações de layout de grade nas linhas.

Estilizar a caixa de diálogo <header>

A função 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 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 e espaçados até as bordas, além de algum 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.

Estilizar o botão de fechar do cabeçalho

Como a demonstração está usando os botões Open Props, o botão de fechar é personalizado em um botão redondo centralizado com ícone, 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.

Estilizar a caixa de diálogo <article>

O elemento "article" tem uma função 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 máximos para si mesmo, que fornecem restrições para que o elemento de artigo alcance se ficar muito alto. Defina overflow-y: auto para que as barras de rolagem só apareçam quando necessário, contenha a rolagem dentro dele 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, adicionar 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 flexbox de quebra 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 porque 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 do flexbox aos elementos do menu de rodapé.

Animação

Os elementos de caixa de diálogo geralmente são animados porque entram e saem da janela. Dar aos diálogos um movimento de suporte para essa entrada e saída ajuda os usuários a se orientarem no fluxo.

Normalmente, o elemento de 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. Antes, o guia definiria a exibição como grade e nunca como "nenhuma". Isso permite animar a entrada e a saída.

O Open Props vem com muitas animações de keyframe para uso, o que facilita a orquestração e a torna legível. Confira as metas de animação e a abordagem em camadas que usei:

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

Uma transição padrão segura e significativa

Embora o Open Props venha com frames-chave para aparecer e desaparecer, prefiro essa abordagem em camadas de transições como padrão, com animações de frames-chave como possíveis upgrades. Antes, já definimos o estilo da 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%, diga ao navegador quanto tempo e que tipo de suavização você quer:

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

Adicionar movimento à transição

Se o usuário não tiver problemas com movimento, as caixas de diálogo grande e pequena vão deslizar para cima ao entrar e diminuir ao sair. Você pode 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;
  }
}

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

Na seção de estilização anterior, o estilo de caixa de diálogo grande foi adaptado para dispositivos móveis e ficou mais parecido com uma folha de ações, como se um pequeno pedaço de papel tivesse deslizado para cima da parte de baixo da tela e ainda estivesse anexado a ela. A animação de saída de expansão não se encaixa bem nesse novo design, e podemos adaptá-la com algumas consultas de mídia e algumas propriedades abertas:

@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á algumas 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 decorrem do desejo de dispensar a ação com um clique (clicando no plano de fundo da caixa de diálogo), animação e alguns eventos adicionais para melhorar o tempo de obtenção dos dados do formulário.

Como adicionar a dispensa leve

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 os cliques no elemento de diálogo e aproveitando o encapsulamento de eventos para avaliar o que foi clicado, e só 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 gerar insights sobre como a caixa de diálogo foi fechada. Você vai notar que também forneci strings próximas sempre que chamo a função de vários botões para dar contexto ao meu aplicativo sobre a interação do usuário.

Adicionar eventos de encerramento 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 pegue os dados ou redefina o formulário de diálogo. Uso esse recurso aqui para gerenciar a adição do atributo inert na caixa de diálogo fechada. Na demonstração, uso esses recursos 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, ouça o evento de fechamento integrado na caixa de diálogo. Defina a caixa de diálogo como inert e envie o evento closing. A próxima tarefa é aguardar a conclusão das animações e transições 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 de toast, retorna uma promessa com base na conclusão das promessas de animação e transição. Por isso, dialogClose é uma função async; ela pode então await a promessa retornada e avançar com confiança para o evento fechado.

Adicionar eventos de abertura e abertos

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

Assim como iniciamos os eventos "closing" e "closed", crie dois novos eventos chamados opening e opened. Desta vez, em vez de ficar aguardando o evento de fechamento da caixa de diálogo, use um observador de mutação criado para monitorar os atributos dela.


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 o attributeName a ser 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 solicita autofocus ou no 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 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 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 fazer isso com outro observador de mutação. Desta vez, em vez de observar atributos em um elemento de caixa de diálogo, vamos observar os filhos do elemento body e monitorar a remoção de elementos de caixa de diálogo.


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 monitoradas 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 de remoção será enviado.

Remover o atributo de carregamento

Para evitar que a animação da caixa de diálogo seja reproduzida ao ser adicionada à página ou no carregamento dela, um atributo de carregamento foi adicionado à caixa de diálogo. O script a seguir aguarda a conclusão das animações da caixa de diálogo e remove o atributo. Agora, a caixa de diálogo pode animar a entrada e a saída, e escondemos uma animação que poderia distrair.

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

Aqui está 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 receber um elemento de caixa de diálogo que quer ter esses novos eventos e funcionalidades 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 são atualizadas com rejeição 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 caixa de diálogo, uso esse evento fechado e os dados do formulário para adicionar um novo elemento de avatar à lista. O tempo é bom, já que a caixa de diálogo concluiu a animação de saída, e alguns scripts animam o novo avatar. Graças aos novos eventos, a orquestração da experiência do usuário pode ser mais tranquila.

Observe dialog.returnValue: ele contém a string de fechamento transmitida quando o evento de caixa de diálogo close() é chamado. É fundamental no evento dialogClosed saber se a caixa de diálogo foi fechada, cancelada ou confirmada. Se for confirmada, o script vai extrair os valores do formulário e redefini-lo. 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, me envie um tweet com o link, e eu vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Recursos