Twig templates of Drupal 8 makes 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. That's I wanna show you how I did it.


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 want to 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. What if we could use only one template and generate custom CSS classes for every menu automatically?
menu_name to the rescue
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 completely understand 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 the issues made me realize 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.
It is the code, as mentioned earlier, from the original menu.html.twig template.
{% import _self as menus %} {# We call a macro which calls itself to render the full tree. @see http://twig.sensiolabs.org/doc/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.
{{ 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 steps 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 %}
Step 3: add CSS class to menu items (“li” elements)
Nothing new is here. We know what to do. :)
(Well. Almost. The 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) }}>
Step 4: add CSS class to menu links (“a” elements)
It 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 http://twig.sensiolabs.org/doc/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 %}
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 (e.g., 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. It is not recommended!
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 the 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. It should be in a follow-up update of this post because I do not know yet where that class is coming from and how to change it.
(If you have a solution to this or have any other recommendation to make the code above better or the explanation more accurate, please let me know and write it in a comment!)
Oszd meg ismerőseiddel!