PageRenderTime 243ms CodeModel.GetById 29ms app.highlight 164ms RepoModel.GetById 1ms app.codeStats 0ms

/wp-admin/js/customize-nav-menus.js

https://gitlab.com/webkod3r/tripolis
JavaScript | 1625 lines | 1115 code | 218 blank | 292 comment | 188 complexity | 074e06ecc789adcc5c08459a7252c714 MD5 | raw file
   1/* global _wpCustomizeNavMenusSettings, wpNavMenu, console */
   2( function( api, wp, $ ) {
   3	'use strict';
   4
   5	/**
   6	 * Set up wpNavMenu for drag and drop.
   7	 */
   8	wpNavMenu.originalInit = wpNavMenu.init;
   9	wpNavMenu.options.menuItemDepthPerLevel = 20;
  10	wpNavMenu.options.sortableItems         = '> .customize-control-nav_menu_item';
  11	wpNavMenu.options.targetTolerance       = 10;
  12	wpNavMenu.init = function() {
  13		this.jQueryExtensions();
  14	};
  15
  16	api.Menus = api.Menus || {};
  17
  18	// Link settings.
  19	api.Menus.data = {
  20		itemTypes: [],
  21		l10n: {},
  22		settingTransport: 'refresh',
  23		phpIntMax: 0,
  24		defaultSettingValues: {
  25			nav_menu: {},
  26			nav_menu_item: {}
  27		},
  28		locationSlugMappedToName: {}
  29	};
  30	if ( 'undefined' !== typeof _wpCustomizeNavMenusSettings ) {
  31		$.extend( api.Menus.data, _wpCustomizeNavMenusSettings );
  32	}
  33
  34	/**
  35	 * Newly-created Nav Menus and Nav Menu Items have negative integer IDs which
  36	 * serve as placeholders until Save & Publish happens.
  37	 *
  38	 * @return {number}
  39	 */
  40	api.Menus.generatePlaceholderAutoIncrementId = function() {
  41		return -Math.ceil( api.Menus.data.phpIntMax * Math.random() );
  42	};
  43
  44	/**
  45	 * wp.customize.Menus.AvailableItemModel
  46	 *
  47	 * A single available menu item model. See PHP's WP_Customize_Nav_Menu_Item_Setting class.
  48	 *
  49	 * @constructor
  50	 * @augments Backbone.Model
  51	 */
  52	api.Menus.AvailableItemModel = Backbone.Model.extend( $.extend(
  53		{
  54			id: null // This is only used by Backbone.
  55		},
  56		api.Menus.data.defaultSettingValues.nav_menu_item
  57	) );
  58
  59	/**
  60	 * wp.customize.Menus.AvailableItemCollection
  61	 *
  62	 * Collection for available menu item models.
  63	 *
  64	 * @constructor
  65	 * @augments Backbone.Model
  66	 */
  67	api.Menus.AvailableItemCollection = Backbone.Collection.extend({
  68		model: api.Menus.AvailableItemModel,
  69
  70		sort_key: 'order',
  71
  72		comparator: function( item ) {
  73			return -item.get( this.sort_key );
  74		},
  75
  76		sortByField: function( fieldName ) {
  77			this.sort_key = fieldName;
  78			this.sort();
  79		}
  80	});
  81	api.Menus.availableMenuItems = new api.Menus.AvailableItemCollection( api.Menus.data.availableMenuItems );
  82
  83	/**
  84	 * wp.customize.Menus.AvailableMenuItemsPanelView
  85	 *
  86	 * View class for the available menu items panel.
  87	 *
  88	 * @constructor
  89	 * @augments wp.Backbone.View
  90	 * @augments Backbone.View
  91	 */
  92	api.Menus.AvailableMenuItemsPanelView = wp.Backbone.View.extend({
  93
  94		el: '#available-menu-items',
  95
  96		events: {
  97			'input #menu-items-search': 'debounceSearch',
  98			'keyup #menu-items-search': 'debounceSearch',
  99			'focus .menu-item-tpl': 'focus',
 100			'click .menu-item-tpl': '_submit',
 101			'click #custom-menu-item-submit': '_submitLink',
 102			'keypress #custom-menu-item-name': '_submitLink',
 103			'keydown': 'keyboardAccessible'
 104		},
 105
 106		// Cache current selected menu item.
 107		selected: null,
 108
 109		// Cache menu control that opened the panel.
 110		currentMenuControl: null,
 111		debounceSearch: null,
 112		$search: null,
 113		searchTerm: '',
 114		rendered: false,
 115		pages: {},
 116		sectionContent: '',
 117		loading: false,
 118
 119		initialize: function() {
 120			var self = this;
 121
 122			if ( ! api.panel.has( 'nav_menus' ) ) {
 123				return;
 124			}
 125
 126			this.$search = $( '#menu-items-search' );
 127			this.sectionContent = this.$el.find( '.accordion-section-content' );
 128
 129			this.debounceSearch = _.debounce( self.search, 500 );
 130
 131			_.bindAll( this, 'close' );
 132
 133			// If the available menu items panel is open and the customize controls are
 134			// interacted with (other than an item being deleted), then close the
 135			// available menu items panel. Also close on back button click.
 136			$( '#customize-controls, .customize-section-back' ).on( 'click keydown', function( e ) {
 137				var isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ),
 138					isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' );
 139				if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) {
 140					self.close();
 141				}
 142			} );
 143
 144			// Clear the search results.
 145			$( '.clear-results' ).on( 'click keydown', function( event ) {
 146				if ( event.type === 'keydown' && ( 13 !== event.which && 32 !== event.which ) ) { // "return" or "space" keys only
 147					return;
 148				}
 149
 150				event.preventDefault();
 151
 152				$( '#menu-items-search' ).val( '' ).focus();
 153				event.target.value = '';
 154				self.search( event );
 155			} );
 156
 157			this.$el.on( 'input', '#custom-menu-item-name.invalid, #custom-menu-item-url.invalid', function() {
 158				$( this ).removeClass( 'invalid' );
 159			});
 160
 161			// Load available items if it looks like we'll need them.
 162			api.panel( 'nav_menus' ).container.bind( 'expanded', function() {
 163				if ( ! self.rendered ) {
 164					self.initList();
 165					self.rendered = true;
 166				}
 167			});
 168
 169			// Load more items.
 170			this.sectionContent.scroll( function() {
 171				var totalHeight = self.$el.find( '.accordion-section.open .accordion-section-content' ).prop( 'scrollHeight' ),
 172					visibleHeight = self.$el.find( '.accordion-section.open' ).height();
 173
 174				if ( ! self.loading && $( this ).scrollTop() > 3 / 4 * totalHeight - visibleHeight ) {
 175					var type = $( this ).data( 'type' ),
 176						object = $( this ).data( 'object' );
 177
 178					if ( 'search' === type ) {
 179						if ( self.searchTerm ) {
 180							self.doSearch( self.pages.search );
 181						}
 182					} else {
 183						self.loadItems( type, object );
 184					}
 185				}
 186			});
 187
 188			// Close the panel if the URL in the preview changes
 189			api.previewer.bind( 'url', this.close );
 190
 191			self.delegateEvents();
 192		},
 193
 194		// Search input change handler.
 195		search: function( event ) {
 196			var $searchSection = $( '#available-menu-items-search' ),
 197				$otherSections = $( '#available-menu-items .accordion-section' ).not( $searchSection );
 198
 199			if ( ! event ) {
 200				return;
 201			}
 202
 203			if ( this.searchTerm === event.target.value ) {
 204				return;
 205			}
 206
 207			if ( '' !== event.target.value && ! $searchSection.hasClass( 'open' ) ) {
 208				$otherSections.fadeOut( 100 );
 209				$searchSection.find( '.accordion-section-content' ).slideDown( 'fast' );
 210				$searchSection.addClass( 'open' );
 211				$searchSection.find( '.clear-results' )
 212					.prop( 'tabIndex', 0 )
 213					.addClass( 'is-visible' );
 214			} else if ( '' === event.target.value ) {
 215				$searchSection.removeClass( 'open' );
 216				$otherSections.show();
 217				$searchSection.find( '.clear-results' )
 218					.prop( 'tabIndex', -1 )
 219					.removeClass( 'is-visible' );
 220			}
 221
 222			this.searchTerm = event.target.value;
 223			this.pages.search = 1;
 224			this.doSearch( 1 );
 225		},
 226
 227		// Get search results.
 228		doSearch: function( page ) {
 229			var self = this, params,
 230				$section = $( '#available-menu-items-search' ),
 231				$content = $section.find( '.accordion-section-content' ),
 232				itemTemplate = wp.template( 'available-menu-item' );
 233
 234			if ( self.currentRequest ) {
 235				self.currentRequest.abort();
 236			}
 237
 238			if ( page < 0 ) {
 239				return;
 240			} else if ( page > 1 ) {
 241				$section.addClass( 'loading-more' );
 242				$content.attr( 'aria-busy', 'true' );
 243				wp.a11y.speak( api.Menus.data.l10n.itemsLoadingMore );
 244			} else if ( '' === self.searchTerm ) {
 245				$content.html( '' );
 246				wp.a11y.speak( '' );
 247				return;
 248			}
 249
 250			$section.addClass( 'loading' );
 251			self.loading = true;
 252			params = {
 253				'customize-menus-nonce': api.settings.nonce['customize-menus'],
 254				'wp_customize': 'on',
 255				'search': self.searchTerm,
 256				'page': page
 257			};
 258
 259			self.currentRequest = wp.ajax.post( 'search-available-menu-items-customizer', params );
 260
 261			self.currentRequest.done(function( data ) {
 262				var items;
 263				if ( 1 === page ) {
 264					// Clear previous results as it's a new search.
 265					$content.empty();
 266				}
 267				$section.removeClass( 'loading loading-more' );
 268				$content.attr( 'aria-busy', 'false' );
 269				$section.addClass( 'open' );
 270				self.loading = false;
 271				items = new api.Menus.AvailableItemCollection( data.items );
 272				self.collection.add( items.models );
 273				items.each( function( menuItem ) {
 274					$content.append( itemTemplate( menuItem.attributes ) );
 275				} );
 276				if ( 20 > items.length ) {
 277					self.pages.search = -1; // Up to 20 posts and 20 terms in results, if <20, no more results for either.
 278				} else {
 279					self.pages.search = self.pages.search + 1;
 280				}
 281				if ( items && page > 1 ) {
 282					wp.a11y.speak( api.Menus.data.l10n.itemsFoundMore.replace( '%d', items.length ) );
 283				} else if ( items && page === 1 ) {
 284					wp.a11y.speak( api.Menus.data.l10n.itemsFound.replace( '%d', items.length ) );
 285				}
 286			});
 287
 288			self.currentRequest.fail(function( data ) {
 289				// data.message may be undefined, for example when typing slow and the request is aborted.
 290				if ( data.message ) {
 291					$content.empty().append( $( '<p class="nothing-found"></p>' ).text( data.message ) );
 292					wp.a11y.speak( data.message );
 293				}
 294				self.pages.search = -1;
 295			});
 296
 297			self.currentRequest.always(function() {
 298				$section.removeClass( 'loading loading-more' );
 299				$content.attr( 'aria-busy', 'false' );
 300				self.loading = false;
 301				self.currentRequest = null;
 302			});
 303		},
 304
 305		// Render the individual items.
 306		initList: function() {
 307			var self = this;
 308
 309			// Render the template for each item by type.
 310			_.each( api.Menus.data.itemTypes, function( itemType ) {
 311				self.pages[ itemType.type + ':' + itemType.object ] = 0;
 312				self.loadItems( itemType.type, itemType.object ); // @todo we need to combine these Ajax requests.
 313			} );
 314		},
 315
 316		// Load available menu items.
 317		loadItems: function( type, object ) {
 318			var self = this, params, request, itemTemplate, availableMenuItemContainer;
 319			itemTemplate = wp.template( 'available-menu-item' );
 320
 321			if ( -1 === self.pages[ type + ':' + object ] ) {
 322				return;
 323			}
 324			availableMenuItemContainer = $( '#available-menu-items-' + type + '-' + object );
 325			availableMenuItemContainer.find( '.accordion-section-title' ).addClass( 'loading' );
 326			self.loading = true;
 327			params = {
 328				'customize-menus-nonce': api.settings.nonce['customize-menus'],
 329				'wp_customize': 'on',
 330				'type': type,
 331				'object': object,
 332				'page': self.pages[ type + ':' + object ]
 333			};
 334			request = wp.ajax.post( 'load-available-menu-items-customizer', params );
 335
 336			request.done(function( data ) {
 337				var items, typeInner;
 338				items = data.items;
 339				if ( 0 === items.length ) {
 340					if ( 0 === self.pages[ type + ':' + object ] ) {
 341						availableMenuItemContainer
 342							.addClass( 'cannot-expand' )
 343							.removeClass( 'loading' )
 344							.find( '.accordion-section-title > button' )
 345							.prop( 'tabIndex', -1 );
 346					}
 347					self.pages[ type + ':' + object ] = -1;
 348					return;
 349				}
 350				items = new api.Menus.AvailableItemCollection( items ); // @todo Why is this collection created and then thrown away?
 351				self.collection.add( items.models );
 352				typeInner = availableMenuItemContainer.find( '.accordion-section-content' );
 353				items.each(function( menuItem ) {
 354					typeInner.append( itemTemplate( menuItem.attributes ) );
 355				});
 356				self.pages[ type + ':' + object ] += 1;
 357			});
 358			request.fail(function( data ) {
 359				if ( typeof console !== 'undefined' && console.error ) {
 360					console.error( data );
 361				}
 362			});
 363			request.always(function() {
 364				availableMenuItemContainer.find( '.accordion-section-title' ).removeClass( 'loading' );
 365				self.loading = false;
 366			});
 367		},
 368
 369		// Adjust the height of each section of items to fit the screen.
 370		itemSectionHeight: function() {
 371			var sections, totalHeight, accordionHeight, diff;
 372			totalHeight = window.innerHeight;
 373			sections = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .accordion-section-content' );
 374			accordionHeight =  46 * ( 2 + sections.length ) - 13; // Magic numbers.
 375			diff = totalHeight - accordionHeight;
 376			if ( 120 < diff && 290 > diff ) {
 377				sections.css( 'max-height', diff );
 378			}
 379		},
 380
 381		// Highlights a menu item.
 382		select: function( menuitemTpl ) {
 383			this.selected = $( menuitemTpl );
 384			this.selected.siblings( '.menu-item-tpl' ).removeClass( 'selected' );
 385			this.selected.addClass( 'selected' );
 386		},
 387
 388		// Highlights a menu item on focus.
 389		focus: function( event ) {
 390			this.select( $( event.currentTarget ) );
 391		},
 392
 393		// Submit handler for keypress and click on menu item.
 394		_submit: function( event ) {
 395			// Only proceed with keypress if it is Enter or Spacebar
 396			if ( 'keypress' === event.type && ( 13 !== event.which && 32 !== event.which ) ) {
 397				return;
 398			}
 399
 400			this.submit( $( event.currentTarget ) );
 401		},
 402
 403		// Adds a selected menu item to the menu.
 404		submit: function( menuitemTpl ) {
 405			var menuitemId, menu_item;
 406
 407			if ( ! menuitemTpl ) {
 408				menuitemTpl = this.selected;
 409			}
 410
 411			if ( ! menuitemTpl || ! this.currentMenuControl ) {
 412				return;
 413			}
 414
 415			this.select( menuitemTpl );
 416
 417			menuitemId = $( this.selected ).data( 'menu-item-id' );
 418			menu_item = this.collection.findWhere( { id: menuitemId } );
 419			if ( ! menu_item ) {
 420				return;
 421			}
 422
 423			this.currentMenuControl.addItemToMenu( menu_item.attributes );
 424
 425			$( menuitemTpl ).find( '.menu-item-handle' ).addClass( 'item-added' );
 426		},
 427
 428		// Submit handler for keypress and click on custom menu item.
 429		_submitLink: function( event ) {
 430			// Only proceed with keypress if it is Enter.
 431			if ( 'keypress' === event.type && 13 !== event.which ) {
 432				return;
 433			}
 434
 435			this.submitLink();
 436		},
 437
 438		// Adds the custom menu item to the menu.
 439		submitLink: function() {
 440			var menuItem,
 441				itemName = $( '#custom-menu-item-name' ),
 442				itemUrl = $( '#custom-menu-item-url' );
 443
 444			if ( ! this.currentMenuControl ) {
 445				return;
 446			}
 447
 448			if ( '' === itemName.val() ) {
 449				itemName.addClass( 'invalid' );
 450				return;
 451			} else if ( '' === itemUrl.val() || 'http://' === itemUrl.val() ) {
 452				itemUrl.addClass( 'invalid' );
 453				return;
 454			}
 455
 456			menuItem = {
 457				'title': itemName.val(),
 458				'url': itemUrl.val(),
 459				'type': 'custom',
 460				'type_label': api.Menus.data.l10n.custom_label,
 461				'object': ''
 462			};
 463
 464			this.currentMenuControl.addItemToMenu( menuItem );
 465
 466			// Reset the custom link form.
 467			itemUrl.val( 'http://' );
 468			itemName.val( '' );
 469		},
 470
 471		// Opens the panel.
 472		open: function( menuControl ) {
 473			this.currentMenuControl = menuControl;
 474
 475			this.itemSectionHeight();
 476
 477			$( 'body' ).addClass( 'adding-menu-items' );
 478
 479			// Collapse all controls.
 480			_( this.currentMenuControl.getMenuItemControls() ).each( function( control ) {
 481				control.collapseForm();
 482			} );
 483
 484			this.$el.find( '.selected' ).removeClass( 'selected' );
 485
 486			this.$search.focus();
 487		},
 488
 489		// Closes the panel
 490		close: function( options ) {
 491			options = options || {};
 492
 493			if ( options.returnFocus && this.currentMenuControl ) {
 494				this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
 495			}
 496
 497			this.currentMenuControl = null;
 498			this.selected = null;
 499
 500			$( 'body' ).removeClass( 'adding-menu-items' );
 501			$( '#available-menu-items .menu-item-handle.item-added' ).removeClass( 'item-added' );
 502
 503			this.$search.val( '' );
 504		},
 505
 506		// Add a few keyboard enhancements to the panel.
 507		keyboardAccessible: function( event ) {
 508			var isEnter = ( 13 === event.which ),
 509				isEsc = ( 27 === event.which ),
 510				isBackTab = ( 9 === event.which && event.shiftKey ),
 511				isSearchFocused = $( event.target ).is( this.$search );
 512
 513			// If enter pressed but nothing entered, don't do anything
 514			if ( isEnter && ! this.$search.val() ) {
 515				return;
 516			}
 517
 518			if ( isSearchFocused && isBackTab ) {
 519				this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
 520				event.preventDefault(); // Avoid additional back-tab.
 521			} else if ( isEsc ) {
 522				this.close( { returnFocus: true } );
 523			}
 524		}
 525	});
 526
 527	/**
 528	 * wp.customize.Menus.MenusPanel
 529	 *
 530	 * Customizer panel for menus. This is used only for screen options management.
 531	 * Note that 'menus' must match the WP_Customize_Menu_Panel::$type.
 532	 *
 533	 * @constructor
 534	 * @augments wp.customize.Panel
 535	 */
 536	api.Menus.MenusPanel = api.Panel.extend({
 537
 538		attachEvents: function() {
 539			api.Panel.prototype.attachEvents.call( this );
 540
 541			var panel = this,
 542				panelMeta = panel.container.find( '.panel-meta' ),
 543				help = panelMeta.find( '.customize-help-toggle' ),
 544				content = panelMeta.find( '.customize-panel-description' ),
 545				options = $( '#screen-options-wrap' ),
 546				button = panelMeta.find( '.customize-screen-options-toggle' );
 547			button.on( 'click keydown', function( event ) {
 548				if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
 549					return;
 550				}
 551				event.preventDefault();
 552
 553				// Hide description
 554				if ( content.not( ':hidden' ) ) {
 555					content.slideUp( 'fast' );
 556					help.attr( 'aria-expanded', 'false' );
 557				}
 558
 559				if ( 'true' === button.attr( 'aria-expanded' ) ) {
 560					button.attr( 'aria-expanded', 'false' );
 561					panelMeta.removeClass( 'open' );
 562					panelMeta.removeClass( 'active-menu-screen-options' );
 563					options.slideUp( 'fast' );
 564				} else {
 565					button.attr( 'aria-expanded', 'true' );
 566					panelMeta.addClass( 'open' );
 567					panelMeta.addClass( 'active-menu-screen-options' );
 568					options.slideDown( 'fast' );
 569				}
 570
 571				return false;
 572			} );
 573
 574			// Help toggle
 575			help.on( 'click keydown', function( event ) {
 576				if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
 577					return;
 578				}
 579				event.preventDefault();
 580
 581				if ( 'true' === button.attr( 'aria-expanded' ) ) {
 582					button.attr( 'aria-expanded', 'false' );
 583					help.attr( 'aria-expanded', 'true' );
 584					panelMeta.addClass( 'open' );
 585					panelMeta.removeClass( 'active-menu-screen-options' );
 586					options.slideUp( 'fast' );
 587					content.slideDown( 'fast' );
 588				}
 589			} );
 590		},
 591
 592		/**
 593		 * Show/hide/save screen options (columns). From common.js.
 594		 */
 595		ready: function() {
 596			var panel = this;
 597			this.container.find( '.hide-column-tog' ).click( function() {
 598				var $t = $( this ), column = $t.val();
 599				if ( $t.prop( 'checked' ) ) {
 600					panel.checked( column );
 601				} else {
 602					panel.unchecked( column );
 603				}
 604
 605				panel.saveManageColumnsState();
 606			});
 607			this.container.find( '.hide-column-tog' ).each( function() {
 608			var $t = $( this ), column = $t.val();
 609				if ( $t.prop( 'checked' ) ) {
 610					panel.checked( column );
 611				} else {
 612					panel.unchecked( column );
 613				}
 614			});
 615		},
 616
 617		saveManageColumnsState: _.debounce( function() {
 618			var panel = this;
 619			if ( panel._updateHiddenColumnsRequest ) {
 620				panel._updateHiddenColumnsRequest.abort();
 621			}
 622
 623			panel._updateHiddenColumnsRequest = wp.ajax.post( 'hidden-columns', {
 624				hidden: panel.hidden(),
 625				screenoptionnonce: $( '#screenoptionnonce' ).val(),
 626				page: 'nav-menus'
 627			} );
 628			panel._updateHiddenColumnsRequest.always( function() {
 629				panel._updateHiddenColumnsRequest = null;
 630			} );
 631		}, 2000 ),
 632
 633		checked: function( column ) {
 634			this.container.addClass( 'field-' + column + '-active' );
 635		},
 636
 637		unchecked: function( column ) {
 638			this.container.removeClass( 'field-' + column + '-active' );
 639		},
 640
 641		hidden: function() {
 642			return $( '.hide-column-tog' ).not( ':checked' ).map( function() {
 643				var id = this.id;
 644				return id.substring( 0, id.length - 5 );
 645			}).get().join( ',' );
 646		}
 647	} );
 648
 649	/**
 650	 * wp.customize.Menus.MenuSection
 651	 *
 652	 * Customizer section for menus. This is used only for lazy-loading child controls.
 653	 * Note that 'nav_menu' must match the WP_Customize_Menu_Section::$type.
 654	 *
 655	 * @constructor
 656	 * @augments wp.customize.Section
 657	 */
 658	api.Menus.MenuSection = api.Section.extend({
 659
 660		/**
 661		 * @since Menu Customizer 0.3
 662		 *
 663		 * @param {String} id
 664		 * @param {Object} options
 665		 */
 666		initialize: function( id, options ) {
 667			var section = this;
 668			api.Section.prototype.initialize.call( section, id, options );
 669			section.deferred.initSortables = $.Deferred();
 670		},
 671
 672		/**
 673		 *
 674		 */
 675		ready: function() {
 676			var section = this;
 677
 678			if ( 'undefined' === typeof section.params.menu_id ) {
 679				throw new Error( 'params.menu_id was not defined' );
 680			}
 681
 682			/*
 683			 * Since newly created sections won't be registered in PHP, we need to prevent the
 684			 * preview's sending of the activeSections to result in this control
 685			 * being deactivated when the preview refreshes. So we can hook onto
 686			 * the setting that has the same ID and its presence can dictate
 687			 * whether the section is active.
 688			 */
 689			section.active.validate = function() {
 690				if ( ! api.has( section.id ) ) {
 691					return false;
 692				}
 693				return !! api( section.id ).get();
 694			};
 695
 696			section.populateControls();
 697
 698			section.navMenuLocationSettings = {};
 699			section.assignedLocations = new api.Value( [] );
 700
 701			api.each(function( setting, id ) {
 702				var matches = id.match( /^nav_menu_locations\[(.+?)]/ );
 703				if ( matches ) {
 704					section.navMenuLocationSettings[ matches[1] ] = setting;
 705					setting.bind( function() {
 706						section.refreshAssignedLocations();
 707					});
 708				}
 709			});
 710
 711			section.assignedLocations.bind(function( to ) {
 712				section.updateAssignedLocationsInSectionTitle( to );
 713			});
 714
 715			section.refreshAssignedLocations();
 716
 717			api.bind( 'pane-contents-reflowed', function() {
 718				// Skip menus that have been removed.
 719				if ( ! section.container.parent().length ) {
 720					return;
 721				}
 722				section.container.find( '.menu-item .menu-item-reorder-nav button' ).attr({ 'tabindex': '0', 'aria-hidden': 'false' });
 723				section.container.find( '.menu-item.move-up-disabled .menus-move-up' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
 724				section.container.find( '.menu-item.move-down-disabled .menus-move-down' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
 725				section.container.find( '.menu-item.move-left-disabled .menus-move-left' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
 726				section.container.find( '.menu-item.move-right-disabled .menus-move-right' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
 727			} );
 728		},
 729
 730		populateControls: function() {
 731			var section = this, menuNameControlId, menuAutoAddControlId, menuControl, menuNameControl, menuAutoAddControl;
 732
 733			// Add the control for managing the menu name.
 734			menuNameControlId = section.id + '[name]';
 735			menuNameControl = api.control( menuNameControlId );
 736			if ( ! menuNameControl ) {
 737				menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, {
 738					params: {
 739						type: 'nav_menu_name',
 740						content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '-name" class="customize-control customize-control-nav_menu_name"></li>', // @todo core should do this for us; see #30741
 741						label: api.Menus.data.l10n.menuNameLabel,
 742						active: true,
 743						section: section.id,
 744						priority: 0,
 745						settings: {
 746							'default': section.id
 747						}
 748					}
 749				} );
 750				api.control.add( menuNameControl.id, menuNameControl );
 751				menuNameControl.active.set( true );
 752			}
 753
 754			// Add the menu control.
 755			menuControl = api.control( section.id );
 756			if ( ! menuControl ) {
 757				menuControl = new api.controlConstructor.nav_menu( section.id, {
 758					params: {
 759						type: 'nav_menu',
 760						content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '" class="customize-control customize-control-nav_menu"></li>', // @todo core should do this for us; see #30741
 761						section: section.id,
 762						priority: 998,
 763						active: true,
 764						settings: {
 765							'default': section.id
 766						},
 767						menu_id: section.params.menu_id
 768					}
 769				} );
 770				api.control.add( menuControl.id, menuControl );
 771				menuControl.active.set( true );
 772			}
 773
 774			// Add the control for managing the menu auto_add.
 775			menuAutoAddControlId = section.id + '[auto_add]';
 776			menuAutoAddControl = api.control( menuAutoAddControlId );
 777			if ( ! menuAutoAddControl ) {
 778				menuAutoAddControl = new api.controlConstructor.nav_menu_auto_add( menuAutoAddControlId, {
 779					params: {
 780						type: 'nav_menu_auto_add',
 781						content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '-auto-add" class="customize-control customize-control-nav_menu_auto_add"></li>', // @todo core should do this for us
 782						label: '',
 783						active: true,
 784						section: section.id,
 785						priority: 999,
 786						settings: {
 787							'default': section.id
 788						}
 789					}
 790				} );
 791				api.control.add( menuAutoAddControl.id, menuAutoAddControl );
 792				menuAutoAddControl.active.set( true );
 793			}
 794
 795		},
 796
 797		/**
 798		 *
 799		 */
 800		refreshAssignedLocations: function() {
 801			var section = this,
 802				menuTermId = section.params.menu_id,
 803				currentAssignedLocations = [];
 804			_.each( section.navMenuLocationSettings, function( setting, themeLocation ) {
 805				if ( setting() === menuTermId ) {
 806					currentAssignedLocations.push( themeLocation );
 807				}
 808			});
 809			section.assignedLocations.set( currentAssignedLocations );
 810		},
 811
 812		/**
 813		 * @param {array} themeLocations
 814		 */
 815		updateAssignedLocationsInSectionTitle: function( themeLocationSlugs ) {
 816			var section = this,
 817				$title;
 818
 819			$title = section.container.find( '.accordion-section-title:first' );
 820			$title.find( '.menu-in-location' ).remove();
 821			_.each( themeLocationSlugs, function( themeLocationSlug ) {
 822				var $label, locationName;
 823				$label = $( '<span class="menu-in-location"></span>' );
 824				locationName = api.Menus.data.locationSlugMappedToName[ themeLocationSlug ];
 825				$label.text( api.Menus.data.l10n.menuLocation.replace( '%s', locationName ) );
 826				$title.append( $label );
 827			});
 828
 829			section.container.toggleClass( 'assigned-to-menu-location', 0 !== themeLocationSlugs.length );
 830
 831		},
 832
 833		onChangeExpanded: function( expanded, args ) {
 834			var section = this;
 835
 836			if ( expanded ) {
 837				wpNavMenu.menuList = section.container.find( '.accordion-section-content:first' );
 838				wpNavMenu.targetList = wpNavMenu.menuList;
 839
 840				// Add attributes needed by wpNavMenu
 841				$( '#menu-to-edit' ).removeAttr( 'id' );
 842				wpNavMenu.menuList.attr( 'id', 'menu-to-edit' ).addClass( 'menu' );
 843
 844				_.each( api.section( section.id ).controls(), function( control ) {
 845					if ( 'nav_menu_item' === control.params.type ) {
 846						control.actuallyEmbed();
 847					}
 848				} );
 849
 850				if ( 'resolved' !== section.deferred.initSortables.state() ) {
 851					wpNavMenu.initSortables(); // Depends on menu-to-edit ID being set above.
 852					section.deferred.initSortables.resolve( wpNavMenu.menuList ); // Now MenuControl can extend the sortable.
 853
 854					// @todo Note that wp.customize.reflowPaneContents() is debounced, so this immediate change will show a slight flicker while priorities get updated.
 855					api.control( 'nav_menu[' + String( section.params.menu_id ) + ']' ).reflowMenuItems();
 856				}
 857			}
 858			api.Section.prototype.onChangeExpanded.call( section, expanded, args );
 859		}
 860	});
 861
 862	/**
 863	 * wp.customize.Menus.NewMenuSection
 864	 *
 865	 * Customizer section for new menus.
 866	 * Note that 'new_menu' must match the WP_Customize_New_Menu_Section::$type.
 867	 *
 868	 * @constructor
 869	 * @augments wp.customize.Section
 870	 */
 871	api.Menus.NewMenuSection = api.Section.extend({
 872
 873		/**
 874		 * Add behaviors for the accordion section.
 875		 *
 876		 * @since Menu Customizer 0.3
 877		 */
 878		attachEvents: function() {
 879			var section = this;
 880			this.container.on( 'click', '.add-menu-toggle', function() {
 881				if ( section.expanded() ) {
 882					section.collapse();
 883				} else {
 884					section.expand();
 885				}
 886			});
 887		},
 888
 889		/**
 890		 * Update UI to reflect expanded state.
 891		 *
 892		 * @since 4.1.0
 893		 *
 894		 * @param {Boolean} expanded
 895		 */
 896		onChangeExpanded: function( expanded ) {
 897			var section = this,
 898				button = section.container.find( '.add-menu-toggle' ),
 899				content = section.container.find( '.new-menu-section-content' ),
 900				customizer = section.container.closest( '.wp-full-overlay-sidebar-content' );
 901			if ( expanded ) {
 902				button.addClass( 'open' );
 903				button.attr( 'aria-expanded', 'true' );
 904				content.slideDown( 'fast', function() {
 905					customizer.scrollTop( customizer.height() );
 906				});
 907			} else {
 908				button.removeClass( 'open' );
 909				button.attr( 'aria-expanded', 'false' );
 910				content.slideUp( 'fast' );
 911				content.find( '.menu-name-field' ).removeClass( 'invalid' );
 912			}
 913		}
 914	});
 915
 916	/**
 917	 * wp.customize.Menus.MenuLocationControl
 918	 *
 919	 * Customizer control for menu locations (rendered as a <select>).
 920	 * Note that 'nav_menu_location' must match the WP_Customize_Nav_Menu_Location_Control::$type.
 921	 *
 922	 * @constructor
 923	 * @augments wp.customize.Control
 924	 */
 925	api.Menus.MenuLocationControl = api.Control.extend({
 926		initialize: function( id, options ) {
 927			var control = this,
 928				matches = id.match( /^nav_menu_locations\[(.+?)]/ );
 929			control.themeLocation = matches[1];
 930			api.Control.prototype.initialize.call( control, id, options );
 931		},
 932
 933		ready: function() {
 934			var control = this, navMenuIdRegex = /^nav_menu\[(-?\d+)]/;
 935
 936			// @todo It would be better if this was added directly on the setting itself, as opposed to the control.
 937			control.setting.validate = function( value ) {
 938				return parseInt( value, 10 );
 939			};
 940
 941			// Add/remove menus from the available options when they are added and removed.
 942			api.bind( 'add', function( setting ) {
 943				var option, menuId, matches = setting.id.match( navMenuIdRegex );
 944				if ( ! matches || false === setting() ) {
 945					return;
 946				}
 947				menuId = matches[1];
 948				option = new Option( displayNavMenuName( setting().name ), menuId );
 949				control.container.find( 'select' ).append( option );
 950			});
 951			api.bind( 'remove', function( setting ) {
 952				var menuId, matches = setting.id.match( navMenuIdRegex );
 953				if ( ! matches ) {
 954					return;
 955				}
 956				menuId = parseInt( matches[1], 10 );
 957				if ( control.setting() === menuId ) {
 958					control.setting.set( '' );
 959				}
 960				control.container.find( 'option[value=' + menuId + ']' ).remove();
 961			});
 962			api.bind( 'change', function( setting ) {
 963				var menuId, matches = setting.id.match( navMenuIdRegex );
 964				if ( ! matches ) {
 965					return;
 966				}
 967				menuId = parseInt( matches[1], 10 );
 968				if ( false === setting() ) {
 969					if ( control.setting() === menuId ) {
 970						control.setting.set( '' );
 971					}
 972					control.container.find( 'option[value=' + menuId + ']' ).remove();
 973				} else {
 974					control.container.find( 'option[value=' + menuId + ']' ).text( displayNavMenuName( setting().name ) );
 975				}
 976			});
 977		}
 978	});
 979
 980	/**
 981	 * wp.customize.Menus.MenuItemControl
 982	 *
 983	 * Customizer control for menu items.
 984	 * Note that 'menu_item' must match the WP_Customize_Menu_Item_Control::$type.
 985	 *
 986	 * @constructor
 987	 * @augments wp.customize.Control
 988	 */
 989	api.Menus.MenuItemControl = api.Control.extend({
 990
 991		/**
 992		 * @inheritdoc
 993		 */
 994		initialize: function( id, options ) {
 995			var control = this;
 996			api.Control.prototype.initialize.call( control, id, options );
 997			control.active.validate = function() {
 998				var value, section = api.section( control.section() );
 999				if ( section ) {
1000					value = section.active();
1001				} else {
1002					value = false;
1003				}
1004				return value;
1005			};
1006		},
1007
1008		/**
1009		 * @since Menu Customizer 0.3
1010		 *
1011		 * Override the embed() method to do nothing,
1012		 * so that the control isn't embedded on load,
1013		 * unless the containing section is already expanded.
1014		 */
1015		embed: function() {
1016			var control = this,
1017				sectionId = control.section(),
1018				section;
1019			if ( ! sectionId ) {
1020				return;
1021			}
1022			section = api.section( sectionId );
1023			if ( ( section && section.expanded() ) || api.settings.autofocus.control === control.id ) {
1024				control.actuallyEmbed();
1025			}
1026		},
1027
1028		/**
1029		 * This function is called in Section.onChangeExpanded() so the control
1030		 * will only get embedded when the Section is first expanded.
1031		 *
1032		 * @since Menu Customizer 0.3
1033		 */
1034		actuallyEmbed: function() {
1035			var control = this;
1036			if ( 'resolved' === control.deferred.embedded.state() ) {
1037				return;
1038			}
1039			control.renderContent();
1040			control.deferred.embedded.resolve(); // This triggers control.ready().
1041		},
1042
1043		/**
1044		 * Set up the control.
1045		 */
1046		ready: function() {
1047			if ( 'undefined' === typeof this.params.menu_item_id ) {
1048				throw new Error( 'params.menu_item_id was not defined' );
1049			}
1050
1051			this._setupControlToggle();
1052			this._setupReorderUI();
1053			this._setupUpdateUI();
1054			this._setupRemoveUI();
1055			this._setupLinksUI();
1056			this._setupTitleUI();
1057		},
1058
1059		/**
1060		 * Show/hide the settings when clicking on the menu item handle.
1061		 */
1062		_setupControlToggle: function() {
1063			var control = this;
1064
1065			this.container.find( '.menu-item-handle' ).on( 'click', function( e ) {
1066				e.preventDefault();
1067				e.stopPropagation();
1068				var menuControl = control.getMenuControl();
1069				if ( menuControl.isReordering || menuControl.isSorting ) {
1070					return;
1071				}
1072				control.toggleForm();
1073			} );
1074		},
1075
1076		/**
1077		 * Set up the menu-item-reorder-nav
1078		 */
1079		_setupReorderUI: function() {
1080			var control = this, template, $reorderNav;
1081
1082			template = wp.template( 'menu-item-reorder-nav' );
1083
1084			// Add the menu item reordering elements to the menu item control.
1085			control.container.find( '.item-controls' ).after( template );
1086
1087			// Handle clicks for up/down/left-right on the reorder nav.
1088			$reorderNav = control.container.find( '.menu-item-reorder-nav' );
1089			$reorderNav.find( '.menus-move-up, .menus-move-down, .menus-move-left, .menus-move-right' ).on( 'click', function() {
1090				var moveBtn = $( this );
1091				moveBtn.focus();
1092
1093				var isMoveUp = moveBtn.is( '.menus-move-up' ),
1094					isMoveDown = moveBtn.is( '.menus-move-down' ),
1095					isMoveLeft = moveBtn.is( '.menus-move-left' ),
1096					isMoveRight = moveBtn.is( '.menus-move-right' );
1097
1098				if ( isMoveUp ) {
1099					control.moveUp();
1100				} else if ( isMoveDown ) {
1101					control.moveDown();
1102				} else if ( isMoveLeft ) {
1103					control.moveLeft();
1104				} else if ( isMoveRight ) {
1105					control.moveRight();
1106				}
1107
1108				moveBtn.focus(); // Re-focus after the container was moved.
1109			} );
1110		},
1111
1112		/**
1113		 * Set up event handlers for menu item updating.
1114		 */
1115		_setupUpdateUI: function() {
1116			var control = this,
1117				settingValue = control.setting();
1118
1119			control.elements = {};
1120			control.elements.url = new api.Element( control.container.find( '.edit-menu-item-url' ) );
1121			control.elements.title = new api.Element( control.container.find( '.edit-menu-item-title' ) );
1122			control.elements.attr_title = new api.Element( control.container.find( '.edit-menu-item-attr-title' ) );
1123			control.elements.target = new api.Element( control.container.find( '.edit-menu-item-target' ) );
1124			control.elements.classes = new api.Element( control.container.find( '.edit-menu-item-classes' ) );
1125			control.elements.xfn = new api.Element( control.container.find( '.edit-menu-item-xfn' ) );
1126			control.elements.description = new api.Element( control.container.find( '.edit-menu-item-description' ) );
1127			// @todo allow other elements, added by plugins, to be automatically picked up here; allow additional values to be added to setting array.
1128
1129			_.each( control.elements, function( element, property ) {
1130				element.bind(function( value ) {
1131					if ( element.element.is( 'input[type=checkbox]' ) ) {
1132						value = ( value ) ? element.element.val() : '';
1133					}
1134
1135					var settingValue = control.setting();
1136					if ( settingValue && settingValue[ property ] !== value ) {
1137						settingValue = _.clone( settingValue );
1138						settingValue[ property ] = value;
1139						control.setting.set( settingValue );
1140					}
1141				});
1142				if ( settingValue ) {
1143					if ( ( property === 'classes' || property === 'xfn' ) && _.isArray( settingValue[ property ] ) ) {
1144						element.set( settingValue[ property ].join( ' ' ) );
1145					} else {
1146						element.set( settingValue[ property ] );
1147					}
1148				}
1149			});
1150
1151			control.setting.bind(function( to, from ) {
1152				var itemId = control.params.menu_item_id,
1153					followingSiblingItemControls = [],
1154					childrenItemControls = [],
1155					menuControl;
1156
1157				if ( false === to ) {
1158					menuControl = api.control( 'nav_menu[' + String( from.nav_menu_term_id ) + ']' );
1159					control.container.remove();
1160
1161					_.each( menuControl.getMenuItemControls(), function( otherControl ) {
1162						if ( from.menu_item_parent === otherControl.setting().menu_item_parent && otherControl.setting().position > from.position ) {
1163							followingSiblingItemControls.push( otherControl );
1164						} else if ( otherControl.setting().menu_item_parent === itemId ) {
1165							childrenItemControls.push( otherControl );
1166						}
1167					});
1168
1169					// Shift all following siblings by the number of children this item has.
1170					_.each( followingSiblingItemControls, function( followingSiblingItemControl ) {
1171						var value = _.clone( followingSiblingItemControl.setting() );
1172						value.position += childrenItemControls.length;
1173						followingSiblingItemControl.setting.set( value );
1174					});
1175
1176					// Now move the children up to be the new subsequent siblings.
1177					_.each( childrenItemControls, function( childrenItemControl, i ) {
1178						var value = _.clone( childrenItemControl.setting() );
1179						value.position = from.position + i;
1180						value.menu_item_parent = from.menu_item_parent;
1181						childrenItemControl.setting.set( value );
1182					});
1183
1184					menuControl.debouncedReflowMenuItems();
1185				} else {
1186					// Update the elements' values to match the new setting properties.
1187					_.each( to, function( value, key ) {
1188						if ( control.elements[ key] ) {
1189							control.elements[ key ].set( to[ key ] );
1190						}
1191					} );
1192					control.container.find( '.menu-item-data-parent-id' ).val( to.menu_item_parent );
1193
1194					// Handle UI updates when the position or depth (parent) change.
1195					if ( to.position !== from.position || to.menu_item_parent !== from.menu_item_parent ) {
1196						control.getMenuControl().debouncedReflowMenuItems();
1197					}
1198				}
1199			});
1200		},
1201
1202		/**
1203		 * Set up event handlers for menu item deletion.
1204		 */
1205		_setupRemoveUI: function() {
1206			var control = this, $removeBtn;
1207
1208			// Configure delete button.
1209			$removeBtn = control.container.find( '.item-delete' );
1210
1211			$removeBtn.on( 'click', function() {
1212				// Find an adjacent element to add focus to when this menu item goes away
1213				var addingItems = true, $adjacentFocusTarget, $next, $prev;
1214
1215				if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) {
1216					addingItems = false;
1217				}
1218
1219				$next = control.container.nextAll( '.customize-control-nav_menu_item:visible' ).first();
1220				$prev = control.container.prevAll( '.customize-control-nav_menu_item:visible' ).first();
1221
1222				if ( $next.length ) {
1223					$adjacentFocusTarget = $next.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1224				} else if ( $prev.length ) {
1225					$adjacentFocusTarget = $prev.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1226				} else {
1227					$adjacentFocusTarget = control.container.nextAll( '.customize-control-nav_menu' ).find( '.add-new-menu-item' ).first();
1228				}
1229
1230				control.container.slideUp( function() {
1231					control.setting.set( false );
1232					wp.a11y.speak( api.Menus.data.l10n.itemDeleted );
1233					$adjacentFocusTarget.focus(); // keyboard accessibility
1234				} );
1235			} );
1236		},
1237
1238		_setupLinksUI: function() {
1239			var $origBtn;
1240
1241			// Configure original link.
1242			$origBtn = this.container.find( 'a.original-link' );
1243
1244			$origBtn.on( 'click', function( e ) {
1245				e.preventDefault();
1246				api.previewer.previewUrl( e.target.toString() );
1247			} );
1248		},
1249
1250		/**
1251		 * Update item handle title when changed.
1252		 */
1253		_setupTitleUI: function() {
1254			var control = this;
1255
1256			control.setting.bind( function( item ) {
1257				if ( ! item ) {
1258					return;
1259				}
1260
1261				var titleEl = control.container.find( '.menu-item-title' ),
1262				    titleText = item.title || api.Menus.data.l10n.untitled;
1263
1264				if ( item._invalid ) {
1265					titleText = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', titleText );
1266				}
1267
1268				// Don't update to an empty title.
1269				if ( item.title ) {
1270					titleEl
1271						.text( titleText )
1272						.removeClass( 'no-title' );
1273				} else {
1274					titleEl
1275						.text( titleText )
1276						.addClass( 'no-title' );
1277				}
1278			} );
1279		},
1280
1281		/**
1282		 *
1283		 * @returns {number}
1284		 */
1285		getDepth: function() {
1286			var control = this, setting = control.setting(), depth = 0;
1287			if ( ! setting ) {
1288				return 0;
1289			}
1290			while ( setting && setting.menu_item_parent ) {
1291				depth += 1;
1292				control = api.control( 'nav_menu_item[' + setting.menu_item_parent + ']' );
1293				if ( ! control ) {
1294					break;
1295				}
1296				setting = control.setting();
1297			}
1298			return depth;
1299		},
1300
1301		/**
1302		 * Amend the control's params with the data necessary for the JS template just in time.
1303		 */
1304		renderContent: function() {
1305			var control = this,
1306				settingValue = control.setting(),
1307				containerClasses;
1308
1309			control.params.title = settingValue.title || '';
1310			control.params.depth = control.getDepth();
1311			control.container.data( 'item-depth', control.params.depth );
1312			containerClasses = [
1313				'menu-item',
1314				'menu-item-depth-' + String( control.params.depth ),
1315				'menu-item-' + settingValue.object,
1316				'menu-item-edit-inactive'
1317			];
1318
1319			if ( settingValue._invalid ) {
1320				containerClasses.push( 'menu-item-invalid' );
1321				control.params.title = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', control.params.title );
1322			} else if ( 'draft' === settingValue.status ) {
1323				containerClasses.push( 'pending' );
1324				control.params.title = api.Menus.data.pendingTitleTpl.replace( '%s', control.params.title );
1325			}
1326
1327			control.params.el_classes = containerClasses.join( ' ' );
1328			control.params.item_type_label = settingValue.type_label;
1329			control.params.item_type = settingValue.type;
1330			control.params.url = settingValue.url;
1331			control.params.target = settingValue.target;
1332			control.params.attr_title = settingValue.attr_title;
1333			control.params.classes = _.isArray( settingValue.classes ) ? settingValue.classes.join( ' ' ) : settingValue.classes;
1334			control.params.attr_title = settingValue.attr_title;
1335			control.params.xfn = settingValue.xfn;
1336			control.params.description = settingValue.description;
1337			control.params.parent = settingValue.menu_item_parent;
1338			control.params.original_title = settingValue.original_title || '';
1339
1340			control.container.addClass( control.params.el_classes );
1341
1342			api.Control.prototype.renderContent.call( control );
1343		},
1344
1345		/***********************************************************************
1346		 * Begin public API methods
1347		 **********************************************************************/
1348
1349		/**
1350		 * @return {wp.customize.controlConstructor.nav_menu|null}
1351		 */
1352		getMenuControl: function() {
1353			var control = this, settingValue = control.setting();
1354			if ( settingValue && settingValue.nav_menu_term_id ) {
1355				return api.control( 'nav_menu[' + settingValue.nav_menu_term_id + ']' );
1356			} else {
1357				return null;
1358			}
1359		},
1360
1361		/**
1362		 * Expand the accordion section containing a control
1363		 */
1364		expandControlSection: function() {
1365			var $section = this.container.closest( '.accordion-section' );
1366
1367			if ( ! $section.hasClass( 'open' ) ) {
1368				$section.find( '.accordion-section-title:first' ).trigger( 'click' );
1369			}
1370		},
1371
1372		/**
1373		 * Expand the menu item form control.
1374		 *
1375		 * @since 4.5.0 Added params.completeCallback.
1376		 *
1377		 * @param {Object}   [params] - Optional params.
1378		 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1379		 */
1380		expandForm: function( params ) {
1381			this.toggleForm( true, params );
1382		},
1383
1384		/**
1385		 * Collapse the menu item form control.
1386		 *
1387		 * @since 4.5.0 Added params.completeCallback.
1388		 *
1389		 * @param {Object}   [params] - Optional params.
1390		 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1391		 */
1392		collapseForm: function( params ) {
1393			this.toggleForm( false, params );
1394		},
1395
1396		/**
1397		 * Expand or collapse the menu item control.
1398		 *
1399		 * @since 4.5.0 Added params.completeCallback.
1400		 *
1401		 * @param {boolean}  [showOrHide] - If not supplied, will be inverse of current visibility
1402		 * @param {Object}   [params] - Optional params.
1403		 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1404		 */
1405		toggleForm: function( showOrHide, params ) {
1406			var self = this, $menuitem, $inside, complete;
1407
1408			$menuitem = this.container;
1409			$inside = $menuitem.find( '.menu-item-settings:first' );
1410			if ( 'undefined' === typeof showOrHide ) {
1411				showOrHide = ! $inside.is( ':visible' );
1412			}
1413
1414			// Already expanded or collapsed.
1415			if ( $inside.is( ':visible' ) === showOrHide ) {
1416				if ( params && params.completeCallback ) {
1417					params.completeCallback();
1418				}
1419				return;
1420			}
1421
1422			if ( showOrHide ) {
1423				// Close all other menu item controls before expanding this one.
1424				api.control.each( function( otherControl ) {
1425					if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1426						otherControl.collapseForm();
1427					}
1428				} );
1429
1430				complete = function() {
1431					$menuitem
1432						.removeClass( 'menu-item-edit-inactive' )
1433						.addClass( 'menu-item-edit-active' );
1434					self.container.trigger( 'expanded' );
1435
1436					if ( params && params.completeCallback ) {
1437						params.completeCallback();
1438					}
1439				};
1440
1441				$menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'true' );
1442				$inside.slideDown( 'fast', complete );
1443
1444				self.container.trigger( 'expand' );
1445			} else {
1446				complete = function() {
1447					$menuitem
1448						.addClass( 'menu-item-edit-inactive' )
1449						.removeClass( 'menu-item-edit-active' );
1450					self.container.trigger( 'collapsed' );
1451
1452					if ( params && params.completeCallback ) {
1453						params.completeCallback();
1454					}
1455				};
1456
1457				self.container.trigger( 'collapse' );
1458
1459				$menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'false' );
1460				$inside.slideUp( 'fast', complete );
1461			}
1462		},
1463
1464		/**
1465		 * Expand the containing menu section, expand the form, and focus on
1466		 * the first input in the control.
1467		 *
1468		 * @since 4.5.0 Added params.completeCallback.
1469		 *
1470		 * @param {Object}   [params] - Params object.
1471		 * @param {Function} [params.completeCallback] - Optional callback function when focus has completed.
1472		 */
1473		focus: function( params ) {
1474			params = params || {};
1475			var control = this, originalCompleteCallback = params.completeCallback;
1476
1477			control.expandControlSection();
1478
1479			params.completeCallback = function() {
1480				var focusable;
1481
1482				// Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583
1483				focusable = control.container.find( '.menu-item-settings' ).find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' );
1484				focusable.first().focus();
1485
1486				if ( originalCompleteCallback ) {
1487					originalCompleteCallback();
1488				}
1489			};
1490
1491			control.expandForm( params );
1492		},
1493
1494		/**
1495		 * Move menu item up one in the menu.
1496		 */
1497		moveUp: function() {
1498			this._changePosition( -1 );
1499			wp.a11y.speak( api.Menus.data.l10n.movedUp );
1500		},
1501
1502		/**
1503		 * Move menu item up one in the menu.
1504		 */
1505		moveDown: function() {
1506			this._changePosition( 1 );
1507			wp.a11y.speak( api.Menus.data.l10n.movedDown );
1508		},
1509		/**
1510		 * Move menu item and all children up one level of depth.
1511		 */
1512		moveLeft: function() {
1513			this._changeDepth( -1 );
1514			wp.a11y.speak( api.Menus.data.l10n.movedLeft );
1515		},
1516
1517		/**
1518		 * Move menu item and children one level deeper, as a submenu of the previous item.
1519		 */
1520		moveRight: function() {
1521			this._changeDepth( 1 );
1522			wp.a11y.speak( api.Menus.data.l10n.movedRight );
1523		},
1524
1525		/**
1526		 * Note that this will trigger a UI update, causing child items to
1527		 * move as well and cardinal order class names to be updated.
1528		 *
1529		 * @private
1530		 *
1531		 * @param {Number} offset 1|-1
1532		 */
1533		_changePosition: function( offset ) {
1534			var control = this,
1535				adjacentSetting,
1536				settingValue = _.clone( control.setting() ),
1537				siblingSettings = [],
1538				realPosition;
1539
1540			if ( 1 !== offset && -1 !== offset ) {
1541				throw new Error( 'Offset changes by 1 are only supported.' );
1542			}
1543
1544			// Skip moving deleted items.
1545			if ( ! control.setting() ) {
1546				return;
1547			}
1548
1549			// Locate the other items under the same parent (siblings).
1550			_( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1551				if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1552					siblingSettings.push( otherControl.setting );
1553				}
1554			});
1555			siblingSettings.sort(function( a, b ) {
1556				return a().position - b().position;
1557			});
1558
1559			realPosition = _.indexOf( siblingSettings, control.setting );
1560			if ( -1 === realPosition ) {
1561				throw new Error( 'Expected setting to be among siblings.' );
1562			}
1563
1564			// Skip doing anything if the item is already at the edge in the desired direction.
1565			if ( ( realPosition === 0 && offset < 0 ) || ( realPosition === siblingSettings.length - 1 && offset > 0 ) ) {
1566				// @todo Should we allow a menu item to be moved up to break it out of a parent? Adopt with previous or following parent?
1567				return;
1568			}
1569
1570			// Update any adjacent menu item setting to take on this item's position.
1571			adjacentSetting = siblingSettings[ realPosition + offset ];
1572			if ( adjacentSetting ) {
1573				adjacentSetting.set( $.extend(
1574					_.clone( adjacentSetting() ),
1575					{
1576						position: settingValue.position
1577					}
1578				) );
1579			}
1580
1581			settingValue.position += offset;
1582			control.setting.set( settingValue );
1583		},
1584
1585		/**
1586		 * Note that this will trigger a UI update, causing child items to
1587		 * move as well and cardinal order class names to be updated.
1588		 *
1589		 * @private
1590		 *
1591		 * @param {Number} offset 1|-1
1592		 */
1593		_changeDepth: function( offset ) {
1594			if ( 1 !== offset && -1 !== offset ) {
1595				throw new Error( 'Offset changes by 1 are only supported.' );
1596			}
1597			var control = this,
1598				settingValue = _.clone( control.setting() ),
1599				siblingControls = [],
1600				realPosition,
1601				siblingControl,
1602				parentControl;
1603
1604			// Locate the other items under the same parent (siblings).
1605			_( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1606				if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1607					siblingControls.push( otherControl );
1608				}
1609			});
1610			siblingControls.sort(function( a, b ) {
1611				return a.setting().position - b.setting().position;
1612			});
1613
1614			realPosition = _.indexOf( siblingControls, control );
1615			if ( -1 === realPosition ) {
1616				throw new Error( 'Expected control to be among siblings.' );
1617			}
1618
1619			if ( -1 === offset ) {
1620				// Skip moving left an item that is already at the top level.
1621				if ( ! settingValue.menu_item_parent ) {
1622					return;
1623				}
1624
1625				parentControl = api.control( 'nav_menu_item[' + set