Toward a Complete JavaScript API for the Customizer

The Customizer is the first true JS-driven feature in core. That’s awesome, especially coming out of WCSF where JavaScript has been highlighted so prominently between Backbone.js, the WP REST API, Node.js, and socket.io. The Customizer has a great architecture with models for settings, controls, watchable-values, collections, events, and asynchronous callbacks. Nevertheless, the JavaScript API in the Customizer is incomplete.

Existing Challenges

When widgets were added to the Customizer in 3.9, adding widget controls to sidebar sections required direct DOM manipulation. For controls there is at least a Control model to manage state For sections and panels, however, there are no JS models at all, and so adding them dynamically is even more of a challenge. And this is the exact challenge that Nick Halsey’s Menu Customizer plugin currently has to struggle through.

When Customizer panels were added in 4.0 to group all widget area sections into a “Widgets” panel, a bug was introduced whereby shift-clicking a widget in the preview no longer revealed the widget control in the Customizer pane because the sections were inside of the collapsed Widgets panel: there were no models to represent the state for whether or not a panel or section were currently expanded. Without models, a fix of this would require more messy DOM traversal to check if parent accordion sections were expanded or not. Storing data in the DOM is bad.

In 4.0 the concept of contextual controls were added to the Customizer. This allowed controls to be registered with an active_callback, such as is_front_page, which the preview would execute and pass back up to the Customizer pane to then show/hide the control based on which URL was being previewed. This worked well, except when all controls in a section were inactive: then the result was a Customizer section without any visible controls. Instead, the expected behavior would be for the section to automatically collapse as well when all controls inside become inactive. Again, this problem stems from the fact that there is no JS model to represent a section and to list out the controls associated with it.

For the past three weeks I’ve been focused on fleshing out the Customizer API to address these challenges, and to facilitate doing new dynamic things in the Customizer. The parent ticket for this is #28709: Improve/introduce Customizer JS models for Controls, Sections, and Panels.

Models for Panels and Sections

As noted above, there is a wp.customize.Control model, and then there is a wp.customize.control collection (yes, it is singular) to store all control instances. So to follow the pattern established by controls, in the patch there is a wp.customize.Panel and wp.customize.Section, along with wp.customize.panel and wp.customize.section collections (both singular again). So just as with controls, you can iterate over panels and sections via:

wp.customize.panel.each( function ( panel ) { /* ... */ } );
wp.customize.section.each( function ( section ) { /* ... */ } );

Relating Controls, Sections, and Panels together

When registering a new control in PHP, you pass in the parent section ID:

$wp_customize->add_control( 'blogname', array(
	'label' => __( 'Site Title' ),
	'section' => 'title_tagline',
) );

In the proposed JavaScript API, a control’s section can be obtained predictably:

id = wp.customize.control( 'blogname' ).section(); // => title_tagline

To get the section object from the ID, you just look up the section by the ID as normal: wp.customize.section( id ).

You can move a control to another section using this section state as well, here moving it to the Navigation section:

wp.customize.control( 'blogname' ).section( 'nav' );

Likewise, you can get a section’s panel ID in the same way:

id = wp.customize.section( 'sidebar-widgets-sidebar-1' ).panel(); // => widgets

You can go the other way as well, to get the children of panels and sections:

sections = wp.customize.panel( 'widgets' ).sections();
controls = wp.customize.section( 'title_tagline' ).controls();

You can use these to move all controls from one section to another:

_.each( wp.customize.section( 'title_tagline' ).controls(), function ( control ) {
	control.section( 'nav' );
});

Contextual Panels and Sections

Also just as with controls, when you invoke $wp_customize->add_section() you can pass an active_callback param to indicate whether the section is relevant to the currently-previewed URL; the same goes for panels. A good example of a contextual section is only showing the “Static Front Page” section if the preview is currently on the front-page:

function contextual_static_front_page_section( $wp_customize ) {
	$wp_customize->get_section( 'static_front_page' )->active_callback = 'is_front_page';
}
add_action( 'customize_register', 'contextual_static_front_page_section', 11 );

Nevertheless, this will not usually be needed because a section inherits its active state from its control children (and a panel inherits from its section children), via the new isContextuallyActive() method. If all controls within a section become inactive, then the section will automatically become inactive.

As with controls, Panel and Section instances have an active state (a wp.customize.Value instance). When the active state changes, the panel, section, and control instances invoke their respective onChangeActive method, which by default slides the container element up and down, if false and true respectively. There are also activate() and deactivate() methods now for manipulating this active state, for panels, sections, and controls:

wp.customize.section( 'nav' ).deactivate(); // slide up
wp.customize.section( 'nav' ).activate({ duration: 1000 }); // slide down slowly
wp.customize.section( 'colors' ).deactivate({ duration: 0 }); // hide immediately
wp.customize.section( 'nav' ).deactivate({ completeCallback: function () {
	wp.customize.section( 'colors' ).activate(); // show after nav hides completely
} });

Note that manually changing the active state would only stick until the preview refreshes or loads another URL. The activate()/deactivate() methods are designed to follow the pattern of the new expanded state.

Expanded State

As noted above, in 4.0 when panels were introduced, a bug was introduced whereby shift-clicking a widget in the preview fails to show the widget control if the Widgets panel is not already open. With the proposed changes, panels, sections, and (widget) controls have an expanded state (another wp.customize.Value instance). When the state changes, the onChangeExpanded method is called which by will handle Panels sliding in and out, and sections sliding up and down (and widget controls up and down, as they are like sections). So now when a widget control needs to be shown, the control’s section and panel can simply have their expanded state to true in order to reveal the control. Expanding a section automatically expands its parent panel. Expanding a widget control, automatically expands its containing section and that section’s panel.

As with activate()/deactivate() to manage the active state, there are expand() and collapse() methods to manage the expanded state. These methods also take a similar params object, including duration and completeCallback. The params object for Section.expand() accepts an additional parameter “allowMultiple” to facilitate dragging widget controls between sidebar sections. By default expanding one section will automatically collapse all other open sections, and so this param overrides that. You can use this, for instance, to expand all sections at once so you can see all controls without having to click to reveal each accordion section one by one:

wp.customize.section.each(function ( section ) {
	if ( ! section.panel() ) {
		section.expand({ allowMultiple: true });
	}
});

Focusing

Building upon the expand()/collapse() methods for panels, sections, and controls, these models also support a focus() method which not only expands all of the necessary element, but also scrolls the target container into view and puts the browser focus on the first focusable element in the container. For instance, to expand the “Static Front Page” section and focus on select dropdown for the “Front page”:

wp.customize.control( 'page_on_front' ).focus()

This naturally fixes the #29529, mentioned above.

The focus functionality is used to implement autofocus: deep-linking to panels, sections, and controls inside of the customizer. Consider these URLs:

  • …/wp-admin/customize.php?autofocus[panel]=widgets
  • …/wp-admin/customize.php?autofocus[section]=colors
  • …/wp-admin/customize.php?autofocus[control]=blogname

This can be used to add a link on the widgets admin page to link directly to the widgets panel within the Customizer.

Priorities

When registering a panel, section, or control in PHP, you can supply a priority parameter. This value is now stored in a wp.customize.Value instance for each respective Panel, Section, and Control instance. For example, you can obtain the priority for the widgets panel via:

priority = wp.customize.panel( 'widgets' ).priority(); // => 110

You can then dynamically change the priority and the Customizer panel will automatically re-arrange to reflect the new priorities:

wp.customize.panel( 'widgets' ).priority( 1 ); // move Widgets to the top

Custom types for Panels and Sections

Just as Customizer controls can have custom types (ColorControlImageControlHeaderControl…) which have custom behaviors in JS:

wp.customize.controlConstructor.FooControl = wp.customize.Control.extend({ /*...*/ });

So too can Panels and Sections have custom behaviors in the proposed changes. A type parameter can be passed when creating a Panel or Section, and then in JavaScript a constructor corresponding to that type can be registered. For instance:

PHP:

add_action( 'customize_register', function ( $wp_customize ) {
	class WP_Customize_Harlem_Shake_Section extends WP_Customize_Section {
		public $type = 'HarlemShake';
	}
	$section = new WP_Customize_Harlem_Shake_Section(
		$wp_customize,
		'harlem_shake',
		array( 'title' => __( 'Harlem Shake' ) )
	);
	$wp_customize->add_section( $section );
	$wp_customize->add_setting( 'harlem_shake_countdown', array(
		'default' => 15,
	));
	$wp_customize->add_control( 'harlem_shake_countdown', array(
		'label' => __( 'Countdown' ),
		'section' => 'harlem_shake',
		'setting' => 'harlem_shake_countdown',
		'type' => 'number',
	));
});

JS:

wp.customize.sectionConstructor.HarlemShake = wp.customize.Section.extend({
	shake: function () {
		// This can be invoked via wp.customize.section( 'harlem_shake' ).shake();
		console.info( 'SHAKE!!' );
	}
});

Next Steps

  • Continue discussion on parent ticket #28709: Improve/introduce Customizer JS models for Controls, Sections, and Panels.
  • Review JavaScript changes in pull request. Anyone is free to open a PR to onto the existing branch on GitHub to make changes. Props to Ryan Kienstra (@ryankienstra) and Nick Halsey (@celloexpressions) for their contributions.
  • Update logic for adding widget controls to use new API (adding widgets is using the old pseudo-API and it is currently broken); allow controls to be added manually.
  • Work with Nick Halsey to make sure that dynamically-created sections and controls suit the needs of Menu Customizer, and make sure that it works for other plugins like Customize Posts.
  • Build upon the initial QUnit tests to add coverage for existing JS API and newly added API (#28579).
  • Harden the logic for animating the Customizer panel into view.
  • Get feedback from other Core devs and get initial patch committed.

Thanks to Nick Halsey (@celloexpressions) for his proofreading and feedback on the drafts of this blog post.

#customize, #menu-customizer, #menus, #widget-customizer