Sulu and Symfony UX - part 2

Welcome to part 2 in this series. You can read part 1 here.

In part 2 we're going to take the sidebar (navigation) out of the Turbo Frame and handle the navigation active state using JavaScript (Stimulus). We'll also fix an issue because now our title tag isn't updated when we navigate, we'll be using Turbo Streams to handle this.

Navigation state

Alright, let's first reduce what we return in our Turbo Frame. Here are the updated page.html.twig and frame.html.twig templates:

page.html.twig

<section id="wrapper" data-controller="app">
    {{ include('includes/sidebar.html.twig') }}

    <main>
        <turbo-frame id="content" data-turbo-action="advance">
            {% block content %}{% endblock %}
        </turbo-frame>
    </main>

    {{ include('includes/footer.html.twig') }}
</section>

You can see we removed the body turbo-frame and instead introduced a content turbo-frame which only encapsulates the content.

frame.html.twig

{% block body %}
    <turbo-frame id="content" data-turbo-action="advance">
        {% block content %}{% endblock %}
    </turbo-frame>
{% endblock %}

Our frame template now only includes the content, without the sidebar and footer and thus reducing the amount of computations/queries done.

We will have to update our sidebar now, for two reasons:

  1. We want the anchor links to navigate the content turbo-frame, we will have to specify this since the anchors links themselves are no longer inside a turbo-frame, so it falls back to _top
  2. We want to add to update the active state when navigating, so we'll have to hook into the turbo:visit event and update the class of the respective anchor.

First, let's look at our sidebar.html.twig

sidebar.html.twig

<nav class="sidebar" data-controller="sidebar" data-action="turbo:visit@document->sidebar#updateActiveState">
    <!-- ... -->

    <ul>
        {% for item in sulu_navigation_root_flat('articles', 2) %}
            <li>
                <a href="{{ sulu_content_path(item.url, item.webspaceKey) }}"
                   title="{{ item.title }}"
                   class="{% if sulu_navigation_is_active(app.request.uri, item.url) %}active{% endif %}"
                   data-sidebar-target="link"
                   data-turbo-frame="content"
                >{{ item.title }}</a>
            </li>
        {% endfor %}
    </ul>

    <!-- ... -->
</nav>

As you can see, the <nav> element had data-controller="sidebar" to make sure it's a stimulus controller and there's also an data-action="turbo:visit@document->sidebar#updateActiveState" to listen to the turbo:visit event which is emitted on the document object.

On the anchor links you also see data-turbo-frame="content" to make sure the anchor link navigates the content turbo frame, and data-sidebar-target="link" just to make it easier to loop over these links in our stimulus controller (see Stimulus Targets).

Now let's have a look at our sidebar_controller.js

sidebar_controller.js

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['link']

    updateActiveState(event) {
        this.linkTargets.forEach((link) => {
            link.classList.remove('active');
            if (link.href === event.detail.url) {
                link.classList.add('active');
            }
        });
    }
}

Here we are looping over all the links, removing the active class if it has one, and matching the URLs to see if we need to add the active class. Simple and effective :).

Now one last thing we need to resolve, when you navigate now, the title tag doesn't update. This is because we only return a turbo-frame in our response, without a title tag.

In order to update the title tag we can use Turbo Streams.

Tubo Streams

Turbo Streams can be used to update, append, prepend and replace content in a container. When you return a turbo stream in a response, Turbo will parse this, remove it from the output and execute the instructions of the turbo stream. This sounds confusing, so let me show you how we can use this.

Let's first give the title tag an ID so we can reference it. We can do this by overriding the title block. A default Sulu base template will have this:

{% block meta %}
    {% include "@SuluWebsite/Extension/seo.html.twig" with {
        "seo": extension.seo|default([]),
        "content": content|default([]),
        "localizations": localizations|default([]),
        "shadowBaseLocale": shadowBaseLocale|default(),
    } %}
{% endblock %}

If we change this from an include to an embed, we can override the title block and add an id, without overriding the entire template.

{% block meta %}
    {% embed "@SuluWebsite/Extension/seo.html.twig" with {
        "seo": extension.seo|default([]),
        "content": content|default([]),
        "localizations": localizations|default([]),
        "shadowBaseLocale": shadowBaseLocale|default(),
    } %}
        {% block title %}<title id="page-title">{{ extension.seo.title|default(content.title|default()) }}</title>{% endblock %}
    {% endembed %}
{% endblock %}

Now that we've added the page-title id, we can add a Turbo Stream to our output in frame.html.twig.

{% block body %}
    <turbo-frame id="content" data-turbo-action="advance">
        <turbo-stream action="update" target="page-title">
            <template>{{ extension.seo.title|default(content.title|default()) }}</template>
        </turbo-stream>
        {% block content %}{% endblock %}
    </turbo-frame>
{% endblock %}

This turbo stream will update the title tag with the new title. And that's it!

I hope this helps you understand Turbo Frames, Turbo Streams and Stimulus a bit more.