From a415c29efee45520ae252d2aa28f1083a521cd7b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 17 Apr 2024 09:56:49 +0200 Subject: Adding upstream version 6.4.3+dfsg1. Signed-off-by: Daniel Baumann --- wp-admin/js/customize-nav-menus.js | 3429 ++++++++++++++++++++++++++++++++++++ 1 file changed, 3429 insertions(+) create mode 100644 wp-admin/js/customize-nav-menus.js (limited to 'wp-admin/js/customize-nav-menus.js') 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( $( '
  • ' ).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.} 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 = $( '' ); + 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