Drupal 8 Twig: add custom CSS classes to menus (based on menu name)

2016. Apr. 30. · 8 min read
Twig templates of Drupal 8 make our life much easier when we want to customize the HTML output. But when the goal is to change a Drupal 8 menu we have to use the menu.html.twig template which is not the most friendly one and it’s customization can be tricky because of several reasons. So I wanna show you how I did it.

Goal

Change the HTML output from this…

<ul>
  <li>
    <a href="/" data-drupal-link-system-path="<front>">Frontpage</a>
  </li>
  <li>
    <a href="/about" data-drupal-link-system-path="node/1">About us</a>
    <ul>
      <li>
        <a class="is-active" href="/team" data-drupal-link-system-path="node/2">
          Team
        </a>
      </li>
    </ul>
  </li>
</ul>

…to this…

<ul class="c-menu-main">                                       
  <li class="c-menu-main__item">
    <a href="/" class="c-menu-main__link" data-drupal-link-system-path="<front>">
      Frontpage
    </a>
  </li>
  <li class="c-menu-main__item c-menu-main__item--expanded c-menu-main__item--active-trail">
    <a href="/about" class="c-menu-main__link" data-drupal-link-system-path="node/1">
      About us
    </a>
    <ul class="c-menu-main__submenu">
      <li class="c-menu-main__item c-menu-main__item--active-trail">
        <a href="/team" class="c-menu-main__link is-active" data-drupal-link-system-path="node/2">
          Our team
        </a>
      </li>
    </ul>
  </li>
</ul>

…so we can keep our CSS specificity low and our CSS component easy to write and maintain.

Let’s start it!

When you turn on Drupal’s Twig debug mode you can see template name suggestions in the generated HTML of your site like this:

<!-- THEME DEBUG -->
<!-- THEME HOOK: 'menu__main' -->
<!-- FILE NAME SUGGESTIONS:
   * menu--main.html.twig
   x menu.html.twig
-->
<!-- BEGIN OUTPUT from 'core/themes/stable/templates/navigation/menu.html.twig' -->

This means that if you want to change the HTML of your main menu you can save a copy of menu.html.twig into the templates folder of your theme naming it menu–main.html.twig and then customize it.

But if you have to deal with more menus, creating a custom template for all of them can be daunting: creating/maintaining more templates means more time and more work (and more possible bugs). What if we could use only one template and generate custom CSS classes for every menu automatically?

You can read at the beginning of the template file that available variables include menu_name which is “the machine name of the menu”.

“Great! Although I do not understand completely the content of this long template it is easy to locate the ul and li elements and add menu_name as a class to them. I just have to write the variable between double curly braces!” — I thought like this and did it immediately. And… nothing happened! Boo! What the heck?! What did I wrong?!

I started to look for the answer and I learned from this drupal.org issue (and from its parent issue) that menu_name needs to get past into the menu_links macro because it is only globally available like items.

“OK. This long complicated code in the template is — mostly — a macro. Following the information in the template comments and in the issues let me know that macros are similar to functions in PHP. And what to do now?” — I wondered.

Step 1: add CSS class to the top-level ul element

This is the aforementioned code from the original menu.html.twig template.

{% import _self as menus %}

{#
  We call a macro which calls itself to render the full tree.
  @see https://twig.symfony.com/doc/1.x/tags/macro.html
#}
{{ menus.menu_links(items, attributes, 0) }}

{% macro menu_links(items, attributes, menu_level) %}
  {% import _self as menus %}
  {% if items %}
    {% if menu_level == 0 %}
      <ul{{ attributes }}>
    {% else %}
      <ul>
    {% endif %}
    {% for item in items %}
      <li{{ item.attributes }}>
        {{ link(item.title, item.url) }}
        {% if item.below %}
          {{ menus.menu_links(item.below, attributes, menu_level + 1) }}
        {% endif %}
      </li>
    {% endfor %}
    </ul>
  {% endif %}
{% endmacro %}

To have the desired class on the top-level ul element we have to do three things. First, we have to pass menu_name as an argument to the macro at the top level.

{{ menus.menu_links(items, attributes, 0, menu_name) }}

{% macro menu_links(items, attributes, menu_level, menu_name) %}

Then we create the class name from menu_name in a new variable. (The machine name of the main menu in Drupal 8 is simply “main”. It is “very creative” and makes it “very easy” to find out what component it is… 😄 That is why I add the static text “menu” into the class name here.)

{{ menus.menu_links(items, attributes, 0, menu_name) }}

{% macro menu_links(items, attributes, menu_level, menu_name) %}
  {% import _self as menus %}
  {%
    set menu_classes = [
      'c-menu-' ~ menu_name|clean_class,
    ]
  %}

At last, we add the new CSS class to the top-level ul element.

{{ menus.menu_links(items, attributes, 0, menu_name) }}

{% macro menu_links(items, attributes, menu_level, menu_name) %}
  {% import _self as menus %}
  {%
    set menu_classes = [
      'c-menu-' ~ menu_name|clean_class,
    ]
  %}
  {% if items %}
    {% if menu_level == 0 %}
      <ul{{ attributes.addClass(menu_classes) }}>

Step 2: add CSS class to ul elements below the top level

To have a CSS class on all sub-level ul elements we follow the second and third step from above. But we also have to remove the top-level class because it leaks down.

{{ menus.menu_links(items, attributes, 0, menu_name) }}

{% macro menu_links(items, attributes, menu_level, menu_name) %}
  {% import _self as menus %}
  {%
    set menu_classes = [
      'o-menu',
      'c-menu-' ~ menu_name|clean_class,
    ]
  %}
  {%
    set submenu_classes = [
      'o-menu',
      'c-menu-' ~ menu_name|clean_class ~ '__submenu',
    ]
  %}
  {% if items %}
    {% if menu_level == 0 %}
      <ul{{ attributes.addClass(menu_classes) }}>
    {% else %}
      <ul{{ attributes.removeClass(menu_classes).addClass(submenu_classes) }}>
    {% endif %}

(Be sure to do removing before adding! Otherwise, those class names which are the same in both in the added and in the removed array — o-menu in this example — will be removed as well.)

Step 3: add CSS class to menu items (li elements)

Nothing new is here. We know what to do. 😎

(Well. Almost. A small difference is that instead of creating only one class we add three more — based on the status of the menu item. The same thing can be found in Classy theme.)

{% for item in items %}
  {%
    set item_classes = [
      'c-menu-' ~ menu_name|clean_class ~ '__item',
      item.is_expanded ? 'c-menu-' ~ menu_name|clean_class ~ '__item--expanded',
      item.is_collapsed ? 'c-menu-' ~ menu_name|clean_class ~ '__item--collapsed',
      item.in_active_trail ? 'c-menu-' ~ menu_name|clean_class ~ '__item--active-trail',
    ]
  %}
  <li{{ item.attributes.addClass(item_classes) }}>

This is a little bit trickier because — as you can see — there are no anchor tags in the template. But there is the link() function instead. We have to add the class to this.

We also have to remove the menu item classes because they are leaking.

And we have to pass menu_name as an argument to the macro again.

{# Create CSS class #}
{%
  set link_classes = [
    'c-menu-' ~ menu_name|clean_class ~ '__link',
  ]
%}
<li{{ item.attributes.addClass(item_classes) }}>
  {# In link function add link class and remove leaking item classes #}
  {{
    link(
      item.title,
      item.url,
      item.attributes.addClass(link_classes).removeClass(item_classes)
    )
  }}
  {% if item.below %}
    {# Pass menu_name as an argument to the macro #}
    {{ menus.menu_links(item.below, attributes, menu_level + 1, menu_name) }}
  {% endif %}
</li>

We’re done

Here is our full custom template.

{#
/**
 * @file
 * Theme override to display a menu.
 */
#}
{% import _self as menus %}

{#
  We call a macro which calls itself to render the full tree.
  @see https://twig.symfony.com/doc/1.x/tags/macro.html
  1. We use menu_name (see above) to create a CSS class name from it.
  See https://www.drupal.org/node/2649076
#}
{{ menus.menu_links(items, attributes, 0, menu_name) }} {# 1. #}

{% macro menu_links(items, attributes, menu_level, menu_name) %} {# 1. #}
  {% import _self as menus %}
  {# 1. #}
  {%
    set menu_classes = [
      'o-menu',
      'c-menu-' ~ menu_name|clean_class,
    ]
  %}
  {# 1. #}
  {%
    set submenu_classes = [
      'o-menu',
      'c-menu-' ~ menu_name|clean_class ~ '__submenu',
    ]
  %}
  {% if items %}
    {% if menu_level == 0 %}
      <ul{{ attributes.addClass(menu_classes) }}> {# 1. #}
    {% else %}
      <ul{{ attributes.removeClass(menu_classes).addClass(submenu_classes) }}> {# 1. #}
    {% endif %}
    {% for item in items %}
      {# 1. #}
      {%
        set item_classes = [
          'c-menu-' ~ menu_name|clean_class ~ '__item',
          item.is_expanded ? 'c-menu-' ~ menu_name|clean_class ~ '__item--expanded',
          item.is_collapsed ? 'c-menu-' ~ menu_name|clean_class ~ '__item--collapsed',
          item.in_active_trail ? 'c-menu-' ~ menu_name|clean_class ~ '__item--active-trail',
        ]
      %}
      {# 1. #}
      {%
        set link_classes = [
          'c-menu-' ~ menu_name|clean_class ~ '__link',
        ]
      %}
      <li{{ item.attributes.addClass(item_classes) }}>{# 1. #}
        {# 1. #}
        {{
          link(
            item.title,
            item.url,
            item.attributes.removeClass(item_classes).addClass(link_classes)
          )
        }}
        {% if item.below %}
          {{ menus.menu_links(item.below, attributes, menu_level + 1, menu_name) }} {# 1. #}
        {% endif %}
      </li>
    {% endfor %}
    </ul>
  {% endif %}
{% endmacro %}

You can check how I used exactly the same solution when I contributed to the development of Drupal core’s Umami demo installation profile!

Something else yet?

Notice that this thing works fine if you have only one instance of a menu. However you may face a problem if you have more instances of the same menu and — obviously — you want to style them differently (eg. main menu top-level items in the header and main menu sub-level items in the sidebar).

In this second case, you may override the styling by adding one more selector from a parent HTML element and increasing CSS specificity. This is not recommended!

Better to create another template with a custom name based on the attributions of the Drupal block which contains the menu.

Another thing that if you compare the HTML output for menu items and menu links (at the top of this post) you may notice that the management of states is different. Menu items use BEM modifier classes but menu link has .is-active state class (SMACSS method).

It would be easy to modify the classes of menu items but it would not fit BEM. Then I should change the class on the menu link. OK. This should be in a follow-up update of this post because — to tell the truth — I do not know yet where that class is coming from and how to change it.

Is there any simpler solution?

If it seems to be too complicated to you can install the Simplify Menu module which lets you write a simpler template. Thanks for the tip to Mario Hernandez!