Cómo compilar un componente de diálogo

Descripción general fundamental de cómo compilar modales multi y mega que se adapten al color, responsivos y accesibles con el elemento <dialog>

En esta publicación, quiero compartir mis ideas sobre cómo compilar modales multi y mega que se adaptan al color, responden y son 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 YouTube de esta publicación:

Descripción general

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

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

Navegadores compatibles

  • 37
  • 79
  • 98
  • 15.4

Origen

Me di cuenta de que al elemento le faltaban algunos aspectos, por lo que en este desafío de la GUI agrego los elementos de experiencia para desarrolladores que espero: eventos adicionales, descarte simple, animaciones personalizadas y un mini y un tipo mega.

Marca

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

<dialog>
  …
</dialog>

Podemos mejorar este modelo de referencia.

Tradicionalmente, un elemento de diálogo comparte mucho con una ventana modal y, a menudo, los nombres son intercambiables. Me tomé la libertad de usar el elemento de diálogo tanto para ventanas emergentes de diálogo pequeñas (mini) como para 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 que puedas 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 con los temas claro y oscuro.

No siempre, pero, por lo general, se usarán elementos de diálogo para recopilar información sobre la interacción. Los formularios dentro de los elementos de diálogo están hechos para ir juntos. Es conveniente que un elemento de formulario una el contenido de tu 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 Mega

Un diálogo combinado tiene tres elementos dentro del formulario: <header>, <article> y <footer>. Estos funcionan como contenedores semánticos, así como objetivos de estilo para la presentación del diálogo. El encabezado titula la ventana modal y ofrece un botón de cierre. Este artículo es para entradas de formularios e información. 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 intercalados onclick. El atributo autofocus recibirá el enfoque cuando se abra el diálogo. Se recomienda colocarlo en el botón Cancelar, no en el botón Confirmar. Esto garantiza que la confirmación sea deliberada y no accidental.

Minicuadro de diálogo

El minidiálogo es muy similar al diálogo mega; solo le falta un elemento <header>. Esto permite que sea más pequeño y más intercalado.

<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 dar lugar a interacciones muy interesantes y potentes en tu sitio o app.

Accesibilidad

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

Restableciendo el enfoque

Como lo hicimos de forma manual en Cómo compilar un componente de navegación lateral, es importante que abrir y cerrar algo se enfoque correctamente en los botones de apertura y cierre relevantes. 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 al botón que lo abrió.

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

Lamentablemente, si deseas animar el diálogo hacia adentro y afuera, esta funcionalidad se pierde. En la sección de JavaScript, restableceré esa funcionalidad.

Enfoque de trampa

El elemento de diálogo administra inert por ti en el documento. Antes del inert, se usaba JavaScript para observar el enfoque que dejaba un elemento, en cuyo punto lo intercepta y lo vuelve a colocar.

Navegadores compatibles

  • 102
  • 102
  • 112
  • 15.5

Origen

Después de inert, cualquier parte del documento puede “congelarse” a tal punto que ya no son objetivos de enfoque ni son interactivas con un mouse. En lugar de capturar el enfoque, el enfoque se guía a la única parte interactiva del documento.

Abrir un elemento y enfocarlo automáticamente

De forma predeterminada, el elemento del diálogo asignará el foco al primer elemento enfocable en el lenguaje de marcado del diálogo. Si este no es el mejor elemento que puede usar el usuario de forma predeterminada, usa el atributo autofocus. Como se describió antes, creo que se recomienda 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.

Cierre con la tecla Escape

Es importante facilitar el cierre de este elemento potencialmente disruptivo. Por suerte, el elemento de diálogo controlará la clave de escape por ti, lo que te liberará de la carga de organización.

Estilos

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

Cómo dar estilo con objetos abiertos

Para acelerar los colores adaptables y la coherencia general del diseño, incluí descaradamente en mi biblioteca de variables de CSS Open Props. Además de las variables gratuitas proporcionadas, también importo un archivo normalizar 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, a la vez que no necesito muchos estilos para admitirlos y hacer que se vea bien.

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

Cómo ser propietario de la propiedad Display

El comportamiento predeterminado de mostrar y ocultar un elemento de diálogo cambia la propiedad de visualización de block a none. Esto significa que no se puede animar de entrada ni de salida, sino solo hacia adentro. Me gustaría agregar una animación de entrada y de salida, y el primer paso es configurar mi propia propiedad display:

dialog {
  display: grid;
}

Al cambiar (y, por lo tanto, poseer, el valor de propiedad de visualización), como se muestra en el fragmento de CSS anterior, se debe administrar una cantidad considerable de diseños 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 si no está abierto. Más adelante, agregaré JavaScript para administrar el atributo inert en el diálogo, lo que garantizará que los usuarios del teclado y de los lectores de pantalla tampoco puedan acceder al diálogo oculto.

Otorga al diálogo un tema de color adaptable

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

Si bien color-scheme habilita tu documento en un tema de color adaptable proporcionado por el navegador para las preferencias del sistema claro y oscuro, quería personalizar más el elemento de diálogo. Open Props ofrece algunos colores de superficie que se adaptan automáticamente a las preferencias del sistema claro y oscuro, de manera similar al uso de color-scheme. Son geniales para crear capas en un diseño, y me encanta usar color para respaldar visualmente la apariencia de las superficies de capas. El color de fondo es var(--surface-1). Para colocarlo 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 adicionales para un elemento de diálogo, pero realmente importantes para crear un diseño de diálogo atractivo y bien diseñado.

Tamaño de diálogos adaptables

De forma predeterminada, el diálogo delega su tamaño a su contenido, lo que suele ser genial. En este caso, mi objetivo es restringir max-inline-size a un tamaño legible (--size-content-3 = 60ch) o al 90% del ancho del viewport. De esta manera, te aseguras de que el diálogo no esté 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 supere la altura de la página. Esto también significa que, en caso de que se trate de un elemento de diálogo alto, necesitaremos especificar la ubicación del área desplazable del diálogo.

dialog {
  …
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

¿Notas que tengo max-block-size dos veces? El primero usa 80vh, una unidad de viewport física. Lo que en realidad quiero es mantener el diálogo dentro del flujo relativo para los usuarios internacionales, así 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 megadiálogo

Para ayudar a posicionar un elemento de diálogo, es conveniente desglosar sus dos partes: el fondo de pantalla completa y el contenedor de diálogo. El fondo debe abarcar todo y proporcionar un efecto de matiz para ayudar a admitir que este diálogo está al frente y que no se puede acceder al contenido detrás. El contenedor de diálogo puede centrarse sobre este fondo y tomar la forma que requiera su contenido.

Los siguientes diseños corrigen el elemento de diálogo de la ventana, lo estiran en 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 pequeñas ventanas de visualización, cambio el estilo de esta mega modal de página completa de forma un poco diferente. Configuré el margen inferior en 0, lo que lleva el contenido del diálogo a la parte inferior del viewport. 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 Herramientas para desarrolladores que superpone el espaciado de los márgenes en el diálogo combinado para computadoras de escritorio y dispositivos móviles mientras está abierto.

Posicionamiento del minidiálogo

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

Destaca

Por último, agrega estilo al diálogo para que parezca una superficie suave sobre la página. La suavidad se logra redondeando las esquinas del diálogo. La profundidad se logra con una de las props de sombra cuidadosamente elaboradas 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:

Navegadores compatibles

  • 76
  • 17
  • 103
  • 9

Origen

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

También elegí establecer 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 superpuesto con un fondo desenfocado de avatares coloridos.

Elementos de diseño adicionales

A esta sección la llamo "extras" porque tiene más que ver con la demostración del elemento de diálogo que con el elemento del diálogo en general.

Contención del desplazamiento

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

Por lo general, overscroll-behavior sería mi solución habitual, pero, según las especificaciones, no tiene efecto en el diálogo porque no es un puerto de desplazamiento, es decir, no es una barra de desplazamiento, por lo que no hay nada que evitar. Podría usar JavaScript para supervisar 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 que :has() sea estable en todos los navegadores:

Navegadores compatibles

  • 105
  • 105
  • 121
  • 15.4

Origen

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

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

El diseño de <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 los elementos del encabezado, el pie de página y el artículo. Con este diseño, tengo la intención de articular el elemento secundario del artículo como un área desplazable. Lo logro con grid-template-rows. Al elemento del artículo se le asigna 1fr, y el formulario en sí tiene la misma altura máxima que el elemento de 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 Herramientas para desarrolladores que superpone la información del diseño de cuadrícula sobre las filas.

Cómo aplicar estilo 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 de cierre fácil de encontrar. También se le da un color de superficie para que parezca estar detrás del contenido del artículo del diálogo. Estos requisitos conducen a un contenedor de flexbox, elementos alineados verticalmente que están espaciados hasta sus bordes y algo de padding y espacios para darle algo de espacio a los botones de título y 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 Herramientas para desarrolladores de Chrome que se superpone a la información de diseño de Flexbox en el encabezado del diálogo.

Cómo aplicar diseño al 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 redondo centrado en el ícono 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 Herramientas para desarrolladores de Chrome que se superpone con la información de tamaño y relleno del botón de cierre del encabezado.

Cómo aplicar estilo al diálogo <article>

El elemento del artículo tiene una función especial en este diálogo: es un espacio destinado al desplazamiento en el caso de un diálogo alto o largo.

Para lograr esto, el elemento de formulario superior estableció algunos máximos que proporcionan restricciones para que este elemento de artículo alcance si se vuelve demasiado alto. Configura 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 rol del pie de página consiste en 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 aplica un poco de 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 Herramientas para desarrolladores de Chrome que superpone información de 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 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 Herramientas para desarrolladores de Chrome que se superpone a información de flexbox en los elementos del menú del pie de página.

Animación

Los elementos de diálogo a menudo se animan porque entran y salen de la ventana. Dar a los diálogos algún movimiento de apoyo para esta entrada y salida ayuda a los usuarios a orientarse en el flujo.

Por lo general, solo se puede animar el elemento de diálogo hacia adentro, no hacia afuera. Esto se debe a que el navegador activa o desactiva la propiedad display en el elemento. Anteriormente, la guía configuró la visualización en cuadrícula y nunca la estableció en ninguno. Esto desbloquea la capacidad de entrar y salir.

Open Props incluye muchas animaciones de fotogramas clave para su uso, lo que hace que la organización sea fácil y legible. Estos son los objetivos de la animación y el enfoque en capas que tomé:

  1. El movimiento reducido es la transición predeterminada, una opacidad simple con fundido de entrada y salida.
  2. Si el movimiento es aceptable, se agregan animaciones de deslizamiento y ajuste.
  3. El diseño responsivo para dispositivos móviles del diálogo combinado se ajusta para deslizarse hacia afuera.

Una transición predeterminada segura y significativa

Si bien Open Props incluye fotogramas clave con el fundido de entrada y la salida, prefiero este enfoque de transiciones en capas como configuración predeterminada y animaciones de fotogramas clave como posibles actualizaciones. Anteriormente, diseñamos la visibilidad del diálogo con opacidad, organizando 1 o 0 según el atributo [open]. Para realizar la transición entre el 0% y el 100%, indícale al navegador durante cuánto tiempo y qué tipo de aceleración deseas que ocurra:

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 escalar horizontalmente como salida. Puedes lograr esto con la consulta de medios prefers-reduced-motion y algunos objetos 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 megadiálogo se adaptó para dispositivos móviles de modo que se parezca más a una hoja de acciones, como si una pequeña hoja de papel se deslizó hacia arriba desde la parte inferior de la pantalla y aún estuviera pegada a la parte inferior. La animación de salida para reducir la escala horizontal no se adapta bien a este nuevo diseño, y podemos adaptar esto con un par de consultas de medios y algunos objetos 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

Puedes agregar varias funciones 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 adiciones derivan del deseo de un descarte ligero (hacer clic en el fondo del diálogo), animaciones y algunos eventos adicionales para obtener mejores tiempos de obtención de los datos del formulario.

Agregando descartado ligero

Esta tarea es sencilla y es un gran aporte para 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 burbuje de eventos para evaluar en qué se hizo clic. Solo 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. Otro JavaScript puede recuperar esta cadena para obtener estadísticas sobre cómo se cerró el diálogo. Descubrirás que también he proporcionado 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.

Agregar eventos de cierre y de cierre

El elemento de diálogo incluye un evento de cierre: se emite de inmediato cuando se llama a la función de diálogo close(). Dado que estamos animando 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. Lo uso aquí para administrar la adición del atributo inert en el diálogo cerrado y, en la demostración, lo 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, busca el evento de cierre integrado en el diálogo. Desde aquí, configura el diálogo como inert y envía el evento closing. La siguiente tarea es esperar a que las animaciones y 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 aviso, muestra una promesa basada en la finalización de las promesas de animación y transición. Es por eso que dialogClose es una función asíncrona, que luego puede await la promesa que se muestra y avanzar con confianza al evento cerrado.

Agregar eventos de apertura y apertura

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

De manera similar a cómo comenzamos los eventos de cierre y cierre, crea dos eventos nuevos llamados opening y opened. Donde anteriormente escuchamos 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 se modifiquen los atributos del diálogo, lo que proporciona la lista de cambios como un array. Itera los cambios de atributos y busca que se abra attributeName. 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 foco en un elemento que solicita autofocus o en el primer elemento button que se encuentra en el diálogo. Por último, al igual que los eventos de cierre y cierre, 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 eliminado

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

Puedes lograr esto con otro observador de mutaciones. Esta vez, en lugar de observar los atributos en un elemento de diálogo, observaremos los elementos secundarios del elemento del cuerpo y observaremos si se quitan los 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 que se quitó.

Quita el atributo de carga

Para evitar que la animación de diálogo reproduzca su animación de salida cuando se la agregue a la página o cuando se cargue la página, se agregó un atributo de carga al diálogo. La siguiente secuencia de comandos espera a que terminen de ejecutarse las animaciones de diálogo y, luego, quita el atributo. Ahora el diálogo se puede animar de entrada y salida, y ocultamos una animación que, de otro modo, podría distraer.

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

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

Todo junto

Ahora que explicamos cada sección de forma individual, aquí se muestra dialog.js en su totalidad:

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

Cómo usar el módulo dialog.js

La función exportada del módulo espera que se la llame y se pase un elemento de diálogo que desee que se agreguen estos nuevos eventos y funcionalidades:

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 actualizan con un descarte ligero, correcciones de carga de animación y más eventos con los que trabajar.

Escucha los nuevos eventos personalizados

Cada elemento de diálogo actualizado ahora puede escuchar cinco eventos nuevos, como el siguiente:

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

A continuación, se incluyen 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 nuevos eventos, organizar la experiencia del usuario puede ser más fluida.

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

Conclusión

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

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

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

Remixes de la comunidad

Recursos