+ /**
+ * Utility function namespace
+ */
+ api.utils = {};
+
+ /**
+ * 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 );
+ }
+ } );
+ } );
+ };
+
+ /**
+ * 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();
+ }
+ };
+
+ /**
+ * 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 );
+ });
+
+ container.attachEvents();
+
+ 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;
+ },
+
+ /**
+ * 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.
+ */