Buy Access to Course
26.

Modal Twig Component

|

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

Today is a good day. Today we get to combine our modal system with Twig components to achieve a goal! I want to be able to quickly add a modal anywhere in our app.

Creating the Modal Component

Start in base.html.twig. All the way at the bottom, copy the modal markup. You can see... it's quite a bit: not something we want to copy and paste somewhere else:

97 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 51
<div
data-controller="modal"
data-action="turbo:before-cache@window->modal#close"
>
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80"
data-modal-target="dialog"
data-action="close->modal#close click->modal#clickOutside"
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame
id="modal"
data-modal-target="dynamicContent"
data-action="turbo:before-fetch-request->modal#showLoading"
class="aria-busy:opacity-50 transition-opacity"
>
{{ include('_frameSuccessStreams.html.twig', { frame: 'modal' }) }}
</turbo-frame>
</div>
</div>
</dialog>
<template data-modal-target="loadingTemplate">
<div class="bg-space-pattern bg-cover rounded-lg p-8">
<div class="space-y-2">
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
</div>
</div>
</template>
</div>
// ... lines 91 - 94
</body>
</html>

Copy, then delete it. Let's craft a Modal component, this time by hand. Create a new file in templates/components/ called Modal.html.twig, and paste:

<div
data-controller="modal"
data-action="turbo:before-cache@window->modal#close"
>
<dialog
class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] md:min-w-[50%] animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80"
data-modal-target="dialog"
data-action="close->modal#close click->modal#clickOutside"
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
<turbo-frame
id="modal"
data-modal-target="dynamicContent"
data-action="turbo:before-fetch-request->modal#showLoading"
class="aria-busy:opacity-50 transition-opacity"
>
{{ include('_frameSuccessStreams.html.twig', { frame: 'modal' }) }}
</turbo-frame>
</div>
</div>
</dialog>
<template data-modal-target="loadingTemplate">
<div class="bg-space-pattern bg-cover rounded-lg p-8">
<div class="space-y-2">
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
</div>
</div>
</template>
</div>

Like I said with the Button, we don't need a PHP class for a component. Because we don't have one, we call this an "anonymous component".

On top, render attributes... then add .defaults() so we can move these two attributes into that. Paste... then each of these needs a makeover to fit as Twig keys and values instead of HTML attributes:

42 lines | templates/components/Modal.html.twig
<div
{{ attributes.defaults({
'data-controller': 'modal',
'data-action': 'turbo:before-cache@window->modal#close',
}) }}
>
// ... lines 7 - 40
</div>

I like it! Over in base.html.twig, render this with <twig:Modal>. Easy!

Adding Blocks to the Component

However, look closer at Modal.html.twig: there are some things that shouldn't be here. For example, the <turbo-frame>! Not every modal needs a frame. A lot of times, we'll render a modal with simple, hardcoded content inside.

Copy this, and replace it with, of course, {% block content %} and {% endblock %}:

35 lines | templates/components/Modal.html.twig
<div
// ... lines 2 - 5
>
<dialog
// ... lines 8 - 10
>
<div class="flex grow p-5">
<div class="grow overflow-auto p-1">
{% block content %}{% endblock %}
</div>
</div>
</dialog>
// ... lines 18 - 33
</div>

In base.html.twig, paste the frame... and add a closing tag:

67 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 54
<twig:Modal>
<turbo-frame
id="modal"
data-modal-target="dynamicContent"
data-action="turbo:before-fetch-request->modal#showLoading"
class="aria-busy:opacity-50 transition-opacity"
>
{{ include('_frameSuccessStreams.html.twig', { frame: 'modal' }) }}
</turbo-frame>
</twig:Modal>
</body>
</html>

Let's keep going! The loading template down here? Yea, that's also something that specific to this one modal. If our modal is a hardcoded message, it won't need this at all!

Copy the loading div, delete, then around the <template> add: if block('loading_template'):

25 lines | templates/components/Modal.html.twig
<div
// ... lines 2 - 5
>
// ... lines 7 - 18
{% if block('loading_template') %}
<template data-modal-target="loadingTemplate">
{% block loading_template %}{% endblock %}
</template>
{% endif %}
</div>

So if we pass the block, render it inside the <template>.

Back in base.html.twig, anywhere, define that block. But instead of the normal {% block %} tag - which would work - when you're inside a Twig component, you can use a special <twig:block> syntax with name="loading_template". Then, paste:

82 lines | templates/base.html.twig
<!DOCTYPE html>
<html>
// ... lines 3 - 15
<body class="bg-black text-white font-mono">
// ... lines 17 - 54
<twig:Modal>
<turbo-frame
// ... lines 57 - 62
</turbo-frame>
<twig:block name="loading_template">
<div class="bg-space-pattern bg-cover rounded-lg p-8">
<div class="space-y-2">
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
<div class="h-4 bg-gray-700 rounded w-3/4 animate-pulse"></div>
<div class="h-4"></div>
<div class="h-4 bg-gray-700 rounded w-1/2 animate-pulse"></div>
</div>
</div>
</twig:block>
</twig:Modal>
</body>
</html>

We just moved around a lot of stuff. And yet... the existing modal still works! And now, we have a leaner, meaner modal component that we can reuse in other places.

Delete Modal with Custom Content

Let's do exactly that. I want to add a delete link on each row that, on click, opens a modal with a confirmation. Open _row.html.twig. Copy the edit link, paste, and call it delete:

19 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap">
// ... lines 6 - 11
<a
href="{{ path('app_voyage_edit', {'id': voyage.id}) }}"
class="ml-4 text-yellow-400 hover:text-yellow-600"
data-turbo-frame="modal"
>edit</a>
</td>
</tr>

To get this to work, one option is to create a new, standalone delete confirmation page, point to that and... done! The data-turbo-frame="modal" would load that page into the modal.

But since we've done that before, let's try something different. Delete the href, change this to a button, remove the data-turbo-frame attribute... and change the yellow colors to red:

17 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap">
// ... lines 6 - 11
<button
class="ml-4 text-red-400 hover:text-red-600"
>delete</button>
</td>
</tr>

Let's go check out the look. Nice!

Back over, I'll paste in a modal:

24 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap">
// ... lines 6 - 14
<twig:Modal>
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" xmlns="http://www.w3.org/2000/svg" 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 d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Delete this thrilling voyage???
</h3>
</twig:Modal>
</td>
</tr>

There's nothing special here. The big difference is, instead of a <turbo-frame>, the content we need is right here. When we refresh, every row now has a delete dialog inside of it. But that's totally okay! It's not open, so it's invisible.

Opening the Modal

Now for the tricky part. When we click this button, we need to open this modal. To make this happen, we need the button to physically live inside the data-controller="modal" element so that it can call the open action on the modal Stimulus controller.

To allow that, inside the modal template, add a new block called trigger, endblock:

27 lines | templates/components/Modal.html.twig
<div
// ... lines 2 - 5
>
{% block trigger %}{% endblock %}
// ... lines 8 - 25
</div>

Now, if you have a button that triggers the modal to open, you can put that right here! Over in _row.html.twig, copy the button, remove it, say <twig:block name="trigger"> and paste.

And because we're inside the modal, add data-action="modal#open":

29 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap flex">
// ... lines 6 - 12
<twig:Modal>
<twig:block name="trigger">
<button
class="ml-4 text-red-400 hover:text-red-600"
data-action="modal#open"
>delete</button>
</twig:block>
// ... lines 20 - 25
</twig:Modal>
</td>
</tr>

Let's try this! So excited! Refresh! The styling got weird. Before, we had three a tags, which are inline elements. Now we have two inline elements and a block element. So that is something that changes slightly, but it's an easy fix. Up on the <td>, add a flex class:

29 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap flex">
// ... lines 6 - 26
</td>
</tr>

And now... much better. Most importantly, when we hit Delete, the modal opens! However, you know me, I want this to be perfect. And I'm not happy with how big this is. When I open the edit form, it makes sense to be half the screen width. But when I open the delete, we should let it shrink down to the size of the content inside.

To do that, in this one case, I want to be pass a new flag called allowSmallWidth set to true:

29 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap flex">
// ... lines 6 - 12
<twig:Modal :allowSmallWidth="true">
// ... lines 14 - 25
</twig:Modal>
</td>
</tr>

I added this : because, if I pass allowSmallWidth="true", that will pass the string true. By adding a colon, this becomes Twig code, so that will pass the Boolean true. They would both work... but I like being stricter.

With the Button, we learned that if you want this to become a variable instead of an attribute, you can add a public property with that same name. And we could create a new Modal.php file.

But there's another way to convert from an attribute into a variable when using an anonymous component. At the top of Modal.html.twig, add a props tag that's special to Twig components. Add allowSmallWidth and default it to false:

28 lines | templates/components/Modal.html.twig
{% props allowSmallWidth=false %}
// ... lines 2 - 28

Cool, huh? Below, we want to make this min-width conditional. Say {{ allowSmallWidth }} - if that is true, render nothing, else render the md:min-w-[50%]:

28 lines | templates/components/Modal.html.twig
{% props allowSmallWidth=false %}
<div
// ... lines 3 - 6
>
// ... lines 8 - 9
<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"
// ... lines 12 - 13
>
// ... lines 15 - 19
</dialog>
// ... lines 21 - 26
</div>

Back on the page, the edit link still opens with half width... but that delete link, ah, it's nice and small! Now it deserves some real content! In _row.html.twig, after the <h3>, I'll add some styling... then I want a cancel button that closes the modal. For that, we can go old-school. Add a <form method="dialog">, and inside a <twig:Button> that says Cancel. And I want the button to look like a link, so add variant="link":

36 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap flex">
// ... lines 6 - 12
<twig:Modal :allowSmallWidth="true">
// ... lines 14 - 26
<div class="flex justify-between">
<form method="dialog">
<twig:Button variant="link">Cancel</twig:Button>
</form>
// ... line 31
</div>
</twig:Modal>
</td>
</tr>

That doesn't exist yet, so in the Button class, add it: variant and it just needs text-white:

24 lines | src/Twig/Components/Button.php
// ... lines 1 - 7
class Button
{
// ... lines 10 - 12
public function getVariantClasses(): string
{
return match ($this->variant) {
// ... lines 16 - 18
'link' => 'text-white',
// ... line 20
};
}
}

After the form, to render the delete button, include voyage/_delete_form.html.twig:

36 lines | templates/voyage/_row.html.twig
<tr class="even:bg-gray-700 odd:bg-gray-600" id="voyage-list-item-{{ voyage.id }}">
// ... lines 2 - 4
<td class="px-6 py-4 whitespace-nowrap flex">
// ... lines 6 - 12
<twig:Modal :allowSmallWidth="true">
// ... lines 14 - 26
<div class="flex justify-between">
<form method="dialog">
<twig:Button variant="link">Cancel</twig:Button>
</form>
{{ include('voyage/_delete_form.html.twig') }}
</div>
</twig:Modal>
</td>
</tr>

Oh, and that template has a built-in confirm. Delete that because we have something way nicer now.

Moment of truth! Refresh and delete. It looks great! Cancel closes the modal... and deleting works. And it shouldn't be a surprise that it works. The delete form is not inside a <turbo-frame>. So when we click delete, that triggers a normal form submit that redirects and causes a normal full page navigation.

Hiding Search Options in a Modal

Ok, I know this is already a full day, but I really want to use the modal in one more spot. And it's a cool use-case.

On the homepage, in my PHP & Symfony code, I won't show it, but I already added logic to filter this list by the planets. I only didn't add any planet checkboxes to the page because... we don't really have space for them.

So here's my idea: add a link here that opens a modal that holds the extra filtering options.

Open up main/homepage.html.twig and find that input. Start by adding a <div class="w-1/3 flex">... add the closing on the other side of the input... then remove w-1/3 from the input. We're making space for that link:

149 lines | templates/main/homepage.html.twig
// ... lines 1 - 27
{% block body %}
<div class="flex">
// ... lines 30 - 36
<section class="flex-1 ml-10">
<form
method="GET"
action="{{ path('app_homepage') }}"
class="mb-6 flex justify-between"
data-controller="autosubmit"
data-turbo-frame="voyage-list"
>
<div class="w-1/3 flex">
<input
type="search"
name="query"
value="{{ app.request.query.get('query') }}"
aria-label="Search voyages"
placeholder="Search voyages"
class="px-4 py-2 rounded bg-gray-800 text-white placeholder-gray-400"
data-action="autosubmit#debouncedSubmit"
autofocus
>
</div>
// ... lines 57 - 59
</form>
// ... lines 61 - 145
</section>
</div>
{% endblock %}

But I'll paste in a full modal:

169 lines | templates/main/homepage.html.twig
// ... lines 1 - 27
{% block body %}
<div class="flex">
// ... lines 30 - 36
<section class="flex-1 ml-10">
<form
method="GET"
action="{{ path('app_homepage') }}"
class="mb-6 flex justify-between"
data-controller="autosubmit"
data-turbo-frame="voyage-list"
>
<div class="w-1/3 flex">
<input
type="search"
name="query"
value="{{ app.request.query.get('query') }}"
aria-label="Search voyages"
placeholder="Search voyages"
class="px-4 py-2 rounded bg-gray-800 text-white placeholder-gray-400"
data-action="autosubmit#debouncedSubmit"
autofocus
>
<twig:Modal>
<twig:block name="trigger">
<twig:Button
variant="link"
type="button"
data-action="modal#open"
>Options</twig:Button>
</twig:block>
<h3 class="text-white text-lg font-semibold mb-2">Search Options</h3>
<hr class="mb-4">
<div class="flex justify-end">
<twig:Button
variant="success"
data-action="modal#close"
>See Results</twig:Button>
</div>
</twig:Modal>
</div>
// ... lines 77 - 79
</form>
// ... lines 81 - 165
</section>
</div>
{% endblock %}

This will be invisible except for the trigger. So we basically just added a button that says "options". But it's already set up to open the modal. Inside, to start, we have an h3 and a <twig:Button> that closes the modal.

Adding a Modal Close Button

But the result when I click options... is nice! Though, it needs a close button on the upper right. We could add it to just this modal... but it might be nice if it were an option in the modal component.

Let's do it! In Modal.html.twig, add one more prop called closeButton defaulting to false:

37 lines | templates/components/Modal.html.twig
{% props allowSmallWidth=false, closeButton=false %}
// ... lines 2 - 37

If that's true, at the end of the dialog, I'll paste in a close button:

37 lines | templates/components/Modal.html.twig
{% props allowSmallWidth=false, closeButton=false %}
<div
// ... lines 3 - 6
>
// ... lines 8 - 9
<dialog
// ... lines 11 - 13
>
// ... lines 15 - 19
{% if closeButton %}
<button
class="absolute right-4 top-3 text-white flex items-center opacity-70 transition-opacity hover:opacity-100"
data-action="modal#close"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" 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 d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>
</button>
{% endif %}
</dialog>
// ... lines 30 - 35
</div>

Again, nothing special here: some absolute styling, an icon... and the important part: it calls modal#close.

In homepage.html.twig find that modal and add closeButton="true"... but with the : like last time:

169 lines | templates/main/homepage.html.twig
// ... lines 1 - 27
{% block body %}
<div class="flex">
// ... lines 30 - 36
<section class="flex-1 ml-10">
<form
// ... lines 39 - 43
>
<div class="w-1/3 flex">
// ... lines 46 - 56
<twig:Modal :closeButton="true">
// ... lines 58 - 74
</twig:Modal>
</div>
// ... lines 77 - 79
</form>
// ... lines 81 - 165
</section>
</div>
{% endblock %}

Let's check it out! I love that!

Finally, let's frost this cake. Near the bottom of the content, I'll paste in the planet checkboxes:

184 lines | templates/main/homepage.html.twig
// ... lines 1 - 27
{% block body %}
<div class="flex">
// ... lines 30 - 36
<section class="flex-1 ml-10">
<form
// ... lines 39 - 43
>
<div class="w-1/3 flex">
// ... lines 46 - 56
<twig:Modal :closeButton="true">
// ... lines 58 - 65
<h3 class="text-white text-lg font-semibold mb-2">Search Options</h3>
<hr class="mb-4">
<h4 class="text-white text-sm font-semibold mb-2">
Planets
</h4>
{% for planet in planets %}
<div class="flex items-center mb-4">
<input
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
name="planets[]"
value="{{ planet.id }}"
id="planet-search-{{ planet.id }}"
{{ planet.id in searchPlanets ? 'checked' : '' }}
>
<label for="planet-search-{{ planet.id }}" class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">{{ planet.name }}</label>
</div>
{% endfor %}
// ... lines 84 - 89
</twig:Modal>
</div>
// ... lines 92 - 94
</form>
// ... lines 96 - 180
</section>
</div>
{% endblock %}

This is more boring code! I loop over the planets and render input check boxes. My Symfony controller is already set up to read the planets parameter and filter the query.

Final test. Open it up. Lovely! Now watch: click a few. When I press "See Results", the table should update. Boom. It did!

But the coolest part is... how this worked! Think about it: I click this button... and the table reloads. That means the form is submitting. But... what caused that? Look at the button: there's no code to submit the form. So what's going on?

Remember: this button, the planet checkboxes and this modal physically live inside the <form> element. And what happens when you press a button that lives inside a form? It submits the form! We run the modal#close, but we also allow the browser to do the default behavior: submitting the form. This is ancient alien technology at work!

On the close button, I was a bit sneaky. When I added that, I included a type="button". That tells the browser to not submit any form that it might be inside. That's why when we click "X", nothing updates. But when we click "see results", the form submits.

Woh! Best day ever! Tomorrow, it's time to look at Live components, where we take Twig components and let them re-render on the page via Ajax as the user interacts when them.