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

On the menu for day 11 is our first big, beautiful, fully-functional feature: a popover. But, like a gorgeous, reusable, lazy-loading popover!

Open source Stimulus controllers already exist to solve lots of different problems. And one of the best sources for them is Stimulus Components: a rich collection of controllers. We're going to work with the one called popover.

If you don't know, a popover is a friendly box that pops over to say hello when you hover on an element. It's like a tooltip, except they're usually bigger and you can hover over the box itself.

Installing & Setting up stimulus-popover

This is a pure JavaScript library. But we're not going to install it with yarn or npm. Instead, you know, run:

php bin/console importmap:require stimulus-popover

Since we're dealing with a pure JavaScript package, there's no Flex recipe. The only change this made was to importmap.php:

44 lines | importmap.php
// ... lines 1 - 15
return [
// ... lines 17 - 39
'stimulus-popover' => [
'version' => '6.2.0',
],
];

So we have access to the code, but this time, we need to register the controller manually.

That's okay! Copy these lines from the documentation... then open assets/bootstrap.js. Paste this on top. We don't need Application.start()... and move application.register() after... and call it app:

6 lines | assets/bootstrap.js
// ... line 1
import Popover from 'stimulus-popover';
const app = startStimulusApp();
app.register('popover', Popover);

Congrats! We have a shiny new controller named popover.

Using the Controller

The goal is to hover over this planet and show a popover with extra info. To get that working, head down on the docs. There's some Rails documentation for server-side stuff.... we don't need that. Here we go. So we need an element with data-controller"popover" and, inside, a link that, on mouseenter calls a show method and, on mouseleave calls hide. Below, this is the content that will be shown in the popover.

Copy this then, head to homepage.html.twig, and search for planets. Here's the td and here's the planet image. Paste... then I'll move my img inside.

Lovely! Then we need to put this data-action somewhere. It's tempting to put it on the img itself. But, the library adds the popover content inside the element that triggers it... and since you can't put content inside an img, it's a no-go. Instead, put it directly on the parent div:

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 %}

So on mouseenter of this div, show the popover, on mouseleave of this div, hide the popover.

That ought to do the trick! It might look a bit wild at first... but hey, let's dive in and see what happens. And, it... works! It's weird and jumpy, but functional!

Styling the Popover

Time to make it look better. I'll select this entire div and paste:

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 %}

That didn't do anything too big: I added a relative class on the outer div... and down here, made the popover absolutely positioned and printed out some planet info.

Now... look at that! You know what would be cool? A little arrow! We can add one in pure CSS with an :after pseudo-element on the popover card target. This is a standard CSS strategy for adding arrows, and you can find it on the web, or you use AI to help generate it.

Open app.css and I'll paste in some code. You can also do this with Tailwind classes:

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;
}

Go check it out! Love it!

Lazy-Loading with a Turbo Frame

At this point, the popover works great and looks great. Are you up for a challenge? Instead of loading all of this markup on page load, I want to load it via Ajax only once the user hovers. The popover library does have a way to do this. But as an extra, extra challenge, I want to do it with regular ol' Turbo frames. Because, Frames are really good at loading stuff via AJAX! Plus, we'll see a couple of extra frames features that we haven't talked about yet.

To start, we need an endpoint that renders this planet info. In the homepage template, copy that code, then delete it:

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

In templates/planet/, create a new file called _card.html.twig, and paste:

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

Next, create an endpoint for this. In src/Controller/PlanetController.php, anywhere, I'll paste in a route and controller:

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
}

Nothing special: it queries for the Planet and passes it to the template. In that template, adjust the variables. Instead of voyage.planet, use planet in each spot:

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

We now have an AJAX endpoint that returns the content. Here's the magic part. Over in homepage.html.twig, we want to load that content right here. Do that by adding a turbo-frame with id set to planet-card- then {{ voyage.planet.id }} so it's unique on the page:

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 %}

Add this same frame in _card.html.twig... with the closing tag:

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

Usually, a <turbo-frame> will be one part of a whole page. But it's perfectly ok to make an endpoint that just returns a single frame.

Back over in homepage.html.twig, we have the basic setup. The trick is that... we're not waiting for somebody to click a link inside this frame that will then load the card page. Nope, we want it to load immediately.

To do that, add a src attribute set to {{ path() }}... and... that's almost correct. The route is 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 %}

Done! When a turbo frame appears that already has a src attribute, it will make the AJAX call to load that content immediately.

Try it. Refresh and... content missing. I mucked something up. That's ok - we can debug! The call failed with a 500 error. This is where the web debug toolbar comes in handy. We can't see the error easily... but via the Ajax toolbar element, we can click to see the big beautiful exception page. Ah:

Variable voyage does not exist.

Because I need to adjust that to planet.id:

11 lines | templates/planet/_card.html.twig
<turbo-frame id="planet-card-{{ planet.id }}">
// ... lines 2 - 9
</turbo-frame>

All right. And now... got it! There is a moment when the popover is empty... but we'll fix that soon.

Lazy-Loading Turbo Frames

By complete accident, our Turbo Frame is even being loaded lazily. What I mean is: when we have a <turbo-frame> with a src attribute, the AJAX call will be made immediately. With that in mind, shouldn't we see 30 Ajax calls happening all at once? Yea... but we don't! It only happens once we hover. Why?

Inspect that element. Ah. It's by accident thanks to the template element. The template element is special in your browser: anything inside it behaves... as if it's not on the page at all: almost like it's a string instead of an element. So, when we first load, the <turbo-frame> is technically not part of the page. But the moment we hover, it copies that onto the page, the turbo-frame comes alive and the Ajax call is made. Pretty cool!

But there will be other situations when we want a turbo-frame to load its content only once that frame becomes visible. And we can do that! To show this off, change the template to a div temporarily:

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

This is going to look awful... because every card will be visible all at once. Yup! They're all on the page and it made 30 Ajax calls immediately! Yikes! To tell these frames to not load until they become visible on the page, add 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

Refresh now. 3 ajax calls... because only 3 frames are visible! The other elements are all on the page... but below the scroll. Watch this number as I scroll. See that? As they become visible, each makes its AJAX call. So cool.

Change the element back to a template so that things work nicely again:

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

Adding Loading Content

Ok, I'm really happy. But I want to perfect this new feature. One thing I don't like is that it's empty before the Ajax call finishes. I'd like to add some loading content.

This is easy: when you have a turbo-frame with a src attribute, whatever content is inside will be shown by default while it loads. I'll paste in a div with an 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

This SVG comes from Tabler Icons. That's a great source to find an icon that you copy into your project. I've coupled that with an animate-spin class from Tailwind.

Let's check it. Quick, spinny and lovely!

Remembering the Ajax Call

Do we have time for one more thing? When we hover over the element, it makes the AJAX call. And... it repeats that every time we hover. It doesn't remember the content from the AJAX call.

That's due to how the popover controller works... and if I had been less stubborn and used its way of Ajax-loading content, it wouldn't be a problem. Anyway, each time we hover, it creates the turbo-frame, destroys it, creates a new one, destroys it, etc.

So: how can we make the controller remember the content? I have no idea! But let's go look inside the code. In assets/vendor/stimulus-popover/, open this file. The contents are minified... but a quick Cmd+L to reformat the code fixes that. How cool is this? We can now read this vendor file - and even add temporary debugging code if we needed to. And... I think I see a way that we can make this work.

Just like with Symfony controllers, we can override Stimulus controllers. Inside the controllers/ directory, create our own popover_controller.js. Then I'll paste in some code:

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');
}
}

Normally we import Controller from Stimulus and extend that. But in this case, I'm importing the popover controller directly and extending that. Then we override the show method and hide method to toggle a hidden class instead of fully destroying the element.

And now that we have our own controller named popover, in bootstrap.js, we don't need to register the one from Stimulus components. The popover controller will be our controller... then we leverage the Stimulus components controller via inheritance.

5 lines | assets/bootstrap.js
// ... lines 1 - 3
// app.register('popover', Popover);

Moment of truth! It loads once... then remembers its content!

Not only did we create the perfect popover, we can now easily repeat this on other parts of our site. If you're wondering if we could reuse some of the popover markup... stay tuned for Day 23 when we talk about Twig Components.

That's a wrap for today! Get some well-deserved rest, because tomorrow we'll write a tiny, yet mighty, Stimulus controller called auto-submit.