Buy Access to Course
11.

¡Popover!

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

En 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:

44 lines | 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:

6 lines | assets/bootstrap.js
// ... 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 showy, 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:

75 lines | templates/main/homepage.html.twig
// ... 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é:

85 lines | templates/main/homepage.html.twig
// ... 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:

72 lines | assets/styles/app.css
// ... 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:

85 lines | templates/main/homepage.html.twig
// ... 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:

11 lines | templates/planet/_card.html.twig
// ... 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:

96 lines | src/Controller/PlanetController.php
// ... lines 1 - 14
class PlanetController extends AbstractController
{
// ... lines 17 - 54
#[Route('/{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 planeten cada lugar:

11 lines | templates/planet/_card.html.twig
// ... 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:

80 lines | templates/main/homepage.html.twig
// ... 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:

80 lines | templates/main/homepage.html.twig
// ... 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:

11 lines | templates/planet/_card.html.twig
<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:

80 lines | templates/main/homepage.html.twig
// ... 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":

80 lines | templates/main/homepage.html.twig
// ... 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:

80 lines | templates/main/homepage.html.twig
// ... 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:

87 lines | templates/main/homepage.html.twig
// ... 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.