diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 1d795980f7..50ee0ad111 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -53,8 +53,6 @@ do_action( 'customize_controls_init' ); wp_enqueue_script( 'customize-controls' ); wp_enqueue_style( 'customize-controls' ); -wp_enqueue_script( 'accordion' ); - /** * Enqueue Customizer control scripts. * @@ -130,7 +128,7 @@ do_action( 'customize_controls_print_scripts' ); ?>
-
+
-
    - containers() as $container ) { - $container->maybe_render(); - } - ?> -
+
+
+
@@ -252,10 +246,13 @@ do_action( 'customize_controls_print_scripts' ); ), 'settings' => array(), 'controls' => array(), + 'panels' => array(), + 'sections' => array(), 'nonce' => array( 'save' => wp_create_nonce( 'save-customize_' . $wp_customize->get_stylesheet() ), 'preview' => wp_create_nonce( 'preview-customize_' . $wp_customize->get_stylesheet() ) ), + 'autofocus' => array(), ); // Prepare Customize Setting objects to pass to Javascript. @@ -266,10 +263,32 @@ do_action( 'customize_controls_print_scripts' ); ); } - // Prepare Customize Control objects to pass to Javascript. + // Prepare Customize Control objects to pass to JavaScript. foreach ( $wp_customize->controls() as $id => $control ) { - $control->to_json(); - $settings['controls'][ $id ] = $control->json; + $settings['controls'][ $id ] = $control->json(); + } + + // Prepare Customize Section objects to pass to JavaScript. + foreach ( $wp_customize->sections() as $id => $section ) { + $settings['sections'][ $id ] = $section->json(); + } + + // Prepare Customize Panel objects to pass to JavaScript. + foreach ( $wp_customize->panels() as $id => $panel ) { + $settings['panels'][ $id ] = $panel->json(); + foreach ( $panel->sections as $section_id => $section ) { + $settings['sections'][ $section_id ] = $section->json(); + } + } + + // Pass to frontend the Customizer construct being deeplinked + if ( isset( $_GET['autofocus'] ) && is_array( $_GET['autofocus'] ) ) { + $autofocus = wp_unslash( $_GET['autofocus'] ); + foreach ( $autofocus as $type => $id ) { + if ( isset( $settings[ $type . 's' ][ $id ] ) ) { + $settings['autofocus'][ $type ] = $id; + } + } } ?> diff --git a/src/wp-admin/js/accordion.js b/src/wp-admin/js/accordion.js index 6cb1c1ca85..1769d27da9 100644 --- a/src/wp-admin/js/accordion.js +++ b/src/wp-admin/js/accordion.js @@ -25,9 +25,6 @@ * * Note that any appropriate tags may be used, as long as the above classes are present. * - * In addition to the standard accordion behavior, this file includes JS for the - * Customizer's "Panel" functionality. - * * @since 3.6.0. */ @@ -46,20 +43,8 @@ accordionSwitch( $( this ) ); }); - // Go back to the top-level Customizer accordion. - $( '#customize-header-actions' ).on( 'click keydown', '.control-panel-back', function( e ) { - if ( e.type === 'keydown' && 13 !== e.which ) { // "return" key - return; - } - - e.preventDefault(); // Keep this AFTER the key filter above - - panelSwitch( $( '.current-panel' ) ); - }); }); - var sectionContent = $( '.accordion-section-content' ); - /** * Close the current accordion section and open a new one. * @@ -69,75 +54,22 @@ function accordionSwitch ( el ) { var section = el.closest( '.accordion-section' ), siblings = section.closest( '.accordion-container' ).find( '.open' ), - content = section.find( sectionContent ); + content = section.find( '.accordion-section-content' ); // This section has no content and cannot be expanded. if ( section.hasClass( 'cannot-expand' ) ) { return; } - // Slide into a sub-panel instead of accordioning (Customizer-specific). - if ( section.hasClass( 'control-panel' ) ) { - panelSwitch( section ); - return; - } - if ( section.hasClass( 'open' ) ) { section.toggleClass( 'open' ); content.toggle( true ).slideToggle( 150 ); } else { siblings.removeClass( 'open' ); - siblings.find( sectionContent ).show().slideUp( 150 ); + siblings.find( '.accordion-section-content' ).show().slideUp( 150 ); content.toggle( false ).slideToggle( 150 ); section.toggleClass( 'open' ); } } - /** - * Slide into an accordion sub-panel. - * - * For the Customizer-specific panel functionality - * - * @param {Object} panel Title element or back button of the accordion panel to toggle. - * @since 4.0.0 - */ - function panelSwitch( panel ) { - var position, scroll, - section = panel.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 ( section.hasClass( 'current-panel' ) ) { - section.toggleClass( 'current-panel' ); - overlay.toggleClass( 'in-sub-panel' ); - content.delay( 180 ).hide( 0, function() { - content.css( 'margin-top', 'inherit' ); // Reset - } ); - topPanel.attr( 'tabindex', '0' ); - backBtn.attr( 'tabindex', '-1' ); - panelTitle.focus(); - container.scrollTop( 0 ); - } else { - // Close all open sections in any accordion level. - siblings.removeClass( 'open' ); - siblings.find( sectionContent ).show().slideUp( 0 ); - content.show( 0, function() { - position = content.offset().top; - scroll = container.scrollTop(); - content.css( 'margin-top', ( 45 - position - scroll ) ); - section.toggleClass( 'current-panel' ); - overlay.toggleClass( 'in-sub-panel' ); - container.scrollTop( 0 ); - } ); - topPanel.attr( 'tabindex', '-1' ); - backBtn.attr( 'tabindex', '0' ); - backBtn.focus(); - } - } - })(jQuery); diff --git a/src/wp-admin/js/customize-controls.js b/src/wp-admin/js/customize-controls.js index 363e965745..275424e0d4 100644 --- a/src/wp-admin/js/customize-controls.js +++ b/src/wp-admin/js/customize-controls.js @@ -1,6 +1,8 @@ /* globals _wpCustomizeHeader, _wpMediaViewsL10n */ (function( exports, $ ){ - var api = wp.customize; + var bubbleChildValueChanges, Container, focus, isKeydownButNotEnterEvent, areElementListsEqual, api = wp.customize; + + // @todo Move private helper functions to wp.customize.utils so they can be unit tested /** * @constructor @@ -31,71 +33,127 @@ }); /** + * Watch all changes to Value properties, and bubble changes to parent Values instance + * + * @param {wp.customize.Class} instance + * @param {Array} properties The names of the Value instances to watch. + */ + 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. + * + * @param {Object} [params] + */ + 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(); + } + }; + + /** + * Return whether the supplied Event object is for a keydown event but not the Enter key. + * + * @param {jQuery.Event} event + * @returns {boolean} + */ + 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. + * + * @param {Array|jQuery} listA + * @param {Array|jQuery} listB + * @returns {boolean} + */ + areElementListsEqual = function ( listA, listB ) { + var equal = ( + listA.length === listB.length && // if lists are different lengths, then naturally they are not equal + -1 === _.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 + } + ).indexOf( false ) // check for presence of false in map's return value + ); + return equal; + }; + + /** + * Base class for Panel and Section + * * @constructor * @augments wp.customize.Class */ - api.Control = api.Class.extend({ - initialize: function( id, options ) { - var control = this, - nodes, radios, settings; + Container = api.Class.extend({ + defaultActiveArguments: { duration: 'fast' }, + defaultExpandedArguments: { duration: 'fast' }, - this.params = {}; - $.extend( this, options || {} ); + initialize: function ( id, options ) { + var container = this; + container.id = id; + container.params = {}; + $.extend( container, options || {} ); + container.container = $( container.params.content ); - this.id = id; - this.selector = '#customize-control-' + id.replace( /\]/g, '' ).replace( /\[/g, '-' ); - this.container = $( this.selector ); - this.active = new api.Value( this.params.active ); + container.deferred = { + ready: new $.Deferred() + }; + container.priority = new api.Value(); + container.active = new api.Value(); + container.activeArgumentsQueue = []; + container.expanded = new api.Value(); + container.expandedArgumentsQueue = []; - settings = $.map( this.params.settings, function( value ) { - return value; + container.active.bind( function ( active ) { + var args = container.activeArgumentsQueue.shift(); + args = $.extend( {}, container.defaultActiveArguments, args ); + active = ( active && container.isContextuallyActive() ); + container.onChangeActive( active, args ); + // @todo trigger 'activated' and 'deactivated' events based on the expanded param? + }); + container.expanded.bind( function ( expanded ) { + var args = container.expandedArgumentsQueue.shift(); + args = $.extend( {}, container.defaultExpandedArguments, args ); + container.onChangeExpanded( expanded, args ); + // @todo trigger 'expanded' and 'collapsed' events based on the expanded param? }); - 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 ] ); - } + bubbleChildValueChanges( container, [ 'priority', 'active' ] ); - control.setting = control.settings['default'] || null; - control.renderContent( function() { - // Don't call ready() until the content has rendered. - control.ready(); - } ); - }) ); - - control.elements = []; - - nodes = this.container.find('[data-customize-setting-link]'); - radios = {}; - - nodes.each( function() { - var node = $(this), - 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 ) { - var element = new api.Element( node ); - control.elements.push( element ); - element.sync( setting ); - element.set( setting() ); - }); - }); - - control.active.bind( function ( active ) { - control.toggle( active ); - } ); - control.toggle( control.active() ); + container.priority.set( isNaN( container.params.priority ) ? 100 : container.params.priority ); + container.active.set( container.params.active ); + container.expanded.set( false ); // @todo True if deeplinking? }, /** @@ -104,20 +162,640 @@ ready: function() {}, /** - * Callback for change to the control's active state. + * Get the child models associated with this parent, sorting them by their priority Value. * - * Override function for custom behavior for the control being active/inactive. + * @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( function ( a, b ) { + return a.priority() - b.priority(); + } ); + return children; + }, + + /** + * To override by subclass, to return whether the container has active children. + * @abstract + */ + isContextuallyActive: function () { + throw new Error( 'Must override with 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. * * @param {Boolean} active + * @param {Object} args merged on top of this.defaultActiveArguments */ - toggle: function ( active ) { + onChangeActive: function ( active, args ) { + var duration = ( 'resolved' === api.previewer.deferred.active.state() ? args.duration : 0 ); if ( active ) { - this.container.slideDown(); + this.container.stop( true, true ).slideDown( duration, args.completeCallback ); } else { - this.container.slideUp(); + this.container.stop( true, true ).slideUp( duration, args.completeCallback ); } }, + /** + * @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 + }); + + /** + * @constructor + * @augments wp.customize.Class + */ + api.Section = Container.extend({ + + /** + * @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 || '' ); + bubbleChildValueChanges( section, [ 'panel' ] ); + + section.embed(); + section.deferred.ready.done( function () { + section.ready(); + }); + }, + + /** + * Embed the container in the DOM when any parent panel is ready. + */ + 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.ready.done( function () { + parentContainer = panel.container.find( 'ul:first' ); + if ( ! section.container.parent().is( parentContainer ) ) { + parentContainer.append( section.container ); + } + section.deferred.ready.resolve(); // @todo Better to use `embedded` instead of `ready` + }); + } ); + } 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.ready.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 + */ + attachEvents: function () { + var section = this; + + // Expand/Collapse accordion sections on click. + section.container.find( '.accordion-section-title' ).on( 'click keydown', function( event ) { + if ( 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. + * + * @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. + * + * @returns {Array} + */ + controls: function () { + return this._children( 'section', 'control' ); + }, + + /** + * Update UI to reflect expanded state + * + * @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 ); + } + } + }); + + /** + * @constructor + * @augments wp.customize.Class + */ + api.Panel = Container.extend({ + initialize: function ( id, options ) { + var panel = this; + Container.prototype.initialize.call( panel, id, options ); + panel.embed(); + panel.deferred.ready.done( function () { + panel.ready(); + }); + }, + + /** + * Embed the container in the DOM when any parent panel is ready. + */ + 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.ready.resolve(); + }, + + /** + * + */ + attachEvents: function () { + var meta, panel = this; + + // Expand/Collapse accordion sections on click. + panel.container.find( '.accordion-section-title' ).on( 'click keydown', function( event ) { + if ( 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 ( 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. + * + * @returns {Array} + */ + sections: function () { + return this._children( 'panel', 'section' ); + }, + + /** + * Return whether this panel has any active sections. + * + * @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 + * + * @param {Boolean} expanded + * @param {Object} args merged with this.defaultExpandedArguments + */ + 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 ); + } + } + }); + + /** + * @constructor + * @augments wp.customize.Class + */ + api.Control = api.Class.extend({ + defaultActiveArguments: { duration: 'fast' }, + + 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 = { + ready: new $.Deferred() + }; + control.section = new api.Value(); + control.priority = new api.Value(); + control.active = new api.Value(); + control.activeArgumentsQueue = []; + + control.elements = []; + + nodes = control.container.find('[data-customize-setting-link]'); + radios = {}; + + nodes.each( function() { + var node = $( this ), + 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 ) { + 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 ); + + 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.ready.done( function () { + control.ready(); + }); + }, + + /** + * + */ + 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 embeded 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.ready.done( function () { + parentContainer = section.container.find( 'ul:first' ); + if ( ! control.container.parent().is( parentContainer ) ) { + parentContainer.append( control.container ); + control.renderContent(); + } + control.deferred.ready.resolve(); // @todo Better to use `embedded` instead of `ready` + }); + }); + }; + control.section.bind( inject ); + inject( control.section.get() ); + }, + + /** + * @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. + * + * @param {Boolean} active + * @param {Object} args merged on top of this.defaultActiveArguments + */ + onChangeActive: function ( active, args ) { + if ( active ) { + this.container.slideDown( args.duration, args.completeCallback ); + } else { + this.container.slideUp( args.duration, args.completeCallback ); + } + }, + + /** + * @deprecated alias of onChangeActive + */ + toggle: function ( active ) { + return this.onChangeActive( active, this.defaultActiveArguments ); + }, + + /** + * Shorthand way to enable the active state. + * + * @param {Object} [params] + * @returns {Boolean} false if already active + */ + activate: Container.prototype.activate, + + /** + * Shorthand way to disable the active state. + * + * @param {Object} [params] + * @returns {Boolean} false if already inactive + */ + deactivate: Container.prototype.deactivate, + dropdownInit: function() { var control = this, statuses = this.container.find('.dropdown-status'), @@ -132,8 +810,9 @@ // 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 ( isKeydownButNotEnterEvent( event ) ) { return; + } event.preventDefault(); @@ -157,20 +836,18 @@ /** * Render the control from its JS template, if it exists. * - * The control's container must alreasy exist in the DOM. + * The control's container must already exist in the DOM. */ - renderContent: function( callback ) { + renderContent: function () { var template, - selector = 'customize-control-' + this.params.type + '-content'; + control = this; - callback = callback || function(){}; - if ( 0 !== $( '#tmpl-' + selector ).length ) { - template = wp.template( selector ); - if ( template && this.container ) { - this.container.append( template( this.params ) ); + if ( 0 !== $( '#tmpl-' + control.templateSelector ).length ) { + template = wp.template( control.templateSelector ); + if ( template && control.container ) { + control.container.append( template( control.params ) ); } } - callback(); } }); @@ -234,8 +911,9 @@ this.remover = this.container.find('.remove'); this.remover.on( 'click keydown', function( event ) { - if ( event.type === 'keydown' && 13 !== event.which ) // enter + if ( isKeydownButNotEnterEvent( event ) ) { return; + } control.setting.set( control.params.removed ); event.preventDefault(); @@ -306,8 +984,9 @@ // Bind tab switch events this.library.children('ul').on( 'click keydown', 'li', function( event ) { - if ( event.type === 'keydown' && 13 !== event.which ) // enter + if ( isKeydownButNotEnterEvent( event ) ) { return; + } var id = $(this).data('customizeTab'), tab = control.tabs[ id ]; @@ -324,8 +1003,9 @@ // Bind events to switch image urls. this.library.on( 'click keydown', 'a', function( event ) { - if ( event.type === 'keydown' && 13 !== event.which ) // enter + if ( isKeydownButNotEnterEvent( event ) ) { return; + } var value = $(this).data('customizeImageValue'); @@ -597,6 +1277,8 @@ // Create the collection of Control objects. api.control = new api.Values({ defaultConstructor: api.Control }); + api.section = new api.Values({ defaultConstructor: api.Section }); + api.panel = new api.Values({ defaultConstructor: api.Panel }); /** * @constructor @@ -632,29 +1314,42 @@ 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 || ! data.activeControls ) { + if ( ! data ) { return; } - $.each( data.activeControls, function ( id, active ) { - var control = api.control( id ); - if ( control ) { - control.active( active ); + var constructs = { + panel: data.activePanels, + section: data.activeSections, + control: data.activeControls + }; + + $.each( constructs, function ( type, activeConstructs ) { + if ( activeConstructs ) { + $.each( activeConstructs, function ( id, active ) { + var construct = api[ type ]( id ); + if ( construct ) { + construct.active( active ); + } + } ); } } ); + } ); this.request = $.ajax( this.previewUrl(), { @@ -676,7 +1371,7 @@ // 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; } @@ -803,6 +1498,9 @@ rscheme = /^https?/; $.extend( this, options || {} ); + this.deferred = { + active: $.Deferred() + }; /* * Wrap this.refresh to prevent it from hammering the servers: @@ -934,6 +1632,7 @@ self.targetWindow( this.targetWindow() ); self.channel( this.channel() ); + self.deferred.active.resolve(); self.send( 'active' ); }); @@ -1001,6 +1700,8 @@ image: api.ImageControl, header: api.HeaderControl }; + api.panelConstructor = {}; + api.sectionConstructor = {}; $( function() { api.settings = window._wpCustomizeSettings; @@ -1031,6 +1732,29 @@ } }); + // Expand/Collapse the main customizer customize info + $( '#customize-info' ).find( '> .accordion-section-title' ).on( 'click keydown', function( event ) { + if ( 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 api.previewer = new api.Previewer({ container: '#customize-preview', @@ -1124,6 +1848,7 @@ $.extend( this.nonce, nonce ); }); + // Create Settings $.each( api.settings.settings, function( id, data ) { api.create( id, id, data.value, { transport: data.transport, @@ -1131,16 +1856,132 @@ } ); }); + // 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: 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.ready.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. + */ + 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 ( ! 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 ( ! areElementListsEqual( controlContainers, appendContainer.children( '[id]' ) ) ) { + _( controls ).each( function ( control ) { + appendContainer.append( control.container ); + } ); + wasReflowed = true; + } + } ); + + // Sort the root panels and sections + rootNodes.sort( function ( a, b ) { + return a.priority() - b.priority(); + } ); + rootContainers = _.pluck( rootNodes, 'container' ); + appendContainer = $( '#customize-theme-controls' ).children( 'ul' ); // @todo This should be defined elsewhere, and to be configurable + if ( ! 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 ( api.previewer.previewUrl() ) { api.previewer.refresh(); @@ -1205,6 +2046,18 @@ event.preventDefault(); }); + // Go back to the top-level Customizer accordion. + $( '#customize-header-actions' ).on( 'click keydown', '.control-panel-back', function( event ) { + if ( isKeydownButNotEnterEvent( event ) ) { + return; + } + + event.preventDefault(); // Keep this AFTER the key filter above + api.panel.each( function ( panel ) { + panel.collapse(); + }); + }); + closeBtn.keydown( function( event ) { if ( 9 === event.which ) // tab return; @@ -1219,8 +2072,9 @@ }); $('.collapse-sidebar').on( 'click keydown', function( event ) { - if ( event.type === 'keydown' && 13 !== event.which ) // enter + if ( isKeydownButNotEnterEvent( event ) ) { return; + } overlay.toggleClass( 'collapsed' ).toggleClass( 'expanded' ); event.preventDefault(); diff --git a/src/wp-admin/js/customize-widgets.js b/src/wp-admin/js/customize-widgets.js index 6be9a0807a..8112f3e093 100644 --- a/src/wp-admin/js/customize-widgets.js +++ b/src/wp-admin/js/customize-widgets.js @@ -404,6 +404,23 @@ * @augments wp.customize.Control */ api.Widgets.WidgetControl = api.Control.extend({ + defaultExpandedArguments: { + duration: 'fast' + }, + + initialize: function ( id, options ) { + var control = this; + api.Control.prototype.initialize.call( control, id, options ); + control.expanded = new api.Value(); + control.expandedArgumentsQueue = []; + control.expanded.bind( function ( expanded ) { + var args = control.expandedArgumentsQueue.shift(); + args = $.extend( {}, control.defaultExpandedArguments, args ); + control.onChangeExpanded( expanded, args ); + }); + control.expanded.set( false ); + }, + /** * Set up the control */ @@ -529,13 +546,13 @@ if ( sidebarWidgetsControl.isReordering ) { return; } - self.toggleForm(); + self.expanded( ! self.expanded() ); } ); $closeBtn = this.container.find( '.widget-control-close' ); $closeBtn.on( 'click', function( e ) { e.preventDefault(); - self.collapseForm(); + self.collapse(); self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility } ); }, @@ -777,9 +794,14 @@ * Overrides api.Control.toggle() * * @param {Boolean} active + * @param {Object} args */ - toggle: function ( active ) { + onChangeActive: function ( active, args ) { + // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments this.container.toggleClass( 'widget-rendered', active ); + if ( args.completeCallback ) { + args.completeCallback(); + } }, /** @@ -1101,51 +1123,90 @@ * Expand the accordion section containing a control */ expandControlSection: function() { - var $section = this.container.closest( '.accordion-section' ); - - if ( ! $section.hasClass( 'open' ) ) { - $section.find( '.accordion-section-title:first' ).trigger( 'click' ); - } + api.Control.prototype.expand.call( this ); }, + /** + * @param {Boolean} expanded + * @param {Object} [params] + * @returns {Boolean} false if state already applied + */ + _toggleExpanded: api.Section.prototype._toggleExpanded, + + /** + * @param {Object} [params] + * @returns {Boolean} false if already expanded + */ + expand: api.Section.prototype.expand, + /** * Expand the widget form control + * + * @deprecated alias of expand() */ expandForm: function() { - this.toggleForm( true ); + this.expand(); }, + /** + * @param {Object} [params] + * @returns {Boolean} false if already collapsed + */ + collapse: api.Section.prototype.collapse, + /** * Collapse the widget form control + * + * @deprecated alias of expand() */ collapseForm: function() { - this.toggleForm( false ); + this.collapse(); }, /** * Expand or collapse the widget control * + * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide ) + * * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility */ toggleForm: function( showOrHide ) { - var self = this, $widget, $inside, complete; - - $widget = this.container.find( 'div.widget:first' ); - $inside = $widget.find( '.widget-inside:first' ); if ( typeof showOrHide === 'undefined' ) { - showOrHide = ! $inside.is( ':visible' ); + showOrHide = ! this.expanded(); } + this.expanded( showOrHide ); + }, - // Already expanded or collapsed, so noop - if ( $inside.is( ':visible' ) === showOrHide ) { + /** + * Respond to change in the expanded state. + * + * @param {Boolean} expanded + * @param {Object} args merged on top of this.defaultActiveArguments + */ + onChangeExpanded: function ( expanded, args ) { + var self = this, $widget, $inside, complete, prevComplete; + + // If the expanded state is unchanged only manipulate container expanded states + if ( args.unchanged ) { + if ( expanded ) { + api.Control.prototype.expand.call( self, { + completeCallback: args.completeCallback + }); + } return; } - if ( showOrHide ) { + $widget = this.container.find( 'div.widget:first' ); + $inside = $widget.find( '.widget-inside:first' ); + + if ( expanded ) { + + self.expandControlSection(); + // Close all other widget controls before expanding this one api.control.each( function( otherControl ) { if ( self.params.type === otherControl.params.type && self !== otherControl ) { - otherControl.collapseForm(); + otherControl.collapse(); } } ); @@ -1154,29 +1215,44 @@ self.container.addClass( 'expanded' ); self.container.trigger( 'expanded' ); }; + if ( args.completeCallback ) { + prevComplete = complete; + complete = function () { + prevComplete(); + args.completeCallback(); + }; + } if ( self.params.is_wide ) { - $inside.fadeIn( 'fast', complete ); + $inside.fadeIn( args.duration, complete ); } else { - $inside.slideDown( 'fast', complete ); + $inside.slideDown( args.duration, complete ); } self.container.trigger( 'expand' ); self.container.addClass( 'expanding' ); } else { + complete = function() { self.container.removeClass( 'collapsing' ); self.container.removeClass( 'expanded' ); self.container.trigger( 'collapsed' ); }; + if ( args.completeCallback ) { + prevComplete = complete; + complete = function () { + prevComplete(); + args.completeCallback(); + }; + } self.container.trigger( 'collapse' ); self.container.addClass( 'collapsing' ); if ( self.params.is_wide ) { - $inside.fadeOut( 'fast', complete ); + $inside.fadeOut( args.duration, complete ); } else { - $inside.slideUp( 'fast', function() { + $inside.slideUp( args.duration, function() { $widget.css( { width:'', margin:'' } ); complete(); } ); @@ -1184,16 +1260,6 @@ } }, - /** - * Expand the containing sidebar section, expand the form, and focus on - * the first input in the control - */ - focus: function() { - this.expandControlSection(); - this.expandForm(); - this.container.find( '.widget-content :focusable:first' ).focus(); - }, - /** * Get the position (index) of the widget in the containing sidebar * @@ -1304,6 +1370,7 @@ * @augments wp.customize.Control */ api.Widgets.SidebarControl = api.Control.extend({ + /** * Set up the control */ @@ -1325,7 +1392,7 @@ registeredSidebar = api.Widgets.registeredSidebars.get( this.params.sidebar_id ); this.setting.bind( function( newWidgetIds, oldWidgetIds ) { - var widgetFormControls, $sidebarWidgetsAddControl, finalControlContainers, removedWidgetIds; + var widgetFormControls, removedWidgetIds, priority; removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds ); @@ -1350,21 +1417,16 @@ widgetFormControls.sort( function( a, b ) { var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ), bIndex = _.indexOf( newWidgetIds, b.params.widget_id ); + return aIndex - bIndex; + }); - if ( aIndex === bIndex ) { - return 0; - } - - return aIndex < bIndex ? -1 : 1; - } ); - - // Append the controls to put them in the right order - finalControlContainers = _( widgetFormControls ).map( function( widgetFormControls ) { - return widgetFormControls.container[0]; - } ); - - $sidebarWidgetsAddControl = self.$sectionContent.find( '.customize-control-sidebar_widgets' ); - $sidebarWidgetsAddControl.before( finalControlContainers ); + priority = 0; + _( widgetFormControls ).each( function ( control ) { + control.priority( priority ); + control.section( self.section() ); + priority += 1; + }); + self.priority( priority ); // Make sure sidebar control remains at end // Re-sort widget form controls (including widgets form other sidebars newly moved here) self._applyCardinalOrderClassNames(); @@ -1434,36 +1496,9 @@ // Update the model with whether or not the sidebar is rendered self.active.bind( function ( active ) { registeredSidebar.set( 'is_rendered', active ); + api.section( self.section.get() ).active( active ); } ); - }, - - /** - * Show the sidebar section when it becomes visible. - * - * Overrides api.Control.toggle() - * - * @param {Boolean} active - */ - toggle: function ( active ) { - var $section, sectionSelector; - - sectionSelector = '#accordion-section-sidebar-widgets-' + this.params.sidebar_id; - $section = $( sectionSelector ); - - if ( active ) { - $section.stop().slideDown( function() { - $( this ).css( 'height', 'auto' ); // so that the .accordion-section-content won't overflow - } ); - - } else { - // Make sure that hidden sections get closed first - if ( $section.hasClass( 'open' ) ) { - // it would be nice if accordionSwitch() in accordion.js was public - $section.find( '.accordion-section-title' ).trigger( 'click' ); - } - - $section.stop().slideUp(); - } + api.section( self.section.get() ).active( self.active() ); }, /** @@ -1500,12 +1535,18 @@ this.$controlSection.find( '.accordion-section-title' ).droppable({ accept: '.customize-control-widget_form', over: function() { - if ( ! self.$controlSection.hasClass( 'open' ) ) { - self.$controlSection.addClass( 'open' ); - self.$sectionContent.toggle( false ).slideToggle( 150, function() { - self.$sectionContent.sortable( 'refreshPositions' ); - } ); - } + var section = api.section( self.section.get() ); + section.expand({ + allowMultiple: true, // Prevent the section being dragged from to be collapsed + completeCallback: function () { + // @todo It is not clear when refreshPositions should be called on which sections, or if it is even needed + api.section.each( function ( otherSection ) { + if ( otherSection.container.find( '.customize-control-sidebar_widgets' ).length ) { + otherSection.container.find( '.accordion-section-content:first' ).sortable( 'refreshPositions' ); + } + } ); + } + }); } }); @@ -1548,16 +1589,30 @@ * Add classes to the widget_form controls to assist with styling */ _applyCardinalOrderClassNames: function() { - this.$sectionContent.find( '.customize-control-widget_form' ) - .removeClass( 'first-widget' ) - .removeClass( 'last-widget' ) - .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 ); + var widgetControls = []; + _.each( this.setting(), function ( widgetId ) { + var widgetControl = api.Widgets.getWidgetFormControlForWidget( widgetId ); + if ( widgetControl ) { + widgetControls.push( widgetControl ); + } + }); - this.$sectionContent.find( '.customize-control-widget_form:first' ) + if ( ! widgetControls.length ) { + return; + } + + $( widgetControls ).each( function () { + $( this.container ) + .removeClass( 'first-widget' ) + .removeClass( 'last-widget' ) + .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 ); + }); + + _.first( widgetControls ).container .addClass( 'first-widget' ) .find( '.move-widget-up' ).prop( 'tabIndex', -1 ); - this.$sectionContent.find( '.customize-control-widget_form:last' ) + _.last( widgetControls ).container .addClass( 'last-widget' ) .find( '.move-widget-down' ).prop( 'tabIndex', -1 ); }, @@ -1571,6 +1626,8 @@ * Enable/disable the reordering UI * * @param {Boolean} showOrHide to enable/disable reordering + * + * @todo We should have a reordering state instead and rename this to onChangeReordering */ toggleReordering: function( showOrHide ) { showOrHide = Boolean( showOrHide ); @@ -1584,7 +1641,7 @@ if ( showOrHide ) { _( this.getWidgetFormControls() ).each( function( formControl ) { - formControl.collapseForm(); + formControl.collapse(); } ); this.$sectionContent.find( '.first-widget .move-widget' ).focus(); @@ -1619,7 +1676,7 @@ * @returns {object|false} widget_form control instance, or false on error */ addWidget: function( widgetId ) { - var self = this, controlHtml, $widget, controlType = 'widget_form', $control, controlConstructor, + var self = this, controlHtml, $widget, controlType = 'widget_form', controlContainer, controlConstructor, parsedWidgetId = parseWidgetId( widgetId ), widgetNumber = parsedWidgetId.number, widgetIdBase = parsedWidgetId.id_base, @@ -1651,30 +1708,28 @@ $widget = $( controlHtml ); - $control = $( '
  • ' ) + controlContainer = $( '
  • ' ) .addClass( 'customize-control' ) .addClass( 'customize-control-' + controlType ) .append( $widget ); // Remove icon which is visible inside the panel - $control.find( '> .widget-icon' ).remove(); + controlContainer.find( '> .widget-icon' ).remove(); if ( widget.get( 'is_multi' ) ) { - $control.find( 'input[name="widget_number"]' ).val( widgetNumber ); - $control.find( 'input[name="multi_number"]' ).val( widgetNumber ); + controlContainer.find( 'input[name="widget_number"]' ).val( widgetNumber ); + controlContainer.find( 'input[name="multi_number"]' ).val( widgetNumber ); } - widgetId = $control.find( '[name="widget-id"]' ).val(); + widgetId = controlContainer.find( '[name="widget-id"]' ).val(); - $control.hide(); // to be slid-down below + controlContainer.hide(); // to be slid-down below settingId = 'widget_' + widget.get( 'id_base' ); if ( widget.get( 'is_multi' ) ) { settingId += '[' + widgetNumber + ']'; } - $control.attr( 'id', 'customize-control-' + settingId.replace( /\]/g, '' ).replace( /\[/g, '-' ) ); - - this.container.after( $control ); + controlContainer.attr( 'id', 'customize-control-' + settingId.replace( /\]/g, '' ).replace( /\[/g, '-' ) ); // Only create setting if it doesn't already exist (if we're adding a pre-existing inactive widget) isExistingWidget = api.has( settingId ); @@ -1692,6 +1747,7 @@ settings: { 'default': settingId }, + content: controlContainer, sidebar_id: self.params.sidebar_id, widget_id: widgetId, widget_id_base: widget.get( 'id_base' ), @@ -1731,9 +1787,9 @@ this.setting( sidebarWidgets ); } - $control.slideDown( function() { + controlContainer.slideDown( function() { if ( isExistingWidget ) { - widgetFormControl.expandForm(); + widgetFormControl.expand(); widgetFormControl.updateWidget( { instance: widgetFormControl.setting(), complete: function( error ) { diff --git a/src/wp-includes/class-wp-customize-control.php b/src/wp-includes/class-wp-customize-control.php index a790b71115..dcebb0f8a2 100644 --- a/src/wp-includes/class-wp-customize-control.php +++ b/src/wp-includes/class-wp-customize-control.php @@ -74,6 +74,7 @@ class WP_Customize_Control { public $input_attrs = array(); /** + * @deprecated It is better to just call the json() method * @access public * @var array */ @@ -218,9 +219,24 @@ class WP_Customize_Control { } $this->json['type'] = $this->type; + $this->json['priority'] = $this->priority; + $this->json['active'] = $this->active(); + $this->json['section'] = $this->section; + $this->json['content'] = $this->get_content(); $this->json['label'] = $this->label; $this->json['description'] = $this->description; - $this->json['active'] = $this->active(); + } + + /** + * Get the data to export to the client via JSON. + * + * @since 4.1.0 + * + * @return array + */ + public function json() { + $this->to_json(); + return $this->json; } /** @@ -243,6 +259,21 @@ class WP_Customize_Control { return true; } + /** + * Get the control's content for insertion into the Customizer pane. + * + * @since 4.1.0 + * + * @return string + */ + public final function get_content() { + ob_start(); + $this->maybe_render(); + $template = trim( ob_get_contents() ); + ob_end_clean(); + return $template; + } + /** * Check capabilities and render the control. * @@ -1073,6 +1104,7 @@ class WP_Customize_Header_Image_Control extends WP_Customize_Image_Control { /** * Widget Area Customize Control Class * + * @since 3.9.0 */ class WP_Widget_Area_Customize_Control extends WP_Customize_Control { public $type = 'sidebar_widgets'; @@ -1114,6 +1146,8 @@ class WP_Widget_Area_Customize_Control extends WP_Customize_Control { /** * Widget Form Customize Control Class + * + * @since 3.9.0 */ class WP_Widget_Form_Customize_Control extends WP_Customize_Control { public $type = 'widget_form'; diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index bcbf127888..4d4e73eda1 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -498,6 +498,8 @@ final class WP_Customize_Manager { $settings = array( 'values' => array(), 'channel' => wp_unslash( $_POST['customize_messenger_channel'] ), + 'activePanels' => array(), + 'activeSections' => array(), 'activeControls' => array(), ); @@ -511,6 +513,12 @@ final class WP_Customize_Manager { foreach ( $this->settings as $id => $setting ) { $settings['values'][ $id ] = $setting->js_value(); } + foreach ( $this->panels as $id => $panel ) { + $settings['activePanels'][ $id ] = $panel->active(); + } + foreach ( $this->sections as $id => $section ) { + $settings['activeSections'][ $id ] = $section->active(); + } foreach ( $this->controls as $id => $control ) { $settings['activeControls'][ $id ] = $control->active(); } @@ -911,11 +919,11 @@ final class WP_Customize_Manager { if ( ! $section->panel ) { // Top-level section. - $sections[] = $section; + $sections[ $section->id ] = $section; } else { // This section belongs to a panel. if ( isset( $this->panels [ $section->panel ] ) ) { - $this->panels[ $section->panel ]->sections[] = $section; + $this->panels[ $section->panel ]->sections[ $section->id ] = $section; } } } @@ -932,8 +940,8 @@ final class WP_Customize_Manager { continue; } - usort( $panel->sections, array( $this, '_cmp_priority' ) ); - $panels[] = $panel; + uasort( $panel->sections, array( $this, '_cmp_priority' ) ); + $panels[ $panel->id ] = $panel; } $this->panels = $panels; diff --git a/src/wp-includes/class-wp-customize-panel.php b/src/wp-includes/class-wp-customize-panel.php index f289cb7ff0..201c4b914f 100644 --- a/src/wp-includes/class-wp-customize-panel.php +++ b/src/wp-includes/class-wp-customize-panel.php @@ -82,6 +82,28 @@ class WP_Customize_Panel { */ public $sections; + /** + * @since 4.1.0 + * @access public + * @var string + */ + public $type; + + /** + * Callback. + * + * @since 4.1.0 + * @access public + * + * @see WP_Customize_Section::active() + * + * @var callable Callback is called with one argument, the instance of + * WP_Customize_Section, and returns bool to indicate whether + * the section is active (such as it relates to the URL + * currently being previewed). + */ + public $active_callback = ''; + /** * Constructor. * @@ -103,12 +125,69 @@ class WP_Customize_Panel { $this->manager = $manager; $this->id = $id; + if ( empty( $this->active_callback ) ) { + $this->active_callback = array( $this, 'active_callback' ); + } $this->sections = array(); // Users cannot customize the $sections array. return $this; } + /** + * Check whether panel is active to current Customizer preview. + * + * @since 4.1.0 + * @access public + * + * @return bool Whether the panel is active to the current preview. + */ + public final function active() { + $panel = $this; + $active = call_user_func( $this->active_callback, $this ); + + /** + * Filter response of WP_Customize_Panel::active(). + * + * @since 4.1.0 + * + * @param bool $active Whether the Customizer panel is active. + * @param WP_Customize_Panel $panel WP_Customize_Panel instance. + */ + $active = apply_filters( 'customize_panel_active', $active, $panel ); + + return $active; + } + + /** + * Default callback used when invoking WP_Customize_Panel::active(). + * + * Subclasses can override this with their specific logic, or they may + * provide an 'active_callback' argument to the constructor. + * + * @since 4.1.0 + * @access public + * + * @return bool Always true. + */ + public function active_callback() { + return true; + } + + /** + * Gather the parameters passed to client JavaScript via JSON. + * + * @since 4.1.0 + * + * @return array The array to be exported to the client as JSON + */ + public function json() { + $array = wp_array_slice_assoc( (array) $this, array( 'title', 'description', 'priority', 'type' ) ); + $array['content'] = $this->get_content(); + $array['active'] = $this->active(); + return $array; + } + /** * Checks required user capabilities and whether the theme has the * feature support required by the panel. @@ -129,6 +208,21 @@ class WP_Customize_Panel { return true; } + /** + * Get the panel's content template for insertion into the Customizer pane. + * + * @since 4.1.0 + * + * @return string + */ + public final function get_content() { + ob_start(); + $this->maybe_render(); + $template = trim( ob_get_contents() ); + ob_end_clean(); + return $template; + } + /** * Check capabilities and render the panel. * @@ -189,7 +283,7 @@ class WP_Customize_Panel { */ protected function render_content() { ?> -
  • +
  • sections as $section ) { - $section->maybe_render(); - } } } diff --git a/src/wp-includes/class-wp-customize-section.php b/src/wp-includes/class-wp-customize-section.php index d740ddba62..3553285cc4 100644 --- a/src/wp-includes/class-wp-customize-section.php +++ b/src/wp-includes/class-wp-customize-section.php @@ -91,6 +91,28 @@ class WP_Customize_Section { */ public $controls; + /** + * @since 4.1.0 + * @access public + * @var string + */ + public $type; + + /** + * Callback. + * + * @since 4.1.0 + * @access public + * + * @see WP_Customize_Section::active() + * + * @var callable Callback is called with one argument, the instance of + * WP_Customize_Section, and returns bool to indicate whether + * the section is active (such as it relates to the URL + * currently being previewed). + */ + public $active_callback = ''; + /** * Constructor. * @@ -105,18 +127,76 @@ class WP_Customize_Section { public function __construct( $manager, $id, $args = array() ) { $keys = array_keys( get_object_vars( $this ) ); foreach ( $keys as $key ) { - if ( isset( $args[ $key ] ) ) + if ( isset( $args[ $key ] ) ) { $this->$key = $args[ $key ]; + } } $this->manager = $manager; $this->id = $id; + if ( empty( $this->active_callback ) ) { + $this->active_callback = array( $this, 'active_callback' ); + } $this->controls = array(); // Users cannot customize the $controls array. return $this; } + /** + * Check whether section is active to current Customizer preview. + * + * @since 4.1.0 + * @access public + * + * @return bool Whether the section is active to the current preview. + */ + public final function active() { + $section = $this; + $active = call_user_func( $this->active_callback, $this ); + + /** + * Filter response of WP_Customize_Section::active(). + * + * @since 4.1.0 + * + * @param bool $active Whether the Customizer section is active. + * @param WP_Customize_Section $section WP_Customize_Section instance. + */ + $active = apply_filters( 'customize_section_active', $active, $section ); + + return $active; + } + + /** + * Default callback used when invoking WP_Customize_Section::active(). + * + * Subclasses can override this with their specific logic, or they may + * provide an 'active_callback' argument to the constructor. + * + * @since 4.1.0 + * @access public + * + * @return bool Always true. + */ + public function active_callback() { + return true; + } + + /** + * Gather the parameters passed to client JavaScript via JSON. + * + * @since 4.1.0 + * + * @return array The array to be exported to the client as JSON + */ + public function json() { + $array = wp_array_slice_assoc( (array) $this, array( 'title', 'description', 'priority', 'panel', 'type' ) ); + $array['content'] = $this->get_content(); + $array['active'] = $this->active(); + return $array; + } + /** * Checks required user capabilities and whether the theme has the * feature support required by the section. @@ -126,23 +206,41 @@ class WP_Customize_Section { * @return bool False if theme doesn't support the section or user doesn't have the capability. */ public final function check_capabilities() { - if ( $this->capability && ! call_user_func_array( 'current_user_can', (array) $this->capability ) ) + if ( $this->capability && ! call_user_func_array( 'current_user_can', (array) $this->capability ) ) { return false; + } - if ( $this->theme_supports && ! call_user_func_array( 'current_theme_supports', (array) $this->theme_supports ) ) + if ( $this->theme_supports && ! call_user_func_array( 'current_theme_supports', (array) $this->theme_supports ) ) { return false; + } return true; } + /** + * Get the section's content template for insertion into the Customizer pane. + * + * @since 4.1.0 + * + * @return string + */ + public final function get_content() { + ob_start(); + $this->maybe_render(); + $template = trim( ob_get_contents() ); + ob_end_clean(); + return $template; + } + /** * Check capabilities and render the section. * * @since 3.4.0 */ public final function maybe_render() { - if ( ! $this->check_capabilities() ) + if ( ! $this->check_capabilities() ) { return; + } /** * Fires before rendering a Customizer section. @@ -172,9 +270,6 @@ class WP_Customize_Section { */ protected function render() { $classes = 'control-section accordion-section'; - if ( $this->panel ) { - $classes .= ' control-subsection'; - } ?>
  • @@ -183,12 +278,10 @@ class WP_Customize_Section {

      description ) ) : ?> -
    • description; ?>

    • +
    • +

      description; ?>

      +
    • - controls as $control ) - $control->maybe_render(); - ?>
  • - + @@ -28,14 +28,15 @@ + +
    - diff --git a/tests/qunit/wp-admin/js/customize-base.js b/tests/qunit/wp-admin/js/customize-base.js new file mode 100644 index 0000000000..422111fd58 --- /dev/null +++ b/tests/qunit/wp-admin/js/customize-base.js @@ -0,0 +1,85 @@ +/* global wp */ + +jQuery( function( $ ) { + var FooSuperClass, BarSubClass, foo, bar; + + module( 'Customize Base: Class' ); + + FooSuperClass = wp.customize.Class.extend( + { + initialize: function ( instanceProps ) { + $.extend( this, instanceProps || {} ); + }, + protoProp: 'protoPropValue' + }, + { + staticProp: 'staticPropValue' + } + ); + test( 'FooSuperClass is a function ', function () { + equal( typeof FooSuperClass, 'function' ); + }); + test( 'FooSuperClass prototype has protoProp', function () { + equal( FooSuperClass.prototype.protoProp, 'protoPropValue' ); + }); + test( 'FooSuperClass does not have protoProp', function () { + equal( typeof FooSuperClass.protoProp, 'undefined' ); + }); + test( 'FooSuperClass has staticProp', function () { + equal( FooSuperClass.staticProp, 'staticPropValue' ); + }); + test( 'FooSuperClass prototype does not have staticProp', function () { + equal( typeof FooSuperClass.prototype.staticProp, 'undefined' ); + }); + + foo = new FooSuperClass( { instanceProp: 'instancePropValue' } ); + test( 'FooSuperClass instance foo extended Class', function () { + equal( foo.extended( wp.customize.Class ), true ); + }); + test( 'foo instance has protoProp', function () { + equal( foo.protoProp, 'protoPropValue' ); + }); + test( 'foo instance does not have staticProp', function () { + equal( typeof foo.staticProp, 'undefined' ); + }); + test( 'FooSuperClass instance foo ran initialize() and has supplied instanceProp', function () { + equal( foo.instanceProp, 'instancePropValue' ); + }); + + // @todo Test Class.constructor() manipulation + // @todo Test Class.applicator? + // @todo do we test object.instance? + + + module( 'Customize Base: Subclass' ); + + BarSubClass = FooSuperClass.extend( + { + initialize: function ( instanceProps ) { + FooSuperClass.prototype.initialize.call( this, instanceProps ); + this.subInstanceProp = 'subInstancePropValue'; + }, + subProtoProp: 'subProtoPropValue' + }, + { + subStaticProp: 'subStaticPropValue' + } + ); + test( 'BarSubClass prototype has subProtoProp', function () { + equal( BarSubClass.prototype.subProtoProp, 'subProtoPropValue' ); + }); + test( 'BarSubClass prototype has parent FooSuperClass protoProp', function () { + equal( BarSubClass.prototype.protoProp, 'protoPropValue' ); + }); + + bar = new BarSubClass( { instanceProp: 'instancePropValue' } ); + test( 'BarSubClass instance bar its initialize() and parent initialize() run', function () { + equal( bar.instanceProp, 'instancePropValue' ); + equal( bar.subInstanceProp, 'subInstancePropValue' ); + }); + + test( 'BarSubClass instance bar extended FooSuperClass', function () { + equal( bar.extended( FooSuperClass ), true ); + }); + +});