]> scripts.mit.edu Git - autoinstalls/wordpress.git/blobdiff - wp-admin/js/customize-controls.js
WordPress 4.1.1
[autoinstalls/wordpress.git] / wp-admin / js / customize-controls.js
index 3a05ad437f74a084943d1babaff828feca65792f..f43630a14a9ee6d243ba05d3e7444e2476e3fbe7 100644 (file)
@@ -1,7 +1,12 @@
+/* globals _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n */
 (function( exports, $ ){
-       var api = wp.customize;
+       var Container, focus, api = wp.customize;
 
        /**
+        * @class
+        * @augments wp.customize.Value
+        * @augments wp.customize.Class
+        *
         * @param options
         * - previewer - The Previewer instance to sync with.
         * - transport - The transport to use for previewing. Supports 'refresh' and 'postMessage'.
                }
        });
 
-       api.Control = api.Class.extend({
-               initialize: function( id, options ) {
-                       var control = this,
-                               nodes, radios, settings;
+       /**
+        * Utility function namespace
+        */
+       api.utils = {};
 
-                       this.params = {};
-                       $.extend( this, options || {} );
+       /**
+        * Watch all changes to Value properties, and bubble changes to parent Values instance
+        *
+        * @since 4.1.0
+        *
+        * @param {wp.customize.Class} instance
+        * @param {Array}              properties  The names of the Value instances to watch.
+        */
+       api.utils.bubbleChildValueChanges = function ( instance, properties ) {
+               $.each( properties, function ( i, key ) {
+                       instance[ key ].bind( function ( to, from ) {
+                               if ( instance.parent && to !== from ) {
+                                       instance.parent.trigger( 'change', instance );
+                               }
+                       } );
+               } );
+       };
 
-                       this.id = id;
-                       this.selector = '#customize-control-' + id.replace( /\]/g, '' ).replace( /\[/g, '-' );
-                       this.container = $( this.selector );
+       /**
+        * Expand a panel, section, or control and focus on the first focusable element.
+        *
+        * @since 4.1.0
+        *
+        * @param {Object}   [params]
+        * @param {Callback} [params.completeCallback]
+        */
+       focus = function ( params ) {
+               var construct, completeCallback, focus;
+               construct = this;
+               params = params || {};
+               focus = function () {
+                       construct.container.find( ':focusable:first' ).focus();
+                       construct.container[0].scrollIntoView( true );
+               };
+               if ( params.completeCallback ) {
+                       completeCallback = params.completeCallback;
+                       params.completeCallback = function () {
+                               focus();
+                               completeCallback();
+                       };
+               } else {
+                       params.completeCallback = focus;
+               }
+               if ( construct.expand ) {
+                       construct.expand( params );
+               } else {
+                       params.completeCallback();
+               }
+       };
 
-                       settings = $.map( this.params.settings, function( value ) {
-                               return value;
+       /**
+        * Stable sort for Panels, Sections, and Controls.
+        *
+        * If a.priority() === b.priority(), then sort by their respective params.instanceNumber.
+        *
+        * @since 4.1.0
+        *
+        * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} a
+        * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} b
+        * @returns {Number}
+        */
+       api.utils.prioritySort = function ( a, b ) {
+               if ( a.priority() === b.priority() && typeof a.params.instanceNumber === 'number' && typeof b.params.instanceNumber === 'number' ) {
+                       return a.params.instanceNumber - b.params.instanceNumber;
+               } else {
+                       return a.priority() - b.priority();
+               }
+       };
+
+       /**
+        * Return whether the supplied Event object is for a keydown event but not the Enter key.
+        *
+        * @since 4.1.0
+        *
+        * @param {jQuery.Event} event
+        * @returns {boolean}
+        */
+       api.utils.isKeydownButNotEnterEvent = function ( event ) {
+               return ( 'keydown' === event.type && 13 !== event.which );
+       };
+
+       /**
+        * Return whether the two lists of elements are the same and are in the same order.
+        *
+        * @since 4.1.0
+        *
+        * @param {Array|jQuery} listA
+        * @param {Array|jQuery} listB
+        * @returns {boolean}
+        */
+       api.utils.areElementListsEqual = function ( listA, listB ) {
+               var equal = (
+                       listA.length === listB.length && // if lists are different lengths, then naturally they are not equal
+                       -1 === _.indexOf( _.map( // are there any false values in the list returned by map?
+                               _.zip( listA, listB ), // pair up each element between the two lists
+                               function ( pair ) {
+                                       return $( pair[0] ).is( pair[1] ); // compare to see if each pair are equal
+                               }
+                       ), false ) // check for presence of false in map's return value
+               );
+               return equal;
+       };
+
+       /**
+        * Base class for Panel and Section.
+        *
+        * @since 4.1.0
+        *
+        * @class
+        * @augments wp.customize.Class
+        */
+       Container = api.Class.extend({
+               defaultActiveArguments: { duration: 'fast', completeCallback: $.noop },
+               defaultExpandedArguments: { duration: 'fast', completeCallback: $.noop },
+
+               /**
+                * @since 4.1.0
+                *
+                * @param {String} id
+                * @param {Object} options
+                */
+               initialize: function ( id, options ) {
+                       var container = this;
+                       container.id = id;
+                       container.params = {};
+                       $.extend( container, options || {} );
+                       container.container = $( container.params.content );
+
+                       container.deferred = {
+                               embedded: new $.Deferred()
+                       };
+                       container.priority = new api.Value();
+                       container.active = new api.Value();
+                       container.activeArgumentsQueue = [];
+                       container.expanded = new api.Value();
+                       container.expandedArgumentsQueue = [];
+
+                       container.active.bind( function ( active ) {
+                               var args = container.activeArgumentsQueue.shift();
+                               args = $.extend( {}, container.defaultActiveArguments, args );
+                               active = ( active && container.isContextuallyActive() );
+                               container.onChangeActive( active, args );
+                       });
+                       container.expanded.bind( function ( expanded ) {
+                               var args = container.expandedArgumentsQueue.shift();
+                               args = $.extend( {}, container.defaultExpandedArguments, args );
+                               container.onChangeExpanded( expanded, args );
                        });
 
-                       api.apply( api, settings.concat( function() {
-                               var key;
+                       container.attachEvents();
 
-                               control.settings = {};
-                               for ( key in control.params.settings ) {
-                                       control.settings[ key ] = api( control.params.settings[ key ] );
+                       api.utils.bubbleChildValueChanges( container, [ 'priority', 'active' ] );
+
+                       container.priority.set( isNaN( container.params.priority ) ? 100 : container.params.priority );
+                       container.active.set( container.params.active );
+                       container.expanded.set( false );
+               },
+
+               /**
+                * @since 4.1.0
+                *
+                * @abstract
+                */
+               ready: function() {},
+
+               /**
+                * Get the child models associated with this parent, sorting them by their priority Value.
+                *
+                * @since 4.1.0
+                *
+                * @param {String} parentType
+                * @param {String} childType
+                * @returns {Array}
+                */
+               _children: function ( parentType, childType ) {
+                       var parent = this,
+                               children = [];
+                       api[ childType ].each( function ( child ) {
+                               if ( child[ parentType ].get() === parent.id ) {
+                                       children.push( child );
                                }
+                       } );
+                       children.sort( api.utils.prioritySort );
+                       return children;
+               },
 
-                               control.setting = control.settings['default'] || null;
-                               control.ready();
-                       }) );
+               /**
+                * To override by subclass, to return whether the container has active children.
+                *
+                * @since 4.1.0
+                *
+                * @abstract
+                */
+               isContextuallyActive: function () {
+                       throw new Error( 'Container.isContextuallyActive() must be overridden in a subclass.' );
+               },
+
+               /**
+                * Handle changes to the active state.
+                *
+                * This does not change the active state, it merely handles the behavior
+                * for when it does change.
+                *
+                * To override by subclass, update the container's UI to reflect the provided active state.
+                *
+                * @since 4.1.0
+                *
+                * @param {Boolean} active
+                * @param {Object}  args
+                * @param {Object}  args.duration
+                * @param {Object}  args.completeCallback
+                */
+               onChangeActive: function ( active, args ) {
+                       var duration = ( 'resolved' === api.previewer.deferred.active.state() ? args.duration : 0 );
+                       if ( ! $.contains( document, this.container ) ) {
+                               // jQuery.fn.slideUp is not hiding an element if it is not in the DOM
+                               this.container.toggle( active );
+                               if ( args.completeCallback ) {
+                                       args.completeCallback();
+                               }
+                       } else if ( active ) {
+                               this.container.stop( true, true ).slideDown( duration, args.completeCallback );
+                       } else {
+                               this.container.stop( true, true ).slideUp( duration, args.completeCallback );
+                       }
+               },
+
+               /**
+                * @since 4.1.0
+                *
+                * @params {Boolean} active
+                * @param {Object}   [params]
+                * @returns {Boolean} false if state already applied
+                */
+               _toggleActive: function ( active, params ) {
+                       var self = this;
+                       params = params || {};
+                       if ( ( active && this.active.get() ) || ( ! active && ! this.active.get() ) ) {
+                               params.unchanged = true;
+                               self.onChangeActive( self.active.get(), params );
+                               return false;
+                       } else {
+                               params.unchanged = false;
+                               this.activeArgumentsQueue.push( params );
+                               this.active.set( active );
+                               return true;
+                       }
+               },
+
+               /**
+                * @param {Object} [params]
+                * @returns {Boolean} false if already active
+                */
+               activate: function ( params ) {
+                       return this._toggleActive( true, params );
+               },
+
+               /**
+                * @param {Object} [params]
+                * @returns {Boolean} false if already inactive
+                */
+               deactivate: function ( params ) {
+                       return this._toggleActive( false, params );
+               },
+
+               /**
+                * To override by subclass, update the container's UI to reflect the provided active state.
+                * @abstract
+                */
+               onChangeExpanded: function () {
+                       throw new Error( 'Must override with subclass.' );
+               },
+
+               /**
+                * @param {Boolean} expanded
+                * @param {Object} [params]
+                * @returns {Boolean} false if state already applied
+                */
+               _toggleExpanded: function ( expanded, params ) {
+                       var self = this;
+                       params = params || {};
+                       if ( ( expanded && this.expanded.get() ) || ( ! expanded && ! this.expanded.get() ) ) {
+                               params.unchanged = true;
+                               self.onChangeExpanded( self.expanded.get(), params );
+                               return false;
+                       } else {
+                               params.unchanged = false;
+                               this.expandedArgumentsQueue.push( params );
+                               this.expanded.set( expanded );
+                               return true;
+                       }
+               },
+
+               /**
+                * @param {Object} [params]
+                * @returns {Boolean} false if already expanded
+                */
+               expand: function ( params ) {
+                       return this._toggleExpanded( true, params );
+               },
+
+               /**
+                * @param {Object} [params]
+                * @returns {Boolean} false if already collapsed
+                */
+               collapse: function ( params ) {
+                       return this._toggleExpanded( false, params );
+               },
+
+               /**
+                * Bring the container into view and then expand this and bring it into view
+                * @param {Object} [params]
+                */
+               focus: focus
+       });
+
+       /**
+        * @since 4.1.0
+        *
+        * @class
+        * @augments wp.customize.Class
+        */
+       api.Section = Container.extend({
+
+               /**
+                * @since 4.1.0
+                *
+                * @param {String} id
+                * @param {Array}  options
+                */
+               initialize: function ( id, options ) {
+                       var section = this;
+                       Container.prototype.initialize.call( section, id, options );
+
+                       section.id = id;
+                       section.panel = new api.Value();
+                       section.panel.bind( function ( id ) {
+                               $( section.container ).toggleClass( 'control-subsection', !! id );
+                       });
+                       section.panel.set( section.params.panel || '' );
+                       api.utils.bubbleChildValueChanges( section, [ 'panel' ] );
+
+                       section.embed();
+                       section.deferred.embedded.done( function () {
+                               section.ready();
+                       });
+               },
+
+               /**
+                * Embed the container in the DOM when any parent panel is ready.
+                *
+                * @since 4.1.0
+                */
+               embed: function () {
+                       var section = this, inject;
+
+                       // Watch for changes to the panel state
+                       inject = function ( panelId ) {
+                               var parentContainer;
+                               if ( panelId ) {
+                                       // The panel has been supplied, so wait until the panel object is registered
+                                       api.panel( panelId, function ( panel ) {
+                                               // The panel has been registered, wait for it to become ready/initialized
+                                               panel.deferred.embedded.done( function () {
+                                                       parentContainer = panel.container.find( 'ul:first' );
+                                                       if ( ! section.container.parent().is( parentContainer ) ) {
+                                                               parentContainer.append( section.container );
+                                                       }
+                                                       section.deferred.embedded.resolve();
+                                               });
+                                       } );
+                               } else {
+                                       // There is no panel, so embed the section in the root of the customizer
+                                       parentContainer = $( '#customize-theme-controls' ).children( 'ul' ); // @todo This should be defined elsewhere, and to be configurable
+                                       if ( ! section.container.parent().is( parentContainer ) ) {
+                                               parentContainer.append( section.container );
+                                       }
+                                       section.deferred.embedded.resolve();
+                               }
+                       };
+                       section.panel.bind( inject );
+                       inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one
+               },
+
+               /**
+                * Add behaviors for the accordion section.
+                *
+                * @since 4.1.0
+                */
+               attachEvents: function () {
+                       var section = this;
+
+                       // Expand/Collapse accordion sections on click.
+                       section.container.find( '.accordion-section-title' ).on( 'click keydown', function( event ) {
+                               if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                                       return;
+                               }
+                               event.preventDefault(); // Keep this AFTER the key filter above
+
+                               if ( section.expanded() ) {
+                                       section.collapse();
+                               } else {
+                                       section.expand();
+                               }
+                       });
+               },
+
+               /**
+                * Return whether this section has any active controls.
+                *
+                * @since 4.1.0
+                *
+                * @returns {Boolean}
+                */
+               isContextuallyActive: function () {
+                       var section = this,
+                               controls = section.controls(),
+                               activeCount = 0;
+                       _( controls ).each( function ( control ) {
+                               if ( control.active() ) {
+                                       activeCount += 1;
+                               }
+                       } );
+                       return ( activeCount !== 0 );
+               },
+
+               /**
+                * Get the controls that are associated with this section, sorted by their priority Value.
+                *
+                * @since 4.1.0
+                *
+                * @returns {Array}
+                */
+               controls: function () {
+                       return this._children( 'section', 'control' );
+               },
+
+               /**
+                * Update UI to reflect expanded state.
+                *
+                * @since 4.1.0
+                *
+                * @param {Boolean} expanded
+                * @param {Object}  args
+                */
+               onChangeExpanded: function ( expanded, args ) {
+                       var section = this,
+                               content = section.container.find( '.accordion-section-content' ),
+                               expand;
+
+                       if ( expanded ) {
+
+                               if ( args.unchanged ) {
+                                       expand = args.completeCallback;
+                               } else {
+                                       expand = function () {
+                                               content.stop().slideDown( args.duration, args.completeCallback );
+                                               section.container.addClass( 'open' );
+                                       };
+                               }
+
+                               if ( ! args.allowMultiple ) {
+                                       api.section.each( function ( otherSection ) {
+                                               if ( otherSection !== section ) {
+                                                       otherSection.collapse( { duration: args.duration } );
+                                               }
+                                       });
+                               }
+
+                               if ( section.panel() ) {
+                                       api.panel( section.panel() ).expand({
+                                               duration: args.duration,
+                                               completeCallback: expand
+                                       });
+                               } else {
+                                       expand();
+                               }
+
+                       } else {
+                               section.container.removeClass( 'open' );
+                               content.slideUp( args.duration, args.completeCallback );
+                       }
+               }
+       });
+
+       /**
+        * @since 4.1.0
+        *
+        * @class
+        * @augments wp.customize.Class
+        */
+       api.Panel = Container.extend({
+               /**
+                * @since 4.1.0
+                *
+                * @param  {String} id
+                * @param  {Object} options
+                */
+               initialize: function ( id, options ) {
+                       var panel = this;
+                       Container.prototype.initialize.call( panel, id, options );
+                       panel.embed();
+                       panel.deferred.embedded.done( function () {
+                               panel.ready();
+                       });
+               },
+
+               /**
+                * Embed the container in the DOM when any parent panel is ready.
+                *
+                * @since 4.1.0
+                */
+               embed: function () {
+                       var panel = this,
+                               parentContainer = $( '#customize-theme-controls > ul' ); // @todo This should be defined elsewhere, and to be configurable
+
+                       if ( ! panel.container.parent().is( parentContainer ) ) {
+                               parentContainer.append( panel.container );
+                       }
+                       panel.deferred.embedded.resolve();
+               },
+
+               /**
+                * @since 4.1.0
+                */
+               attachEvents: function () {
+                       var meta, panel = this;
+
+                       // Expand/Collapse accordion sections on click.
+                       panel.container.find( '.accordion-section-title' ).on( 'click keydown', function( event ) {
+                               if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                                       return;
+                               }
+                               event.preventDefault(); // Keep this AFTER the key filter above
+
+                               if ( ! panel.expanded() ) {
+                                       panel.expand();
+                               }
+                       });
+
+                       meta = panel.container.find( '.panel-meta:first' );
+
+                       meta.find( '> .accordion-section-title' ).on( 'click keydown', function( event ) {
+                               if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                                       return;
+                               }
+                               event.preventDefault(); // Keep this AFTER the key filter above
+
+                               if ( meta.hasClass( 'cannot-expand' ) ) {
+                                       return;
+                               }
+
+                               var content = meta.find( '.accordion-section-content:first' );
+                               if ( meta.hasClass( 'open' ) ) {
+                                       meta.toggleClass( 'open' );
+                                       content.slideUp( panel.defaultExpandedArguments.duration );
+                               } else {
+                                       content.slideDown( panel.defaultExpandedArguments.duration );
+                                       meta.toggleClass( 'open' );
+                               }
+                       });
+
+               },
+
+               /**
+                * Get the sections that are associated with this panel, sorted by their priority Value.
+                *
+                * @since 4.1.0
+                *
+                * @returns {Array}
+                */
+               sections: function () {
+                       return this._children( 'panel', 'section' );
+               },
+
+               /**
+                * Return whether this panel has any active sections.
+                *
+                * @since 4.1.0
+                *
+                * @returns {boolean}
+                */
+               isContextuallyActive: function () {
+                       var panel = this,
+                               sections = panel.sections(),
+                               activeCount = 0;
+                       _( sections ).each( function ( section ) {
+                               if ( section.active() && section.isContextuallyActive() ) {
+                                       activeCount += 1;
+                               }
+                       } );
+                       return ( activeCount !== 0 );
+               },
+
+               /**
+                * Update UI to reflect expanded state
+                *
+                * @since 4.1.0
+                *
+                * @param {Boolean}  expanded
+                * @param {Object}   args
+                * @param {Boolean}  args.unchanged
+                * @param {Callback} args.completeCallback
+                */
+               onChangeExpanded: function ( expanded, args ) {
+
+                       // Immediately call the complete callback if there were no changes
+                       if ( args.unchanged ) {
+                               if ( args.completeCallback ) {
+                                       args.completeCallback();
+                               }
+                               return;
+                       }
+
+                       // Note: there is a second argument 'args' passed
+                       var position, scroll,
+                               panel = this,
+                               section = panel.container.closest( '.accordion-section' ),
+                               overlay = section.closest( '.wp-full-overlay' ),
+                               container = section.closest( '.accordion-container' ),
+                               siblings = container.find( '.open' ),
+                               topPanel = overlay.find( '#customize-theme-controls > ul > .accordion-section > .accordion-section-title' ).add( '#customize-info > .accordion-section-title' ),
+                               backBtn = overlay.find( '.control-panel-back' ),
+                               panelTitle = section.find( '.accordion-section-title' ).first(),
+                               content = section.find( '.control-panel-content' );
+
+                       if ( expanded ) {
+
+                               // Collapse any sibling sections/panels
+                               api.section.each( function ( section ) {
+                                       if ( ! section.panel() ) {
+                                               section.collapse( { duration: 0 } );
+                                       }
+                               });
+                               api.panel.each( function ( otherPanel ) {
+                                       if ( panel !== otherPanel ) {
+                                               otherPanel.collapse( { duration: 0 } );
+                                       }
+                               });
+
+                               content.show( 0, function() {
+                                       position = content.offset().top;
+                                       scroll = container.scrollTop();
+                                       content.css( 'margin-top', ( 45 - position - scroll ) );
+                                       section.addClass( 'current-panel' );
+                                       overlay.addClass( 'in-sub-panel' );
+                                       container.scrollTop( 0 );
+                                       if ( args.completeCallback ) {
+                                               args.completeCallback();
+                                       }
+                               } );
+                               topPanel.attr( 'tabindex', '-1' );
+                               backBtn.attr( 'tabindex', '0' );
+                               backBtn.focus();
+                       } else {
+                               siblings.removeClass( 'open' );
+                               section.removeClass( 'current-panel' );
+                               overlay.removeClass( 'in-sub-panel' );
+                               content.delay( 180 ).hide( 0, function() {
+                                       content.css( 'margin-top', 'inherit' ); // Reset
+                                       if ( args.completeCallback ) {
+                                               args.completeCallback();
+                                       }
+                               } );
+                               topPanel.attr( 'tabindex', '0' );
+                               backBtn.attr( 'tabindex', '-1' );
+                               panelTitle.focus();
+                               container.scrollTop( 0 );
+                       }
+               }
+       });
+
+       /**
+        * A Customizer Control.
+        *
+        * A control provides a UI element that allows a user to modify a Customizer Setting.
+        *
+        * @see PHP class WP_Customize_Control.
+        *
+        * @class
+        * @augments wp.customize.Class
+        *
+        * @param {string} id                            Unique identifier for the control instance.
+        * @param {object} options                       Options hash for the control instance.
+        * @param {object} options.params
+        * @param {object} options.params.type           Type of control (e.g. text, radio, dropdown-pages, etc.)
+        * @param {string} options.params.content        The HTML content for the control.
+        * @param {string} options.params.priority       Order of priority to show the control within the section.
+        * @param {string} options.params.active
+        * @param {string} options.params.section
+        * @param {string} options.params.label
+        * @param {string} options.params.description
+        * @param {string} options.params.instanceNumber Order in which this instance was created in relation to other instances.
+        */
+       api.Control = api.Class.extend({
+               defaultActiveArguments: { duration: 'fast', completeCallback: $.noop },
+
+               initialize: function( id, options ) {
+                       var control = this,
+                               nodes, radios, settings;
+
+                       control.params = {};
+                       $.extend( control, options || {} );
+                       control.id = id;
+                       control.selector = '#customize-control-' + id.replace( /\]/g, '' ).replace( /\[/g, '-' );
+                       control.templateSelector = 'customize-control-' + control.params.type + '-content';
+                       control.container = control.params.content ? $( control.params.content ) : $( control.selector );
+
+                       control.deferred = {
+                               embedded: new $.Deferred()
+                       };
+                       control.section = new api.Value();
+                       control.priority = new api.Value();
+                       control.active = new api.Value();
+                       control.activeArgumentsQueue = [];
 
                        control.elements = [];
 
-                       nodes  = this.container.find('[data-customize-setting-link]');
+                       nodes  = control.container.find('[data-customize-setting-link]');
                        radios = {};
 
                        nodes.each( function() {
-                               var node = $(this),
+                               var node = $( this ),
                                        name;
 
-                               if ( node.is(':radio') ) {
-                                       name = node.prop('name');
-                                       if ( radios[ name ] )
+                               if ( node.is( ':radio' ) ) {
+                                       name = node.prop( 'name' );
+                                       if ( radios[ name ] ) {
                                                return;
+                                       }
 
                                        radios[ name ] = true;
                                        node = nodes.filter( '[name="' + name + '"]' );
                                }
 
-                               api( node.data('customizeSettingLink'), function( setting ) {
+                               api( node.data( 'customizeSettingLink' ), function( setting ) {
                                        var element = new api.Element( node );
                                        control.elements.push( element );
                                        element.sync( setting );
                                        element.set( setting() );
                                });
                        });
+
+                       control.active.bind( function ( active ) {
+                               var args = control.activeArgumentsQueue.shift();
+                               args = $.extend( {}, control.defaultActiveArguments, args );
+                               control.onChangeActive( active, args );
+                       } );
+
+                       control.section.set( control.params.section );
+                       control.priority.set( isNaN( control.params.priority ) ? 10 : control.params.priority );
+                       control.active.set( control.params.active );
+
+                       api.utils.bubbleChildValueChanges( control, [ 'section', 'priority', 'active' ] );
+
+                       // Associate this control with its settings when they are created
+                       settings = $.map( control.params.settings, function( value ) {
+                               return value;
+                       });
+                       api.apply( api, settings.concat( function () {
+                               var key;
+
+                               control.settings = {};
+                               for ( key in control.params.settings ) {
+                                       control.settings[ key ] = api( control.params.settings[ key ] );
+                               }
+
+                               control.setting = control.settings['default'] || null;
+
+                               control.embed();
+                       }) );
+
+                       control.deferred.embedded.done( function () {
+                               control.ready();
+                       });
+               },
+
+               /**
+                * Embed the control into the page.
+                */
+               embed: function () {
+                       var control = this,
+                               inject;
+
+                       // Watch for changes to the section state
+                       inject = function ( sectionId ) {
+                               var parentContainer;
+                               if ( ! sectionId ) { // @todo allow a control to be embedded without a section, for instance a control embedded in the frontend
+                                       return;
+                               }
+                               // Wait for the section to be registered
+                               api.section( sectionId, function ( section ) {
+                                       // Wait for the section to be ready/initialized
+                                       section.deferred.embedded.done( function () {
+                                               parentContainer = section.container.find( 'ul:first' );
+                                               if ( ! control.container.parent().is( parentContainer ) ) {
+                                                       parentContainer.append( control.container );
+                                                       control.renderContent();
+                                               }
+                                               control.deferred.embedded.resolve();
+                                       });
+                               });
+                       };
+                       control.section.bind( inject );
+                       inject( control.section.get() );
+               },
+
+               /**
+                * Triggered when the control's markup has been injected into the DOM.
+                *
+                * @abstract
+                */
+               ready: function() {},
+
+               /**
+                * Normal controls do not expand, so just expand its parent
+                *
+                * @param {Object} [params]
+                */
+               expand: function ( params ) {
+                       api.section( this.section() ).expand( params );
+               },
+
+               /**
+                * Bring the containing section and panel into view and then
+                * this control into view, focusing on the first input.
+                */
+               focus: focus,
+
+               /**
+                * Update UI in response to a change in the control's active state.
+                * This does not change the active state, it merely handles the behavior
+                * for when it does change.
+                *
+                * @since 4.1.0
+                *
+                * @param {Boolean}  active
+                * @param {Object}   args
+                * @param {Number}   args.duration
+                * @param {Callback} args.completeCallback
+                */
+               onChangeActive: function ( active, args ) {
+                       if ( ! $.contains( document, this.container ) ) {
+                               // jQuery.fn.slideUp is not hiding an element if it is not in the DOM
+                               this.container.toggle( active );
+                               if ( args.completeCallback ) {
+                                       args.completeCallback();
+                               }
+                       } else if ( active ) {
+                               this.container.slideDown( args.duration, args.completeCallback );
+                       } else {
+                               this.container.slideUp( args.duration, args.completeCallback );
+                       }
+               },
+
+               /**
+                * @deprecated 4.1.0 Use this.onChangeActive() instead.
+                */
+               toggle: function ( active ) {
+                       return this.onChangeActive( active, this.defaultActiveArguments );
                },
 
-               ready: function() {},
+               /**
+                * Shorthand way to enable the active state.
+                *
+                * @since 4.1.0
+                *
+                * @param {Object} [params]
+                * @returns {Boolean} false if already active
+                */
+               activate: Container.prototype.activate,
+
+               /**
+                * Shorthand way to disable the active state.
+                *
+                * @since 4.1.0
+                *
+                * @param {Object} [params]
+                * @returns {Boolean} false if already inactive
+                */
+               deactivate: Container.prototype.deactivate,
+
+               /**
+                * Re-use _toggleActive from Container class.
+                *
+                * @access private
+                */
+               _toggleActive: Container.prototype._toggleActive,
 
                dropdownInit: function() {
                        var control      = this,
 
                        // Support the .dropdown class to open/close complex elements
                        this.container.on( 'click keydown', '.dropdown', function( event ) {
-                               if ( event.type === 'keydown' &&  13 !== event.which ) // enter
+                               if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
                                        return;
+                               }
 
                                event.preventDefault();
 
 
                        this.setting.bind( update );
                        update( this.setting() );
+               },
+
+               /**
+                * Render the control from its JS template, if it exists.
+                *
+                * The control's container must already exist in the DOM.
+                *
+                * @since 4.1.0
+                */
+               renderContent: function () {
+                       var template,
+                               control = this;
+
+                       // Replace the container element's content with the control.
+                       if ( 0 !== $( '#tmpl-' + control.templateSelector ).length ) {
+                               template = wp.template( control.templateSelector );
+                               if ( template && control.container ) {
+                                       control.container.html( template( control.params ) );
+                               }
+                       }
                }
        });
 
+       /**
+        * A colorpicker control.
+        *
+        * @class
+        * @augments wp.customize.Control
+        * @augments wp.customize.Class
+        */
        api.ColorControl = api.Control.extend({
                ready: function() {
                        var control = this,
                                        control.setting.set( false );
                                }
                        });
+
+                       this.setting.bind( function ( value ) {
+                               picker.val( value );
+                               picker.wpColorPicker( 'color', value );
+                       });
                }
        });
 
+       /**
+        * An upload control, which utilizes the media modal.
+        *
+        * @class
+        * @augments wp.customize.Control
+        * @augments wp.customize.Class
+        */
        api.UploadControl = api.Control.extend({
+
+               /**
+                * When the control's DOM structure is ready,
+                * set up internal event bindings.
+                */
                ready: function() {
                        var control = this;
+                       // Shortcut so that we don't have to use _.bind every time we add a callback.
+                       _.bindAll( control, 'restoreDefault', 'removeFile', 'openFrame', 'select' );
 
-                       this.params.removed = this.params.removed || '';
+                       // Bind events, with delegation to facilitate re-rendering.
+                       control.container.on( 'click keydown', '.upload-button', control.openFrame );
+                       control.container.on( 'click keydown', '.thumbnail-image img', control.openFrame );
+                       control.container.on( 'click keydown', '.default-button', control.restoreDefault );
+                       control.container.on( 'click keydown', '.remove-button', control.removeFile );
 
-                       this.success = $.proxy( this.success, this );
+                       // Re-render whenever the control's setting changes.
+                       control.setting.bind( function () { control.renderContent(); } );
+               },
+
+               /**
+                * Open the media modal.
+                */
+               openFrame: function( event ) {
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                               return;
+                       }
 
-                       this.uploader = $.extend({
-                               container: this.container,
-                               browser:   this.container.find('.upload'),
-                               dropzone:  this.container.find('.upload-dropzone'),
-                               success:   this.success,
-                               plupload:  {},
-                               params:    {}
-                       }, this.uploader || {} );
+                       event.preventDefault();
 
-                       if ( control.params.extensions ) {
-                               control.uploader.plupload.filters = [{
-                                       title:      api.l10n.allowedFiles,
-                                       extensions: control.params.extensions
-                               }];
+                       if ( ! this.frame ) {
+                               this.initFrame();
                        }
 
-                       if ( control.params.context )
-                               control.uploader.params['post_data[context]'] = this.params.context;
+                       this.frame.open();
+               },
 
-                       if ( api.settings.theme.stylesheet )
-                               control.uploader.params['post_data[theme]'] = api.settings.theme.stylesheet;
+               /**
+                * Create a media modal select frame, and store it so the instance can be reused when needed.
+                */
+               initFrame: function() {
+                       this.frame = wp.media({
+                               button: {
+                                       text: this.params.button_labels.frame_button
+                               },
+                               states: [
+                                       new wp.media.controller.Library({
+                                               title:     this.params.button_labels.frame_title,
+                                               library:   wp.media.query({ type: this.params.mime_type }),
+                                               multiple:  false,
+                                               date:      false
+                                       })
+                               ]
+                       });
 
-                       this.uploader = new wp.Uploader( this.uploader );
+                       // When a file is selected, run a callback.
+                       this.frame.on( 'select', this.select );
+               },
 
-                       this.remover = this.container.find('.remove');
-                       this.remover.on( 'click keydown', function( event ) {
-                               if ( event.type === 'keydown' &&  13 !== event.which ) // enter
-                                       return;
+               /**
+                * Callback handler for when an attachment is selected in the media modal.
+                * Gets the selected image information, and sets it within the control.
+                */
+               select: function() {
+                       // Get the attachment from the modal frame.
+                       var attachment = this.frame.state().get( 'selection' ).first().toJSON();
 
-                               control.setting.set( control.params.removed );
-                               event.preventDefault();
-                       });
+                       this.params.attachment = attachment;
 
-                       this.removerVisibility = $.proxy( this.removerVisibility, this );
-                       this.setting.bind( this.removerVisibility );
-                       this.removerVisibility( this.setting.get() );
+                       // Set the Customizer setting; the callback takes care of rendering.
+                       this.setting( attachment.url );
                },
-               success: function( attachment ) {
-                       this.setting.set( attachment.get('url') );
+
+               /**
+                * Reset the setting to the default value.
+                */
+               restoreDefault: function( event ) {
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                               return;
+                       }
+                       event.preventDefault();
+
+                       this.params.attachment = this.params.defaultAttachment;
+                       this.setting( this.params.defaultAttachment.url );
+               },
+
+               /**
+                * Called when the "Remove" link is clicked. Empties the setting.
+                *
+                * @param {object} event jQuery Event object
+                */
+               removeFile: function( event ) {
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                               return;
+                       }
+                       event.preventDefault();
+
+                       this.params.attachment = {};
+                       this.setting( '' );
+                       this.renderContent(); // Not bound to setting change when emptying.
                },
-               removerVisibility: function( to ) {
-                       this.remover.toggle( to != this.params.removed );
-               }
+
+               // @deprecated
+               success: function() {},
+
+               // @deprecated
+               removerVisibility: function() {}
        });
 
+       /**
+        * A control for uploading images.
+        *
+        * This control no longer needs to do anything more
+        * than what the upload control does in JS.
+        *
+        * @class
+        * @augments wp.customize.UploadControl
+        * @augments wp.customize.Control
+        * @augments wp.customize.Class
+        */
        api.ImageControl = api.UploadControl.extend({
+               // @deprecated
+               thumbnailSrc: function() {}
+       });
+
+       /**
+        * A control for uploading background images.
+        *
+        * @class
+        * @augments wp.customize.UploadControl
+        * @augments wp.customize.Control
+        * @augments wp.customize.Class
+        */
+       api.BackgroundControl = api.UploadControl.extend({
+
+               /**
+                * When the control's DOM structure is ready,
+                * set up internal event bindings.
+                */
                ready: function() {
-                       var control = this,
-                               panels;
+                       api.UploadControl.prototype.ready.apply( this, arguments );
+               },
 
-                       this.uploader = {
-                               init: function() {
-                                       var fallback, button;
+               /**
+                * Callback handler for when an attachment is selected in the media modal.
+                * Does an additional AJAX request for setting the background context.
+                */
+               select: function() {
+                       api.UploadControl.prototype.select.apply( this, arguments );
+
+                       wp.ajax.post( 'custom-background-add', {
+                               nonce: _wpCustomizeBackground.nonces.add,
+                               wp_customize: 'on',
+                               theme: api.settings.theme.stylesheet,
+                               attachment_id: this.params.attachment.id
+                       } );
+               }
+       });
 
-                                       if ( this.supports.dragdrop )
-                                               return;
+       /**
+        * @class
+        * @augments wp.customize.Control
+        * @augments wp.customize.Class
+        */
+       api.HeaderControl = api.Control.extend({
+               ready: function() {
+                       this.btnRemove        = $('#customize-control-header_image .actions .remove');
+                       this.btnNew           = $('#customize-control-header_image .actions .new');
 
-                                       // Maintain references while wrapping the fallback button.
-                                       fallback = control.container.find( '.upload-fallback' );
-                                       button   = fallback.children().detach();
+                       _.bindAll(this, 'openMedia', 'removeImage');
 
-                                       this.browser.detach().empty().append( button );
-                                       fallback.append( this.browser ).show();
-                               }
-                       };
+                       this.btnNew.on( 'click', this.openMedia );
+                       this.btnRemove.on( 'click', this.removeImage );
 
-                       api.UploadControl.prototype.ready.call( this );
+                       api.HeaderTool.currentHeader = new api.HeaderTool.ImageModel();
 
-                       this.thumbnail    = this.container.find('.preview-thumbnail img');
-                       this.thumbnailSrc = $.proxy( this.thumbnailSrc, this );
-                       this.setting.bind( this.thumbnailSrc );
+                       new api.HeaderTool.CurrentView({
+                               model: api.HeaderTool.currentHeader,
+                               el: '#customize-control-header_image .current .container'
+                       });
 
-                       this.library = this.container.find('.library');
+                       new api.HeaderTool.ChoiceListView({
+                               collection: api.HeaderTool.UploadsList = new api.HeaderTool.ChoiceList(),
+                               el: '#customize-control-header_image .choices .uploaded .list'
+                       });
 
-                       // Generate tab objects
-                       this.tabs = {};
-                       panels    = this.library.find('.library-content');
+                       new api.HeaderTool.ChoiceListView({
+                               collection: api.HeaderTool.DefaultsList = new api.HeaderTool.DefaultsList(),
+                               el: '#customize-control-header_image .choices .default .list'
+                       });
 
-                       this.library.children('ul').children('li').each( function() {
-                               var link  = $(this),
-                                       id    = link.data('customizeTab'),
-                                       panel = panels.filter('[data-customize-tab="' + id + '"]');
+                       api.HeaderTool.combinedList = api.HeaderTool.CombinedList = new api.HeaderTool.CombinedList([
+                               api.HeaderTool.UploadsList,
+                               api.HeaderTool.DefaultsList
+                       ]);
+               },
 
-                               control.tabs[ id ] = {
-                                       both:  link.add( panel ),
-                                       link:  link,
-                                       panel: panel
-                               };
+               /**
+                * Returns a set of options, computed from the attached image data and
+                * theme-specific data, to be fed to the imgAreaSelect plugin in
+                * wp.media.view.Cropper.
+                *
+                * @param {wp.media.model.Attachment} attachment
+                * @param {wp.media.controller.Cropper} controller
+                * @returns {Object} Options
+                */
+               calculateImageSelectOptions: function(attachment, controller) {
+                       var xInit = parseInt(_wpCustomizeHeader.data.width, 10),
+                               yInit = parseInt(_wpCustomizeHeader.data.height, 10),
+                               flexWidth = !! parseInt(_wpCustomizeHeader.data['flex-width'], 10),
+                               flexHeight = !! parseInt(_wpCustomizeHeader.data['flex-height'], 10),
+                               ratio, xImg, yImg, realHeight, realWidth,
+                               imgSelectOptions;
+
+                       realWidth = attachment.get('width');
+                       realHeight = attachment.get('height');
+
+                       this.headerImage = new api.HeaderTool.ImageModel();
+                       this.headerImage.set({
+                               themeWidth: xInit,
+                               themeHeight: yInit,
+                               themeFlexWidth: flexWidth,
+                               themeFlexHeight: flexHeight,
+                               imageWidth: realWidth,
+                               imageHeight: realHeight
                        });
 
-                       // Bind tab switch events
-                       this.library.children('ul').on( 'click keydown', 'li', function( event ) {
-                               if ( event.type === 'keydown' &&  13 !== event.which ) // enter
-                                       return;
+                       controller.set( 'canSkipCrop', ! this.headerImage.shouldBeCropped() );
 
-                               var id  = $(this).data('customizeTab'),
-                                       tab = control.tabs[ id ];
+                       ratio = xInit / yInit;
+                       xImg = realWidth;
+                       yImg = realHeight;
 
-                               event.preventDefault();
+                       if ( xImg / yImg > ratio ) {
+                               yInit = yImg;
+                               xInit = yInit * ratio;
+                       } else {
+                               xInit = xImg;
+                               yInit = xInit / ratio;
+                       }
 
-                               if ( tab.link.hasClass('library-selected') )
-                                       return;
+                       imgSelectOptions = {
+                               handles: true,
+                               keys: true,
+                               instance: true,
+                               persistent: true,
+                               imageWidth: realWidth,
+                               imageHeight: realHeight,
+                               x1: 0,
+                               y1: 0,
+                               x2: xInit,
+                               y2: yInit
+                       };
 
-                               control.selected.both.removeClass('library-selected');
-                               control.selected = tab;
-                               control.selected.both.addClass('library-selected');
-                       });
+                       if (flexHeight === false && flexWidth === false) {
+                               imgSelectOptions.aspectRatio = xInit + ':' + yInit;
+                       }
+                       if (flexHeight === false ) {
+                               imgSelectOptions.maxHeight = yInit;
+                       }
+                       if (flexWidth === false ) {
+                               imgSelectOptions.maxWidth = xInit;
+                       }
 
-                       // Bind events to switch image urls.
-                       this.library.on( 'click keydown', 'a', function( event ) {
-                               if ( event.type === 'keydown' && 13 !== event.which ) // enter
-                                       return;
+                       return imgSelectOptions;
+               },
+
+               /**
+                * Sets up and opens the Media Manager in order to select an image.
+                * Depending on both the size of the image and the properties of the
+                * current theme, a cropping step after selection may be required or
+                * skippable.
+                *
+                * @param {event} event
+                */
+               openMedia: function(event) {
+                       var l10n = _wpMediaViewsL10n;
 
-                               var value = $(this).data('customizeImageValue');
+                       event.preventDefault();
 
-                               if ( value ) {
-                                       control.setting.set( value );
-                                       event.preventDefault();
-                               }
+                       this.frame = wp.media({
+                               button: {
+                                       text: l10n.selectAndCrop,
+                                       close: false
+                               },
+                               states: [
+                                       new wp.media.controller.Library({
+                                               title:     l10n.chooseImage,
+                                               library:   wp.media.query({ type: 'image' }),
+                                               multiple:  false,
+                                               date:      false,
+                                               priority:  20,
+                                               suggestedWidth: _wpCustomizeHeader.data.width,
+                                               suggestedHeight: _wpCustomizeHeader.data.height
+                                       }),
+                                       new wp.media.controller.Cropper({
+                                               imgSelectOptions: this.calculateImageSelectOptions
+                                       })
+                               ]
                        });
 
-                       if ( this.tabs.uploaded ) {
-                               this.tabs.uploaded.target = this.library.find('.uploaded-target');
-                               if ( ! this.tabs.uploaded.panel.find('.thumbnail').length )
-                                       this.tabs.uploaded.both.addClass('hidden');
-                       }
+                       this.frame.on('select', this.onSelect, this);
+                       this.frame.on('cropped', this.onCropped, this);
+                       this.frame.on('skippedcrop', this.onSkippedCrop, this);
+
+                       this.frame.open();
+               },
 
-                       // Select a tab
-                       panels.each( function() {
-                               var tab = control.tabs[ $(this).data('customizeTab') ];
+               /**
+                * After an image is selected in the media modal,
+                * switch to the cropper state.
+                */
+               onSelect: function() {
+                       this.frame.setState('cropper');
+               },
 
-                               // Select the first visible tab.
-                               if ( ! tab.link.hasClass('hidden') ) {
-                                       control.selected = tab;
-                                       tab.both.addClass('library-selected');
-                                       return false;
-                               }
-                       });
+               /**
+                * After the image has been cropped, apply the cropped image data to the setting.
+                *
+                * @param {object} croppedImage Cropped attachment data.
+                */
+               onCropped: function(croppedImage) {
+                       var url = croppedImage.post_content,
+                               attachmentId = croppedImage.attachment_id,
+                               w = croppedImage.width,
+                               h = croppedImage.height;
+                       this.setImageFromURL(url, attachmentId, w, h);
+               },
 
-                       this.dropdownInit();
+               /**
+                * If cropping was skipped, apply the image data directly to the setting.
+                *
+                * @param {object} selection
+                */
+               onSkippedCrop: function(selection) {
+                       var url = selection.get('url'),
+                               w = selection.get('width'),
+                               h = selection.get('height');
+                       this.setImageFromURL(url, selection.id, w, h);
                },
-               success: function( attachment ) {
-                       api.UploadControl.prototype.success.call( this, attachment );
 
-                       // Add the uploaded image to the uploaded tab.
-                       if ( this.tabs.uploaded && this.tabs.uploaded.target.length ) {
-                               this.tabs.uploaded.both.removeClass('hidden');
+               /**
+                * Creates a new wp.customize.HeaderTool.ImageModel from provided
+                * header image data and inserts it into the user-uploaded headers
+                * collection.
+                *
+                * @param {String} url
+                * @param {Number} attachmentId
+                * @param {Number} width
+                * @param {Number} height
+                */
+               setImageFromURL: function(url, attachmentId, width, height) {
+                       var choice, data = {};
+
+                       data.url = url;
+                       data.thumbnail_url = url;
+                       data.timestamp = _.now();
+
+                       if (attachmentId) {
+                               data.attachment_id = attachmentId;
+                       }
+
+                       if (width) {
+                               data.width = width;
+                       }
 
-                               // @todo: Do NOT store this on the attachment model. That is bad.
-                               attachment.element = $( '<a href="#" class="thumbnail"></a>' )
-                                       .data( 'customizeImageValue', attachment.get('url') )
-                                       .append( '<img src="' +  attachment.get('url')+ '" />' )
-                                       .appendTo( this.tabs.uploaded.target );
+                       if (height) {
+                               data.height = height;
                        }
+
+                       choice = new api.HeaderTool.ImageModel({
+                               header: data,
+                               choice: url.split('/').pop()
+                       });
+                       api.HeaderTool.UploadsList.add(choice);
+                       api.HeaderTool.currentHeader.set(choice.toJSON());
+                       choice.save();
+                       choice.importImage();
                },
-               thumbnailSrc: function( to ) {
-                       if ( /^(https?:)?\/\//.test( to ) )
-                               this.thumbnail.prop( 'src', to ).show();
-                       else
-                               this.thumbnail.hide();
+
+               /**
+                * Triggers the necessary events to deselect an image which was set as
+                * the currently selected one.
+                */
+               removeImage: function() {
+                       api.HeaderTool.currentHeader.trigger('hide');
+                       api.HeaderTool.CombinedList.trigger('control:removeImage');
                }
+
        });
 
        // Change objects contained within the main customize object to Settings.
        api.defaultConstructor = api.Setting;
 
-       // Create the collection of Control objects.
+       // Create the collections for Controls, Sections and Panels.
        api.control = new api.Values({ defaultConstructor: api.Control });
+       api.section = new api.Values({ defaultConstructor: api.Section });
+       api.panel = new api.Values({ defaultConstructor: api.Panel });
 
+       /**
+        * @class
+        * @augments wp.customize.Messenger
+        * @augments wp.customize.Class
+        * @mixes wp.customize.Events
+        */
        api.PreviewFrame = api.Messenger.extend({
                sensitivity: 2000,
 
                                loaded = false,
                                ready  = false;
 
-                       if ( this._ready )
+                       if ( this._ready ) {
                                this.unbind( 'ready', this._ready );
+                       }
 
                        this._ready = function() {
                                ready = true;
 
-                               if ( loaded )
+                               if ( loaded ) {
                                        deferred.resolveWith( self );
+                               }
                        };
 
                        this.bind( 'ready', this._ready );
 
+                       this.bind( 'ready', function ( data ) {
+                               if ( ! data ) {
+                                       return;
+                               }
+
+                               /*
+                                * Walk over all panels, sections, and controls and set their
+                                * respective active states to true if the preview explicitly
+                                * indicates as such.
+                                */
+                               var constructs = {
+                                       panel: data.activePanels,
+                                       section: data.activeSections,
+                                       control: data.activeControls
+                               };
+                               _( constructs ).each( function ( activeConstructs, type ) {
+                                       api[ type ].each( function ( construct, id ) {
+                                               var active = !! ( activeConstructs && activeConstructs[ id ] );
+                                               construct.active( active );
+                                       } );
+                               } );
+                       } );
+
                        this.request = $.ajax( this.previewUrl(), {
                                type: 'POST',
                                data: this.query,
 
                                // Check if the location response header differs from the current URL.
                                // If so, the request was redirected; try loading the requested page.
-                               if ( location && location != self.previewUrl() ) {
+                               if ( location && location !== self.previewUrl() ) {
                                        deferred.rejectWith( self, [ 'redirect', location ] );
                                        return;
                                }
 
        (function(){
                var uuid = 0;
+               /**
+                * Create a universally unique identifier.
+                *
+                * @return {int}
+                */
                api.PreviewFrame.uuid = function() {
                        return 'preview-' + uuid++;
                };
        }());
 
+       /**
+        * Set the document title of the customizer.
+        *
+        * @since 4.1.0
+        *
+        * @param {string} documentTitle
+        */
+       api.setDocumentTitle = function ( documentTitle ) {
+               var tmpl, title;
+               tmpl = api.settings.documentTitleTmpl;
+               title = tmpl.replace( '%s', documentTitle );
+               document.title = title;
+               if ( window !== window.parent ) {
+                       window.parent.document.title = document.title;
+               }
+       };
+
+       /**
+        * @class
+        * @augments wp.customize.Messenger
+        * @augments wp.customize.Class
+        * @mixes wp.customize.Events
+        */
        api.Previewer = api.Messenger.extend({
                refreshBuffer: 250,
 
                                rscheme = /^https?/;
 
                        $.extend( this, options || {} );
+                       this.deferred = {
+                               active: $.Deferred()
+                       };
 
                        /*
                         * Wrap this.refresh to prevent it from hammering the servers:
                        // Limit the URL to internal, front-end links.
                        //
                        // If the frontend and the admin are served from the same domain, load the
-                       // preview over ssl if the customizer is being loaded over ssl. This avoids
+                       // preview over ssl if the Customizer is being loaded over ssl. This avoids
                        // insecure content warnings. This is not attempted if the admin and frontend
                        // are on different domains to avoid the case where the frontend doesn't have
                        // ssl certs.
 
                        // Update the URL when the iframe sends a URL message.
                        this.bind( 'url', this.previewUrl );
+
+                       // Update the document title when the preview changes.
+                       this.bind( 'documentTitle', function ( title ) {
+                               api.setDocumentTitle( title );
+                       } );
                },
 
                query: function() {},
                                        self.targetWindow( this.targetWindow() );
                                        self.channel( this.channel() );
 
+                                       self.deferred.active.resolve();
                                        self.send( 'active' );
                                });
 
                }
        });
 
-       /* =====================================================================
-        * Ready.
-        * ===================================================================== */
-
        api.controlConstructor = {
                color:  api.ColorControl,
                upload: api.UploadControl,
-               image:  api.ImageControl
+               image:  api.ImageControl,
+               header: api.HeaderControl,
+               background: api.BackgroundControl
        };
+       api.panelConstructor = {};
+       api.sectionConstructor = {};
 
        $( function() {
                api.settings = window._wpCustomizeSettings;
                api.l10n = window._wpCustomizeControlsL10n;
 
-               // Check if we can run the customizer.
-               if ( ! api.settings )
+               // Check if we can run the Customizer.
+               if ( ! api.settings ) {
                        return;
+               }
 
                // Redirect to the fallback preview if any incompatibilities are found.
                if ( ! $.support.postMessage || ( ! $.support.cors && api.settings.isCrossDomain ) )
                        return window.location = api.settings.url.fallback;
 
-               var previewer, parent, topFocus,
+               var parent, topFocus,
                        body = $( document.body ),
-                       overlay = body.children('.wp-full-overlay');
+                       overlay = body.children( '.wp-full-overlay' ),
+                       title = $( '#customize-info .theme-name.site-title' ),
+                       closeBtn = $( '.customize-controls-close' ),
+                       saveBtn = $( '#save' );
 
-               // Prevent the form from saving when enter is pressed.
+               // Prevent the form from saving when enter is pressed on an input or select element.
                $('#customize-controls').on( 'keydown', function( e ) {
-                       if ( $( e.target ).is('textarea') )
-                               return;
+                       var isEnter = ( 13 === e.which ),
+                               $el = $( e.target );
 
-                       if ( 13 === e.which ) // Enter
+                       if ( isEnter && ( $el.is( 'input:not([type=button])' ) || $el.is( 'select' ) ) ) {
                                e.preventDefault();
+                       }
+               });
+
+               // Expand/Collapse the main customizer customize info.
+               $( '#customize-info' ).find( '> .accordion-section-title' ).on( 'click keydown', function( event ) {
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
+                               return;
+                       }
+                       event.preventDefault(); // Keep this AFTER the key filter above
+
+                       var section = $( this ).parent(),
+                               content = section.find( '.accordion-section-content:first' );
+
+                       if ( section.hasClass( 'cannot-expand' ) ) {
+                               return;
+                       }
+
+                       if ( section.hasClass( 'open' ) ) {
+                               section.toggleClass( 'open' );
+                               content.slideUp( api.Panel.prototype.defaultExpandedArguments.duration );
+                       } else {
+                               content.slideDown( api.Panel.prototype.defaultExpandedArguments.duration );
+                               section.toggleClass( 'open' );
+                       }
                });
 
                // Initialize Previewer
-               previewer = new api.Previewer({
+               api.previewer = new api.Previewer({
                        container:   '#customize-preview',
                        form:        '#customize-controls',
                        previewUrl:  api.settings.url.preview,
                        nonce: api.settings.nonce,
 
                        query: function() {
+                               var dirtyCustomized = {};
+                               api.each( function ( value, key ) {
+                                       if ( value._dirty ) {
+                                               dirtyCustomized[ key ] = value();
+                                       }
+                               } );
+
                                return {
                                        wp_customize: 'on',
                                        theme:      api.settings.theme.stylesheet,
-                                       customized: JSON.stringify( api.get() ),
+                                       customized: JSON.stringify( dirtyCustomized ),
                                        nonce:      this.nonce.preview
                                };
                        },
                                        query = $.extend( this.query(), {
                                                action: 'customize_save',
                                                nonce:  this.nonce.save
-                                       }),
-                                       request = $.post( api.settings.url.ajax, query );
-
-                               api.trigger( 'save', request );
-
-                               body.addClass('saving');
+                                       } ),
+                                       processing = api.state( 'processing' ),
+                                       submitWhenDoneProcessing,
+                                       submit;
+
+                               body.addClass( 'saving' );
+
+                               submit = function () {
+                                       var request = $.post( api.settings.url.ajax, query );
+
+                                       api.trigger( 'save', request );
+
+                                       request.always( function () {
+                                               body.removeClass( 'saving' );
+                                       } );
+
+                                       request.done( function( response ) {
+                                               // Check if the user is logged out.
+                                               if ( '0' === response ) {
+                                                       self.preview.iframe.hide();
+                                                       self.login().done( function() {
+                                                               self.save();
+                                                               self.preview.iframe.show();
+                                                       } );
+                                                       return;
+                                               }
 
-                               request.always( function() {
-                                       body.removeClass('saving');
-                               });
+                                               // Check for cheaters.
+                                               if ( '-1' === response ) {
+                                                       self.cheatin();
+                                                       return;
+                                               }
 
-                               request.done( function( response ) {
-                                       // Check if the user is logged out.
-                                       if ( '0' === response ) {
-                                               self.preview.iframe.hide();
-                                               self.login().done( function() {
-                                                       self.save();
-                                                       self.preview.iframe.show();
-                                               });
-                                               return;
-                                       }
+                                               // Clear setting dirty states
+                                               api.each( function ( value ) {
+                                                       value._dirty = false;
+                                               } );
+                                               api.trigger( 'saved' );
+                                       } );
+                               };
 
-                                       // Check for cheaters.
-                                       if ( '-1' === response ) {
-                                               self.cheatin();
-                                               return;
-                                       }
+                               if ( 0 === processing() ) {
+                                       submit();
+                               } else {
+                                       submitWhenDoneProcessing = function () {
+                                               if ( 0 === processing() ) {
+                                                       api.state.unbind( 'change', submitWhenDoneProcessing );
+                                                       submit();
+                                               }
+                                       };
+                                       api.state.bind( 'change', submitWhenDoneProcessing );
+                               }
 
-                                       api.trigger( 'saved' );
-                               });
                        }
                });
 
                // Refresh the nonces if the preview sends updated nonces over.
-               previewer.bind( 'nonce', function( nonce ) {
+               api.previewer.bind( 'nonce', function( nonce ) {
                        $.extend( this.nonce, nonce );
                });
 
+               // Create Settings
                $.each( api.settings.settings, function( id, data ) {
                        api.create( id, id, data.value, {
                                transport: data.transport,
-                               previewer: previewer
+                               previewer: api.previewer
+                       } );
+               });
+
+               // Create Panels
+               $.each( api.settings.panels, function ( id, data ) {
+                       var constructor = api.panelConstructor[ data.type ] || api.Panel,
+                               panel;
+
+                       panel = new constructor( id, {
+                               params: data
+                       } );
+                       api.panel.add( id, panel );
+               });
+
+               // Create Sections
+               $.each( api.settings.sections, function ( id, data ) {
+                       var constructor = api.sectionConstructor[ data.type ] || api.Section,
+                               section;
+
+                       section = new constructor( id, {
+                               params: data
                        } );
+                       api.section.add( id, section );
                });
 
+               // Create Controls
                $.each( api.settings.controls, function( id, data ) {
                        var constructor = api.controlConstructor[ data.type ] || api.Control,
                                control;
 
-                       control = api.control.add( id, new constructor( id, {
+                       control = new constructor( id, {
                                params: data,
-                               previewer: previewer
-                       } ) );
+                               previewer: api.previewer
+                       } );
+                       api.control.add( id, control );
+               });
+
+               // Focus the autofocused element
+               _.each( [ 'panel', 'section', 'control' ], function ( type ) {
+                       var instance, id = api.settings.autofocus[ type ];
+                       if ( id && api[ type ]( id ) ) {
+                               instance = api[ type ]( id );
+                               // Wait until the element is embedded in the DOM
+                               instance.deferred.embedded.done( function () {
+                                       // Wait until the preview has activated and so active panels, sections, controls have been set
+                                       api.previewer.deferred.active.done( function () {
+                                               instance.focus();
+                                       });
+                               });
+                       }
                });
 
+               /**
+                * Sort panels, sections, controls by priorities. Hide empty sections and panels.
+                *
+                * @since 4.1.0
+                */
+               api.reflowPaneContents = _.bind( function () {
+
+                       var appendContainer, activeElement, rootContainers, rootNodes = [], wasReflowed = false;
+
+                       if ( document.activeElement ) {
+                               activeElement = $( document.activeElement );
+                       }
+
+                       // Sort the sections within each panel
+                       api.panel.each( function ( panel ) {
+                               var sections = panel.sections(),
+                                       sectionContainers = _.pluck( sections, 'container' );
+                               rootNodes.push( panel );
+                               appendContainer = panel.container.find( 'ul:first' );
+                               if ( ! api.utils.areElementListsEqual( sectionContainers, appendContainer.children( '[id]' ) ) ) {
+                                       _( sections ).each( function ( section ) {
+                                               appendContainer.append( section.container );
+                                       } );
+                                       wasReflowed = true;
+                               }
+                       } );
+
+                       // Sort the controls within each section
+                       api.section.each( function ( section ) {
+                               var controls = section.controls(),
+                                       controlContainers = _.pluck( controls, 'container' );
+                               if ( ! section.panel() ) {
+                                       rootNodes.push( section );
+                               }
+                               appendContainer = section.container.find( 'ul:first' );
+                               if ( ! api.utils.areElementListsEqual( controlContainers, appendContainer.children( '[id]' ) ) ) {
+                                       _( controls ).each( function ( control ) {
+                                               appendContainer.append( control.container );
+                                       } );
+                                       wasReflowed = true;
+                               }
+                       } );
+
+                       // Sort the root panels and sections
+                       rootNodes.sort( api.utils.prioritySort );
+                       rootContainers = _.pluck( rootNodes, 'container' );
+                       appendContainer = $( '#customize-theme-controls' ).children( 'ul' ); // @todo This should be defined elsewhere, and to be configurable
+                       if ( ! api.utils.areElementListsEqual( rootContainers, appendContainer.children() ) ) {
+                               _( rootNodes ).each( function ( rootNode ) {
+                                       appendContainer.append( rootNode.container );
+                               } );
+                               wasReflowed = true;
+                       }
+
+                       // Now re-trigger the active Value callbacks to that the panels and sections can decide whether they can be rendered
+                       api.panel.each( function ( panel ) {
+                               var value = panel.active();
+                               panel.active.callbacks.fireWith( panel.active, [ value, value ] );
+                       } );
+                       api.section.each( function ( section ) {
+                               var value = section.active();
+                               section.active.callbacks.fireWith( section.active, [ value, value ] );
+                       } );
+
+                       // Restore focus if there was a reflow and there was an active (focused) element
+                       if ( wasReflowed && activeElement ) {
+                               activeElement.focus();
+                       }
+               }, api );
+               api.bind( 'ready', api.reflowPaneContents );
+               api.reflowPaneContents = _.debounce( api.reflowPaneContents, 100 );
+               $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) {
+                       values.bind( 'add', api.reflowPaneContents );
+                       values.bind( 'change', api.reflowPaneContents );
+                       values.bind( 'remove', api.reflowPaneContents );
+               } );
+
                // Check if preview url is valid and load the preview frame.
-               if ( previewer.previewUrl() )
-                       previewer.refresh();
-               else
-                       previewer.previewUrl( api.settings.url.home );
+               if ( api.previewer.previewUrl() ) {
+                       api.previewer.refresh();
+               } else {
+                       api.previewer.previewUrl( api.settings.url.home );
+               }
 
                // Save and activated states
                (function() {
                        var state = new api.Values(),
-                               saved = state.create('saved'),
-                               activated = state.create('activated');
+                               saved = state.create( 'saved' ),
+                               activated = state.create( 'activated' ),
+                               processing = state.create( 'processing' );
 
                        state.bind( 'change', function() {
-                               var save = $('#save'),
-                                       back = $('.back');
-
                                if ( ! activated() ) {
-                                       save.val( api.l10n.activate ).prop( 'disabled', false );
-                                       back.text( api.l10n.cancel );
+                                       saveBtn.val( api.l10n.activate ).prop( 'disabled', false );
+                                       closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
 
                                } else if ( saved() ) {
-                                       save.val( api.l10n.saved ).prop( 'disabled', true );
-                                       back.text( api.l10n.close );
+                                       saveBtn.val( api.l10n.saved ).prop( 'disabled', true );
+                                       closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
 
                                } else {
-                                       save.val( api.l10n.save ).prop( 'disabled', false );
-                                       back.text( api.l10n.cancel );
+                                       saveBtn.val( api.l10n.save ).prop( 'disabled', false );
+                                       closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
                                }
                        });
 
                        // Set default states.
                        saved( true );
                        activated( api.settings.theme.active );
+                       processing( 0 );
 
                        api.bind( 'change', function() {
                                state('saved').set( false );
                }());
 
                // Button bindings.
-               $('#save').click( function( event ) {
-                       previewer.save();
+               saveBtn.click( function( event ) {
+                       api.previewer.save();
                        event.preventDefault();
                }).keydown( function( event ) {
                        if ( 9 === event.which ) // tab
                                return;
                        if ( 13 === event.which ) // enter
-                               previewer.save();
+                               api.previewer.save();
                        event.preventDefault();
                });
 
-               $('.back').keydown( function( event ) {
-                       if ( 9 === event.which ) // tab
+               // Go back to the top-level Customizer accordion.
+               $( '#customize-header-actions' ).on( 'click keydown', '.control-panel-back', function( event ) {
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
                                return;
-                       if ( 13 === event.which ) // enter
-                               this.click();
-                       event.preventDefault();
+                       }
+
+                       event.preventDefault(); // Keep this AFTER the key filter above
+                       api.panel.each( function ( panel ) {
+                               panel.collapse();
+                       });
                });
 
-               $('.upload-dropzone a.upload').keydown( function( event ) {
+               closeBtn.keydown( function( event ) {
+                       if ( 9 === event.which ) // tab
+                               return;
                        if ( 13 === event.which ) // enter
                                this.click();
+                       event.preventDefault();
                });
 
                $('.collapse-sidebar').on( 'click keydown', function( event ) {
-                       if ( event.type === 'keydown' &&  13 !== event.which ) // enter
+                       if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
                                return;
+                       }
 
                        overlay.toggleClass( 'collapsed' ).toggleClass( 'expanded' );
                        event.preventDefault();
                });
 
+               // Bind site title display to the corresponding field.
+               if ( title.length ) {
+                       $( '#customize-control-blogname input' ).on( 'input', function() {
+                               title.text( this.value );
+                       } );
+               }
+
                // Create a potential postMessage connection with the parent frame.
                parent = new api.Messenger({
                        url: api.settings.url.parent,
                // If we receive a 'back' event, we're inside an iframe.
                // Send any clicks to the 'Return' link to the parent page.
                parent.bind( 'back', function() {
-                       $('.back').on( 'click.back', function( event ) {
+                       closeBtn.on( 'click.customize-controls-close', function( event ) {
                                event.preventDefault();
                                parent.send( 'close' );
                        });
                });
 
+               // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
+               $( window ).on( 'beforeunload', function () {
+                       if ( ! api.state( 'saved' )() ) {
+                               return api.l10n.saveAlert;
+                       }
+               } );
+
                // Pass events through to the parent.
-               api.bind( 'saved', function() {
-                       parent.send( 'saved' );
-               });
+               $.each( [ 'saved', 'change' ], function ( i, event ) {
+                       api.bind( event, function() {
+                               parent.send( event );
+                       });
+               } );
 
                // When activated, let the loader handle redirecting the page.
                // If no loader exists, redirect the page ourselves (if a url exists).
                        });
                });
 
-               // Handle header image data
-               api.control( 'header_image', function( control ) {
-                       control.setting.bind( function( to ) {
-                               if ( to === control.params.removed )
-                                       control.settings.data.set( false );
-                       });
-
-                       control.library.on( 'click', 'a', function() {
-                               control.settings.data.set( $(this).data('customizeHeaderImageData') );
-                       });
-
-                       control.uploader.success = function( attachment ) {
-                               var data;
-
-                               api.ImageControl.prototype.success.call( control, attachment );
-
-                               data = {
-                                       attachment_id: attachment.get('id'),
-                                       url:           attachment.get('url'),
-                                       thumbnail_url: attachment.get('url'),
-                                       height:        attachment.get('height'),
-                                       width:         attachment.get('width')
-                               };
-
-                               attachment.element.data( 'customizeHeaderImageData', data );
-                               control.settings.data.set( data );
-                       };
-               });
-
                api.trigger( 'ready' );
 
                // Make sure left column gets focus
-               topFocus = $('.back');
+               topFocus = closeBtn;
                topFocus.focus();
                setTimeout(function () {
                        topFocus.focus();