¡Popover!
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 SubscribeEn el menú del día 11 está nuestra primera característica grande, bonita y totalmente funcional: un popover. Pero, ¡un popover precioso, reutilizable y de carga lenta!
Ya existen controladores Stimulus de código abierto para resolver un montón de problemas diferentes. Y una de las mejores fuentes de ellos es Stimulus Components: una rica colección de controladores. Vamos a trabajar con uno llamado popover.
Por si no lo sabes, un popover es un simpático recuadro que aparece para saludarte cuando pasas el ratón por encima de un elemento. Es como un tooltip, salvo que suelen ser más grandes y puedes pasar el ratón por encima del propio cuadro.
Instalación y configuración de stimulus-popover
Se trata de una biblioteca JavaScript pura. Pero no vamos a instalarla conyarn
o npm
. En lugar de eso, ya sabes, ejecuta:
php bin/console importmap:require stimulus-popover
Como se trata de un paquete JavaScript puro, no hay receta Flex. El único cambio que se hizo fue en importmap.php
:
// ... lines 1 - 15 | |
return [ | |
// ... lines 17 - 39 | |
'stimulus-popover' => [ | |
'version' => '6.2.0', | |
], | |
]; |
Así que tenemos acceso al código, pero esta vez, tenemos que registrar el controlador manualmente.
¡No pasa nada! Copia estas líneas de la documentación... y luego abre assets/bootstrap.js
. Pega esto encima. No necesitamos Application.start()
... y mueveapplication.register()
después... y llámalo app
:
// ... line 1 | |
import Popover from 'stimulus-popover'; | |
const app = startStimulusApp(); | |
app.register('popover', Popover); |
¡Felicidades! Tenemos un nuevo y reluciente controlador llamado popover
.
Utilizar el controlador
El objetivo es pasar el ratón por encima de este planeta y mostrar una ventana emergente con información adicional. Para conseguir que funcione, dirígete a la documentación. Hay documentación de Rails para cosas del lado del servidor.... que no necesitamos. Allá vamos. Así que necesitamos un elemento condata-controller"popover"
y, dentro, un enlace que, en mouseenter
llame a un método show
y, en mouseleave
llame a hide
. Debajo, este es el contenido que se mostrará en el popover.
Copia esto entonces, dirígete a homepage.html.twig
, y busca planetas. Aquí está eltd
y aquí está la imagen del planeta. Pega... y luego moveré mi img
dentro.
¡Estupendo! Luego tenemos que poner este data-action
en algún sitio. Es tentador ponerlo en el propio img
. Pero, la biblioteca añade el contenido del popover dentro del elemento que lo activa... y como no puedes poner contenido dentro de un img
, es un no-go. En lugar de eso, ponlo directamente en el div
padre:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 7 - 13 | |
<section class="flex-1 ml-10"> | |
// ... lines 15 - 29 | |
<div class="bg-gray-800 p-4 rounded"> | |
<table class="w-full text-white"> | |
// ... lines 32 - 38 | |
<tbody> | |
{% for voyage in voyages %} | |
<tr class="border-b border-gray-700 {% if loop.index is odd %} bg-gray-800 {% else %} bg-gray-700 {% endif %}"> | |
// ... line 42 | |
<td class="px-2 whitespace-nowrap"> | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
> | |
<img | |
src="{{ asset('images/'~voyage.planet.imageFilename) }}" | |
alt="Image of {{ voyage.planet.name }}" | |
class="inline-block w-8 h-8 rounded-full bg-gray-600 ml-2" | |
> | |
<template data-popover-target="content"> | |
<div data-popover-target="card"> | |
<p>This content is in a hidden | |
template.</p> | |
</div> | |
</template> | |
</div> | |
</td> | |
// ... line 62 | |
</tr> | |
{% endfor %} | |
</tbody> | |
</table> | |
</div> | |
// ... lines 68 - 71 | |
</section> | |
</div> | |
{% endblock %} |
Así que en mouseenter
de este div, muestra el popover, en mouseleave
de este div, oculta el popover.
Esto debería funcionar Puede parecer un poco alocado al principio... pero bueno, zambullámonos y veamos qué pasa. Y... ¡funciona! Es raro y nervioso, ¡pero funcional!
Estilizar el Popover
Es hora de darle un mejor aspecto. Seleccionaré todo este div
y lo pegaré:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 7 - 13 | |
<section class="flex-1 ml-10"> | |
// ... lines 15 - 29 | |
<div class="bg-gray-800 p-4 rounded"> | |
<table class="w-full text-white"> | |
// ... lines 32 - 38 | |
<tbody> | |
{% for voyage in voyages %} | |
<tr class="border-b border-gray-700 {% if loop.index is odd %} bg-gray-800 {% else %} bg-gray-700 {% endif %}"> | |
// ... line 42 | |
<td class="px-2 whitespace-nowrap"> | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
<img | |
src="{{ asset('images/'~voyage.planet.imageFilename) }}" | |
alt="Image of {{ voyage.planet.name }}" | |
class="inline-block w-8 h-8 rounded-full bg-gray-600 ml-2" | |
> | |
<template data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<div class="px-6 py-4"> | |
<h4> | |
<a class="hover:text-blue-300 transition-colors duration-100" href="{{ path('app_planet_show', { id: voyage.planet.id }) }}"> | |
{{ voyage.planet.name }} | |
</a> | |
</h4> | |
<small>{{ voyage.planet.lightYearsFromEarth|round|number_format }} ly</small> | |
</div> | |
</div> | |
</template> | |
</div> | |
</td> | |
// ... line 72 | |
</tr> | |
{% endfor %} | |
</tbody> | |
</table> | |
</div> | |
// ... lines 78 - 81 | |
</section> | |
</div> | |
{% endblock %} |
No hice nada del otro mundo: añadí una clase relative
en el exterior div
... y aquí abajo, hice que el popover tuviera una posición absoluta e imprimí algo de información sobre el planeta.
Ahora... ¡mira eso! ¿Sabes qué estaría bien? ¡Una flechita! Podemos añadir una en CSS puro con un pseudoelemento :after
en el objetivo del popover card
. Esta es una estrategia CSS estándar para añadir flechas, y puedes encontrarla en la web, o utilizar AI para ayudarte a generarla.
Abre app.css
y pegaré algo de código. También puedes hacer esto con clases Tailwind:
// ... lines 1 - 63 | |
[data-popover-target=card]:after { | |
content: ""; | |
position: absolute; | |
top: 100%; | |
left: 1rem; | |
border-width: .75rem; | |
@apply border-t-white dark:border-t-gray-900 border-transparent; | |
} |
¡Ve a comprobarlo! Me encanta
Carga perezosa con un marco turbo
Llegados a este punto, el popover funciona muy bien y tiene un aspecto estupendo. ¿Te apuntas a un reto? En lugar de cargar todo este código al cargar la página, quiero cargarlo mediante Ajax sólo cuando el usuario pase el ratón por encima. La biblioteca de ventanas emergentes tiene una forma de hacerlo. Pero como reto extra, quiero hacerlo con marcos normales ol
' Turbo. Porque, ¡los marcos son realmente buenos cargando cosas mediante AJAX! Además, veremos un par de características extra de los frames de las que aún no hemos hablado.
Para empezar, necesitamos una ruta que muestre esta información del planeta. En la plantilla de la página de inicio, copia este código y luego elimínalo:
// ... lines 1 - 59 | |
<div class="px-6 py-4"> | |
<h4> | |
<a class="hover:text-blue-300 transition-colors duration-100" href="{{ path('app_planet_show', { id: voyage.planet.id }) }}"> | |
{{ voyage.planet.name }} | |
</a> | |
</h4> | |
<small>{{ voyage.planet.lightYearsFromEarth|round|number_format }} ly</small> | |
</div> | |
// ... lines 68 - 85 |
En templates/planet/
, crea un nuevo archivo llamado _card.html.twig
, y pégalo:
// ... line 1 | |
<div class="px-6 py-4"> | |
<h4> | |
<a class="hover:text-blue-300 transition-colors duration-100" href="{{ path('app_planet_show', { id: voyage.planet.id }) }}"> | |
{{ voyage.planet.name }} | |
</a> | |
</h4> | |
<small>{{ voyage.planet.lightYearsFromEarth|round|number_format }} ly</small> | |
</div> | |
// ... lines 10 - 11 |
A continuación, crea una ruta para esto. En src/Controller/PlanetController.php
, en cualquier lugar, pegaré una ruta y un controlador:
// ... lines 1 - 14 | |
class PlanetController extends AbstractController | |
{ | |
// ... lines 17 - 54 | |
'/{id}/card', name: 'app_planet_show_card', methods: ['GET']) | (|
public function showCard(Planet $planet): Response | |
{ | |
return $this->render('planet/_card.html.twig', [ | |
'planet' => $planet, | |
]); | |
} | |
// ... lines 62 - 94 | |
} |
Nada especial: consulta el Planet
y pásalo a la plantilla. En esa plantilla, ajusta las variables. En lugar de voyage.planet
, utiliza planet
en cada lugar:
// ... line 1 | |
<div class="px-6 py-4"> | |
<h4> | |
<a class="hover:text-blue-300 transition-colors duration-100" href="{{ path('app_planet_show', { id: planet.id }) }}"> | |
{{ planet.name }} | |
</a> | |
</h4> | |
<small>{{ planet.lightYearsFromEarth|round|number_format }} ly</small> | |
</div> | |
// ... lines 10 - 11 |
Ahora tenemos una ruta AJAX que devuelve el contenido. Aquí está la parte mágica. En homepage.html.twig
, queremos cargar ese contenido justo aquí. Hazlo añadiendo un turbo-frame
con id
ajustado a planet-card-
y luego a {{ voyage.planet.id }}
para que sea único en la página:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 7 - 13 | |
<section class="flex-1 ml-10"> | |
// ... lines 15 - 29 | |
<div class="bg-gray-800 p-4 rounded"> | |
<table class="w-full text-white"> | |
// ... lines 32 - 38 | |
<tbody> | |
{% for voyage in voyages %} | |
<tr class="border-b border-gray-700 {% if loop.index is odd %} bg-gray-800 {% else %} bg-gray-700 {% endif %}"> | |
// ... line 42 | |
<td class="px-2 whitespace-nowrap"> | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 49 - 54 | |
<template data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame id="planet-card-{{ voyage.planet.id }}" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"></turbo-frame> | |
</div> | |
</template> | |
</div> | |
</td> | |
// ... line 67 | |
</tr> | |
{% endfor %} | |
</tbody> | |
</table> | |
</div> | |
// ... lines 73 - 76 | |
</section> | |
</div> | |
{% endblock %} |
Añade este mismo marco en _card.html.twig
... con la etiqueta de cierre:
<turbo-frame id="planet-card-{{ planet.id }}"> | |
<div class="px-6 py-4"> | |
<h4> | |
<a class="hover:text-blue-300 transition-colors duration-100" href="{{ path('app_planet_show', { id: planet.id }) }}"> | |
{{ planet.name }} | |
</a> | |
</h4> | |
<small>{{ planet.lightYearsFromEarth|round|number_format }} ly</small> | |
</div> | |
</turbo-frame> |
Normalmente, un <turbo-frame>
será una parte de una página entera. Pero está perfectamente bien hacer una ruta que sólo devuelva un único fotograma.
Volviendo a homepage.html.twig
, tenemos la configuración básica. El truco es que... no estamos esperando a que alguien haga clic en un enlace dentro de este marco que luego cargará la página de la tarjeta. No, queremos que se cargue inmediatamente.
Para ello, añade un atributo src
establecido en {{ path() }}
... y... eso es casi correcto. La ruta es app_planet_show_card
:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="flex"> | |
// ... lines 7 - 13 | |
<section class="flex-1 ml-10"> | |
// ... lines 15 - 29 | |
<div class="bg-gray-800 p-4 rounded"> | |
<table class="w-full text-white"> | |
// ... lines 32 - 38 | |
<tbody> | |
{% for voyage in voyages %} | |
<tr class="border-b border-gray-700 {% if loop.index is odd %} bg-gray-800 {% else %} bg-gray-700 {% endif %}"> | |
// ... line 42 | |
<td class="px-2 whitespace-nowrap"> | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 49 - 54 | |
<template data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame id="planet-card-{{ voyage.planet.id }}" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"></turbo-frame> | |
</div> | |
</template> | |
</div> | |
</td> | |
// ... line 67 | |
</tr> | |
{% endfor %} | |
</tbody> | |
</table> | |
</div> | |
// ... lines 73 - 76 | |
</section> | |
</div> | |
{% endblock %} |
¡Listo! Cuando aparezca un marco turbo que ya tenga un atributo src
, hará la llamada AJAX para cargar ese contenido inmediatamente.
Pruébalo. Actualiza y... falta contenido. He estropeado algo. No pasa nada, ¡podemos depurar! La llamada ha fallado con un error 500. Aquí es donde la barra de herramientas de depuración web resulta útil. No podemos ver el error fácilmente... pero a través del elemento de la barra de herramientas Ajax, podemos hacer clic para ver la gran y bonita página de excepción. Ah:
La variable
voyage
no existe.
Porque tengo que ajustarla a planet.id
:
<turbo-frame id="planet-card-{{ planet.id }}"> | |
// ... lines 2 - 9 | |
</turbo-frame> |
De acuerdo. Y ahora... ¡lo tengo! Hay un momento en que el popover está vacío... pero lo arreglaremos pronto.
Carga perezosa de Turbo Frames
Por pura casualidad, nuestro Turbo Frame se está cargando perezosamente. Lo que quiero decir es: cuando tenemos un <turbo-frame>
con un atributo src
, la llamada AJAX se realiza inmediatamente. Teniendo esto en cuenta, ¿no deberíamos ver 30 llamadas Ajax sucediendo a la vez? Sí... ¡pero no es así! Sólo ocurre cuando pasamos el ratón por encima. ¿Por qué?
Inspecciona ese elemento. Ah. Es por accidente, gracias al elemento template
. El elementotemplate
es especial en tu navegador: cualquier cosa dentro de él se comporta... como si no estuviera en la página en absoluto: casi como si fuera una cadena en lugar de un elemento. Así, cuando cargamos por primera vez, el <turbo-frame>
técnicamente no forma parte de la página. Pero en el momento en que pasamos el ratón por encima, lo copia en la página, el turbo-frame
cobra vida y se realiza la llamada Ajax. ¡Genial!
Pero habrá otras situaciones en las que queramos que un turbo-frame
cargue su contenido sólo cuando ese marco se haga visible. Para demostrarlo, cambia temporalmente el template
por un div
:
// ... lines 1 - 43 | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 49 - 54 | |
<div data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame id="planet-card-{{ voyage.planet.id }}" target="_top" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"></turbo-frame> | |
</div> | |
</div> | |
</div> | |
// ... lines 66 - 80 |
Esto va a tener un aspecto horrible... porque todas las cartas serán visibles a la vez. ¡Sí! ¡Están todas en la página y ha hecho 30 llamadas Ajax inmediatamente! ¡Vaya! Para indicar a estos marcos que no se carguen hasta que sean visibles en la página, añade loading="lazy"
:
// ... lines 1 - 43 | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 49 - 54 | |
<div data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame loading="lazy" id="planet-card-{{ voyage.planet.id }}" target="_top" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"></turbo-frame> | |
</div> | |
</div> | |
</div> | |
// ... lines 66 - 80 |
Actualizar ahora. 3 llamadas ajax... ¡porque sólo 3 marcos son visibles! Los demás elementos están todos en la página... pero por debajo del scroll. Observa este número mientras me desplazo. ¿Lo ves? A medida que se hacen visibles, cada uno realiza su llamada AJAX. Qué guay.
Vuelve a cambiar el elemento a template
para que las cosas vuelvan a funcionar bien:
// ... lines 1 - 43 | |
<div | |
data-controller="popover" | |
data-action="mouseenter->popover#show mouseleave->popover#hide" | |
class="relative" | |
> | |
// ... lines 49 - 54 | |
<template data-popover-target="content"> | |
<div | |
data-popover-target="card" | |
class="max-w-sm rounded shadow-lg bg-gray-900 absolute left-0 bottom-10" | |
> | |
<turbo-frame loading="lazy" id="planet-card-{{ voyage.planet.id }}" target="_top" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"></turbo-frame> | |
</div> | |
</template> | |
</div> | |
// ... lines 66 - 80 |
Añadir contenido de carga
Vale, estoy muy contento. Pero quiero perfeccionar esta nueva función. Una cosa que no me gusta es que esté vacío antes de que termine la llamada Ajax. Me gustaría añadir contenido de carga.
Esto es fácil: cuando tienes un turbo-frame
con un atributo src
, cualquier contenido que haya dentro se mostrará por defecto mientras se carga. Voy a pegar un div
con un SVG:
// ... lines 1 - 59 | |
<turbo-frame loading="lazy" id="planet-card-{{ voyage.planet.id }}" target="_top" src="{{ path('app_planet_show_card', { | |
'id': voyage.planet.id, | |
}) }}"> | |
<div class="p-10"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="24" height="24" 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> | |
<path d="M12 3a9 9 0 1 0 9 9"></path> | |
</svg> | |
</div> | |
</turbo-frame> | |
// ... lines 70 - 87 |
Este SVG procede de Tabler Icons. Es una gran fuente para encontrar un icono que puedes copiar en tu proyecto. Lo he unido a una clase animate-spin
de Tailwind.
Vamos a comprobarlo. ¡Rápido, giratorio y encantador!
Recordando la llamada Ajax
¿Tenemos tiempo para una cosa más? Cuando pasamos el ratón por encima del elemento, hace la llamada AJAX. Y... la repite cada vez que pasamos el ratón por encima. No recuerda el contenido de la llamada AJAX.
Eso se debe a cómo funciona el controlador de ventanas emergentes... y si hubiera sido menos terco y hubiera utilizado su forma de cargar el contenido mediante Ajax, no sería un problema. De todas formas, cada vez que pasamos el ratón, se crea el turbo-frame
, se destruye, se crea uno nuevo, se destruye, etc.
Entonces: ¿cómo podemos hacer que el controlador recuerde el contenido? ¡No tengo ni idea! Pero vamos a mirar dentro del código. En assets/vendor/stimulus-popover/
, abre este archivo. El contenido está minificado... pero un rápido Cmd
+L
para reformatear el código lo arregla ¿A que mola? Ahora podemos leer este archivo de proveedor, e incluso añadir código de depuración temporal si lo necesitamos. Y... Creo que veo una forma de hacer que esto funcione.
Al igual que con los controladores Symfony, podemos anular los controladores Stimulus. Dentro del directorio controllers/
, crearemos nuestro propio popover_controller.js
. Luego pegaré algo de código:
import Popover from 'stimulus-popover'; | |
export default class extends Popover { | |
async show(t) { | |
if (this.hasCardTarget) { | |
this.cardTarget.classList.remove('hidden'); | |
return; | |
} | |
super.show(t); | |
} | |
hide() { | |
this.hasCardTarget && this.cardTarget.classList.add('hidden'); | |
} | |
} |
Normalmente importamos Controller
de Stimulus y lo extendemos. Pero en este caso, estoy importando directamente el controlador popover y extendiéndolo. A continuación, anulamos el método show
y el método hide
para activar una clase hidden
en lugar de destruir completamente el elemento.
Y ahora que tenemos nuestro propio controlador llamado popover
, en bootstrap.js
, no necesitamos registrar el de los componentes Stimulus. El controlador de popover
será nuestro controlador... entonces aprovechamos el controlador de componentes Stimulus mediante herencia.
¡Momento de la verdad! Se carga una vez... ¡y luego recuerda su contenido!
No sólo hemos creado el popover perfecto, sino que ahora podemos repetirlo fácilmente en otras partes de nuestro sitio. Si te estás preguntando si podríamos reutilizar parte del marcado del popover... permanece atento al Día 23, cuando hablemos de los Componentes Twig.
Esto es todo por hoy Tómate un merecido descanso, porque mañana escribiremos un pequeño, pero poderoso, controlador de Stimulus llamado auto-enviar.
Even though the popover is displayed correctly, I have a series of errors in console saying "The popover element with id "content" does not exist. Please check the data-popover-target attribute." How do I fix it?