Custom dropdown field in wordpress menus

It's always better to add the functionality to your WordPress theme 'in the code'. This code will give you the ability to have a dropdown field in your menus.

It happens more often than not that you stumble upon a new and neat functionality in your work, by necessity. In my case the necessity was adding icons to the WordPress menus. So after a bit of googling around I found a code made by Weston Ruter, about custom fields in the WordPress menu. I modified his code, and was happy with the results. After a while a question came up on stackoverflow, to add a dropdown field to the menus where you’ll be able to choose the megamenu size on the menus. There are tons of solutions out there, and if you ask somebody chances are that he’ll recommend a plugin solution.

Plugins are a valid way to add a functionality to your site … But they can often be heavy, and if you include too much of them on your site, there is a good possibility that your website will become slow. And google doesn’t love slow sites.

Plugins are a valid way to add a functionality to your site, and that is their first intention. But they can often be heavy, and if you include too much of them on your site, there is a good possibility that your website will become slow. And google doesn’t love slow sites (ref1) (ref2). Being a web developer that spent over a year in developing themes for Themeforest, I guess I got used to the fact that I need to code as much functionality as I can, to avoid slowing the site down. This poured over into my client work that I do now. I’ll admit sometimes it’s easier to get a working plugin that will save you hours of coding and researching, but even so, I always try to code as much as possible inside the theme I’m creating for a client.

One of the possibility of adding the megamenu dropdown is described in this gist. While this approach works, it has its drawbacks. First of all, you need to add the dropdown with Javascript. That means that when you add a page to your menu you won’t see a thing (since adding page in WordPress menu page is done with AJAX and there is no page reload). So you need to add a page, save it and only then you’ll see a dropdown with megamenu description. And that’s a bad UX practice (if you don’t have a tutorial that explains this functionality the chances are that you’ll miss it altogether). I knew that I can add additional fields to my menus (mostly input fields), which meant that there has got to be a way to include this dropdown menu as well.

But there was a catch (there always is). Input fields are different than select dropdown. When you write in the input field, the contents are readily available to the global$_POSTvariable on save. And having just a select dropdown won’t give you that option. So I added one hidden input field where my selected option will be. That way I will be able to put that option in the$_POSTvariable, and save it to the post meta (WordPress treats menu items as special kind of posts).

Megamenu part

First you’ll need to have amenu_walker.phpfile, where you’ll put your custom menu walker. I’ll put the entire code of my walker file below.

// Megamenu in menu. Modified original file by Weston Ruter on https://gist.github.com/westonruter/3802459

add_action( 'init', array( 'my_custom_Nav_Menu_Item_Custom_Fields', 'setup' ) );

class my_custom_Nav_Menu_Item_Custom_Fields {

	static $options = array(
			'item' => '
				<div class="additional-menu-field-{name} {container_class}">
				<style scoped>
					.additional-menu-field-megamenu{display:inline-block;}
				</style>
					<label for="edit-menu-item-{name}-{id}">{label}</label>
					<div class="megamenu">
						<select name="{name}" id="select-mega-menu-{id}">
							<option value="">Default Standard Menu</option>
							<option value="{mega1}">Mega Menu - Single Column</option>
							<option value="{mega2}">Mega Menu - 2 Columns</option>
							<option value="{mega3}">Mega Menu - 3 Columns</option>
							<option value="{mega4}">Mega Menu - 4 Columns</option>
							<option value="{mega5}">Mega Menu - 5 Columns</option>
							<option value="{mega6}">Mega Menu - 6 Columns</option>
							<option value="{mega7}">Mega Menu - 7 Columns</option>
						</select>
						<input type="{input_type}" id="edit-menu-item-{name}-{id}" class="widefat code edit-menu-item-{name}" name="menu-item-{name}[{id}]" value="{value}">
					</div>
				</div>
			',
		);

	static function setup() {
		self::$options['fields'] = array(
			'megamenu' => array(
				'name'            => 'megamenu',
				'label'           => esc_html__('Mega Menu', 'my_custom'),
				'container_class' => 'menu-megamenu',
				'input_type'      => 'hidden',
			),
		);

		add_filter( 'wp_edit_nav_menu_walker', function () {
			return 'my_custom_Walker_Nav_Menu_Edit';
		});
		add_filter( 'my_custom_nav_menu_item_additional_fields', array( __CLASS__, '_add_fields' ), 10, 5 );
		add_action( 'save_post', array( __CLASS__, '_save_post' ) );
	}

	static function get_fields_schema() {
		$schema = array();
		foreach(self::$options['fields'] as $name => $field) {
			if (empty($field['name'])) {
				$field['name'] = $name;
			}
			$schema[] = $field;
		}
		return $schema;
	}

	static function get_menu_item_postmeta_key($name) {
		return '_menu_item_' . $name;
	}

	/**
	 * Inject the
	 * @hook {action} save_post
	 */
	static function _add_fields($new_fields, $item_output, $item, $depth, $args) {
		$schema = self::get_fields_schema($item->ID);
		foreach($schema as $field) {
			$field['value'] = get_post_meta($item->ID, self::get_menu_item_postmeta_key($field['name']), true);
			$field['id'] = $item->ID;
			//Tried to implement selected() here but was unsuccesfull, so I just left it like this, the mega fields aren't necessary, you can add them by hand
			$field['mega1'] = 'mega1';
			$field['mega2'] = 'mega2';
			$field['mega3'] = 'mega3';
			$field['mega4'] = 'mega4';
			$field['mega5'] = 'mega5';
			$field['mega6'] = 'mega6';
			$field['mega7'] = 'mega7';
			$new_fields .= str_replace(
				array_map(function($key){ return '{' . $key . '}'; }, array_keys($field)),
				array_values(array_map('esc_attr', $field)),
				self::$options['item']
			);
		}
		return $new_fields;
	}
	/**
	 * Save the newly submitted fields
	 * @hook {action} save_post
	 */
	static function _save_post($post_id) {
		if (get_post_type($post_id) !== 'nav_menu_item') {
			return;
		}
		$fields_schema = self::get_fields_schema($post_id);
		foreach($fields_schema as $field_schema) {
			$form_field_name = 'menu-item-' . $field_schema['name'];
			if (isset($_POST[$form_field_name][$post_id])) {
				$key = self::get_menu_item_postmeta_key($field_schema['name']);
				$value = stripslashes($_POST[$form_field_name][$post_id]);
				update_post_meta($post_id, $key, $value);
			}
		}
	}
}

First, you’ll add an action with the custom class for adding additional fields. You can read more about adding an action with the class here. You need to create an options array with the HTML look of your new field. This can be input field, dropdown, or a custom field of your choice. You’ll notice the addition of the hidden input field. This is where we store the value of our select.

Next you add a setup function that will have save action, filters that handle the editing and adding the additional fields, and a description of your field in the options array (you are pulling the names, labels etc. from it).

Now one thing that was unnecessary was that I defined ‘mega’ fields that I later pulled in each option. I was trying out how to use WordPress native selected() function, but I had no luck and just forgot to remove it. So you can just remove

$field['mega1'] = 'mega1';
$field['mega2'] = 'mega2';
$field['mega3'] = 'mega3';
$field['mega4'] = 'mega4';
$field['mega5'] = 'mega5';
$field['mega6'] = 'mega6';
$field['mega7'] = 'mega7';

from the_add_fields() function, and you’ll just remove the curly brackets from your options to look like this: <option value=”mega1″>Mega Menu – Single Column</option>. The last function is the one that saves our option in the post meta with our key and value. You’ll also need this extension

require_once ABSPATH . 'wp-admin/includes/nav-menu.php';
class my_custom_Walker_Nav_Menu_Edit extends Walker_Nav_Menu_Edit {
	function start_el(&$output, $item, $depth = 0, $args = array(), $id = 0) {
		$item_output = '';
		parent::start_el($item_output, $item, $depth, $args, $id);
		$new_fields = apply_filters( 'my_custom_nav_menu_item_additional_fields', '', $item_output, $item, $depth, $args );
		// Inject $new_fields before: <div class="menu-item-actions description-wide submitbox">
		if ($new_fields) {
			$item_output = preg_replace('/(?=<div[^>]+class="[^"]*submitbox)/', $new_fields, $item_output);
		}
		$output .= $item_output;
	}
}

that will just put the new field before the default description field.

Javascript part

Ok, so after we’ve added this, we need the javascript that will put our values from the selected box into the hidden input field. This is where Javascript comes to the rescue

jQuery(document).ready(function($) {
    "use strict";

    $(document).on('change', '.megamenu select', function(){
        var $this = $(this);
        $this.next().val( $this.val() );
    });

    $('.megamenu select').each(function(){
        var $this = $(this);
    	var selected = $this.next().val();
    	var option_length = $this.find('option').length;

    	for (var i = 0; i < option_length; i++) {
    		if ($($this.find('option')[i]).val() == selected) {
    			$($this.find('option')[i]).attr('selected', 'selected');
    		}
    	}

    });

});

We need this, because it’s our hidden input field that will save the option we need. This is all cool, but how do we use it? Well you need your custom walker for this. I’ll paste the content of my entire menu_walker.php file

Full code

<?php

// Allow HTML descriptions in WordPress Menu
remove_filter( 'nav_menu_description', 'strip_tags' );
function my_plugin_wp_setup_nav_menu_item( $menu_item ) {
    if ( isset( $menu_item->post_type ) ) {
        if ( 'nav_menu_item' == $menu_item->post_type ) {
            $menu_item->description = apply_filters( 'nav_menu_description', $menu_item->post_content );
        }
    }

    return $menu_item;
}

add_filter( 'wp_setup_nav_menu_item', 'my_plugin_wp_setup_nav_menu_item' );


class my_custom_walker_nav_menu extends Walker_Nav_Menu {

public function display_element($el, &$children, $max_depth, $depth = 0, $args, &$output){
	$id = $this->db_fields['id'];

	if(isset($children[$el->$id])){
		$el->classes[] = 'has_children';
	}

	parent::display_element($el, $children, $max_depth, $depth, $args, $output);
}


// add classes to ul sub-menus
function start_lvl( &$output, $depth = 0, $args = array() ) {
	// depth dependent classes
	$indent = ( $depth > 0  ? str_repeat( "\t", $depth ) : '' ); // code indent
	$display_depth = ( $depth + 1); // because it counts the first submenu as 0
	$classes = array(
		'navi',
		( $display_depth ==1 ? 'first' : '' ),
		( $display_depth >=2 ? 'navi' : '' ),
		'menu-depth-' . $display_depth
		);
	$class_names = implode( ' ', $classes );

	// build html
	$output .= "\n" . $indent . '<ul class="' . $class_names . '">' . "\n";
}
// add main/sub classes to li's and links
 function start_el( &$output, $item, $depth = 0, $args = array(), $id = 0 ) {
	global $wp_query;
	$indent = ( $depth > 0 ? str_repeat( "\t", $depth ) : '' ); // code indent

	static $is_first;
	$is_first++;
	// depth dependent classes
	$depth_classes = array(
		( $depth == 0 ? 'main-menu-item' : '' ),
		( $depth >=2 ? 'navi' : '' ),
		( $is_first ==1 ? 'menu-first' : '' ),
		'menu-item-depth-' . $depth
	);
	$depth_class_names = esc_attr( implode( ' ', $depth_classes ) );
	// passed classes
	$classes = empty( $item->classes ) ? array() : (array) $item->classes;
	$class_names = esc_attr( implode( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $item ) ) );

	$post_meta_array = get_post_meta($item->ID);
	$is_megamenu = $post_meta_array['_menu_item_megamenu'][0];
	$is_mega_menu = ($is_megamenu !== '') ? true : false;
	$use_desc = (strpos($class_names, 'use_desc') !== false) ? true : false;
	$no_title = (strpos($class_names, 'no_title') !== false) ? true : false;
	$is_sidebar = (strpos($class_names, 'menu_sidebar') !== false) ? true : false;

	if(!$is_mega_menu){
		$class_names .= ' normal_menu_item';
	} else{
		$class_names .= ' '.$is_megamenu;
	}

	// build html
	$output .= $indent . '<li a id="nav-menu-item-'. $item->ID . '" class="' . $depth_class_names . ' ' . $class_names . '">';
	// link attributes
	$attributes  = ! empty( $item->attr_title ) ? ' title="'  . esc_attr( $item->attr_title ) .'"' : '';
	$attributes .= ! empty( $item->target )     ? ' target="' . esc_attr( $item->target     ) .'"' : '';
	$attributes .= ! empty( $item->xfn )        ? ' rel="'    . esc_attr( $item->xfn        ) .'"' : '';
	$attributes .= ! empty( $item->url )        ? ' href="' . (($item->url[0] == "#" && !is_front_page()) ? esc_url(home_url('/')) : '') . esc_attr($item->url) .'"' : '';
	$attributes .= ' class="menu-link '.((strpos($item->url,'#') === false) ? '' : 'scroll').' ' . ( $depth > 0 ? 'sub-menu-link' : 'main-menu-link' ) . '"';

	$html_output = ($use_desc) ? '<div class="description_menu_item">'.$item->description.'</div>' : '';

	if ($is_sidebar) {
		ob_start();
		dynamic_sidebar($item->description);
		$sidebar_html = ob_get_clean();

		$sidebar_output = '<div class="sidebar_menu_item">'.$sidebar_html.'</div>';

		$item_output = $sidebar_output;

	} else{
		$item_output = (!$no_title) ? '<a ' . $attributes . '><span>' . apply_filters( 'the_title', $item->title, $item->ID ) . '</span></a>'. $html_output : $html_output;
	}
	// build html
	$output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args ).( ($is_mega_menu) ? '<div class="sf-mega"><div class="sf-mega-inner clearfix">':'');
}

function end_el( &$output, $item, $depth = 0, $args = array() ) {

	$classes = empty( $item->classes ) ? array() : (array) $item->classes;
	$class_names = esc_attr( implode( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $item ) ) );

	$is_mega_menu = (strpos($class_names,'mega') !== false) ? true : false;

    $output .= (($is_mega_menu)?'</div></div>':'') . "</li>\n";
}

}

// Megamenu in menu. Modified original file by Weston Ruter on https://gist.github.com/westonruter/3802459

add_action( 'init', array( 'my_custom_Nav_Menu_Item_Custom_Fields', 'setup' ) );

class my_custom_Nav_Menu_Item_Custom_Fields {

	static $options = array(
			'item' => '
				<div class="additional-menu-field-{name} {container_class}">
				<style scoped>
					.additional-menu-field-megamenu{display:inline-block;}
				</style>
					<label for="edit-menu-item-{name}-{id}">{label}</label>
					<div class="megamenu">
						<select name="{name}" id="select-mega-menu-{id}">
							<option value="">Default Standard Menu</option>
							<option value="{mega1}">Mega Menu - Single Column</option>
							<option value="{mega2}">Mega Menu - 2 Columns</option>
							<option value="{mega3}">Mega Menu - 3 Columns</option>
							<option value="{mega4}">Mega Menu - 4 Columns</option>
							<option value="{mega5}">Mega Menu - 5 Columns</option>
							<option value="{mega6}">Mega Menu - 6 Columns</option>
							<option value="{mega7}">Mega Menu - 7 Columns</option>
						</select>
						<input type="{input_type}" id="edit-menu-item-{name}-{id}" class="widefat code edit-menu-item-{name}" name="menu-item-{name}[{id}]" value="{value}">
					</div>
				</div>
			',
		);

	static function setup() {
		self::$options['fields'] = array(
			'megamenu' => array(
				'name'            => 'megamenu',
				'label'           => esc_html__('Mega Menu', 'my_custom'),
				'container_class' => 'menu-megamenu',
				'input_type'      => 'hidden',
			),
		);

		add_filter( 'wp_edit_nav_menu_walker', function () {
			return 'my_custom_Walker_Nav_Menu_Edit';
		});
		add_filter( 'my_custom_nav_menu_item_additional_fields', array( __CLASS__, '_add_fields' ), 10, 5 );
		add_action( 'save_post', array( __CLASS__, '_save_post' ) );
	}

	static function get_fields_schema() {
		$schema = array();
		foreach(self::$options['fields'] as $name => $field) {
			if (empty($field['name'])) {
				$field['name'] = $name;
			}
			$schema[] = $field;
		}
		return $schema;
	}

	static function get_menu_item_postmeta_key($name) {
		return '_menu_item_' . $name;
	}

	/**
	 * Inject the
	 * @hook {action} save_post
	 */
	static function _add_fields($new_fields, $item_output, $item, $depth, $args) {
		$schema = self::get_fields_schema($item->ID);
		foreach($schema as $field) {
			$field['value'] = get_post_meta($item->ID, self::get_menu_item_postmeta_key($field['name']), true);
			$field['id'] = $item->ID;
			//Tried to implement selected() here but was unsuccesfull, so I just left it like this, the mega fields aren't necessary, you can add them by hand
			$field['mega1'] = 'mega1';
			$field['mega2'] = 'mega2';
			$field['mega3'] = 'mega3';
			$field['mega4'] = 'mega4';
			$field['mega5'] = 'mega5';
			$field['mega6'] = 'mega6';
			$field['mega7'] = 'mega7';
			$new_fields .= str_replace(
				array_map(function($key){ return '{' . $key . '}'; }, array_keys($field)),
				array_values(array_map('esc_attr', $field)),
				self::$options['item']
			);
		}
		return $new_fields;
	}
	/**
	 * Save the newly submitted fields
	 * @hook {action} save_post
	 */
	static function _save_post($post_id) {
		if (get_post_type($post_id) !== 'nav_menu_item') {
			return;
		}
		$fields_schema = self::get_fields_schema($post_id);
		foreach($fields_schema as $field_schema) {
			$form_field_name = 'menu-item-' . $field_schema['name'];
			if (isset($_POST[$form_field_name][$post_id])) {
				$key = self::get_menu_item_postmeta_key($field_schema['name']);
				$value = stripslashes($_POST[$form_field_name][$post_id]);
				update_post_meta($post_id, $key, $value);
			}
		}
	}
}

require_once ABSPATH . 'wp-admin/includes/nav-menu.php';
class my_custom_Walker_Nav_Menu_Edit extends Walker_Nav_Menu_Edit {
	function start_el(&$output, $item, $depth = 0, $args = array(), $id = 0) {
		$item_output = '';
		parent::start_el($item_output, $item, $depth, $args, $id);
		$new_fields = apply_filters( 'my_custom_nav_menu_item_additional_fields', '', $item_output, $item, $depth, $args );
		// Inject $new_fields before: <div class="menu-item-actions description-wide submitbox">
		if ($new_fields) {
			$item_output = preg_replace('/(?=<div[^>]+class="[^"]*submitbox)/', $new_fields, $item_output);
		}
		$output .= $item_output;
	}
}

//Sidebars in Menu

if ( !class_exists('sidebars_custom_menu')) {
    class sidebars_custom_menu {
        public function add_nav_menu_meta_boxes() {
        	add_meta_box(
        		'sidebar_menu_add',
        		esc_html__('Add Sidebar', 'my_custom'),
        		array( $this, 'nav_menu_link'),
        		'nav-menus',
        		'side',
        		'low'
        	);
        }

        public function nav_menu_link() {?>
        	<div id="posttype-sidebars" class="posttypediv">
        		<div id="tabs-panel-sidebars" class="tabs-panel tabs-panel-active">
        			<ul id ="sidebars-checklist" class="categorychecklist form-no-clear">
						<?php
						$i = -1;
						foreach ( $GLOBALS['wp_registered_sidebars'] as $sidebar ) {
							ob_start();
							dynamic_sidebar($sidebar['id']);
							$sidebar_html = ob_get_clean();
							?>
		        			<li>
		        				<label class="menu-item-title">
		        					<input type="checkbox" class="menu-item-checkbox" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-object-id]" value="<?php echo $sidebar['id']; ?>"> <?php echo ucwords( $sidebar['name'] ); ?>
		        				</label>
		        				<input type="hidden" class="menu-item-type" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-type]" value="custom">
		        				<input type="hidden" class="menu-item-attr-title" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-attr-title]" value="<?php echo ucwords( $sidebar['name'] ); ?>">
								<input type="hidden" class="menu-item-title" name="menu-item[<?php echo esc_attr( $i ); ?>][menu-item-title]" value="&nbsp;" />
		        				<input type="hidden" class="menu-item-url" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-url]" value="">
		        				<input type="hidden" class="menu-item-classes" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-classes]" value="menu_sidebar">
		        				<input type="hidden" class="menu-item-description" name="menu-item[<?php echo esc_attr($i); ?>][menu-item-description]" value="<?php echo $sidebar['id']; ?>">
		        			</li>
							<?php
							$i --;
						}
						?>
        			</ul>
        		</div>
        		<p class="button-controls">
        			<span class="list-controls">
        				<a href="<?php echo admin_url( 'nav-menus.php?page-tab=all&amp;selectall=1#posttype-sidebars' ); ?>" class="select-all"><?php esc_html_e('Select All', 'my_custom'); ?></a>
        			</span>
        			<span class="add-to-menu">
        				<input type="submit" class="button-secondary submit-add-to-menu right" value="<?php esc_html_e('Add to Menu', 'my_custom');?>" name="add-post-type-menu-item" id="submit-posttype-sidebars">
        				<span class="spinner"></span>
        			</span>
        		</p>
        	</div>
        <?php }
    }
}

$custom_nav = new sidebars_custom_menu;

add_action('admin_init', array($custom_nav, 'add_nav_menu_meta_boxes'));

You’ll need to copy the entire code, I’ve only shown the 30 lines (because it’s rather big chunk of code). Either click on the ‘Toggle plain code’ button in the upper right or click on the ‘Copy’ button. In it you have code to allow HTML in the description box, megamenu, and adding sidebars to the menu (which is a cool thing to combine with megamenus).

Now the way we add megamenus is basically by adding a classes to our list items. First we get the post meta for each menu item (I said that the menu items are posts)

$post_meta_array = get_post_meta($item->ID);
$is_megamenu = $post_meta_array['_menu_item_megamenu'][0];
$is_mega_menu = ($is_megamenu !== '') ? true : false;

a check to see if we have a megamenu or not

if(!$is_mega_menu){
	$class_names .= ' normal_menu_item';
} else{
	$class_names .= ' '.$is_megamenu;
}

and then we build our html

// build html
$output .= $indent . '<li a id="nav-menu-item-'. $item->ID . '" class="' . $depth_class_names . ' ' . $class_names . '">';
// link attributes
$attributes  = ! empty( $item->attr_title ) ? ' title="'  . esc_attr( $item->attr_title ) .'"' : '';
$attributes .= ! empty( $item->target )     ? ' target="' . esc_attr( $item->target     ) .'"' : '';
$attributes .= ! empty( $item->xfn )        ? ' rel="'    . esc_attr( $item->xfn        ) .'"' : '';
$attributes .= ! empty( $item->url )        ? ' href="' . (($item->url[0] == "#" && !is_front_page()) ? esc_url(home_url('/')) : '') . esc_attr($item->url) .'"' : '';
$attributes .= ' class="menu-link '.((strpos($item->url,'#') === false) ? '' : 'scroll').' ' . ( $depth > 0 ? 'sub-menu-link' : 'main-menu-link' ) . '"';

$html_output = ($use_desc) ? '<div class="description_menu_item">'.$item->description.'</div>' : '';

if ($is_sidebar) {
	ob_start();
	dynamic_sidebar($item->description);
	$sidebar_html = ob_get_clean();
	$sidebar_output = '<div class="sidebar_menu_item">'.$sidebar_html.'</div>';
	$item_output = $sidebar_output;
} else{
	$item_output = (!$no_title) ? '<a ' . $attributes . '><span>' . apply_filters( 'the_title', $item->title, $item->ID ) . '</span></a>'. $html_output : $html_output;
}
// build html
$output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args ).( ($is_mega_menu) ? '<div class="sf-mega"><div class="sf-mega-inner clearfix">':'');

The last part you’ll need is the css that will make it all work on the front end, which is an exercise I leave to you.

menu dropdown field wordpress backend

A working dropdown field on the menu pages in the menu item.

And there you have it. A cool way to add any kind of extra field to your menu pages. Hope this help, if it does or you have any kind of questions, just leave them in the comment below :)

Join the Discussion