Diálogo HTML para Módulos
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeBienvenido al día 19. Hoy tenemos la suerte de jugar con un elemento HTML poco conocido que es absolutamente genial cuando se trata de construir modales. El elemento <dialog>
. Si tienes prisa por la magnificencia de los modales, puedes saltar más adelante para engancharte al marcado final y al controlador Stimulus. Pero te prometo que el viaje de hoy va a ser divertido.
Abre templates/voyage/index.html.twig
. En h1
, voy a pegar algo de contenido nuevo:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
<h1 class="text-xl font-semibold text-white mb-4">Voyages</h1> | |
<button | |
class="flex items-center space-x-1 bg-blue-500 hover:bg-blue-700 text-white text-sm font-bold px-4 rounded" | |
> | |
<span>New Voyage</span> | |
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 inline" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M9 12h6" /><path d="M12 9v6" /></svg> | |
</button> | |
</div> | |
// ... lines 18 - 45 | |
</div> | |
{% endblock %} |
Esto añade un botón "Nuevo viaje".
En la parte inferior, eliminaré el botón antiguo. Este nuevo código no tiene nada de especial: es sólo... un botón. Y cuando vayamos a la página correcta... ¡ahí está! Pero todavía no hace nada.
Hola <dialog>
De vuelta en la plantilla, justo después del botón, añade un elemento <dialog>
. Dentro proclamaré "Soy un diálogo". Añade también un atributo open
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
// ... lines 10 - 17 | |
<dialog open> | |
I am a dialog! | |
</dialog> | |
</div> | |
// ... lines 22 - 49 | |
</div> | |
{% endblock %} |
Pulsa actualizar y contempla el elemento dialog
. Es... interesante. El dialog
está absolutamente posicionado en la página, centrado horizontalmente y cerca, pero no arriba verticalmente. Eso es porque el elemento <dialog>
está diseñado para modales... o realmente para cualquier diálogo, como una alerta desechable o cualquier subventana. Es un elemento HTML normal, pero con un montón de superpoderes que vamos a experimentar.
Hacer un diálogo bonito
Pero primero, tenemos que hacerlo más bonito. De vuelta a la plantilla, pegaré encima ese diálogo:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
// ... lines 10 - 18 | |
<dialog | |
open | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%]" | |
> | |
<div class="flex grow p-5"> | |
<div class="grow overflow-auto p-1"> | |
<div class="text-white space-y-4"> | |
<div class="flex justify-between items-center"> | |
<h2 class="text-xl font-bold">Create new Voyage</h2> | |
<button class="text-lg absolute top-5 right-5"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="w-4" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12"/><path d="M6 6l12 12"/></svg> | |
</button> | |
</div> | |
<p class="text-gray-400"> | |
Join us on an exciting journey through the cosmos! Discover the | |
mysteries of the universe and explore distant galaxies. | |
</p> | |
<div class="flex justify-end"> | |
<button | |
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> | |
Let's Go! | |
</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</dialog> | |
</div> | |
// ... lines 47 - 74 | |
</div> | |
{% endblock %} |
Esto es una adaptación de Flowbite con algo de ayuda de la IA. Y un diseñador podría crear esto sin problemas. Porque, no hay nada especial: seguimos teniendo un dialog
, sigue siendo open
... e incluso las clases Tailwind son bastante aburridas. Establezco una anchura... y redondeo las esquinas. Pero la mayoría de los detalles de posicionamiento ya están incorporados en el elemento. Y la mayor parte del código es contenido modal ficticio para empezar.
El resultado... es impresionante. Aunque... ¡el botón de cerrar aún no hace su trabajo! No te preocupes: ¡ésta es una gran oportunidad para mostrar uno de los superpoderes de diálogo!
Busca el botón de cerrar. A su alrededor, añade un <form method="dialog">
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
// ... lines 10 - 18 | |
<dialog | |
open | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%]" | |
> | |
<div class="flex grow p-5"> | |
<div class="grow overflow-auto p-1"> | |
<div class="text-white space-y-4"> | |
<div class="flex justify-between items-center"> | |
// ... line 27 | |
<form method="dialog"> | |
<button class="text-lg absolute top-5 right-5"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="w-4" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12"/><path d="M6 6l12 12"/></svg> | |
</button> | |
</form> | |
</div> | |
// ... lines 34 - 43 | |
</div> | |
</div> | |
</div> | |
</dialog> | |
</div> | |
// ... lines 49 - 76 | |
</div> | |
{% endblock %} |
Este es un botón normal: naturalmente enviará el formulario cuando lo pulsemos, pero el botón no tiene nada especial.
Pero ahora, cuando hagamos clic en X... ¡se cerrará!
Abrir con un Controlador de Stimulus modal
Para hacer brillar realmente el elemento <dialog>
, necesitamos un poco de JavaScript. Dirígete a assets/controllers/
y crea un nuevo archivo llamado modal_controller.js
. Haré trampas, robaré algo de contenido de otro controlador... y lo limpiaré. Este controlador será sencillo. Empieza añadiendo un static targets = ['dialog']
para que podamos encontrar rápidamente el elemento <dialog>
. A continuación, añade un método open
. Aquí, digamos this.dialogTarget.show()
:
import { Controller } from '@hotwired/stimulus'; | |
export default class extends Controller { | |
static targets = ['dialog']; | |
open() { | |
this.dialogTarget.show(); | |
} | |
} |
Éste es otro superpoder del elemento <dialog>
¡tiene un método show()
! Integrada en el elemento <dialog>
está esta idea central de mostrar y ocultar.
Para utilizar el nuevo controlador, en index.html.twig
, busca el div
que contiene el button
y el dialog
y añade data-controller="modal"
. Luego, en el botón, di data-action="modal#open"
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 12 | |
<button | |
data-action="modal#open" | |
class="flex items-center space-x-1 bg-blue-500 hover:bg-blue-700 text-white text-sm font-bold px-4 rounded" | |
> | |
// ... lines 17 - 18 | |
</button> | |
// ... lines 20 - 49 | |
</div> | |
// ... lines 51 - 78 | |
</div> | |
{% endblock %} |
Por último, tenemos que establecer el <dialog>
como objetivo. Elimina el atributo open
para que empiece cerrado y sustitúyelo por data-modal-target="dialog"
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 20 | |
<dialog | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%]" | |
data-modal-target="dialog" | |
> | |
// ... lines 25 - 49 | |
</div> | |
// ... lines 51 - 78 | |
</div> | |
{% endblock %} |
¡Me gusta! Aquí empieza cerrado. Y cuando hagamos clic, ¡abre! Cerrar, abrir, ¡cerrar!
Abrir como modal
Un elemento <dialog>
tiene dos modos: el modo normal que hemos estado utilizando y un modo modal... que es mucho más útil. Para utilizar el modo modal, en lugar de show()
, utiliza showModal()
:
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 5 | |
open() { | |
this.dialogTarget.showModal(); | |
} | |
} |
Ahora, cuando hacemos clic, se sigue abriendo, pero hay algunas diferencias sutiles. La primera es que podemos cerrarlo pulsando Esc
. ¡Genial! La segunda es que tiene un fondo. Observa: cuando haga clic, la pantalla se oscurecerá un poco. ¿Lo has visto? Esto también me impide interactuar con el resto de la página. Y esto nos sale gratis gracias a <dialog>
. Eso es enorme.
Estilizar el telón de fondo
Inspecciona y busca el elemento <dialog>
- ahí está. El telón de fondo se añade a través de un pseudoelemento llamado backdrop
. Así que se encarga de añadirlo por nosotros... pero es un elemento real al que se le puede aplicar estilo. ¡Y quiero darle estilo!
De vuelta a la plantilla, busca el elemento dialog
. Gracias a Tailwind, podemos aplicar estilo directamente al pseudoelemento telón de fondo. Añade backdrop:bg-slate-600
ybackdrop:opacity-80
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 20 | |
<dialog | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] backdrop:bg-slate-600 backdrop:opacity-80" | |
data-modal-target="dialog" | |
> | |
// ... lines 25 - 48 | |
</dialog> | |
</div> | |
// ... lines 51 - 78 | |
</div> | |
{% endblock %} |
Observa el efecto. Esto empieza a ser muy, muy suave.
Eliminar el desplazamiento de página de fondo
Una cosa que el elemento dialog
no maneja automáticamente es que... la página del fondo sigue desplazándose. No afecta a nada... pero no es el comportamiento que esperamos.
Para solucionarlo, en el método open()
, di document.body
para obtener el elemento body, .classList.add('overflow-hidden')
:
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 5 | |
open() { | |
// ... line 7 | |
document.body.classList.add('overflow-hidden'); | |
} | |
} |
Y ahora... ¡eso es lo que queremos!
Limpieza al cerrar
Aunque... si cerramos, ¡todavía no puedo desplazarme! Tenemos que eliminar esa clase.
Para ello, copia el método open()
, pégalo y llámalo close()
. Para cerrar el diálogo, llama a close()
... y luego elimina overflow-hidden
:
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 10 | |
close() { | |
this.dialogTarget.close(); | |
document.body.classList.remove('overflow-hidden'); | |
} | |
} |
¡Me gusta! Sólo hay un pequeño problema: ¡no estamos llamando al método close()
! Si pulsamos X o Esc, el diálogo se cierra, sí, pero sigo sin poder desplazarme porque nada llama a este método close()
en nuestro controlador.
Afortunadamente, el elemento dialog
nos cubre las espaldas. Cada vez que un elemento dialog
se cierra -por cualquier motivo-, envía un evento llamado close
. Podemos escucharlo.
En el elemento <dialog>
, añade un conjunto data-action
a close->modal#close
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 20 | |
<dialog | |
// ... lines 22 - 23 | |
data-action="close->modal#close" | |
> | |
// ... lines 26 - 49 | |
</dialog> | |
</div> | |
// ... lines 52 - 79 | |
</div> | |
{% endblock %} |
Así, independientemente de cómo se cierre dialog
-presionaré Escape-, ahora podemos desplazarnos porque se ha llamado al método close()
de nuestro controlador.
Difuminar el fondo
Tip
Gracias a la ayuda de Rob Meijer, puedes hacer esto en CSS puro. En el elemento <dialog>
utiliza backdrop:bg-opacity-80
en lugar de backdrop:opacity-80
y luego añade backdrop:backdrop-blur-sm
. ¡No necesitas JS!
Vale, estoy emocionado. ¿Qué más podemos hacer? ¿Qué tal difuminar el fondo? Puedes intentar hacerlo difuminando el fondo. Yo lo he intentado... pero no he conseguido que funcione. No pasa nada. Lo que podemos desenfocar es el cuerpo. Añade una clase más: blur-sm
y elimina la blur-sm
en close()
:
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 5 | |
open() { | |
// ... line 7 | |
document.body.classList.add('overflow-hidden', 'blur-sm'); | |
} | |
close() { | |
// ... line 12 | |
document.body.classList.remove('overflow-hidden', 'blur-sm'); | |
} | |
} |
Veamos cómo queda. ¡Esto sí que mola!
Cerrar al hacer clic fuera
Pero si intento hacer clic fuera del modal, no se cierra. Esa es otra cosa que el elemento dialog
no maneja. Afortunadamente, hay una solución rápida.
Arriba, en el elemento raíz de nuestro controlador... En realidad, podemos ponerlo aquí abajo, en el elemento dialog
. Añade una nueva acción: click->modal#clickOutside
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 20 | |
<dialog | |
// ... lines 22 - 23 | |
data-action="close->modal#close click->modal#clickOutside" | |
> | |
// ... lines 26 - 49 | |
</dialog> | |
</div> | |
// ... lines 52 - 79 | |
</div> | |
// ... lines 81 - 82 |
Apuesto a que parece raro -se llamará cada vez que hagamos clic en cualquier parte del diálogo-, así que vamos a escribir ese método. Digamos clickOutside()
, dale un argumento event
, luego si event.target === this.dialogTarget
, this.dialogTarget.close()
:
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 15 | |
clickOutside(event) { | |
if (event.target === this.dialogTarget) { | |
this.dialogTarget.close(); | |
} | |
} | |
} |
Tip
Para que el "clic fuera" funcione perfectamente, en lugar de añadir relleno directamente a dialog
, añade un elemento dentro y dale el relleno. Ya lo hemos hecho, pero es un detalle importante.
event.target
será el elemento real que ha recibido el clic. Resulta que la única forma de hacer clic exactamente en el propio elemento dialog
es si haces clic en el fondo. Si haces clic en cualquier otro lugar del interior, event.target
será uno de estos elementos. Así que son tres ingeniosas líneas de código, pero el resultado es perfecto. Haz clic aquí, sin problemas. Haz clic ahí, cerrado.
Animación CSS para el fundido de entrada
Llegados a este punto, ¡estoy contento! Pero este tutorial no trata de hacer cosas buenas, sino cosas geniales. Siguiente paso: Quiero que el elemento dialog
se desvanezca. Podríamos hacerlo con una transición CSS. Pero otra opción es una animación CSS. Lo sé, transiciones, animaciones... CSS tiene muchas.
Una animación es algo que aplicas a un elemento y... simplemente... hará esa animación para siempre. O puedes hacer que se anime sólo una vez. Por ejemplo, podemos hacer que este botón se anime arriba y abajo para siempre. Una de las cosas buenas de las animaciones es que puedes hacer que una animación sólo ocurra una vez... y no empezará hasta que el elemento se haga visible en la página. Por ejemplo, podríamos crear una animación de opacidad 0 a opacidad 100, que se ejecutaría en cuanto nuestro dialog
se hiciera visible.
Tailwind tiene algunas animaciones incorporadas, pero no una para el desvanecimiento. Así que la añadiremos. Abajo, en tailwind.config.js
, pegaré sobre la tecla theme
:
// ... lines 1 - 3 | |
module.exports = { | |
// ... lines 5 - 9 | |
theme: { | |
extend: { | |
animation: { | |
'fade-in': 'fadeIn .5s ease-out;', | |
}, | |
keyframes: { | |
fadeIn: { | |
'0%': { opacity: 0 }, | |
'100%': { opacity: 1 }, | |
}, | |
}, | |
}, | |
}, | |
// ... lines 23 - 27 | |
} |
Esto es principalmente material de animación CSS: añade una nueva llamada fade-in
que pasará de opacidad 0 a 100 en 1/2 segundo.
Para utilizarlo, busca el elemento dialog
y añade animate-fade-in
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
data-controller="modal" | |
class="flex justify-between" | |
> | |
// ... lines 11 - 20 | |
<dialog | |
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80" | |
// ... lines 23 - 24 | |
> | |
// ... lines 26 - 49 | |
</dialog> | |
</div> | |
// ... lines 52 - 79 | |
</div> | |
{% endblock %} |
Pruébalo. ¡Precioso! ¿Podríamos desvanecerlo? Claro, pero en realidad me gusta que se cierre inmediatamente. Así que voy a omitirlo.
Módulos y caché de página turbo
Vale, tengo un último detalle antes de despedirte por hoy. Cuando añadimos las transiciones de vista, en app.js
, desactivamos una función de Turbo llamada caché de página... porque aparentemente no siempre funciona bien con las transiciones de vista. Cuando las transiciones de vista sean estándar en Turbo 8, supongo que esto no será un problema.
De todos modos, cuando la caché está activada
// ... lines 1 - 20 | |
document.addEventListener('turbo:load', () => { | |
// View Transitions don't play nicely with Turbo cache | |
// if (shouldPerformTransition()) Turbo.cache.exemptPageFromCache(); | |
}); | |
// ... lines 25 - 42 |
en el momento en que haces clic fuera de una página, Turbo toma una instantánea de la página antes de navegar fuera. Cuando volvemos a hacer clic, es instantáneo: ¡boom! En lugar de hacer una petición a la red, utiliza la versión en caché de esta página. Hay más cosas, pero captas la idea.
Con el almacenamiento en caché activado, una cosa de la que tenemos que preocuparnos es de eliminar cualquier elemento temporal de la página antes de que se tome la instantánea, como mensajes tostados o modales. Porque, cuando hagas clic en "Atrás", no querrás que haya una notificación tostada aquí arriba.
La forma en que solemos resolver esto, por ejemplo en _flashes.html.twig
, es añadir un atributo data-turbo-temporary
:
{% for message in app.flashes('success') %} | |
<div | |
// ... lines 3 - 4 | |
data-turbo-temporary | |
// ... lines 6 - 7 | |
> | |
// ... lines 9 - 31 | |
</div> | |
{% endfor %} |
Que le dice a Turbo que elimine este elemento antes de tomar la instantánea.
Probemos a añadir esto a nuestro dialog
para que no aparezca en la instantánea. Para ver qué ocurre, abre el modal y haz clic atrás. Eso acaba de tomar una instantánea de la página anterior. Ahora haz clic hacia adelante. Vaya. Estamos en un estado extraño. Parece que el diálogo ha desaparecido... pero no podemos desplazarnos y la página está borrosa.
Eso es porque necesitamos hacer algo más que ocultar el dialog
: necesitamos eliminar estas clases del cuerpo. Básicamente, antes de que Turbo tome la instantánea, ¡necesitamos algo que llame al método close()
!
¡Y podemos hacerlo! En index.html.twig
, en el elemento controlador raíz -aunque esto podría ir en cualquier sitio- añade un data-action=""
. Justo antes de que Turbo tome su instantánea, envía un evento llamado turbo:before-cache
. Podemos escucharlo y luego llamar a modal#close
. El único detalle es que el evento turbo:before-cache
no se envía a un elemento específico. Así que escucharlo en este elemento no funcionará. Se envía por encima de nosotros, a la ventana. Es un evento global.
Afortunadamente, Turbo nos proporciona una forma sencilla de escuchar los eventos globales añadiendo@window
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
// ... line 8 | |
data-action="turbo:before-cache@window->modal#close" | |
// ... line 10 | |
> | |
// ... lines 12 - 51 | |
</div> | |
// ... lines 53 - 80 | |
</div> | |
{% endblock %} |
Es un poco técnico, pero con este único arreglo, podemos abrir el modal, retroceder, avanzar, y la página queda preciosa.
¡Guau! Hoy ha sido un día enorme, ¡pero mira lo que hemos conseguido! Un bonito sistema modal sobre el que tenemos un control total. Mañana va a ser igual de grande, ya que daremos vida a este modal con contenido y formularios dinámicos reales. Hasta entonces.
As an alternative of hardcoding (Tailwind) classes into the Stimulus controller, you can also use Stimulus CSS Classes.
They use a logical name approach, but can be a bit tricky to understand and write.
For example:
Or alternatively use the Twig helper:
And add to
modal_controller.js
:And use the class:
I admit this might not the best use-case to leverage this, because you probably want the overflow-hidden behaviour everytime you're using the
modal_controller
.But still wanted to share the solution. Especially when using multiple classes this can help out.