Cómo compilar un componente de diálogo

Una descripción general fundamental de cómo compilar mini y megamodales adaptables, responsivas y accesibles con el elemento <dialog>.

En esta publicación, quiero compartir mis ideas sobre cómo compilar mini y megamodales adaptables, 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 claros y oscuros.

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

Descripción general

El elemento <dialog> es excelente para la acción o información contextual 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 volvió estable en todos los navegadores:

Navegadores compatibles

  • 37
  • 79
  • 98
  • 15.4

Origen

Creo que al elemento le faltaban algunos aspectos, por lo que en este desafío de la GUI agrego los elementos de la experiencia para desarrolladores que espero: eventos adicionales, descarte de luz, animaciones personalizadas, y los tipos mini y mega.

Marca

Los elementos esenciales de un elemento <dialog> son sencillos. El elemento se ocultará automáticamente y tendrá diseños incorporados para superponerse a tu contenido.

<dialog>
  …
</dialog>

Podemos mejorar este modelo de referencia.

Tradicionalmente, un elemento de diálogo comparte mucho con un modal y, con frecuencia, los nombres son intercambiables. Aquí me tomé la libertad de usar el elemento de diálogo para ventanas emergentes de diálogo pequeñas (mini) y diálogos de página completa (mega). Los denominé mega y mini, y ambos diálogos se adaptaron ligeramente a 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 en los temas claro y oscuro.

No siempre, pero, por lo general, se usan 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. Se recomienda que un elemento de formulario incluya 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>

Megadiálogo

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

Minidiálogo

El minidiálogo es muy similar al megadiálogo, solo le falta un elemento <header>. Esto permite que sea más pequeño y esté 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 aspectos esenciales pueden generar interacciones muy interesantes y eficaces en tu sitio o app.

Accesibilidad

El elemento de diálogo tiene una muy buena accesibilidad integrada. En lugar de agregar estas funciones como lo hago normalmente, muchas ya están ahí.

Restableciendo el enfoque

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

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

Por desgracia, si deseas animar el diálogo para que aparezca y salga, esta funcionalidad se perderá. En la sección de JavaScript, restableceré esa funcionalidad.

Enfoque de captura

El elemento de diálogo administra inert en el documento por ti. Antes de inert, se usaba JavaScript para detectar el foco que dejaba un elemento. En ese momento, lo intercepta y lo volvía a colocar.

Navegadores compatibles

  • 102
  • 102
  • 112
  • 15.5

Origen

Después de inert, cualquier parte del documento puede "congelarse" en caso de que ya no sea un objetivo de enfoque o sea interactiva con un mouse. En lugar de capturar el enfoque, este se guía hacia la única parte interactiva del documento.

Cómo abrir y enfocar automáticamente un elemento

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

Cómo cerrar con la tecla Escape

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

Estilos

Hay una ruta sencilla para aplicar estilo al elemento del diálogo y una ruta fija. La ruta de acceso fácil se logra sin cambiar la propiedad de visualización del diálogo y trabajar con sus limitaciones. Retomo la ruta difícil para proporcionar animaciones personalizadas para abrir y cerrar el diálogo, apoderándose de la propiedad display y más.

Cómo dar estilo con objetos abiertos

Para acelerar los colores adaptables y la coherencia general del diseño, incorporamos de manera descarada mi biblioteca de variables de CSS Open Props. Además de las variables gratuitas que se proporcionan, 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, a la vez que no se necesitan 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 de visualización

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

dialog {
  display: grid;
}

Como se muestra en el fragmento de CSS anterior, cuando se cambia el valor de la propiedad de visualización y, por lo tanto, se te hace propietario, se deben administrar una cantidad considerable de diseños 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 tenga 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 y garantizar que los usuarios del teclado y lector de pantalla tampoco puedan acceder al diálogo oculto.

Dar al diálogo un tema de color adaptable

Megadiálogo que muestra los temas claro y oscuro, lo que demuestra los colores de la superficie.

Si bien color-scheme habilita tu documento en un tema de color adaptable que proporciona el navegador según las preferencias claras y oscuras del sistema, quería personalizar más el elemento de diálogo. Open Props proporciona algunos colores de superficie que se adaptan automáticamente a las preferencias de luz y oscuridad del sistema, de manera similar al uso de color-scheme. Son excelentes para crear capas en un diseño, y me encanta usar color para ayudar a respaldar visualmente esta apariencia de las superficies de las capas. El color de fondo es var(--surface-1). Para colocarte 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 colores más adaptables para los elementos secundarios, como el encabezado y el pie de página. Los considero adicionales para un elemento de diálogo, pero son muy importantes a la hora de crear 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 cual suele ser genial. Mi objetivo aquí es limitar el max-inline-size a un tamaño legible (--size-content-3 = 60ch) o al 90% del ancho del viewport. De esta manera, se garantiza que el diálogo no se extienda de borde a borde en los dispositivos móviles y que no sea tan ancho en la pantalla de una 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 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;
}

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

Posicionamiento de los grandes diálogos

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 sombreado 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 libremente sobre este fondo y tomar la forma que requiera su contenido.

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

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

En viewports pequeñas, el estilo de esta megamodal completa de página completa de un modo un poco diferente. Configuré el margen inferior en 0, 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 acción, más cerca del dedo pulgar 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 del margen 
  en el cuadro de diálogo combinado para computadoras y dispositivos móviles mientras está abierto.

Posicionamiento de minidiálogos

Cuando se usaba una 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.

Haz que se destaque

Por último, agrega estilo al diálogo para que se vea como una superficie suave sobre la página. La suavidad se logra redondeando las esquinas del diálogo. La profundidad se logra con uno de los props de sombra cuidadosamente elaborados de Open Props:

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

Cómo personalizar el seudoelemento del fondo

Elegí trabajar muy bien con el fondo y agregar un efecto de desenfoque con backdrop-filter al megadiálogo:

Navegadores compatibles

  • 76
  • 79
  • 103
  • 9

Origen

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

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

Elementos de diseño adicionales

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

Contención del desplazamiento

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

Por lo general, overscroll-behavior sería la 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 desplazador, por lo que no hay nada que se pueda evitar. Puedo 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 esperar a 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 diálogo combinado, 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 para diseñar los elementos de encabezado, pie de página y artículo. Con este diseño, quiero articular el elemento secundario del artículo como un área desplazable. Lo logro con grid-template-rows. El elemento de artículo recibe 1fr y el formulario en sí tiene la misma altura máxima que el elemento de diálogo. Establecer esta altura y un tamaño de fila fijos 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 superpone la información de diseño de cuadrícula en las filas.

Cómo aplicar 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 de cierre fácil de encontrar. También se le asigna un color de superficie para que parezca estar detrás del contenido del artículo de diálogo. Estos requisitos conducen a un contenedor de flexbox, elementos alineados verticalmente que están espaciados a sus bordes y algunos rellenos 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 Herramientas para desarrolladores de Chrome que se superpone con la información de diseño de flexbox en el encabezado del diálogo.

Cómo aplicar estilo al botón para cerrar el encabezado

Como en la demostración se usan los botones Open Props, el botón de cerrar 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 superpone información sobre el tamaño y el relleno del botón para cerrar el encabezado.

Cómo aplicar diseño 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 caso de que aparezca un diálogo alto o largo.

Para lograrlo, el elemento de formulario superior estableció algunos máximos por sí mismo que proporcionan restricciones para que pueda alcanzar este elemento de artículo si se vuelve demasiado alto. Configura overflow-y: auto para que las barras de desplazamiento solo se muestren cuando sea necesario, contengan el desplazamiento 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 es 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, un poco de espacio para dejar 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 flexible 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 porque 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 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. Darles a los diálogos cierto movimiento de apoyo para esta entrada y salida ayuda a los usuarios a orientarse en el flujo.

Normalmente, 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. Anteriormente, la guía configuraba la pantalla en cuadrícula y nunca la configuraba en ninguno. Esto desbloquea la capacidad de animar dentro y fuera.

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

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

Una transición predeterminada segura y significativa

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

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

Cómo agregar movimiento a la transición

Si el usuario está de acuerdo con el movimiento, tanto el diálogo mega como el mini deben deslizarse hacia arriba como su entrada y escalar horizontalmente como su salida. Puedes lograr esto con la consulta de medios prefers-reduced-motion y algunas Props abiertas:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Adaptar la animación de salida para dispositivos móviles

Anteriormente en la sección de estilo, el estilo de diálogo combinado se adapta a los dispositivos móviles para que parezca más una hoja de acciones, como si un pequeño trozo de papel se deslizara hacia arriba desde la parte inferior de la pantalla y aún estuviera conectado a la parte inferior. La animación de salida de escalamiento horizontal no se ajusta bien a este nuevo diseño, y podemos adaptarla con un par de consultas de medios y algunas Props abiertas:

@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

Existen varias opciones para 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 adiciones surgen del deseo de descartar la luz (hacer clic en el fondo del diálogo), animaciones y algunos eventos adicionales para obtener mejores tiempos en la obtención de datos del formulario.

Agregando el descarte claro

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 burbuja de eventos para evaluar qué se hizo clic. 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. Otro 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.

Agrega 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 close() del diálogo. Como animaremos este elemento, es bueno tener eventos para antes y después de la animación, de modo 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, escucha 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 el componente de compilación de un aviso, muestra una promesa basada en la finalización de las promesas de animación y transición. Esta es la razón por la que dialogClose es una función asíncrona; luego, puede await la promesa que se mostró y avanzar con confianza al evento cerrado.

Cómo agregar eventos abiertos y de apertura

Estos eventos no son tan fáciles de agregar, 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 los cambios en los atributos del diálogo. En este observador, buscaré los cambios en el atributo abierto y administraré los eventos personalizados según corresponda.

De manera similar a como comenzamos los eventos de cierre y cierre, crea dos eventos nuevos llamados opening y opened. Donde antes escuchamos el evento de cierre del diálogo, esta vez usa un observador de mutación creado para ver 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 proporcionará la lista de cambios como un array. Itera los cambios de atributos y busca que attributeName esté abierto. Luego, 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 enfoca un elemento que solicite autofocus o el primer elemento button que se encuentre en el diálogo. Por último, al igual que con 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 quitado

En las aplicaciones de una sola página, los diálogos a menudo se agregan y quitan en función de las rutas y otras necesidades y estados 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 los atributos en un elemento de diálogo, observaremos los elementos secundarios del elemento del cuerpo 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 corresponden a 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 despacha el evento personalizado quitado.

Quita el atributo de carga

Para evitar que la animación de salida reproduzca su animación de salida cuando se la agrega a la página o durante la carga, se agregó un atributo de carga al diálogo. La siguiente secuencia de comandos espera a que terminen de ejecutarse las animaciones del diálogo y, luego, quita el atributo. Ahora, la animación de entrada y salida del diálogo es libre, y ocultamos de manera efectiva una animación que de otra forma 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

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

Cómo usar el módulo dialog.js

Se espera que se llame a la función exportada del módulo y se la pase a un elemento de diálogo al que se le deben agregar 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 simple, los dos diálogos se actualizan con descarte de luz, correcciones de carga de animaciones 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 este:

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 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 momento adecuado es que se haya completado la animación de salida del diálogo y, luego, se animen algunas secuencias de comandos en el avatar nuevo. Gracias a los nuevos eventos, la organización de 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() del diálogo. En el evento dialogClosed, es fundamental 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 se vuelva a mostrar el diálogo, quede 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 que aparece más abajo.

Remixes de la comunidad

Recursos