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 SubscribeOn 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
:
// ... 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
:
// ... 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
:
// ... 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:
// ... 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:
// ... 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:
// ... 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:
// ... 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:
// ... 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 | |
} |
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:
// ... 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:
// ... 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
:
// ... 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
:
<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:
// ... 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"
:
// ... 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:
// ... 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:
// ... 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.
// ... 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.
Hey Ryan, thanks for this nice course !
I'm following this based on text + pics, at the end when we create our own popover controller, there is no clue (except in the video) that we have to remove the import + register from the bootstrap file.
Maybe could you just add the file so others don't search for this !
Thanks again !