fbpx
Capital Software

Diálogos de JavaScript con el nuevo elemento de diálogo HTML

¿Sabe cómo hay cuadros de diálogo de JavaScript para alertar, confirmar y solicitar acciones del usuario? Digamos que desea reemplazar los diálogos de JavaScript con el nuevo elemento de diálogo HTML.

Recientemente trabajé en un proyecto con muchas llamadas a la API y comentarios de los usuarios recopilados con cuadros de diálogo de JavaScript. Mientras esperaba que otro desarrollador codificara el componente , <Modal /> usé y en mi código. Por ejemplo: alert()confirm()prompt()

const deleteLocation = confirm('Delete location');
if (deleteLocation) {
  alert('Location deleted');
}

Entonces me di cuenta: obtienes muchas funciones relacionadas con modales de forma gratuita con alert()confirm()prompt()que a menudo se pasan por alto:

  • Es un verdadero modal. Como en, siempre estará en la parte superior de la pila, incluso en la parte superior <div> con z-index: 99999;.
  • Es accesible con el teclado. Pulse Enter para aceptar y Escape para cancelar.
  • Es compatible con lectores de pantalla. Mueve el foco y permite que el contenido modal se lea en voz alta.
  • Atrapa el enfoque. Presionar Tab no alcanzará ningún elemento enfocable en la página principal, pero en Firefox y Safari, de hecho, mueve el foco a la interfaz de usuario del navegador. Sin embargo, lo extraño es que no puede mover el foco a los botones «aceptar» o «cancelar» en ningún navegador usando la Tab tecla.
  • Es compatible con las preferencias del usuario. Obtenemos soporte automático de modo claro y oscuro desde el primer momento.
  • Hace una pausa en la ejecución del código. Además, espera la entrada del usuario.

Estos tres métodos de JavaScript funcionan el 99% del tiempo cuando necesito alguna de estas funcionalidades. Entonces, ¿por qué yo, o cualquier otro desarrollador web, no los uso? Probablemente porque parecen errores del sistema a los que no se les puede aplicar estilo. Otra gran consideración: ha habido un movimiento hacia su desaprobación. Primera eliminación de iframes de dominios cruzados y, se dice, de la plataforma web por completo, aunque también parece que los planes para eso están en espera.

Con esa gran consideración en mente…

¿Cuáles son alert()confirm()las prompt()alternativas que tenemos para reemplazarlos? Es posible que ya haya escuchado sobre el <dialog>elemento HTML y eso es lo que quiero ver en este artículo, usándolo junto con JavaScript class.

Es imposible reemplazar por completo los diálogos de Javascript con una funcionalidad idéntica, pero si usamos el showModal()método <dialog> combinado con uno Promise que puede resolve(aceptar) o reject(cancelar), entonces tenemos algo casi igual de bueno. Diablos, mientras estamos en eso, agreguemos sonido al elemento de diálogo HTML, ¡al igual que los diálogos del sistema real!

Si desea ver la demostración de inmediato, está aquí .

Una clase de diálogo

Primero, necesitamos un JavaScript básico Class con un settings objeto que se fusionará con la configuración predeterminada. Estos ajustes se utilizarán para todos los diálogos, a menos que los sobrescriba cuando los invoque (pero hablaremos de eso más adelante).

export default class Dialog {
constructor(settings = {}) {
  this.settings = Object.assign(
    {
      /* DEFAULT SETTINGS - see description below */
    },
    settings
  )
  this.init()
}

Los ajustes son:

  • accept: Esta es la etiqueta del botón “Aceptar”.
  • bodyClass: Esta es una clase CSS que se agrega al <body>elemento cuando el diálogo no es compatible con el navegador open.<dialog>
  • cancel: Esta es la etiqueta del botón «Cancelar».
  • dialogClass: Esta es una clase CSS personalizada agregada al <dialog>elemento.
  • message: Este es el contenido dentro del <dialog>.
  • soundAccept: Esta es la URL del archivo de sonido que reproduciremos cuando el usuario presione el botón «Aceptar».
  • soundOpen: Esta es la URL del archivo de sonido que reproduciremos cuando el usuario abra el cuadro de diálogo.
  • template: Esta es una pequeña plantilla HTML opcional que se inyecta en el archivo <dialog>.

La plantilla inicial para reemplazar los diálogos de JavaScript

En el initmétodo, agregaremos una función auxiliar para detectar la compatibilidad con el elemento de diálogo HTML en los navegadores y configuraremos el HTML básico:

init() {
  // Testing for <dialog> support
  this.dialogSupported = typeof HTMLDialogElement === 'function'
  this.dialog = document.createElement('dialog')
  this.dialog.dataset.component = this.dialogSupported ? 'dialog' : 'no-dialog'
  this.dialog.role = 'dialog'
  
  // HTML template
  this.dialog.innerHTML = `
  <form method="dialog" data-ref="form">
    <fieldset data-ref="fieldset" role="document">
      <legend data-ref="message" id="${(Math.round(Date.now())).toString(36)}">
      </legend>
      <div data-ref="template"></div>
    </fieldset>
    <menu>
      <button data-ref="cancel" value="cancel"></button>
      <button data-ref="accept" value="default"></button>
    </menu>
    <audio data-ref="soundAccept"></audio>
    <audio data-ref="soundOpen"></audio>
  </form>`

  document.body.appendChild(this.dialog)

  // ...
}

Comprobación de apoyo

El camino para que los navegadores sean compatibles <dialog> ha sido largo. Safari lo recogió recientemente . Firefox incluso más recientemente , aunque no la <form method="dialog"> parte. Entonces, necesitamos agregar type="button"a los botones «Aceptar» y «Cancelar» que estamos imitando. De lo contrario, POST formarán el formulario y causarán una actualización de la página y queremos evitar eso.

<button${this.dialogSupported ? '' : ` type="button"`}...></button>

Referencias de nodos DOM

¿Notaste todos los data-ref-atributos? Los usaremos para obtener referencias a los nodos DOM:

this.elements = {}
this.dialog.querySelectorAll('[data-ref]').forEach(el => this.elements[el.dataset.ref] = el)

Hasta ahora, this.elements.acceptes una referencia al botón «Aceptar» y this.elements.cancelse refiere al botón «Cancelar».

Atributos del botón

Para los lectores de pantalla, necesitamos un aria-labelledby atributo que apunte al ID de la etiqueta que describe el cuadro de diálogo; esa es la <legend> etiqueta y contendrá el archivo message.

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

eso id? Es una referencia única a esta parte del <legend>elemento:

this.dialog.setAttribute('aria-labelledby', this.elements.message.id)

El botón «Cancelar»

¡Buenas noticias! El elemento de diálogo HTML tiene un cancel()método integrado que facilita la sustitución de los diálogos de JavaScript que llaman al confirm()método. Emitimos ese evento cuando hacemos clic en el botón «Cancelar»:

this.elements.cancel.addEventListener('click', () => { 
  this.dialog.dispatchEvent(new Event('cancel')) 
})

Ese es el marco para <dialog>que reemplacemos alert()confirm()prompt().

Navegadores no compatibles con polyfilling

Necesitamos ocultar el elemento de diálogo HTML para los navegadores que no lo admiten. Para hacer eso, ajustaremos la lógica para mostrar y ocultar el diálogo en un nuevo método toggle():

toggle(open = false) {
  if (this.dialogSupported && open) this.dialog.showModal()
  if (!this.dialogSupported) {
    document.body.classList.toggle(this.settings.bodyClass, open)
    this.dialog.hidden = !open
    /* If a `target` exists, set focus on it when closing */
    if (this.elements.target && !open) {
      this.elements.target.focus()
    }
  }
}
/* Then call it at the end of `init`: */
this.toggle()

Navegación por teclado

A continuación, implementemos una forma de atrapar el foco para que el usuario pueda tabular entre los botones del cuadro de diálogo sin salir del cuadro de diálogo sin darse cuenta. Hay muchas maneras de hacer esto. Me gusta la forma CSS , pero desafortunadamente, no es confiable. En su lugar, tomemos todos los elementos enfocables del cuadro de diálogo NodeListy almacenémoslos en this.focusable:

getFocusable() {
  return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type=&quot;hidden&quot;]),[tabindex]:not([tabindex=&quot;-1&quot;])')]
}

A continuación, agregaremos un keydowndetector de eventos, que manejará toda la lógica de navegación de nuestro teclado:

this.dialog.addEventListener('keydown', e => {
  if (e.key === 'Enter') {
    if (!this.dialogSupported) e.preventDefault()
    this.elements.accept.dispatchEvent(new Event('click'))
  }
  if (e.key === 'Escape') this.dialog.dispatchEvent(new Event('cancel'))
  if (e.key === 'Tab') {
    e.preventDefault()
    const len =  this.focusable.length - 1;
    let index = this.focusable.indexOf(e.target);
    index = e.shiftKey ? index-1 : index+1;
    if (index < 0) index = len;
    if (index > len) index = 0;
    this.focusable[index].focus();
  }
})

Para Enter, debemos evitar que se <form>envíe en navegadores donde el <dialog>elemento no es compatible. Escapeemitirá un cancelevento. Al presionar la Tabtecla, encontrará el elemento actual en la lista de nodos de elementos enfocables this.focusable, y establecerá el enfoque en el siguiente elemento (o en el anterior si mantiene presionada la Shifttecla al mismo tiempo).

Mostrando el <dialog>

¡Ahora vamos a mostrar el diálogo! Para esto, necesitamos un pequeño método que fusione un settingsobjeto opcional con los valores predeterminados. En este objeto, exactamente como el settingsobjeto predeterminado, podemos agregar o cambiar la configuración de un cuadro de diálogo específico.

open(settings = {}) {
  const dialog = Object.assign({}, this.settings, settings)
  this.dialog.className = dialog.dialogClass || ''

  /* set innerText of the elements */
  this.elements.accept.innerText = dialog.accept
  this.elements.cancel.innerText = dialog.cancel
  this.elements.cancel.hidden = dialog.cancel === ''
  this.elements.message.innerText = dialog.message

  /* If sounds exists, update `src` */
  this.elements.soundAccept.src = dialog.soundAccept || ''
  this.elements.soundOpen.src = dialog.soundOpen || ''

  /* A target can be added (from the element invoking the dialog */
  this.elements.target = dialog.target || ''

  /* Optional HTML for custom dialogs */
  this.elements.template.innerHTML = dialog.template || ''

  /* Grab focusable elements */
  this.focusable = this.getFocusable()
  this.hasFormData = this.elements.fieldset.elements.length > 0
  if (dialog.soundOpen) {
    this.elements.soundOpen.play()
  }
  this.toggle(true)
  if (this.hasFormData) {
    /* If form elements exist, focus on that first */
    this.focusable[0].focus()
    this.focusable[0].select()
  }
  else {
    this.elements.accept.focus()
  }
}

¡Uf! Eso fue mucho código . Ahora podemos mostrar el <dialog>elemento en todos los navegadores. Pero aún necesitamos imitar la funcionalidad que espera la entrada de un usuario después de la ejecución, como los métodos nativos alert()confirm()y . prompt()Para eso, necesitamos Promiseun nuevo método al que llamo waitForUser():

waitForUser() {
  return new Promise(resolve => {
    this.dialog.addEventListener('cancel', () => { 
      this.toggle()
      resolve(false)
    }, { once: true })
    this.elements.accept.addEventListener('click', () => {
      let value = this.hasFormData ? 
        this.collectFormData(new FormData(this.elements.form)) : true;
      if (this.elements.soundAccept.src) this.elements.soundAccept.play()
      this.toggle()
      resolve(value)
    }, { once: true })
  })
}

Este método devuelve un Promise. Dentro de eso, agregamos detectores de eventos para «cancelar» y «aceptar» que resuelven false(cancelan) o true(aceptan). Si formDataexiste (para cuadros de diálogo personalizados o prompt), estos se recopilarán con un método auxiliar y luego se devolverán en un objeto:

collectFormData(formData) {
  const object = {};
  formData.forEach((value, key) => {
    if (!Reflect.has(object, key)) {
      object[key] = value
      return
    }
    if (!Array.isArray(object[key])) {
      object[key] = [object[key]]
    }
    object[key].push(value)
  })
  return object
}

Podemos eliminar los detectores de eventos inmediatamente, usando { once: true }.

Para mantenerlo simple, no uso reject()sino que simplemente resuelvo false.

Ocultar el <dialog>

Anteriormente, agregamos detectores de eventos para el evento integrado cancel. Llamamos a este evento cuando el usuario hace clic en el botón «cancelar» o presiona la Escapetecla. El cancelevento elimina el openatributo en el <dialog>, por lo que lo oculta.

¿Adónde:focus?

En nuestro open()método, nos enfocamos en el primer campo de formulario enfocable o en el botón «Aceptar»:

if (this.hasFormData) {
  this.focusable[0].focus()
  this.focusable[0].select()
}
else {
  this.elements.accept.focus()
}

¿Pero es esto correcto? En el ejemplo del «Diálogo modal» de W3 , este es el caso. Sin embargo, en el ejemplo de Scott Ohara , la atención se centra en el cuadro de diálogo en sí, lo que tiene sentido si el lector de pantalla debe leer el texto que definimos aria-labelledbyanteriormente en el atributo. No estoy seguro de cuál es el correcto o el mejor, pero si queremos usar el método de Scott. necesitamos agregar tabindex="-1"<dialog>en nuestro initmétodo:

this.dialog.tabIndex = -1

Luego, en el open()método, reemplazaremos el código de enfoque con esto:

this.dialog.focus()

Podemos verificar activeElement(el elemento que tiene el foco) en cualquier momento en DevTools haciendo clic en el icono del «ojo» y escribiendo document.activeElementen la consola. Intenta tabular para ver cómo se actualiza:

Mostrando el ícono del ojo en DevTools, resaltado en verde brillante.
Al hacer clic en el icono del «ojo»

Adición de alerta, confirmación y aviso

Finalmente estamos listos para agregar alert()confirm()prompt()a nuestra Dialogclase. Estos serán pequeños métodos de ayuda que reemplazan los diálogos de JavaScript y la sintaxis original de esos métodos. Todos ellos llaman al open()método que creamos anteriormente, pero con un settingsobjeto que coincide con la forma en que activamos los métodos originales.

Comparemos con la sintaxis original.

alert()normalmente se activa así:

window.alert(message);

En nuestro Diálogo, agregaremos un alert()método que imitará esto:

/* dialog.alert() */
alert(message, config = { target: event.target }) {
  const settings = Object.assign({}, config, { cancel: '', message, template: '' })
  this.open(settings)
  return this.waitForUser()
}

Establecemos canceltemplatepara vaciar cadenas, de modo que, incluso si hubiéramos establecido valores predeterminados anteriormente, estos no se ocultarán, y solo messagese acceptmostrarán.

confirm()normalmente se activa así:

window.confirm(message);

En nuestra versión, similar a alert(), creamos un método personalizado que muestra los elementos y message:cancelaccept

/* dialog.confirm() */
confirm(message, config = { target: event.target }) {
  const settings = Object.assign({}, config, { message, template: '' })
  this.open(settings)
  return this.waitForUser()
}

prompt()normalmente se activa así:

window.prompt(message, default);

Aquí, necesitamos agregar un templatecon un <input>que envolveremos en un <label>:

/* dialog.prompt() */
prompt(message, value, config = { target: event.target }) {
  const template = `
  <label aria-label="${message}">
    <input name="prompt" value="${value}">
  </label>`
  const settings = Object.assign({}, config, { message, template })
  this.open(settings)
  return this.waitForUser()
}

{ target: event.target }es una referencia al elemento DOM que llama al método. Lo usaremos para volver a centrarnos en ese elemento cuando cerremos el <dialog>, devolviendo al usuario a donde estaba antes de que se activara el cuadro de diálogo.

Deberíamos probar esto

Es hora de probar y asegurarse de que todo funciona como se esperaba. Vamos a crear un nuevo archivo HTML, importar la clase y crear una instancia:

<script type="module">
  import Dialog from './dialog.js';
  const dialog = new Dialog();
</script>

¡Pruebe los siguientes casos de uso uno a la vez!

/* alert */
dialog.alert('Please refresh your browser')
/* or */
dialog.alert('Please refresh your browser').then((res) => {  console.log(res) })

/* confirm */
dialog.confirm('Do you want to continue?').then((res) => { console.log(res) })

/* prompt */
dialog.prompt('The meaning of life?', 42).then((res) => { console.log(res) })

Luego observe la consola mientras hace clic en «Aceptar» o «Cancelar». Vuelva a intentarlo mientras presiona las teclas EscapeEnteren su lugar.

Asíncrono/Espera

También podemos usar la async/awaitforma de hacer esto. Estamos reemplazando los diálogos de JavaScript aún más al imitar la sintaxis original, pero requiere que la función de ajuste sea async, mientras que el código interno requiere la awaitpalabra clave:

document.getElementById('promptButton').addEventListener('click', async (e) => {
  const value = await dialog.prompt('The meaning of life?', 42);
  console.log(value);
});

Estilo de navegador cruzado

¡Ahora tenemos un elemento de diálogo HTML compatible con todos los navegadores y lectores de pantalla que reemplaza los diálogos de JavaScript! Hemos cubierto mucho. Pero el estilo podría usar mucho amor. Utilicemos los atributos data-componenty existentes data-refpara agregar un estilo entre navegadores, ¡sin necesidad de clases adicionales u otros atributos!

Usaremos el :wherepseudo-selector de CSS para mantener nuestros estilos predeterminados libres de especificidad :

:where([data-component*="dialog"] *) {  
  box-sizing: border-box;
  outline-color: var(--dlg-outline-c, hsl(218, 79.19%, 35%))
}
:where([data-component*="dialog"]) {
  --dlg-gap: 1em;
  background: var(--dlg-bg, #fff);
  border: var(--dlg-b, 0);
  border-radius: var(--dlg-bdrs, 0.25em);
  box-shadow: var(--dlg-bxsh, 0px 25px 50px -12px rgba(0, 0, 0, 0.25));
  font-family:var(--dlg-ff, ui-sansserif, system-ui, sans-serif);
  min-inline-size: var(--dlg-mis, auto);
  padding: var(--dlg-p, var(--dlg-gap));
  width: var(--dlg-w, fit-content);
}
:where([data-component="no-dialog"]:not([hidden])) {
  display: block;
  inset-block-start: var(--dlg-gap);
  inset-inline-start: 50%;
  position: fixed;
  transform: translateX(-50%);
}
:where([data-component*="dialog"] menu) {
  display: flex;
  gap: calc(var(--dlg-gap) / 2);
  justify-content: var(--dlg-menu-jc, flex-end);
  margin: 0;
  padding: 0;
}
:where([data-component*="dialog"] menu button) {
  background-color: var(--dlg-button-bgc);
  border: 0;
  border-radius: var(--dlg-bdrs, 0.25em);
  color: var(--dlg-button-c);
  font-size: var(--dlg-button-fz, 0.8em);
  padding: var(--dlg-button-p, 0.65em 1.5em);
}
:where([data-component*="dialog"] [data-ref="accept"]) {
  --dlg-button-bgc: var(--dlg-accept-bgc, hsl(218, 79.19%, 46.08%));
  --dlg-button-c: var(--dlg-accept-c, #fff);
}
:where([data-component*="dialog"] [data-ref="cancel"]) {
  --dlg-button-bgc: var(--dlg-cancel-bgc, transparent);
  --dlg-button-c: var(--dlg-cancel-c, inherit);
}
:where([data-component*="dialog"] [data-ref="fieldset"]) {
  border: 0;
  margin: unset;
  padding: unset;
}
:where([data-component*="dialog"] [data-ref="message"]) {
  font-size: var(--dlg-message-fz, 1.25em);
  margin-block-end: var(--dlg-gap);
}
:where([data-component*="dialog"] [data-ref="template"]:not(:empty)) {
  margin-block-end: var(--dlg-gap);
  width: 100%;
}

Puedes diseñarlos como quieras, por supuesto. Esto es lo que le dará el CSS anterior:

Mostrando cómo reemplazar los diálogos de JavaScript que usan el método de alerta.  El modal es blanco sobre un fondo gris.  El contenido dice, actualice su navegador, seguido de un botón azul con una etiqueta blanca que dice Aceptar.
alert()
Mostrando cómo reemplazar los diálogos de JavaScript que usan el método de confirmación.  El modal es blanco sobre un fondo gris.  El contenido dice por favor, ¿quieres continuar?  y le sigue un enlace negro que dice cancelar y un botón azul con una etiqueta blanca que dice Aceptar.
confirm()
Mostrando cómo reemplazar los diálogos de JavaScript que usan el método de solicitud.  El modal es blanco sobre un fondo gris.  El contenido lee el sentido de la vida, seguido de una entrada de texto con el número 42, seguido de un enlace negro que dice cancelar y un botón azul con una etiqueta blanca que dice Aceptar.
prompt()

Para sobrescribir estos estilos y usar los suyos propios, agregue una clase en dialogClass,

dialogClass: 'custom'

… luego agregue la clase en CSS y actualice los valores de propiedad personalizados de CSS:

.custom {
  --dlg-accept-bgc: hsl(159, 65%, 75%);
  --dlg-accept-c: #000;
  /* etc. */
}

Un ejemplo de diálogo personalizado

¿Qué pasa si el estándar alert()confirm()los prompt()métodos que estamos imitando no funcionan para su caso de uso específico? De hecho, podemos hacer un poco más para que sea <dialog>más flexible para cubrir más que el contenido, los botones y la funcionalidad que hemos cubierto hasta ahora, y no es mucho más trabajo.

Anteriormente, me burlé de la idea de agregar un sonido al diálogo. Vamos a hacer eso.

Puede usar la templatepropiedad del settingsobjeto para inyectar más HTML. Aquí hay un ejemplo personalizado, invocado desde un <button>con id="btnCustom"que activa un pequeño sonido divertido de un archivo MP3:

document.getElementById('btnCustom').addEventListener('click', (e) => {
  dialog.open({
    accept: 'Sign in',
    dialogClass: 'custom',
    message: 'Please enter your credentials',
    soundAccept: 'https://assets.yourdomain.com/accept.mp3',
    soundOpen: 'https://assets.yourdomain.com/open.mp3',
    target: e.target,
    template: `
    <label>Username<input type="text" name="username" value="admin"></label>
    <label>Password<input type="password" name="password" value="password"></label>`
  })
  dialog.waitForUser().then((res) => {  console.log(res) })
});

Demo en vivo

¡Aquí hay una pluma con todo lo que construimos! Abra la consola, haga clic en los botones y juegue con los cuadros de diálogo, haga clic en los botones y use el teclado para aceptar y cancelar.https://codepen.io/anon/embed/bGovmLa?height=450&theme-id=1&slug-hash=bGovmLa&default-tab=result

¿Entonces, qué piensas? ¿Es esta una buena manera de reemplazar los diálogos de JavaScript con el elemento de diálogo HTML más nuevo? ¿O has probado a hacerlo de otra forma? ¡Házmelo saber en los comentarios!

Abrir chat
1
Escanea el código
Hola, bienvenido a Capital Software, somos una empresa de soluciones informáticas. ¿En qué podemos ayudarte hoy?