Cómo compilar un componente de diálogo

Una descripción general fundamental sobre cómo compilar mini y mega modales responsivos, adaptables al color y accesibles con el elemento <dialog>.

En esta publicación, quiero compartir mis ideas sobre cómo crear mini y mega modales adaptables al color, responsivos y accesibles con el elemento <dialog>. Prueba la demostración y consulta la fuente.

Demostración de los diálogos mega y mini en sus temas claro y oscuro.

Si prefieres ver un video, aquí tienes una versión de esta publicación en YouTube:

Descripción general

El elemento <dialog> es ideal para la información o la acción contextual en la página. Ten en cuenta cuándo la experiencia del usuario puede beneficiarse de una acción en la misma página en lugar de una acción de varias páginas: quizás porque el formulario es pequeño o la única acción que se requiere del usuario es confirmar o cancelar.

Recientemente, el elemento <dialog> se volvió estable en todos los navegadores:

Navegadores compatibles

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

Origen

Noté que al elemento le faltaban algunos elementos, por lo que, en este Desafío de GUI, agregué los elementos de experiencia del desarrollador que espero: eventos adicionales, descarte ligero, animaciones personalizadas y un tipo mini y mega.

Marca

Los elementos esenciales de un elemento <dialog> son modestos. El elemento se ocultará automáticamente y tendrá estilos integrados para superponer tu contenido.

<dialog>
  …
</dialog>

Podemos mejorar este modelo de referencia.

Tradicionalmente, un elemento de diálogo comparte mucho con un elemento modal y, a menudo, los nombres son intercambiables. Me tomé la libertad de usar el elemento de diálogo para las ventanas emergentes de diálogo pequeñas (mini) y los diálogos de página completa (mega). Los llamé mega y mini, con ambos diálogos ligeramente adaptados para diferentes casos de uso. Agregué un atributo modal-mode para permitirte especificar el tipo:

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

Captura de pantalla de los diálogos mini y mega en los temas claro y oscuro.

No siempre, pero, por lo general, se usarán elementos de diálogo para recopilar información de interacción. Los formularios dentro de los elementos de diálogo están hechos para ir juntos. Es una buena idea tener un elemento de formulario que una el contenido del diálogo para que JavaScript pueda acceder a los datos que ingresó el usuario. Además, los botones dentro de un formulario que usan method="dialog" pueden cerrar un diálogo sin JavaScript y pasar datos.

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

Diálogo Mega

Un megadiálogo tiene tres elementos dentro del formulario: <header>, <article> y <footer>. Estos sirven como contenedores semánticos, así como destinos de estilo para la presentación del diálogo. El encabezado titula el diálogo modal y ofrece un botón para cerrarlo. El artículo está destinado a las entradas y la información de los formularios. El pie de página contiene un <menu> de botones de acción.

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

El primer botón de menú tiene autofocus y un controlador de eventos intercalado onclick. El atributo autofocus recibirá el enfoque cuando se abra el diálogo, y creo que es una práctica recomendada colocarlo en el botón Cancelar, no en el botón Confirmar. Esto garantiza que la confirmación sea deliberada y no accidental.

Diálogo pequeño

El diálogo mini es muy similar al diálogo mega, solo le falta un elemento <header>. Esto permite que sea más pequeño y esté más alineado.

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

El elemento de diálogo proporciona una base sólida para un elemento de viewport completo que puede recopilar datos y la interacción del usuario. Estos elementos esenciales pueden generar interacciones muy interesantes y poderosas en tu sitio o aplicación.

Accesibilidad

El elemento de diálogo tiene una accesibilidad integrada muy buena. En lugar de agregar estas funciones como suelo hacer, muchas ya están allí.

Cómo restablecer el enfoque

Como lo hicimos a mano en Cómo compilar un componente de panel lateral, es importante que abrir y cerrar algo de forma correcta enfoque los botones de apertura y cierre relevantes. Cuando se abre ese panel lateral, el enfoque se coloca en el botón de cierre. Cuando se presiona el botón de cierre, se restablece el enfoque en el botón que lo abrió.

Con el elemento de diálogo, este es el comportamiento predeterminado integrado:

Lamentablemente, si quieres animar el diálogo dentro y fuera, se pierde esta funcionalidad. En la sección de JavaScript, restableceré esa funcionalidad.

Cómo atrapar el enfoque

El elemento de diálogo administra inert por ti en el documento. Antes de inert, se usaba JavaScript para detectar si el enfoque abandonaba un elemento, en cuyo momento lo interceptaba y lo volvía a colocar.

Navegadores compatibles

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

Origen

Después de inert, cualquier parte del documento se puede “inmovilizar” de modo que ya no sean objetivos de enfoque ni sean interactivos con el mouse. En lugar de atrapar el enfoque, este se guía a la única parte interactiva del documento.

Cómo abrir un elemento y enfocarlo automáticamente

De forma predeterminada, el elemento de diálogo asignará el enfoque al primer elemento que pueda enfocarse en el marcado de diálogo. Si este no es el mejor elemento para que el usuario use de forma predeterminada, usa el atributo autofocus. Como se describió anteriormente, creo que es una práctica recomendada colocar esto en el botón Cancelar y no en el botón Confirmar. Esto garantiza que la confirmación sea deliberada y no accidental.

Cómo cerrar con la tecla Escape

Es importante que sea fácil cerrar este elemento potencialmente interruptivo. Por suerte, el elemento de diálogo controlará la tecla de escape por ti, lo que te liberará de la carga de orquestación.

Estilos

Hay una forma sencilla de aplicar diseño al elemento de diálogo y una forma difícil. La ruta fácil se logra si no se cambia la propiedad de visualización del diálogo y se trabaja con sus limitaciones. Elegí la opción más difícil para proporcionar animaciones personalizadas para abrir y cerrar el diálogo, tomar la propiedad display y mucho más.

Cómo aplicar diseño con Open Props

Para acelerar los colores adaptables y la coherencia general del diseño, incluí sin vergüenza mi biblioteca de variables CSS Open Props. Además de las variables proporcionadas de forma gratuita, también importo un archivo normalize y algunos botones, que Open Props proporciona como importaciones opcionales. Estas importaciones me ayudan a enfocarme en personalizar el diálogo y la demo sin necesidad de muchos estilos para admitirlo y que se vea bien.

Cómo aplicar diseño al elemento <dialog>

Propiedad de la propiedad de visualización

El comportamiento predeterminado de mostrar y ocultar un elemento de diálogo activa o desactiva la propiedad de visualización de block a none. Lamentablemente, esto significa que no se puede animar dentro y fuera, solo dentro. Me gustaría animar la entrada y la salida, y el primer paso es configurar mi propia propiedad display:

dialog {
  display: grid;
}

Cuando se cambia el valor de la propiedad de visualización y, por lo tanto, se es propietario de ella, como se muestra en el fragmento de CSS anterior, se debe administrar una cantidad considerable de estilos para facilitar la experiencia del usuario adecuada. Primero, se cierra el estado predeterminado de un diálogo. Puedes representar este estado de forma visual y evitar que el diálogo reciba interacciones con los siguientes estilos:

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

Ahora, el diálogo es invisible y no se puede interactuar con él cuando no está abierto. Más adelante, agregaré un poco de JavaScript para administrar el atributo inert en el diálogo, lo que garantizará que los usuarios del teclado y el lector de pantalla tampoco puedan acceder al diálogo oculto.

Cómo asignarle un tema de color adaptable al diálogo

Diálogo mega que muestra el tema claro y oscuro, y demuestra los colores de la superficie.

Si bien color-scheme elige tu documento en un tema de color adaptable proporcionado por el navegador para las preferencias del sistema claro y oscuro, quería personalizar el elemento de diálogo más que eso. Open Props proporciona algunos colores de superficie que se adaptan automáticamente a las preferencias del sistema claro y oscuro, de manera similar a usar color-scheme. Son ideales para crear capas en un diseño y me encanta usar el color para ayudar a respaldar visualmente esta apariencia de las superficies de las capas. El color de fondo es var(--surface-1). Para ubicarte sobre esa capa, usa 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);
  }
}

Más adelante, se agregarán más colores adaptables para los elementos secundarios, como el encabezado y el pie de página. Los considero un elemento adicional para un diálogo, pero son muy importantes para lograr un diseño de diálogo atractivo y bien diseñado.

Tamaño del diálogo responsivo

De forma predeterminada, el diálogo delega su tamaño a su contenido, lo que, por lo general, es una gran ventaja. Mi objetivo aquí es restringir el elemento max-inline-size a un tamaño legible (--size-content-3 = 60ch) o al 90% del ancho del viewport. Esto garantiza que el diálogo no se extienda de borde a borde en un dispositivo móvil y que no sea tan ancho en una pantalla de computadora de escritorio que sea difícil de leer. Luego, agrego un max-block-size para que el diálogo no exceda la altura de la página. Esto también significa que necesitaremos especificar dónde está el área desplazable del diálogo, en caso de que sea un elemento 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;
}

¿Observas cómo tengo max-block-size dos veces? El primero usa 80vh, una unidad de viewport física. Lo que realmente quiero es mantener el diálogo dentro del flujo relativo para los usuarios internacionales, por lo que uso la unidad dvb lógica, más reciente y solo parcialmente compatible en la segunda declaración para cuando se vuelva más estable.

Posicionamiento del diálogo mega

Para ayudar a posicionar un elemento de diálogo, vale la pena desglosar sus dos partes: el fondo de pantalla completa y el contenedor de diálogo. El fondo debe cubrir todo y proporcionar un efecto de sombra para ayudar a que este diálogo esté en primer plano y no se pueda acceder al contenido que está detrás. El contenedor de diálogo puede centrarse sobre este fondo y tomar la forma que requiera su contenido.

Los siguientes estilos fijan el elemento de diálogo en la ventana, lo estiran a cada esquina y usan margin: auto para centrar el contenido:

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

En viewports pequeños, le doy un estilo un poco diferente a este mega modal de página completa. Establecí el margen inferior en 0, lo que lleva el contenido del diálogo a la parte inferior de la ventana de visualización. Con un par de ajustes de estilo, puedo convertir el diálogo en una hoja de acciones, más cerca de los pulgares del usuario:

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

Captura de pantalla de las herramientas para desarrolladores que superponen el espaciado de margen en el megadiálogo de computadoras de escritorio y dispositivos móviles mientras está abierto.

Posicionamiento del diálogo en miniatura

Cuando usas un viewport más grande, como en una computadora de escritorio, elegí posicionar los diálogos en miniatura sobre el elemento que los llamó. Para ello, necesito JavaScript. Puedes encontrar la técnica que uso aquí, pero creo que está fuera del alcance de este artículo. Sin JavaScript, el diálogo mini aparece en el centro de la pantalla, al igual que el diálogo mega.

Haz que resalte

Por último, agrega un poco de estilo al diálogo para que se vea como una superficie suave que se encuentra por sobre la página. La suavidad se logra redondeando las esquinas del diálogo. La profundidad se logra con uno de los elementos de sombra de Open Props, creados con mucho cuidado:

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

Personaliza el pseudoelemento de fondo

Elegí trabajar muy ligeramente con el fondo y solo agregué un efecto de desenfoque con backdrop-filter al diálogo mega:

Navegadores compatibles

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

Origen

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

También decidí colocar una transición en backdrop-filter, con la esperanza de que los navegadores permitan la transición del elemento de fondo en el futuro:

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

Captura de pantalla del megadiálogo que se superpone a un fondo desenfocado de avatares coloridos.

Elementos adicionales de diseño

Llamo a esta sección “extras” porque tiene más que ver con mi demo del elemento de diálogo que con el elemento de diálogo en general.

Contención de desplazamiento

Cuando se muestra el diálogo, el usuario aún puede desplazarse por la página detrás de él, lo que no quiero:

Por lo general, overscroll-behavior sería mi solución habitual, pero según las especificaciones, no tiene ningún efecto en el diálogo porque no es un puerto de desplazamiento, es decir, no es un control deslizante, por lo que no hay nada que evitar. Podría usar JavaScript para detectar los eventos nuevos de esta guía, como “cerrado” y “abierto”, y activar o desactivar overflow: hidden en el documento, o bien podría esperar a que :has() sea estable en todos los navegadores:

Navegadores compatibles

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

Origen

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

Ahora, cuando se abre un diálogo mega, el documento HTML tiene overflow: hidden.

El diseño <form>

Además de ser un elemento muy importante para recopilar la información de interacción del usuario, lo uso aquí para diseñar el encabezado, el pie de página y los elementos del artículo. Con este diseño, mi intención es articular el artículo secundario como un área desplazable. Lo logro con grid-template-rows. El elemento artículo tiene 1fr y el formulario tiene la misma altura máxima que el elemento de diálogo. Establecer esta altura firme y este tamaño de fila firme es lo que permite que el elemento de artículo se restrinja y se desplace cuando se desborda:

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

Captura de pantalla de las herramientas para desarrolladores que superponen la información del diseño de cuadrícula sobre las filas.

Aplica diseño al diálogo <header>

La función de este elemento es proporcionar un título para el contenido del diálogo y ofrecer un botón para cerrar fácil de encontrar. También se le asigna un color de superficie para que parezca que está detrás del contenido del artículo del diálogo. Estos requisitos generan un contenedor de flexbox, elementos alineados verticalmente que están espaciados hasta sus bordes y algunos padding y espacios para dar espacio al título y a los botones de cierre:

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 pantalla de las Herramientas para desarrolladores de Chrome que superponen información del diseño de flexbox en el encabezado del diálogo.

Aplica diseño al botón de cierre del encabezado

Dado que la demostración usa los botones Open Props, el botón de cierre se personaliza en un botón centrado en el ícono redondo de la siguiente manera:

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 pantalla de las Herramientas para desarrolladores de Chrome que superponen información de tamaño y padding para el botón de cierre del encabezado.

Aplica diseño al diálogo <article>

El elemento article tiene un rol especial en este diálogo: es un espacio destinado a desplazarse en el caso de un diálogo alto o largo.

Para lograrlo, el elemento de formulario superior estableció algunos máximos para sí mismo que proporcionan restricciones para que este elemento de artículo alcance si se hace demasiado alto. Establece overflow-y: auto para que las barras de desplazamiento solo se muestren cuando sea necesario, contengan el desplazamiento dentro de ellas con overscroll-behavior: contain y el resto sean estilos de presentación 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);
  }
}

El pie de página tiene la función de contener menús de botones de acción. Flexbox se usa para alinear el contenido al final del eje intercalado del pie de página y, luego, se agrega un espaciado para darles espacio a los botones.

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 pantalla de Chrome DevTools que superpone información del diseño de flexbox en el elemento del pie de página.

El elemento menu se usa para contener los botones de acción del diálogo. Usa un diseño de flexbox de unión con gap para proporcionar espacio entre los botones. Los elementos del menú tienen padding, como un <ul>. También quité ese estilo, ya que no lo necesito.

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 pantalla de Chrome DevTools que superpone información de flexbox en los elementos del menú del pie de página.

Animación

Los elementos de diálogo suelen estar animados porque entran y salen de la ventana. Agregar un movimiento de apoyo a los diálogos para esta entrada y salida ayuda a los usuarios a orientarse en el flujo.

Por lo general, el elemento de diálogo solo se puede animar hacia adentro, no hacia afuera. Esto se debe a que el navegador activa o desactiva la propiedad display en el elemento. Antes, la guía configuraba la pantalla en cuadrícula y nunca la configuraba en ninguna. Esto desbloquea la capacidad de animar los elementos dentro y fuera.

Open Props incluye muchas animaciones de fotogramas clave para usar, lo que facilita y hace legible la orquestación. Estos son los objetivos de animación y el enfoque en capas que utilicé:

  1. El movimiento reducido es la transición predeterminada, una atenuación de opacidad simple.
  2. Si el movimiento es correcto, se agregan animaciones de deslizamiento y escalamiento.
  3. El diseño responsivo para dispositivos móviles del diálogo mega se ajusta para deslizarse hacia afuera.

Una transición predeterminada segura y significativa

Si bien Open Props incluye fotogramas clave para la aparición y atenuación graduales, prefiero este enfoque en capas de transiciones como la opción predeterminada con animaciones de fotogramas clave como posibles actualizaciones. Anteriormente, ya aplicamos diseño a la visibilidad del diálogo con opacidad, orquestando 1 o 0 según el atributo [open]. Para realizar la transición entre el 0% y el 100%, indícale al navegador cuánto tiempo y qué tipo de atenuación deseas:

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

Cómo agregar movimiento a la transición

Si el usuario acepta el movimiento, tanto el diálogo mega como el mini deben deslizarse hacia arriba como entrada y reducirse de tamaño como salida. Puedes lograrlo con la consulta de medios prefers-reduced-motion y algunos elementos 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;
  }
}

Cómo adaptar la animación de salida para dispositivos móviles

Anteriormente, en la sección de diseño, el estilo de diálogo mega se adaptó para que los dispositivos móviles sean más como una hoja de acciones, como si un pequeño papel se deslizara desde la parte inferior de la pantalla y aún estuviera unido a la parte inferior. La animación de salida de escalamiento no se ajusta bien a este nuevo diseño, y podemos adaptarla con algunas consultas de medios y algunos elementos 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

Hay bastantes elementos que se pueden agregar con 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
}

Estas incorporaciones provienen del deseo de descartar de forma ligera (hacer clic en el fondo del diálogo), animación y algunos eventos adicionales para obtener mejores tiempos para obtener los datos del formulario.

Cómo agregar el rechazo de luz

Esta tarea es sencilla y es una gran adición a un elemento de diálogo que no se anima. La interacción se logra observando los clics en el elemento de diálogo y aprovechando el burbujamiento de eventos para evaluar en qué se hizo clic. Solo se close() si es el elemento más alto:

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

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

Observa dialog.close('dismiss'). Se llama al evento y se proporciona una cadena. Otro código JavaScript puede recuperar esta cadena para obtener estadísticas sobre cómo se cerró el diálogo. Verás que también proporcioné cadenas de cierre cada vez que llamo a la función desde varios botones para proporcionar contexto a mi aplicación sobre la interacción del usuario.

Cómo agregar eventos de cierre y cerrados

El elemento de diálogo incluye un evento de cierre: se emite de inmediato cuando se llama a la función close() del diálogo. Como animaremos este elemento, es útil tener eventos antes y después de la animación para que se realice un cambio para obtener los datos o restablecer el formulario de diálogo. Aquí lo uso para administrar la adición del atributo inert en el diálogo cerrado y, en la demostración, los uso para modificar la lista de avatares si el usuario envió una imagen nueva.

Para lograrlo, crea dos eventos nuevos llamados closing y closed. Luego, escucha el evento de cierre integrado en el diálogo. Desde aquí, configura el diálogo en inert y envía el evento closing. La siguiente tarea es esperar a que las animaciones y las transiciones terminen de ejecutarse en el diálogo y, luego, enviar el 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))

La función animationsComplete, que también se usa en Cómo compilar un componente de tostada, muestra una promesa según la finalización de las promesas de animación y transición. Por eso, dialogClose es una función asíncrona; luego, puede await la promesa que se muestra y avanzar con confianza al evento cerrado.

Cómo agregar eventos de apertura y abiertos

Estos eventos no son tan fáciles de agregar, ya que el elemento de diálogo integrado no proporciona un evento de apertura como lo hace con el cierre. Uso un MutationObserver para proporcionar estadísticas sobre los cambios en los atributos del diálogo. En este observador, vigilaré los cambios en el atributo abierto y administraré los eventos personalizados según corresponda.

De manera similar a como iniciamos los eventos de cierre y cerrado, crea dos eventos nuevos llamados opening y opened. Donde antes escuchábamos el evento de cierre del diálogo, esta vez usa un observador de mutación creado para observar los atributos del 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)
    }
  })
})

Se llamará a la función de devolución de llamada del observador de mutación cuando se cambien los atributos del diálogo y se proporcione la lista de cambios como un array. Itera sobre los cambios de atributos y busca que attributeName esté abierto. A continuación, verifica si el elemento tiene el atributo o no. Esto indica si el diálogo se abrió o no. Si se abrió, quita el atributo inert y establece el enfoque en un elemento que solicite autofocus o en el primer elemento button que se encuentre en el diálogo. Por último, de manera similar al evento de cierre y cerrado, envía el evento de apertura de inmediato, espera a que finalicen las animaciones y, luego, envía el evento abierto.

Cómo agregar un evento quitado

En las aplicaciones de una sola página, los diálogos a menudo se agregan y quitan según las rutas o las necesidades y el estado de la aplicación. Puede ser útil limpiar eventos o datos cuando se quita un diálogo.

Puedes lograrlo con otro observador de mutaciones. Esta vez, en lugar de observar atributos en un elemento de diálogo, observaremos los elementos secundarios del elemento cuerpo y buscaremos elementos de diálogo que se quiten.


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)
      }
    })
  })
})

Se llama a la devolución de llamada del observador de mutación cada vez que se agregan o quitan elementos secundarios del cuerpo del documento. Las mutaciones específicas que se observan son para removedNodes que tienen el nodeName de un diálogo. Si se quitó un diálogo, se quitan los eventos de clic y cierre para liberar memoria, y se envía el evento personalizado quitado.

Cómo quitar el atributo de carga

Para evitar que la animación del diálogo reproduzca su animación de salida cuando se agrega a la página o cuando se carga, se agregó un atributo de carga al diálogo. La siguiente secuencia de comandos espera a que las animaciones del diálogo terminen de ejecutarse y, luego, quita el atributo. Ahora, el diálogo puede animarse y ocultarse, y ocultamos de manera efectiva una animación que, de otro modo, sería una distracción.

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

Obtén más información sobre el problema de evitar las animaciones de fotogramas clave en la carga de la página aquí.

Todos juntos

A continuación, se muestra dialog.js en su totalidad, ahora que explicamos cada sección 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')
}

Usa el módulo dialog.js

La función exportada del módulo espera que se le llame y se le pase un elemento de diálogo que quiera agregar estos eventos y funciones nuevos:

import GuiDialog from './dialog.js'

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

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Así, los dos diálogos se actualizan con el descarte de luz, correcciones de carga de animación y más eventos con los que trabajar.

Cómo escuchar los nuevos eventos personalizados

Cada elemento de diálogo actualizado ahora puede detectar cinco eventos nuevos, de la siguiente manera:

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

Estos son dos ejemplos de cómo controlar esos 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()
  }
}

En la demostración que creé con el elemento de diálogo, uso ese evento cerrado y los datos del formulario para agregar un nuevo elemento de avatar a la lista. El tiempo es bueno, ya que el diálogo completó su animación de salida y, luego, algunas secuencias de comandos se animan en el nuevo avatar. Gracias a los eventos nuevos, la orquestación de la experiencia del usuario puede ser más fluida.

Observa dialog.returnValue: Contiene la cadena de cierre que se pasa cuando se llama al evento close() del diálogo. En el evento dialogClosed, es fundamental saber si se cerró, canceló o confirmó el diálogo. Si se confirma, la secuencia de comandos toma los valores del formulario y lo restablece. El restablecimiento es útil para que, cuando se vuelva a mostrar el diálogo, esté en blanco y listo para un nuevo envío.

Conclusión

Ahora que sabes cómo lo hice, ¿cómo lo harías tú? 🙂

Diversifiquemos nuestros enfoques y aprendamos todas las formas de compilar en la Web.

Crea una demo, twittea los vínculos y los agregaré a la sección de remixes de la comunidad a continuación.

Remixes de la comunidad

Recursos