1 /* global _wpCustomizeNavMenusSettings, wpNavMenu, console */
2 ( function( api, wp, $ ) {
6 * Set up wpNavMenu for drag and drop.
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();
16 api.Menus = api.Menus || {};
22 settingTransport: 'refresh',
24 defaultSettingValues: {
28 locationSlugMappedToName: {}
30 if ( 'undefined' !== typeof _wpCustomizeNavMenusSettings ) {
31 $.extend( api.Menus.data, _wpCustomizeNavMenusSettings );
35 * Newly-created Nav Menus and Nav Menu Items have negative integer IDs which
36 * serve as placeholders until Save & Publish happens.
40 api.Menus.generatePlaceholderAutoIncrementId = function() {
41 return -Math.ceil( api.Menus.data.phpIntMax * Math.random() );
45 * wp.customize.Menus.AvailableItemModel
47 * A single available menu item model. See PHP's WP_Customize_Nav_Menu_Item_Setting class.
50 * @augments Backbone.Model
52 api.Menus.AvailableItemModel = Backbone.Model.extend( $.extend(
54 id: null // This is only used by Backbone.
56 api.Menus.data.defaultSettingValues.nav_menu_item
60 * wp.customize.Menus.AvailableItemCollection
62 * Collection for available menu item models.
65 * @augments Backbone.Model
67 api.Menus.AvailableItemCollection = Backbone.Collection.extend({
68 model: api.Menus.AvailableItemModel,
72 comparator: function( item ) {
73 return -item.get( this.sort_key );
76 sortByField: function( fieldName ) {
77 this.sort_key = fieldName;
81 api.Menus.availableMenuItems = new api.Menus.AvailableItemCollection( api.Menus.data.availableMenuItems );
84 * Insert a new `auto-draft` post.
89 * @param {object} params - Parameters for the draft post to create.
90 * @param {string} params.post_type - Post type to add.
91 * @param {string} params.post_title - Post title to use.
92 * @return {jQuery.promise} Promise resolved with the added post.
94 api.Menus.insertAutoDraftPost = function insertAutoDraftPost( params ) {
95 var request, deferred = $.Deferred();
97 request = wp.ajax.post( 'customize-nav-menus-insert-auto-draft', {
98 'customize-menus-nonce': api.settings.nonce['customize-menus'],
103 request.done( function( response ) {
104 if ( response.post_id ) {
105 api( 'nav_menus_created_posts' ).set(
106 api( 'nav_menus_created_posts' ).get().concat( [ response.post_id ] )
109 if ( 'page' === params.post_type ) {
111 // Activate static front page controls as this could be the first page created.
112 if ( api.section.has( 'static_front_page' ) ) {
113 api.section( 'static_front_page' ).activate();
116 // Add new page to dropdown-pages controls.
117 api.control.each( function( control ) {
119 if ( 'dropdown-pages' === control.params.type ) {
120 select = control.container.find( 'select[name^="_customize-dropdown-pages-"]' );
121 select.append( new Option( params.post_title, response.post_id ) );
125 deferred.resolve( response );
129 request.fail( function( response ) {
130 var error = response || '';
132 if ( 'undefined' !== typeof response.message ) {
133 error = response.message;
136 console.error( error );
137 deferred.rejectWith( error );
140 return deferred.promise();
144 * wp.customize.Menus.AvailableMenuItemsPanelView
146 * View class for the available menu items panel.
149 * @augments wp.Backbone.View
150 * @augments Backbone.View
152 api.Menus.AvailableMenuItemsPanelView = wp.Backbone.View.extend({
154 el: '#available-menu-items',
157 'input #menu-items-search': 'debounceSearch',
158 'keyup #menu-items-search': 'debounceSearch',
159 'focus .menu-item-tpl': 'focus',
160 'click .menu-item-tpl': '_submit',
161 'click #custom-menu-item-submit': '_submitLink',
162 'keypress #custom-menu-item-name': '_submitLink',
163 'click .new-content-item .add-content': '_submitNew',
164 'keypress .create-item-input': '_submitNew',
165 'keydown': 'keyboardAccessible'
168 // Cache current selected menu item.
171 // Cache menu control that opened the panel.
172 currentMenuControl: null,
173 debounceSearch: null,
183 initialize: function() {
186 if ( ! api.panel.has( 'nav_menus' ) ) {
190 this.$search = $( '#menu-items-search' );
191 this.$clearResults = this.$el.find( '.clear-results' );
192 this.sectionContent = this.$el.find( '.available-menu-items-list' );
194 this.debounceSearch = _.debounce( self.search, 500 );
196 _.bindAll( this, 'close' );
198 // If the available menu items panel is open and the customize controls are
199 // interacted with (other than an item being deleted), then close the
200 // available menu items panel. Also close on back button click.
201 $( '#customize-controls, .customize-section-back' ).on( 'click keydown', function( e ) {
202 var isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ),
203 isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' );
204 if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) {
209 // Clear the search results and trigger a `keyup` event to fire a new search.
210 this.$clearResults.on( 'click', function() {
211 self.$search.val( '' ).focus().trigger( 'keyup' );
214 this.$el.on( 'input', '#custom-menu-item-name.invalid, #custom-menu-item-url.invalid', function() {
215 $( this ).removeClass( 'invalid' );
218 // Load available items if it looks like we'll need them.
219 api.panel( 'nav_menus' ).container.bind( 'expanded', function() {
220 if ( ! self.rendered ) {
222 self.rendered = true;
227 this.sectionContent.scroll( function() {
228 var totalHeight = self.$el.find( '.accordion-section.open .available-menu-items-list' ).prop( 'scrollHeight' ),
229 visibleHeight = self.$el.find( '.accordion-section.open' ).height();
231 if ( ! self.loading && $( this ).scrollTop() > 3 / 4 * totalHeight - visibleHeight ) {
232 var type = $( this ).data( 'type' ),
233 object = $( this ).data( 'object' );
235 if ( 'search' === type ) {
236 if ( self.searchTerm ) {
237 self.doSearch( self.pages.search );
241 { type: type, object: object }
247 // Close the panel if the URL in the preview changes
248 api.previewer.bind( 'url', this.close );
250 self.delegateEvents();
253 // Search input change handler.
254 search: function( event ) {
255 var $searchSection = $( '#available-menu-items-search' ),
256 $otherSections = $( '#available-menu-items .accordion-section' ).not( $searchSection );
262 if ( this.searchTerm === event.target.value ) {
266 if ( '' !== event.target.value && ! $searchSection.hasClass( 'open' ) ) {
267 $otherSections.fadeOut( 100 );
268 $searchSection.find( '.accordion-section-content' ).slideDown( 'fast' );
269 $searchSection.addClass( 'open' );
270 this.$clearResults.addClass( 'is-visible' );
271 } else if ( '' === event.target.value ) {
272 $searchSection.removeClass( 'open' );
273 $otherSections.show();
274 this.$clearResults.removeClass( 'is-visible' );
277 this.searchTerm = event.target.value;
278 this.pages.search = 1;
282 // Get search results.
283 doSearch: function( page ) {
284 var self = this, params,
285 $section = $( '#available-menu-items-search' ),
286 $content = $section.find( '.accordion-section-content' ),
287 itemTemplate = wp.template( 'available-menu-item' );
289 if ( self.currentRequest ) {
290 self.currentRequest.abort();
295 } else if ( page > 1 ) {
296 $section.addClass( 'loading-more' );
297 $content.attr( 'aria-busy', 'true' );
298 wp.a11y.speak( api.Menus.data.l10n.itemsLoadingMore );
299 } else if ( '' === self.searchTerm ) {
305 $section.addClass( 'loading' );
308 params = api.previewer.query( { excludeCustomizedSaved: true } );
310 'customize-menus-nonce': api.settings.nonce['customize-menus'],
311 'wp_customize': 'on',
312 'search': self.searchTerm,
316 self.currentRequest = wp.ajax.post( 'search-available-menu-items-customizer', params );
318 self.currentRequest.done(function( data ) {
321 // Clear previous results as it's a new search.
324 $section.removeClass( 'loading loading-more' );
325 $content.attr( 'aria-busy', 'false' );
326 $section.addClass( 'open' );
327 self.loading = false;
328 items = new api.Menus.AvailableItemCollection( data.items );
329 self.collection.add( items.models );
330 items.each( function( menuItem ) {
331 $content.append( itemTemplate( menuItem.attributes ) );
333 if ( 20 > items.length ) {
334 self.pages.search = -1; // Up to 20 posts and 20 terms in results, if <20, no more results for either.
336 self.pages.search = self.pages.search + 1;
338 if ( items && page > 1 ) {
339 wp.a11y.speak( api.Menus.data.l10n.itemsFoundMore.replace( '%d', items.length ) );
340 } else if ( items && page === 1 ) {
341 wp.a11y.speak( api.Menus.data.l10n.itemsFound.replace( '%d', items.length ) );
345 self.currentRequest.fail(function( data ) {
346 // data.message may be undefined, for example when typing slow and the request is aborted.
347 if ( data.message ) {
348 $content.empty().append( $( '<li class="nothing-found"></li>' ).text( data.message ) );
349 wp.a11y.speak( data.message );
351 self.pages.search = -1;
354 self.currentRequest.always(function() {
355 $section.removeClass( 'loading loading-more' );
356 $content.attr( 'aria-busy', 'false' );
357 self.loading = false;
358 self.currentRequest = null;
362 // Render the individual items.
363 initList: function() {
366 // Render the template for each item by type.
367 _.each( api.Menus.data.itemTypes, function( itemType ) {
368 self.pages[ itemType.type + ':' + itemType.object ] = 0;
370 self.loadItems( api.Menus.data.itemTypes );
374 * Load available nav menu items.
377 * @since 4.7.0 Changed function signature to take list of item types instead of single type/object.
380 * @param {Array.<object>} itemTypes List of objects containing type and key.
381 * @param {string} deprecated Formerly the object parameter.
384 loadItems: function( itemTypes, deprecated ) {
385 var self = this, _itemTypes, requestItemTypes = [], params, request, itemTemplate, availableMenuItemContainers = {};
386 itemTemplate = wp.template( 'available-menu-item' );
388 if ( _.isString( itemTypes ) && _.isString( deprecated ) ) {
389 _itemTypes = [ { type: itemTypes, object: deprecated } ];
391 _itemTypes = itemTypes;
394 _.each( _itemTypes, function( itemType ) {
395 var container, name = itemType.type + ':' + itemType.object;
396 if ( -1 === self.pages[ name ] ) {
397 return; // Skip types for which there are no more results.
399 container = $( '#available-menu-items-' + itemType.type + '-' + itemType.object );
400 container.find( '.accordion-section-title' ).addClass( 'loading' );
401 availableMenuItemContainers[ name ] = container;
403 requestItemTypes.push( {
404 object: itemType.object,
406 page: self.pages[ name ]
410 if ( 0 === requestItemTypes.length ) {
416 params = api.previewer.query( { excludeCustomizedSaved: true } );
418 'customize-menus-nonce': api.settings.nonce['customize-menus'],
419 'wp_customize': 'on',
420 'item_types': requestItemTypes
423 request = wp.ajax.post( 'load-available-menu-items-customizer', params );
425 request.done(function( data ) {
427 _.each( data.items, function( typeItems, name ) {
428 if ( 0 === typeItems.length ) {
429 if ( 0 === self.pages[ name ] ) {
430 availableMenuItemContainers[ name ].find( '.accordion-section-title' )
431 .addClass( 'cannot-expand' )
432 .removeClass( 'loading' )
433 .find( '.accordion-section-title > button' )
434 .prop( 'tabIndex', -1 );
436 self.pages[ name ] = -1;
438 } else if ( ( 'post_type:page' === name ) && ( ! availableMenuItemContainers[ name ].hasClass( 'open' ) ) ) {
439 availableMenuItemContainers[ name ].find( '.accordion-section-title > button' ).click();
441 typeItems = new api.Menus.AvailableItemCollection( typeItems ); // @todo Why is this collection created and then thrown away?
442 self.collection.add( typeItems.models );
443 typeInner = availableMenuItemContainers[ name ].find( '.available-menu-items-list' );
444 typeItems.each( function( menuItem ) {
445 typeInner.append( itemTemplate( menuItem.attributes ) );
447 self.pages[ name ] += 1;
450 request.fail(function( data ) {
451 if ( typeof console !== 'undefined' && console.error ) {
452 console.error( data );
455 request.always(function() {
456 _.each( availableMenuItemContainers, function( container ) {
457 container.find( '.accordion-section-title' ).removeClass( 'loading' );
459 self.loading = false;
463 // Adjust the height of each section of items to fit the screen.
464 itemSectionHeight: function() {
465 var sections, lists, totalHeight, accordionHeight, diff;
466 totalHeight = window.innerHeight;
467 sections = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .accordion-section-content' );
468 lists = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .available-menu-items-list:not(":only-child")' );
469 accordionHeight = 46 * ( 1 + sections.length ) + 14; // Magic numbers.
470 diff = totalHeight - accordionHeight;
471 if ( 120 < diff && 290 > diff ) {
472 sections.css( 'max-height', diff );
473 lists.css( 'max-height', ( diff - 60 ) );
477 // Highlights a menu item.
478 select: function( menuitemTpl ) {
479 this.selected = $( menuitemTpl );
480 this.selected.siblings( '.menu-item-tpl' ).removeClass( 'selected' );
481 this.selected.addClass( 'selected' );
484 // Highlights a menu item on focus.
485 focus: function( event ) {
486 this.select( $( event.currentTarget ) );
489 // Submit handler for keypress and click on menu item.
490 _submit: function( event ) {
491 // Only proceed with keypress if it is Enter or Spacebar
492 if ( 'keypress' === event.type && ( 13 !== event.which && 32 !== event.which ) ) {
496 this.submit( $( event.currentTarget ) );
499 // Adds a selected menu item to the menu.
500 submit: function( menuitemTpl ) {
501 var menuitemId, menu_item;
503 if ( ! menuitemTpl ) {
504 menuitemTpl = this.selected;
507 if ( ! menuitemTpl || ! this.currentMenuControl ) {
511 this.select( menuitemTpl );
513 menuitemId = $( this.selected ).data( 'menu-item-id' );
514 menu_item = this.collection.findWhere( { id: menuitemId } );
519 this.currentMenuControl.addItemToMenu( menu_item.attributes );
521 $( menuitemTpl ).find( '.menu-item-handle' ).addClass( 'item-added' );
524 // Submit handler for keypress and click on custom menu item.
525 _submitLink: function( event ) {
526 // Only proceed with keypress if it is Enter.
527 if ( 'keypress' === event.type && 13 !== event.which ) {
534 // Adds the custom menu item to the menu.
535 submitLink: function() {
537 itemName = $( '#custom-menu-item-name' ),
538 itemUrl = $( '#custom-menu-item-url' );
540 if ( ! this.currentMenuControl ) {
544 if ( '' === itemName.val() ) {
545 itemName.addClass( 'invalid' );
547 } else if ( '' === itemUrl.val() || 'http://' === itemUrl.val() ) {
548 itemUrl.addClass( 'invalid' );
553 'title': itemName.val(),
554 'url': itemUrl.val(),
556 'type_label': api.Menus.data.l10n.custom_label,
560 this.currentMenuControl.addItemToMenu( menuItem );
562 // Reset the custom link form.
563 itemUrl.val( 'http://' );
568 * Submit handler for keypress (enter) on field and click on button.
573 * @param {jQuery.Event} event Event.
576 _submitNew: function( event ) {
579 // Only proceed with keypress if it is Enter.
580 if ( 'keypress' === event.type && 13 !== event.which ) {
584 if ( this.addingNew ) {
588 container = $( event.target ).closest( '.accordion-section' );
590 this.submitNew( container );
594 * Creates a new object and adds an associated menu item to the menu.
599 * @param {jQuery} container
602 submitNew: function( container ) {
604 itemName = container.find( '.create-item-input' ),
605 title = itemName.val(),
606 dataContainer = container.find( '.available-menu-items-list' ),
607 itemType = dataContainer.data( 'type' ),
608 itemObject = dataContainer.data( 'object' ),
609 itemTypeLabel = dataContainer.data( 'type_label' ),
612 if ( ! this.currentMenuControl ) {
616 // Only posts are supported currently.
617 if ( 'post_type' !== itemType ) {
621 if ( '' === $.trim( itemName.val() ) ) {
622 itemName.addClass( 'invalid' );
626 itemName.removeClass( 'invalid' );
627 container.find( '.accordion-section-title' ).addClass( 'loading' );
630 panel.addingNew = true;
631 itemName.attr( 'disabled', 'disabled' );
632 promise = api.Menus.insertAutoDraftPost( {
634 post_type: itemObject
636 promise.done( function( data ) {
637 var availableItem, $content, itemElement;
638 availableItem = new api.Menus.AvailableItemModel( {
639 'id': 'post-' + data.post_id, // Used for available menu item Backbone models.
640 'title': itemName.val(),
642 'type_label': itemTypeLabel,
643 'object': itemObject,
644 'object_id': data.post_id,
648 // Add new item to menu.
649 panel.currentMenuControl.addItemToMenu( availableItem.attributes );
651 // Add the new item to the list of available items.
652 api.Menus.availableMenuItemsPanel.collection.add( availableItem );
653 $content = container.find( '.available-menu-items-list' );
654 itemElement = $( wp.template( 'available-menu-item' )( availableItem.attributes ) );
655 itemElement.find( '.menu-item-handle:first' ).addClass( 'item-added' );
656 $content.prepend( itemElement );
657 $content.scrollTop();
659 // Reset the create content form.
660 itemName.val( '' ).removeAttr( 'disabled' );
661 panel.addingNew = false;
662 container.find( '.accordion-section-title' ).removeClass( 'loading' );
667 open: function( menuControl ) {
668 this.currentMenuControl = menuControl;
670 this.itemSectionHeight();
672 $( 'body' ).addClass( 'adding-menu-items' );
674 // Collapse all controls.
675 _( this.currentMenuControl.getMenuItemControls() ).each( function( control ) {
676 control.collapseForm();
679 this.$el.find( '.selected' ).removeClass( 'selected' );
681 this.$search.focus();
685 close: function( options ) {
686 options = options || {};
688 if ( options.returnFocus && this.currentMenuControl ) {
689 this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
692 this.currentMenuControl = null;
693 this.selected = null;
695 $( 'body' ).removeClass( 'adding-menu-items' );
696 $( '#available-menu-items .menu-item-handle.item-added' ).removeClass( 'item-added' );
698 this.$search.val( '' );
701 // Add a few keyboard enhancements to the panel.
702 keyboardAccessible: function( event ) {
703 var isEnter = ( 13 === event.which ),
704 isEsc = ( 27 === event.which ),
705 isBackTab = ( 9 === event.which && event.shiftKey ),
706 isSearchFocused = $( event.target ).is( this.$search );
708 // If enter pressed but nothing entered, don't do anything
709 if ( isEnter && ! this.$search.val() ) {
713 if ( isSearchFocused && isBackTab ) {
714 this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
715 event.preventDefault(); // Avoid additional back-tab.
716 } else if ( isEsc ) {
717 this.close( { returnFocus: true } );
723 * wp.customize.Menus.MenusPanel
725 * Customizer panel for menus. This is used only for screen options management.
726 * Note that 'menus' must match the WP_Customize_Menu_Panel::$type.
729 * @augments wp.customize.Panel
731 api.Menus.MenusPanel = api.Panel.extend({
733 attachEvents: function() {
734 api.Panel.prototype.attachEvents.call( this );
737 panelMeta = panel.container.find( '.panel-meta' ),
738 help = panelMeta.find( '.customize-help-toggle' ),
739 content = panelMeta.find( '.customize-panel-description' ),
740 options = $( '#screen-options-wrap' ),
741 button = panelMeta.find( '.customize-screen-options-toggle' );
742 button.on( 'click keydown', function( event ) {
743 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
746 event.preventDefault();
749 if ( content.not( ':hidden' ) ) {
750 content.slideUp( 'fast' );
751 help.attr( 'aria-expanded', 'false' );
754 if ( 'true' === button.attr( 'aria-expanded' ) ) {
755 button.attr( 'aria-expanded', 'false' );
756 panelMeta.removeClass( 'open' );
757 panelMeta.removeClass( 'active-menu-screen-options' );
758 options.slideUp( 'fast' );
760 button.attr( 'aria-expanded', 'true' );
761 panelMeta.addClass( 'open' );
762 panelMeta.addClass( 'active-menu-screen-options' );
763 options.slideDown( 'fast' );
770 help.on( 'click keydown', function( event ) {
771 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
774 event.preventDefault();
776 if ( 'true' === button.attr( 'aria-expanded' ) ) {
777 button.attr( 'aria-expanded', 'false' );
778 help.attr( 'aria-expanded', 'true' );
779 panelMeta.addClass( 'open' );
780 panelMeta.removeClass( 'active-menu-screen-options' );
781 options.slideUp( 'fast' );
782 content.slideDown( 'fast' );
788 * Update field visibility when clicking on the field toggles.
792 panel.container.find( '.hide-column-tog' ).click( function() {
793 panel.saveManageColumnsState();
798 * Save hidden column states.
805 saveManageColumnsState: _.debounce( function() {
807 if ( panel._updateHiddenColumnsRequest ) {
808 panel._updateHiddenColumnsRequest.abort();
811 panel._updateHiddenColumnsRequest = wp.ajax.post( 'hidden-columns', {
812 hidden: panel.hidden(),
813 screenoptionnonce: $( '#screenoptionnonce' ).val(),
816 panel._updateHiddenColumnsRequest.always( function() {
817 panel._updateHiddenColumnsRequest = null;
822 * @deprecated Since 4.7.0 now that the nav_menu sections are responsible for toggling the classes on their own containers.
824 checked: function() {},
827 * @deprecated Since 4.7.0 now that the nav_menu sections are responsible for toggling the classes on their own containers.
829 unchecked: function() {},
837 * @returns {Array} Fields (columns) that are hidden.
840 return $( '.hide-column-tog' ).not( ':checked' ).map( function() {
842 return id.substring( 0, id.length - 5 );
843 }).get().join( ',' );
848 * wp.customize.Menus.MenuSection
850 * Customizer section for menus. This is used only for lazy-loading child controls.
851 * Note that 'nav_menu' must match the WP_Customize_Menu_Section::$type.
854 * @augments wp.customize.Section
856 api.Menus.MenuSection = api.Section.extend({
864 * @param {Object} options
866 initialize: function( id, options ) {
868 api.Section.prototype.initialize.call( section, id, options );
869 section.deferred.initSortables = $.Deferred();
876 var section = this, fieldActiveToggles, handleFieldActiveToggle;
878 if ( 'undefined' === typeof section.params.menu_id ) {
879 throw new Error( 'params.menu_id was not defined' );
883 * Since newly created sections won't be registered in PHP, we need to prevent the
884 * preview's sending of the activeSections to result in this control
885 * being deactivated when the preview refreshes. So we can hook onto
886 * the setting that has the same ID and its presence can dictate
887 * whether the section is active.
889 section.active.validate = function() {
890 if ( ! api.has( section.id ) ) {
893 return !! api( section.id ).get();
896 section.populateControls();
898 section.navMenuLocationSettings = {};
899 section.assignedLocations = new api.Value( [] );
901 api.each(function( setting, id ) {
902 var matches = id.match( /^nav_menu_locations\[(.+?)]/ );
904 section.navMenuLocationSettings[ matches[1] ] = setting;
905 setting.bind( function() {
906 section.refreshAssignedLocations();
911 section.assignedLocations.bind(function( to ) {
912 section.updateAssignedLocationsInSectionTitle( to );
915 section.refreshAssignedLocations();
917 api.bind( 'pane-contents-reflowed', function() {
918 // Skip menus that have been removed.
919 if ( ! section.contentContainer.parent().length ) {
922 section.container.find( '.menu-item .menu-item-reorder-nav button' ).attr({ 'tabindex': '0', 'aria-hidden': 'false' });
923 section.container.find( '.menu-item.move-up-disabled .menus-move-up' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
924 section.container.find( '.menu-item.move-down-disabled .menus-move-down' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
925 section.container.find( '.menu-item.move-left-disabled .menus-move-left' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
926 section.container.find( '.menu-item.move-right-disabled .menus-move-right' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
930 * Update the active field class for the content container for a given checkbox toggle.
935 handleFieldActiveToggle = function() {
936 var className = 'field-' + $( this ).val() + '-active';
937 section.contentContainer.toggleClass( className, $( this ).prop( 'checked' ) );
939 fieldActiveToggles = api.panel( 'nav_menus' ).contentContainer.find( '.metabox-prefs:first' ).find( '.hide-column-tog' );
940 fieldActiveToggles.each( handleFieldActiveToggle );
941 fieldActiveToggles.on( 'click', handleFieldActiveToggle );
944 populateControls: function() {
945 var section = this, menuNameControlId, menuAutoAddControlId, menuControl, menuNameControl, menuAutoAddControl;
947 // Add the control for managing the menu name.
948 menuNameControlId = section.id + '[name]';
949 menuNameControl = api.control( menuNameControlId );
950 if ( ! menuNameControl ) {
951 menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, {
953 type: 'nav_menu_name',
954 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
955 label: api.Menus.data.l10n.menuNameLabel,
960 'default': section.id
964 api.control.add( menuNameControl.id, menuNameControl );
965 menuNameControl.active.set( true );
968 // Add the menu control.
969 menuControl = api.control( section.id );
970 if ( ! menuControl ) {
971 menuControl = new api.controlConstructor.nav_menu( section.id, {
974 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
979 'default': section.id
981 menu_id: section.params.menu_id
984 api.control.add( menuControl.id, menuControl );
985 menuControl.active.set( true );
988 // Add the control for managing the menu auto_add.
989 menuAutoAddControlId = section.id + '[auto_add]';
990 menuAutoAddControl = api.control( menuAutoAddControlId );
991 if ( ! menuAutoAddControl ) {
992 menuAutoAddControl = new api.controlConstructor.nav_menu_auto_add( menuAutoAddControlId, {
994 type: 'nav_menu_auto_add',
995 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
1001 'default': section.id
1005 api.control.add( menuAutoAddControl.id, menuAutoAddControl );
1006 menuAutoAddControl.active.set( true );
1014 refreshAssignedLocations: function() {
1016 menuTermId = section.params.menu_id,
1017 currentAssignedLocations = [];
1018 _.each( section.navMenuLocationSettings, function( setting, themeLocation ) {
1019 if ( setting() === menuTermId ) {
1020 currentAssignedLocations.push( themeLocation );
1023 section.assignedLocations.set( currentAssignedLocations );
1027 * @param {Array} themeLocationSlugs Theme location slugs.
1029 updateAssignedLocationsInSectionTitle: function( themeLocationSlugs ) {
1033 $title = section.container.find( '.accordion-section-title:first' );
1034 $title.find( '.menu-in-location' ).remove();
1035 _.each( themeLocationSlugs, function( themeLocationSlug ) {
1036 var $label, locationName;
1037 $label = $( '<span class="menu-in-location"></span>' );
1038 locationName = api.Menus.data.locationSlugMappedToName[ themeLocationSlug ];
1039 $label.text( api.Menus.data.l10n.menuLocation.replace( '%s', locationName ) );
1040 $title.append( $label );
1043 section.container.toggleClass( 'assigned-to-menu-location', 0 !== themeLocationSlugs.length );
1047 onChangeExpanded: function( expanded, args ) {
1048 var section = this, completeCallback;
1051 wpNavMenu.menuList = section.contentContainer;
1052 wpNavMenu.targetList = wpNavMenu.menuList;
1054 // Add attributes needed by wpNavMenu
1055 $( '#menu-to-edit' ).removeAttr( 'id' );
1056 wpNavMenu.menuList.attr( 'id', 'menu-to-edit' ).addClass( 'menu' );
1058 _.each( api.section( section.id ).controls(), function( control ) {
1059 if ( 'nav_menu_item' === control.params.type ) {
1060 control.actuallyEmbed();
1064 // Make sure Sortables is initialized after the section has been expanded to prevent `offset` issues.
1065 if ( args.completeCallback ) {
1066 completeCallback = args.completeCallback;
1068 args.completeCallback = function() {
1069 if ( 'resolved' !== section.deferred.initSortables.state() ) {
1070 wpNavMenu.initSortables(); // Depends on menu-to-edit ID being set above.
1071 section.deferred.initSortables.resolve( wpNavMenu.menuList ); // Now MenuControl can extend the sortable.
1073 // @todo Note that wp.customize.reflowPaneContents() is debounced, so this immediate change will show a slight flicker while priorities get updated.
1074 api.control( 'nav_menu[' + String( section.params.menu_id ) + ']' ).reflowMenuItems();
1076 if ( _.isFunction( completeCallback ) ) {
1081 api.Section.prototype.onChangeExpanded.call( section, expanded, args );
1086 * wp.customize.Menus.NewMenuSection
1088 * Customizer section for new menus.
1089 * Note that 'new_menu' must match the WP_Customize_New_Menu_Section::$type.
1092 * @augments wp.customize.Section
1094 api.Menus.NewMenuSection = api.Section.extend({
1097 * Add behaviors for the accordion section.
1101 attachEvents: function() {
1103 this.container.on( 'click', '.add-menu-toggle', function() {
1104 if ( section.expanded() ) {
1113 * Update UI to reflect expanded state.
1117 * @param {Boolean} expanded
1119 onChangeExpanded: function( expanded ) {
1121 button = section.container.find( '.add-menu-toggle' ),
1122 content = section.contentContainer,
1123 customizer = section.headContainer.closest( '.wp-full-overlay-sidebar-content' );
1125 button.addClass( 'open' );
1126 button.attr( 'aria-expanded', 'true' );
1127 content.slideDown( 'fast', function() {
1128 customizer.scrollTop( customizer.height() );
1131 button.removeClass( 'open' );
1132 button.attr( 'aria-expanded', 'false' );
1133 content.slideUp( 'fast' );
1134 content.find( '.menu-name-field' ).removeClass( 'invalid' );
1139 * Find the content element.
1143 * @returns {jQuery} Content UL element.
1145 getContent: function() {
1146 return this.container.find( 'ul:first' );
1151 * wp.customize.Menus.MenuLocationControl
1153 * Customizer control for menu locations (rendered as a <select>).
1154 * Note that 'nav_menu_location' must match the WP_Customize_Nav_Menu_Location_Control::$type.
1157 * @augments wp.customize.Control
1159 api.Menus.MenuLocationControl = api.Control.extend({
1160 initialize: function( id, options ) {
1162 matches = id.match( /^nav_menu_locations\[(.+?)]/ );
1163 control.themeLocation = matches[1];
1164 api.Control.prototype.initialize.call( control, id, options );
1168 var control = this, navMenuIdRegex = /^nav_menu\[(-?\d+)]/;
1170 // @todo It would be better if this was added directly on the setting itself, as opposed to the control.
1171 control.setting.validate = function( value ) {
1172 return parseInt( value, 10 );
1175 // Edit menu button.
1176 control.container.find( '.edit-menu' ).on( 'click', function() {
1177 var menuId = control.setting();
1178 api.section( 'nav_menu[' + menuId + ']' ).focus();
1180 control.setting.bind( 'change', function() {
1181 if ( 0 === control.setting() ) {
1182 control.container.find( '.edit-menu' ).addClass( 'hidden' );
1184 control.container.find( '.edit-menu' ).removeClass( 'hidden' );
1188 // Add/remove menus from the available options when they are added and removed.
1189 api.bind( 'add', function( setting ) {
1190 var option, menuId, matches = setting.id.match( navMenuIdRegex );
1191 if ( ! matches || false === setting() ) {
1194 menuId = matches[1];
1195 option = new Option( displayNavMenuName( setting().name ), menuId );
1196 control.container.find( 'select' ).append( option );
1198 api.bind( 'remove', function( setting ) {
1199 var menuId, matches = setting.id.match( navMenuIdRegex );
1203 menuId = parseInt( matches[1], 10 );
1204 if ( control.setting() === menuId ) {
1205 control.setting.set( '' );
1207 control.container.find( 'option[value=' + menuId + ']' ).remove();
1209 api.bind( 'change', function( setting ) {
1210 var menuId, matches = setting.id.match( navMenuIdRegex );
1214 menuId = parseInt( matches[1], 10 );
1215 if ( false === setting() ) {
1216 if ( control.setting() === menuId ) {
1217 control.setting.set( '' );
1219 control.container.find( 'option[value=' + menuId + ']' ).remove();
1221 control.container.find( 'option[value=' + menuId + ']' ).text( displayNavMenuName( setting().name ) );
1228 * wp.customize.Menus.MenuItemControl
1230 * Customizer control for menu items.
1231 * Note that 'menu_item' must match the WP_Customize_Menu_Item_Control::$type.
1234 * @augments wp.customize.Control
1236 api.Menus.MenuItemControl = api.Control.extend({
1241 initialize: function( id, options ) {
1243 control.expanded = new api.Value( false );
1244 control.expandedArgumentsQueue = [];
1245 control.expanded.bind( function( expanded ) {
1246 var args = control.expandedArgumentsQueue.shift();
1247 args = $.extend( {}, control.defaultExpandedArguments, args );
1248 control.onChangeExpanded( expanded, args );
1250 api.Control.prototype.initialize.call( control, id, options );
1251 control.active.validate = function() {
1252 var value, section = api.section( control.section() );
1254 value = section.active();
1263 * Override the embed() method to do nothing,
1264 * so that the control isn't embedded on load,
1265 * unless the containing section is already expanded.
1271 sectionId = control.section(),
1273 if ( ! sectionId ) {
1276 section = api.section( sectionId );
1277 if ( ( section && section.expanded() ) || api.settings.autofocus.control === control.id ) {
1278 control.actuallyEmbed();
1283 * This function is called in Section.onChangeExpanded() so the control
1284 * will only get embedded when the Section is first expanded.
1288 actuallyEmbed: function() {
1290 if ( 'resolved' === control.deferred.embedded.state() ) {
1293 control.renderContent();
1294 control.deferred.embedded.resolve(); // This triggers control.ready().
1298 * Set up the control.
1301 if ( 'undefined' === typeof this.params.menu_item_id ) {
1302 throw new Error( 'params.menu_item_id was not defined' );
1305 this._setupControlToggle();
1306 this._setupReorderUI();
1307 this._setupUpdateUI();
1308 this._setupRemoveUI();
1309 this._setupLinksUI();
1310 this._setupTitleUI();
1314 * Show/hide the settings when clicking on the menu item handle.
1316 _setupControlToggle: function() {
1319 this.container.find( '.menu-item-handle' ).on( 'click', function( e ) {
1321 e.stopPropagation();
1322 var menuControl = control.getMenuControl();
1323 if ( menuControl.isReordering || menuControl.isSorting ) {
1326 control.toggleForm();
1331 * Set up the menu-item-reorder-nav
1333 _setupReorderUI: function() {
1334 var control = this, template, $reorderNav;
1336 template = wp.template( 'menu-item-reorder-nav' );
1338 // Add the menu item reordering elements to the menu item control.
1339 control.container.find( '.item-controls' ).after( template );
1341 // Handle clicks for up/down/left-right on the reorder nav.
1342 $reorderNav = control.container.find( '.menu-item-reorder-nav' );
1343 $reorderNav.find( '.menus-move-up, .menus-move-down, .menus-move-left, .menus-move-right' ).on( 'click', function() {
1344 var moveBtn = $( this );
1347 var isMoveUp = moveBtn.is( '.menus-move-up' ),
1348 isMoveDown = moveBtn.is( '.menus-move-down' ),
1349 isMoveLeft = moveBtn.is( '.menus-move-left' ),
1350 isMoveRight = moveBtn.is( '.menus-move-right' );
1354 } else if ( isMoveDown ) {
1356 } else if ( isMoveLeft ) {
1358 } else if ( isMoveRight ) {
1359 control.moveRight();
1362 moveBtn.focus(); // Re-focus after the container was moved.
1367 * Set up event handlers for menu item updating.
1369 _setupUpdateUI: function() {
1371 settingValue = control.setting();
1373 control.elements = {};
1374 control.elements.url = new api.Element( control.container.find( '.edit-menu-item-url' ) );
1375 control.elements.title = new api.Element( control.container.find( '.edit-menu-item-title' ) );
1376 control.elements.attr_title = new api.Element( control.container.find( '.edit-menu-item-attr-title' ) );
1377 control.elements.target = new api.Element( control.container.find( '.edit-menu-item-target' ) );
1378 control.elements.classes = new api.Element( control.container.find( '.edit-menu-item-classes' ) );
1379 control.elements.xfn = new api.Element( control.container.find( '.edit-menu-item-xfn' ) );
1380 control.elements.description = new api.Element( control.container.find( '.edit-menu-item-description' ) );
1381 // @todo allow other elements, added by plugins, to be automatically picked up here; allow additional values to be added to setting array.
1383 _.each( control.elements, function( element, property ) {
1384 element.bind(function( value ) {
1385 if ( element.element.is( 'input[type=checkbox]' ) ) {
1386 value = ( value ) ? element.element.val() : '';
1389 var settingValue = control.setting();
1390 if ( settingValue && settingValue[ property ] !== value ) {
1391 settingValue = _.clone( settingValue );
1392 settingValue[ property ] = value;
1393 control.setting.set( settingValue );
1396 if ( settingValue ) {
1397 if ( ( property === 'classes' || property === 'xfn' ) && _.isArray( settingValue[ property ] ) ) {
1398 element.set( settingValue[ property ].join( ' ' ) );
1400 element.set( settingValue[ property ] );
1405 control.setting.bind(function( to, from ) {
1406 var itemId = control.params.menu_item_id,
1407 followingSiblingItemControls = [],
1408 childrenItemControls = [],
1411 if ( false === to ) {
1412 menuControl = api.control( 'nav_menu[' + String( from.nav_menu_term_id ) + ']' );
1413 control.container.remove();
1415 _.each( menuControl.getMenuItemControls(), function( otherControl ) {
1416 if ( from.menu_item_parent === otherControl.setting().menu_item_parent && otherControl.setting().position > from.position ) {
1417 followingSiblingItemControls.push( otherControl );
1418 } else if ( otherControl.setting().menu_item_parent === itemId ) {
1419 childrenItemControls.push( otherControl );
1423 // Shift all following siblings by the number of children this item has.
1424 _.each( followingSiblingItemControls, function( followingSiblingItemControl ) {
1425 var value = _.clone( followingSiblingItemControl.setting() );
1426 value.position += childrenItemControls.length;
1427 followingSiblingItemControl.setting.set( value );
1430 // Now move the children up to be the new subsequent siblings.
1431 _.each( childrenItemControls, function( childrenItemControl, i ) {
1432 var value = _.clone( childrenItemControl.setting() );
1433 value.position = from.position + i;
1434 value.menu_item_parent = from.menu_item_parent;
1435 childrenItemControl.setting.set( value );
1438 menuControl.debouncedReflowMenuItems();
1440 // Update the elements' values to match the new setting properties.
1441 _.each( to, function( value, key ) {
1442 if ( control.elements[ key] ) {
1443 control.elements[ key ].set( to[ key ] );
1446 control.container.find( '.menu-item-data-parent-id' ).val( to.menu_item_parent );
1448 // Handle UI updates when the position or depth (parent) change.
1449 if ( to.position !== from.position || to.menu_item_parent !== from.menu_item_parent ) {
1450 control.getMenuControl().debouncedReflowMenuItems();
1457 * Set up event handlers for menu item deletion.
1459 _setupRemoveUI: function() {
1460 var control = this, $removeBtn;
1462 // Configure delete button.
1463 $removeBtn = control.container.find( '.item-delete' );
1465 $removeBtn.on( 'click', function() {
1466 // Find an adjacent element to add focus to when this menu item goes away
1467 var addingItems = true, $adjacentFocusTarget, $next, $prev;
1469 if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) {
1470 addingItems = false;
1473 $next = control.container.nextAll( '.customize-control-nav_menu_item:visible' ).first();
1474 $prev = control.container.prevAll( '.customize-control-nav_menu_item:visible' ).first();
1476 if ( $next.length ) {
1477 $adjacentFocusTarget = $next.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1478 } else if ( $prev.length ) {
1479 $adjacentFocusTarget = $prev.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1481 $adjacentFocusTarget = control.container.nextAll( '.customize-control-nav_menu' ).find( '.add-new-menu-item' ).first();
1484 control.container.slideUp( function() {
1485 control.setting.set( false );
1486 wp.a11y.speak( api.Menus.data.l10n.itemDeleted );
1487 $adjacentFocusTarget.focus(); // keyboard accessibility
1492 _setupLinksUI: function() {
1495 // Configure original link.
1496 $origBtn = this.container.find( 'a.original-link' );
1498 $origBtn.on( 'click', function( e ) {
1500 api.previewer.previewUrl( e.target.toString() );
1505 * Update item handle title when changed.
1507 _setupTitleUI: function() {
1510 control.setting.bind( function( item ) {
1515 var titleEl = control.container.find( '.menu-item-title' ),
1516 titleText = item.title || item.original_title || api.Menus.data.l10n.untitled;
1518 if ( item._invalid ) {
1519 titleText = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', titleText );
1522 // Don't update to an empty title.
1523 if ( item.title || item.original_title ) {
1526 .removeClass( 'no-title' );
1530 .addClass( 'no-title' );
1539 getDepth: function() {
1540 var control = this, setting = control.setting(), depth = 0;
1544 while ( setting && setting.menu_item_parent ) {
1546 control = api.control( 'nav_menu_item[' + setting.menu_item_parent + ']' );
1550 setting = control.setting();
1556 * Amend the control's params with the data necessary for the JS template just in time.
1558 renderContent: function() {
1560 settingValue = control.setting(),
1563 control.params.title = settingValue.title || '';
1564 control.params.depth = control.getDepth();
1565 control.container.data( 'item-depth', control.params.depth );
1566 containerClasses = [
1568 'menu-item-depth-' + String( control.params.depth ),
1569 'menu-item-' + settingValue.object,
1570 'menu-item-edit-inactive'
1573 if ( settingValue._invalid ) {
1574 containerClasses.push( 'menu-item-invalid' );
1575 control.params.title = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', control.params.title );
1576 } else if ( 'draft' === settingValue.status ) {
1577 containerClasses.push( 'pending' );
1578 control.params.title = api.Menus.data.pendingTitleTpl.replace( '%s', control.params.title );
1581 control.params.el_classes = containerClasses.join( ' ' );
1582 control.params.item_type_label = settingValue.type_label;
1583 control.params.item_type = settingValue.type;
1584 control.params.url = settingValue.url;
1585 control.params.target = settingValue.target;
1586 control.params.attr_title = settingValue.attr_title;
1587 control.params.classes = _.isArray( settingValue.classes ) ? settingValue.classes.join( ' ' ) : settingValue.classes;
1588 control.params.attr_title = settingValue.attr_title;
1589 control.params.xfn = settingValue.xfn;
1590 control.params.description = settingValue.description;
1591 control.params.parent = settingValue.menu_item_parent;
1592 control.params.original_title = settingValue.original_title || '';
1594 control.container.addClass( control.params.el_classes );
1596 api.Control.prototype.renderContent.call( control );
1599 /***********************************************************************
1600 * Begin public API methods
1601 **********************************************************************/
1604 * @return {wp.customize.controlConstructor.nav_menu|null}
1606 getMenuControl: function() {
1607 var control = this, settingValue = control.setting();
1608 if ( settingValue && settingValue.nav_menu_term_id ) {
1609 return api.control( 'nav_menu[' + settingValue.nav_menu_term_id + ']' );
1616 * Expand the accordion section containing a control
1618 expandControlSection: function() {
1619 var $section = this.container.closest( '.accordion-section' );
1620 if ( ! $section.hasClass( 'open' ) ) {
1621 $section.find( '.accordion-section-title:first' ).trigger( 'click' );
1628 * @param {Boolean} expanded
1629 * @param {Object} [params]
1630 * @returns {Boolean} false if state already applied
1632 _toggleExpanded: api.Section.prototype._toggleExpanded,
1637 * @param {Object} [params]
1638 * @returns {Boolean} false if already expanded
1640 expand: api.Section.prototype.expand,
1643 * Expand the menu item form control.
1645 * @since 4.5.0 Added params.completeCallback.
1647 * @param {Object} [params] - Optional params.
1648 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1650 expandForm: function( params ) {
1651 this.expand( params );
1657 * @param {Object} [params]
1658 * @returns {Boolean} false if already collapsed
1660 collapse: api.Section.prototype.collapse,
1663 * Collapse the menu item form control.
1665 * @since 4.5.0 Added params.completeCallback.
1667 * @param {Object} [params] - Optional params.
1668 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1670 collapseForm: function( params ) {
1671 this.collapse( params );
1675 * Expand or collapse the menu item control.
1677 * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
1678 * @since 4.5.0 Added params.completeCallback.
1680 * @param {boolean} [showOrHide] - If not supplied, will be inverse of current visibility
1681 * @param {Object} [params] - Optional params.
1682 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1684 toggleForm: function( showOrHide, params ) {
1685 if ( typeof showOrHide === 'undefined' ) {
1686 showOrHide = ! this.expanded();
1689 this.expand( params );
1691 this.collapse( params );
1696 * Expand or collapse the menu item control.
1699 * @param {boolean} [showOrHide] - If not supplied, will be inverse of current visibility
1700 * @param {Object} [params] - Optional params.
1701 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1703 onChangeExpanded: function( showOrHide, params ) {
1704 var self = this, $menuitem, $inside, complete;
1706 $menuitem = this.container;
1707 $inside = $menuitem.find( '.menu-item-settings:first' );
1708 if ( 'undefined' === typeof showOrHide ) {
1709 showOrHide = ! $inside.is( ':visible' );
1712 // Already expanded or collapsed.
1713 if ( $inside.is( ':visible' ) === showOrHide ) {
1714 if ( params && params.completeCallback ) {
1715 params.completeCallback();
1721 // Close all other menu item controls before expanding this one.
1722 api.control.each( function( otherControl ) {
1723 if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1724 otherControl.collapseForm();
1728 complete = function() {
1730 .removeClass( 'menu-item-edit-inactive' )
1731 .addClass( 'menu-item-edit-active' );
1732 self.container.trigger( 'expanded' );
1734 if ( params && params.completeCallback ) {
1735 params.completeCallback();
1739 $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'true' );
1740 $inside.slideDown( 'fast', complete );
1742 self.container.trigger( 'expand' );
1744 complete = function() {
1746 .addClass( 'menu-item-edit-inactive' )
1747 .removeClass( 'menu-item-edit-active' );
1748 self.container.trigger( 'collapsed' );
1750 if ( params && params.completeCallback ) {
1751 params.completeCallback();
1755 self.container.trigger( 'collapse' );
1757 $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'false' );
1758 $inside.slideUp( 'fast', complete );
1763 * Expand the containing menu section, expand the form, and focus on
1764 * the first input in the control.
1766 * @since 4.5.0 Added params.completeCallback.
1768 * @param {Object} [params] - Params object.
1769 * @param {Function} [params.completeCallback] - Optional callback function when focus has completed.
1771 focus: function( params ) {
1772 params = params || {};
1773 var control = this, originalCompleteCallback = params.completeCallback, focusControl;
1775 focusControl = function() {
1776 control.expandControlSection();
1778 params.completeCallback = function() {
1781 // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583
1782 focusable = control.container.find( '.menu-item-settings' ).find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' );
1783 focusable.first().focus();
1785 if ( originalCompleteCallback ) {
1786 originalCompleteCallback();
1790 control.expandForm( params );
1793 if ( api.section.has( control.section() ) ) {
1794 api.section( control.section() ).expand( {
1795 completeCallback: focusControl
1803 * Move menu item up one in the menu.
1805 moveUp: function() {
1806 this._changePosition( -1 );
1807 wp.a11y.speak( api.Menus.data.l10n.movedUp );
1811 * Move menu item up one in the menu.
1813 moveDown: function() {
1814 this._changePosition( 1 );
1815 wp.a11y.speak( api.Menus.data.l10n.movedDown );
1818 * Move menu item and all children up one level of depth.
1820 moveLeft: function() {
1821 this._changeDepth( -1 );
1822 wp.a11y.speak( api.Menus.data.l10n.movedLeft );
1826 * Move menu item and children one level deeper, as a submenu of the previous item.
1828 moveRight: function() {
1829 this._changeDepth( 1 );
1830 wp.a11y.speak( api.Menus.data.l10n.movedRight );
1834 * Note that this will trigger a UI update, causing child items to
1835 * move as well and cardinal order class names to be updated.
1839 * @param {Number} offset 1|-1
1841 _changePosition: function( offset ) {
1844 settingValue = _.clone( control.setting() ),
1845 siblingSettings = [],
1848 if ( 1 !== offset && -1 !== offset ) {
1849 throw new Error( 'Offset changes by 1 are only supported.' );
1852 // Skip moving deleted items.
1853 if ( ! control.setting() ) {
1857 // Locate the other items under the same parent (siblings).
1858 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1859 if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1860 siblingSettings.push( otherControl.setting );
1863 siblingSettings.sort(function( a, b ) {
1864 return a().position - b().position;
1867 realPosition = _.indexOf( siblingSettings, control.setting );
1868 if ( -1 === realPosition ) {
1869 throw new Error( 'Expected setting to be among siblings.' );
1872 // Skip doing anything if the item is already at the edge in the desired direction.
1873 if ( ( realPosition === 0 && offset < 0 ) || ( realPosition === siblingSettings.length - 1 && offset > 0 ) ) {
1874 // @todo Should we allow a menu item to be moved up to break it out of a parent? Adopt with previous or following parent?
1878 // Update any adjacent menu item setting to take on this item's position.
1879 adjacentSetting = siblingSettings[ realPosition + offset ];
1880 if ( adjacentSetting ) {
1881 adjacentSetting.set( $.extend(
1882 _.clone( adjacentSetting() ),
1884 position: settingValue.position
1889 settingValue.position += offset;
1890 control.setting.set( settingValue );
1894 * Note that this will trigger a UI update, causing child items to
1895 * move as well and cardinal order class names to be updated.
1899 * @param {Number} offset 1|-1
1901 _changeDepth: function( offset ) {
1902 if ( 1 !== offset && -1 !== offset ) {
1903 throw new Error( 'Offset changes by 1 are only supported.' );
1906 settingValue = _.clone( control.setting() ),
1907 siblingControls = [],
1912 // Locate the other items under the same parent (siblings).
1913 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1914 if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1915 siblingControls.push( otherControl );
1918 siblingControls.sort(function( a, b ) {
1919 return a.setting().position - b.setting().position;
1922 realPosition = _.indexOf( siblingControls, control );
1923 if ( -1 === realPosition ) {
1924 throw new Error( 'Expected control to be among siblings.' );
1927 if ( -1 === offset ) {
1928 // Skip moving left an item that is already at the top level.
1929 if ( ! settingValue.menu_item_parent ) {
1933 parentControl = api.control( 'nav_menu_item[' + settingValue.menu_item_parent + ']' );
1935 // Make this control the parent of all the following siblings.
1936 _( siblingControls ).chain().slice( realPosition ).each(function( siblingControl, i ) {
1937 siblingControl.setting.set(
1940 siblingControl.setting(),
1942 menu_item_parent: control.params.menu_item_id,
1949 // Increase the positions of the parent item's subsequent children to make room for this one.
1950 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1951 var otherControlSettingValue, isControlToBeShifted;
1952 isControlToBeShifted = (
1953 otherControl.setting().menu_item_parent === parentControl.setting().menu_item_parent &&
1954 otherControl.setting().position > parentControl.setting().position
1956 if ( isControlToBeShifted ) {
1957 otherControlSettingValue = _.clone( otherControl.setting() );
1958 otherControl.setting.set(
1960 otherControlSettingValue,
1961 { position: otherControlSettingValue.position + 1 }
1967 // Make this control the following sibling of its parent item.
1968 settingValue.position = parentControl.setting().position + 1;
1969 settingValue.menu_item_parent = parentControl.setting().menu_item_parent;
1970 control.setting.set( settingValue );
1972 } else if ( 1 === offset ) {
1973 // Skip moving right an item that doesn't have a previous sibling.
1974 if ( realPosition === 0 ) {
1978 // Make the control the last child of the previous sibling.
1979 siblingControl = siblingControls[ realPosition - 1 ];
1980 settingValue.menu_item_parent = siblingControl.params.menu_item_id;
1981 settingValue.position = 0;
1982 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1983 if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1984 settingValue.position = Math.max( settingValue.position, otherControl.setting().position );
1987 settingValue.position += 1;
1988 control.setting.set( settingValue );
1994 * wp.customize.Menus.MenuNameControl
1996 * Customizer control for a nav menu's name.
1999 * @augments wp.customize.Control
2001 api.Menus.MenuNameControl = api.Control.extend({
2005 settingValue = control.setting();
2008 * Since the control is not registered in PHP, we need to prevent the
2009 * preview's sending of the activeControls to result in this control
2010 * being deactivated.
2012 control.active.validate = function() {
2013 var value, section = api.section( control.section() );
2015 value = section.active();
2022 control.nameElement = new api.Element( control.container.find( '.menu-name-field' ) );
2024 control.nameElement.bind(function( value ) {
2025 var settingValue = control.setting();
2026 if ( settingValue && settingValue.name !== value ) {
2027 settingValue = _.clone( settingValue );
2028 settingValue.name = value;
2029 control.setting.set( settingValue );
2032 if ( settingValue ) {
2033 control.nameElement.set( settingValue.name );
2036 control.setting.bind(function( object ) {
2038 control.nameElement.set( object.name );
2046 * wp.customize.Menus.MenuAutoAddControl
2048 * Customizer control for a nav menu's auto add.
2051 * @augments wp.customize.Control
2053 api.Menus.MenuAutoAddControl = api.Control.extend({
2057 settingValue = control.setting();
2060 * Since the control is not registered in PHP, we need to prevent the
2061 * preview's sending of the activeControls to result in this control
2062 * being deactivated.
2064 control.active.validate = function() {
2065 var value, section = api.section( control.section() );
2067 value = section.active();
2074 control.autoAddElement = new api.Element( control.container.find( 'input[type=checkbox].auto_add' ) );
2076 control.autoAddElement.bind(function( value ) {
2077 var settingValue = control.setting();
2078 if ( settingValue && settingValue.name !== value ) {
2079 settingValue = _.clone( settingValue );
2080 settingValue.auto_add = value;
2081 control.setting.set( settingValue );
2084 if ( settingValue ) {
2085 control.autoAddElement.set( settingValue.auto_add );
2088 control.setting.bind(function( object ) {
2090 control.autoAddElement.set( object.auto_add );
2098 * wp.customize.Menus.MenuControl
2100 * Customizer control for menus.
2101 * Note that 'nav_menu' must match the WP_Menu_Customize_Control::$type
2104 * @augments wp.customize.Control
2106 api.Menus.MenuControl = api.Control.extend({
2108 * Set up the control.
2112 section = api.section( control.section() ),
2113 menuId = control.params.menu_id,
2114 menu = control.setting(),
2119 if ( 'undefined' === typeof this.params.menu_id ) {
2120 throw new Error( 'params.menu_id was not defined' );
2124 * Since the control is not registered in PHP, we need to prevent the
2125 * preview's sending of the activeControls to result in this control
2126 * being deactivated.
2128 control.active.validate = function() {
2131 value = section.active();
2138 control.$controlSection = section.headContainer;
2139 control.$sectionContent = control.container.closest( '.accordion-section-content' );
2143 api.section( control.section(), function( section ) {
2144 section.deferred.initSortables.done(function( menuList ) {
2145 control._setupSortable( menuList );
2149 this._setupAddition();
2150 this._setupLocations();
2153 // Add menu to Custom Menu widgets.
2155 name = displayNavMenuName( menu.name );
2157 // Add the menu to the existing controls.
2158 api.control.each( function( widgetControl ) {
2159 if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) {
2162 widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).show();
2163 widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).hide();
2165 select = widgetControl.container.find( 'select' );
2166 if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) {
2167 select.append( new Option( name, menuId ) );
2171 // Add the menu to the widget template.
2172 widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' );
2173 widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).show();
2174 widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).hide();
2175 select = widgetTemplate.find( '.widget-inside select:first' );
2176 if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) {
2177 select.append( new Option( name, menuId ) );
2183 * Update ordering of menu item controls when the setting is updated.
2185 _setupModel: function() {
2187 menuId = control.params.menu_id;
2189 control.setting.bind( function( to ) {
2191 if ( false === to ) {
2192 control._handleDeletion();
2194 // Update names in the Custom Menu widgets.
2195 name = displayNavMenuName( to.name );
2196 api.control.each( function( widgetControl ) {
2197 if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) {
2200 var select = widgetControl.container.find( 'select' );
2201 select.find( 'option[value=' + String( menuId ) + ']' ).text( name );
2206 control.container.find( '.menu-delete' ).on( 'click', function( event ) {
2207 event.stopPropagation();
2208 event.preventDefault();
2209 control.setting.set( false );
2214 * Allow items in each menu to be re-ordered, and for the order to be previewed.
2216 * Notice that the UI aspects here are handled by wpNavMenu.initSortables()
2217 * which is called in MenuSection.onChangeExpanded()
2219 * @param {object} menuList - The element that has sortable().
2221 _setupSortable: function( menuList ) {
2224 if ( ! menuList.is( control.$sectionContent ) ) {
2225 throw new Error( 'Unexpected menuList.' );
2228 menuList.on( 'sortstart', function() {
2229 control.isSorting = true;
2232 menuList.on( 'sortstop', function() {
2233 setTimeout( function() { // Next tick.
2234 var menuItemContainerIds = control.$sectionContent.sortable( 'toArray' ),
2235 menuItemControls = [],
2239 control.isSorting = false;
2241 // Reset horizontal scroll position when done dragging.
2242 control.$sectionContent.scrollLeft( 0 );
2244 _.each( menuItemContainerIds, function( menuItemContainerId ) {
2245 var menuItemId, menuItemControl, matches;
2246 matches = menuItemContainerId.match( /^customize-control-nav_menu_item-(-?\d+)$/, '' );
2250 menuItemId = parseInt( matches[1], 10 );
2251 menuItemControl = api.control( 'nav_menu_item[' + String( menuItemId ) + ']' );
2252 if ( menuItemControl ) {
2253 menuItemControls.push( menuItemControl );
2257 _.each( menuItemControls, function( menuItemControl ) {
2258 if ( false === menuItemControl.setting() ) {
2259 // Skip deleted items.
2262 var setting = _.clone( menuItemControl.setting() );
2265 setting.position = position;
2266 menuItemControl.priority( priority );
2268 // Note that wpNavMenu will be setting this .menu-item-data-parent-id input's value.
2269 setting.menu_item_parent = parseInt( menuItemControl.container.find( '.menu-item-data-parent-id' ).val(), 10 );
2270 if ( ! setting.menu_item_parent ) {
2271 setting.menu_item_parent = 0;
2274 menuItemControl.setting.set( setting );
2279 control.isReordering = false;
2282 * Keyboard-accessible reordering.
2284 this.container.find( '.reorder-toggle' ).on( 'click', function() {
2285 control.toggleReordering( ! control.isReordering );
2290 * Set up UI for adding a new menu item.
2292 _setupAddition: function() {
2295 this.container.find( '.add-new-menu-item' ).on( 'click', function( event ) {
2296 if ( self.$sectionContent.hasClass( 'reordering' ) ) {
2300 if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) {
2301 $( this ).attr( 'aria-expanded', 'true' );
2302 api.Menus.availableMenuItemsPanel.open( self );
2304 $( this ).attr( 'aria-expanded', 'false' );
2305 api.Menus.availableMenuItemsPanel.close();
2306 event.stopPropagation();
2311 _handleDeletion: function() {
2314 menuId = control.params.menu_id,
2318 section = api.section( control.section() );
2319 removeSection = function() {
2320 section.container.remove();
2321 api.section.remove( section.id );
2324 if ( section && section.expanded() ) {
2326 completeCallback: function() {
2328 wp.a11y.speak( api.Menus.data.l10n.menuDeleted );
2329 api.panel( 'nav_menus' ).focus();
2336 api.each(function( setting ) {
2337 if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) {
2342 // Remove the menu from any Custom Menu widgets.
2343 api.control.each(function( widgetControl ) {
2344 if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) {
2347 var select = widgetControl.container.find( 'select' );
2348 if ( select.val() === String( menuId ) ) {
2349 select.prop( 'selectedIndex', 0 ).trigger( 'change' );
2352 widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount );
2353 widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount );
2354 widgetControl.container.find( 'option[value=' + String( menuId ) + ']' ).remove();
2357 // Remove the menu to the nav menu widget template.
2358 widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' );
2359 widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount );
2360 widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount );
2361 widgetTemplate.find( 'option[value=' + String( menuId ) + ']' ).remove();
2364 // Setup theme location checkboxes.
2365 _setupLocations: function() {
2368 control.container.find( '.assigned-menu-location' ).each(function() {
2369 var container = $( this ),
2370 checkbox = container.find( 'input[type=checkbox]' ),
2372 updateSelectedMenuLabel,
2373 navMenuLocationSetting = api( 'nav_menu_locations[' + checkbox.data( 'location-id' ) + ']' );
2375 updateSelectedMenuLabel = function( selectedMenuId ) {
2376 var menuSetting = api( 'nav_menu[' + String( selectedMenuId ) + ']' );
2377 if ( ! selectedMenuId || ! menuSetting || ! menuSetting() ) {
2378 container.find( '.theme-location-set' ).hide();
2380 container.find( '.theme-location-set' ).show().find( 'span' ).text( displayNavMenuName( menuSetting().name ) );
2384 element = new api.Element( checkbox );
2385 element.set( navMenuLocationSetting.get() === control.params.menu_id );
2387 checkbox.on( 'change', function() {
2388 // Note: We can't use element.bind( function( checked ){ ... } ) here because it will trigger a change as well.
2389 navMenuLocationSetting.set( this.checked ? control.params.menu_id : 0 );
2392 navMenuLocationSetting.bind(function( selectedMenuId ) {
2393 element.set( selectedMenuId === control.params.menu_id );
2394 updateSelectedMenuLabel( selectedMenuId );
2396 updateSelectedMenuLabel( navMenuLocationSetting.get() );
2402 * Update Section Title as menu name is changed.
2404 _setupTitle: function() {
2407 control.setting.bind( function( menu ) {
2412 var section = api.section( control.section() ),
2413 menuId = control.params.menu_id,
2414 controlTitle = section.headContainer.find( '.accordion-section-title' ),
2415 sectionTitle = section.contentContainer.find( '.customize-section-title h3' ),
2416 location = section.headContainer.find( '.menu-in-location' ),
2417 action = sectionTitle.find( '.customize-action' ),
2418 name = displayNavMenuName( menu.name );
2420 // Update the control title
2421 controlTitle.text( name );
2422 if ( location.length ) {
2423 location.appendTo( controlTitle );
2426 // Update the section title
2427 sectionTitle.text( name );
2428 if ( action.length ) {
2429 action.prependTo( sectionTitle );
2432 // Update the nav menu name in location selects.
2433 api.control.each( function( control ) {
2434 if ( /^nav_menu_locations\[/.test( control.id ) ) {
2435 control.container.find( 'option[value=' + menuId + ']' ).text( name );
2439 // Update the nav menu name in all location checkboxes.
2440 section.contentContainer.find( '.customize-control-checkbox input' ).each( function() {
2441 if ( $( this ).prop( 'checked' ) ) {
2442 $( '.current-menu-location-name-' + $( this ).data( 'location-id' ) ).text( name );
2448 /***********************************************************************
2449 * Begin public API methods
2450 **********************************************************************/
2453 * Enable/disable the reordering UI
2455 * @param {Boolean} showOrHide to enable/disable reordering
2457 toggleReordering: function( showOrHide ) {
2458 var addNewItemBtn = this.container.find( '.add-new-menu-item' ),
2459 reorderBtn = this.container.find( '.reorder-toggle' ),
2460 itemsTitle = this.$sectionContent.find( '.item-title' );
2462 showOrHide = Boolean( showOrHide );
2464 if ( showOrHide === this.$sectionContent.hasClass( 'reordering' ) ) {
2468 this.isReordering = showOrHide;
2469 this.$sectionContent.toggleClass( 'reordering', showOrHide );
2470 this.$sectionContent.sortable( this.isReordering ? 'disable' : 'enable' );
2471 if ( this.isReordering ) {
2472 addNewItemBtn.attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
2473 reorderBtn.attr( 'aria-label', api.Menus.data.l10n.reorderLabelOff );
2474 wp.a11y.speak( api.Menus.data.l10n.reorderModeOn );
2475 itemsTitle.attr( 'aria-hidden', 'false' );
2477 addNewItemBtn.removeAttr( 'tabindex aria-hidden' );
2478 reorderBtn.attr( 'aria-label', api.Menus.data.l10n.reorderLabelOn );
2479 wp.a11y.speak( api.Menus.data.l10n.reorderModeOff );
2480 itemsTitle.attr( 'aria-hidden', 'true' );
2484 _( this.getMenuItemControls() ).each( function( formControl ) {
2485 formControl.collapseForm();
2491 * @return {wp.customize.controlConstructor.nav_menu_item[]}
2493 getMenuItemControls: function() {
2494 var menuControl = this,
2495 menuItemControls = [],
2496 menuTermId = menuControl.params.menu_id;
2498 api.control.each(function( control ) {
2499 if ( 'nav_menu_item' === control.params.type && control.setting() && menuTermId === control.setting().nav_menu_term_id ) {
2500 menuItemControls.push( control );
2504 return menuItemControls;
2508 * Make sure that each menu item control has the proper depth.
2510 reflowMenuItems: function() {
2511 var menuControl = this,
2512 menuItemControls = menuControl.getMenuItemControls(),
2515 reflowRecursively = function( context ) {
2516 var currentMenuItemControls = [],
2517 thisParent = context.currentParent;
2518 _.each( context.menuItemControls, function( menuItemControl ) {
2519 if ( thisParent === menuItemControl.setting().menu_item_parent ) {
2520 currentMenuItemControls.push( menuItemControl );
2521 // @todo We could remove this item from menuItemControls now, for efficiency.
2524 currentMenuItemControls.sort( function( a, b ) {
2525 return a.setting().position - b.setting().position;
2528 _.each( currentMenuItemControls, function( menuItemControl ) {
2530 context.currentAbsolutePosition += 1;
2531 menuItemControl.priority.set( context.currentAbsolutePosition ); // This will change the sort order.
2534 if ( ! menuItemControl.container.hasClass( 'menu-item-depth-' + String( context.currentDepth ) ) ) {
2535 _.each( menuItemControl.container.prop( 'className' ).match( /menu-item-depth-\d+/g ), function( className ) {
2536 menuItemControl.container.removeClass( className );
2538 menuItemControl.container.addClass( 'menu-item-depth-' + String( context.currentDepth ) );
2540 menuItemControl.container.data( 'item-depth', context.currentDepth );
2542 // Process any children items.
2543 context.currentDepth += 1;
2544 context.currentParent = menuItemControl.params.menu_item_id;
2545 reflowRecursively( context );
2546 context.currentDepth -= 1;
2547 context.currentParent = thisParent;
2550 // Update class names for reordering controls.
2551 if ( currentMenuItemControls.length ) {
2552 _( currentMenuItemControls ).each(function( menuItemControl ) {
2553 menuItemControl.container.removeClass( 'move-up-disabled move-down-disabled move-left-disabled move-right-disabled' );
2554 if ( 0 === context.currentDepth ) {
2555 menuItemControl.container.addClass( 'move-left-disabled' );
2556 } else if ( 10 === context.currentDepth ) {
2557 menuItemControl.container.addClass( 'move-right-disabled' );
2561 currentMenuItemControls[0].container
2562 .addClass( 'move-up-disabled' )
2563 .addClass( 'move-right-disabled' )
2564 .toggleClass( 'move-down-disabled', 1 === currentMenuItemControls.length );
2565 currentMenuItemControls[ currentMenuItemControls.length - 1 ].container
2566 .addClass( 'move-down-disabled' )
2567 .toggleClass( 'move-up-disabled', 1 === currentMenuItemControls.length );
2571 reflowRecursively( {
2572 menuItemControls: menuItemControls,
2575 currentAbsolutePosition: 0
2578 menuControl.container.find( '.reorder-toggle' ).toggle( menuItemControls.length > 1 );
2582 * Note that this function gets debounced so that when a lot of setting
2583 * changes are made at once, for instance when moving a menu item that
2584 * has child items, this function will only be called once all of the
2585 * settings have been updated.
2587 debouncedReflowMenuItems: _.debounce( function() {
2588 this.reflowMenuItems.apply( this, arguments );
2592 * Add a new item to this menu.
2594 * @param {object} item - Value for the nav_menu_item setting to be created.
2595 * @returns {wp.customize.Menus.controlConstructor.nav_menu_item} The newly-created nav_menu_item control instance.
2597 addItemToMenu: function( item ) {
2598 var menuControl = this, customizeId, settingArgs, setting, menuItemControl, placeholderId, position = 0, priority = 10;
2600 _.each( menuControl.getMenuItemControls(), function( control ) {
2601 if ( false === control.setting() ) {
2604 priority = Math.max( priority, control.priority() );
2605 if ( 0 === control.setting().menu_item_parent ) {
2606 position = Math.max( position, control.setting().position );
2614 api.Menus.data.defaultSettingValues.nav_menu_item,
2617 nav_menu_term_id: menuControl.params.menu_id,
2618 original_title: item.title,
2622 delete item.id; // only used by Backbone
2624 placeholderId = api.Menus.generatePlaceholderAutoIncrementId();
2625 customizeId = 'nav_menu_item[' + String( placeholderId ) + ']';
2627 type: 'nav_menu_item',
2628 transport: api.Menus.data.settingTransport,
2629 previewer: api.previewer
2631 setting = api.create( customizeId, customizeId, {}, settingArgs );
2632 setting.set( item ); // Change from initial empty object to actual item to mark as dirty.
2634 // Add the menu item control.
2635 menuItemControl = new api.controlConstructor.nav_menu_item( customizeId, {
2637 type: 'nav_menu_item',
2638 content: '<li id="customize-control-nav_menu_item-' + String( placeholderId ) + '" class="customize-control customize-control-nav_menu_item"></li>',
2639 section: menuControl.id,
2643 'default': customizeId
2645 menu_item_id: placeholderId
2647 previewer: api.previewer
2650 api.control.add( customizeId, menuItemControl );
2652 menuControl.debouncedReflowMenuItems();
2654 wp.a11y.speak( api.Menus.data.l10n.itemAdded );
2656 return menuItemControl;
2661 * wp.customize.Menus.NewMenuControl
2663 * Customizer control for creating new menus and handling deletion of existing menus.
2664 * Note that 'new_menu' must match the WP_Customize_New_Menu_Control::$type.
2667 * @augments wp.customize.Control
2669 api.Menus.NewMenuControl = api.Control.extend({
2671 * Set up the control.
2674 this._bindHandlers();
2677 _bindHandlers: function() {
2679 name = $( '#customize-control-new_menu_name input' ),
2680 submit = $( '#create-new-menu-submit' );
2681 name.on( 'keydown', function( event ) {
2682 if ( 13 === event.which ) { // Enter.
2686 submit.on( 'click', function( event ) {
2688 event.stopPropagation();
2689 event.preventDefault();
2694 * Create the new menu with the name supplied.
2696 submit: function() {
2699 container = control.container.closest( '.accordion-section-new-menu' ),
2700 nameInput = container.find( '.menu-name-field' ).first(),
2701 name = nameInput.val(),
2704 placeholderId = api.Menus.generatePlaceholderAutoIncrementId();
2707 nameInput.addClass( 'invalid' );
2712 customizeId = 'nav_menu[' + String( placeholderId ) + ']';
2714 // Register the menu control setting.
2715 api.create( customizeId, customizeId, {}, {
2717 transport: api.Menus.data.settingTransport,
2718 previewer: api.previewer
2720 api( customizeId ).set( $.extend(
2722 api.Menus.data.defaultSettingValues.nav_menu,
2729 * Add the menu section (and its controls).
2730 * Note that this will automatically create the required controls
2731 * inside via the Section's ready method.
2733 menuSection = new api.Menus.MenuSection( customizeId, {
2737 title: displayNavMenuName( name ),
2738 customizeAction: api.Menus.data.l10n.customizingMenus,
2741 menu_id: placeholderId
2744 api.section.add( customizeId, menuSection );
2746 // Clear name field.
2747 nameInput.val( '' );
2748 nameInput.removeClass( 'invalid' );
2750 wp.a11y.speak( api.Menus.data.l10n.menuAdded );
2752 // Focus on the new menu section.
2753 api.section( customizeId ).focus(); // @todo should we focus on the new menu's control and open the add-items panel? Thinking user flow...
2758 * Extends wp.customize.controlConstructor with control constructor for
2759 * menu_location, menu_item, nav_menu, and new_menu.
2761 $.extend( api.controlConstructor, {
2762 nav_menu_location: api.Menus.MenuLocationControl,
2763 nav_menu_item: api.Menus.MenuItemControl,
2764 nav_menu: api.Menus.MenuControl,
2765 nav_menu_name: api.Menus.MenuNameControl,
2766 nav_menu_auto_add: api.Menus.MenuAutoAddControl,
2767 new_menu: api.Menus.NewMenuControl
2771 * Extends wp.customize.panelConstructor with section constructor for menus.
2773 $.extend( api.panelConstructor, {
2774 nav_menus: api.Menus.MenusPanel
2778 * Extends wp.customize.sectionConstructor with section constructor for menu.
2780 $.extend( api.sectionConstructor, {
2781 nav_menu: api.Menus.MenuSection,
2782 new_menu: api.Menus.NewMenuSection
2786 * Init Customizer for menus.
2788 api.bind( 'ready', function() {
2790 // Set up the menu items panel.
2791 api.Menus.availableMenuItemsPanel = new api.Menus.AvailableMenuItemsPanelView({
2792 collection: api.Menus.availableMenuItems
2795 api.bind( 'saved', function( data ) {
2796 if ( data.nav_menu_updates || data.nav_menu_item_updates ) {
2797 api.Menus.applySavedData( data );
2802 * Reset the list of posts created in the customizer once published.
2803 * The setting is updated quietly (bypassing events being triggered)
2804 * so that the customized state doesn't become immediately dirty.
2806 api.state( 'changesetStatus' ).bind( function( status ) {
2807 if ( 'publish' === status ) {
2808 api( 'nav_menus_created_posts' )._value = [];
2812 // Open and focus menu control.
2813 api.previewer.bind( 'focus-nav-menu-item-control', api.Menus.focusMenuItemControl );
2817 * When customize_save comes back with a success, make sure any inserted
2818 * nav menus and items are properly re-added with their newly-assigned IDs.
2820 * @param {object} data
2821 * @param {array} data.nav_menu_updates
2822 * @param {array} data.nav_menu_item_updates
2824 api.Menus.applySavedData = function( data ) {
2826 var insertedMenuIdMapping = {}, insertedMenuItemIdMapping = {};
2828 _( data.nav_menu_updates ).each(function( update ) {
2829 var oldCustomizeId, newCustomizeId, customizeId, oldSetting, newSetting, setting, settingValue, oldSection, newSection, wasSaved, widgetTemplate, navMenuCount;
2830 if ( 'inserted' === update.status ) {
2831 if ( ! update.previous_term_id ) {
2832 throw new Error( 'Expected previous_term_id' );
2834 if ( ! update.term_id ) {
2835 throw new Error( 'Expected term_id' );
2837 oldCustomizeId = 'nav_menu[' + String( update.previous_term_id ) + ']';
2838 if ( ! api.has( oldCustomizeId ) ) {
2839 throw new Error( 'Expected setting to exist: ' + oldCustomizeId );
2841 oldSetting = api( oldCustomizeId );
2842 if ( ! api.section.has( oldCustomizeId ) ) {
2843 throw new Error( 'Expected control to exist: ' + oldCustomizeId );
2845 oldSection = api.section( oldCustomizeId );
2847 settingValue = oldSetting.get();
2848 if ( ! settingValue ) {
2849 throw new Error( 'Did not expect setting to be empty (deleted).' );
2851 settingValue = $.extend( _.clone( settingValue ), update.saved_value );
2853 insertedMenuIdMapping[ update.previous_term_id ] = update.term_id;
2854 newCustomizeId = 'nav_menu[' + String( update.term_id ) + ']';
2855 newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, {
2857 transport: api.Menus.data.settingTransport,
2858 previewer: api.previewer
2861 if ( oldSection.expanded() ) {
2862 oldSection.collapse();
2865 // Add the menu section.
2866 newSection = new api.Menus.MenuSection( newCustomizeId, {
2870 title: settingValue.name,
2871 customizeAction: api.Menus.data.l10n.customizingMenus,
2873 priority: oldSection.priority.get(),
2875 menu_id: update.term_id
2879 // Add new control for the new menu.
2880 api.section.add( newCustomizeId, newSection );
2882 // Update the values for nav menus in Custom Menu controls.
2883 api.control.each( function( setting ) {
2884 if ( ! setting.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== setting.params.widget_id_base ) {
2887 var select, oldMenuOption, newMenuOption;
2888 select = setting.container.find( 'select' );
2889 oldMenuOption = select.find( 'option[value=' + String( update.previous_term_id ) + ']' );
2890 newMenuOption = select.find( 'option[value=' + String( update.term_id ) + ']' );
2891 newMenuOption.prop( 'selected', oldMenuOption.prop( 'selected' ) );
2892 oldMenuOption.remove();
2895 // Delete the old placeholder nav_menu.
2896 oldSetting.callbacks.disable(); // Prevent setting triggering Customizer dirty state when set.
2897 oldSetting.set( false );
2898 oldSetting.preview();
2899 newSetting.preview();
2900 oldSetting._dirty = false;
2902 // Remove nav_menu section.
2903 oldSection.container.remove();
2904 api.section.remove( oldCustomizeId );
2906 // Update the nav_menu widget to reflect removed placeholder menu.
2908 api.each(function( setting ) {
2909 if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) {
2913 widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' );
2914 widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount );
2915 widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount );
2916 widgetTemplate.find( 'option[value=' + String( update.previous_term_id ) + ']' ).remove();
2918 // Update the nav_menu_locations[...] controls to remove the placeholder menus from the dropdown options.
2919 wp.customize.control.each(function( control ){
2920 if ( /^nav_menu_locations\[/.test( control.id ) ) {
2921 control.container.find( 'option[value=' + String( update.previous_term_id ) + ']' ).remove();
2925 // Update nav_menu_locations to reference the new ID.
2926 api.each( function( setting ) {
2927 var wasSaved = api.state( 'saved' ).get();
2928 if ( /^nav_menu_locations\[/.test( setting.id ) && setting.get() === update.previous_term_id ) {
2929 setting.set( update.term_id );
2930 setting._dirty = false; // Not dirty because this is has also just been done on server in WP_Customize_Nav_Menu_Setting::update().
2931 api.state( 'saved' ).set( wasSaved );
2936 if ( oldSection.expanded.get() ) {
2937 // @todo This doesn't seem to be working.
2938 newSection.expand();
2940 } else if ( 'updated' === update.status ) {
2941 customizeId = 'nav_menu[' + String( update.term_id ) + ']';
2942 if ( ! api.has( customizeId ) ) {
2943 throw new Error( 'Expected setting to exist: ' + customizeId );
2946 // Make sure the setting gets updated with its sanitized server value (specifically the conflict-resolved name).
2947 setting = api( customizeId );
2948 if ( ! _.isEqual( update.saved_value, setting.get() ) ) {
2949 wasSaved = api.state( 'saved' ).get();
2950 setting.set( update.saved_value );
2951 setting._dirty = false;
2952 api.state( 'saved' ).set( wasSaved );
2957 // Build up mapping of nav_menu_item placeholder IDs to inserted IDs.
2958 _( data.nav_menu_item_updates ).each(function( update ) {
2959 if ( update.previous_post_id ) {
2960 insertedMenuItemIdMapping[ update.previous_post_id ] = update.post_id;
2964 _( data.nav_menu_item_updates ).each(function( update ) {
2965 var oldCustomizeId, newCustomizeId, oldSetting, newSetting, settingValue, oldControl, newControl;
2966 if ( 'inserted' === update.status ) {
2967 if ( ! update.previous_post_id ) {
2968 throw new Error( 'Expected previous_post_id' );
2970 if ( ! update.post_id ) {
2971 throw new Error( 'Expected post_id' );
2973 oldCustomizeId = 'nav_menu_item[' + String( update.previous_post_id ) + ']';
2974 if ( ! api.has( oldCustomizeId ) ) {
2975 throw new Error( 'Expected setting to exist: ' + oldCustomizeId );
2977 oldSetting = api( oldCustomizeId );
2978 if ( ! api.control.has( oldCustomizeId ) ) {
2979 throw new Error( 'Expected control to exist: ' + oldCustomizeId );
2981 oldControl = api.control( oldCustomizeId );
2983 settingValue = oldSetting.get();
2984 if ( ! settingValue ) {
2985 throw new Error( 'Did not expect setting to be empty (deleted).' );
2987 settingValue = _.clone( settingValue );
2989 // If the parent menu item was also inserted, update the menu_item_parent to the new ID.
2990 if ( settingValue.menu_item_parent < 0 ) {
2991 if ( ! insertedMenuItemIdMapping[ settingValue.menu_item_parent ] ) {
2992 throw new Error( 'inserted ID for menu_item_parent not available' );
2994 settingValue.menu_item_parent = insertedMenuItemIdMapping[ settingValue.menu_item_parent ];
2997 // If the menu was also inserted, then make sure it uses the new menu ID for nav_menu_term_id.
2998 if ( insertedMenuIdMapping[ settingValue.nav_menu_term_id ] ) {
2999 settingValue.nav_menu_term_id = insertedMenuIdMapping[ settingValue.nav_menu_term_id ];
3002 newCustomizeId = 'nav_menu_item[' + String( update.post_id ) + ']';
3003 newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, {
3004 type: 'nav_menu_item',
3005 transport: api.Menus.data.settingTransport,
3006 previewer: api.previewer
3009 // Add the menu control.
3010 newControl = new api.controlConstructor.nav_menu_item( newCustomizeId, {
3012 type: 'nav_menu_item',
3013 content: '<li id="customize-control-nav_menu_item-' + String( update.post_id ) + '" class="customize-control customize-control-nav_menu_item"></li>',
3014 menu_id: update.post_id,
3015 section: 'nav_menu[' + String( settingValue.nav_menu_term_id ) + ']',
3016 priority: oldControl.priority.get(),
3019 'default': newCustomizeId
3021 menu_item_id: update.post_id
3023 previewer: api.previewer
3026 // Remove old control.
3027 oldControl.container.remove();
3028 api.control.remove( oldCustomizeId );
3030 // Add new control to take its place.
3031 api.control.add( newCustomizeId, newControl );
3033 // Delete the placeholder and preview the new setting.
3034 oldSetting.callbacks.disable(); // Prevent setting triggering Customizer dirty state when set.
3035 oldSetting.set( false );
3036 oldSetting.preview();
3037 newSetting.preview();
3038 oldSetting._dirty = false;
3040 newControl.container.toggleClass( 'menu-item-edit-inactive', oldControl.container.hasClass( 'menu-item-edit-inactive' ) );
3045 * Update the settings for any nav_menu widgets that had selected a placeholder ID.
3047 _.each( data.widget_nav_menu_updates, function( widgetSettingValue, widgetSettingId ) {
3048 var setting = api( widgetSettingId );
3050 setting._value = widgetSettingValue;
3051 setting.preview(); // Send to the preview now so that menu refresh will use the inserted menu.
3057 * Focus a menu item control.
3059 * @param {string} menuItemId
3061 api.Menus.focusMenuItemControl = function( menuItemId ) {
3062 var control = api.Menus.getMenuItemControl( menuItemId );
3069 * Get the control for a given menu.
3072 * @return {wp.customize.controlConstructor.menus[]}
3074 api.Menus.getMenuControl = function( menuId ) {
3075 return api.control( 'nav_menu[' + menuId + ']' );
3079 * Given a menu item ID, get the control associated with it.
3081 * @param {string} menuItemId
3082 * @return {object|null}
3084 api.Menus.getMenuItemControl = function( menuItemId ) {
3085 return api.control( menuItemIdToSettingId( menuItemId ) );
3089 * @param {String} menuItemId
3091 function menuItemIdToSettingId( menuItemId ) {
3092 return 'nav_menu_item[' + menuItemId + ']';
3096 * Apply sanitize_text_field()-like logic to the supplied name, returning a
3097 * "unnammed" fallback string if the name is then empty.
3099 * @param {string} name
3102 function displayNavMenuName( name ) {
3104 name = $( '<div>' ).text( name ).html(); // Emulate esc_html() which is used in wp-admin/nav-menus.php.
3105 name = $.trim( name );
3106 return name || api.Menus.data.l10n.unnamed;
3109 })( wp.customize, wp, jQuery );