-/* global JSON, _wpCustomizePreviewNavMenusExports */
-
-( function( $, _, wp ) {
+/* global _wpCustomizePreviewNavMenusExports */
+wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( $, _, wp, api ) {
'use strict';
- if ( ! wp || ! wp.customize ) { return; }
-
- var api = wp.customize,
- currentRefreshDebounced = {},
- refreshDebounceDelay = 200,
- settings = {},
- defaultSettings = {
- renderQueryVar: null,
- renderNonceValue: null,
- renderNoncePostKey: null,
- previewCustomizeNonce: null,
- requestUri: '/',
- theme: {
- active: false,
- stylesheet: ''
- },
+ var self = {
+ data: {
navMenuInstanceArgs: {}
- };
+ }
+ };
+ if ( 'undefined' !== typeof _wpCustomizePreviewNavMenusExports ) {
+ _.extend( self.data, _wpCustomizePreviewNavMenusExports );
+ }
+
+ /**
+ * Initialize nav menus preview.
+ */
+ self.init = function() {
+ var self = this;
+
+ if ( api.selectiveRefresh ) {
+ // Listen for changes to settings related to nav menus.
+ api.each( function( setting ) {
+ self.bindSettingListener( setting );
+ } );
+ api.bind( 'add', function( setting ) {
+ self.bindSettingListener( setting, { fire: true } );
+ } );
+ api.bind( 'remove', function( setting ) {
+ self.unbindSettingListener( setting );
+ } );
+
+ /*
+ * Ensure that wp_nav_menu() instances nested inside of other partials
+ * will be recognized as being present on the page.
+ */
+ api.selectiveRefresh.bind( 'render-partials-response', function( response ) {
+ if ( response.nav_menu_instance_args ) {
+ _.extend( self.data.navMenuInstanceArgs, response.nav_menu_instance_args );
+ }
+ } );
+ }
+
+ api.preview.bind( 'active', function() {
+ self.highlightControls();
+ } );
+ };
+
+ if ( api.selectiveRefresh ) {
- api.MenusCustomizerPreview = {
/**
- * Bootstrap functionality.
+ * Partial representing an invocation of wp_nav_menu().
+ *
+ * @class
+ * @augments wp.customize.selectiveRefresh.Partial
+ * @since 4.5.0
*/
- init : function() {
- var self = this, initializedSettings = {};
+ self.NavMenuInstancePartial = api.selectiveRefresh.Partial.extend({
+
+ /**
+ * Constructor.
+ *
+ * @since 4.5.0
+ * @param {string} id - Partial ID.
+ * @param {Object} options
+ * @param {Object} options.params
+ * @param {Object} options.params.navMenuArgs
+ * @param {string} options.params.navMenuArgs.args_hmac
+ * @param {string} [options.params.navMenuArgs.theme_location]
+ * @param {number} [options.params.navMenuArgs.menu]
+ * @param {object} [options.constructingContainerContext]
+ */
+ initialize: function( id, options ) {
+ var partial = this, matches, argsHmac;
+ matches = id.match( /^nav_menu_instance\[([0-9a-f]{32})]$/ );
+ if ( ! matches ) {
+ throw new Error( 'Illegal id for nav_menu_instance partial. The key corresponds with the args HMAC.' );
+ }
+ argsHmac = matches[1];
+
+ options = options || {};
+ options.params = _.extend(
+ {
+ selector: '[data-customize-partial-id="' + id + '"]',
+ navMenuArgs: options.constructingContainerContext || {},
+ containerInclusive: true
+ },
+ options.params || {}
+ );
+ api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options );
+
+ if ( ! _.isObject( partial.params.navMenuArgs ) ) {
+ throw new Error( 'Missing navMenuArgs' );
+ }
+ if ( partial.params.navMenuArgs.args_hmac !== argsHmac ) {
+ throw new Error( 'args_hmac mismatch with id' );
+ }
+ },
- settings = _.extend( {}, defaultSettings );
- if ( 'undefined' !== typeof _wpCustomizePreviewNavMenusExports ) {
- _.extend( settings, _wpCustomizePreviewNavMenusExports );
- }
+ /**
+ * Return whether the setting is related to this partial.
+ *
+ * @since 4.5.0
+ * @param {wp.customize.Value|string} setting - Object or ID.
+ * @param {number|object|false|null} newValue - New value, or null if the setting was just removed.
+ * @param {number|object|false|null} oldValue - Old value, or null if the setting was just added.
+ * @returns {boolean}
+ */
+ isRelatedSetting: function( setting, newValue, oldValue ) {
+ var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting;
+ if ( _.isString( setting ) ) {
+ setting = api( setting );
+ }
- api.each( function( setting, id ) {
- setting.id = id;
- initializedSettings[ setting.id ] = true;
- self.bindListener( setting );
- } );
+ /*
+ * Prevent nav_menu_item changes only containing type_label differences triggering a refresh.
+ * These settings in the preview do not include type_label property, and so if one of these
+ * nav_menu_item settings is dirty, after a refresh the nav menu instance would do a selective
+ * refresh immediately because the setting from the pane would have the type_label whereas
+ * the setting in the preview would not, thus triggering a change event. The following
+ * condition short-circuits this unnecessary selective refresh and also prevents an infinite
+ * loop in the case where a nav_menu_instance partial had done a fallback refresh.
+ * @todo Nav menu item settings should not include a type_label property to begin with.
+ */
+ isNavMenuItemSetting = /^nav_menu_item\[/.test( setting.id );
+ if ( isNavMenuItemSetting && _.isObject( newValue ) && _.isObject( oldValue ) ) {
+ delete newValue.type_label;
+ delete oldValue.type_label;
+ if ( _.isEqual( oldValue, newValue ) ) {
+ return false;
+ }
+ }
- api.preview.bind( 'setting', function( args ) {
- var id, value, setting;
- args = args.slice();
- id = args.shift();
- value = args.shift();
+ if ( partial.params.navMenuArgs.theme_location ) {
+ if ( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' === setting.id ) {
+ return true;
+ }
+ navMenuLocationSetting = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' );
+ }
- setting = api( id );
- if ( ! setting ) {
- // Currently customize-preview.js is not creating settings for dynamically-created settings in the pane, so we have to do it.
- setting = api.create( id, value ); // @todo This should be in core
+ navMenuId = partial.params.navMenuArgs.menu;
+ if ( ! navMenuId && navMenuLocationSetting ) {
+ navMenuId = navMenuLocationSetting();
}
- if ( ! setting.id ) {
- // Currently customize-preview.js doesn't set the id property for each setting, like customize-controls.js does.
- setting.id = id;
+
+ if ( ! navMenuId ) {
+ return false;
}
+ return (
+ ( 'nav_menu[' + navMenuId + ']' === setting.id ) ||
+ ( isNavMenuItemSetting && (
+ ( newValue && newValue.nav_menu_term_id === navMenuId ) ||
+ ( oldValue && oldValue.nav_menu_term_id === navMenuId )
+ ) )
+ );
+ },
- if ( ! initializedSettings[ setting.id ] ) {
- initializedSettings[ setting.id ] = true;
- if ( self.bindListener( setting ) ) {
- setting.callbacks.fireWith( setting, [ setting(), null ] );
- }
+ /**
+ * Make sure that partial fallback behavior is invoked if there is no associated menu.
+ *
+ * @since 4.5.0
+ *
+ * @returns {Promise}
+ */
+ refresh: function() {
+ var partial = this, menuId, deferred = $.Deferred();
+
+ // Make sure the fallback behavior is invoked when the partial is no longer associated with a menu.
+ if ( _.isNumber( partial.params.navMenuArgs.menu ) ) {
+ menuId = partial.params.navMenuArgs.menu;
+ } else if ( partial.params.navMenuArgs.theme_location && api.has( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ) ) {
+ menuId = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ).get();
+ }
+ if ( ! menuId ) {
+ partial.fallback();
+ deferred.reject();
+ return deferred.promise();
+ }
+
+ return api.selectiveRefresh.Partial.prototype.refresh.call( partial );
+ },
+
+ /**
+ * Render content.
+ *
+ * @inheritdoc
+ * @param {wp.customize.selectiveRefresh.Placement} placement
+ */
+ renderContent: function( placement ) {
+ var partial = this, previousContainer = placement.container;
+
+ // Do fallback behavior to refresh preview if menu is now empty.
+ if ( '' === placement.addedContent ) {
+ placement.partial.fallback();
+ }
+
+ if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) {
+
+ // Trigger deprecated event.
+ $( document ).trigger( 'customize-preview-menu-refreshed', [ {
+ instanceNumber: null, // @deprecated
+ wpNavArgs: placement.context, // @deprecated
+ wpNavMenuArgs: placement.context,
+ oldContainer: previousContainer,
+ newContainer: placement.container
+ } ] );
}
+ }
+ });
+
+ api.selectiveRefresh.partialConstructor.nav_menu_instance = self.NavMenuInstancePartial;
+
+ /**
+ * Request full refresh if there are nav menu instances that lack partials which also match the supplied args.
+ *
+ * @param {object} navMenuInstanceArgs
+ */
+ self.handleUnplacedNavMenuInstances = function( navMenuInstanceArgs ) {
+ var unplacedNavMenuInstances;
+ unplacedNavMenuInstances = _.filter( _.values( self.data.navMenuInstanceArgs ), function( args ) {
+ return ! api.selectiveRefresh.partial.has( 'nav_menu_instance[' + args.args_hmac + ']' );
} );
- },
+ if ( _.findWhere( unplacedNavMenuInstances, navMenuInstanceArgs ) ) {
+ api.selectiveRefresh.requestFullRefresh();
+ return true;
+ }
+ return false;
+ };
/**
+ * Add change listener for a nav_menu[], nav_menu_item[], or nav_menu_locations[] setting.
+ *
+ * @since 4.5.0
*
* @param {wp.customize.Value} setting
- * @returns {boolean} Whether the setting was bound.
+ * @param {object} [options]
+ * @param {boolean} options.fire Whether to invoke the callback after binding.
+ * This is used when a dynamic setting is added.
+ * @return {boolean} Whether the setting was bound.
*/
- bindListener : function( setting ) {
- var matches, themeLocation;
+ self.bindSettingListener = function( setting, options ) {
+ var matches;
+ options = options || {};
matches = setting.id.match( /^nav_menu\[(-?\d+)]$/ );
if ( matches ) {
- setting.navMenuId = parseInt( matches[1], 10 );
+ setting._navMenuId = parseInt( matches[1], 10 );
setting.bind( this.onChangeNavMenuSetting );
+ if ( options.fire ) {
+ this.onChangeNavMenuSetting.call( setting, setting(), false );
+ }
return true;
}
matches = setting.id.match( /^nav_menu_item\[(-?\d+)]$/ );
if ( matches ) {
- setting.navMenuItemId = parseInt( matches[1], 10 );
+ setting._navMenuItemId = parseInt( matches[1], 10 );
setting.bind( this.onChangeNavMenuItemSetting );
+ if ( options.fire ) {
+ this.onChangeNavMenuItemSetting.call( setting, setting(), false );
+ }
return true;
}
matches = setting.id.match( /^nav_menu_locations\[(.+?)]/ );
if ( matches ) {
- themeLocation = matches[1];
- setting.bind( _.bind( function() {
- this.refreshMenuLocation( themeLocation );
- }, this ) );
+ setting._navMenuThemeLocation = matches[1];
+ setting.bind( this.onChangeNavMenuLocationsSetting );
+ if ( options.fire ) {
+ this.onChangeNavMenuLocationsSetting.call( setting, setting(), false );
+ }
return true;
}
return false;
- },
+ };
/**
- * Handle changing of a nav_menu setting.
+ * Remove change listeners for nav_menu[], nav_menu_item[], or nav_menu_locations[] setting.
*
- * @this {wp.customize.Setting}
- */
- onChangeNavMenuSetting : function() {
- var setting = this;
- if ( ! setting.navMenuId ) {
- throw new Error( 'Expected navMenuId property to be set.' );
- }
- api.MenusCustomizerPreview.refreshMenu( setting.navMenuId );
- },
-
- /**
- * Handle changing of a nav_menu_item setting.
+ * @since 4.5.0
*
- * @this {wp.customize.Setting}
- * @param {object} to
- * @param {object} from
+ * @param {wp.customize.Value} setting
*/
- onChangeNavMenuItemSetting : function( to, from ) {
- if ( from && from.nav_menu_term_id && ( ! to || from.nav_menu_term_id !== to.nav_menu_term_id ) ) {
- api.MenusCustomizerPreview.refreshMenu( from.nav_menu_term_id );
- }
- if ( to && to.nav_menu_term_id ) {
- api.MenusCustomizerPreview.refreshMenu( to.nav_menu_term_id );
- }
- },
+ self.unbindSettingListener = function( setting ) {
+ setting.unbind( this.onChangeNavMenuSetting );
+ setting.unbind( this.onChangeNavMenuItemSetting );
+ setting.unbind( this.onChangeNavMenuLocationsSetting );
+ };
/**
- * Update a given menu rendered in the preview.
+ * Handle change for nav_menu[] setting for nav menu instances lacking partials.
+ *
+ * @since 4.5.0
*
- * @param {int} menuId
+ * @this {wp.customize.Value}
*/
- refreshMenu : function( menuId ) {
- var assignedLocations = [];
+ self.onChangeNavMenuSetting = function() {
+ var setting = this;
- api.each(function( setting, id ) {
- var matches = id.match( /^nav_menu_locations\[(.+?)]/ );
- if ( matches && menuId === setting() ) {
- assignedLocations.push( matches[1] );
- }
- });
+ self.handleUnplacedNavMenuInstances( {
+ menu: setting._navMenuId
+ } );
- _.each( settings.navMenuInstanceArgs, function( navMenuArgs, instanceNumber ) {
- if ( menuId === navMenuArgs.menu || -1 !== _.indexOf( assignedLocations, navMenuArgs.theme_location ) ) {
- this.refreshMenuInstanceDebounced( instanceNumber );
+ // Ensure all nav menu instances with a theme_location assigned to this menu are handled.
+ api.each( function( otherSetting ) {
+ if ( ! otherSetting._navMenuThemeLocation ) {
+ return;
}
- }, this );
- },
+ if ( setting._navMenuId === otherSetting() ) {
+ self.handleUnplacedNavMenuInstances( {
+ theme_location: otherSetting._navMenuThemeLocation
+ } );
+ }
+ } );
+ };
/**
- * Refresh the menu(s) associated with a given nav menu location.
+ * Handle change for nav_menu_item[] setting for nav menu instances lacking partials.
*
- * @param {string} location
+ * @since 4.5.0
+ *
+ * @param {object} newItem New value for nav_menu_item[] setting.
+ * @param {object} oldItem Old value for nav_menu_item[] setting.
+ * @this {wp.customize.Value}
*/
- refreshMenuLocation : function( location ) {
- var foundInstance = false;
- _.each( settings.navMenuInstanceArgs, function( navMenuArgs, instanceNumber ) {
- if ( location === navMenuArgs.theme_location ) {
- this.refreshMenuInstanceDebounced( instanceNumber );
- foundInstance = true;
- }
- }, this );
- if ( ! foundInstance ) {
- api.preview.send( 'refresh' );
+ self.onChangeNavMenuItemSetting = function( newItem, oldItem ) {
+ var item = newItem || oldItem, navMenuSetting;
+ navMenuSetting = api( 'nav_menu[' + String( item.nav_menu_term_id ) + ']' );
+ if ( navMenuSetting ) {
+ self.onChangeNavMenuSetting.call( navMenuSetting );
}
- },
+ };
/**
- * Update a specific instance of a given menu on the page.
+ * Handle change for nav_menu_locations[] setting for nav menu instances lacking partials.
*
- * @param {int} instanceNumber
+ * @since 4.5.0
+ *
+ * @this {wp.customize.Value}
*/
- refreshMenuInstance : function( instanceNumber ) {
- var data, menuId, customized, container, request, wpNavMenuArgs, instance, containerInstanceClassName;
-
- if ( ! settings.navMenuInstanceArgs[ instanceNumber ] ) {
- throw new Error( 'unknown_instance_number' );
- }
- instance = settings.navMenuInstanceArgs[ instanceNumber ];
-
- containerInstanceClassName = 'partial-refreshable-nav-menu-' + String( instanceNumber );
- container = $( '.' + containerInstanceClassName );
+ self.onChangeNavMenuLocationsSetting = function() {
+ var setting = this, hasNavMenuInstance;
+ self.handleUnplacedNavMenuInstances( {
+ theme_location: setting._navMenuThemeLocation
+ } );
- if ( _.isNumber( instance.menu ) ) {
- menuId = instance.menu;
- } else if ( instance.theme_location && api.has( 'nav_menu_locations[' + instance.theme_location + ']' ) ) {
- menuId = api( 'nav_menu_locations[' + instance.theme_location + ']' ).get();
+ // If there are no wp_nav_menu() instances that refer to the theme location, do full refresh.
+ hasNavMenuInstance = !! _.findWhere( _.values( self.data.navMenuInstanceArgs ), {
+ theme_location: setting._navMenuThemeLocation
+ } );
+ if ( ! hasNavMenuInstance ) {
+ api.selectiveRefresh.requestFullRefresh();
}
-
- if ( ! menuId || ! instance.can_partial_refresh || 0 === container.length ) {
- api.preview.send( 'refresh' );
+ };
+ }
+
+ /**
+ * Connect nav menu items with their corresponding controls in the pane.
+ *
+ * Setup shift-click on nav menu items which are more granular than the nav menu partial itself.
+ * Also this applies even if a nav menu is not partial-refreshable.
+ *
+ * @since 4.5.0
+ */
+ self.highlightControls = function() {
+ var selector = '.menu-item';
+
+ // Focus on the menu item control when shift+clicking the menu item.
+ $( document ).on( 'click', selector, function( e ) {
+ var navMenuItemParts;
+ if ( ! e.shiftKey ) {
return;
}
- menuId = parseInt( menuId, 10 );
-
- data = {
- nonce: settings.previewCustomizeNonce, // for Customize Preview
- wp_customize: 'on'
- };
- if ( ! settings.theme.active ) {
- data.theme = settings.theme.stylesheet;
- }
- data[ settings.renderQueryVar ] = '1';
-
- // Gather settings to send in partial refresh request.
- customized = {};
- api.each( function( setting, id ) {
- var value = setting.get(), shouldSend = false;
- // @todo Core should propagate the dirty state into the Preview as well so we can use that here.
-
- // Send setting if it is a nav_menu_locations[] setting.
- shouldSend = shouldSend || /^nav_menu_locations\[/.test( id );
-
- // Send setting if it is the setting for this menu.
- shouldSend = shouldSend || id === 'nav_menu[' + String( menuId ) + ']';
-
- // Send setting if it is one that is associated with this menu, or it is deleted.
- shouldSend = shouldSend || ( /^nav_menu_item\[/.test( id ) && ( false === value || menuId === value.nav_menu_term_id ) );
-
- if ( shouldSend ) {
- customized[ id ] = value;
- }
- } );
- data.customized = JSON.stringify( customized );
- data[ settings.renderNoncePostKey ] = settings.renderNonceValue;
- wpNavMenuArgs = $.extend( {}, instance );
- data.wp_nav_menu_args_hash = wpNavMenuArgs.args_hash;
- delete wpNavMenuArgs.args_hash;
- data.wp_nav_menu_args = JSON.stringify( wpNavMenuArgs );
-
- container.addClass( 'customize-partial-refreshing' );
-
- request = wp.ajax.send( null, {
- data: data,
- url: settings.requestUri
- } );
- request.done( function( data ) {
- // If the menu is now not visible, refresh since the page layout may have changed.
- if ( false === data ) {
- api.preview.send( 'refresh' );
- return;
- }
-
- var eventParam, previousContainer = container;
- container = $( data );
- container.addClass( containerInstanceClassName );
- container.addClass( 'partial-refreshable-nav-menu customize-partial-refreshing' );
- previousContainer.replaceWith( container );
- eventParam = {
- instanceNumber: instanceNumber,
- wpNavArgs: wpNavMenuArgs, // @deprecated
- wpNavMenuArgs: wpNavMenuArgs,
- oldContainer: previousContainer,
- newContainer: container
- };
- container.removeClass( 'customize-partial-refreshing' );
- $( document ).trigger( 'customize-preview-menu-refreshed', [ eventParam ] );
- } );
- },
-
- refreshMenuInstanceDebounced : function( instanceNumber ) {
- if ( currentRefreshDebounced[ instanceNumber ] ) {
- clearTimeout( currentRefreshDebounced[ instanceNumber ] );
+ navMenuItemParts = $( this ).attr( 'class' ).match( /(?:^|\s)menu-item-(\d+)(?:\s|$)/ );
+ if ( navMenuItemParts ) {
+ e.preventDefault();
+ e.stopPropagation(); // Make sure a sub-nav menu item will get focused instead of parent items.
+ api.preview.send( 'focus-nav-menu-item-control', parseInt( navMenuItemParts[1], 10 ) );
}
- currentRefreshDebounced[ instanceNumber ] = setTimeout(
- _.bind( function() {
- this.refreshMenuInstance( instanceNumber );
- }, this ),
- refreshDebounceDelay
- );
- }
+ });
};
api.bind( 'preview-ready', function() {
- api.preview.bind( 'active', function() {
- api.MenusCustomizerPreview.init();
- } );
+ self.init();
} );
-}( jQuery, _, wp ) );
+ return self;
+
+}( jQuery, _, wp, wp.customize ) );