Buy Access to Course
27.

Live Components

|

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

Happy Day 27 of Last Stack! We've accomplished a lot during the first 26 days with just three letters of LAST Stack: Asset Mapper, Stimulus, and Turbo. Today we crack the code on the L of LAST Stack: Live components. Live components let us take a Twig component... then re-render it via Ajax as the user interacts with it.

Our goal is this global search. When I click nothing happens! What I want to do is open a modal with a search box that, as we type, loads a live search.

Opening the Search Modal

Start inside templates/base.html.twig. Search for search! Perfect: this is the fake search input we just saw. Add a <twig:Modal> with :closeButton="true", then a <twig:block> with name="trigger". Put the fake input inside that. To make this open the modal, we need data-action="modal#open":

87 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true">
<twig:block name="trigger">
<div
class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer"
data-action="modal#open"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>
<span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span>
</div>
</twig:block>
</twig:Modal>
</nav>
</header>
// ... lines 45 - 54
</div>
// ... lines 56 - 84
</body>
</html>

Cool! If we refresh, nothing changes: the only visible part of the modal is the trigger. For the modal content, after the Twig block, I'll paste in a div:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true">
<twig:block name="trigger">
<div
class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer"
data-action="modal#open"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>
<span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span>
</div>
</twig:block>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>
</div>
<input
type="search"
aria-label="Search site"
placeholder="Search for anything"
class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none"
/>
</div>
</twig:Modal>
</nav>
</header>
// ... lines 57 - 66
</div>
// ... lines 68 - 96
</body>
</html>

Nothing special here: just a real search input.

Back at the browser, when I click... uh oh. Nothing happens! Debugging always starts in the console. No errors, but when I click, watch: there's no log that says that the action is being triggered. We've got something wrong with that and maybe you saw my mistake? We added the data-action to a div. Unlike a button or a form, Stimulus doesn't have a default event for a div. Add click->:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true">
<twig:block name="trigger">
<div
// ... line 35
data-action="click->modal#open"
>
// ... lines 38 - 39
</div>
</twig:block>
// ... lines 42 - 53
</twig:Modal>
</nav>
</header>
// ... lines 57 - 66
</div>
// ... lines 68 - 96
</body>
</html>

And now... got it!

Oh, and it auto-focused the input! That's.... just a feature of dialogs! They work like a mini page within a page: it autofocuses the first tabbable element... or you can use the normal autofocus attribute for more control. It just works how you want it to.

Anyway, I'm picky: this is more padding than I want. But that's ok! We can make our Modal component just a bit more flexible. In components/Modal.html.twig, the extra padding is this p-5. On top, add a third prop: padding='p-5'. Copy that. And down here, render padding:

37 lines | templates/components/Modal.html.twig
{% props allowSmallWidth=false, closeButton=false, padding="p-5" %}
<div
// ... lines 3 - 6
>
// ... lines 8 - 9
<dialog
// ... lines 11 - 13
>
<div class="flex grow {{ padding }}">
// ... lines 16 - 18
</div>
// ... lines 20 - 28
</dialog>
// ... lines 30 - 35
</div>

Over in base.html.twig, on the modal, add padding equals empty quotes:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true" padding="">
// ... lines 33 - 53
</twig:Modal>
</nav>
</header>
// ... lines 57 - 66
</div>
// ... lines 68 - 96
</body>
</html>

Let's check it! And... much neater.

Creating the Twig Component

To bring the results to life, we could repeat the data-tables setup from the homepage. We could add a <turbo-frame> with the results right here and make the input autosubmit into that frame.

Another option is to build this with a live component. But before we talk about that, let's first organize the modal contents into a twig component.

In templates/components/, create a new file called SearchSite.html.twig. I'll add a div with {{ attributes }}. Then go steal the entire body of the modal, and paste it here:

<div {{ attributes }}>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>
</div>
<input
type="search"
aria-label="Search site"
placeholder="Search for anything"
class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none"
/>
</div>
</div>

Over in base.html.twig, it's easy, right? <twig:SearchSite /> and done:

89 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true" padding="">
<twig:block name="trigger">
// ... lines 34 - 40
</twig:block>
<twig:SearchSite />
</twig:Modal>
</nav>
</header>
// ... lines 47 - 56
</div>
// ... lines 58 - 86
</body>
</html>

At the browser, we get the same result.

Fetching Data with a Twig Component

The site search is really going to be a voyage search. To render the results, we have two options. First, we could... somehow get the voyages that we want to show inside of base.html.twig and pass them into SearchSite as a prop. But... fetching data from our base layout is tricky... we'd probably need a custom Twig function.

The second option is to leverage our Twig component! One of its superpowers is the ability to fetch its own data: to be standalone.

To do that, this Twig component now needs a PHP class. In src/Twig/Components/, create a new PHP class called SearchSite. The only thing that this needs to be recognized as a Twig component is an attribute: #[AsTwigComponent]:

24 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 2
namespace App\Twig\Components;
// ... lines 4 - 6
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class SearchSite
{
// ... lines 12 - 22
}

This is exactly what we saw inside the Button class. A few days ago, I quickly mentioned that Twig component classes are services, which means we can autowire other services like VoyageRepository, $voyageRepository:

24 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 5
use App\Repository\VoyageRepository;
// ... lines 7 - 8
#[AsTwigComponent]
class SearchSite
{
public function __construct(private VoyageRepository $voyageRepository)
{
}
// ... lines 15 - 22
}

To provide the data to the template, create a new method called voyages()! This will return an array... which will really be an array of Voyage[]. Inside return $this->voyageRepository->findBySearch(). That's the same method we're using on the homepage. Pass null, an empty array, and limit to 10 results:

24 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 4
use App\Entity\Voyage;
// ... lines 6 - 8
#[AsTwigComponent]
class SearchSite
{
// ... lines 12 - 15
/**
* @return Voyage[]
*/
public function voyages(): array
{
return $this->voyageRepository->findBySearch(null, [], 10);
}
}

The search query isn't dynamic yet, but we do now have a voyages() method that we can use in the template. I'll start with a bit of styling, then it's normal twig code: {% for voyage in this - that's our component object - .voyages. Add endfor, and in the middle, I'll paste that in:

31 lines | templates/components/SearchSite.html.twig
<div {{ attributes }}>
// ... lines 2 - 13
<div class="text-white py-2 rounded-lg">
{% for voyage in this.voyages %}
<a href="{{ path('app_voyage_show', { id: voyage.id }) }}" class="flex items-center space-x-4 px-4 p-2 hover:bg-gray-700 cursor-pointer">
<img
class="h-10 w-10 rounded-full"
src="{{ asset('images/'~voyage.planet.imageFilename) }}"
alt="{{ voyage.planet.name }} planet"
>
<div>
<p class="text-sm font-medium text-white">{{ voyage.purpose }}</p>
<p class="text-xs text-gray-400">{{ voyage.leaveAt|ago }}</p>
</div>
</a>
{% endfor %}
</div>
</div>

Nothing special: an anchor tag, an image tag, and some info.

Let's try it. Open! Sweet! Though, of course, when we type, nothing updates! Lame!

Installing & Upgrading to a LiveComponent

This is where live components comes in handy. So let's get it installed!

composer require symfony/ux-live-component

To upgrade our Twig component to a Live component, we only need to do two things. First, it's #[AsLiveComponent]. And second, use DefaultActionTrait:

27 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 6
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class SearchSite
{
use DefaultActionTrait;
// ... lines 14 - 25
}

That's an internal detail... but needed.

So far, nothing will change. It's still a Twig component... and we haven't added any live component superpowers.

Adding a Writable Prop

One of the key concepts with a Live Component is that you can add a property and allow the user to change that property from the frontend. For example, create a public string $query to represent the search string:

31 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 10
#[AsLiveComponent]
class SearchSite
{
// ... lines 14 - 16
public string $query = '';
// ... lines 18 - 29
}

Below, use that when we call the repository:

31 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 10
#[AsLiveComponent]
class SearchSite
{
// ... lines 14 - 16
public string $query = '';
// ... lines 18 - 25
public function voyages(): array
{
return $this->voyageRepository->findBySearch($this->query, [], 10);
}
}

To allow the user to modify this property, we need to give it an attribute: #[LiveProp] with writeable: true:

31 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 7
use Symfony\UX\LiveComponent\Attribute\LiveProp;
// ... lines 9 - 10
#[AsLiveComponent]
class SearchSite
{
// ... lines 14 - 15
#[LiveProp(writable: true)]
public string $query = '';
// ... lines 18 - 29
}

Finally, to bind this property to the input - so that the query property changes as the user types - add data-model="query":

32 lines | templates/components/SearchSite.html.twig
<div {{ attributes }}>
<div class="relative">
// ... lines 3 - 5
<input
type="search"
data-model="query"
// ... lines 9 - 11
/>
</div>
// ... lines 14 - 30
</div>

That's it! Check out the result. We start with everything, but when we type... it filters! It even has built-in debouncing.

Backstage, it makes an AJAX request, populates the query property with this string, re-renders the Twig template and pops it right here.

Now that this is working, I don't think we need to load all the results at first. And, look, it's just PHP, so this is easy. If not $this->query, then return an empty array:

35 lines | src/Twig/Components/SearchSite.php
// ... lines 1 - 10
#[AsLiveComponent]
class SearchSite
{
// ... lines 14 - 25
public function voyages(): array
{
if (!$this->query) {
return [];
}
// ... lines 31 - 32
}
}

And in SearchSite.html.twig, add an if statement around this: if this.voyages is not empty, render that... with the endif at the bottom:

34 lines | templates/components/SearchSite.html.twig
<div {{ attributes }}>
// ... lines 2 - 14
{% if this.voyages is not empty %}
<div class="text-white py-2 rounded-lg">
{% for voyage in this.voyages %}
// ... lines 18 - 29
{% endfor %}
</div>
{% endif %}
</div>

For those of you that are sticklers for details, yes, with this.voyages, we're calling the method twice. But there are ways around this - and my favorite is called #[ExposeInTemplate]. I won't show it, but it's a quick change.

Fixing the Modal to the Top

So, I'm happy! But, this isn't perfect... and I want that. One thing that bothers me is the position: it looks low when it's empty. And as we type, it jumps around. That's the native <dialog> positioning, which is normally great, but not when our content is changing. So in this one case, let's fix the position near the top.

In Modal.html.twig, add one last piece of flexibility to our component: a prop called fixedTop = false:

42 lines | templates/components/Modal.html.twig
{% props
// ... lines 2 - 3
padding="p-5",
fixedTop=false
%}
// ... lines 7 - 42

Then, at the end of the dialog classes, if fixedTop, render mt-14 to set the top margin. Else do nothing:

42 lines | templates/components/Modal.html.twig
// ... lines 1 - 6
<div
// ... lines 8 - 11
>
// ... lines 13 - 14
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] {{ allowSmallWidth ? '' : 'md:min-w-[50%] ' }}animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80{{ fixedTop ? ' mt-14' : '' }}"
// ... lines 17 - 18
>
// ... lines 20 - 33
</dialog>
// ... lines 35 - 40
</div>

Over in base.html.twig, on the modal... it's time to break this onto multiple lines. Then pass :fixedTop="true":

89 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true" padding="" :fixedTop="true">
// ... lines 33 - 43
</twig:Modal>
</nav>
</header>
// ... lines 47 - 56
</div>
// ... lines 58 - 86
</body>
</html>

And now, ah. Much nicer and no more jumping around.

Setting the Search as Turbo Permanent

What else? Pressing up and down on my keyboard to go through the results is needed, though I'll save that for another time. But watch this. If I search, then click out and navigate to another page, not surprisingly, when we open the search modal, it's empty. It would be really cool if it remembered the search.

And we can do that with a trick from Turbo. In base.html.twig, on the modal, add data-turbo-permanent:

89 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal :closeButton="true" padding="" :fixedTop="true" data-turbo-permanent id="global-search-modal">
// ... lines 33 - 43
</twig:Modal>
</nav>
</header>
// ... lines 47 - 56
</div>
// ... lines 58 - 86
</body>
</html>

That tells Turbo to keep this on the page when it navigates. When you use this, it needs an id.

Let's see how this feels. Open the search, type something, click off, go to the homepage and open it again. So darn cool!

Opening Search on Ctrl+K

Ok, final thing! Up here, I'm advertising that you open the search with a keyboard shortcut. That's a lie! But we can add this... and, again, it's easy.

On the modal, add a data-action. Stimulus has built-in support for doing things on keydown. So we can say keydown., then whatever key we want, like K. Or in this case, Ctrl+K.

If we stopped now, this would only trigger if the modal were focused and then someone pressed Ctrl+K. That's... not going to happen. Instead, we want this to open no matter what is focused. We want a global listener. Do that by adding @window.

Copy that, add a space, paste and also trigger on meta+k. Meta is the command key on a Mac:

96 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
<div class="container mx-auto min-h-screen flex flex-col">
<header class="my-8 px-4">
<nav class="flex items-center justify-between mb-4">
// ... lines 20 - 31
<twig:Modal
// ... lines 33 - 37
data-action="keydown.meta+k@window->modal#open keydown.ctrl+k@window->modal#open"
>
// ... lines 40 - 50
</twig:Modal>
</nav>
</header>
// ... lines 54 - 63
</div>
// ... lines 65 - 93
</body>
</html>

Testing time! I'll move over and... keyboard! I love it! Done!

Lazy-Loading Live Component

Oh, and Live Components can also be loaded lazily via AJAX! Watch: add a defer attribute. When we refresh, we won't see any difference... because that component is hidden on page load anyway. But in reality, it just loaded empty then immediately made an Ajax call to load for real. We can see that down here in the web debug toolbar! This is a great way to defer loading something heavy, so it doesn't slow down your page.

It's not particularly useful in our case because the SearchSite component is so lightweight, so I'll remove it.

Tomorrow, we'll spend one more day with Live Components - this time to give a form real-time-validation superpowers and solve the age-old pesky problem of dynamic or dependent form fields.