Como criar um componente de caixa de diálogo

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

Nesta postagem, quero compartilhar minhas ideias sobre como criar mini e megamodais adaptáveis, responsivos e acessíveis com o elemento <dialog>. Experimente a demonstração e veja a fonte.

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

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

Visão geral

O elemento <dialog> é ótimo para ações ou informaçõ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 a única ação necessária do usuário seja confirmar ou cancelar.

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

Compatibilidade com navegadores

  • 37
  • 79
  • 98
  • 15,4

Origem

Percebi que faltavam alguns itens no elemento. Por isso, neste desafio da GUI, adiciono os itens esperados da experiência do desenvolvedor: outros eventos, dispensa de luz, animações personalizadas e um mini e megatipo.

Marcação

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

<dialog>
  …
</dialog>

Podemos melhorar essa linha de base.

Tradicionalmente, um elemento de caixa de diálogo compartilha muito com um modal e, muitas vezes, os nomes são intercambiáveis. Tive a liberdade de usar o elemento de caixa de diálogo para pop-ups pequenos (mini) e de página inteira (mega). Elas foram chamadas de mega e mini, com ambas as caixas de diálogo ligeiramente adaptadas para diferentes casos de uso. Adicionei um atributo modal-mode para permitir que você especifique o tipo:

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

Captura de tela das caixas de diálogo mini e mega nos temas claro e escuro.

Nem sempre, mas geralmente elementos da caixa de diálogo são usados para coletar algumas informações de interação. Os formulários dentro de elementos de caixa de diálogo são feitos para ficar juntos. É recomendável usar um elemento de formulário para unir 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 que usam method="dialog" podem fechar uma caixa de diálogo sem JavaScript e transmitir dados.

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

Megacaixa de diálogo

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

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

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

Minidiálogo

A minicaixa de diálogo é muito semelhante à caixa de diálogo mega. Só falta um elemento <header>. Isso permite que ele seja menor e mais inline.

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

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

Acessibilidade

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

Restaurando o foco

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

Este é o comportamento padrão integrado com o elemento da caixa de diálogo:

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

Foco na armadilha

O elemento da caixa de diálogo gerencia inert para você no documento. Antes de inert, o JavaScript era usado para observar se o foco saía de um elemento, momento em que ele o interceptava e o colocava de volta.

Compatibilidade com navegadores

  • 102
  • 102
  • 112
  • 15.5

Origem

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

Abrir e colocar um elemento em foco automaticamente

Por padrão, o elemento da 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 adotar como padrão, use o atributo autofocus. Conforme descrito anteriormente, é uma prática recomendada colocar isso no botão "Cancelar", e não no de "Confirmar". Isso garante que a confirmação seja deliberada e não acidental.

Fechando com a tecla Esc

É importante fechar esse elemento potencialmente interruptivo. O elemento da caixa de diálogo vai processar a tecla de escape para você, liberando do trabalho 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 mais fácil é alcançado sem mudar a propriedade de exibição da caixa de diálogo e trabalhando com as limitações. Eu escolho o caminho mais difícil para fornecer animações personalizadas para abrir e fechar a caixa de diálogo, assumindo a propriedade display e muito mais.

Estilizar com objetos abertos

Para acelerar as cores adaptáveis e a consistência geral do design, incluí descaradamente minha 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 como importações opcionais. Essas importações me ajudam a me concentrar em personalizar a caixa de diálogo e a demonstração sem precisar de muitos estilos para oferecer suporte e para que ela tenha uma boa aparência.

Como definir o estilo do elemento <dialog>

Como proprietário da propriedade de exibição

O comportamento padrão de mostrar e ocultar de um elemento da caixa de diálogo alterna a propriedade de exibição de block para none. Infelizmente, isso significa que ela não pode ser animada dentro e fora, somente para dentro. Quero animar para dentro e para fora, e o primeiro passo é definir minha própria propriedade display:

dialog {
  display: grid;
}

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

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

Agora a caixa de diálogo é invisível e não é possível interagir com ela 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 leitor de tela também não consigam acessar a caixa de diálogo oculta.

Usar um tema de cor adaptável na caixa de diálogo

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

Embora o color-scheme ative seu documento em um tema de cor adaptável fornecido pelo navegador às 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 (link em inglês) que se adaptam automaticamente às preferências claras e escuras do sistema, de forma semelhante ao uso do color-scheme. Eles são ótimos para criar camadas em um design, e adoro usar cores para apoiar visualmente essa aparência das superfícies das camadas. A cor do plano de fundo é var(--surface-1). Para ficar na parte de cima dessa camada, use var(--surface-2):

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

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

Cores mais adaptáveis serão adicionadas posteriormente para elementos filhos, como o cabeçalho e o rodapé. Considero-os extras para um elemento de diálogo, mas muito importantes para criar um design de diálogo atraente e bem projetado.

Dimensionamento responsivo da caixa de diálogo

O padrão da caixa de diálogo é delegar o tamanho ao conteúdo, o que geralmente é ótimo. Meu objetivo é restringir max-inline-size a um tamanho legível (--size-content-3 = 60ch) ou a 90% da largura da janela de visualização. Isso garante que a caixa de diálogo não fique de uma borda a outra em um dispositivo móvel e não seja tão ampla em uma tela de computador que seja 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 de rolagem 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;
}

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

Megaposicionamento de diálogo

Para ajudar a posicionar um elemento de caixa de diálogo, vale a pena dividir as duas partes dele: o pano de fundo de tela cheia e o contêiner da caixa de diálogo. O pano de fundo precisa cobrir tudo, fornecendo um efeito de sombra para indicar que a caixa de diálogo está à frente e que o conteúdo por trás está inacessível. O contêiner da caixa de diálogo pode ser centralizado sobre esse pano de fundo e assumir a forma necessária para o conteúdo.

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

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

Em janelas de visualização pequenas, o estilo desse mega modal de página inteira é um pouco diferente. Definimos a margem inferior como 0, o que leva o conteúdo da caixa de diálogo para a parte de baixo da janela de visualização. Com alguns ajustes de estilo, posso transformar a caixa de diálogo em uma planilha de ações, mais próxima das miniaturas do usuário:

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

Captura de tela do DevTools sobrepondo o espaçamento de margem
  na caixa de diálogo mega do computador e do dispositivo móvel enquanto aberto.

Posicionamento da minicaixa de diálogo

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

Dê destaque

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

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

Personalizar o pseudoelemento do pano de fundo

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

Compatibilidade com navegadores

  • 76
  • 17
  • 103
  • 9

Origem

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

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

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

Captura de tela da caixa de diálogo mega sobre a sobreposição de um plano de fundo desfocado de avatares coloridos.

Outros recursos de estilo

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

Contenção de rolagem

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

Normalmente, overscroll-behavior seria minha solução comum, mas de acordo com a especificação, ele não afeta a caixa de diálogo porque não é uma porta de rolagem, ou seja, não é um botão de rolagem, portanto, não há nada a evitar. Eu poderia usar o JavaScript para acompanhar os novos eventos deste guia, como "fechados" e "abertos", e ativar overflow: hidden no documento ou esperar que :has() fique estável em todos os navegadores:

Compatibilidade com navegadores

  • 105
  • 105
  • 121
  • 15,4

Origem

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

Agora, quando uma mega caixa de diálogo for aberta, o documento html terá overflow: hidden.

O layout <form>

Além de ser um elemento muito importante para coletar as informações de interação do usuário, eu o uso aqui para posicionar os elementos de cabeçalho, rodapé e artigo. Com esse layout, pretendemos articular o elemento filho do artigo como uma área rolável. Isso pode ser feito com grid-template-rows. O elemento do artigo recebe 1fr, e o formulário em si tem a mesma altura máxima que o elemento da caixa de diálogo. Definir essa altura e o tamanho da linha firmes é o que permite que o elemento do artigo seja restrito e role quando ultrapassar:

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 sobre as informações de layout de grade sobre as 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 fazer com que pareça estar por trás do conteúdo do artigo da caixa de diálogo. Esses requisitos resultam em um contêiner flexbox, itens alinhados verticalmente que são espaçados nas bordas e um preenchimento e lacunas para dar espaço aos botões "Título" e "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 do flexbox no cabeçalho da caixa de diálogo.

Aplicar estilo ao botão "Fechar" do cabeçalho

Como a demonstração usa os botões "Open Props", o botão "Close" é personalizado para um botão redondo, centralizado em um ícone, desta forma:

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

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

Estilizar a caixa de diálogo <article>

O elemento do artigo tem uma função especial nessa caixa de diálogo: é um espaço que pode ser rolado no caso de uma caixa de diálogo alta ou longa.

Para fazer isso, o elemento do formulário pai estabeleceu alguns limites máximos que fornecem restrições para esse elemento do artigo alcançar se ficar muito alto. Configure overflow-y: auto para que as barras de rolagem sejam exibidas apenas quando necessário, contendo a rolagem dentro delas 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, dar algum espaçamento para dar algum 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 do 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 com gap para fornecer espaço entre os botões. Os elementos de menu têm padding, como <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 do flexbox aos elementos do menu de rodapé.

Animação

Elementos de caixa de diálogo são frequentemente animados porque entram e saem da janela. Oferecer aos diálogos algum movimento de apoio para essa entrada e saída ajuda os usuários a se orientarem no fluxo.

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

O Open Props vem com muitas animações de frames-chave para uso, o que facilita a orquestração e a leitura. Estas são as metas de animação e a abordagem em camadas que segui:

  1. O movimento reduzido é a transição padrão, uma simples opacidade que esmaece.
  2. Se o movimento estiver adequado, animações de deslize e escala serão adicionadas.
  3. O layout responsivo de dispositivos móveis para a caixa de diálogo mega é ajustado para sair.

Uma transição padrão segura e significativa

Embora o Open Props venha com frames-chave para exibição e exibição, prefiro essa abordagem em camadas de transições como padrão com animações de frame-chave como os possíveis upgrades. 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 desejado:

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 precisam deslizar para cima como a entrada e escalonar horizontalmente para a saída. É possível 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;
  }
}

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

No início da seção de estilo, o estilo de caixa de diálogo mega é adaptado para dispositivos móveis para que sejam mais como uma folha de ações, como se um pequeno pedaço de papel tivesse escapado da parte inferior da tela e ainda estivesse anexado à parte de baixo. A animação de saída de escalonamento horizontal 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á 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 são resultado do desejo de dispensar a luz (clicando no plano de fundo da caixa de diálogo), da animação e de alguns outros eventos para conseguir os dados do formulário de forma mais rápida.

Adicionando iluminação

Essa tarefa é simples e uma ótima adição a um elemento de diálogo que não está sendo animado. A interação ocorre observando os cliques no elemento da caixa de diálogo e aproveitando a navegação de eventos para avaliar o que foi clicado, e só close() se for o elemento superior:

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 ter insights sobre como a caixa de diálogo foi fechada. Você verá que também forneci strings de fechamento toda vez que chamo a função em vários botões para fornecer contexto ao meu aplicativo sobre a interação do usuário.

Adicionar eventos de fechamento e fechados

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

Para isso, crie dois novos eventos chamados closing e closed. Em seguida, detecte o evento de fechamento integrado na caixa de diálogo. Agora, defina a caixa de diálogo como inert e envie o evento closing. A próxima tarefa é aguardar que as animações e transições terminem de ser executadas na caixa de diálogo e enviar o evento closed.

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

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

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

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

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

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

Adicionar eventos abertos e abertos

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

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

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

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

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

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

      dialog.removeAttribute('inert')

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

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

A função de callback do observador de mutações será chamada quando os atributos da caixa de diálogo forem alterados, fornecendo a lista de alterações como uma matriz. Itere as mudanças de atributo, procurando que attributeName esteja aberto. Em seguida, confira se o elemento tem o atributo ou não: isso informa se a caixa de diálogo foi aberta ou não. Se ele tiver sido aberto, remova o atributo inert e defina o foco para um elemento que solicite autofocus ou o primeiro elemento button encontrado na caixa de diálogo. Por último, de maneira 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.

Como adicionar um evento removido

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

Você pode conseguir isso com outro observador de mutações. Desta vez, em vez de observar atributos em um elemento de caixa de diálogo, observaremos os filhos do elemento de corpo e observaremos se elementos de caixa de diálogo são 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ções é chamado sempre que filhos são adicionados ou removidos do corpo do documento. As mutações específicas que estão sendo monitoradas são para removedNodes que tem o nodeName de uma caixa de diálogo. Se uma caixa de diálogo for removida, os eventos de clique e de 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 da caixa de diálogo mostre a animação de saída 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 da caixa de diálogo e remove o atributo. Agora a caixa de diálogo pode ser animada para dentro e fora da caixa de diálogo, e nós ocultamos uma animação que poderia causar distração.

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

Saiba mais sobre o problema de impedir animações de frame-chave no carregamento da página neste link.

Tudo em um só lugar

Confira 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 transmitir um elemento de caixa 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 foram atualizadas com dispensa de luz, correções de carregamento de animação e mais eventos para trabalhar.

Como ouvir os novos eventos personalizados

Cada elemento atualizado da caixa de diálogo 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)

Veja dois exemplos de processamento desses eventos:

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

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

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

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

Na demonstração que criei com o elemento da caixa de diálogo, uso esse evento fechado e os dados do formulário para adicionar um novo elemento do avatar à lista. O momento é adequado, porque a caixa de diálogo concluiu a animação de saída e 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 fácil.

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

Conclusão

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

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

Crie uma demonstração, envie um tweet para mim e os adicionarei à seção de remixes da comunidade abaixo.

Remixes da comunidade

Recursos