* @output wp-admin/js/customize-widgets.js
/* global _wpCustomizeWidgetsSettings */
if ( ! wp || ! wp.customize ) { return; }
// Set up our namespace...
* @namespace wp.customize.Widgets
api.Widgets = api.Widgets || {};
api.Widgets.savedWidgetIds = {};
api.Widgets.data = _wpCustomizeWidgetsSettings || {};
l10n = api.Widgets.data.l10n;
* wp.customize.Widgets.WidgetModel
* @class wp.customize.Widgets.WidgetModel
* @augments Backbone.Model
api.Widgets.WidgetModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.WidgetModel.prototype */{
* wp.customize.Widgets.WidgetCollection
* Collection for widget models.
* @class wp.customize.Widgets.WidgetCollection
* @augments Backbone.Collection
api.Widgets.WidgetCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.WidgetCollection.prototype */{
model: api.Widgets.WidgetModel,
// Controls searching on the current widget collection
// and triggers an update event.
doSearch: function( value ) {
// Don't do anything if we've already done this search.
// Useful because the search handler fires multiple times per keystroke.
if ( this.terms === value ) {
// Updates terms with the value passed.
// If we have terms, run a search...
if ( this.terms.length > 0 ) {
this.search( this.terms );
// If search is blank, set all the widgets as they matched the search to reset the views.
if ( this.terms === '' ) {
this.each( function ( widget ) {
widget.set( 'search_matched', true );
// Performs a search within the collection.
search: function( term ) {
// Escape the term string for RegExp meta characters.
term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
// Consider spaces as word delimiters and match the whole string
// so matching terms can be combined.
term = term.replace( / /g, ')(?=.*' );
match = new RegExp( '^(?=.*' + term + ').+', 'i' );
this.each( function ( data ) {
haystack = [ data.get( 'name' ), data.get( 'description' ) ].join( ' ' );
data.set( 'search_matched', match.test( haystack ) );
api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
* wp.customize.Widgets.SidebarModel
* A single sidebar model.
* @class wp.customize.Widgets.SidebarModel
* @augments Backbone.Model
api.Widgets.SidebarModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.SidebarModel.prototype */{
* wp.customize.Widgets.SidebarCollection
* Collection for sidebar models.
* @class wp.customize.Widgets.SidebarCollection
* @augments Backbone.Collection
api.Widgets.SidebarCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.SidebarCollection.prototype */{
model: api.Widgets.SidebarModel
api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend(/** @lends wp.customize.Widgets.AvailableWidgetsPanelView.prototype */{
el: '#available-widgets',
'input #widgets-search': 'search',
'focus .widget-tpl' : 'focus',
'click .widget-tpl' : '_submit',
'keypress .widget-tpl' : '_submit',
'keydown' : 'keyboardAccessible'
// Cache current selected widget.
// Cache sidebar control which has opened panel.
currentSidebarControl: null,
searchMatchesCount: null,
* View class for the available widgets panel.
* @constructs wp.customize.Widgets.AvailableWidgetsPanelView
* @augments wp.Backbone.View
this.$search = $( '#widgets-search' );
this.$clearResults = this.$el.find( '.clear-results' );
_.bindAll( this, 'close' );
this.listenTo( this.collection, 'change', this.updateList );
// Set the initial search count to the number of available widgets.
this.searchMatchesCount = this.collection.length;
* If the available widgets panel is open and the customize controls
* are interacted with (i.e. available widgets panel is blurred) then
* close the available widgets panel. Also close on back button click.
$( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) {
var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
// Clear the search results and trigger an `input` event to fire a new search.
this.$clearResults.on( 'click', function() {
self.$search.val( '' ).focus().trigger( 'input' );
// Close the panel if the URL in the preview changes.
api.previewer.bind( 'url', this.close );
* Performs a search and handles selected widget.
search: _.debounce( function( event ) {
this.collection.doSearch( event.target.value );
// Update the search matches count.
this.updateSearchMatchesCount();
// Announce how many search results.
this.announceSearchMatches();
// Remove a widget from being selected if it is no longer visible.
if ( this.selected && ! this.selected.is( ':visible' ) ) {
this.selected.removeClass( 'selected' );
// If a widget was selected but the filter value has been cleared out, clear selection.
if ( this.selected && ! event.target.value ) {
this.selected.removeClass( 'selected' );
// If a filter has been entered and a widget hasn't been selected, select the first one shown.
if ( ! this.selected && event.target.value ) {
firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
if ( firstVisible.length ) {
this.select( firstVisible );
// Toggle the clear search results button.
if ( '' !== event.target.value ) {
this.$clearResults.addClass( 'is-visible' );
} else if ( '' === event.target.value ) {
this.$clearResults.removeClass( 'is-visible' );
// Set a CSS class on the search container when there are no search results.
if ( ! this.searchMatchesCount ) {
this.$el.addClass( 'no-widgets-found' );
this.$el.removeClass( 'no-widgets-found' );
* Updates the count of the available widgets that have the `search_matched` attribute.
updateSearchMatchesCount: function() {
this.searchMatchesCount = this.collection.where({ search_matched: true }).length;
* Sends a message to the aria-live region to announce how many search results.
announceSearchMatches: function() {
var message = l10n.widgetsFound.replace( '%d', this.searchMatchesCount ) ;
if ( ! this.searchMatchesCount ) {
message = l10n.noWidgetsFound;
wp.a11y.speak( message );
* Changes visibility of available widgets.
this.collection.each( function( widget ) {
var widgetTpl = $( '#widget-tpl-' + widget.id );
widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
select: function( widgetTpl ) {
this.selected = $( widgetTpl );
this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
this.selected.addClass( 'selected' );
* Highlights a widget on focus.
focus: function( event ) {
this.select( $( event.currentTarget ) );
* Handles submit for keypress and click on widget.
_submit: function( event ) {
// Only proceed with keypress if it is Enter or Spacebar.
if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
this.submit( $( event.currentTarget ) );
* Adds a selected widget to the sidebar.
submit: function( widgetTpl ) {
var widgetId, widget, widgetFormControl;
widgetTpl = this.selected;
if ( ! widgetTpl || ! this.currentSidebarControl ) {
this.select( widgetTpl );
widgetId = $( this.selected ).data( 'widget-id' );
widget = this.collection.findWhere( { id: widgetId } );
widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
if ( widgetFormControl ) {
widgetFormControl.focus();
open: function( sidebarControl ) {
this.currentSidebarControl = sidebarControl;
// Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens.
_( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
if ( control.params.is_wide ) {
if ( api.section.has( 'publish_settings' ) ) {
api.section( 'publish_settings' ).collapse();
$( 'body' ).addClass( 'adding-widget' );
this.$el.find( '.selected' ).removeClass( 'selected' );
this.collection.doSearch( '' );
if ( ! api.settings.browser.mobile ) {
close: function( options ) {
if ( options.returnFocus && this.currentSidebarControl ) {
this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
this.currentSidebarControl = null;
$( 'body' ).removeClass( 'adding-widget' );
this.$search.val( '' ).trigger( 'input' );
* Adds keyboard accessiblity to the panel.
keyboardAccessible: function( event ) {
var isEnter = ( event.which === 13 ),
isEsc = ( event.which === 27 ),
isDown = ( event.which === 40 ),
isUp = ( event.which === 38 ),
isTab = ( event.which === 9 ),
isShift = ( event.shiftKey ),
firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
isSearchFocused = $( event.target ).is( this.$search ),
isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
} else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
selected = this.selected.nextAll( '.widget-tpl:visible:first' );
} else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
selected = this.selected.prevAll( '.widget-tpl:visible:first' );
// If enter pressed but nothing entered, don't do anything.
if ( isEnter && ! this.$search.val() ) {
this.close( { returnFocus: true } );
if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
* Handlers for the widget-synced event, organized by widget ID base.
* Other widgets may provide their own update handlers by adding
* listeners for the widget-synced event.
* @alias wp.customize.Widgets.formSyncHandlers
api.Widgets.formSyncHandlers = {
* @param {jQuery.Event} e
* @param {string} newForm
rss: function( e, widget, newForm ) {
var oldWidgetError = widget.find( '.widget-error:first' ),
newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
if ( oldWidgetError.length && newWidgetError.length ) {
oldWidgetError.replaceWith( newWidgetError );
} else if ( oldWidgetError.length ) {
} else if ( newWidgetError.length ) {
widget.find( '.widget-content:first' ).prepend( newWidgetError );
api.Widgets.WidgetControl = api.Control.extend(/** @lends wp.customize.Widgets.WidgetControl.prototype */{
defaultExpandedArguments: {
* wp.customize.Widgets.WidgetControl
* Customizer control for widgets.
* Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
* @constructs wp.customize.Widgets.WidgetControl
* @augments wp.customize.Control
initialize: function( id, options ) {
control.widgetControlEmbedded = false;
control.widgetContentEmbedded = false;
control.expanded = new api.Value( false );