Les hooks des menus de navigation de WordPress

Il y a quelque temps maintenant, j’ai rédigé un tutoriel pour expliquer comment créer et utiliser les Walkers personnalisés, ces classes qui permettent de redéfinir le comportement de certains éléments de WordPress tels que les menus de navigation.

J’aime utiliser les Walkers car cela offre énormément de possibilités mais cela a aussi un inconvénient : il faut les tenir à jour.
Les customs walkers copient une partie des fonctions du core de WordPress, et donc si l’on veut tirer parti des dernières évolutions du code apporté au walkers originels du CMS, il faudra, de temps en temps, les mettre à jour.

Je précise cependant qu’il n’y a pas de risque de voir son code ne plus fonctionner du jour au lendemain ; c’est juste qu’au fil des années, votre code ne sera peut-être plus adapté pour jouer avec les fonctionnalités les plus récentes de WordPress.

Bref, tout cela pour dire que le précédent article montrait comment personnaliser les menus de navigation (wp_nav_menu) grâce aux Walkers, aujourd’hui j’ai envie de vous montrer tout ce qu’il est possible de faire en utilisant les quelques hooks de ces mêmes menus.

nav menu hooks
Personnaliser les menus grâce aux hooks

Vous allez voir que l’on peut faire exactement la même chose (et même davantage), tout en n’ayant pas ce risque d’obsolescence 🙂

Let’s Go !

Modifier les classes des éléments de menus

C’est ici un des filtres les plus connus et utilisé du wp_nav_menunav_menu_css_class est appliqué aux classes d’un élément de menu, avant que celui-ci ne soit retourné, dans Walker_Nav_Menu::start_el().

On peut donc l’utiliser pour nettoyer les classes que nous n’utiliserons pas en css, ou en ajouter de nouvelles qui nous manqueraient.
Ce hook filter dispose de trois arguments : le tableau des classes à filtrer, l’élément sous forme d’objet php, et les paramètres passés à la fonction wp_nav_menu.

Voici un exemple d’utilisation :

<?php
/**
 * ajout de classes
 */
add_action( 'nav_menu_css_class', 'menu_item_classes', 10, 3 );
function menu_item_classes( $classes, $item, $args ) {
    // Gardons seulement les classes qui nous intéressent
    $classes = array_intersect( $classes, array( 
                               'menu-item', 
                               'current-menu-item', 
                               'current-menu-parent', 
                               'current-menu-ancestor', 
                               'menu-item-has-children' 
                               ) );
    // Ajoutons la classe pour désigner les éléments vides
    if ( "#" == $item->url ) {
        $classes[] = 'empty-item';
    }
    // Si nous sommes sur une page single d'un CPT (ici my_cpt)...
    // ... Ajoutons la classe current-menu-parent sur l'item correspondant à son archive
    if ( is_singular( 'my_cpt' ) && get_post_type_archive_link( 'my_cpt' ) == $item->url ) {
        $classes[] = 'current-menu-ancestor';
    }
    return $classes;
}
  • ligne 8, je demande de ne conserver que les classes que j’utilise en CSS
  • ligne 16, j’ajoute une classe « empty-item » si l’élément ne contient qu’un lien vide
  • ligne 21, si l’élément courant est une archive de custom post type, et que l’on est justement sur la page singulière d’une entrée de ce type de contenu, on ajoute la classe « current-menu-ancestor »

Je vous laisse donc jouer avec ce hook pour voir tout ce qu’il permet.

Modifier la balise des liens « vides » et « current »

Pour intégrer certaines maquettes, on est parfois obligé de faire des éléments de menus vides (qui contiennent juste #). C’est par exemple le cas lorsque vous avez un sous-menu plein de liens, mais que l’élément de menu parent ne sert qu’à afficher le sous-menu au survol.

C’est crade de laisser un lien sans attribut href utile… autant employer un autre élément html tel qu’un span.

De même, Daniel m’a fait remarquer qu’on peut aussi en profiter supprimer le lien de l’élément actuel, pour éviter de faire un lien vers la page actuel, ce qui est mauvais en SEO.

C’est ce que nous faisons ici, grâce au filtre walker_nav_menu_start_el :

<?php
/**
 * Changer les liens vides en span
 */
add_action( 'walker_nav_menu_start_el', 'empty_nav_links_to_span', 10, 4 );
function empty_nav_links_to_span( $item_output, $item, $depth, $args ) {
	if ( '#' == $item->url || true == $item->current ) {
		$item_output = preg_replace( '/<a.*?>(.*)<\/a>/', '<span>$1</span>', $item_output );
	}
	return $item_output;
}

Ce filtre est appliqué au contenu d’un élément de menu (sans le <li> ouvrant donc…) avant qu’il ne soit retourné dans Walker_Nav_Menu::start_el(). Il propose quatre arguments : la sortie à filtrer, l’élément courant (objet), la profondeur courante (racine, sous-menu, sous-sous-menu…) ainsi que les paramètres passés à wp_nav_menu.

Si l’attribut url de l’élément est une ancre vide, ou s’il s’agit de l’élément actuel, alors on applique une petite regex pour remplacer la balise et tout ses attributs par un span.

Nous réutiliserons ce hook un peu plus loin dans un tout autre dessein…

Ouvrir les liens externes du menu dans un nouvel onglet

L’interface de l’administration des menus permet déjà de sélectionner la destination des liens, mais si on veut automatiser la chose, il faut utiliser le filtre nav_menu_link_attributes. Il filtre un tableau d’attributs qui sera renvoyé dans Walker_Nav_Menu::start_el() pour construire les attributs du lien.

Ici, donc, si l’attribut href ne commence pas par le domaine du site, on force le target à blank, et on avertit l’utilisateur en changeant le title du lien.

<?php
/**
 * Ouvrir les liens externes dans une nouvelles fenêtre
 */
add_filter( 'nav_menu_link_attributes', 'open_external_nav_link_new_window' );
function open_external_nav_link_new_window( $atts ) {
	// Si l'URL ne commence pas par mon domaine
	if ( ! preg_match( '/^http:\/\/example\.com/', $atts['href'] ) ) {
		$atts['target'] = '_blank';
		$atts['title']  = "Ce lien s'ouvrira dans un nouvel onglet";
	}
	return $atts;
}

Notez qu’on aurait pu également ajouter des attributs si besoin, tels que des aria…

Ajouter un bouton pour le menu responsive

Il existe deux techniques pour gérer la navigation en responsive : utiliser un menu alternatif, masqué par défaut :-/ ou bien modifier l’apparence d’un unique menu, en fonction de la dimension de l’écran. Pour cette deuxième méthode, il est souvent nécessaire d’utiliser un bouton pour déployer le menu.

Alors certes, on pourrait mettre ce bouton directement dans le template, ou bien le pousser en paramètre de wp_nav_menu, mais imaginez que vous intervenez sur un thème enfant : allez vous vraiment dupliquer chaque fichier du thème parent qui contient un menu, juste pour ajouter ce petit paramètre ? Si vous faites ça vous allez perdre tout le bénéfice des mises à jour du thème.

Pour une fois sortons un peu de Walker_nav_menu pour explorer les hooks autour ! Cette fois le filtre que nous allons employer est appliqué directement dans la fonction wp_nav_menu, et permet d’en modifier la sortie. wp_nav_menu ne prend que deux paramètres : l’output, et les arguments originels.

Voici ce que nous allons faire :

<?php
/**
 * Ajout d'un élément avant le menu
 */
add_action( 'wp_nav_menu', 'responsive_menu_button', 9, 2 );
function responsive_menu_button( $menu, $args ) {
	// S'il s'agit du menu principal, j'ajoute mon bouton devant
	if ( 'menu_primary' == $args->theme_location ) {
		$menu = '<button class="switch-menu" type="button">Menu</button>' . $menu;
	}
	return $menu;
}

Si le menu correspond à tel emplacement, alors on ajoute un bouton juste avant. Il ne restera plus qu’à gérer son affichage en css et son comportement en javascript.

Ajouter une barre de recherche au menu

Voici un petit exemple qui va me permettre d’illustrer un nouveau hook : wp_nav_menu_items

Ce hook est un filtre qui est appliqué à la liste des éléments html, une fois qu’elle a été générée par le walker, et avant d’être poussée dans son <ul> global. L’intérêt de ce filtre est de pouvoir ajouter des éléments de liste, au début ou à la fin du menu.

Si l’on souhaite ajouter un formulaire de recherche à la fin du menu, il suffit de procéder comme ceci :

<?php
/**
 * Ajouter la barre de recherche
 */
add_filter( 'wp_nav_menu_items', 'nav_menu_add_search', 10, 2 );
function nav_menu_add_search( $items, $args ) {
	if ( 'primary' == $args->theme_location ) {
		$searchbar = '<li><form action="' . home_url( '/' ) . '">';
		$searchbar .= '<input type="search" name="s">';
		$searchbar .= '<input type="submit" value="go">';
		$searchbar .= '</form><li>';

		$items .= $searchbar;
	}
	return $items;
}

Dans cet exemple, je n’ajoute la barre de recherche que si le thème_location est « primary » (menu par defaut du thème twentyfifteen).

Mettre en cache les menus grâce aux transients

Les menus de WordPress son extrêmement requêtovaures !

Cela s’explique par le fait qu’à chaque élément de menu correspond une entrée en base de type nav_menu_item, et que la plupart de ces éléments en désignent d’autres à aller chercher en base également.
Pour un thème_location, il faut trouver le menu correspondant, puis ces éléments, puis les cibles de ces éléments, et éventuellement d’autres informations.

Pour de gros menus on arrive vite à plus de cent requêtes.

Pour optimiser ça, on peut enregistrer chaque menu dans un transient : une option de WordPress destinée à stocker des données temporaires.

Nous allons utiliser deux hooks pour cela :

  • wp_nav_menu, pour écrire notre transient, s’il n’existe pas ;
  • pre_wp_nav_menu pour récupérer notre transient et le retourner directement, sans générer le menu (économisant ainsi de nombreuses requêtes MySQL). Ce second hook se trouve au tout début de wp_nav_menu, et son comportement et de court-circuiter le reste de la fonction s’il retourne autre chose que « null ».
<?php
/**
 * Transient menu
 */
// Set transient
add_filter( 'wp_nav_menu', 'set_transient_nav_menu', 10, 2 );
function set_transient_nav_menu( $nav_menu, $args ) {
    // La taille d'un md5 est de 32 caractères
    // Celle du nom d'un transient ne peut dépasser 45
    $transient_name = 'nav_' . md5( serialize( $args ) );
    set_transient( $transient_name, $nav_menu );
    return $nav_menu;
}

// Get transient
add_filter( 'pre_wp_nav_menu', 'get_transient_nav_menu', 10, 2 );
function get_transient_nav_menu( $pre, $args ) {
    $transient_name = 'nav_' . md5( serialize( $args ) );
    if ( false !== ( $t = get_transient( $transient_name ) ) ) {
        $pre = $t;
    }
    return $pre;
}

La seule valeur qui identifie un transient, c’est son nom. Il faut donc veiller à ce qu’il soit unique pour chaque menu, et qu’il ne dépasse pas 45 caractères. C’est pour cela que je pousse tous les arguments de la fonction en md5…

Flusher les transients lors des mises à jour du menu

Si vous mettez à jour votre menu, le transient ne sera pas automatiquement détruit, et les anciennes infos toujours retournées. Nous allons donc utiliser les hooks d’action wp_update_nav_menu et wp_delete_nav_menu afin de supprimer les transients lorsque le menu est mis à jour ou supprimé :

// Flush transient
add_action( 'wp_update_nav_menu', 'delete_transients_nav_menu' );
add_action( 'wp_delete_nav_menu', 'delete_transients_nav_menu' );
function delete_transients_nav_menu() {
    // On supprime tous les transients dès qu'un menu est mis à jour
    // Pas moyen de faire dans la dentèle...
    // ... car difficilement possible de connaitre l'ID du menu au moment du hook wp_nav_menu
    global $wpdb;
    $wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE '\_transient\_nav\_%'" );
}

Comme précisé dans le code : pas moyen de faire dans la dentelle ici : on supprime tous nos transients lorsqu’un seul menu est mis à jour. C’est simplement dû à notre nommage : nous n’avons pas accès ici aux paramètres de wp_nav_menu.

Encore quelques petits détails

Si vous utilisez cette technique, vous ne pourrez plus utiliser correctement les classes css désignant l’élément courant ou ancêtre, car le transient est commun à toutes les pages. Il faudra donc filtrer les classes comme nous l’avons vu tout en haut de l’article.

Ou bien, vous pouvez enregistrer un transient par page, ce qui alourdira un peu votre table des options (mais sans conséquence sur la performance du site), en poussant l’URL courant dans le nom de votre transient :

if ( ! is_404() ) {
	$transient_name = 'nav_' . md5( serialize( $args ) . $_SERVER["REQUEST_URI"] );
	//...

Bien sûr, il faut veiller à exclure les 404, sinon la table risque d’exploser 😛

Créer un sous-menu dynamique

Une des fonctionnalités particulièrement intéressante des walkers était de pouvoir concevoir des sous-rubriques dynamiques, où les éléments du menus sont générés automatiquement.

En fait on peut faire la même chose avec walker_nav_menu_start_el (vous vous souvenez, on l’a vu plus haut…). Après avoir construit le contenu d’un <li>, le filtre est appliqué. On dispose ici des infos nécessaires pour construire un sous-menu, comme par exemple une liste de catégories :

<?php
/**
 * Faire une liste dynamique
 */
add_filter( 'walker_nav_menu_start_el', 'nav_list_cat_on_page_for_posts', 10, 4 );
function nav_list_cat_on_page_for_posts( $item_output, $item, $depth, $args ) {
	// Si l'ID de la page ciblé par le menu est la home du blog
	// page_for_posts est autoload : pas de requête en plus :-3
	if ( get_option( 'page_for_posts' ) == $item->object_id ) {
		$item_output .= '<ul class="sub-menu">';
		$item_output .= wp_list_categories( array(
							'orderby'          => 'count',
							'order'            => 'DESC',
							'number'           => 5, // juste les 5 plus remplies
							'echo'             => false,
		                   ) );
		$item_output .= '</ul>';
	}
	return $item_output;
}

Dans ce code, si l’ID de l’objet ciblé par l’élément est la home du blog (et non, la home du site) on liste les 5 catégories les plus utilisées du site grâce à la fonction wp_list_categories.

Attention : les classes CSS ont déjà été ajoutées à ce stade. Si vous souhaitez préciser une classe du type « menu-item-has-children », il faudra aussi faire un petit test sur cet élément via le filtre nav_menu_css_class vu plus haut.

N’afficher le sous-menu que pour l’élément courant

Il y a plusieurs mois, Daniel d’SeoMix, m’avait posé la colle suivante :

Comment, dans un menu avec des sous-menus, n’afficher le sous-menu que pour la rubrique courante (que l’on se situe sur l’élément parent, un enfant ou un descendant) ?

Je lui avais alors pondu un code à rallonge, utilisant un walker et faisant des tests dans tous les sens pour déterminer la filiation entre la page en cours et un élément de menu.

En fait il y a beaucoup plus simple…

Dans wp_nav_menu, la fonction _wp_menu_item_classes_by_context est exécutée afin de construire la liste des objets à afficher dans le menu. C’est au sein de cette fonction que de nombreuses opérations sont effectuées pour déterminer la filiation avec la page en cours (cela sert pour générer les classes).
Nul besoin de ré-inventer la poudre donc, il suffit d’utiliser le filtre wp_nav_menu_objects pour trier les éléments de menu avant de les envoyer au walker.

Je vous montre la fonction, et je vous la commente ensuite :

<?php
/**
 * N'afficher que les sous-menus de la section courante
 */
add_filter( 'wp_nav_menu_objects', 'only_submenu_for_current', 10, 2 );
function only_submenu_for_current( $sorted_menu_items, $args ) {
    $parent_to_leave = array( 0 );
    foreach ( $sorted_menu_items as $item ) {
        $id_el = 0 != $item->menu_item_parent ? $item->menu_item_parent : $item->ID;
        if ( ! in_array( $id_el, $parent_to_leave ) 
          && count( array_intersect( $item->classes, array( 
               'current-menu-item',
               'current-menu-parent',
               'current-menu-ancestor' ) ) ) > 0 ) {
            $parent_to_leave[] = $id_el;
        }
    }

    // 2nd passe
    foreach ( $sorted_menu_items as $key => $item ) {
        if ( ! in_array( $item->menu_item_parent, $parent_to_leave ) ) {
            unset( $sorted_menu_items[ $key ] );
        } elseif ( 0 == $item->menu_item_parent 
                && ! in_array( $item->ID, $parent_to_leave ) ) {
            if ( $class_key = array_search( 'menu-item-has-children', $sorted_menu_items[ $key ]->classes ) ) {
                unset( $sorted_menu_items[ $key ]->classes[ $class_key ] );
            }
        }
    }
    return $sorted_menu_items;
}
  1. Tout d’abord, on applique le filtre, qui prend deux paramètres : la liste des objets de menu, triés, ainsi que les arguments de la fonction de menu.
  2. Ensuite on crée un tableau qui contiendra les ID des éléments de niveau 0 qui conserveront les sous menus.
  3. Pour chaque élément :
    • si c’est un sous-élément, que son parent n’est pas déjà dans le tableau, et qu’il s’agit de la page en cours ou d’un ancêtre, on ajoute son parent au tableau ;
    • si c’est un élément de niveau 0, qu’il n’est pas dans le tableau et qu’il s’agit de la page en cours ou d’un ancêtre, on l’ajoute au tableau.
  4. Puis on repasse sur chaque élément :
    • si c’est un sous-élément dont le parent n’est pas dans le tableau, on le supprime ;
    • si c’est un élément de niveau 0 qui n’est pas dans le tableau, et qu’il a la classe menu-item-has-children, on supprime cette classe.

Voilà un bon exemple d’utilisation du filtre wp_nav_menu_objects 🙂

Forcer l’utilisation d’un walker sur tous les menus

Pour finir, je pense qu’il est intéressant de parler du filtre wp_nav_menu_args qui permet de modifier les paramètres d’une fonction wp_nav_menu.

Prenons cet exemple : j’ai envie d’appliquer un walker, en masse, à tous mes menus. J’en ai pas mal, et je n’ai pas envie d’aller modifier les fonctions une par une. Voici ma solution :

<?php
/**
 * Si je préfère utiliser les walkers, mais que je n'ai pas envie
 * d'éplucher ce thème themeforest à la recherche
 * de tous les wp_nav_menu
 */
add_filter( 'wp_nav_menu_args', 'need_walker_texas_ranger' );
function need_walker_texas_ranger( $args ) {
	$args->walker = new Walker_Texas_Ranger();
	return $args;
}

Ce filtre est bien pratique aussi si vous souhaitez changer le container du menu, son wrapper, ses classes ou ses ids…

Voilà, j’en ai fini de vous présenter les possibilités des hooks des menus de navigation. Je suis volontairement passé à côté de quelques hooks tels que nav_menu_item_id ou wp_nav_menu_container_allowedtags qui présentent, selon moi, moins d’intérêt en terme de fonctionnalité.

Et lorsque vous souhaiterez modifier le fonctionnement de WP, n’hésitez pas à aller fouiller le code source à la recherche de d’autres hooks 🙂

9 commentaires
Cristophe, il y a 2 années
Bien que pas développeur et seulement simple petit utilisateur à peine éveillé au codage dans WordPress, je me permets une question critique. d:-) N’y aurait-il pas moyen, pour ouvrir les liens externes dans un nouvel onglet, de faire autrement qu’écrire en dur dans le code le nom du domaine ? Autrement dit, pourrait-on utiliser une fonction qui ramène ce nom ?
Willy Bahuaud, il y a 2 années
Tu peux faire :

'/^' . preg_quote( home_url() ) . '/'

… mais ce code est là pour l’exemple, à chacun de le faire évoluer 🙂

Benjamin, il y a 2 années
A quand le camelCase pour les fonctions et les variables ?

Bisous 🙂

Willy Bahuaud, il y a 2 années
Jamais : PHP coding standards of WordPress 😉
Benjamin, il y a 2 années
C’est pas joli m’enfin si ce sont les standards…
Je suis désespérement attristé et désabusé par WordPress 🙂
Willy Bahuaud, il y a 2 années
Y’a des pour et des contre. Selon chaque framework il faut coller aux standards, sinon les deux se mélangent et c’est là que ça devient moche…
Par contre ça devient un peu hors-sujet sur l’article ^^
Benjamin, il y a 2 années
Je relisais ton code héhé après je connais pas toutes les subtilités WP donc je ne me permettrais pas de faire de retours. J’arrête de polluer ton fil de coms :p Allez Bisous Vilsy King @+
Grégoire Noyelle, il y a 2 années
Hello Willy
J’en parlais avec Thierry cette semaine. Ton tuto tombe à Pic, je voulais m’y mettre. Merci.
Je comptais aussi faire cohabiter nos gravatars dans la foulée 🙂
Willy Bahuaud, il y a 2 années
cool si ce mon tuto tombe à Pic ! Tu vois de temps en temps je lit une partie du core, je repère les hooks et j’essaye d’imaginer ce qu’on peut faire avec. C’est un exercice assez sympa 🙂

Hahaha pour les gravatars XD

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Publié le 02 mars 2015
par Willy Bahuaud
Catégorie Développement WordPress