PageRenderTime 271ms CodeModel.GetById 27ms app.highlight 217ms RepoModel.GetById 1ms app.codeStats 0ms

/wp-admin/js/customize-widgets.js

https://gitlab.com/em645jn/brochure
JavaScript | 1694 lines | 1026 code | 265 blank | 403 comment | 182 complexity | abb70fb1b0901491d706d3734c5586aa MD5 | raw file
   1/* global _wpCustomizeWidgetsSettings */
   2(function( wp, $ ){
   3
   4	if ( ! wp || ! wp.customize ) { return; }
   5
   6	// Set up our namespace...
   7	var api = wp.customize,
   8		l10n;
   9
  10	api.Widgets = api.Widgets || {};
  11	api.Widgets.savedWidgetIds = {};
  12
  13	// Link settings
  14	api.Widgets.data = _wpCustomizeWidgetsSettings || {};
  15	l10n = api.Widgets.data.l10n;
  16	delete api.Widgets.data.l10n;
  17
  18	/**
  19	 * wp.customize.Widgets.WidgetModel
  20	 *
  21	 * A single widget model.
  22	 *
  23	 * @constructor
  24	 * @augments Backbone.Model
  25	 */
  26	api.Widgets.WidgetModel = Backbone.Model.extend({
  27		id: null,
  28		temp_id: null,
  29		classname: null,
  30		control_tpl: null,
  31		description: null,
  32		is_disabled: null,
  33		is_multi: null,
  34		multi_number: null,
  35		name: null,
  36		id_base: null,
  37		transport: null,
  38		params: [],
  39		width: null,
  40		height: null,
  41		search_matched: true
  42	});
  43
  44	/**
  45	 * wp.customize.Widgets.WidgetCollection
  46	 *
  47	 * Collection for widget models.
  48	 *
  49	 * @constructor
  50	 * @augments Backbone.Model
  51	 */
  52	api.Widgets.WidgetCollection = Backbone.Collection.extend({
  53		model: api.Widgets.WidgetModel,
  54
  55		// Controls searching on the current widget collection
  56		// and triggers an update event
  57		doSearch: function( value ) {
  58
  59			// Don't do anything if we've already done this search
  60			// Useful because the search handler fires multiple times per keystroke
  61			if ( this.terms === value ) {
  62				return;
  63			}
  64
  65			// Updates terms with the value passed
  66			this.terms = value;
  67
  68			// If we have terms, run a search...
  69			if ( this.terms.length > 0 ) {
  70				this.search( this.terms );
  71			}
  72
  73			// If search is blank, show all themes
  74			// Useful for resetting the views when you clean the input
  75			if ( this.terms === '' ) {
  76				this.each( function ( widget ) {
  77					widget.set( 'search_matched', true );
  78				} );
  79			}
  80		},
  81
  82		// Performs a search within the collection
  83		// @uses RegExp
  84		search: function( term ) {
  85			var match, haystack;
  86
  87			// Escape the term string for RegExp meta characters
  88			term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
  89
  90			// Consider spaces as word delimiters and match the whole string
  91			// so matching terms can be combined
  92			term = term.replace( / /g, ')(?=.*' );
  93			match = new RegExp( '^(?=.*' + term + ').+', 'i' );
  94
  95			this.each( function ( data ) {
  96				haystack = [ data.get( 'name' ), data.get( 'id' ), data.get( 'description' ) ].join( ' ' );
  97				data.set( 'search_matched', match.test( haystack ) );
  98			} );
  99		}
 100	});
 101	api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
 102
 103	/**
 104	 * wp.customize.Widgets.SidebarModel
 105	 *
 106	 * A single sidebar model.
 107	 *
 108	 * @constructor
 109	 * @augments Backbone.Model
 110	 */
 111	api.Widgets.SidebarModel = Backbone.Model.extend({
 112		after_title: null,
 113		after_widget: null,
 114		before_title: null,
 115		before_widget: null,
 116		'class': null,
 117		description: null,
 118		id: null,
 119		name: null,
 120		is_rendered: false
 121	});
 122
 123	/**
 124	 * wp.customize.Widgets.SidebarCollection
 125	 *
 126	 * Collection for sidebar models.
 127	 *
 128	 * @constructor
 129	 * @augments Backbone.Collection
 130	 */
 131	api.Widgets.SidebarCollection = Backbone.Collection.extend({
 132		model: api.Widgets.SidebarModel
 133	});
 134	api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
 135
 136	/**
 137	 * wp.customize.Widgets.AvailableWidgetsPanelView
 138	 *
 139	 * View class for the available widgets panel.
 140	 *
 141	 * @constructor
 142	 * @augments wp.Backbone.View
 143	 * @augments Backbone.View
 144	 */
 145	api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend({
 146
 147		el: '#available-widgets',
 148
 149		events: {
 150			'input #widgets-search': 'search',
 151			'keyup #widgets-search': 'search',
 152			'change #widgets-search': 'search',
 153			'search #widgets-search': 'search',
 154			'focus .widget-tpl' : 'focus',
 155			'click .widget-tpl' : '_submit',
 156			'keypress .widget-tpl' : '_submit',
 157			'keydown' : 'keyboardAccessible'
 158		},
 159
 160		// Cache current selected widget
 161		selected: null,
 162
 163		// Cache sidebar control which has opened panel
 164		currentSidebarControl: null,
 165		$search: null,
 166
 167		initialize: function() {
 168			var self = this;
 169
 170			this.$search = $( '#widgets-search' );
 171
 172			_.bindAll( this, 'close' );
 173
 174			this.listenTo( this.collection, 'change', this.updateList );
 175
 176			this.updateList();
 177
 178			// If the available widgets panel is open and the customize controls are
 179			// interacted with (i.e. available widgets panel is blurred) then close the
 180			// available widgets panel. Also close on back button click.
 181			$( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) {
 182				var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
 183				if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
 184					self.close();
 185				}
 186			} );
 187
 188			// Close the panel if the URL in the preview changes
 189			api.previewer.bind( 'url', this.close );
 190		},
 191
 192		// Performs a search and handles selected widget
 193		search: function( event ) {
 194			var firstVisible;
 195
 196			this.collection.doSearch( event.target.value );
 197
 198			// Remove a widget from being selected if it is no longer visible
 199			if ( this.selected && ! this.selected.is( ':visible' ) ) {
 200				this.selected.removeClass( 'selected' );
 201				this.selected = null;
 202			}
 203
 204			// If a widget was selected but the filter value has been cleared out, clear selection
 205			if ( this.selected && ! event.target.value ) {
 206				this.selected.removeClass( 'selected' );
 207				this.selected = null;
 208			}
 209
 210			// If a filter has been entered and a widget hasn't been selected, select the first one shown
 211			if ( ! this.selected && event.target.value ) {
 212				firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
 213				if ( firstVisible.length ) {
 214					this.select( firstVisible );
 215				}
 216			}
 217		},
 218
 219		// Changes visibility of available widgets
 220		updateList: function() {
 221			this.collection.each( function( widget ) {
 222				var widgetTpl = $( '#widget-tpl-' + widget.id );
 223				widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
 224				if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
 225					this.selected = null;
 226				}
 227			} );
 228		},
 229
 230		// Highlights a widget
 231		select: function( widgetTpl ) {
 232			this.selected = $( widgetTpl );
 233			this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
 234			this.selected.addClass( 'selected' );
 235		},
 236
 237		// Highlights a widget on focus
 238		focus: function( event ) {
 239			this.select( $( event.currentTarget ) );
 240		},
 241
 242		// Submit handler for keypress and click on widget
 243		_submit: function( event ) {
 244			// Only proceed with keypress if it is Enter or Spacebar
 245			if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
 246				return;
 247			}
 248
 249			this.submit( $( event.currentTarget ) );
 250		},
 251
 252		// Adds a selected widget to the sidebar
 253		submit: function( widgetTpl ) {
 254			var widgetId, widget, widgetFormControl;
 255
 256			if ( ! widgetTpl ) {
 257				widgetTpl = this.selected;
 258			}
 259
 260			if ( ! widgetTpl || ! this.currentSidebarControl ) {
 261				return;
 262			}
 263
 264			this.select( widgetTpl );
 265
 266			widgetId = $( this.selected ).data( 'widget-id' );
 267			widget = this.collection.findWhere( { id: widgetId } );
 268			if ( ! widget ) {
 269				return;
 270			}
 271
 272			widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
 273			if ( widgetFormControl ) {
 274				widgetFormControl.focus();
 275			}
 276
 277			this.close();
 278		},
 279
 280		// Opens the panel
 281		open: function( sidebarControl ) {
 282			this.currentSidebarControl = sidebarControl;
 283
 284			// Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens
 285			_( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
 286				if ( control.params.is_wide ) {
 287					control.collapseForm();
 288				}
 289			} );
 290
 291			$( 'body' ).addClass( 'adding-widget' );
 292
 293			this.$el.find( '.selected' ).removeClass( 'selected' );
 294
 295			// Reset search
 296			this.collection.doSearch( '' );
 297
 298			if ( ! api.settings.browser.mobile ) {
 299				this.$search.focus();
 300			}
 301		},
 302
 303		// Closes the panel
 304		close: function( options ) {
 305			options = options || {};
 306
 307			if ( options.returnFocus && this.currentSidebarControl ) {
 308				this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
 309			}
 310
 311			this.currentSidebarControl = null;
 312			this.selected = null;
 313
 314			$( 'body' ).removeClass( 'adding-widget' );
 315
 316			this.$search.val( '' );
 317		},
 318
 319		// Add keyboard accessiblity to the panel
 320		keyboardAccessible: function( event ) {
 321			var isEnter = ( event.which === 13 ),
 322				isEsc = ( event.which === 27 ),
 323				isDown = ( event.which === 40 ),
 324				isUp = ( event.which === 38 ),
 325				isTab = ( event.which === 9 ),
 326				isShift = ( event.shiftKey ),
 327				selected = null,
 328				firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
 329				lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
 330				isSearchFocused = $( event.target ).is( this.$search ),
 331				isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
 332
 333			if ( isDown || isUp ) {
 334				if ( isDown ) {
 335					if ( isSearchFocused ) {
 336						selected = firstVisible;
 337					} else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
 338						selected = this.selected.nextAll( '.widget-tpl:visible:first' );
 339					}
 340				} else if ( isUp ) {
 341					if ( isSearchFocused ) {
 342						selected = lastVisible;
 343					} else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
 344						selected = this.selected.prevAll( '.widget-tpl:visible:first' );
 345					}
 346				}
 347
 348				this.select( selected );
 349
 350				if ( selected ) {
 351					selected.focus();
 352				} else {
 353					this.$search.focus();
 354				}
 355
 356				return;
 357			}
 358
 359			// If enter pressed but nothing entered, don't do anything
 360			if ( isEnter && ! this.$search.val() ) {
 361				return;
 362			}
 363
 364			if ( isEnter ) {
 365				this.submit();
 366			} else if ( isEsc ) {
 367				this.close( { returnFocus: true } );
 368			}
 369
 370			if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
 371				this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
 372				event.preventDefault();
 373			}
 374		}
 375	});
 376
 377	/**
 378	 * Handlers for the widget-synced event, organized by widget ID base.
 379	 * Other widgets may provide their own update handlers by adding
 380	 * listeners for the widget-synced event.
 381	 */
 382	api.Widgets.formSyncHandlers = {
 383
 384		/**
 385		 * @param {jQuery.Event} e
 386		 * @param {jQuery} widget
 387		 * @param {String} newForm
 388		 */
 389		rss: function( e, widget, newForm ) {
 390			var oldWidgetError = widget.find( '.widget-error:first' ),
 391				newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
 392
 393			if ( oldWidgetError.length && newWidgetError.length ) {
 394				oldWidgetError.replaceWith( newWidgetError );
 395			} else if ( oldWidgetError.length ) {
 396				oldWidgetError.remove();
 397			} else if ( newWidgetError.length ) {
 398				widget.find( '.widget-content:first' ).prepend( newWidgetError );
 399			}
 400		}
 401	};
 402
 403	/**
 404	 * wp.customize.Widgets.WidgetControl
 405	 *
 406	 * Customizer control for widgets.
 407	 * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
 408	 *
 409	 * @constructor
 410	 * @augments wp.customize.Control
 411	 */
 412	api.Widgets.WidgetControl = api.Control.extend({
 413		defaultExpandedArguments: {
 414			duration: 'fast',
 415			completeCallback: $.noop
 416		},
 417
 418		/**
 419		 * @since 4.1.0
 420		 */
 421		initialize: function( id, options ) {
 422			var control = this;
 423
 424			control.widgetControlEmbedded = false;
 425			control.widgetContentEmbedded = false;
 426			control.expanded = new api.Value( false );
 427			control.expandedArgumentsQueue = [];
 428			control.expanded.bind( function( expanded ) {
 429				var args = control.expandedArgumentsQueue.shift();
 430				args = $.extend( {}, control.defaultExpandedArguments, args );
 431				control.onChangeExpanded( expanded, args );
 432			});
 433			control.altNotice = true;
 434
 435			api.Control.prototype.initialize.call( control, id, options );
 436		},
 437
 438		/**
 439		 * Set up the control.
 440		 *
 441		 * @since 3.9.0
 442		 */
 443		ready: function() {
 444			var control = this;
 445
 446			/*
 447			 * Embed a placeholder once the section is expanded. The full widget
 448			 * form content will be embedded once the control itself is expanded,
 449			 * and at this point the widget-added event will be triggered.
 450			 */
 451			if ( ! control.section() ) {
 452				control.embedWidgetControl();
 453			} else {
 454				api.section( control.section(), function( section ) {
 455					var onExpanded = function( isExpanded ) {
 456						if ( isExpanded ) {
 457							control.embedWidgetControl();
 458							section.expanded.unbind( onExpanded );
 459						}
 460					};
 461					if ( section.expanded() ) {
 462						onExpanded( true );
 463					} else {
 464						section.expanded.bind( onExpanded );
 465					}
 466				} );
 467			}
 468		},
 469
 470		/**
 471		 * Embed the .widget element inside the li container.
 472		 *
 473		 * @since 4.4.0
 474		 */
 475		embedWidgetControl: function() {
 476			var control = this, widgetControl;
 477
 478			if ( control.widgetControlEmbedded ) {
 479				return;
 480			}
 481			control.widgetControlEmbedded = true;
 482
 483			widgetControl = $( control.params.widget_control );
 484			control.container.append( widgetControl );
 485
 486			control._setupModel();
 487			control._setupWideWidget();
 488			control._setupControlToggle();
 489
 490			control._setupWidgetTitle();
 491			control._setupReorderUI();
 492			control._setupHighlightEffects();
 493			control._setupUpdateUI();
 494			control._setupRemoveUI();
 495		},
 496
 497		/**
 498		 * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event.
 499		 *
 500		 * @since 4.4.0
 501		 */
 502		embedWidgetContent: function() {
 503			var control = this, widgetContent;
 504
 505			control.embedWidgetControl();
 506			if ( control.widgetContentEmbedded ) {
 507				return;
 508			}
 509			control.widgetContentEmbedded = true;
 510
 511			widgetContent = $( control.params.widget_content );
 512			control.container.find( '.widget-content:first' ).append( widgetContent );
 513
 514			/*
 515			 * Trigger widget-added event so that plugins can attach any event
 516			 * listeners and dynamic UI elements.
 517			 */
 518			$( document ).trigger( 'widget-added', [ control.container.find( '.widget:first' ) ] );
 519
 520		},
 521
 522		/**
 523		 * Handle changes to the setting
 524		 */
 525		_setupModel: function() {
 526			var self = this, rememberSavedWidgetId;
 527
 528			// Remember saved widgets so we know which to trash (move to inactive widgets sidebar)
 529			rememberSavedWidgetId = function() {
 530				api.Widgets.savedWidgetIds[self.params.widget_id] = true;
 531			};
 532			api.bind( 'ready', rememberSavedWidgetId );
 533			api.bind( 'saved', rememberSavedWidgetId );
 534
 535			this._updateCount = 0;
 536			this.isWidgetUpdating = false;
 537			this.liveUpdateMode = true;
 538
 539			// Update widget whenever model changes
 540			this.setting.bind( function( to, from ) {
 541				if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) {
 542					self.updateWidget( { instance: to } );
 543				}
 544			} );
 545		},
 546
 547		/**
 548		 * Add special behaviors for wide widget controls
 549		 */
 550		_setupWideWidget: function() {
 551			var self = this, $widgetInside, $widgetForm, $customizeSidebar,
 552				$themeControlsContainer, positionWidget;
 553
 554			if ( ! this.params.is_wide ) {
 555				return;
 556			}
 557
 558			$widgetInside = this.container.find( '.widget-inside' );
 559			$widgetForm = $widgetInside.find( '> .form' );
 560			$customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' );
 561			this.container.addClass( 'wide-widget-control' );
 562
 563			this.container.find( '.widget-content:first' ).css( {
 564				'max-width': this.params.width,
 565				'min-height': this.params.height
 566			} );
 567
 568			/**
 569			 * Keep the widget-inside positioned so the top of fixed-positioned
 570			 * element is at the same top position as the widget-top. When the
 571			 * widget-top is scrolled out of view, keep the widget-top in view;
 572			 * likewise, don't allow the widget to drop off the bottom of the window.
 573			 * If a widget is too tall to fit in the window, don't let the height
 574			 * exceed the window height so that the contents of the widget control
 575			 * will become scrollable (overflow:auto).
 576			 */
 577			positionWidget = function() {
 578				var offsetTop = self.container.offset().top,
 579					windowHeight = $( window ).height(),
 580					formHeight = $widgetForm.outerHeight(),
 581					top;
 582				$widgetInside.css( 'max-height', windowHeight );
 583				top = Math.max(
 584					0, // prevent top from going off screen
 585					Math.min(
 586						Math.max( offsetTop, 0 ), // distance widget in panel is from top of screen
 587						windowHeight - formHeight // flush up against bottom of screen
 588					)
 589				);
 590				$widgetInside.css( 'top', top );
 591			};
 592
 593			$themeControlsContainer = $( '#customize-theme-controls' );
 594			this.container.on( 'expand', function() {
 595				positionWidget();
 596				$customizeSidebar.on( 'scroll', positionWidget );
 597				$( window ).on( 'resize', positionWidget );
 598				$themeControlsContainer.on( 'expanded collapsed', positionWidget );
 599			} );
 600			this.container.on( 'collapsed', function() {
 601				$customizeSidebar.off( 'scroll', positionWidget );
 602				$( window ).off( 'resize', positionWidget );
 603				$themeControlsContainer.off( 'expanded collapsed', positionWidget );
 604			} );
 605
 606			// Reposition whenever a sidebar's widgets are changed
 607			api.each( function( setting ) {
 608				if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) {
 609					setting.bind( function() {
 610						if ( self.container.hasClass( 'expanded' ) ) {
 611							positionWidget();
 612						}
 613					} );
 614				}
 615			} );
 616		},
 617
 618		/**
 619		 * Show/hide the control when clicking on the form title, when clicking
 620		 * the close button
 621		 */
 622		_setupControlToggle: function() {
 623			var self = this, $closeBtn;
 624
 625			this.container.find( '.widget-top' ).on( 'click', function( e ) {
 626				e.preventDefault();
 627				var sidebarWidgetsControl = self.getSidebarWidgetsControl();
 628				if ( sidebarWidgetsControl.isReordering ) {
 629					return;
 630				}
 631				self.expanded( ! self.expanded() );
 632			} );
 633
 634			$closeBtn = this.container.find( '.widget-control-close' );
 635			$closeBtn.on( 'click', function( e ) {
 636				e.preventDefault();
 637				self.collapse();
 638				self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility
 639			} );
 640		},
 641
 642		/**
 643		 * Update the title of the form if a title field is entered
 644		 */
 645		_setupWidgetTitle: function() {
 646			var self = this, updateTitle;
 647
 648			updateTitle = function() {
 649				var title = self.setting().title,
 650					inWidgetTitle = self.container.find( '.in-widget-title' );
 651
 652				if ( title ) {
 653					inWidgetTitle.text( ': ' + title );
 654				} else {
 655					inWidgetTitle.text( '' );
 656				}
 657			};
 658			this.setting.bind( updateTitle );
 659			updateTitle();
 660		},
 661
 662		/**
 663		 * Set up the widget-reorder-nav
 664		 */
 665		_setupReorderUI: function() {
 666			var self = this, selectSidebarItem, $moveWidgetArea,
 667				$reorderNav, updateAvailableSidebars, template;
 668
 669			/**
 670			 * select the provided sidebar list item in the move widget area
 671			 *
 672			 * @param {jQuery} li
 673			 */
 674			selectSidebarItem = function( li ) {
 675				li.siblings( '.selected' ).removeClass( 'selected' );
 676				li.addClass( 'selected' );
 677				var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id );
 678				self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar );
 679			};
 680
 681			/**
 682			 * Add the widget reordering elements to the widget control
 683			 */
 684			this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) );
 685
 686
 687			template = _.template( api.Widgets.data.tpl.moveWidgetArea );
 688			$moveWidgetArea = $( template( {
 689					sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' )
 690				} )
 691			);
 692			this.container.find( '.widget-top' ).after( $moveWidgetArea );
 693
 694			/**
 695			 * Update available sidebars when their rendered state changes
 696			 */
 697			updateAvailableSidebars = function() {
 698				var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem,
 699					renderedSidebarCount = 0;
 700
 701				selfSidebarItem = $sidebarItems.filter( function(){
 702					return $( this ).data( 'id' ) === self.params.sidebar_id;
 703				} );
 704
 705				$sidebarItems.each( function() {
 706					var li = $( this ),
 707						sidebarId, sidebar, sidebarIsRendered;
 708
 709					sidebarId = li.data( 'id' );
 710					sidebar = api.Widgets.registeredSidebars.get( sidebarId );
 711					sidebarIsRendered = sidebar.get( 'is_rendered' );
 712
 713					li.toggle( sidebarIsRendered );
 714
 715					if ( sidebarIsRendered ) {
 716						renderedSidebarCount += 1;
 717					}
 718
 719					if ( li.hasClass( 'selected' ) && ! sidebarIsRendered ) {
 720						selectSidebarItem( selfSidebarItem );
 721					}
 722				} );
 723
 724				if ( renderedSidebarCount > 1 ) {
 725					self.container.find( '.move-widget' ).show();
 726				} else {
 727					self.container.find( '.move-widget' ).hide();
 728				}
 729			};
 730
 731			updateAvailableSidebars();
 732			api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars );
 733
 734			/**
 735			 * Handle clicks for up/down/move on the reorder nav
 736			 */
 737			$reorderNav = this.container.find( '.widget-reorder-nav' );
 738			$reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() {
 739				$( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' );
 740			} ).on( 'click keypress', function( event ) {
 741				if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
 742					return;
 743				}
 744				$( this ).focus();
 745
 746				if ( $( this ).is( '.move-widget' ) ) {
 747					self.toggleWidgetMoveArea();
 748				} else {
 749					var isMoveDown = $( this ).is( '.move-widget-down' ),
 750						isMoveUp = $( this ).is( '.move-widget-up' ),
 751						i = self.getWidgetSidebarPosition();
 752
 753					if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) {
 754						return;
 755					}
 756
 757					if ( isMoveUp ) {
 758						self.moveUp();
 759						wp.a11y.speak( l10n.widgetMovedUp );
 760					} else {
 761						self.moveDown();
 762						wp.a11y.speak( l10n.widgetMovedDown );
 763					}
 764
 765					$( this ).focus(); // re-focus after the container was moved
 766				}
 767			} );
 768
 769			/**
 770			 * Handle selecting a sidebar to move to
 771			 */
 772			this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( event ) {
 773				if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
 774					return;
 775				}
 776				event.preventDefault();
 777				selectSidebarItem( $( this ) );
 778			} );
 779
 780			/**
 781			 * Move widget to another sidebar
 782			 */
 783			this.container.find( '.move-widget-btn' ).click( function() {
 784				self.getSidebarWidgetsControl().toggleReordering( false );
 785
 786				var oldSidebarId = self.params.sidebar_id,
 787					newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ),
 788					oldSidebarWidgetsSetting, newSidebarWidgetsSetting,
 789					oldSidebarWidgetIds, newSidebarWidgetIds, i;
 790
 791				oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' );
 792				newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' );
 793				oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() );
 794				newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() );
 795
 796				i = self.getWidgetSidebarPosition();
 797				oldSidebarWidgetIds.splice( i, 1 );
 798				newSidebarWidgetIds.push( self.params.widget_id );
 799
 800				oldSidebarWidgetsSetting( oldSidebarWidgetIds );
 801				newSidebarWidgetsSetting( newSidebarWidgetIds );
 802
 803				self.focus();
 804			} );
 805		},
 806
 807		/**
 808		 * Highlight widgets in preview when interacted with in the Customizer
 809		 */
 810		_setupHighlightEffects: function() {
 811			var self = this;
 812
 813			// Highlight whenever hovering or clicking over the form
 814			this.container.on( 'mouseenter click', function() {
 815				self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
 816			} );
 817
 818			// Highlight when the setting is updated
 819			this.setting.bind( function() {
 820				self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
 821			} );
 822		},
 823
 824		/**
 825		 * Set up event handlers for widget updating
 826		 */
 827		_setupUpdateUI: function() {
 828			var self = this, $widgetRoot, $widgetContent,
 829				$saveBtn, updateWidgetDebounced, formSyncHandler;
 830
 831			$widgetRoot = this.container.find( '.widget:first' );
 832			$widgetContent = $widgetRoot.find( '.widget-content:first' );
 833
 834			// Configure update button
 835			$saveBtn = this.container.find( '.widget-control-save' );
 836			$saveBtn.val( l10n.saveBtnLabel );
 837			$saveBtn.attr( 'title', l10n.saveBtnTooltip );
 838			$saveBtn.removeClass( 'button-primary' ).addClass( 'button-secondary' );
 839			$saveBtn.on( 'click', function( e ) {
 840				e.preventDefault();
 841				self.updateWidget( { disable_form: true } ); // @todo disable_form is unused?
 842			} );
 843
 844			updateWidgetDebounced = _.debounce( function() {
 845				self.updateWidget();
 846			}, 250 );
 847
 848			// Trigger widget form update when hitting Enter within an input
 849			$widgetContent.on( 'keydown', 'input', function( e ) {
 850				if ( 13 === e.which ) { // Enter
 851					e.preventDefault();
 852					self.updateWidget( { ignoreActiveElement: true } );
 853				}
 854			} );
 855
 856			// Handle widgets that support live previews
 857			$widgetContent.on( 'change input propertychange', ':input', function( e ) {
 858				if ( ! self.liveUpdateMode ) {
 859					return;
 860				}
 861				if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) {
 862					updateWidgetDebounced();
 863				}
 864			} );
 865
 866			// Remove loading indicators when the setting is saved and the preview updates
 867			this.setting.previewer.channel.bind( 'synced', function() {
 868				self.container.removeClass( 'previewer-loading' );
 869			} );
 870
 871			api.previewer.bind( 'widget-updated', function( updatedWidgetId ) {
 872				if ( updatedWidgetId === self.params.widget_id ) {
 873					self.container.removeClass( 'previewer-loading' );
 874				}
 875			} );
 876
 877			formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ];
 878			if ( formSyncHandler ) {
 879				$( document ).on( 'widget-synced', function( e, widget ) {
 880					if ( $widgetRoot.is( widget ) ) {
 881						formSyncHandler.apply( document, arguments );
 882					}
 883				} );
 884			}
 885		},
 886
 887		/**
 888		 * Update widget control to indicate whether it is currently rendered.
 889		 *
 890		 * Overrides api.Control.toggle()
 891		 *
 892		 * @since 4.1.0
 893		 *
 894		 * @param {Boolean}   active
 895		 * @param {Object}    args
 896		 * @param {Callback}  args.completeCallback
 897		 */
 898		onChangeActive: function ( active, args ) {
 899			// Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments
 900			this.container.toggleClass( 'widget-rendered', active );
 901			if ( args.completeCallback ) {
 902				args.completeCallback();
 903			}
 904		},
 905
 906		/**
 907		 * Set up event handlers for widget removal
 908		 */
 909		_setupRemoveUI: function() {
 910			var self = this, $removeBtn, replaceDeleteWithRemove;
 911
 912			// Configure remove button
 913			$removeBtn = this.container.find( 'a.widget-control-remove' );
 914			$removeBtn.on( 'click', function( e ) {
 915				e.preventDefault();
 916
 917				// Find an adjacent element to add focus to when this widget goes away
 918				var $adjacentFocusTarget;
 919				if ( self.container.next().is( '.customize-control-widget_form' ) ) {
 920					$adjacentFocusTarget = self.container.next().find( '.widget-action:first' );
 921				} else if ( self.container.prev().is( '.customize-control-widget_form' ) ) {
 922					$adjacentFocusTarget = self.container.prev().find( '.widget-action:first' );
 923				} else {
 924					$adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' );
 925				}
 926
 927				self.container.slideUp( function() {
 928					var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ),
 929						sidebarWidgetIds, i;
 930
 931					if ( ! sidebarsWidgetsControl ) {
 932						return;
 933					}
 934
 935					sidebarWidgetIds = sidebarsWidgetsControl.setting().slice();
 936					i = _.indexOf( sidebarWidgetIds, self.params.widget_id );
 937					if ( -1 === i ) {
 938						return;
 939					}
 940
 941					sidebarWidgetIds.splice( i, 1 );
 942					sidebarsWidgetsControl.setting( sidebarWidgetIds );
 943
 944					$adjacentFocusTarget.focus(); // keyboard accessibility
 945				} );
 946			} );
 947
 948			replaceDeleteWithRemove = function() {
 949				$removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the link as "Delete"
 950				$removeBtn.attr( 'title', l10n.removeBtnTooltip );
 951			};
 952
 953			if ( this.params.is_new ) {
 954				api.bind( 'saved', replaceDeleteWithRemove );
 955			} else {
 956				replaceDeleteWithRemove();
 957			}
 958		},
 959
 960		/**
 961		 * Find all inputs in a widget container that should be considered when
 962		 * comparing the loaded form with the sanitized form, whose fields will
 963		 * be aligned to copy the sanitized over. The elements returned by this
 964		 * are passed into this._getInputsSignature(), and they are iterated
 965		 * over when copying sanitized values over to the form loaded.
 966		 *
 967		 * @param {jQuery} container element in which to look for inputs
 968		 * @returns {jQuery} inputs
 969		 * @private
 970		 */
 971		_getInputs: function( container ) {
 972			return $( container ).find( ':input[name]' );
 973		},
 974
 975		/**
 976		 * Iterate over supplied inputs and create a signature string for all of them together.
 977		 * This string can be used to compare whether or not the form has all of the same fields.
 978		 *
 979		 * @param {jQuery} inputs
 980		 * @returns {string}
 981		 * @private
 982		 */
 983		_getInputsSignature: function( inputs ) {
 984			var inputsSignatures = _( inputs ).map( function( input ) {
 985				var $input = $( input ), signatureParts;
 986
 987				if ( $input.is( ':checkbox, :radio' ) ) {
 988					signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ];
 989				} else {
 990					signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ];
 991				}
 992
 993				return signatureParts.join( ',' );
 994			} );
 995
 996			return inputsSignatures.join( ';' );
 997		},
 998
 999		/**
1000		 * Get the state for an input depending on its type.
1001		 *
1002		 * @param {jQuery|Element} input
1003		 * @returns {string|boolean|array|*}
1004		 * @private
1005		 */
1006		_getInputState: function( input ) {
1007			input = $( input );
1008			if ( input.is( ':radio, :checkbox' ) ) {
1009				return input.prop( 'checked' );
1010			} else if ( input.is( 'select[multiple]' ) ) {
1011				return input.find( 'option:selected' ).map( function () {
1012					return $( this ).val();
1013				} ).get();
1014			} else {
1015				return input.val();
1016			}
1017		},
1018
1019		/**
1020		 * Update an input's state based on its type.
1021		 *
1022		 * @param {jQuery|Element} input
1023		 * @param {string|boolean|array|*} state
1024		 * @private
1025		 */
1026		_setInputState: function ( input, state ) {
1027			input = $( input );
1028			if ( input.is( ':radio, :checkbox' ) ) {
1029				input.prop( 'checked', state );
1030			} else if ( input.is( 'select[multiple]' ) ) {
1031				if ( ! $.isArray( state ) ) {
1032					state = [];
1033				} else {
1034					// Make sure all state items are strings since the DOM value is a string
1035					state = _.map( state, function ( value ) {
1036						return String( value );
1037					} );
1038				}
1039				input.find( 'option' ).each( function () {
1040					$( this ).prop( 'selected', -1 !== _.indexOf( state, String( this.value ) ) );
1041				} );
1042			} else {
1043				input.val( state );
1044			}
1045		},
1046
1047		/***********************************************************************
1048		 * Begin public API methods
1049		 **********************************************************************/
1050
1051		/**
1052		 * @return {wp.customize.controlConstructor.sidebar_widgets[]}
1053		 */
1054		getSidebarWidgetsControl: function() {
1055			var settingId, sidebarWidgetsControl;
1056
1057			settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']';
1058			sidebarWidgetsControl = api.control( settingId );
1059
1060			if ( ! sidebarWidgetsControl ) {
1061				return;
1062			}
1063
1064			return sidebarWidgetsControl;
1065		},
1066
1067		/**
1068		 * Submit the widget form via Ajax and get back the updated instance,
1069		 * along with the new widget control form to render.
1070		 *
1071		 * @param {object} [args]
1072		 * @param {Object|null} [args.instance=null]  When the model changes, the instance is sent here; otherwise, the inputs from the form are used
1073		 * @param {Function|null} [args.complete=null]  Function which is called when the request finishes. Context is bound to the control. First argument is any error. Following arguments are for success.
1074		 * @param {Boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element.
1075		 */
1076		updateWidget: function( args ) {
1077			var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent,
1078				updateNumber, params, data, $inputs, processing, jqxhr, isChanged;
1079
1080			// The updateWidget logic requires that the form fields to be fully present.
1081			self.embedWidgetContent();
1082
1083			args = $.extend( {
1084				instance: null,
1085				complete: null,
1086				ignoreActiveElement: false
1087			}, args );
1088
1089			instanceOverride = args.instance;
1090			completeCallback = args.complete;
1091
1092			this._updateCount += 1;
1093			updateNumber = this._updateCount;
1094
1095			$widgetRoot = this.container.find( '.widget:first' );
1096			$widgetContent = $widgetRoot.find( '.widget-content:first' );
1097
1098			// Remove a previous error message
1099			$widgetContent.find( '.widget-error' ).remove();
1100
1101			this.container.addClass( 'widget-form-loading' );
1102			this.container.addClass( 'previewer-loading' );
1103			processing = api.state( 'processing' );
1104			processing( processing() + 1 );
1105
1106			if ( ! this.liveUpdateMode ) {
1107				this.container.addClass( 'widget-form-disabled' );
1108			}
1109
1110			params = {};
1111			params.action = 'update-widget';
1112			params.wp_customize = 'on';
1113			params.nonce = api.settings.nonce['update-widget'];
1114			params.theme = api.settings.theme.stylesheet;
1115			params.customized = wp.customize.previewer.query().customized;
1116
1117			data = $.param( params );
1118			$inputs = this._getInputs( $widgetContent );
1119
1120			// Store the value we're submitting in data so that when the response comes back,
1121			// we know if it got sanitized; if there is no difference in the sanitized value,
1122			// then we do not need to touch the UI and mess up the user's ongoing editing.
1123			$inputs.each( function() {
1124				$( this ).data( 'state' + updateNumber, self._getInputState( this ) );
1125			} );
1126
1127			if ( instanceOverride ) {
1128				data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } );
1129			} else {
1130				data += '&' + $inputs.serialize();
1131			}
1132			data += '&' + $widgetContent.find( '~ :input' ).serialize();
1133
1134			if ( this._previousUpdateRequest ) {
1135				this._previousUpdateRequest.abort();
1136			}
1137			jqxhr = $.post( wp.ajax.settings.url, data );
1138			this._previousUpdateRequest = jqxhr;
1139
1140			jqxhr.done( function( r ) {
1141				var message, sanitizedForm,	$sanitizedInputs, hasSameInputsInResponse,
1142					isLiveUpdateAborted = false;
1143
1144				// Check if the user is logged out.
1145				if ( '0' === r ) {
1146					api.previewer.preview.iframe.hide();
1147					api.previewer.login().done( function() {
1148						self.updateWidget( args );
1149						api.previewer.preview.iframe.show();
1150					} );
1151					return;
1152				}
1153
1154				// Check for cheaters.
1155				if ( '-1' === r ) {
1156					api.previewer.cheatin();
1157					return;
1158				}
1159
1160				if ( r.success ) {
1161					sanitizedForm = $( '<div>' + r.data.form + '</div>' );
1162					$sanitizedInputs = self._getInputs( sanitizedForm );
1163					hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs );
1164
1165					// Restore live update mode if sanitized fields are now aligned with the existing fields
1166					if ( hasSameInputsInResponse && ! self.liveUpdateMode ) {
1167						self.liveUpdateMode = true;
1168						self.container.removeClass( 'widget-form-disabled' );
1169						self.container.find( 'input[name="savewidget"]' ).hide();
1170					}
1171
1172					// Sync sanitized field states to existing fields if they are aligned
1173					if ( hasSameInputsInResponse && self.liveUpdateMode ) {
1174						$inputs.each( function( i ) {
1175							var $input = $( this ),
1176								$sanitizedInput = $( $sanitizedInputs[i] ),
1177								submittedState, sanitizedState,	canUpdateState;
1178
1179							submittedState = $input.data( 'state' + updateNumber );
1180							sanitizedState = self._getInputState( $sanitizedInput );
1181							$input.data( 'sanitized', sanitizedState );
1182
1183							canUpdateState = ( ! _.isEqual( submittedState, sanitizedState ) && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) ) );
1184							if ( canUpdateState ) {
1185								self._setInputState( $input, sanitizedState );
1186							}
1187						} );
1188
1189						$( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] );
1190
1191					// Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled
1192					} else if ( self.liveUpdateMode ) {
1193						self.liveUpdateMode = false;
1194						self.container.find( 'input[name="savewidget"]' ).show();
1195						isLiveUpdateAborted = true;
1196
1197					// Otherwise, replace existing form with the sanitized form
1198					} else {
1199						$widgetContent.html( r.data.form );
1200
1201						self.container.removeClass( 'widget-form-disabled' );
1202
1203						$( document ).trigger( 'widget-updated', [ $widgetRoot ] );
1204					}
1205
1206					/**
1207					 * If the old instance is identical to the new one, there is nothing new
1208					 * needing to be rendered, and so we can preempt the event for the
1209					 * preview finishing loading.
1210					 */
1211					isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance );
1212					if ( isChanged ) {
1213						self.isWidgetUpdating = true; // suppress triggering another updateWidget
1214						self.setting( r.data.instance );
1215						self.isWidgetUpdating = false;
1216					} else {
1217						// no change was made, so stop the spinner now instead of when the preview would updates
1218						self.container.removeClass( 'previewer-loading' );
1219					}
1220
1221					if ( completeCallback ) {
1222						completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } );
1223					}
1224				} else {
1225					// General error message
1226					message = l10n.error;
1227
1228					if ( r.data && r.data.message ) {
1229						message = r.data.message;
1230					}
1231
1232					if ( completeCallback ) {
1233						completeCallback.call( self, message );
1234					} else {
1235						$widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' );
1236					}
1237				}
1238			} );
1239
1240			jqxhr.fail( function( jqXHR, textStatus ) {
1241				if ( completeCallback ) {
1242					completeCallback.call( self, textStatus );
1243				}
1244			} );
1245
1246			jqxhr.always( function() {
1247				self.container.removeClass( 'widget-form-loading' );
1248
1249				$inputs.each( function() {
1250					$( this ).removeData( 'state' + updateNumber );
1251				} );
1252
1253				processing( processing() - 1 );
1254			} );
1255		},
1256
1257		/**
1258		 * Expand the accordion section containing a control
1259		 */
1260		expandControlSection: function() {
1261			api.Control.prototype.expand.call( this );
1262		},
1263
1264		/**
1265		 * @since 4.1.0
1266		 *
1267		 * @param {Boolean} expanded
1268		 * @param {Object} [params]
1269		 * @returns {Boolean} false if state already applied
1270		 */
1271		_toggleExpanded: api.Section.prototype._toggleExpanded,
1272
1273		/**
1274		 * @since 4.1.0
1275		 *
1276		 * @param {Object} [params]
1277		 * @returns {Boolean} false if already expanded
1278		 */
1279		expand: api.Section.prototype.expand,
1280
1281		/**
1282		 * Expand the widget form control
1283		 *
1284		 * @deprecated 4.1.0 Use this.expand() instead.
1285		 */
1286		expandForm: function() {
1287			this.expand();
1288		},
1289
1290		/**
1291		 * @since 4.1.0
1292		 *
1293		 * @param {Object} [params]
1294		 * @returns {Boolean} false if already collapsed
1295		 */
1296		collapse: api.Section.prototype.collapse,
1297
1298		/**
1299		 * Collapse the widget form control
1300		 *
1301		 * @deprecated 4.1.0 Use this.collapse() instead.
1302		 */
1303		collapseForm: function() {
1304			this.collapse();
1305		},
1306
1307		/**
1308		 * Expand or collapse the widget control
1309		 *
1310		 * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
1311		 *
1312		 * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility
1313		 */
1314		toggleForm: function( showOrHide ) {
1315			if ( typeof showOrHide === 'undefined' ) {
1316				showOrHide = ! this.expanded();
1317			}
1318			this.expanded( showOrHide );
1319		},
1320
1321		/**
1322		 * Respond to change in the expanded state.
1323		 *
1324		 * @param {Boolean} expanded
1325		 * @param {Object} args  merged on top of this.defaultActiveArguments
1326		 */
1327		onChangeExpanded: function ( expanded, args ) {
1328			var self = this, $widget, $inside, complete, prevComplete;
1329
1330			self.embedWidgetControl(); // Make sure the outer form is embedded so that the expanded state can be set in the UI.
1331			if ( expanded ) {
1332				self.embedWidgetContent();
1333			}
1334
1335			// If the expanded state is unchanged only manipulate container expanded states
1336			if ( args.unchanged ) {
1337				if ( expanded ) {
1338					api.Control.prototype.expand.call( self, {
1339						completeCallback:  args.completeCallback
1340					});
1341				}
1342				return;
1343			}
1344
1345			$widget = this.container.find( 'div.widget:first' );
1346			$inside = $widget.find( '.widget-inside:first' );
1347
1348			if ( expanded ) {
1349
1350				if ( self.section() && api.section( self.section() ) ) {
1351					self.expandControlSection();
1352				}
1353
1354				// Close all other widget controls before expanding this one
1355				api.control.each( function( otherControl ) {
1356					if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1357						otherControl.collapse();
1358					}
1359				} );
1360
1361				complete = function() {
1362					self.container.removeClass( 'expanding' );
1363					self.container.addClass( 'expanded' );
1364					self.container.trigger( 'expanded' );
1365				};
1366				if ( args.completeCallback ) {
1367					prevComplete = complete;
1368					complete = function () {
1369						prevComplete();
1370						args.completeCallback();
1371					};
1372				}
1373
1374				if ( self.params.is_wide ) {
1375					$inside.fadeIn( args.duration, complete );
1376				} else {
1377					$inside.slideDown( args.duration, complete );
1378				}
1379
1380				self.container.trigger( 'expand' );
1381				self.container.addClass( 'expanding' );
1382			} else {
1383
1384				complete = function() {
1385					self.container.removeClass( 'collapsing' );
1386					self.container.removeClass( 'expanded' );
1387					self.container.trigger( 'collapsed' );
1388				};
1389				if ( args.completeCallback ) {
1390					prevComplete = complete;
1391					complete = function () {
1392						prevComplete();
1393						args.completeCallback();
1394					};
1395				}
1396
1397				self.container.trigger( 'collapse' );
1398				self.container.addClass( 'collapsing' );
1399
1400				if ( self.params.is_wide ) {
1401					$inside.fadeOut( args.duration, complete );
1402				} else {
1403					$inside.slideUp( args.duration, function() {
1404						$widget.css( { width:'', margin:'' } );
1405						complete();
1406					} );
1407				}
1408			}
1409		},
1410
1411		/**
1412		 * Get the position (index) of the widget in the containing sidebar
1413		 *
1414		 * @returns {Number}
1415		 */
1416		getWidgetSidebarPosition: function() {
1417			var sidebarWidgetIds, position;
1418
1419			sidebarWidgetIds = this.getSidebarWidgetsControl().setting();
1420			position = _.indexOf( sidebarWidgetIds, this.params.widget_id );
1421
1422			if ( position === -1 ) {
1423				return;
1424			}
1425
1426			return position;
1427		},
1428
1429		/**
1430		 * Move widget up one in the sidebar
1431		 */
1432		moveUp: function() {
1433			this._moveWidgetByOne( -1 );
1434		},
1435
1436		/**
1437		 * Move widget up one in the sidebar
1438		 */
1439		moveDown: function() {
1440			this._moveWidgetByOne( 1 );
1441		},
1442
1443		/**
1444		 * @private
1445		 *
1446		 * @param {Number} offset 1|-1
1447		 */
1448		_moveWidgetByOne: function( offset ) {
1449			var i, sidebarWidgetsSetting, sidebarWidgetIds,	adjacentWidgetId;
1450
1451			i = this.getWidgetSidebarPosition();
1452
1453			sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting;
1454			sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // clone
1455			adjacentWidgetId = sidebarWidgetIds[i + offset];
1456			sidebarWidgetIds[i + offset] = this.params.widget_id;
1457			sidebarWidgetIds[i] = adjacentWidgetId;
1458
1459			sidebarWidgetsSetting( sidebarWidgetIds );
1460		},
1461
1462		/**
1463		 * Toggle visibility of the widget move area
1464		 *
1465		 * @param {Boolean} [showOrHide]
1466		 */
1467		toggleWidgetMoveArea: function( showOrHide ) {
1468			var self = this, $moveWidgetArea;
1469
1470			$moveWidgetArea = this.container.find( '.move-widget-area' );
1471
1472			if ( typeof showOrHide === 'undefined' ) {
1473				showOrHide = ! $moveWidgetArea.hasClass( 'active' );
1474			}
1475
1476			if ( showOrHide ) {
1477				// reset the selected sidebar
1478				$moveWidgetArea.find( '.selected' ).removeClass( 'selected' );
1479
1480				$moveWidgetArea.find( 'li' ).filter( function() {
1481					return $( this ).data( 'id' ) === self.params.sidebar_id;
1482				} ).addClass( 'selected' );
1483
1484				this.container.find( '.move-widget-btn' ).prop( 'disabled', true );
1485			}
1486
1487			$moveWidgetArea.toggleClass( 'active', showOrHide );
1488		},
1489
1490		/**
1491		 * Highlight the widget control and section
1492		 */
1493		highlightSectionAndControl: function() {
1494			var $target;
1495
1496			if ( this.container.is( ':hidden' ) ) {
1497				$target = this.container.closest( '.control-section' );
1498			} else {
1499				$target = this.container;
1500			}
1501
1502			$( '.highlighted' ).removeClass( 'highlighted' );
1503			$target.addClass( 'highlighted' );
1504
1505			setTimeout( function() {
1506				$target.removeClass( 'highlighted' );
1507			}, 500 );
1508		}
1509	} );
1510
1511	/**
1512	 * wp.customize.Widgets.WidgetsPanel
1513	 *
1514	 * Customizer panel containing the widget area sections.
1515	 *
1516	 * @since 4.4.0
1517	 */
1518	api.Widgets.WidgetsPanel = api.Panel.extend({
1519
1520		/**
1521		 * Add and manage the display of the no-rendered-areas notice.
1522		 *
1523		 * @since 4.4.0
1524		 */
1525		ready: function () {
1526			var panel = this;
1527
1528			api.Panel.prototype.ready.call( panel );
1529
1530			panel.deferred.embedded.done(function() {
1531				var panelMetaContainer, noRenderedAreasNotice, shouldShowNotice;
1532				panelMetaContainer = panel.container.find( '.panel-meta' );
1533				noRenderedAreasNotice = $( '<div></div>', {
1534					'class': 'no-widget-areas-rendered-notice'
1535				});
1536				noRenderedAreasNotice.append( $( '<em></em>', {
1537					text: l10n.noAreasRendered
1538				} ) );
1539				panelMetaContainer.append( noRenderedAreasNotice );
1540
1541				shouldShowNotice = function() {
1542					return ( 0 === _.filter( panel.sections(), function( section ) {
1543						return section.active();
1544					} ).length );
1545				};
1546
1547				/*
1548				 * Set the initial visibility state for rendered notice.
1549				 * Update the visibility of the notice whenever a reflow happens.
1550				 */
1551				noRenderedAreasNotice.toggle( shouldShowNotice() );
1552				api.previewer.deferred.active.done( function () {
1553					noRenderedAreasNotice.toggle( shouldShowNotice() );
1554				});
1555				api.bind( 'pane-contents-reflowed', function() {
1556					var duration = ( 'resolved' === api.previewer.deferred.active.state() ) ? 'fast' : 0;
1557					if ( shouldShowNotice() ) {
1558						noRenderedAreasNotice.slideDown( duration );
1559					} else {
1560						noRenderedAreasNotice.slideUp( duration );
1561					}
1562				});
1563			});
1564		},
1565
1566		/**
1567		 * Allow an active widgets panel to be contextually active even when it has no active sections (widget areas).
1568		 *
1569		 * This ensures that the widgets panel appears even when there are no
1570		 * sidebars displayed on the URL currently being previewed.
1571		 *
1572		 * @since 4.4.0
1573		 *
1574		 * @returns {boolean}
1575		 */
1576		isContextuallyActive: function() {
1577			var panel = this;
1578			return panel.active();
1579		}
1580	});
1581
1582	/**
1583	 * wp.customize.Widgets.SidebarSection
1584	 *
1585	 * Customizer section representing a widget area widget
1586	 *
1587	 * @since 4.1.0
1588	 */
1589	api.Widgets.SidebarSection = api.Section.extend({
1590
1591		/**
1592		 * Sync the section's active state back to the Backbone model's is_rendered attribute
1593		 *
1594		 * @since 4.1.0
1595		 */
1596		ready: function () {
1597			var section = this, registeredSidebar;
1598			api.Section.prototype.ready.call( this );
1599			registeredSidebar = api.Widgets.registeredSidebars.get( section.params.sidebarId );
1600			section.active.bind( function ( active ) {
1601				registeredSidebar.set( 'is_rendered', active );
1602			});
1603			registeredSidebar.set( 'is_rendered', section.active() );
1604		}
1605	});
1606
1607	/**
1608	 * wp.customize.Widgets.SidebarControl
1609	 *
1610	 * Customizer control for widgets.
1611	 * Note that 'sidebar_widgets' must match the WP_Widget_Area_Customize_Control::$type
1612	 *
1613	 * @since 3.9.0
1614	 *
1615	 * @constructor
1616	 * @augments wp.customize.Control
1617	 */
1618	api.Widgets.SidebarControl = api.Control.extend({
1619
1620		/**
1621		 * Set up the control
1622		 */
1623		ready: function() {
1624			this.$controlSection = this.container.closest( '.control-section' );
1625			this.$sectionContent = this.container.closest( '.accordion-section-content' );
1626
1627			this._setupModel();
1628			this._setupSortable();
1629			this._setupAddition();
1630			this._applyCardinalOrderClassNames();
1631		},
1632
1633		/**
1634		 * Update ordering of widget control forms when the setting is updated
1635		 */
1636		_setupModel: function() {
1637			var self = this;
1638
1639			this.setting.bind( function( newWidgetIds, oldWidgetIds ) {
1640				var widgetFormControls, removedWidgetIds, priority;
1641
1642				removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds );
1643
1644				// Filter out any persistent widget IDs for widgets which have been deactivated
1645				newWidgetIds = _( newWidgetIds ).filter( function( newWidgetId ) {
1646					var parsedWidgetId = parseWidgetId( newWidgetId );
1647
1648					return !! api.Widgets.availableWidgets.findWhere( { id_base: parsedWidgetId.id_base } );
1649				} );
1650
1651				widgetFormControls = _( newWidgetIds ).map( function( widgetId ) {
1652					var widgetFormControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
1653
1654					if ( ! widgetFormControl ) {
1655						widgetFormControl = self.addWidget( widgetId );
1656					}
1657
1658					return widgetFormControl;
1659				} );
1660
1661				// Sort widget controls to their new positions
1662				widgetFormControls.sort( function( a, b ) {
1663					var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ),
1664						bIndex = _.indexOf( newWidgetIds, b.params.widget_id );
1665					return aIndex - bIndex;
1666				});
1667
1668				priority = 0;
1669				_( widgetFormControls ).each( function ( control ) {
1670					control.priority( priority );
1671					control.section( self.section() );
1672					priority += 1;
1673				});
1674				self.priority( priority ); // Make sure sidebar control remains at end
1675
1676				// Re-sort widget form controls (including widgets form other sidebars newly moved here)
1677				self._applyCardinalOrderClassNames();
1678
1679				// If the widget was dragged into the sidebar, make sure the sidebar_id param is updated
1680				_( widgetFormControls ).each( function( widgetFormControl ) {
1681					widgetFormControl.params.sidebar_id = self.params.sidebar_id;
1682				} );
1683
1684				// Cleanup after widget removal
1685				_( removedWidgetIds ).each( function( removedWidgetId ) {
1686
1687					// Using setTimeout so that when moving a widget to another sidebar, the other sidebars_widgets settings get a chance to update
1688					setTimeout( function() {
1689						var removedControl, wasDraggedToAnotherSidebar, inactiveWidgets, removedIdBase,
1690							widget, isPresentInAnotherSidebar = false;
1691
1692						// Check if the widget is in another sidebar
1693						api.each( function( otherSetting ) {
1694							if ( otherSetting.id === self.setting.id || 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) || otherSetting.id === 'sidebars_widgets[wp_in