Cómo compilar un componente de diálogo

Descripción general básica sobre cómo compilar modales pequeños y grandes adaptables al color, responsivos y accesibles con el elemento <dialog>.

En esta publicación, quiero compartir mis ideas sobre cómo crear modales pequeños y grandes que sean adaptables al color, responsivos y accesibles con el elemento <dialog>. Prueba la demostración y consulta el código fuente.

Demostración de los diálogos grandes y pequeños en sus temas claro y oscuro.

Si prefieres un video, aquí tienes una versión de este artículo en YouTube:

Descripción general

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

El elemento <dialog> se ha vuelto estable recientemente en todos los navegadores:

Browser Support

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

Source

Descubrí que al elemento le faltaban algunas cosas, por lo que en este desafío de GUI agregué los elementos de experiencia del desarrollador que esperaba: eventos adicionales, descarte ligero, animaciones personalizadas y un tipo mini y mega.

Marca

Los aspectos básicos de un elemento <dialog> son modestos. El elemento se ocultará automáticamente y tiene estilos integrados para superponer tu contenido.

<dialog>
  …
</dialog>

Podemos mejorar este modelo de referencia.

Tradicionalmente, un elemento de diálogo comparte mucho con un modal, y, a menudo, los nombres son intercambiables. Aquí me tomé la libertad de usar el elemento de diálogo tanto para las ventanas emergentes de diálogo pequeñas (mini) como para los diálogos de página completa (mega). Los llamé mega y mini, y ambos diálogos se adaptaron ligeramente 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, en general, los elementos de diálogo se usarán para recopilar información de interacción. Los formularios dentro de los elementos de diálogo están diseñados para ir juntos. Es una buena idea que un elemento de formulario envuelva 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 usa 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 de Mega

Un diálogo grande tiene tres elementos dentro del formulario: <header>, <article> y <footer>. Estos elementos sirven como contenedores semánticos y como objetivos de estilo para la presentación del diálogo. El encabezado titula el diálogo modal y ofrece un botón de cierre. El artículo es para la información y las entradas de 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 de cancelar, no en el botón de confirmar. Esto garantiza que la confirmación sea deliberada y no accidental.

Minidiálogo

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

<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 básicos pueden generar interacciones muy interesantes y eficaces 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 lo hago habitualmente, muchas ya están disponibles.

Cómo restablecer el enfoque

Al igual que lo hicimos de forma manual en Cómo compilar un componente de navegación lateral, es importante que abrir y cerrar algo correctamente enfoque los botones de abrir y cerrar pertinentes. Cuando se abre esa navegación lateral, el enfoque se coloca en el botón de cierre. Cuando se presiona el botón de cierre, el enfoque se restablece en el botón que lo abrió.

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

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

Cómo atrapar el enfoque

El elemento dialog administra inert por ti en el documento. Antes de inert, se usaba JavaScript para observar si el enfoque abandonaba un elemento, momento en el que lo interceptaba y lo volvía a colocar.

Browser Support

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

Source

Después de inert, cualquier parte del documento se puede "congelar" de modo que ya no sean objetivos de enfoque ni interactivos con un mouse. En lugar de atrapar el enfoque, se lo guía hacia la única parte interactiva del documento.

Abrir y enfocar automáticamente un elemento

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

Cómo cerrar con la tecla de escape

Es importante que sea fácil cerrar este elemento que podría interrumpir la experiencia. Afortunadamente, el elemento de diálogo controlará la tecla de escape por ti, lo que te liberará de la carga de la organización.

Estilos

Hay una ruta fácil y una ruta difícil para aplicar diseño al elemento de diálogo. La ruta fácil se logra sin cambiar la propiedad de visualización del diálogo y trabajando con sus limitaciones. Tomo el camino difícil para proporcionar animaciones personalizadas para abrir y cerrar el diálogo, tomando el control de la propiedad display y mucho más.

Cómo dar estilo con Open Props

Para acelerar los colores adaptativos y la coherencia general del diseño, incorporé sin pudor 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 demostración sin necesidad de muchos estilos para admitirlos y hacer que se vean bien.

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

Ser propietario de la propiedad de visualización

El comportamiento predeterminado de mostrar y ocultar un elemento de diálogo alterna la propiedad de visualización de block a none. Lamentablemente, esto significa que no se puede animar para que aparezca y desaparezca, sino solo para que aparezca. Me gustaría animar la entrada y la salida, y el primer paso es establecer mi propia propiedad display:

dialog {
  display: grid;
}

Al cambiar, y, por lo tanto, poseer, el valor de la propiedad de visualización, 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, el estado predeterminado de un diálogo es cerrado. Puedes representar este estado visualmente 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é código JavaScript para administrar el atributo inert en el diálogo, lo que garantizará que los usuarios de teclado y lector de pantalla tampoco puedan acceder al diálogo oculto.

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

Mega diálogo que muestra el tema claro y oscuro, y demuestra los colores de superficie.

Si bien color-scheme habilita 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 de modo claro y oscuro, de manera similar al uso de 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 superficies en capas. El color de fondo es var(--surface-1). Para que se muestre 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 adaptativos para los elementos secundarios, como el encabezado y el pie de página. Los considero extras para un elemento de diálogo, pero son muy importantes para crear un diseño de diálogo atractivo y bien diseñado.

Tamaño de diálogo responsivo

De forma predeterminada, el diálogo delega su tamaño a su contenido, lo que suele ser excelente. Mi objetivo aquí es restringir el 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 escritorio que dificulte su lectura. Luego, agrego un max-block-size para que el diálogo no exceda la altura de la página. Esto también significa que tendremos que especificar dónde se encuentra 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 que tengo max-block-size dos veces? La primera 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 nueva y solo parcialmente compatible en la segunda declaración para cuando se vuelva más estable.

Posicionamiento de diálogos grandes

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 indicar que el diálogo está en primer plano y que no se puede acceder al contenido que está detrás. El contenedor de diálogo puede centrarse libremente sobre este fondo y adoptar la forma que requiera su contenido.

Los siguientes estilos fijan el elemento de diálogo a 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álogos mega para dispositivos móviles

En viewports pequeños, le aplico un estilo un poco diferente a este modal grande de página completa. Establezco el margen inferior en 0, lo que lleva el contenido del diálogo a la parte inferior de la ventana gráfica. Con algunos 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 márgenes en el diálogo grande para computadoras y dispositivos móviles mientras está abierto.

Posicionamiento de diálogos pequeños

Cuando se usa una ventana gráfica más grande, como en una computadora de escritorio, elegí colocar los diálogos pequeños 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 pequeño aparece en el centro de la pantalla, al igual que el diálogo grande.

Haz que resalte

Por último, agrega un poco de estilo al diálogo para que parezca una superficie suave que se encuentra muy por encima de la página. La suavidad se logra redondeando las esquinas del diálogo. La profundidad se logra con una de las propiedades de sombra cuidadosamente diseñadas de Open Props:

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

Cómo personalizar el seudoelemento de fondo

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

Browser Support

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

Source

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

También elegí poner 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 diálogo grande que se superpone a un fondo desenfocado de avatares coloridos.

Accesorios de diseño

Llamo a esta sección "extras" porque tiene más que ver con mi demostración 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 que se encuentra detrás de él, lo que no quiero que suceda:

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

Browser Support

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

Source

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

Ahora, cuando se abre un diálogo grande, 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, aquí lo uso para diseñar el encabezado, el pie de página y los elementos del artículo. Con este diseño, pretendo articular el elemento secundario del artículo como un área desplazable. Esto se logra con grid-template-rows. El elemento del artículo recibe 1fr y el formulario en sí tiene la misma altura máxima que el elemento del diálogo. Establecer esta altura y tamaño de fila firmes es lo que permite que el elemento del 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.

Cómo aplicar diseño al diálogo <header>

El rol 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 estar detrás del contenido del artículo del diálogo. Estos requisitos generan un contenedor de flexbox, elementos alineados verticalmente que se espacian hasta sus bordes y algunos padding y espacios para darles lugar a los botones de título y 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.

Cómo diseñar el botón de cierre del encabezado

Dado que la demostración usa los botones de Open Props, el botón de cierre se personaliza en un botón centrado en un í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 sobre el tamaño y el padding del botón de cierre del encabezado.

Cómo aplicar 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 lograr esto, el elemento de formulario principal estableció algunos máximos para sí mismo que proporcionan restricciones para que este elemento de artículo alcance si se vuelve demasiado alto. Establece overflow-y: auto para que las barras de desplazamiento solo se muestren cuando sea necesario, incluye el desplazamiento dentro de él con overscroll-behavior: contain y el resto serán 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 el rol de contener menús de botones de acción. Se usa Flexbox para alinear el contenido al final del eje intercalado del pie de página y, luego, se agrega un poco de espacio para que los botones tengan lugar.

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 de diseño de Flexbox en el elemento de pie de página.

El elemento menu se usa para contener los botones de acción del diálogo. Utiliza un diseño de flexbox de ajuste con gap para proporcionar espacio entre los botones. Los elementos de menú tienen padding, como un <ul>. También quito 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ú de pie de página.

Animación

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

Por lo general, el elemento de diálogo solo se puede animar para que aparezca, no para que desaparezca. Esto se debe a que el navegador activa o desactiva la propiedad display en el elemento. Anteriormente, la guía establecía la pantalla en cuadrícula y nunca la establecía en ninguna. Esto desbloquea la capacidad de animar la entrada y salida.

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

  1. El movimiento reducido es la transición predeterminada, un simple desvanecimiento de opacidad.
  2. Si el movimiento es correcto, se agregan animaciones de deslizamiento y escala.
  3. Se ajustó el diseño responsivo para dispositivos móviles del diálogo grande para que se deslice 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 de transiciones en capas como predeterminado con animaciones de fotogramas clave como posibles actualizaciones. Anteriormente, ya aplicamos un diseño a la visibilidad del diálogo con opacidad, y orquestamos 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 aceleració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 grande como el pequeño deben deslizarse hacia arriba al entrar y reducirse al salir. Puedes lograrlo con la consulta de medios prefers-reduced-motion y algunas 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 diseño del diálogo grande se adaptó para dispositivos móviles para que se pareciera más a una hoja de acción, como si un pequeño trozo de papel se hubiera deslizado hacia arriba desde la parte inferior de la pantalla y aún estuviera unido a la parte inferior. La animación de salida de reducción no se adapta bien a este nuevo diseño, y podemos adaptarla con algunas consultas de medios y algunas 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 varias cosas que puedes 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 surgen del deseo de descartar con un clic (hacer clic en el fondo del diálogo), de agregar animación y algunos eventos adicionales para mejorar la sincronización de la obtención de los datos del formulario.

Cómo agregar el descarte con deslizamiento

Esta tarea es sencilla y es una excelente 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 la propagación de eventos para evaluar en qué se hizo clic, y solo se close() si es el elemento superior:

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. Otros códigos JavaScript pueden recuperar esta cadena para obtener estadísticas sobre cómo se cerró el diálogo. También verás que proporcioné cadenas cercanas 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 eventos cerrados

El elemento de diálogo incluye un evento de cierre: se emite inmediatamente cuando se llama a la función close() del diálogo. Como animamos este elemento, es bueno tener eventos para antes y después de la animación, para que un cambio tome los datos o restablezca 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 uso estos 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 finalicen las animaciones y transiciones 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 notificación emergente, devuelve una promesa basada en la finalización de las promesas de animación y transición. Por eso, dialogClose es una función asíncrona: puede await la promesa que se devolvió y avanzar con confianza hacia el evento cerrado.

Cómo agregar eventos de apertura y eventos 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, supervisaré los cambios en el atributo open 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. En lugar de escuchar el evento de cierre del diálogo, esta vez usa un observador de mutaciones 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 mutaciones cuando cambien los atributos del diálogo, y se proporcionará la lista de cambios como un array. Itera sobre los cambios de atributo y busca el attributeName que se abrirá. 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, 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 de apertura.

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 los eventos o los datos cuando se quita un diálogo.

Puedes lograrlo con otro observador de mutaciones. Esta vez, en lugar de observar los atributos de un elemento de diálogo, observaremos los elementos secundarios del elemento body y observaremos si se quitan elementos 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)
      }
    })
  })
})

Se llama a la devolución de llamada del observador de mutaciones 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 loading

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 la página, se agregó un atributo de carga al diálogo. La siguiente secuencia de comandos espera a que finalicen las animaciones del diálogo y, luego, quita el atributo. Ahora, el diálogo puede animarse libremente, y ocultamos de manera eficaz una animación que, de otro modo, distraería.

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 de forma individual:

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

Se espera que se llame a la función exportada del módulo y se le pase un elemento de diálogo que desee tener estos nuevos eventos y funcionalidades agregados:

import GuiDialog from './dialog.js'

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

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Así de fácil, los dos diálogos se actualizaron con la función de descarte ligero, correcciones de carga de animación y más eventos con los que trabajar.

Cómo escuchar los nuevos eventos personalizados

Ahora, cada elemento de diálogo actualizado puede escuchar cinco eventos nuevos, como se muestra a continuación:

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

A continuación, se muestran 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 compilé 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 animan el nuevo avatar. Gracias a los nuevos eventos, la organización de la experiencia del usuario puede ser más fluida.

Aviso dialog.returnValue: Contiene la cadena de cierre que se pasó cuando se llamó al evento close() del diálogo. Es fundamental en el evento dialogClosed saber si el diálogo se cerró, canceló o confirmó. Si se confirma, el script 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 crear contenido en la Web.

Crea una demostración, envíame por Twitter los vínculos y la agregaré a la sección de remixes de la comunidad que se encuentra a continuación.

Remixes de la comunidad

Recursos