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()
y 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>
conz-index: 99999;
. - Es accesible con el teclado. Pulse
Enter
para aceptar yEscape
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 laTab
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()
y 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 navegadoropen
.<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 init
mé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.accept
es una referencia al botón «Aceptar» y this.elements.cancel
se 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()
y 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 NodeList
y almacenémoslos en this.focusable
:
getFocusable() {
return [...this.dialog.querySelectorAll('button,[href],select,textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])')]
}
A continuación, agregaremos un keydown
detector 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. Escape
emitirá un cancel
evento. 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 settings
objeto opcional con los valores predeterminados. En este objeto, exactamente como el settings
objeto 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 Promise
un 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 formData
existe (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 cancel
evento elimina el open
atributo 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-labelledby
anteriormente 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"
a <dialog>
en nuestro init
mé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.activeElement
en la consola. Intenta tabular para ver cómo se actualiza:
Adición de alerta, confirmación y aviso
Finalmente estamos listos para agregar alert()
, confirm()
y prompt()
a nuestra Dialog
clase. 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 settings
objeto 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 cancel
y template
para vaciar cadenas, de modo que, incluso si hubiéramos establecido valores predeterminados anteriormente, estos no se ocultarán, y solo message
se accept
mostrará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
:cancel
accept
/* 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 template
con 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 Escapeo Enteren su lugar.
Asíncrono/Espera
También podemos usar la async/await
forma 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 await
palabra 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-component
y existentes data-ref
para agregar un estilo entre navegadores, ¡sin necesidad de clases adicionales u otros atributos!
Usaremos el :where
pseudo-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:
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()
y 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 template
propiedad del settings
objeto 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!