diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 07:56:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 07:56:49 +0000 |
commit | a415c29efee45520ae252d2aa28f1083a521cd7b (patch) | |
tree | f4ade4b6668ecc0765de7e1424f7c1427ad433ff /wp-admin/js/customize-nav-menus.js | |
parent | Initial commit. (diff) | |
download | wordpress-a415c29efee45520ae252d2aa28f1083a521cd7b.tar.xz wordpress-a415c29efee45520ae252d2aa28f1083a521cd7b.zip |
Adding upstream version 6.4.3+dfsg1.upstream/6.4.3+dfsg1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'wp-admin/js/customize-nav-menus.js')
-rw-r--r-- | wp-admin/js/customize-nav-menus.js | 3429 |
1 files changed, 3429 insertions, 0 deletions
diff --git a/wp-admin/js/customize-nav-menus.js b/wp-admin/js/customize-nav-menus.js new file mode 100644 index 0000000..8930f15 --- /dev/null +++ b/wp-admin/js/customize-nav-menus.js @@ -0,0 +1,3429 @@ +/** + * @output wp-admin/js/customize-nav-menus.js + */ + +/* global _wpCustomizeNavMenusSettings, wpNavMenu, console */ +( function( api, wp, $ ) { + 'use strict'; + + /** + * Set up wpNavMenu for drag and drop. + */ + wpNavMenu.originalInit = wpNavMenu.init; + wpNavMenu.options.menuItemDepthPerLevel = 20; + wpNavMenu.options.sortableItems = '> .customize-control-nav_menu_item'; + wpNavMenu.options.targetTolerance = 10; + wpNavMenu.init = function() { + this.jQueryExtensions(); + }; + + /** + * @namespace wp.customize.Menus + */ + api.Menus = api.Menus || {}; + + // Link settings. + api.Menus.data = { + itemTypes: [], + l10n: {}, + settingTransport: 'refresh', + phpIntMax: 0, + defaultSettingValues: { + nav_menu: {}, + nav_menu_item: {} + }, + locationSlugMappedToName: {} + }; + if ( 'undefined' !== typeof _wpCustomizeNavMenusSettings ) { + $.extend( api.Menus.data, _wpCustomizeNavMenusSettings ); + } + + /** + * Newly-created Nav Menus and Nav Menu Items have negative integer IDs which + * serve as placeholders until Save & Publish happens. + * + * @alias wp.customize.Menus.generatePlaceholderAutoIncrementId + * + * @return {number} + */ + api.Menus.generatePlaceholderAutoIncrementId = function() { + return -Math.ceil( api.Menus.data.phpIntMax * Math.random() ); + }; + + /** + * wp.customize.Menus.AvailableItemModel + * + * A single available menu item model. See PHP's WP_Customize_Nav_Menu_Item_Setting class. + * + * @class wp.customize.Menus.AvailableItemModel + * @augments Backbone.Model + */ + api.Menus.AvailableItemModel = Backbone.Model.extend( $.extend( + { + id: null // This is only used by Backbone. + }, + api.Menus.data.defaultSettingValues.nav_menu_item + ) ); + + /** + * wp.customize.Menus.AvailableItemCollection + * + * Collection for available menu item models. + * + * @class wp.customize.Menus.AvailableItemCollection + * @augments Backbone.Collection + */ + api.Menus.AvailableItemCollection = Backbone.Collection.extend(/** @lends wp.customize.Menus.AvailableItemCollection.prototype */{ + model: api.Menus.AvailableItemModel, + + sort_key: 'order', + + comparator: function( item ) { + return -item.get( this.sort_key ); + }, + + sortByField: function( fieldName ) { + this.sort_key = fieldName; + this.sort(); + } + }); + api.Menus.availableMenuItems = new api.Menus.AvailableItemCollection( api.Menus.data.availableMenuItems ); + + /** + * Insert a new `auto-draft` post. + * + * @since 4.7.0 + * @alias wp.customize.Menus.insertAutoDraftPost + * + * @param {Object} params - Parameters for the draft post to create. + * @param {string} params.post_type - Post type to add. + * @param {string} params.post_title - Post title to use. + * @return {jQuery.promise} Promise resolved with the added post. + */ + api.Menus.insertAutoDraftPost = function insertAutoDraftPost( params ) { + var request, deferred = $.Deferred(); + + request = wp.ajax.post( 'customize-nav-menus-insert-auto-draft', { + 'customize-menus-nonce': api.settings.nonce['customize-menus'], + 'wp_customize': 'on', + 'customize_changeset_uuid': api.settings.changeset.uuid, + 'params': params + } ); + + request.done( function( response ) { + if ( response.post_id ) { + api( 'nav_menus_created_posts' ).set( + api( 'nav_menus_created_posts' ).get().concat( [ response.post_id ] ) + ); + + if ( 'page' === params.post_type ) { + + // Activate static front page controls as this could be the first page created. + if ( api.section.has( 'static_front_page' ) ) { + api.section( 'static_front_page' ).activate(); + } + + // Add new page to dropdown-pages controls. + api.control.each( function( control ) { + var select; + if ( 'dropdown-pages' === control.params.type ) { + select = control.container.find( 'select[name^="_customize-dropdown-pages-"]' ); + select.append( new Option( params.post_title, response.post_id ) ); + } + } ); + } + deferred.resolve( response ); + } + } ); + + request.fail( function( response ) { + var error = response || ''; + + if ( 'undefined' !== typeof response.message ) { + error = response.message; + } + + console.error( error ); + deferred.rejectWith( error ); + } ); + + return deferred.promise(); + }; + + api.Menus.AvailableMenuItemsPanelView = wp.Backbone.View.extend(/** @lends wp.customize.Menus.AvailableMenuItemsPanelView.prototype */{ + + el: '#available-menu-items', + + events: { + 'input #menu-items-search': 'debounceSearch', + 'focus .menu-item-tpl': 'focus', + 'click .menu-item-tpl': '_submit', + 'click #custom-menu-item-submit': '_submitLink', + 'keypress #custom-menu-item-name': '_submitLink', + 'click .new-content-item .add-content': '_submitNew', + 'keypress .create-item-input': '_submitNew', + 'keydown': 'keyboardAccessible' + }, + + // Cache current selected menu item. + selected: null, + + // Cache menu control that opened the panel. + currentMenuControl: null, + debounceSearch: null, + $search: null, + $clearResults: null, + searchTerm: '', + rendered: false, + pages: {}, + sectionContent: '', + loading: false, + addingNew: false, + + /** + * wp.customize.Menus.AvailableMenuItemsPanelView + * + * View class for the available menu items panel. + * + * @constructs wp.customize.Menus.AvailableMenuItemsPanelView + * @augments wp.Backbone.View + */ + initialize: function() { + var self = this; + + if ( ! api.panel.has( 'nav_menus' ) ) { + return; + } + + this.$search = $( '#menu-items-search' ); + this.$clearResults = this.$el.find( '.clear-results' ); + this.sectionContent = this.$el.find( '.available-menu-items-list' ); + + this.debounceSearch = _.debounce( self.search, 500 ); + + _.bindAll( this, 'close' ); + + /* + * If the available menu items panel is open and the customize controls + * are interacted with (other than an item being deleted), then close + * the available menu items panel. Also close on back button click. + */ + $( '#customize-controls, .customize-section-back' ).on( 'click keydown', function( e ) { + var isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ), + isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' ); + if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) { + self.close(); + } + } ); + + // Clear the search results and trigger an `input` event to fire a new search. + this.$clearResults.on( 'click', function() { + self.$search.val( '' ).trigger( 'focus' ).trigger( 'input' ); + } ); + + this.$el.on( 'input', '#custom-menu-item-name.invalid, #custom-menu-item-url.invalid', function() { + $( this ).removeClass( 'invalid' ); + }); + + // Load available items if it looks like we'll need them. + api.panel( 'nav_menus' ).container.on( 'expanded', function() { + if ( ! self.rendered ) { + self.initList(); + self.rendered = true; + } + }); + + // Load more items. + this.sectionContent.on( 'scroll', function() { + var totalHeight = self.$el.find( '.accordion-section.open .available-menu-items-list' ).prop( 'scrollHeight' ), + visibleHeight = self.$el.find( '.accordion-section.open' ).height(); + + if ( ! self.loading && $( this ).scrollTop() > 3 / 4 * totalHeight - visibleHeight ) { + var type = $( this ).data( 'type' ), + object = $( this ).data( 'object' ); + + if ( 'search' === type ) { + if ( self.searchTerm ) { + self.doSearch( self.pages.search ); + } + } else { + self.loadItems( [ + { type: type, object: object } + ] ); + } + } + }); + + // Close the panel if the URL in the preview changes. + api.previewer.bind( 'url', this.close ); + + self.delegateEvents(); + }, + + // Search input change handler. + search: function( event ) { + var $searchSection = $( '#available-menu-items-search' ), + $otherSections = $( '#available-menu-items .accordion-section' ).not( $searchSection ); + + if ( ! event ) { + return; + } + + if ( this.searchTerm === event.target.value ) { + return; + } + + if ( '' !== event.target.value && ! $searchSection.hasClass( 'open' ) ) { + $otherSections.fadeOut( 100 ); + $searchSection.find( '.accordion-section-content' ).slideDown( 'fast' ); + $searchSection.addClass( 'open' ); + this.$clearResults.addClass( 'is-visible' ); + } else if ( '' === event.target.value ) { + $searchSection.removeClass( 'open' ); + $otherSections.show(); + this.$clearResults.removeClass( 'is-visible' ); + } + + this.searchTerm = event.target.value; + this.pages.search = 1; + this.doSearch( 1 ); + }, + + // Get search results. + doSearch: function( page ) { + var self = this, params, + $section = $( '#available-menu-items-search' ), + $content = $section.find( '.accordion-section-content' ), + itemTemplate = wp.template( 'available-menu-item' ); + + if ( self.currentRequest ) { + self.currentRequest.abort(); + } + + if ( page < 0 ) { + return; + } else if ( page > 1 ) { + $section.addClass( 'loading-more' ); + $content.attr( 'aria-busy', 'true' ); + wp.a11y.speak( api.Menus.data.l10n.itemsLoadingMore ); + } else if ( '' === self.searchTerm ) { + $content.html( '' ); + wp.a11y.speak( '' ); + return; + } + + $section.addClass( 'loading' ); + self.loading = true; + + params = api.previewer.query( { excludeCustomizedSaved: true } ); + _.extend( params, { + 'customize-menus-nonce': api.settings.nonce['customize-menus'], + 'wp_customize': 'on', + 'search': self.searchTerm, + 'page': page + } ); + + self.currentRequest = wp.ajax.post( 'search-available-menu-items-customizer', params ); + + self.currentRequest.done(function( data ) { + var items; + if ( 1 === page ) { + // Clear previous results as it's a new search. + $content.empty(); + } + $section.removeClass( 'loading loading-more' ); + $content.attr( 'aria-busy', 'false' ); + $section.addClass( 'open' ); + self.loading = false; + items = new api.Menus.AvailableItemCollection( data.items ); + self.collection.add( items.models ); + items.each( function( menuItem ) { + $content.append( itemTemplate( menuItem.attributes ) ); + } ); + if ( 20 > items.length ) { + self.pages.search = -1; // Up to 20 posts and 20 terms in results, if <20, no more results for either. + } else { + self.pages.search = self.pages.search + 1; + } + if ( items && page > 1 ) { + wp.a11y.speak( api.Menus.data.l10n.itemsFoundMore.replace( '%d', items.length ) ); + } else if ( items && page === 1 ) { + wp.a11y.speak( api.Menus.data.l10n.itemsFound.replace( '%d', items.length ) ); + } + }); + + self.currentRequest.fail(function( data ) { + // data.message may be undefined, for example when typing slow and the request is aborted. + if ( data.message ) { + $content.empty().append( $( '<li class="nothing-found"></li>' ).text( data.message ) ); + wp.a11y.speak( data.message ); + } + self.pages.search = -1; + }); + + self.currentRequest.always(function() { + $section.removeClass( 'loading loading-more' ); + $content.attr( 'aria-busy', 'false' ); + self.loading = false; + self.currentRequest = null; + }); + }, + + // Render the individual items. + initList: function() { + var self = this; + + // Render the template for each item by type. + _.each( api.Menus.data.itemTypes, function( itemType ) { + self.pages[ itemType.type + ':' + itemType.object ] = 0; + } ); + self.loadItems( api.Menus.data.itemTypes ); + }, + + /** + * Load available nav menu items. + * + * @since 4.3.0 + * @since 4.7.0 Changed function signature to take list of item types instead of single type/object. + * @access private + * + * @param {Array.<Object>} itemTypes List of objects containing type and key. + * @param {string} deprecated Formerly the object parameter. + * @return {void} + */ + loadItems: function( itemTypes, deprecated ) { + var self = this, _itemTypes, requestItemTypes = [], params, request, itemTemplate, availableMenuItemContainers = {}; + itemTemplate = wp.template( 'available-menu-item' ); + + if ( _.isString( itemTypes ) && _.isString( deprecated ) ) { + _itemTypes = [ { type: itemTypes, object: deprecated } ]; + } else { + _itemTypes = itemTypes; + } + + _.each( _itemTypes, function( itemType ) { + var container, name = itemType.type + ':' + itemType.object; + if ( -1 === self.pages[ name ] ) { + return; // Skip types for which there are no more results. + } + container = $( '#available-menu-items-' + itemType.type + '-' + itemType.object ); + container.find( '.accordion-section-title' ).addClass( 'loading' ); + availableMenuItemContainers[ name ] = container; + + requestItemTypes.push( { + object: itemType.object, + type: itemType.type, + page: self.pages[ name ] + } ); + } ); + + if ( 0 === requestItemTypes.length ) { + return; + } + + self.loading = true; + + params = api.previewer.query( { excludeCustomizedSaved: true } ); + _.extend( params, { + 'customize-menus-nonce': api.settings.nonce['customize-menus'], + 'wp_customize': 'on', + 'item_types': requestItemTypes + } ); + + request = wp.ajax.post( 'load-available-menu-items-customizer', params ); + + request.done(function( data ) { + var typeInner; + _.each( data.items, function( typeItems, name ) { + if ( 0 === typeItems.length ) { + if ( 0 === self.pages[ name ] ) { + availableMenuItemContainers[ name ].find( '.accordion-section-title' ) + .addClass( 'cannot-expand' ) + .removeClass( 'loading' ) + .find( '.accordion-section-title > button' ) + .prop( 'tabIndex', -1 ); + } + self.pages[ name ] = -1; + return; + } else if ( ( 'post_type:page' === name ) && ( ! availableMenuItemContainers[ name ].hasClass( 'open' ) ) ) { + availableMenuItemContainers[ name ].find( '.accordion-section-title > button' ).trigger( 'click' ); + } + typeItems = new api.Menus.AvailableItemCollection( typeItems ); // @todo Why is this collection created and then thrown away? + self.collection.add( typeItems.models ); + typeInner = availableMenuItemContainers[ name ].find( '.available-menu-items-list' ); + typeItems.each( function( menuItem ) { + typeInner.append( itemTemplate( menuItem.attributes ) ); + } ); + self.pages[ name ] += 1; + }); + }); + request.fail(function( data ) { + if ( typeof console !== 'undefined' && console.error ) { + console.error( data ); + } + }); + request.always(function() { + _.each( availableMenuItemContainers, function( container ) { + container.find( '.accordion-section-title' ).removeClass( 'loading' ); + } ); + self.loading = false; + }); + }, + + // Adjust the height of each section of items to fit the screen. + itemSectionHeight: function() { + var sections, lists, totalHeight, accordionHeight, diff; + totalHeight = window.innerHeight; + sections = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .accordion-section-content' ); + lists = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .available-menu-items-list:not(":only-child")' ); + accordionHeight = 46 * ( 1 + sections.length ) + 14; // Magic numbers. + diff = totalHeight - accordionHeight; + if ( 120 < diff && 290 > diff ) { + sections.css( 'max-height', diff ); + lists.css( 'max-height', ( diff - 60 ) ); + } + }, + + // Highlights a menu item. + select: function( menuitemTpl ) { + this.selected = $( menuitemTpl ); + this.selected.siblings( '.menu-item-tpl' ).removeClass( 'selected' ); + this.selected.addClass( 'selected' ); + }, + + // Highlights a menu item on focus. + focus: function( event ) { + this.select( $( event.currentTarget ) ); + }, + + // Submit handler for keypress and click on menu item. + _submit: function( event ) { + // Only proceed with keypress if it is Enter or Spacebar. + if ( 'keypress' === event.type && ( 13 !== event.which && 32 !== event.which ) ) { + return; + } + + this.submit( $( event.currentTarget ) ); + }, + + // Adds a selected menu item to the menu. + submit: function( menuitemTpl ) { + var menuitemId, menu_item; + + if ( ! menuitemTpl ) { + menuitemTpl = this.selected; + } + + if ( ! menuitemTpl || ! this.currentMenuControl ) { + return; + } + + this.select( menuitemTpl ); + + menuitemId = $( this.selected ).data( 'menu-item-id' ); + menu_item = this.collection.findWhere( { id: menuitemId } ); + if ( ! menu_item ) { + return; + } + + this.currentMenuControl.addItemToMenu( menu_item.attributes ); + + $( menuitemTpl ).find( '.menu-item-handle' ).addClass( 'item-added' ); + }, + + // Submit handler for keypress and click on custom menu item. + _submitLink: function( event ) { + // Only proceed with keypress if it is Enter. + if ( 'keypress' === event.type && 13 !== event.which ) { + return; + } + + this.submitLink(); + }, + + // Adds the custom menu item to the menu. + submitLink: function() { + var menuItem, + itemName = $( '#custom-menu-item-name' ), + itemUrl = $( '#custom-menu-item-url' ), + url = itemUrl.val().trim(), + urlRegex; + + if ( ! this.currentMenuControl ) { + return; + } + + /* + * Allow URLs including: + * - http://example.com/ + * - //example.com + * - /directory/ + * - ?query-param + * - #target + * - mailto:foo@example.com + * + * Any further validation will be handled on the server when the setting is attempted to be saved, + * so this pattern does not need to be complete. + */ + urlRegex = /^((\w+:)?\/\/\w.*|\w+:(?!\/\/$)|\/|\?|#)/; + + if ( '' === itemName.val() ) { + itemName.addClass( 'invalid' ); + return; + } else if ( ! urlRegex.test( url ) ) { + itemUrl.addClass( 'invalid' ); + return; + } + + menuItem = { + 'title': itemName.val(), + 'url': url, + 'type': 'custom', + 'type_label': api.Menus.data.l10n.custom_label, + 'object': 'custom' + }; + + this.currentMenuControl.addItemToMenu( menuItem ); + + // Reset the custom link form. + itemUrl.val( '' ).attr( 'placeholder', 'https://' ); + itemName.val( '' ); + }, + + /** + * Submit handler for keypress (enter) on field and click on button. + * + * @since 4.7.0 + * @private + * + * @param {jQuery.Event} event Event. + * @return {void} + */ + _submitNew: function( event ) { + var container; + + // Only proceed with keypress if it is Enter. + if ( 'keypress' === event.type && 13 !== event.which ) { + return; + } + + if ( this.addingNew ) { + return; + } + + container = $( event.target ).closest( '.accordion-section' ); + + this.submitNew( container ); + }, + + /** + * Creates a new object and adds an associated menu item to the menu. + * + * @since 4.7.0 + * @private + * + * @param {jQuery} container + * @return {void} + */ + submitNew: function( container ) { + var panel = this, + itemName = container.find( '.create-item-input' ), + title = itemName.val(), + dataContainer = container.find( '.available-menu-items-list' ), + itemType = dataContainer.data( 'type' ), + itemObject = dataContainer.data( 'object' ), + itemTypeLabel = dataContainer.data( 'type_label' ), + promise; + + if ( ! this.currentMenuControl ) { + return; + } + + // Only posts are supported currently. + if ( 'post_type' !== itemType ) { + return; + } + + if ( '' === itemName.val().trim() ) { + itemName.addClass( 'invalid' ); + itemName.focus(); + return; + } else { + itemName.removeClass( 'invalid' ); + container.find( '.accordion-section-title' ).addClass( 'loading' ); + } + + panel.addingNew = true; + itemName.attr( 'disabled', 'disabled' ); + promise = api.Menus.insertAutoDraftPost( { + post_title: title, + post_type: itemObject + } ); + promise.done( function( data ) { + var availableItem, $content, itemElement; + availableItem = new api.Menus.AvailableItemModel( { + 'id': 'post-' + data.post_id, // Used for available menu item Backbone models. + 'title': itemName.val(), + 'type': itemType, + 'type_label': itemTypeLabel, + 'object': itemObject, + 'object_id': data.post_id, + 'url': data.url + } ); + + // Add new item to menu. + panel.currentMenuControl.addItemToMenu( availableItem.attributes ); + + // Add the new item to the list of available items. + api.Menus.availableMenuItemsPanel.collection.add( availableItem ); + $content = container.find( '.available-menu-items-list' ); + itemElement = $( wp.template( 'available-menu-item' )( availableItem.attributes ) ); + itemElement.find( '.menu-item-handle:first' ).addClass( 'item-added' ); + $content.prepend( itemElement ); + $content.scrollTop(); + + // Reset the create content form. + itemName.val( '' ).removeAttr( 'disabled' ); + panel.addingNew = false; + container.find( '.accordion-section-title' ).removeClass( 'loading' ); + } ); + }, + + // Opens the panel. + open: function( menuControl ) { + var panel = this, close; + + this.currentMenuControl = menuControl; + + this.itemSectionHeight(); + + if ( api.section.has( 'publish_settings' ) ) { + api.section( 'publish_settings' ).collapse(); + } + + $( 'body' ).addClass( 'adding-menu-items' ); + + close = function() { + panel.close(); + $( this ).off( 'click', close ); + }; + $( '#customize-preview' ).on( 'click', close ); + + // Collapse all controls. + _( this.currentMenuControl.getMenuItemControls() ).each( function( control ) { + control.collapseForm(); + } ); + + this.$el.find( '.selected' ).removeClass( 'selected' ); + + this.$search.trigger( 'focus' ); + }, + + // Closes the panel. + close: function( options ) { + options = options || {}; + + if ( options.returnFocus && this.currentMenuControl ) { + this.currentMenuControl.container.find( '.add-new-menu-item' ).focus(); + } + + this.currentMenuControl = null; + this.selected = null; + + $( 'body' ).removeClass( 'adding-menu-items' ); + $( '#available-menu-items .menu-item-handle.item-added' ).removeClass( 'item-added' ); + + this.$search.val( '' ).trigger( 'input' ); + }, + + // Add a few keyboard enhancements to the panel. + keyboardAccessible: function( event ) { + var isEnter = ( 13 === event.which ), + isEsc = ( 27 === event.which ), + isBackTab = ( 9 === event.which && event.shiftKey ), + isSearchFocused = $( event.target ).is( this.$search ); + + // If enter pressed but nothing entered, don't do anything. + if ( isEnter && ! this.$search.val() ) { + return; + } + + if ( isSearchFocused && isBackTab ) { + this.currentMenuControl.container.find( '.add-new-menu-item' ).focus(); + event.preventDefault(); // Avoid additional back-tab. + } else if ( isEsc ) { + this.close( { returnFocus: true } ); + } + } + }); + + /** + * wp.customize.Menus.MenusPanel + * + * Customizer panel for menus. This is used only for screen options management. + * Note that 'menus' must match the WP_Customize_Menu_Panel::$type. + * + * @class wp.customize.Menus.MenusPanel + * @augments wp.customize.Panel + */ + api.Menus.MenusPanel = api.Panel.extend(/** @lends wp.customize.Menus.MenusPanel.prototype */{ + + attachEvents: function() { + api.Panel.prototype.attachEvents.call( this ); + + var panel = this, + panelMeta = panel.container.find( '.panel-meta' ), + help = panelMeta.find( '.customize-help-toggle' ), + content = panelMeta.find( '.customize-panel-description' ), + options = $( '#screen-options-wrap' ), + button = panelMeta.find( '.customize-screen-options-toggle' ); + button.on( 'click keydown', function( event ) { + if ( api.utils.isKeydownButNotEnterEvent( event ) ) { + return; + } + event.preventDefault(); + + // Hide description. + if ( content.not( ':hidden' ) ) { + content.slideUp( 'fast' ); + help.attr( 'aria-expanded', 'false' ); + } + + if ( 'true' === button.attr( 'aria-expanded' ) ) { + button.attr( 'aria-expanded', 'false' ); + panelMeta.removeClass( 'open' ); + panelMeta.removeClass( 'active-menu-screen-options' ); + options.slideUp( 'fast' ); + } else { + button.attr( 'aria-expanded', 'true' ); + panelMeta.addClass( 'open' ); + panelMeta.addClass( 'active-menu-screen-options' ); + options.slideDown( 'fast' ); + } + + return false; + } ); + + // Help toggle. + help.on( 'click keydown', function( event ) { + if ( api.utils.isKeydownButNotEnterEvent( event ) ) { + return; + } + event.preventDefault(); + + if ( 'true' === button.attr( 'aria-expanded' ) ) { + button.attr( 'aria-expanded', 'false' ); + help.attr( 'aria-expanded', 'true' ); + panelMeta.addClass( 'open' ); + panelMeta.removeClass( 'active-menu-screen-options' ); + options.slideUp( 'fast' ); + content.slideDown( 'fast' ); + } + } ); + }, + + /** + * Update field visibility when clicking on the field toggles. + */ + ready: function() { + var panel = this; + panel.container.find( '.hide-column-tog' ).on( 'click', function() { + panel.saveManageColumnsState(); + }); + + // Inject additional heading into the menu locations section's head container. + api.section( 'menu_locations', function( section ) { + section.headContainer.prepend( + wp.template( 'nav-menu-locations-header' )( api.Menus.data ) + ); + } ); + }, + + /** + * Save hidden column states. + * + * @since 4.3.0 + * @private + * + * @return {void} + */ + saveManageColumnsState: _.debounce( function() { + var panel = this; + if ( panel._updateHiddenColumnsRequest ) { + panel._updateHiddenColumnsRequest.abort(); + } + + panel._updateHiddenColumnsRequest = wp.ajax.post( 'hidden-columns', { + hidden: panel.hidden(), + screenoptionnonce: $( '#screenoptionnonce' ).val(), + page: 'nav-menus' + } ); + panel._updateHiddenColumnsRequest.always( function() { + panel._updateHiddenColumnsRequest = null; + } ); + }, 2000 ), + + /** + * @deprecated Since 4.7.0 now that the nav_menu sections are responsible for toggling the classes on their own containers. + */ + checked: function() {}, + + /** + * @deprecated Since 4.7.0 now that the nav_menu sections are responsible for toggling the classes on their own containers. + */ + unchecked: function() {}, + + /** + * Get hidden fields. + * + * @since 4.3.0 + * @private + * + * @return {Array} Fields (columns) that are hidden. + */ + hidden: function() { + return $( '.hide-column-tog' ).not( ':checked' ).map( function() { + var id = this.id; + return id.substring( 0, id.length - 5 ); + }).get().join( ',' ); + } + } ); + + /** + * wp.customize.Menus.MenuSection + * + * Customizer section for menus. This is used only for lazy-loading child controls. + * Note that 'nav_menu' must match the WP_Customize_Menu_Section::$type. + * + * @class wp.customize.Menus.MenuSection + * @augments wp.customize.Section + */ + api.Menus.MenuSection = api.Section.extend(/** @lends wp.customize.Menus.MenuSection.prototype */{ + + /** + * Initialize. + * + * @since 4.3.0 + * + * @param {string} id + * @param {Object} options + */ + initialize: function( id, options ) { + var section = this; + api.Section.prototype.initialize.call( section, id, options ); + section.deferred.initSortables = $.Deferred(); + }, + + /** + * Ready. + */ + ready: function() { + var section = this, fieldActiveToggles, handleFieldActiveToggle; + + if ( 'undefined' === typeof section.params.menu_id ) { + throw new Error( 'params.menu_id was not defined' ); + } + + /* + * Since newly created sections won't be registered in PHP, we need to prevent the + * preview's sending of the activeSections to result in this control + * being deactivated when the preview refreshes. So we can hook onto + * the setting that has the same ID and its presence can dictate + * whether the section is active. + */ + section.active.validate = function() { + if ( ! api.has( section.id ) ) { + return false; + } + return !! api( section.id ).get(); + }; + + section.populateControls(); + + section.navMenuLocationSettings = {}; + section.assignedLocations = new api.Value( [] ); + + api.each(function( setting, id ) { + var matches = id.match( /^nav_menu_locations\[(.+?)]/ ); + if ( matches ) { + section.navMenuLocationSettings[ matches[1] ] = setting; + setting.bind( function() { + section.refreshAssignedLocations(); + }); + } + }); + + section.assignedLocations.bind(function( to ) { + section.updateAssignedLocationsInSectionTitle( to ); + }); + + section.refreshAssignedLocations(); + + api.bind( 'pane-contents-reflowed', function() { + // Skip menus that have been removed. + if ( ! section.contentContainer.parent().length ) { + return; + } + section.container.find( '.menu-item .menu-item-reorder-nav button' ).attr({ 'tabindex': '0', 'aria-hidden': 'false' }); + section.container.find( '.menu-item.move-up-disabled .menus-move-up' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); + section.container.find( '.menu-item.move-down-disabled .menus-move-down' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); + section.container.find( '.menu-item.move-left-disabled .menus-move-left' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); + section.container.find( '.menu-item.move-right-disabled .menus-move-right' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); + } ); + + /** + * Update the active field class for the content container for a given checkbox toggle. + * + * @this {jQuery} + * @return {void} + */ + handleFieldActiveToggle = function() { + var className = 'field-' + $( this ).val() + '-active'; + section.contentContainer.toggleClass( className, $( this ).prop( 'checked' ) ); + }; + fieldActiveToggles = api.panel( 'nav_menus' ).contentContainer.find( '.metabox-prefs:first' ).find( '.hide-column-tog' ); + fieldActiveToggles.each( handleFieldActiveToggle ); + fieldActiveToggles.on( 'click', handleFieldActiveToggle ); + }, + + populateControls: function() { + var section = this, + menuNameControlId, + menuLocationsControlId, + menuAutoAddControlId, + menuDeleteControlId, + menuControl, + menuNameControl, + menuLocationsControl, + menuAutoAddControl, + menuDeleteControl; + + // Add the control for managing the menu name. + menuNameControlId = section.id + '[name]'; + menuNameControl = api.control( menuNameControlId ); + if ( ! menuNameControl ) { + menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, { + type: 'nav_menu_name', + label: api.Menus.data.l10n.menuNameLabel, + section: section.id, + priority: 0, + settings: { + 'default': section.id + } + } ); + api.control.add( menuNameControl ); + menuNameControl.active.set( true ); + } + + // Add the menu control. + menuControl = api.control( section.id ); + if ( ! menuControl ) { + menuControl = new api.controlConstructor.nav_menu( section.id, { + type: 'nav_menu', + section: section.id, + priority: 998, + settings: { + 'default': section.id + }, + menu_id: section.params.menu_id + } ); + api.control.add( menuControl ); + menuControl.active.set( true ); + } + + // Add the menu locations control. + menuLocationsControlId = section.id + '[locations]'; + menuLocationsControl = api.control( menuLocationsControlId ); + if ( ! menuLocationsControl ) { + menuLocationsControl = new api.controlConstructor.nav_menu_locations( menuLocationsControlId, { + section: section.id, + priority: 999, + settings: { + 'default': section.id + }, + menu_id: section.params.menu_id + } ); + api.control.add( menuLocationsControl.id, menuLocationsControl ); + menuControl.active.set( true ); + } + + // Add the control for managing the menu auto_add. + menuAutoAddControlId = section.id + '[auto_add]'; + menuAutoAddControl = api.control( menuAutoAddControlId ); + if ( ! menuAutoAddControl ) { + menuAutoAddControl = new api.controlConstructor.nav_menu_auto_add( menuAutoAddControlId, { + type: 'nav_menu_auto_add', + label: '', + section: section.id, + priority: 1000, + settings: { + 'default': section.id + } + } ); + api.control.add( menuAutoAddControl ); + menuAutoAddControl.active.set( true ); + } + + // Add the control for deleting the menu. + menuDeleteControlId = section.id + '[delete]'; + menuDeleteControl = api.control( menuDeleteControlId ); + if ( ! menuDeleteControl ) { + menuDeleteControl = new api.Control( menuDeleteControlId, { + section: section.id, + priority: 1001, + templateId: 'nav-menu-delete-button' + } ); + api.control.add( menuDeleteControl.id, menuDeleteControl ); + menuDeleteControl.active.set( true ); + menuDeleteControl.deferred.embedded.done( function () { + menuDeleteControl.container.find( 'button' ).on( 'click', function() { + var menuId = section.params.menu_id; + var menuControl = api.Menus.getMenuControl( menuId ); + menuControl.setting.set( false ); + }); + } ); + } + }, + + /** + * + */ + refreshAssignedLocations: function() { + var section = this, + menuTermId = section.params.menu_id, + currentAssignedLocations = []; + _.each( section.navMenuLocationSettings, function( setting, themeLocation ) { + if ( setting() === menuTermId ) { + currentAssignedLocations.push( themeLocation ); + } + }); + section.assignedLocations.set( currentAssignedLocations ); + }, + + /** + * @param {Array} themeLocationSlugs Theme location slugs. + */ + updateAssignedLocationsInSectionTitle: function( themeLocationSlugs ) { + var section = this, + $title; + + $title = section.container.find( '.accordion-section-title:first' ); + $title.find( '.menu-in-location' ).remove(); + _.each( themeLocationSlugs, function( themeLocationSlug ) { + var $label, locationName; + $label = $( '<span class="menu-in-location"></span>' ); + locationName = api.Menus.data.locationSlugMappedToName[ themeLocationSlug ]; + $label.text( api.Menus.data.l10n.menuLocation.replace( '%s', locationName ) ); + $title.append( $label ); + }); + + section.container.toggleClass( 'assigned-to-menu-location', 0 !== themeLocationSlugs.length ); + + }, + + onChangeExpanded: function( expanded, args ) { + var section = this, completeCallback; + + if ( expanded ) { + wpNavMenu.menuList = section.contentContainer; + wpNavMenu.targetList = wpNavMenu.menuList; + + // Add attributes needed by wpNavMenu. + $( '#menu-to-edit' ).removeAttr( 'id' ); + wpNavMenu.menuList.attr( 'id', 'menu-to-edit' ).addClass( 'menu' ); + + _.each( api.section( section.id ).controls(), function( control ) { + if ( 'nav_menu_item' === control.params.type ) { + control.actuallyEmbed(); + } + } ); + + // Make sure Sortables is initialized after the section has been expanded to prevent `offset` issues. + if ( args.completeCallback ) { + completeCallback = args.completeCallback; + } + args.completeCallback = function() { + if ( 'resolved' !== section.deferred.initSortables.state() ) { + wpNavMenu.initSortables(); // Depends on menu-to-edit ID being set above. + section.deferred.initSortables.resolve( wpNavMenu.menuList ); // Now MenuControl can extend the sortable. + + // @todo Note that wp.customize.reflowPaneContents() is debounced, + // so this immediate change will show a slight flicker while priorities get updated. + api.control( 'nav_menu[' + String( section.params.menu_id ) + ']' ).reflowMenuItems(); + } + if ( _.isFunction( completeCallback ) ) { + completeCallback(); + } + }; + } + api.Section.prototype.onChangeExpanded.call( section, expanded, args ); + }, + + /** + * Highlight how a user may create new menu items. + * + * This method reminds the user to create new menu items and how. + * It's exposed this way because this class knows best which UI needs + * highlighted but those expanding this section know more about why and + * when the affordance should be highlighted. + * + * @since 4.9.0 + * + * @return {void} + */ + highlightNewItemButton: function() { + api.utils.highlightButton( this.contentContainer.find( '.add-new-menu-item' ), { delay: 2000 } ); + } + }); + + /** + * Create a nav menu setting and section. + * + * @since 4.9.0 + * + * @param {string} [name=''] Nav menu name. + * @return {wp.customize.Menus.MenuSection} Added nav menu. + */ + api.Menus.createNavMenu = function createNavMenu( name ) { + var customizeId, placeholderId, setting; + placeholderId = api.Menus.generatePlaceholderAutoIncrementId(); + + customizeId = 'nav_menu[' + String( placeholderId ) + ']'; + + // Register the menu control setting. + setting = api.create( customizeId, customizeId, {}, { + type: 'nav_menu', + transport: api.Menus.data.settingTransport, + previewer: api.previewer + } ); + setting.set( $.extend( + {}, + api.Menus.data.defaultSettingValues.nav_menu, + { + name: name || '' + } + ) ); + + /* + * Add the menu section (and its controls). + * Note that this will automatically create the required controls + * inside via the Section's ready method. + */ + return api.section.add( new api.Menus.MenuSection( customizeId, { + panel: 'nav_menus', + title: displayNavMenuName( name ), + customizeAction: api.Menus.data.l10n.customizingMenus, + priority: 10, + menu_id: placeholderId + } ) ); + }; + + /** + * wp.customize.Menus.NewMenuSection + * + * Customizer section for new menus. + * + * @class wp.customize.Menus.NewMenuSection + * @augments wp.customize.Section + */ + api.Menus.NewMenuSection = api.Section.extend(/** @lends wp.customize.Menus.NewMenuSection.prototype */{ + + /** + * Add behaviors for the accordion section. + * + * @since 4.3.0 + */ + attachEvents: function() { + var section = this, + container = section.container, + contentContainer = section.contentContainer, + navMenuSettingPattern = /^nav_menu\[/; + + section.headContainer.find( '.accordion-section-title' ).replaceWith( + wp.template( 'nav-menu-create-menu-section-title' ) + ); + + /* + * We have to manually handle section expanded because we do not + * apply the `accordion-section-title` class to this button-driven section. + */ + container.on( 'click', '.customize-add-menu-button', function() { + section.expand(); + }); + + contentContainer.on( 'keydown', '.menu-name-field', function( event ) { + if ( 13 === event.which ) { // Enter. + section.submit(); + } + } ); + contentContainer.on( 'click', '#customize-new-menu-submit', function( event ) { + section.submit(); + event.stopPropagation(); + event.preventDefault(); + } ); + + /** + * Get number of non-deleted nav menus. + * + * @since 4.9.0 + * @return {number} Count. + */ + function getNavMenuCount() { + var count = 0; + api.each( function( setting ) { + if ( navMenuSettingPattern.test( setting.id ) && false !== setting.get() ) { + count += 1; + } + } ); + return count; + } + + /** + * Update visibility of notice to prompt users to create menus. + * + * @since 4.9.0 + * @return {void} + */ + function updateNoticeVisibility() { + container.find( '.add-new-menu-notice' ).prop( 'hidden', getNavMenuCount() > 0 ); + } + + /** + * Handle setting addition. + * + * @since 4.9.0 + * @param {wp.customize.Setting} setting - Added setting. + * @return {void} + */ + function addChangeEventListener( setting ) { + if ( navMenuSettingPattern.test( setting.id ) ) { + setting.bind( updateNoticeVisibility ); + updateNoticeVisibility(); + } + } + + /** + * Handle setting removal. + * + * @since 4.9.0 + * @param {wp.customize.Setting} setting - Removed setting. + * @return {void} + */ + function removeChangeEventListener( setting ) { + if ( navMenuSettingPattern.test( setting.id ) ) { + setting.unbind( updateNoticeVisibility ); + updateNoticeVisibility(); + } + } + + api.each( addChangeEventListener ); + api.bind( 'add', addChangeEventListener ); + api.bind( 'removed', removeChangeEventListener ); + updateNoticeVisibility(); + + api.Section.prototype.attachEvents.apply( section, arguments ); + }, + + /** + * Set up the control. + * + * @since 4.9.0 + */ + ready: function() { + this.populateControls(); + }, + + /** + * Create the controls for this section. + * + * @since 4.9.0 + */ + populateControls: function() { + var section = this, + menuNameControlId, + menuLocationsControlId, + newMenuSubmitControlId, + menuNameControl, + menuLocationsControl, + newMenuSubmitControl; + + menuNameControlId = section.id + '[name]'; + menuNameControl = api.control( menuNameControlId ); + if ( ! menuNameControl ) { + menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, { + label: api.Menus.data.l10n.menuNameLabel, + description: api.Menus.data.l10n.newMenuNameDescription, + section: section.id, + priority: 0 + } ); + api.control.add( menuNameControl.id, menuNameControl ); + menuNameControl.active.set( true ); + } + + menuLocationsControlId = section.id + '[locations]'; + menuLocationsControl = api.control( menuLocationsControlId ); + if ( ! menuLocationsControl ) { + menuLocationsControl = new api.controlConstructor.nav_menu_locations( menuLocationsControlId, { + section: section.id, + priority: 1, + menu_id: '', + isCreating: true + } ); + api.control.add( menuLocationsControlId, menuLocationsControl ); + menuLocationsControl.active.set( true ); + } + + newMenuSubmitControlId = section.id + '[submit]'; + newMenuSubmitControl = api.control( newMenuSubmitControlId ); + if ( !newMenuSubmitControl ) { + newMenuSubmitControl = new api.Control( newMenuSubmitControlId, { + section: section.id, + priority: 1, + templateId: 'nav-menu-submit-new-button' + } ); + api.control.add( newMenuSubmitControlId, newMenuSubmitControl ); + newMenuSubmitControl.active.set( true ); + } + }, + + /** + * Create the new menu with name and location supplied by the user. + * + * @since 4.9.0 + */ + submit: function() { + var section = this, + contentContainer = section.contentContainer, + nameInput = contentContainer.find( '.menu-name-field' ).first(), + name = nameInput.val(), + menuSection; + + if ( ! name ) { + nameInput.addClass( 'invalid' ); + nameInput.focus(); + return; + } + + menuSection = api.Menus.createNavMenu( name ); + + // Clear name field. + nameInput.val( '' ); + nameInput.removeClass( 'invalid' ); + + contentContainer.find( '.assigned-menu-location input[type=checkbox]' ).each( function() { + var checkbox = $( this ), + navMenuLocationSetting; + + if ( checkbox.prop( 'checked' ) ) { + navMenuLocationSetting = api( 'nav_menu_locations[' + checkbox.data( 'location-id' ) + ']' ); + navMenuLocationSetting.set( menuSection.params.menu_id ); + + // Reset state for next new menu. + checkbox.prop( 'checked', false ); + } + } ); + + wp.a11y.speak( api.Menus.data.l10n.menuAdded ); + + // Focus on the new menu section. + menuSection.focus( { + completeCallback: function() { + menuSection.highlightNewItemButton(); + } + } ); + }, + + /** + * Select a default location. + * + * This method selects a single location by default so we can support + * creating a menu for a specific menu location. + * + * @since 4.9.0 + * + * @param {string|null} locationId - The ID of the location to select. `null` clears all selections. + * @return {void} + */ + selectDefaultLocation: function( locationId ) { + var locationControl = api.control( this.id + '[locations]' ), + locationSelections = {}; + + if ( locationId !== null ) { + locationSelections[ locationId ] = true; + } + + locationControl.setSelections( locationSelections ); + } + }); + + /** + * wp.customize.Menus.MenuLocationControl + * + * Customizer control for menu locations (rendered as a <select>). + * Note that 'nav_menu_location' must match the WP_Customize_Nav_Menu_Location_Control::$type. + * + * @class wp.customize.Menus.MenuLocationControl + * @augments wp.customize.Control + */ + api.Menus.MenuLocationControl = api.Control.extend(/** @lends wp.customize.Menus.MenuLocationControl.prototype */{ + initialize: function( id, options ) { + var control = this, + matches = id.match( /^nav_menu_locations\[(.+?)]/ ); + control.themeLocation = matches[1]; + api.Control.prototype.initialize.call( control, id, options ); + }, + + ready: function() { + var control = this, navMenuIdRegex = /^nav_menu\[(-?\d+)]/; + + // @todo It would be better if this was added directly on the setting itself, as opposed to the control. + control.setting.validate = function( value ) { + if ( '' === value ) { + return 0; + } else { + return parseInt( value, 10 ); + } + }; + + // Create and Edit menu buttons. + control.container.find( '.create-menu' ).on( 'click', function() { + var addMenuSection = api.section( 'add_menu' ); + addMenuSection.selectDefaultLocation( this.dataset.locationId ); + addMenuSection.focus(); + } ); + control.container.find( '.edit-menu' ).on( 'click', function() { + var menuId = control.setting(); + api.section( 'nav_menu[' + menuId + ']' ).focus(); + }); + control.setting.bind( 'change', function() { + var menuIsSelected = 0 !== control.setting(); + control.container.find( '.create-menu' ).toggleClass( 'hidden', menuIsSelected ); + control.container.find( '.edit-menu' ).toggleClass( 'hidden', ! menuIsSelected ); + }); + + // Add/remove menus from the available options when they are added and removed. + api.bind( 'add', function( setting ) { + var option, menuId, matches = setting.id.match( navMenuIdRegex ); + if ( ! matches || false === setting() ) { + return; + } + menuId = matches[1]; + option = new Option( displayNavMenuName( setting().name ), menuId ); + control.container.find( 'select' ).append( option ); + }); + api.bind( 'remove', function( setting ) { + var menuId, matches = setting.id.match( navMenuIdRegex ); + if ( ! matches ) { + return; + } + menuId = parseInt( matches[1], 10 ); + if ( control.setting() === menuId ) { + control.setting.set( '' ); + } + control.container.find( 'option[value=' + menuId + ']' ).remove(); + }); + api.bind( 'change', function( setting ) { + var menuId, matches = setting.id.match( navMenuIdRegex ); + if ( ! matches ) { + return; + } + menuId = parseInt( matches[1], 10 ); + if ( false === setting() ) { + if ( control.setting() === menuId ) { + control.setting.set( '' ); + } + control.container.find( 'option[value=' + menuId + ']' ).remove(); + } else { + control.container.find( 'option[value=' + menuId + ']' ).text( displayNavMenuName( setting().name ) ); + } + }); + } + }); + + api.Menus.MenuItemControl = api.Control.extend(/** @lends wp.customize.Menus.MenuItemControl.prototype */{ + + /** + * wp.customize.Menus.MenuItemControl + * + * Customizer control for menu items. + * Note that 'menu_item' must match the WP_Customize_Menu_Item_Control::$type. + * + * @constructs wp.customize.Menus.MenuItemControl + * @augments wp.customize.Control + * + * @inheritDoc + */ + initialize: function( id, options ) { + var control = this; + control.expanded = new api.Value( false ); + control.expandedArgumentsQueue = []; + control.expanded.bind( function( expanded ) { + var args = control.expandedArgumentsQueue.shift(); + args = $.extend( {}, control.defaultExpandedArguments, args ); + control.onChangeExpanded( expanded, args ); + }); + api.Control.prototype.initialize.call( control, id, options ); + control.active.validate = function() { + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; + }; + }, + + /** + * Override the embed() method to do nothing, + * so that the control isn't embedded on load, + * unless the containing section is already expanded. + * + * @since 4.3.0 + */ + embed: function() { + var control = this, + sectionId = control.section(), + section; + if ( ! sectionId ) { + return; + } + section = api.section( sectionId ); + if ( ( section && section.expanded() ) || api.settings.autofocus.control === control.id ) { + control.actuallyEmbed(); + } + }, + + /** + * This function is called in Section.onChangeExpanded() so the control + * will only get embedded when the Section is first expanded. + * + * @since 4.3.0 + */ + actuallyEmbed: function() { + var control = this; + if ( 'resolved' === control.deferred.embedded.state() ) { + return; + } + control.renderContent(); + control.deferred.embedded.resolve(); // This triggers control.ready(). + }, + + /** + * Set up the control. + */ + ready: function() { + if ( 'undefined' === typeof this.params.menu_item_id ) { + throw new Error( 'params.menu_item_id was not defined' ); + } + + this._setupControlToggle(); + this._setupReorderUI(); + this._setupUpdateUI(); + this._setupRemoveUI(); + this._setupLinksUI(); + this._setupTitleUI(); + }, + + /** + * Show/hide the settings when clicking on the menu item handle. + */ + _setupControlToggle: function() { + var control = this; + + this.container.find( '.menu-item-handle' ).on( 'click', function( e ) { + e.preventDefault(); + e.stopPropagation(); + var menuControl = control.getMenuControl(), + isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ), + isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' ); + + if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) { + api.Menus.availableMenuItemsPanel.close(); + } + + if ( menuControl.isReordering || menuControl.isSorting ) { + return; + } + control.toggleForm(); + } ); + }, + + /** + * Set up the menu-item-reorder-nav + */ + _setupReorderUI: function() { + var control = this, template, $reorderNav; + + template = wp.template( 'menu-item-reorder-nav' ); + + // Add the menu item reordering elements to the menu item control. + control.container.find( '.item-controls' ).after( template ); + + // Handle clicks for up/down/left-right on the reorder nav. + $reorderNav = control.container.find( '.menu-item-reorder-nav' ); + $reorderNav.find( '.menus-move-up, .menus-move-down, .menus-move-left, .menus-move-right' ).on( 'click', function() { + var moveBtn = $( this ); + moveBtn.focus(); + + var isMoveUp = moveBtn.is( '.menus-move-up' ), + isMoveDown = moveBtn.is( '.menus-move-down' ), + isMoveLeft = moveBtn.is( '.menus-move-left' ), + isMoveRight = moveBtn.is( '.menus-move-right' ); + + if ( isMoveUp ) { + control.moveUp(); + } else if ( isMoveDown ) { + control.moveDown(); + } else if ( isMoveLeft ) { + control.moveLeft(); + } else if ( isMoveRight ) { + control.moveRight(); + } + + moveBtn.focus(); // Re-focus after the container was moved. + } ); + }, + + /** + * Set up event handlers for menu item updating. + */ + _setupUpdateUI: function() { + var control = this, + settingValue = control.setting(), + updateNotifications; + + control.elements = {}; + control.elements.url = new api.Element( control.container.find( '.edit-menu-item-url' ) ); + control.elements.title = new api.Element( control.container.find( '.edit-menu-item-title' ) ); + control.elements.attr_title = new api.Element( control.container.find( '.edit-menu-item-attr-title' ) ); + control.elements.target = new api.Element( control.container.find( '.edit-menu-item-target' ) ); + control.elements.classes = new api.Element( control.container.find( '.edit-menu-item-classes' ) ); + control.elements.xfn = new api.Element( control.container.find( '.edit-menu-item-xfn' ) ); + control.elements.description = new api.Element( control.container.find( '.edit-menu-item-description' ) ); + // @todo Allow other elements, added by plugins, to be automatically picked up here; + // allow additional values to be added to setting array. + + _.each( control.elements, function( element, property ) { + element.bind(function( value ) { + if ( element.element.is( 'input[type=checkbox]' ) ) { + value = ( value ) ? element.element.val() : ''; + } + + var settingValue = control.setting(); + if ( settingValue && settingValue[ property ] !== value ) { + settingValue = _.clone( settingValue ); + settingValue[ property ] = value; + control.setting.set( settingValue ); + } + }); + if ( settingValue ) { + if ( ( property === 'classes' || property === 'xfn' ) && _.isArray( settingValue[ property ] ) ) { + element.set( settingValue[ property ].join( ' ' ) ); + } else { + element.set( settingValue[ property ] ); + } + } + }); + + control.setting.bind(function( to, from ) { + var itemId = control.params.menu_item_id, + followingSiblingItemControls = [], + childrenItemControls = [], + menuControl; + + if ( false === to ) { + menuControl = api.control( 'nav_menu[' + String( from.nav_menu_term_id ) + ']' ); + control.container.remove(); + + _.each( menuControl.getMenuItemControls(), function( otherControl ) { + if ( from.menu_item_parent === otherControl.setting().menu_item_parent && otherControl.setting().position > from.position ) { + followingSiblingItemControls.push( otherControl ); + } else if ( otherControl.setting().menu_item_parent === itemId ) { + childrenItemControls.push( otherControl ); + } + }); + + // Shift all following siblings by the number of children this item has. + _.each( followingSiblingItemControls, function( followingSiblingItemControl ) { + var value = _.clone( followingSiblingItemControl.setting() ); + value.position += childrenItemControls.length; + followingSiblingItemControl.setting.set( value ); + }); + + // Now move the children up to be the new subsequent siblings. + _.each( childrenItemControls, function( childrenItemControl, i ) { + var value = _.clone( childrenItemControl.setting() ); + value.position = from.position + i; + value.menu_item_parent = from.menu_item_parent; + childrenItemControl.setting.set( value ); + }); + + menuControl.debouncedReflowMenuItems(); + } else { + // Update the elements' values to match the new setting properties. + _.each( to, function( value, key ) { + if ( control.elements[ key] ) { + control.elements[ key ].set( to[ key ] ); + } + } ); + control.container.find( '.menu-item-data-parent-id' ).val( to.menu_item_parent ); + + // Handle UI updates when the position or depth (parent) change. + if ( to.position !== from.position || to.menu_item_parent !== from.menu_item_parent ) { + control.getMenuControl().debouncedReflowMenuItems(); + } + } + }); + + // Style the URL field as invalid when there is an invalid_url notification. + updateNotifications = function() { + control.elements.url.element.toggleClass( 'invalid', control.setting.notifications.has( 'invalid_url' ) ); + }; + control.setting.notifications.bind( 'add', updateNotifications ); + control.setting.notifications.bind( 'removed', updateNotifications ); + }, + + /** + * Set up event handlers for menu item deletion. + */ + _setupRemoveUI: function() { + var control = this, $removeBtn; + + // Configure delete button. + $removeBtn = control.container.find( '.item-delete' ); + + $removeBtn.on( 'click', function() { + // Find an adjacent element to add focus to when this menu item goes away. + var addingItems = true, $adjacentFocusTarget, $next, $prev, + instanceCounter = 0, // Instance count of the menu item deleted. + deleteItemOriginalItemId = control.params.original_item_id, + addedItems = control.getMenuControl().$sectionContent.find( '.menu-item' ), + availableMenuItem; + + if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) { + addingItems = false; + } + + $next = control.container.nextAll( '.customize-control-nav_menu_item:visible' ).first(); + $prev = control.container.prevAll( '.customize-control-nav_menu_item:visible' ).first(); + + if ( $next.length ) { + $adjacentFocusTarget = $next.find( false === addingItems ? '.item-edit' : '.item-delete' ).first(); + } else if ( $prev.length ) { + $adjacentFocusTarget = $prev.find( false === addingItems ? '.item-edit' : '.item-delete' ).first(); + } else { + $adjacentFocusTarget = control.container.nextAll( '.customize-control-nav_menu' ).find( '.add-new-menu-item' ).first(); + } + + /* + * If the menu item deleted is the only of its instance left, + * remove the check icon of this menu item in the right panel. + */ + _.each( addedItems, function( addedItem ) { + var menuItemId, menuItemControl, matches; + + // This is because menu item that's deleted is just hidden. + if ( ! $( addedItem ).is( ':visible' ) ) { + return; + } + + matches = addedItem.getAttribute( 'id' ).match( /^customize-control-nav_menu_item-(-?\d+)$/, '' ); + if ( ! matches ) { + return; + } + + menuItemId = parseInt( matches[1], 10 ); + menuItemControl = api.control( 'nav_menu_item[' + String( menuItemId ) + ']' ); + + // Check for duplicate menu items. + if ( menuItemControl && deleteItemOriginalItemId == menuItemControl.params.original_item_id ) { + instanceCounter++; + } + } ); + + if ( instanceCounter <= 1 ) { + // Revert the check icon to add icon. + availableMenuItem = $( '#menu-item-tpl-' + control.params.original_item_id ); + availableMenuItem.removeClass( 'selected' ); + availableMenuItem.find( '.menu-item-handle' ).removeClass( 'item-added' ); + } + + control.container.slideUp( function() { + control.setting.set( false ); + wp.a11y.speak( api.Menus.data.l10n.itemDeleted ); + $adjacentFocusTarget.focus(); // Keyboard accessibility. + } ); + + control.setting.set( false ); + } ); + }, + + _setupLinksUI: function() { + var $origBtn; + + // Configure original link. + $origBtn = this.container.find( 'a.original-link' ); + + $origBtn.on( 'click', function( e ) { + e.preventDefault(); + api.previewer.previewUrl( e.target.toString() ); + } ); + }, + + /** + * Update item handle title when changed. + */ + _setupTitleUI: function() { + var control = this, titleEl; + + // Ensure that whitespace is trimmed on blur so placeholder can be shown. + control.container.find( '.edit-menu-item-title' ).on( 'blur', function() { + $( this ).val( $( this ).val().trim() ); + } ); + + titleEl = control.container.find( '.menu-item-title' ); + control.setting.bind( function( item ) { + var trimmedTitle, titleText; + if ( ! item ) { + return; + } + item.title = item.title || ''; + trimmedTitle = item.title.trim(); + + titleText = trimmedTitle || item.original_title || api.Menus.data.l10n.untitled; + + if ( item._invalid ) { + titleText = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', titleText ); + } + + // Don't update to an empty title. + if ( trimmedTitle || item.original_title ) { + titleEl + .text( titleText ) + .removeClass( 'no-title' ); + } else { + titleEl + .text( titleText ) + .addClass( 'no-title' ); + } + } ); + }, + + /** + * + * @return {number} + */ + getDepth: function() { + var control = this, setting = control.setting(), depth = 0; + if ( ! setting ) { + return 0; + } + while ( setting && setting.menu_item_parent ) { + depth += 1; + control = api.control( 'nav_menu_item[' + setting.menu_item_parent + ']' ); + if ( ! control ) { + break; + } + setting = control.setting(); + } + return depth; + }, + + /** + * Amend the control's params with the data necessary for the JS template just in time. + */ + renderContent: function() { + var control = this, + settingValue = control.setting(), + containerClasses; + + control.params.title = settingValue.title || ''; + control.params.depth = control.getDepth(); + control.container.data( 'item-depth', control.params.depth ); + containerClasses = [ + 'menu-item', + 'menu-item-depth-' + String( control.params.depth ), + 'menu-item-' + settingValue.object, + 'menu-item-edit-inactive' + ]; + + if ( settingValue._invalid ) { + containerClasses.push( 'menu-item-invalid' ); + control.params.title = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', control.params.title ); + } else if ( 'draft' === settingValue.status ) { + containerClasses.push( 'pending' ); + control.params.title = api.Menus.data.pendingTitleTpl.replace( '%s', control.params.title ); + } + + control.params.el_classes = containerClasses.join( ' ' ); + control.params.item_type_label = settingValue.type_label; + control.params.item_type = settingValue.type; + control.params.url = settingValue.url; + control.params.target = settingValue.target; + control.params.attr_title = settingValue.attr_title; + control.params.classes = _.isArray( settingValue.classes ) ? settingValue.classes.join( ' ' ) : settingValue.classes; + control.params.xfn = settingValue.xfn; + control.params.description = settingValue.description; + control.params.parent = settingValue.menu_item_parent; + control.params.original_title = settingValue.original_title || ''; + + control.container.addClass( control.params.el_classes ); + + api.Control.prototype.renderContent.call( control ); + }, + + /*********************************************************************** + * Begin public API methods + **********************************************************************/ + + /** + * @return {wp.customize.controlConstructor.nav_menu|null} + */ + getMenuControl: function() { + var control = this, settingValue = control.setting(); + if ( settingValue && settingValue.nav_menu_term_id ) { + return api.control( 'nav_menu[' + settingValue.nav_menu_term_id + ']' ); + } else { + return null; + } + }, + + /** + * Expand the accordion section containing a control + */ + expandControlSection: function() { + var $section = this.container.closest( '.accordion-section' ); + if ( ! $section.hasClass( 'open' ) ) { + $section.find( '.accordion-section-title:first' ).trigger( 'click' ); + } + }, + + /** + * @since 4.6.0 + * + * @param {Boolean} expanded + * @param {Object} [params] + * @return {Boolean} False if state already applied. + */ + _toggleExpanded: api.Section.prototype._toggleExpanded, + + /** + * @since 4.6.0 + * + * @param {Object} [params] + * @return {Boolean} False if already expanded. + */ + expand: api.Section.prototype.expand, + + /** + * Expand the menu item form control. + * + * @since 4.5.0 Added params.completeCallback. + * + * @param {Object} [params] - Optional params. + * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating. + */ + expandForm: function( params ) { + this.expand( params ); + }, + + /** + * @since 4.6.0 + * + * @param {Object} [params] + * @return {Boolean} False if already collapsed. + */ + collapse: api.Section.prototype.collapse, + + /** + * Collapse the menu item form control. + * + * @since 4.5.0 Added params.completeCallback. + * + * @param {Object} [params] - Optional params. + * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating. + */ + collapseForm: function( params ) { + this.collapse( params ); + }, + + /** + * Expand or collapse the menu item control. + * + * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide ) + * @since 4.5.0 Added params.completeCallback. + * + * @param {boolean} [showOrHide] - If not supplied, will be inverse of current visibility + * @param {Object} [params] - Optional params. + * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating. + */ + toggleForm: function( showOrHide, params ) { + if ( typeof showOrHide === 'undefined' ) { + showOrHide = ! this.expanded(); + } + if ( showOrHide ) { + this.expand( params ); + } else { + this.collapse( params ); + } + }, + + /** + * Expand or collapse the menu item control. + * + * @since 4.6.0 + * @param {boolean} [showOrHide] - If not supplied, will be inverse of current visibility + * @param {Object} [params] - Optional params. + * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating. + */ + onChangeExpanded: function( showOrHide, params ) { + var self = this, $menuitem, $inside, complete; + + $menuitem = this.container; + $inside = $menuitem.find( '.menu-item-settings:first' ); + if ( 'undefined' === typeof showOrHide ) { + showOrHide = ! $inside.is( ':visible' ); + } + + // Already expanded or collapsed. + if ( $inside.is( ':visible' ) === showOrHide ) { + if ( params && params.completeCallback ) { + params.completeCallback(); + } + return; + } + + if ( showOrHide ) { + // Close all other menu item controls before expanding this one. + api.control.each( function( otherControl ) { + if ( self.params.type === otherControl.params.type && self !== otherControl ) { + otherControl.collapseForm(); + } + } ); + + complete = function() { + $menuitem + .removeClass( 'menu-item-edit-inactive' ) + .addClass( 'menu-item-edit-active' ); + self.container.trigger( 'expanded' ); + + if ( params && params.completeCallback ) { + params.completeCallback(); + } + }; + + $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'true' ); + $inside.slideDown( 'fast', complete ); + + self.container.trigger( 'expand' ); + } else { + complete = function() { + $menuitem + .addClass( 'menu-item-edit-inactive' ) + .removeClass( 'menu-item-edit-active' ); + self.container.trigger( 'collapsed' ); + + if ( params && params.completeCallback ) { + params.completeCallback(); + } + }; + + self.container.trigger( 'collapse' ); + + $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'false' ); + $inside.slideUp( 'fast', complete ); + } + }, + + /** + * Expand the containing menu section, expand the form, and focus on + * the first input in the control. + * + * @since 4.5.0 Added params.completeCallback. + * + * @param {Object} [params] - Params object. + * @param {Function} [params.completeCallback] - Optional callback function when focus has completed. + */ + focus: function( params ) { + params = params || {}; + var control = this, originalCompleteCallback = params.completeCallback, focusControl; + + focusControl = function() { + control.expandControlSection(); + + params.completeCallback = function() { + var focusable; + + // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583 + focusable = control.container.find( '.menu-item-settings' ).find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' ); + focusable.first().focus(); + + if ( originalCompleteCallback ) { + originalCompleteCallback(); + } + }; + + control.expandForm( params ); + }; + + if ( api.section.has( control.section() ) ) { + api.section( control.section() ).expand( { + completeCallback: focusControl + } ); + } else { + focusControl(); + } + }, + + /** + * Move menu item up one in the menu. + */ + moveUp: function() { + this._changePosition( -1 ); + wp.a11y.speak( api.Menus.data.l10n.movedUp ); + }, + + /** + * Move menu item up one in the menu. + */ + moveDown: function() { + this._changePosition( 1 ); + wp.a11y.speak( api.Menus.data.l10n.movedDown ); + }, + /** + * Move menu item and all children up one level of depth. + */ + moveLeft: function() { + this._changeDepth( -1 ); + wp.a11y.speak( api.Menus.data.l10n.movedLeft ); + }, + + /** + * Move menu item and children one level deeper, as a submenu of the previous item. + */ + moveRight: function() { + this._changeDepth( 1 ); + wp.a11y.speak( api.Menus.data.l10n.movedRight ); + }, + + /** + * Note that this will trigger a UI update, causing child items to + * move as well and cardinal order class names to be updated. + * + * @private + * + * @param {number} offset 1|-1 + */ + _changePosition: function( offset ) { + var control = this, + adjacentSetting, + settingValue = _.clone( control.setting() ), + siblingSettings = [], + realPosition; + + if ( 1 !== offset && -1 !== offset ) { + throw new Error( 'Offset changes by 1 are only supported.' ); + } + + // Skip moving deleted items. + if ( ! control.setting() ) { + return; + } + + // Locate the other items under the same parent (siblings). + _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) { + if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) { + siblingSettings.push( otherControl.setting ); + } + }); + siblingSettings.sort(function( a, b ) { + return a().position - b().position; + }); + + realPosition = _.indexOf( siblingSettings, control.setting ); + if ( -1 === realPosition ) { + throw new Error( 'Expected setting to be among siblings.' ); + } + + // Skip doing anything if the item is already at the edge in the desired direction. + if ( ( realPosition === 0 && offset < 0 ) || ( realPosition === siblingSettings.length - 1 && offset > 0 ) ) { + // @todo Should we allow a menu item to be moved up to break it out of a parent? Adopt with previous or following parent? + return; + } + + // Update any adjacent menu item setting to take on this item's position. + adjacentSetting = siblingSettings[ realPosition + offset ]; + if ( adjacentSetting ) { + adjacentSetting.set( $.extend( + _.clone( adjacentSetting() ), + { + position: settingValue.position + } + ) ); + } + + settingValue.position += offset; + control.setting.set( settingValue ); + }, + + /** + * Note that this will trigger a UI update, causing child items to + * move as well and cardinal order class names to be updated. + * + * @private + * + * @param {number} offset 1|-1 + */ + _changeDepth: function( offset ) { + if ( 1 !== offset && -1 !== offset ) { + throw new Error( 'Offset changes by 1 are only supported.' ); + } + var control = this, + settingValue = _.clone( control.setting() ), + siblingControls = [], + realPosition, + siblingControl, + parentControl; + + // Locate the other items under the same parent (siblings). + _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) { + if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) { + siblingControls.push( otherControl ); + } + }); + siblingControls.sort(function( a, b ) { + return a.setting().position - b.setting().position; + }); + + realPosition = _.indexOf( siblingControls, control ); + if ( -1 === realPosition ) { + throw new Error( 'Expected control to be among siblings.' ); + } + + if ( -1 === offset ) { + // Skip moving left an item that is already at the top level. + if ( ! settingValue.menu_item_parent ) { + return; + } + + parentControl = api.control( 'nav_menu_item[' + settingValue.menu_item_parent + ']' ); + + // Make this control the parent of all the following siblings. + _( siblingControls ).chain().slice( realPosition ).each(function( siblingControl, i ) { + siblingControl.setting.set( + $.extend( + {}, + siblingControl.setting(), + { + menu_item_parent: control.params.menu_item_id, + position: i + } + ) + ); + }); + + // Increase the positions of the parent item's subsequent children to make room for this one. + _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) { + var otherControlSettingValue, isControlToBeShifted; + isControlToBeShifted = ( + otherControl.setting().menu_item_parent === parentControl.setting().menu_item_parent && + otherControl.setting().position > parentControl.setting().position + ); + if ( isControlToBeShifted ) { + otherControlSettingValue = _.clone( otherControl.setting() ); + otherControl.setting.set( + $.extend( + otherControlSettingValue, + { position: otherControlSettingValue.position + 1 } + ) + ); + } + }); + + // Make this control the following sibling of its parent item. + settingValue.position = parentControl.setting().position + 1; + settingValue.menu_item_parent = parentControl.setting().menu_item_parent; + control.setting.set( settingValue ); + + } else if ( 1 === offset ) { + // Skip moving right an item that doesn't have a previous sibling. + if ( realPosition === 0 ) { + return; + } + + // Make the control the last child of the previous sibling. + siblingControl = siblingControls[ realPosition - 1 ]; + settingValue.menu_item_parent = siblingControl.params.menu_item_id; + settingValue.position = 0; + _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) { + if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) { + settingValue.position = Math.max( settingValue.position, otherControl.setting().position ); + } + }); + settingValue.position += 1; + control.setting.set( settingValue ); + } + } + } ); + + /** + * wp.customize.Menus.MenuNameControl + * + * Customizer control for a nav menu's name. + * + * @class wp.customize.Menus.MenuNameControl + * @augments wp.customize.Control + */ + api.Menus.MenuNameControl = api.Control.extend(/** @lends wp.customize.Menus.MenuNameControl.prototype */{ + + ready: function() { + var control = this; + + if ( control.setting ) { + var settingValue = control.setting(); + + control.nameElement = new api.Element( control.container.find( '.menu-name-field' ) ); + + control.nameElement.bind(function( value ) { + var settingValue = control.setting(); + if ( settingValue && settingValue.name !== value ) { + settingValue = _.clone( settingValue ); + settingValue.name = value; + control.setting.set( settingValue ); + } + }); + if ( settingValue ) { + control.nameElement.set( settingValue.name ); + } + + control.setting.bind(function( object ) { + if ( object ) { + control.nameElement.set( object.name ); + } + }); + } + } + }); + + /** + * wp.customize.Menus.MenuLocationsControl + * + * Customizer control for a nav menu's locations. + * + * @since 4.9.0 + * @class wp.customize.Menus.MenuLocationsControl + * @augments wp.customize.Control + */ + api.Menus.MenuLocationsControl = api.Control.extend(/** @lends wp.customize.Menus.MenuLocationsControl.prototype */{ + + /** + * Set up the control. + * + * @since 4.9.0 + */ + ready: function () { + var control = this; + + control.container.find( '.assigned-menu-location' ).each(function() { + var container = $( this ), + checkbox = container.find( 'input[type=checkbox]' ), + element = new api.Element( checkbox ), + navMenuLocationSetting = api( 'nav_menu_locations[' + checkbox.data( 'location-id' ) + ']' ), + isNewMenu = control.params.menu_id === '', + updateCheckbox = isNewMenu ? _.noop : function( checked ) { + element.set( checked ); + }, + updateSetting = isNewMenu ? _.noop : function( checked ) { + navMenuLocationSetting.set( checked ? control.params.menu_id : 0 ); + }, + updateSelectedMenuLabel = function( selectedMenuId ) { + var menuSetting = api( 'nav_menu[' + String( selectedMenuId ) + ']' ); + if ( ! selectedMenuId || ! menuSetting || ! menuSetting() ) { + container.find( '.theme-location-set' ).hide(); + } else { + container.find( '.theme-location-set' ).show().find( 'span' ).text( displayNavMenuName( menuSetting().name ) ); + } + }; + + updateCheckbox( navMenuLocationSetting.get() === control.params.menu_id ); + + checkbox.on( 'change', function() { + // Note: We can't use element.bind( function( checked ){ ... } ) here because it will trigger a change as well. + updateSetting( this.checked ); + } ); + + navMenuLocationSetting.bind( function( selectedMenuId ) { + updateCheckbox( selectedMenuId === control.params.menu_id ); + updateSelectedMenuLabel( selectedMenuId ); + } ); + updateSelectedMenuLabel( navMenuLocationSetting.get() ); + }); + }, + + /** + * Set the selected locations. + * + * This method sets the selected locations and allows us to do things like + * set the default location for a new menu. + * + * @since 4.9.0 + * + * @param {Object.<string,boolean>} selections - A map of location selections. + * @return {void} + */ + setSelections: function( selections ) { + this.container.find( '.menu-location' ).each( function( i, checkboxNode ) { + var locationId = checkboxNode.dataset.locationId; + checkboxNode.checked = locationId in selections ? selections[ locationId ] : false; + } ); + } + }); + + /** + * wp.customize.Menus.MenuAutoAddControl + * + * Customizer control for a nav menu's auto add. + * + * @class wp.customize.Menus.MenuAutoAddControl + * @augments wp.customize.Control + */ + api.Menus.MenuAutoAddControl = api.Control.extend(/** @lends wp.customize.Menus.MenuAutoAddControl.prototype */{ + + ready: function() { + var control = this, + settingValue = control.setting(); + + /* + * Since the control is not registered in PHP, we need to prevent the + * preview's sending of the activeControls to result in this control + * being deactivated. + */ + control.active.validate = function() { + var value, section = api.section( control.section() ); + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; + }; + + control.autoAddElement = new api.Element( control.container.find( 'input[type=checkbox].auto_add' ) ); + + control.autoAddElement.bind(function( value ) { + var settingValue = control.setting(); + if ( settingValue && settingValue.name !== value ) { + settingValue = _.clone( settingValue ); + settingValue.auto_add = value; + control.setting.set( settingValue ); + } + }); + if ( settingValue ) { + control.autoAddElement.set( settingValue.auto_add ); + } + + control.setting.bind(function( object ) { + if ( object ) { + control.autoAddElement.set( object.auto_add ); + } + }); + } + + }); + + /** + * wp.customize.Menus.MenuControl + * + * Customizer control for menus. + * Note that 'nav_menu' must match the WP_Menu_Customize_Control::$type + * + * @class wp.customize.Menus.MenuControl + * @augments wp.customize.Control + */ + api.Menus.MenuControl = api.Control.extend(/** @lends wp.customize.Menus.MenuControl.prototype */{ + /** + * Set up the control. + */ + ready: function() { + var control = this, + section = api.section( control.section() ), + menuId = control.params.menu_id, + menu = control.setting(), + name, + widgetTemplate, + select; + + if ( 'undefined' === typeof this.params.menu_id ) { + throw new Error( 'params.menu_id was not defined' ); + } + + /* + * Since the control is not registered in PHP, we need to prevent the + * preview's sending of the activeControls to result in this control + * being deactivated. + */ + control.active.validate = function() { + var value; + if ( section ) { + value = section.active(); + } else { + value = false; + } + return value; + }; + + control.$controlSection = section.headContainer; + control.$sectionContent = control.container.closest( '.accordion-section-content' ); + + this._setupModel(); + + api.section( control.section(), function( section ) { + section.deferred.initSortables.done(function( menuList ) { + control._setupSortable( menuList ); + }); + } ); + + this._setupAddition(); + this._setupTitle(); + + // Add menu to Navigation Menu widgets. + if ( menu ) { + name = displayNavMenuName( menu.name ); + + // Add the menu to the existing controls. + api.control.each( function( widgetControl ) { + if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) { + return; + } + widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).show(); + widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).hide(); + + select = widgetControl.container.find( 'select' ); + if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) { + select.append( new Option( name, menuId ) ); + } + } ); + + // Add the menu to the widget template. + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).show(); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).hide(); + select = widgetTemplate.find( '.widget-inside select:first' ); + if ( 0 === select.find( 'option[value=' + String( menuId ) + ']' ).length ) { + select.append( new Option( name, menuId ) ); + } + } + + /* + * Wait for menu items to be added. + * Ideally, we'd bind to an event indicating construction is complete, + * but deferring appears to be the best option today. + */ + _.defer( function () { + control.updateInvitationVisibility(); + } ); + }, + + /** + * Update ordering of menu item controls when the setting is updated. + */ + _setupModel: function() { + var control = this, + menuId = control.params.menu_id; + + control.setting.bind( function( to ) { + var name; + if ( false === to ) { + control._handleDeletion(); + } else { + // Update names in the Navigation Menu widgets. + name = displayNavMenuName( to.name ); + api.control.each( function( widgetControl ) { + if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) { + return; + } + var select = widgetControl.container.find( 'select' ); + select.find( 'option[value=' + String( menuId ) + ']' ).text( name ); + }); + } + } ); + }, + + /** + * Allow items in each menu to be re-ordered, and for the order to be previewed. + * + * Notice that the UI aspects here are handled by wpNavMenu.initSortables() + * which is called in MenuSection.onChangeExpanded() + * + * @param {Object} menuList - The element that has sortable(). + */ + _setupSortable: function( menuList ) { + var control = this; + + if ( ! menuList.is( control.$sectionContent ) ) { + throw new Error( 'Unexpected menuList.' ); + } + + menuList.on( 'sortstart', function() { + control.isSorting = true; + }); + + menuList.on( 'sortstop', function() { + setTimeout( function() { // Next tick. + var menuItemContainerIds = control.$sectionContent.sortable( 'toArray' ), + menuItemControls = [], + position = 0, + priority = 10; + + control.isSorting = false; + + // Reset horizontal scroll position when done dragging. + control.$sectionContent.scrollLeft( 0 ); + + _.each( menuItemContainerIds, function( menuItemContainerId ) { + var menuItemId, menuItemControl, matches; + matches = menuItemContainerId.match( /^customize-control-nav_menu_item-(-?\d+)$/, '' ); + if ( ! matches ) { + return; + } + menuItemId = parseInt( matches[1], 10 ); + menuItemControl = api.control( 'nav_menu_item[' + String( menuItemId ) + ']' ); + if ( menuItemControl ) { + menuItemControls.push( menuItemControl ); + } + } ); + + _.each( menuItemControls, function( menuItemControl ) { + if ( false === menuItemControl.setting() ) { + // Skip deleted items. + return; + } + var setting = _.clone( menuItemControl.setting() ); + position += 1; + priority += 1; + setting.position = position; + menuItemControl.priority( priority ); + + // Note that wpNavMenu will be setting this .menu-item-data-parent-id input's value. + setting.menu_item_parent = parseInt( menuItemControl.container.find( '.menu-item-data-parent-id' ).val(), 10 ); + if ( ! setting.menu_item_parent ) { + setting.menu_item_parent = 0; + } + + menuItemControl.setting.set( setting ); + }); + }); + + }); + control.isReordering = false; + + /** + * Keyboard-accessible reordering. + */ + this.container.find( '.reorder-toggle' ).on( 'click', function() { + control.toggleReordering( ! control.isReordering ); + } ); + }, + + /** + * Set up UI for adding a new menu item. + */ + _setupAddition: function() { + var self = this; + + this.container.find( '.add-new-menu-item' ).on( 'click', function( event ) { + if ( self.$sectionContent.hasClass( 'reordering' ) ) { + return; + } + + if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) { + $( this ).attr( 'aria-expanded', 'true' ); + api.Menus.availableMenuItemsPanel.open( self ); + } else { + $( this ).attr( 'aria-expanded', 'false' ); + api.Menus.availableMenuItemsPanel.close(); + event.stopPropagation(); + } + } ); + }, + + _handleDeletion: function() { + var control = this, + section, + menuId = control.params.menu_id, + removeSection, + widgetTemplate, + navMenuCount = 0; + section = api.section( control.section() ); + removeSection = function() { + section.container.remove(); + api.section.remove( section.id ); + }; + + if ( section && section.expanded() ) { + section.collapse({ + completeCallback: function() { + removeSection(); + wp.a11y.speak( api.Menus.data.l10n.menuDeleted ); + api.panel( 'nav_menus' ).focus(); + } + }); + } else { + removeSection(); + } + + api.each(function( setting ) { + if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) { + navMenuCount += 1; + } + }); + + // Remove the menu from any Navigation Menu widgets. + api.control.each(function( widgetControl ) { + if ( ! widgetControl.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== widgetControl.params.widget_id_base ) { + return; + } + var select = widgetControl.container.find( 'select' ); + if ( select.val() === String( menuId ) ) { + select.prop( 'selectedIndex', 0 ).trigger( 'change' ); + } + + widgetControl.container.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetControl.container.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetControl.container.find( 'option[value=' + String( menuId ) + ']' ).remove(); + }); + + // Remove the menu to the nav menu widget template. + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetTemplate.find( 'option[value=' + String( menuId ) + ']' ).remove(); + }, + + /** + * Update Section Title as menu name is changed. + */ + _setupTitle: function() { + var control = this; + + control.setting.bind( function( menu ) { + if ( ! menu ) { + return; + } + + var section = api.section( control.section() ), + menuId = control.params.menu_id, + controlTitle = section.headContainer.find( '.accordion-section-title' ), + sectionTitle = section.contentContainer.find( '.customize-section-title h3' ), + location = section.headContainer.find( '.menu-in-location' ), + action = sectionTitle.find( '.customize-action' ), + name = displayNavMenuName( menu.name ); + + // Update the control title. + controlTitle.text( name ); + if ( location.length ) { + location.appendTo( controlTitle ); + } + + // Update the section title. + sectionTitle.text( name ); + if ( action.length ) { + action.prependTo( sectionTitle ); + } + + // Update the nav menu name in location selects. + api.control.each( function( control ) { + if ( /^nav_menu_locations\[/.test( control.id ) ) { + control.container.find( 'option[value=' + menuId + ']' ).text( name ); + } + } ); + + // Update the nav menu name in all location checkboxes. + section.contentContainer.find( '.customize-control-checkbox input' ).each( function() { + if ( $( this ).prop( 'checked' ) ) { + $( '.current-menu-location-name-' + $( this ).data( 'location-id' ) ).text( name ); + } + } ); + } ); + }, + + /*********************************************************************** + * Begin public API methods + **********************************************************************/ + + /** + * Enable/disable the reordering UI + * + * @param {boolean} showOrHide to enable/disable reordering + */ + toggleReordering: function( showOrHide ) { + var addNewItemBtn = this.container.find( '.add-new-menu-item' ), + reorderBtn = this.container.find( '.reorder-toggle' ), + itemsTitle = this.$sectionContent.find( '.item-title' ); + + showOrHide = Boolean( showOrHide ); + + if ( showOrHide === this.$sectionContent.hasClass( 'reordering' ) ) { + return; + } + + this.isReordering = showOrHide; + this.$sectionContent.toggleClass( 'reordering', showOrHide ); + this.$sectionContent.sortable( this.isReordering ? 'disable' : 'enable' ); + if ( this.isReordering ) { + addNewItemBtn.attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); + reorderBtn.attr( 'aria-label', api.Menus.data.l10n.reorderLabelOff ); + wp.a11y.speak( api.Menus.data.l10n.reorderModeOn ); + itemsTitle.attr( 'aria-hidden', 'false' ); + } else { + addNewItemBtn.removeAttr( 'tabindex aria-hidden' ); + reorderBtn.attr( 'aria-label', api.Menus.data.l10n.reorderLabelOn ); + wp.a11y.speak( api.Menus.data.l10n.reorderModeOff ); + itemsTitle.attr( 'aria-hidden', 'true' ); + } + + if ( showOrHide ) { + _( this.getMenuItemControls() ).each( function( formControl ) { + formControl.collapseForm(); + } ); + } + }, + + /** + * @return {wp.customize.controlConstructor.nav_menu_item[]} + */ + getMenuItemControls: function() { + var menuControl = this, + menuItemControls = [], + menuTermId = menuControl.params.menu_id; + + api.control.each(function( control ) { + if ( 'nav_menu_item' === control.params.type && control.setting() && menuTermId === control.setting().nav_menu_term_id ) { + menuItemControls.push( control ); + } + }); + + return menuItemControls; + }, + + /** + * Make sure that each menu item control has the proper depth. + */ + reflowMenuItems: function() { + var menuControl = this, + menuItemControls = menuControl.getMenuItemControls(), + reflowRecursively; + + reflowRecursively = function( context ) { + var currentMenuItemControls = [], + thisParent = context.currentParent; + _.each( context.menuItemControls, function( menuItemControl ) { + if ( thisParent === menuItemControl.setting().menu_item_parent ) { + currentMenuItemControls.push( menuItemControl ); + // @todo We could remove this item from menuItemControls now, for efficiency. + } + }); + currentMenuItemControls.sort( function( a, b ) { + return a.setting().position - b.setting().position; + }); + + _.each( currentMenuItemControls, function( menuItemControl ) { + // Update position. + context.currentAbsolutePosition += 1; + menuItemControl.priority.set( context.currentAbsolutePosition ); // This will change the sort order. + + // Update depth. + if ( ! menuItemControl.container.hasClass( 'menu-item-depth-' + String( context.currentDepth ) ) ) { + _.each( menuItemControl.container.prop( 'className' ).match( /menu-item-depth-\d+/g ), function( className ) { + menuItemControl.container.removeClass( className ); + }); + menuItemControl.container.addClass( 'menu-item-depth-' + String( context.currentDepth ) ); + } + menuItemControl.container.data( 'item-depth', context.currentDepth ); + + // Process any children items. + context.currentDepth += 1; + context.currentParent = menuItemControl.params.menu_item_id; + reflowRecursively( context ); + context.currentDepth -= 1; + context.currentParent = thisParent; + }); + + // Update class names for reordering controls. + if ( currentMenuItemControls.length ) { + _( currentMenuItemControls ).each(function( menuItemControl ) { + menuItemControl.container.removeClass( 'move-up-disabled move-down-disabled move-left-disabled move-right-disabled' ); + if ( 0 === context.currentDepth ) { + menuItemControl.container.addClass( 'move-left-disabled' ); + } else if ( 10 === context.currentDepth ) { + menuItemControl.container.addClass( 'move-right-disabled' ); + } + }); + + currentMenuItemControls[0].container + .addClass( 'move-up-disabled' ) + .addClass( 'move-right-disabled' ) + .toggleClass( 'move-down-disabled', 1 === currentMenuItemControls.length ); + currentMenuItemControls[ currentMenuItemControls.length - 1 ].container + .addClass( 'move-down-disabled' ) + .toggleClass( 'move-up-disabled', 1 === currentMenuItemControls.length ); + } + }; + + reflowRecursively( { + menuItemControls: menuItemControls, + currentParent: 0, + currentDepth: 0, + currentAbsolutePosition: 0 + } ); + + menuControl.updateInvitationVisibility( menuItemControls ); + menuControl.container.find( '.reorder-toggle' ).toggle( menuItemControls.length > 1 ); + }, + + /** + * Note that this function gets debounced so that when a lot of setting + * changes are made at once, for instance when moving a menu item that + * has child items, this function will only be called once all of the + * settings have been updated. + */ + debouncedReflowMenuItems: _.debounce( function() { + this.reflowMenuItems.apply( this, arguments ); + }, 0 ), + + /** + * Add a new item to this menu. + * + * @param {Object} item - Value for the nav_menu_item setting to be created. + * @return {wp.customize.Menus.controlConstructor.nav_menu_item} The newly-created nav_menu_item control instance. + */ + addItemToMenu: function( item ) { + var menuControl = this, customizeId, settingArgs, setting, menuItemControl, placeholderId, position = 0, priority = 10, + originalItemId = item.id || ''; + + _.each( menuControl.getMenuItemControls(), function( control ) { + if ( false === control.setting() ) { + return; + } + priority = Math.max( priority, control.priority() ); + if ( 0 === control.setting().menu_item_parent ) { + position = Math.max( position, control.setting().position ); + } + }); + position += 1; + priority += 1; + + item = $.extend( + {}, + api.Menus.data.defaultSettingValues.nav_menu_item, + item, + { + nav_menu_term_id: menuControl.params.menu_id, + original_title: item.title, + position: position + } + ); + delete item.id; // Only used by Backbone. + + placeholderId = api.Menus.generatePlaceholderAutoIncrementId(); + customizeId = 'nav_menu_item[' + String( placeholderId ) + ']'; + settingArgs = { + type: 'nav_menu_item', + transport: api.Menus.data.settingTransport, + previewer: api.previewer + }; + setting = api.create( customizeId, customizeId, {}, settingArgs ); + setting.set( item ); // Change from initial empty object to actual item to mark as dirty. + + // Add the menu item control. + menuItemControl = new api.controlConstructor.nav_menu_item( customizeId, { + type: 'nav_menu_item', + section: menuControl.id, + priority: priority, + settings: { + 'default': customizeId + }, + menu_item_id: placeholderId, + original_item_id: originalItemId + } ); + + api.control.add( menuItemControl ); + setting.preview(); + menuControl.debouncedReflowMenuItems(); + + wp.a11y.speak( api.Menus.data.l10n.itemAdded ); + + return menuItemControl; + }, + + /** + * Show an invitation to add new menu items when there are no menu items. + * + * @since 4.9.0 + * + * @param {wp.customize.controlConstructor.nav_menu_item[]} optionalMenuItemControls + */ + updateInvitationVisibility: function ( optionalMenuItemControls ) { + var menuItemControls = optionalMenuItemControls || this.getMenuItemControls(); + + this.container.find( '.new-menu-item-invitation' ).toggle( menuItemControls.length === 0 ); + } + } ); + + /** + * Extends wp.customize.controlConstructor with control constructor for + * menu_location, menu_item, nav_menu, and new_menu. + */ + $.extend( api.controlConstructor, { + nav_menu_location: api.Menus.MenuLocationControl, + nav_menu_item: api.Menus.MenuItemControl, + nav_menu: api.Menus.MenuControl, + nav_menu_name: api.Menus.MenuNameControl, + nav_menu_locations: api.Menus.MenuLocationsControl, + nav_menu_auto_add: api.Menus.MenuAutoAddControl + }); + + /** + * Extends wp.customize.panelConstructor with section constructor for menus. + */ + $.extend( api.panelConstructor, { + nav_menus: api.Menus.MenusPanel + }); + + /** + * Extends wp.customize.sectionConstructor with section constructor for menu. + */ + $.extend( api.sectionConstructor, { + nav_menu: api.Menus.MenuSection, + new_menu: api.Menus.NewMenuSection + }); + + /** + * Init Customizer for menus. + */ + api.bind( 'ready', function() { + + // Set up the menu items panel. + api.Menus.availableMenuItemsPanel = new api.Menus.AvailableMenuItemsPanelView({ + collection: api.Menus.availableMenuItems + }); + + api.bind( 'saved', function( data ) { + if ( data.nav_menu_updates || data.nav_menu_item_updates ) { + api.Menus.applySavedData( data ); + } + } ); + + /* + * Reset the list of posts created in the customizer once published. + * The setting is updated quietly (bypassing events being triggered) + * so that the customized state doesn't become immediately dirty. + */ + api.state( 'changesetStatus' ).bind( function( status ) { + if ( 'publish' === status ) { + api( 'nav_menus_created_posts' )._value = []; + } + } ); + + // Open and focus menu control. + api.previewer.bind( 'focus-nav-menu-item-control', api.Menus.focusMenuItemControl ); + } ); + + /** + * When customize_save comes back with a success, make sure any inserted + * nav menus and items are properly re-added with their newly-assigned IDs. + * + * @alias wp.customize.Menus.applySavedData + * + * @param {Object} data + * @param {Array} data.nav_menu_updates + * @param {Array} data.nav_menu_item_updates + */ + api.Menus.applySavedData = function( data ) { + + var insertedMenuIdMapping = {}, insertedMenuItemIdMapping = {}; + + _( data.nav_menu_updates ).each(function( update ) { + var oldCustomizeId, newCustomizeId, customizeId, oldSetting, newSetting, setting, settingValue, oldSection, newSection, wasSaved, widgetTemplate, navMenuCount, shouldExpandNewSection; + if ( 'inserted' === update.status ) { + if ( ! update.previous_term_id ) { + throw new Error( 'Expected previous_term_id' ); + } + if ( ! update.term_id ) { + throw new Error( 'Expected term_id' ); + } + oldCustomizeId = 'nav_menu[' + String( update.previous_term_id ) + ']'; + if ( ! api.has( oldCustomizeId ) ) { + throw new Error( 'Expected setting to exist: ' + oldCustomizeId ); + } + oldSetting = api( oldCustomizeId ); + if ( ! api.section.has( oldCustomizeId ) ) { + throw new Error( 'Expected control to exist: ' + oldCustomizeId ); + } + oldSection = api.section( oldCustomizeId ); + + settingValue = oldSetting.get(); + if ( ! settingValue ) { + throw new Error( 'Did not expect setting to be empty (deleted).' ); + } + settingValue = $.extend( _.clone( settingValue ), update.saved_value ); + + insertedMenuIdMapping[ update.previous_term_id ] = update.term_id; + newCustomizeId = 'nav_menu[' + String( update.term_id ) + ']'; + newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, { + type: 'nav_menu', + transport: api.Menus.data.settingTransport, + previewer: api.previewer + } ); + + shouldExpandNewSection = oldSection.expanded(); + if ( shouldExpandNewSection ) { + oldSection.collapse(); + } + + // Add the menu section. + newSection = new api.Menus.MenuSection( newCustomizeId, { + panel: 'nav_menus', + title: settingValue.name, + customizeAction: api.Menus.data.l10n.customizingMenus, + type: 'nav_menu', + priority: oldSection.priority.get(), + menu_id: update.term_id + } ); + + // Add new control for the new menu. + api.section.add( newSection ); + + // Update the values for nav menus in Navigation Menu controls. + api.control.each( function( setting ) { + if ( ! setting.extended( api.controlConstructor.widget_form ) || 'nav_menu' !== setting.params.widget_id_base ) { + return; + } + var select, oldMenuOption, newMenuOption; + select = setting.container.find( 'select' ); + oldMenuOption = select.find( 'option[value=' + String( update.previous_term_id ) + ']' ); + newMenuOption = select.find( 'option[value=' + String( update.term_id ) + ']' ); + newMenuOption.prop( 'selected', oldMenuOption.prop( 'selected' ) ); + oldMenuOption.remove(); + } ); + + // Delete the old placeholder nav_menu. + oldSetting.callbacks.disable(); // Prevent setting triggering Customizer dirty state when set. + oldSetting.set( false ); + oldSetting.preview(); + newSetting.preview(); + oldSetting._dirty = false; + + // Remove nav_menu section. + oldSection.container.remove(); + api.section.remove( oldCustomizeId ); + + // Update the nav_menu widget to reflect removed placeholder menu. + navMenuCount = 0; + api.each(function( setting ) { + if ( /^nav_menu\[/.test( setting.id ) && false !== setting() ) { + navMenuCount += 1; + } + }); + widgetTemplate = $( '#available-widgets-list .widget-tpl:has( input.id_base[ value=nav_menu ] )' ); + widgetTemplate.find( '.nav-menu-widget-form-controls:first' ).toggle( 0 !== navMenuCount ); + widgetTemplate.find( '.nav-menu-widget-no-menus-message:first' ).toggle( 0 === navMenuCount ); + widgetTemplate.find( 'option[value=' + String( update.previous_term_id ) + ']' ).remove(); + + // Update the nav_menu_locations[...] controls to remove the placeholder menus from the dropdown options. + wp.customize.control.each(function( control ){ + if ( /^nav_menu_locations\[/.test( control.id ) ) { + control.container.find( 'option[value=' + String( update.previous_term_id ) + ']' ).remove(); + } + }); + + // Update nav_menu_locations to reference the new ID. + api.each( function( setting ) { + var wasSaved = api.state( 'saved' ).get(); + if ( /^nav_menu_locations\[/.test( setting.id ) && setting.get() === update.previous_term_id ) { + setting.set( update.term_id ); + setting._dirty = false; // Not dirty because this is has also just been done on server in WP_Customize_Nav_Menu_Setting::update(). + api.state( 'saved' ).set( wasSaved ); + setting.preview(); + } + } ); + + if ( shouldExpandNewSection ) { + newSection.expand(); + } + } else if ( 'updated' === update.status ) { + customizeId = 'nav_menu[' + String( update.term_id ) + ']'; + if ( ! api.has( customizeId ) ) { + throw new Error( 'Expected setting to exist: ' + customizeId ); + } + + // Make sure the setting gets updated with its sanitized server value (specifically the conflict-resolved name). + setting = api( customizeId ); + if ( ! _.isEqual( update.saved_value, setting.get() ) ) { + wasSaved = api.state( 'saved' ).get(); + setting.set( update.saved_value ); + setting._dirty = false; + api.state( 'saved' ).set( wasSaved ); + } + } + } ); + + // Build up mapping of nav_menu_item placeholder IDs to inserted IDs. + _( data.nav_menu_item_updates ).each(function( update ) { + if ( update.previous_post_id ) { + insertedMenuItemIdMapping[ update.previous_post_id ] = update.post_id; + } + }); + + _( data.nav_menu_item_updates ).each(function( update ) { + var oldCustomizeId, newCustomizeId, oldSetting, newSetting, settingValue, oldControl, newControl; + if ( 'inserted' === update.status ) { + if ( ! update.previous_post_id ) { + throw new Error( 'Expected previous_post_id' ); + } + if ( ! update.post_id ) { + throw new Error( 'Expected post_id' ); + } + oldCustomizeId = 'nav_menu_item[' + String( update.previous_post_id ) + ']'; + if ( ! api.has( oldCustomizeId ) ) { + throw new Error( 'Expected setting to exist: ' + oldCustomizeId ); + } + oldSetting = api( oldCustomizeId ); + if ( ! api.control.has( oldCustomizeId ) ) { + throw new Error( 'Expected control to exist: ' + oldCustomizeId ); + } + oldControl = api.control( oldCustomizeId ); + + settingValue = oldSetting.get(); + if ( ! settingValue ) { + throw new Error( 'Did not expect setting to be empty (deleted).' ); + } + settingValue = _.clone( settingValue ); + + // If the parent menu item was also inserted, update the menu_item_parent to the new ID. + if ( settingValue.menu_item_parent < 0 ) { + if ( ! insertedMenuItemIdMapping[ settingValue.menu_item_parent ] ) { + throw new Error( 'inserted ID for menu_item_parent not available' ); + } + settingValue.menu_item_parent = insertedMenuItemIdMapping[ settingValue.menu_item_parent ]; + } + + // If the menu was also inserted, then make sure it uses the new menu ID for nav_menu_term_id. + if ( insertedMenuIdMapping[ settingValue.nav_menu_term_id ] ) { + settingValue.nav_menu_term_id = insertedMenuIdMapping[ settingValue.nav_menu_term_id ]; + } + + newCustomizeId = 'nav_menu_item[' + String( update.post_id ) + ']'; + newSetting = api.create( newCustomizeId, newCustomizeId, settingValue, { + type: 'nav_menu_item', + transport: api.Menus.data.settingTransport, + previewer: api.previewer + } ); + + // Add the menu control. + newControl = new api.controlConstructor.nav_menu_item( newCustomizeId, { + type: 'nav_menu_item', + menu_id: update.post_id, + section: 'nav_menu[' + String( settingValue.nav_menu_term_id ) + ']', + priority: oldControl.priority.get(), + settings: { + 'default': newCustomizeId + }, + menu_item_id: update.post_id + } ); + + // Remove old control. + oldControl.container.remove(); + api.control.remove( oldCustomizeId ); + + // Add new control to take its place. + api.control.add( newControl ); + + // Delete the placeholder and preview the new setting. + oldSetting.callbacks.disable(); // Prevent setting triggering Customizer dirty state when set. + oldSetting.set( false ); + oldSetting.preview(); + newSetting.preview(); + oldSetting._dirty = false; + + newControl.container.toggleClass( 'menu-item-edit-inactive', oldControl.container.hasClass( 'menu-item-edit-inactive' ) ); + } + }); + + /* + * Update the settings for any nav_menu widgets that had selected a placeholder ID. + */ + _.each( data.widget_nav_menu_updates, function( widgetSettingValue, widgetSettingId ) { + var setting = api( widgetSettingId ); + if ( setting ) { + setting._value = widgetSettingValue; + setting.preview(); // Send to the preview now so that menu refresh will use the inserted menu. + } + }); + }; + + /** + * Focus a menu item control. + * + * @alias wp.customize.Menus.focusMenuItemControl + * + * @param {string} menuItemId + */ + api.Menus.focusMenuItemControl = function( menuItemId ) { + var control = api.Menus.getMenuItemControl( menuItemId ); + if ( control ) { + control.focus(); + } + }; + + /** + * Get the control for a given menu. + * + * @alias wp.customize.Menus.getMenuControl + * + * @param menuId + * @return {wp.customize.controlConstructor.menus[]} + */ + api.Menus.getMenuControl = function( menuId ) { + return api.control( 'nav_menu[' + menuId + ']' ); + }; + + /** + * Given a menu item ID, get the control associated with it. + * + * @alias wp.customize.Menus.getMenuItemControl + * + * @param {string} menuItemId + * @return {Object|null} + */ + api.Menus.getMenuItemControl = function( menuItemId ) { + return api.control( menuItemIdToSettingId( menuItemId ) ); + }; + + /** + * @alias wp.customize.Menus~menuItemIdToSettingId + * + * @param {string} menuItemId + */ + function menuItemIdToSettingId( menuItemId ) { + return 'nav_menu_item[' + menuItemId + ']'; + } + + /** + * Apply sanitize_text_field()-like logic to the supplied name, returning a + * "unnammed" fallback string if the name is then empty. + * + * @alias wp.customize.Menus~displayNavMenuName + * + * @param {string} name + * @return {string} + */ + function displayNavMenuName( name ) { + name = name || ''; + name = wp.sanitize.stripTagsAndEncodeText( name ); // Remove any potential tags from name. + name = name.toString().trim(); + return name || api.Menus.data.l10n.unnamed; + } + +})( wp.customize, wp, jQuery ); |