Drop-down menus are an accepted and intuitive way of presenting links for large numbers of nested pages on a web page, but Drupal currently lacks a built-in mechanism for easily implementing them. For drop-down menus to work, our generated HTML should include all the items of a given menu, not just those at the top level and in the active trail; plus a flag indicating the current page, if any. While modules like Nice Menus already exist for providing nested menus in a block, this method makes it possible for drop-downs to appear anywhere lists of menu links currently appear in your theme.

The main challenge with getting this to work is generating the underlying data structure representing the menu hierarchy. While Drupal doesn’t (yet) include a function that does what we need, it does have two functions whose output can be combined. They are:

  • menu_tree_page_data(), which when given the name of a menu, returns a tree (a nested array of menu items and metadata) leading to the current page. You can see this function at work generating the main navigation menu. Its output depends on the page that called it.
  • menu_tree_all_data(), which returns a tree containing every item in a given menu.

What we want is a tree which contains every menu item, plus data indicating which item in the menu is the current page. To do that, we’re going to modify the built-in menu_navigation_links() function. In its default form, it generates a 1-dimensional array representing the top-level links of a given menu. We’re going to split it into two functions and have them recursively generate a multidimensional array of all the links in a menu. All the following code would be located inside your theme’s folder in the template.php file.

/**
* Return a multidimensional array of links for a navigation menu.
*/
function themename_navigation_links($menu_name, $level = 0) {
  // Don't even bother querying the menu table if no menu is specified.
  if (empty($menu_name)) {
    return array();
  }

  // Get the menu hierarchy for the current page.
  $tree_page = menu_tree_page_data($menu_name);
  // Also get the full menu hierarchy.
  $tree_all = menu_tree_all_data($menu_name);

  // Go down the active trail until the right level is reached.
  while ($level-- > 0 && $tree_page) {
    // Loop through the current level's items until we find one that is in trail.
    while ($item = array_shift($tree_page)) {
      if ($item['link']['in_active_trail']) {
        // If the item is in the active trail, we continue in the subtree.
        $tree_page = empty($item['below']) ? array() : $item['below'];
        break;
      }
    }
  }

  return themename_navigation_links_level($tree_page, $tree_all);
}


/**
* Helper function for themename_navigation_links to recursively create an array of links.
* (Both trees are required in order to include every menu item and active trail info.)
*/
function themename_navigation_links_level($tree_page, $tree_all) {
  $links = array();
  foreach ($tree_all as $key => $item) {
    $item_page = $tree_page[$key];
    $item_all = $tree_all[$key];
    if (!$item_all['link']['hidden']) {
    	$class = '';
      $l = $item_all['link']['localized_options'];
      $l['href'] = $item_all['link']['href'];
      $l['title'] = $item_all['link']['title'];
      if ($item_page['link']['in_active_trail']) {
      	$class = ' active-trail';
      }
      if ($item_all['below']) {
        $l['children'] = themename_navigation_links_level($item_page['below'], $item_all['below']);
      }
      // Keyed with the unique mlid to generate classes in theme_links().
      $links['menu-'. $item_all['link']['mlid'] . $class] = $l;
    }
  }
  return $links;
}

As you can see, the second function incorporates active trail information into the final array by simultaneously checking items in $tree_page and $tree_all. Since both trees represent the same data, the keys from one tree can be used to look up information in the other. And if a menu item contains nested items, as indicated in the tree with a key labelled “below”, themename_navigation_links_level() calls itself, passing in the nested items of each tree and returning an array of the nested items which are given the key “children” in the final array.

So we’re halfway there. We have an array (in the same format used by menu_primary_links() and menu_secondary_links()) which we can use for generating the menu’s HTML, but the built-in theme_links() function is not prepared to handle multidimensional arrays of links. Fortunately, since it’s a themeable function, we can override it by including it in template.php and naming it themename_links() without any additional changes to our theme. (Of course, you should always replace instances of “themename” with your own theme's machine name.)

/**
* Return a themed set of links. (Extended to support multidimensional arrays of links.) 
*/
function themename_links($links, $attributes = array('class' => 'links')) {
  $output = '';

  if (count($links) > 0) {
    $output = '<ul'. drupal_attributes($attributes) .'>';

    $num_links = count($links);
    $i = 1;

    foreach ($links as $key => $link) {
      $class = $key;

      // Add first, last and active classes to the list of links to help out themers.
      if ($i == 1) {
        $class .= ' first';
      }
      if ($i == $num_links) {
        $class .= ' last';
      }
      if (isset($link['href']) && ($link['href'] == $_GET['q'] || ($link['href'] == '<front>' && drupal_is_front_page()))) {
        $class .= ' active';
      }
      // Added: if the link has child items, add a haschildren class
      if (isset($link['children'])) {
        $class .= ' haschildren';
      }
      $output .= '<li'. drupal_attributes(array('class' => $class)) .'>';

      if (isset($link['href'])) {
        // Pass in $link as $options, they share the same keys.
        $output .= l($link['title'], $link['href'], $link);
      }
      else if (!empty($link['title'])) {
        // Some links are actually not links, but we wrap these in <span> for adding title and class attributes
        if (empty($link['html'])) {
          $link['title'] = check_plain($link['title']);
        }
        $span_attributes = '';
        if (isset($link['attributes'])) {
          $span_attributes = drupal_attributes($link['attributes']);
        }
        $output .= '<span'. $span_attributes .'>'. $link['title'] .'</span>';
      }
      
      // Added: if the link has child items, print them out recursively
      if (isset($link['children'])) {
        $output .= "\n" . theme('links', $link['children'], array('class' =>'sublinks'));
      }

      $i++;
      $output .= "</li>\n";
    }

    $output .= '</ul>';
  }

  return $output;
}

The only changes we’ve made are adding a statement to tack on a "haschildren" class to parent menu items, and another statement just before the $i++; causing the function to recursively call itself to generate the HTML for any nested items it detects.

We’ll add one last function to make calling these functions a little more concise. The code below, when called, generates markup for the primary links. This is completely optional, though.

function themename_primary_links() {
  return themename_navigation_links(variable_get('menu_primary_links_source', 'primary-links'));
}

With the back end finished, changes to our theme’s template is relatively simple. The following code from page.tpl.php, or something similar to it:

print theme('links', $primary_links, array('class' =>'links', 'id' => 'primary-links')) 

can be replaced with:

print theme('links', themename_primary_links(), array('class' =>'links', 'id' => 'primary-links')) 

in order to generate nested menus for the primary links.

Edit: I found the above code in page.tpl.php not leading to a call on themenanme_links. So, instead I am using the following code in page.tpl.php, which makes everything work fine:

   print themename_links(themename_navigation_links('primary-links'), array('class' =>'dropdown', 'id' => 'mymenuid'))

Here's some sample output of a primary links menu with a single parent link when viewed from the “North America” page:

<div id="menu">
  <ul class="links" id="primary-links">
    <li class="menu-115 first haschildren"><a href="#" title="" class="active-trail">Continents</a>
      <ul class="sublinks">
        <li class="menu-238 first"><a href="#" title="">Australia</a></li>
        <li class="menu-235"><a href="#" title="">Europe</a></li>
        <li class="menu-233"><a href="#" title="">Asia</a></li>
        <li class="menu-231 last active haschildren"><a href="#" title="" class="active-trail active">North America</a>
        <ul class="sublinks">
          <li class="menu-239 first"><a href="#" title="">Canada</a></li>
          <li class="menu-232 last"><a href="#" title="">USA</a></li>
        </ul>
        </li>
      </ul>
    </li>
  </ul>
</div>

Of course, all this is useless without proper CSS styling. As this topic has been covered before, I'd suggest you look at how I did it in my theme, NoProb. The main idea is that all ul.sublinks elements have “display: none;” applied to them by default, and are shown only when their parent li.haschildren element is hovered over. Since IE6 doesn't apply the :hover pseudo-class to anything other than links, some JavaScript is required, which I originally got from http://www.alistapart.com/articles/horizdropdowns and modified to support more than one level of nesting.

A template.php file containing the above code is attached to this page. Questions, comments, improvements, and pointing out of glaring oversights are welcome.

AttachmentSize
template.php_.txt4.41 KB

Comments

KKarimi’s picture

The only tutorial I found that actually works! Basically I added the template.php to my theme after renaming all it's functions to my own theme's name, then I put in this line:

print theme('links', MyThemesName_primary_links(), array('class' =>'nav', 'id' => 'primary-links'))

*nav is the class where in your css you have defined your navigation style

spacecowboyian’s picture

If you need nested menu markup for suckerfish, superfish or any kind of JS fish. This is the way to go. I searched for 3 hours before I found the right solution and it took 5 mins to implement.

THANKS!

Got a donate link?

Eric3’s picture

Nah... donate to the Drupal project instead.

Roulion’s picture

I try to integrate your snippet to fusiontheme but i can't get all the tree menu. I only have one level.
Here is the part of my template.php file

// $Id: template.php,v 1.1 2009/09/20 17:19:55 blagoj Exp $

/**
 * Return a multidimensional array of links for a navigation menu.
 *
 * @param $menu_name
 *   The name of the menu.
 * @param $level
 *   Optional, the depth of the menu to be returned.
 * @return
 *   An array of links of the specified menu and level.
 */
function afterfoot_navigation_links($menu_name, $level = 0) {
  // Don't even bother querying the menu table if no menu is specified.
  if (empty($menu_name)) {
    return array();
  }

  // Get the menu hierarchy for the current page.
  $tree_page = menu_tree_page_data($menu_name);
  // Also get the full menu hierarchy.
  $tree_all = menu_tree_all_data($menu_name);

  // Go down the active trail until the right level is reached.
  while ($level-- > 0 && $tree_page) {
    // Loop through the current level's items until we find one that is in trail.
    while ($item = array_shift($tree_page)) {
      if ($item['link']['in_active_trail']) {
        // If the item is in the active trail, we continue in the subtree.
        $tree_page = empty($item['below']) ? array() : $item['below'];
        break;
      }
    }
  }
  return afterfoot_navigation_links_level($tree_page, $tree_all);
}


/**
 * Helper function for afterfoot_navigation_links to recursively create an array of links.
 * (Both trees are required in order to include every menu item and active trail info.)
 */
function afterfoot_navigation_links_level($tree_page, $tree_all) {
  $links = array();
  foreach ($tree_all as $key => $item) {
    $item_page = $tree_page[$key];
    $item_all = $tree_all[$key];
    if (!$item_all['link']['hidden']) {
    	$class = '';
      $l = $item_all['link']['localized_options'];
      $l['href'] = $item_all['link']['href'];
      $l['title'] = $item_all['link']['title'];
      if ($item_page['link']['in_active_trail']) {
      	$class = ' active-trail';
      }
      if ($item_all['below']) {
        $l['children'] = afterfoot_navigation_links_level($item_page['below'], $item_all['below']);
      }
      // Keyed with the unique mlid to generate classes in theme_links().
      $links['menu-'. $item_all['link']['mlid'] . $class] = $l;
    }
  }
  return $links;
}


/**
 * Helper function to retrieve the primary links using afterfoot_navigation_links().
 */
function afterfoot_primary_links() {
  return afterfoot_navigation_links(variable_get('menu_primary_links_source', 'primary-links'));
}


/**
 * Return a themed set of links. (Extended to support multidimensional arrays of links.) 
 *
 * @param $links
 *   A keyed array of links to be themed.
 * @param $attributes
 *   A keyed array of attributes
 * @return
 *   A string containing an unordered list of links.
 */
function afterfoot_links($links, $attributes = array('class' => 'links')) {
  $output = '';

  if (count($links) > 0) {
    $output = '<ul'. drupal_attributes($attributes) .'>';

    $num_links = count($links);
    $i = 1;

    foreach ($links as $key => $link) {
      $class = $key;

      // Add first, last and active classes to the list of links to help out themers.
      if ($i == 1) {
        $class .= ' first';
      }
      if ($i == $num_links) {
        $class .= ' last';
      }
      if (isset($link['href']) && ($link['href'] == $_GET['q'] || ($link['href'] == '<front>' && drupal_is_front_page()))) {
        $class .= ' active';
      }
      // Added: if the link has child items, add a haschildren class
      if (isset($link['children'])) {
        $class .= ' haschildren';
      }
      $output .= '<li'. drupal_attributes(array('class' => $class)) .'>';

      if (isset($link['href'])) {
        // Pass in $link as $options, they share the same keys.
        $output .= l($link['title'], $link['href'], $link);
      }
      else if (!empty($link['title'])) {
        // Some links are actually not links, but we wrap these in <span> for adding title and class attributes
        if (empty($link['html'])) {
          $link['title'] = check_plain($link['title']);
        }
        $span_attributes = '';
        if (isset($link['attributes'])) {
          $span_attributes = drupal_attributes($link['attributes']);
        }
        $output .= '<span'. $span_attributes .'>'. $link['title'] .'</span>';
      }
      
      // Added: if the link has child items, print them out recursively
      if (isset($link['children'])) {
        $output .= "\n" . theme('links', $link['children'], array('class' =>'sublinks'));
      }

      $i++;
      $output .= "</li>\n";
    }

    $output .= '</ul>';
  }
  return $output;
}






/**
 * Override or insert PHPTemplate variables into the page templates.
 *
 * @param $vars
 *   A sequential array of variables to pass to the theme template.
 * @param $hook
 *   The name of the theme function being called ("page" in this case.)
 */

function afterfoot_preprocess_page(&$vars, $hook) {
  $vars['footer_msg'] = ' &copy; ' . $vars['site_name'] . ' ' . date('Y');
  $vars['search_box'] = str_replace(t('Search this site: '), '', $vars['search_box']); 
  $vars['site_logo'] = '<a href="'. $vars['front_page'] .'" title="'. t('Home page') .'" rel="home"><img src="'. $vars['logo'] .'" alt="'. $vars['site_name'] .' '. t('logo') .'" /></a>';
} 

here is my page.tpl.php call

<div id="menu">
						<?php if (isset($primary_links)) { ?>
						<?php print theme('links', afterfoot_primary_links(), array('class' => 'links', 'id' => 'primary-links')) ?>
					<?php } ?>
                </div>

I anyone could help, please

r_honey’s picture

Although it has been some time, the above problem was posted, still I felt like providing the solution for others who land on this page via Google.

After some excellent code in the article above, the goof-up has been on this part:

function themename_primary_links() {
  return themename_navigation_links(variable_get('menu_primary_links_source', 'primary-links'));
}
print theme('links', themename_primary_links(), array('class' =>'links', 'id' => 'primary-links'))

The above 2 php blocks in template.php, and page.tpl.php respectively completely bypass the all-important themename_links method, and rather lead to a direct call on themename_navigation_links method.

I completely dumpd the primary_links method in template.php, and modified the page.tpl.php to the following:

  print themename_links(themename_navigation_links('primary-links'), array('class' =>'dropdown', 'id' => 'mymenuid'))  

?>

And it works like a charm now. Kudos to Eric3 for posting the original code.

Roulion’s picture

I'm not sure to understand properly what am i supposed to do.

I kept my template?php as i said and i changed de page.tpl.php file with your code and the the menu disappear. I assume something is wrong. What do you mean with "I completely dumpd the primary_links method in template.php" ?

tank for your help

r_honey’s picture

Hi Roulion,

Can you please try the following in your page.tpl.php:

If this still does not work, can you post the code you are using in page.tpl.php??

Also try print_r on afterfoot_navigation_links('primary-links') to be sure that the menu is being generated correctly.

Roulion’s picture

Hi

I could manage to get the menu tree but only the first 2 level (i have 3).
(the point was that my menu was considererd as primary link but not the "primary-links" menu, so my template code is still the same, as my page.tpl file.

However, with a print_r i get all levels of my menu...

by the way i also would like ton integrate the Classes per level of menu items links in my menu. If anyone could help

r_honey’s picture

Hi Roulion, although I am not clear what you mean by (my menu was considererd as primary link but not the "primary-links" menu), make sure you have correct menu selected as primary-links menu at "/admin/build/menu/settings"

Next regarding only the first 2 levels, make sure that first, menu items at level 2 that have sub-levels have their expanded set to checked, and then, the menu items at level 3 have enabled checked. Also, make sure that your client-side javascript that enables drop down menus supports 3 or further levels.

Lastly, the link you posted for classes per menu level provides sufficient explanation and code for get the job done. What else do you want to know??

Roulion’s picture

Actually i would like to integrate the level number class for each menu item.

All menu level are enabled and checked. However, i don't have any js to enable 2level menu. In the fusiontheme, there is no js folder with a script.js file.

Well i'll try to have a look this week-end... Thanks for your help

abm.ansar’s picture

What a surprise
this is like a magic
its really very simple to implement
Thanks for your help

willhowlett’s picture

Thankyou so much for posting this. Just what I'm after.

internets’s picture

Thank you for posting this helpful code and explanation!

It helped with converting a Drupal 5 theme's custom menu function using menu_get_menu() to work with Drupal 6.

vunger’s picture

It works! I don't understand it, but hope that will come later. I've been struggling with the child menu items issue for 2 days. Thanks so much, Eric3.

vunger’s picture

And I added a little something so that it's not necessary to change the template file. The reason it's necessary to bypass the theme function with this code is because menu_navigation_links is not a hook function. So if you make your own version--yourtheme_navigation_links--t's not going to get called unless you call it somewhere.

But template_preprocess_page is a hook function. Make your own version and Drupal will automatically call it. So in addition to Eric3's code, I added something like this:

<?php
function yourtheme_preprocess_page(&$variables) {
  // Retrieve entire menu tree for the primary links
  $pid = variable_get('menu_primary_links_source', 'primary-links');
  $variables['primary_links'] = theme_get_setting('toggle_primary_links') ? yourtheme_navigation_links($pid) : array();
}
?>

where yourtheme is replaced by the theme name. Then changing page.tpl.php is no longer necessary.

aricw’s picture

This is the best piece of information I've found on this topic yet... It's hard to believe that there wouldn't be more information out on this.

Develcode’s picture

How to Add The Javascript file . like Superfish.js

willhowlett’s picture

rmarius’s picture

Does this can work on drupal 7? After aplying this code i am getting 3 unneeded li's in submenu:

<li class="links"></li>
<li class="attributes"></li>
<li class="heading last"></li>

How to get rid of this?

manObject’s picture

The current Form for creating and editing menu items is okay for most simple menu needs but presents problems for drop-down menus, including so-called mega menus which are becoming the preferred approach for usability and user friendliness on multi-page sites (see: http://www.useit.com/alertbox/mega-dropdown-menus.html).
For example, there is no way to specify on the Form that a top level or second level menu item is just a non-clickable header, under which associated clickable links are listed. This is either difficult or impossible to achieve with CSS if one or more top level items have no children while others do. The only way out of the dilemma is either to link to the front page or create a 'dummy' page for each top level item that has 'children', each one merely re-stating the information detailed in the second and third levels. Such duplication of effort is hardly welcome and is a potential site maintenance headache; one has to remember to update the 'dummy' page whenever a link is added, changed or deleted. This kind of thing needs to be avoided at all costs.
There is a module called 'Special Menu Items' that is supposed to be able to create 'non-clickable' headers and even 'seperator' items but it didn't work for me; the links are still clickable and point to a page that says something like "this page should not be visible". What is needed is href="javascript:void(0)" for the link target whenever the 'nolink' Form option is selected, but the page source code shows something very different!

gwanjama’s picture

Excellent tutorial/solution by Eric3.

For those who need to achieve the same, but in Drupal 7, you will realize that a few things break, and many other bugs/unexpected behaviour start to creep in. This is because the theme.inc file located at /includes/theme.inc is significantly different between Drupal 6 and 7.

I have adapted/modified Eric3's solution so that it works in Drupal 7 (version 7.34).

First, modify the themename_links function (provided in Eric3's solution) in your template.php to become...

<?php
function themename_links($variables) {
  //Modified to work properly with Drupal 7
  $links = $variables['links'];
  $attributes = $variables['attributes'];
  $heading = $variables['heading'];
  global $language_url;
  $output = '';

  if (count($links) > 0) {
    // Treat the heading first if it is present to prepend it to the
    // list of links.
    if (!empty($heading)) {
      if (is_string($heading)) {
        // Prepare the array that will be used when the passed heading
        // is a string.
        $heading = array(
          'text' => $heading,
          // Set the default level of the heading.
          'level' => 'h2',
        );
      }
      $output .= '<' . $heading['level'];
      if (!empty($heading['class'])) {
        $output .= drupal_attributes(array('class' => $heading['class']));
      }
      $output .= '>' . check_plain($heading['text']) . '</' . $heading['level'] . '>';
    }

    $output .= '<ul' . drupal_attributes($attributes) . '>';

    $num_links = count($links);
    $i = 1;

    foreach ($links as $key => $link) {
      $class = array($key);

      // Add first, last and active classes to the list of links to help out themers.
      if ($i == 1) {
        $class[] = 'first';
      }
      if ($i == $num_links) {
        $class[] = 'last';
      }
      if (isset($link['href']) && ($link['href'] == $_GET['q'] || ($link['href'] == '<front>' && drupal_is_front_page()))
          && (empty($link['language']) || $link['language']->language == $language_url->language)) {
        $class[] = 'active';
      }
	  //Added: if the link has child items, add a haschildren class
	  if (isset($link['children'])) {
        $class[] = ' haschildren';
      }
      $output .= '<li' . drupal_attributes(array('class' => $class)) . '>';

      if (isset($link['href'])) {
        // Pass in $link as $options, they share the same keys.
        $output .= l($link['title'], $link['href'], $link);
      }
      elseif (!empty($link['title'])) {
        // Some links are actually not links, but we wrap these in <span> for adding title and class attributes.
        if (empty($link['html'])) {
          $link['title'] = check_plain($link['title']);
        }
        $span_attributes = '';
        if (isset($link['attributes'])) {
          $span_attributes = drupal_attributes($link['attributes']);
        }
        $output .= '<span' . $span_attributes . '>' . $link['title'] . '</span>';
      }
	  
	  //Added: if the link has child items, print them out recursively
	  if (isset($link['children'])) {
		//Modified to work properly with Drupal 7
		$output .= "\n" . theme('links', array('links' => $link['children'], 'attributes' => array('class' =>'subMenu')));
      }

      $i++;
      $output .= "</li>\n";
    }

    $output .= '</ul>';
  }

  return $output;
}
?>

You'll notice that the D7's version of theme_links only receives one variable when it is called - An associative array (See function theme_links).

The next thing you need to do, is modify how you print out the menu in page.tpl.php...

<?php
 print theme('links', array('links' => themename_navigation_links('main-menu'), 'attributes' => array('class' =>'mainmenu', 'id' => 'myMenuID')));
?>

Make sure you replace 'themename' with the name of your theme, in both code blocks I have provided above.

That should do it!

Now all you have to do, is style your menu with CSS (and perhaps some JQuery, if you want) to bring it to life on your website.

Questions, comments, improvements, and pointing out of glaring oversights are welcome.

willhowlett’s picture

As an alternate method, I did this recently through a block preprocess in template.php

/**
 * Implements template_preprocess_block(&$variables).
 */
function THEMENAME_preprocess_block(&$vars) {
  // Expand main-menu for dropdown functionality
  switch ($vars['block']->module) {
    case 'system':
      if ($vars['block']->delta == 'main-menu') {
        $main_menu_tree = menu_tree_all_data('main-menu');
        $main_menu_render_array = menu_tree_output($main_menu_tree);
        $vars['content'] = render($main_menu_render_array);
      }
    break;
  }
}