16

I am trying to create a Menu that shows a maximum of 5 items. If there are more items it should wrap them into another <ul> Element to create a dropdown.

5 Items or less:

Dropdown

6 Items or more

Dropdown

I know this kind of functionality could easily be created with a walker that counts the menu items and wraps if there are more then 5 the remaing into a seperate <ul>. But I dont know how to create this walker.

The code that shows my menu at the moment is the following:

<?php wp_nav_menu( array( 'theme_location' => 'navigation', 'fallback_cb' => 'custom_menu', 'walker' =>new Custom_Walker_Nav_Menu ) ); ?>

I noticed that if the menu is not defined by the user and it uses the fallback function instead the walker has no effect. I need it to work in both cases.

  • 1
    The custom menu walker is a class that extends Walker_Nav_Menu and there is an example in the codex. What do you mean with "I don't know how to create the Walker"? – cybmeta Mar 5 '15 at 8:53
  • Btw, +1 because the idea is actually quite awesome. How did you stumble upon it? Do you have a source post or something? If so, I'd be happy to read that. Thanks in advance. – kaiser Mar 5 '15 at 10:32
  • @kaiser just a nasty design idea :) no source post, thats why I am asking. – Snowball Mar 6 '15 at 8:31
  • @cybmeta I know to create the walker and as well that there is an example in the codex, but there is no example for this specific problem. So I dont know how to create a custom walker that gives me a solution – Snowball Mar 6 '15 at 8:32
  • You should ask the UX.SE guys about this idea and verify if there're problems with that for the user. UX is a really awesome site that brings a quite good reality check on usability/experience and regularly well thought answers and problems. You could as well then come back and we all refine that idea together. (that would really be awesome!). – kaiser Mar 6 '15 at 11:28
9

Using a custom Walker, the start_el() method has access to $depth param: when it is 0 the elemnt is a top one, and we can use this info to maintain an internal counter.

When the counter reach a limit, we can use DOMDocument to get from full HTML output just the last element added, wrap it in a submenu and add it again to HTML.


Edit

When the number of elements are exactly the number we required + 1, e.g. we required 5 elements be visible and menu has 6, it makes no sense to split the menu, because elements will be 6 either way. The code was edited to address that.


Here's the code:

class SplitMenuWalker extends Walker_Nav_Menu {

  private $split_at;
  private $button;
  private $count = 0;
  private $wrappedOutput;
  private $replaceTarget;
  private $wrapped = false;
  private $toSplit = false;

  public function __construct($split_at = 5, $button = '<a href="#">&hellip;</a>') {
      $this->split_at = $split_at;
      $this->button = $button;
  }

  public function walk($elements, $max_depth) {
      $args = array_slice(func_get_args(), 2);
      $output = parent::walk($elements, $max_depth, reset($args));
      return $this->toSplit ? $output.'</ul></li>' : $output;
  }

  public function start_el(&$output, $item, $depth = 0, $args = array(), $id = 0 ) {
      $this->count += $depth === 0 ? 1 : 0;
      parent::start_el($output, $item, $depth, $args, $id);
      if (($this->count === $this->split_at) && ! $this->wrapped) {
          // split at number has been reached generate and store wrapped output
          $this->wrapped = true;
          $this->replaceTarget = $output;
          $this->wrappedOutput = $this->wrappedOutput($output);
      } elseif(($this->count === $this->split_at + 1) && ! $this->toSplit) {
          // split at number has been exceeded, replace regular with wrapped output
          $this->toSplit = true;
          $output = str_replace($this->replaceTarget, $this->wrappedOutput, $output);
      }
   }

   private function wrappedOutput($output) {
       $dom = new DOMDocument;
       $dom->loadHTML($output.'</li>');
       $lis = $dom->getElementsByTagName('li');
       $last = trim(substr($dom->saveHTML($lis->item($lis->length-1)), 0, -5));
       // remove last li
       $wrappedOutput = substr(trim($output), 0, -1 * strlen($last));
       $classes = array(
         'menu-item',
         'menu-item-type-custom',
         'menu-item-object-custom',
         'menu-item-has-children',
         'menu-item-split-wrapper'
       );
       // add wrap li element
       $wrappedOutput .= '<li class="'.implode(' ', $classes).'">';
       // add the "more" link
       $wrappedOutput .= $this->button;
       // add the last item wrapped in a submenu and return
       return $wrappedOutput . '<ul class="sub-menu">'. $last;
   }
}

The usage is pretty simple:

// by default make visible 5 elements
wp_nav_menu(array('menu' => 'my_menu', 'walker' => new SplitMenuWalker()));

// let's make visible 2 elements
wp_nav_menu(array('menu' => 'another_menu', 'walker' => new SplitMenuWalker(2)));

// customize the link to click/over to see wrapped items
wp_nav_menu(array(
  'menu' => 'another_menu',
  'walker' => new SplitMenuWalker(5, '<a href="#">more...</a>')
));
  • Works excellent! Awesome work Giuseppe. The great thing about it is that it works as well if there is a submenu in the first 5 menu elements. And that it doesn't wrap just a single menu point into a submenu if there is no need for it. Just a minor thing: By default it shows 6 elements as $split_at = 5 but $count index starts at 0. – Snowball Mar 11 '15 at 7:47
  • Thank @Snowball I fixed that minor issue, now menu shows exact number passed as $split_at argument, 5 by default. – gmazzap Mar 11 '15 at 14:13
10

There even is a way to make this possible with CSS alone. This has some limitations, but I still thought it might be an interesting approach:

Limitations

  • You need to hardcode the width of the dropdown
  • Browser-Support. You basically need CSS3 selectors. But everything from IE8 up should work, although I haven't tested this.
  • This is more of a proof-of-concept. There are several drawbacks like only working if there are no sub-items.

Approach

Although I'm not really using "Quantity Queries" the creative usage of :nth-child and ~ I've read in the recent Quantity Queries for CSS were what led me to this solution.

The approach is basically this:

  1. Hide all items after the 4th
  2. Add ... dots using a before pseudo-element.
  3. When hovering the dots (or any of the hidden elements) show the extra items aka the submenu.

Here is the CSS code for a default WordPress menu markup. I've commented inline.

/* Optional: Center the navigation */
.main-navigation {
    text-align: center;
}

.menu-main-menu-container {
    display: inline-block;
}

/* Float menu items */
.nav-menu li {
    float:left;
    list-style-type: none;
}

/* Pull the 5th menu item to the left a bit so that there isn't too
   much space between item 4 and ... */
.nav-menu li:nth-child(4) {
    margin-right: -60px;
}

/* Create a pseudo element for ... and force line break afterwards
   (Hint: Use a symbol font to improve styling) */
.nav-menu li:nth-child(5):before {
    content: "...\A";
    white-space: pre;
}

/* Give the first 4 items some padding and push them in front of the submenu */
.nav-menu li:nth-child(-n+4) {
    padding-right: 15px;
    position: relative;
    z-index: 1;
}

/* Float dropdown-items to the right. Hardcode width of dropdown. */
.nav-menu li:nth-child(n+5) {
    float:right;
    clear: right;
    width: 150px;
}

/* Float Links in dropdown to the right and hide by default */
.nav-menu li:nth-child(n+5) a{
    display: none;      
    float: right;
    clear: right;
}   

/* When hovering the menu, show all menu items from the 5th on */
.nav-menu:hover li:nth-child(n+5) a,
.nav-menu:hover li:nth-child(n+5) ~ li a{
    display: inherit;
}

/* When hovering one of the first 4 items, hide all items after it 
   so we do not activate the dropdown on the first 4 items */
.nav-menu li:nth-child(-n+4):hover ~ li:nth-child(n+5) a{
    display: none;
}

I've also created a jsfiddle to show it in action: http://jsfiddle.net/jg6pLfd1/

If you have any further questions how this works please leave a comment, I'd be happy to clarify the code further.

  • Thanks for your approach. I already thought in doing it with CSS but I think its cleaner to do it directly within php. Additional this solution puts the 5th menu point into a submenu, also there is no need for if there are just five menu items. – Snowball Mar 6 '15 at 8:36
  • Well only enabling it for 5+ items could probably be fixed. Anyway I am aware that this isn't perfect and a PHP approach might be cleaner. But I still found it interesting enough to include it for completeness sake. Another option is always nice. :) – kraftner Mar 6 '15 at 10:12
  • 2
    Of course. Btw. if you add another submenu to this it breaks as well – Snowball Mar 6 '15 at 10:39
  • 1
    Sure. This is more of a proof of concept until now. Added a warning. – kraftner Mar 6 '15 at 12:27
8

You can use wp_nav_menu_items filter. It accepts menu output and arguments which hold menu attributes, like menu slug, container, etc.

add_filter('wp_nav_menu_items', 'wpse_180221_nav_menu_items', 20, 2);

function wpse_180221_nav_menu_items($items, $args) {
    if ($args->menu != 'my-menu-slug') {
        return $items;
    }

    // extract all <li></li> elements from menu output
    preg_match_all('/<li[^>]*>.*?<\/li>/iU', $items, $matches);

    // if menu has less the 5 items, just do nothing
    if (! isset($matches[0][5])) {
        return $items;
    }

    // add <ul> after 5th item (can be any number - can use e.g. site-wide variable)
    $matches[0][5] = '<li class="menu-item menu-item-type-custom">&hellip;<ul>'
          . $matches[0][5];

    // $matches contain multidimensional array
    // first (and only) item is found matches array
    return implode('', $matches[0]) . '</ul></li>';
}
  • 1
    I've edited a couple of minor issues, however this works only if all menu items have no sub items. Because Regex does not recognize hierarchy. Test it: if any of the first 4 menu items contain a child item, the menu is pretty destroyed. – gmazzap Mar 5 '15 at 18:31
  • 1
    That's true. In that case DOMDocument can be used. However, in this question there are no sub menus, thus answer is correct for this specific case. DOMDocument would be "universal" solution but I have no time right now. You can investigate ;) looping through the LI items, if one has UL child skip it, that would be solution but needs written version :) – mjakic Mar 5 '15 at 20:00
  • 1
    (a) you don't know if there're submenu in OP. Submenus appears when mouse is over, so... (b) Yes, DOMDocument may work, but in that case you need to recursively loop items to do the check for inner ul. WordPress already loops menu items in menu walker. It is already a slow operation per se, adding and additional loop I think is not the right solution, on the countrary, a custom walker would be much cleaner and efficient solution. – gmazzap Mar 6 '15 at 4:24
  • Thanks guys, but @gmazzap is true, there is the possibility that the other menu points (both the first 4 or the other ones) contain another submenu. So this soultion won't work. – Snowball Mar 6 '15 at 8:34
  • You could also place two menus, main one and "hidden" one. Add a styled button with three dots "..." and on click or hover show second menu. Should be super easy. – mjakic Mar 6 '15 at 12:48
5

Got a working function, but not sure if it is the best solution.

I used a custom walker:

class Custom_Walker_Nav_Menu extends Walker_Nav_Menu {
function start_el(  &$output, $item, $depth = 0, $args = array(), $id = 0 ) {
    global $wp_query;
    $indent = ( $depth ) ? str_repeat( "\t", $depth ) : '';

    $classes = empty( $item->classes ) ? array() : (array) $item->classes;
    $classes[] = 'menu-item-' . $item->ID;

    $class_names = join( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $item, $args, $depth ) );
    $class_names = $class_names ? ' class="' . esc_attr( $class_names ) . '"' : '';

    $id = apply_filters( 'nav_menu_item_id', 'menu-item-'. $item->ID, $item, $args, $depth );
    $id = $id ? ' id="' . esc_attr( $id ) . '"' : '';

    /**
     * This counts the $menu_items and wraps if there are more then 5 items the
     * remaining items into an extra <ul>
     */
    global $menu_items;
    $menu_items = substr_count($output,'<li');
    if ($menu_items == 4) {
      $output .= '<li class="tooltip"><span>...</span><ul class="tooltip-menu">';
    }

    $output .= $indent . '<li' . $id . $class_names .'>';

    $atts = array();
    $atts['title']  = ! empty( $item->attr_title ) ? $item->attr_title : '';
    $atts['target'] = ! empty( $item->target )     ? $item->target     : '';
    $atts['rel']    = ! empty( $item->xfn )        ? $item->xfn        : '';
    $atts['href']   = ! empty( $item->url )        ? $item->url        : '';

    $atts = apply_filters( 'nav_menu_link_attributes', $atts, $item, $args, $depth );

    $attributes = '';
    foreach ( $atts as $attr => $value ) {
      if ( ! empty( $value ) ) {
        $value = ( 'href' === $attr ) ? esc_url( $value ) : esc_attr( $value );
        $attributes .= ' ' . $attr . '="' . $value . '"';
      }
    }

    $item_output = $args->before;
    $item_output .= '<a'. $attributes .'>';
    $item_output .= $args->link_before . apply_filters( 'the_title', $item->title, $item->ID ) . $args->link_after;
    $item_output .= '</a>';
    $item_output .= $args->after;

    $output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args );

  }
}

The function that shows the actual menu is the following:

        <?php
        wp_nav_menu( array( 'container' => false, 'theme_location' => 'navigation', 'fallback_cb' => 'custom_menu', 'walker' =>new Custom_Walker_Nav_Menu ) );
        global $menu_items;
        // This adds the closing </li> and </ul> if there are more then 4 items in the menu
        if ($menu_items > 4) {
            echo "</li></ul>";
        }
        ?>

I declared the global variable $menu_items and used it to show the closing <li> and <ul>-tags. Its probabbly possible to do that as well inside the custom walker, but I didn't found where and how.

Two problems: 1. If there are just 5 Items in the Menu, it wraps the last item as well into an althought there is no need for it.

  1. It just works if the user has actually allocated a menu to the theme_location, the walker doesn't fire if wp_nav_menu is showing the fallback function
  • Have you tried what happen if any of the first 4 items have some submenus? Tip: substr_count($output,'<li') will be == 4 in the wrong place... – gmazzap Mar 7 '15 at 12:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.