1 /* global _wpCustomizeWidgetsSettings */
4 if ( ! wp || ! wp.customize ) { return; }
6 // Set up our namespace...
7 var api = wp.customize,
10 api.Widgets = api.Widgets || {};
11 api.Widgets.savedWidgetIds = {};
14 api.Widgets.data = _wpCustomizeWidgetsSettings || {};
15 l10n = api.Widgets.data.l10n;
16 delete api.Widgets.data.l10n;
19 * wp.customize.Widgets.WidgetModel
21 * A single widget model.
24 * @augments Backbone.Model
26 api.Widgets.WidgetModel = Backbone.Model.extend({
45 * wp.customize.Widgets.WidgetCollection
47 * Collection for widget models.
50 * @augments Backbone.Model
52 api.Widgets.WidgetCollection = Backbone.Collection.extend({
53 model: api.Widgets.WidgetModel,
55 // Controls searching on the current widget collection
56 // and triggers an update event
57 doSearch: function( value ) {
59 // Don't do anything if we've already done this search
60 // Useful because the search handler fires multiple times per keystroke
61 if ( this.terms === value ) {
65 // Updates terms with the value passed
68 // If we have terms, run a search...
69 if ( this.terms.length > 0 ) {
70 this.search( this.terms );
73 // If search is blank, set all the widgets as they matched the search to reset the views.
74 if ( this.terms === '' ) {
75 this.each( function ( widget ) {
76 widget.set( 'search_matched', true );
81 // Performs a search within the collection
83 search: function( term ) {
86 // Escape the term string for RegExp meta characters
87 term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
89 // Consider spaces as word delimiters and match the whole string
90 // so matching terms can be combined
91 term = term.replace( / /g, ')(?=.*' );
92 match = new RegExp( '^(?=.*' + term + ').+', 'i' );
94 this.each( function ( data ) {
95 haystack = [ data.get( 'name' ), data.get( 'id' ), data.get( 'description' ) ].join( ' ' );
96 data.set( 'search_matched', match.test( haystack ) );
100 api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
103 * wp.customize.Widgets.SidebarModel
105 * A single sidebar model.
108 * @augments Backbone.Model
110 api.Widgets.SidebarModel = Backbone.Model.extend({
123 * wp.customize.Widgets.SidebarCollection
125 * Collection for sidebar models.
128 * @augments Backbone.Collection
130 api.Widgets.SidebarCollection = Backbone.Collection.extend({
131 model: api.Widgets.SidebarModel
133 api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
136 * wp.customize.Widgets.AvailableWidgetsPanelView
138 * View class for the available widgets panel.
141 * @augments wp.Backbone.View
142 * @augments Backbone.View
144 api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend({
146 el: '#available-widgets',
149 'input #widgets-search': 'search',
150 'keyup #widgets-search': 'search',
151 'focus .widget-tpl' : 'focus',
152 'click .widget-tpl' : '_submit',
153 'keypress .widget-tpl' : '_submit',
154 'keydown' : 'keyboardAccessible'
157 // Cache current selected widget
160 // Cache sidebar control which has opened panel
161 currentSidebarControl: null,
164 searchMatchesCount: null,
166 initialize: function() {
169 this.$search = $( '#widgets-search' );
171 this.$clearResults = this.$el.find( '.clear-results' );
173 _.bindAll( this, 'close' );
175 this.listenTo( this.collection, 'change', this.updateList );
179 // Set the initial search count to the number of available widgets.
180 this.searchMatchesCount = this.collection.length;
182 // If the available widgets panel is open and the customize controls are
183 // interacted with (i.e. available widgets panel is blurred) then close the
184 // available widgets panel. Also close on back button click.
185 $( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) {
186 var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
187 if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
192 // Clear the search results and trigger a `keyup` event to fire a new search.
193 this.$clearResults.on( 'click', function() {
194 self.$search.val( '' ).focus().trigger( 'keyup' );
197 // Close the panel if the URL in the preview changes
198 api.previewer.bind( 'url', this.close );
201 // Performs a search and handles selected widget
202 search: function( event ) {
205 this.collection.doSearch( event.target.value );
206 // Update the search matches count.
207 this.updateSearchMatchesCount();
208 // Announce how many search results.
209 this.announceSearchMatches();
211 // Remove a widget from being selected if it is no longer visible
212 if ( this.selected && ! this.selected.is( ':visible' ) ) {
213 this.selected.removeClass( 'selected' );
214 this.selected = null;
217 // If a widget was selected but the filter value has been cleared out, clear selection
218 if ( this.selected && ! event.target.value ) {
219 this.selected.removeClass( 'selected' );
220 this.selected = null;
223 // If a filter has been entered and a widget hasn't been selected, select the first one shown
224 if ( ! this.selected && event.target.value ) {
225 firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
226 if ( firstVisible.length ) {
227 this.select( firstVisible );
231 // Toggle the clear search results button.
232 if ( '' !== event.target.value ) {
233 this.$clearResults.addClass( 'is-visible' );
234 } else if ( '' === event.target.value ) {
235 this.$clearResults.removeClass( 'is-visible' );
238 // Set a CSS class on the search container when there are no search results.
239 if ( ! this.searchMatchesCount ) {
240 this.$el.addClass( 'no-widgets-found' );
242 this.$el.removeClass( 'no-widgets-found' );
246 // Update the count of the available widgets that have the `search_matched` attribute.
247 updateSearchMatchesCount: function() {
248 this.searchMatchesCount = this.collection.where({ search_matched: true }).length;
251 // Send a message to the aria-live region to announce how many search results.
252 announceSearchMatches: _.debounce( function() {
253 var message = l10n.widgetsFound.replace( '%d', this.searchMatchesCount ) ;
255 if ( ! this.searchMatchesCount ) {
256 message = l10n.noWidgetsFound;
259 wp.a11y.speak( message );
262 // Changes visibility of available widgets
263 updateList: function() {
264 this.collection.each( function( widget ) {
265 var widgetTpl = $( '#widget-tpl-' + widget.id );
266 widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
267 if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
268 this.selected = null;
273 // Highlights a widget
274 select: function( widgetTpl ) {
275 this.selected = $( widgetTpl );
276 this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
277 this.selected.addClass( 'selected' );
280 // Highlights a widget on focus
281 focus: function( event ) {
282 this.select( $( event.currentTarget ) );
285 // Submit handler for keypress and click on widget
286 _submit: function( event ) {
287 // Only proceed with keypress if it is Enter or Spacebar
288 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
292 this.submit( $( event.currentTarget ) );
295 // Adds a selected widget to the sidebar
296 submit: function( widgetTpl ) {
297 var widgetId, widget, widgetFormControl;
300 widgetTpl = this.selected;
303 if ( ! widgetTpl || ! this.currentSidebarControl ) {
307 this.select( widgetTpl );
309 widgetId = $( this.selected ).data( 'widget-id' );
310 widget = this.collection.findWhere( { id: widgetId } );
315 widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
316 if ( widgetFormControl ) {
317 widgetFormControl.focus();
324 open: function( sidebarControl ) {
325 this.currentSidebarControl = sidebarControl;
327 // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens
328 _( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
329 if ( control.params.is_wide ) {
330 control.collapseForm();
334 $( 'body' ).addClass( 'adding-widget' );
336 this.$el.find( '.selected' ).removeClass( 'selected' );
339 this.collection.doSearch( '' );
341 if ( ! api.settings.browser.mobile ) {
342 this.$search.focus();
347 close: function( options ) {
348 options = options || {};
350 if ( options.returnFocus && this.currentSidebarControl ) {
351 this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
354 this.currentSidebarControl = null;
355 this.selected = null;
357 $( 'body' ).removeClass( 'adding-widget' );
359 this.$search.val( '' );
362 // Add keyboard accessiblity to the panel
363 keyboardAccessible: function( event ) {
364 var isEnter = ( event.which === 13 ),
365 isEsc = ( event.which === 27 ),
366 isDown = ( event.which === 40 ),
367 isUp = ( event.which === 38 ),
368 isTab = ( event.which === 9 ),
369 isShift = ( event.shiftKey ),
371 firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
372 lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
373 isSearchFocused = $( event.target ).is( this.$search ),
374 isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
376 if ( isDown || isUp ) {
378 if ( isSearchFocused ) {
379 selected = firstVisible;
380 } else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
381 selected = this.selected.nextAll( '.widget-tpl:visible:first' );
384 if ( isSearchFocused ) {
385 selected = lastVisible;
386 } else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
387 selected = this.selected.prevAll( '.widget-tpl:visible:first' );
391 this.select( selected );
396 this.$search.focus();
402 // If enter pressed but nothing entered, don't do anything
403 if ( isEnter && ! this.$search.val() ) {
409 } else if ( isEsc ) {
410 this.close( { returnFocus: true } );
413 if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
414 this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
415 event.preventDefault();
421 * Handlers for the widget-synced event, organized by widget ID base.
422 * Other widgets may provide their own update handlers by adding
423 * listeners for the widget-synced event.
425 api.Widgets.formSyncHandlers = {
428 * @param {jQuery.Event} e
429 * @param {jQuery} widget
430 * @param {String} newForm
432 rss: function( e, widget, newForm ) {
433 var oldWidgetError = widget.find( '.widget-error:first' ),
434 newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
436 if ( oldWidgetError.length && newWidgetError.length ) {
437 oldWidgetError.replaceWith( newWidgetError );
438 } else if ( oldWidgetError.length ) {
439 oldWidgetError.remove();
440 } else if ( newWidgetError.length ) {
441 widget.find( '.widget-content:first' ).prepend( newWidgetError );
447 * wp.customize.Widgets.WidgetControl
449 * Customizer control for widgets.
450 * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
453 * @augments wp.customize.Control
455 api.Widgets.WidgetControl = api.Control.extend({
456 defaultExpandedArguments: {
458 completeCallback: $.noop
464 initialize: function( id, options ) {
467 control.widgetControlEmbedded = false;
468 control.widgetContentEmbedded = false;
469 control.expanded = new api.Value( false );
470 control.expandedArgumentsQueue = [];
471 control.expanded.bind( function( expanded ) {
472 var args = control.expandedArgumentsQueue.shift();
473 args = $.extend( {}, control.defaultExpandedArguments, args );
474 control.onChangeExpanded( expanded, args );
476 control.altNotice = true;
478 api.Control.prototype.initialize.call( control, id, options );
482 * Set up the control.
490 * Embed a placeholder once the section is expanded. The full widget
491 * form content will be embedded once the control itself is expanded,
492 * and at this point the widget-added event will be triggered.
494 if ( ! control.section() ) {
495 control.embedWidgetControl();
497 api.section( control.section(), function( section ) {
498 var onExpanded = function( isExpanded ) {
500 control.embedWidgetControl();
501 section.expanded.unbind( onExpanded );
504 if ( section.expanded() ) {
507 section.expanded.bind( onExpanded );
514 * Embed the .widget element inside the li container.
518 embedWidgetControl: function() {
519 var control = this, widgetControl;
521 if ( control.widgetControlEmbedded ) {
524 control.widgetControlEmbedded = true;
526 widgetControl = $( control.params.widget_control );
527 control.container.append( widgetControl );
529 control._setupModel();
530 control._setupWideWidget();
531 control._setupControlToggle();
533 control._setupWidgetTitle();
534 control._setupReorderUI();
535 control._setupHighlightEffects();
536 control._setupUpdateUI();
537 control._setupRemoveUI();
541 * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event.
545 embedWidgetContent: function() {
546 var control = this, widgetContent;
548 control.embedWidgetControl();
549 if ( control.widgetContentEmbedded ) {
552 control.widgetContentEmbedded = true;
554 widgetContent = $( control.params.widget_content );
555 control.container.find( '.widget-content:first' ).append( widgetContent );
558 * Trigger widget-added event so that plugins can attach any event
559 * listeners and dynamic UI elements.
561 $( document ).trigger( 'widget-added', [ control.container.find( '.widget:first' ) ] );
566 * Handle changes to the setting
568 _setupModel: function() {
569 var self = this, rememberSavedWidgetId;
571 // Remember saved widgets so we know which to trash (move to inactive widgets sidebar)
572 rememberSavedWidgetId = function() {
573 api.Widgets.savedWidgetIds[self.params.widget_id] = true;
575 api.bind( 'ready', rememberSavedWidgetId );
576 api.bind( 'saved', rememberSavedWidgetId );
578 this._updateCount = 0;
579 this.isWidgetUpdating = false;
580 this.liveUpdateMode = true;
582 // Update widget whenever model changes
583 this.setting.bind( function( to, from ) {
584 if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) {
585 self.updateWidget( { instance: to } );
591 * Add special behaviors for wide widget controls
593 _setupWideWidget: function() {
594 var self = this, $widgetInside, $widgetForm, $customizeSidebar,
595 $themeControlsContainer, positionWidget;
597 if ( ! this.params.is_wide ) {
601 $widgetInside = this.container.find( '.widget-inside' );
602 $widgetForm = $widgetInside.find( '> .form' );
603 $customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' );
604 this.container.addClass( 'wide-widget-control' );
606 this.container.find( '.widget-content:first' ).css( {
607 'max-width': this.params.width,
608 'min-height': this.params.height
612 * Keep the widget-inside positioned so the top of fixed-positioned
613 * element is at the same top position as the widget-top. When the
614 * widget-top is scrolled out of view, keep the widget-top in view;
615 * likewise, don't allow the widget to drop off the bottom of the window.
616 * If a widget is too tall to fit in the window, don't let the height
617 * exceed the window height so that the contents of the widget control
618 * will become scrollable (overflow:auto).
620 positionWidget = function() {
621 var offsetTop = self.container.offset().top,
622 windowHeight = $( window ).height(),
623 formHeight = $widgetForm.outerHeight(),
625 $widgetInside.css( 'max-height', windowHeight );
627 0, // prevent top from going off screen
629 Math.max( offsetTop, 0 ), // distance widget in panel is from top of screen
630 windowHeight - formHeight // flush up against bottom of screen
633 $widgetInside.css( 'top', top );
636 $themeControlsContainer = $( '#customize-theme-controls' );
637 this.container.on( 'expand', function() {
639 $customizeSidebar.on( 'scroll', positionWidget );
640 $( window ).on( 'resize', positionWidget );
641 $themeControlsContainer.on( 'expanded collapsed', positionWidget );
643 this.container.on( 'collapsed', function() {
644 $customizeSidebar.off( 'scroll', positionWidget );
645 $( window ).off( 'resize', positionWidget );
646 $themeControlsContainer.off( 'expanded collapsed', positionWidget );
649 // Reposition whenever a sidebar's widgets are changed
650 api.each( function( setting ) {
651 if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) {
652 setting.bind( function() {
653 if ( self.container.hasClass( 'expanded' ) ) {
662 * Show/hide the control when clicking on the form title, when clicking
665 _setupControlToggle: function() {
666 var self = this, $closeBtn;
668 this.container.find( '.widget-top' ).on( 'click', function( e ) {
670 var sidebarWidgetsControl = self.getSidebarWidgetsControl();
671 if ( sidebarWidgetsControl.isReordering ) {
674 self.expanded( ! self.expanded() );
677 $closeBtn = this.container.find( '.widget-control-close' );
678 $closeBtn.on( 'click', function( e ) {
681 self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility
686 * Update the title of the form if a title field is entered
688 _setupWidgetTitle: function() {
689 var self = this, updateTitle;
691 updateTitle = function() {
692 var title = self.setting().title,
693 inWidgetTitle = self.container.find( '.in-widget-title' );
696 inWidgetTitle.text( ': ' + title );
698 inWidgetTitle.text( '' );
701 this.setting.bind( updateTitle );
706 * Set up the widget-reorder-nav
708 _setupReorderUI: function() {
709 var self = this, selectSidebarItem, $moveWidgetArea,
710 $reorderNav, updateAvailableSidebars, template;
713 * select the provided sidebar list item in the move widget area
717 selectSidebarItem = function( li ) {
718 li.siblings( '.selected' ).removeClass( 'selected' );
719 li.addClass( 'selected' );
720 var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id );
721 self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar );
725 * Add the widget reordering elements to the widget control
727 this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) );
730 template = _.template( api.Widgets.data.tpl.moveWidgetArea );
731 $moveWidgetArea = $( template( {
732 sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' )
735 this.container.find( '.widget-top' ).after( $moveWidgetArea );
738 * Update available sidebars when their rendered state changes
740 updateAvailableSidebars = function() {
741 var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem,
742 renderedSidebarCount = 0;
744 selfSidebarItem = $sidebarItems.filter( function(){
745 return $( this ).data( 'id' ) === self.params.sidebar_id;
748 $sidebarItems.each( function() {
750 sidebarId, sidebar, sidebarIsRendered;
752 sidebarId = li.data( 'id' );
753 sidebar = api.Widgets.registeredSidebars.get( sidebarId );
754 sidebarIsRendered = sidebar.get( 'is_rendered' );
756 li.toggle( sidebarIsRendered );
758 if ( sidebarIsRendered ) {
759 renderedSidebarCount += 1;
762 if ( li.hasClass( 'selected' ) && ! sidebarIsRendered ) {
763 selectSidebarItem( selfSidebarItem );
767 if ( renderedSidebarCount > 1 ) {
768 self.container.find( '.move-widget' ).show();
770 self.container.find( '.move-widget' ).hide();
774 updateAvailableSidebars();
775 api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars );
778 * Handle clicks for up/down/move on the reorder nav
780 $reorderNav = this.container.find( '.widget-reorder-nav' );
781 $reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() {
782 $( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' );
783 } ).on( 'click keypress', function( event ) {
784 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
789 if ( $( this ).is( '.move-widget' ) ) {
790 self.toggleWidgetMoveArea();
792 var isMoveDown = $( this ).is( '.move-widget-down' ),
793 isMoveUp = $( this ).is( '.move-widget-up' ),
794 i = self.getWidgetSidebarPosition();
796 if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) {
802 wp.a11y.speak( l10n.widgetMovedUp );
805 wp.a11y.speak( l10n.widgetMovedDown );
808 $( this ).focus(); // re-focus after the container was moved
813 * Handle selecting a sidebar to move to
815 this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( event ) {
816 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
819 event.preventDefault();
820 selectSidebarItem( $( this ) );
824 * Move widget to another sidebar
826 this.container.find( '.move-widget-btn' ).click( function() {
827 self.getSidebarWidgetsControl().toggleReordering( false );
829 var oldSidebarId = self.params.sidebar_id,
830 newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ),
831 oldSidebarWidgetsSetting, newSidebarWidgetsSetting,
832 oldSidebarWidgetIds, newSidebarWidgetIds, i;
834 oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' );
835 newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' );
836 oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() );
837 newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() );
839 i = self.getWidgetSidebarPosition();
840 oldSidebarWidgetIds.splice( i, 1 );
841 newSidebarWidgetIds.push( self.params.widget_id );
843 oldSidebarWidgetsSetting( oldSidebarWidgetIds );
844 newSidebarWidgetsSetting( newSidebarWidgetIds );
851 * Highlight widgets in preview when interacted with in the Customizer
853 _setupHighlightEffects: function() {
856 // Highlight whenever hovering or clicking over the form
857 this.container.on( 'mouseenter click', function() {
858 self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
861 // Highlight when the setting is updated
862 this.setting.bind( function() {
863 self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
868 * Set up event handlers for widget updating
870 _setupUpdateUI: function() {
871 var self = this, $widgetRoot, $widgetContent,
872 $saveBtn, updateWidgetDebounced, formSyncHandler;
874 $widgetRoot = this.container.find( '.widget:first' );
875 $widgetContent = $widgetRoot.find( '.widget-content:first' );
877 // Configure update button
878 $saveBtn = this.container.find( '.widget-control-save' );
879 $saveBtn.val( l10n.saveBtnLabel );
880 $saveBtn.attr( 'title', l10n.saveBtnTooltip );
881 $saveBtn.removeClass( 'button-primary' );
882 $saveBtn.on( 'click', function( e ) {
884 self.updateWidget( { disable_form: true } ); // @todo disable_form is unused?
887 updateWidgetDebounced = _.debounce( function() {
891 // Trigger widget form update when hitting Enter within an input
892 $widgetContent.on( 'keydown', 'input', function( e ) {
893 if ( 13 === e.which ) { // Enter
895 self.updateWidget( { ignoreActiveElement: true } );
899 // Handle widgets that support live previews
900 $widgetContent.on( 'change input propertychange', ':input', function( e ) {
901 if ( ! self.liveUpdateMode ) {
904 if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) {
905 updateWidgetDebounced();
909 // Remove loading indicators when the setting is saved and the preview updates
910 this.setting.previewer.channel.bind( 'synced', function() {
911 self.container.removeClass( 'previewer-loading' );
914 api.previewer.bind( 'widget-updated', function( updatedWidgetId ) {
915 if ( updatedWidgetId === self.params.widget_id ) {
916 self.container.removeClass( 'previewer-loading' );
920 formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ];
921 if ( formSyncHandler ) {
922 $( document ).on( 'widget-synced', function( e, widget ) {
923 if ( $widgetRoot.is( widget ) ) {
924 formSyncHandler.apply( document, arguments );
931 * Update widget control to indicate whether it is currently rendered.
933 * Overrides api.Control.toggle()
937 * @param {Boolean} active
938 * @param {Object} args
939 * @param {Callback} args.completeCallback
941 onChangeActive: function ( active, args ) {
942 // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments
943 this.container.toggleClass( 'widget-rendered', active );
944 if ( args.completeCallback ) {
945 args.completeCallback();
950 * Set up event handlers for widget removal
952 _setupRemoveUI: function() {
953 var self = this, $removeBtn, replaceDeleteWithRemove;
955 // Configure remove button
956 $removeBtn = this.container.find( 'a.widget-control-remove' );
957 $removeBtn.on( 'click', function( e ) {
960 // Find an adjacent element to add focus to when this widget goes away
961 var $adjacentFocusTarget;
962 if ( self.container.next().is( '.customize-control-widget_form' ) ) {
963 $adjacentFocusTarget = self.container.next().find( '.widget-action:first' );
964 } else if ( self.container.prev().is( '.customize-control-widget_form' ) ) {
965 $adjacentFocusTarget = self.container.prev().find( '.widget-action:first' );
967 $adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' );
970 self.container.slideUp( function() {
971 var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ),
974 if ( ! sidebarsWidgetsControl ) {
978 sidebarWidgetIds = sidebarsWidgetsControl.setting().slice();
979 i = _.indexOf( sidebarWidgetIds, self.params.widget_id );
984 sidebarWidgetIds.splice( i, 1 );
985 sidebarsWidgetsControl.setting( sidebarWidgetIds );
987 $adjacentFocusTarget.focus(); // keyboard accessibility
991 replaceDeleteWithRemove = function() {
992 $removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the link as "Delete"
993 $removeBtn.attr( 'title', l10n.removeBtnTooltip );
996 if ( this.params.is_new ) {
997 api.bind( 'saved', replaceDeleteWithRemove );
999 replaceDeleteWithRemove();
1004 * Find all inputs in a widget container that should be considered when
1005 * comparing the loaded form with the sanitized form, whose fields will
1006 * be aligned to copy the sanitized over. The elements returned by this
1007 * are passed into this._getInputsSignature(), and they are iterated
1008 * over when copying sanitized values over to the form loaded.
1010 * @param {jQuery} container element in which to look for inputs
1011 * @returns {jQuery} inputs
1014 _getInputs: function( container ) {
1015 return $( container ).find( ':input[name]' );
1019 * Iterate over supplied inputs and create a signature string for all of them together.
1020 * This string can be used to compare whether or not the form has all of the same fields.
1022 * @param {jQuery} inputs
1026 _getInputsSignature: function( inputs ) {
1027 var inputsSignatures = _( inputs ).map( function( input ) {
1028 var $input = $( input ), signatureParts;
1030 if ( $input.is( ':checkbox, :radio' ) ) {
1031 signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ];
1033 signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ];
1036 return signatureParts.join( ',' );
1039 return inputsSignatures.join( ';' );
1043 * Get the state for an input depending on its type.
1045 * @param {jQuery|Element} input
1046 * @returns {string|boolean|array|*}
1049 _getInputState: function( input ) {
1051 if ( input.is( ':radio, :checkbox' ) ) {
1052 return input.prop( 'checked' );
1053 } else if ( input.is( 'select[multiple]' ) ) {
1054 return input.find( 'option:selected' ).map( function () {
1055 return $( this ).val();
1063 * Update an input's state based on its type.
1065 * @param {jQuery|Element} input
1066 * @param {string|boolean|array|*} state
1069 _setInputState: function ( input, state ) {
1071 if ( input.is( ':radio, :checkbox' ) ) {
1072 input.prop( 'checked', state );
1073 } else if ( input.is( 'select[multiple]' ) ) {
1074 if ( ! $.isArray( state ) ) {
1077 // Make sure all state items are strings since the DOM value is a string
1078 state = _.map( state, function ( value ) {
1079 return String( value );
1082 input.find( 'option' ).each( function () {
1083 $( this ).prop( 'selected', -1 !== _.indexOf( state, String( this.value ) ) );
1090 /***********************************************************************
1091 * Begin public API methods
1092 **********************************************************************/
1095 * @return {wp.customize.controlConstructor.sidebar_widgets[]}
1097 getSidebarWidgetsControl: function() {
1098 var settingId, sidebarWidgetsControl;
1100 settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']';
1101 sidebarWidgetsControl = api.control( settingId );
1103 if ( ! sidebarWidgetsControl ) {
1107 return sidebarWidgetsControl;
1111 * Submit the widget form via Ajax and get back the updated instance,
1112 * along with the new widget control form to render.
1114 * @param {object} [args]
1115 * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used
1116 * @param {Function|null} [args.complete=null] Function which is called when the request finishes. Context is bound to the control. First argument is any error. Following arguments are for success.
1117 * @param {Boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element.
1119 updateWidget: function( args ) {
1120 var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent,
1121 updateNumber, params, data, $inputs, processing, jqxhr, isChanged;
1123 // The updateWidget logic requires that the form fields to be fully present.
1124 self.embedWidgetContent();
1129 ignoreActiveElement: false
1132 instanceOverride = args.instance;
1133 completeCallback = args.complete;
1135 this._updateCount += 1;
1136 updateNumber = this._updateCount;
1138 $widgetRoot = this.container.find( '.widget:first' );
1139 $widgetContent = $widgetRoot.find( '.widget-content:first' );
1141 // Remove a previous error message
1142 $widgetContent.find( '.widget-error' ).remove();
1144 this.container.addClass( 'widget-form-loading' );
1145 this.container.addClass( 'previewer-loading' );
1146 processing = api.state( 'processing' );
1147 processing( processing() + 1 );
1149 if ( ! this.liveUpdateMode ) {
1150 this.container.addClass( 'widget-form-disabled' );
1154 params.action = 'update-widget';
1155 params.wp_customize = 'on';
1156 params.nonce = api.settings.nonce['update-widget'];
1157 params.customize_theme = api.settings.theme.stylesheet;
1158 params.customized = wp.customize.previewer.query().customized;
1160 data = $.param( params );
1161 $inputs = this._getInputs( $widgetContent );
1163 // Store the value we're submitting in data so that when the response comes back,
1164 // we know if it got sanitized; if there is no difference in the sanitized value,
1165 // then we do not need to touch the UI and mess up the user's ongoing editing.
1166 $inputs.each( function() {
1167 $( this ).data( 'state' + updateNumber, self._getInputState( this ) );
1170 if ( instanceOverride ) {
1171 data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } );
1173 data += '&' + $inputs.serialize();
1175 data += '&' + $widgetContent.find( '~ :input' ).serialize();
1177 if ( this._previousUpdateRequest ) {
1178 this._previousUpdateRequest.abort();
1180 jqxhr = $.post( wp.ajax.settings.url, data );
1181 this._previousUpdateRequest = jqxhr;
1183 jqxhr.done( function( r ) {
1184 var message, sanitizedForm, $sanitizedInputs, hasSameInputsInResponse,
1185 isLiveUpdateAborted = false;
1187 // Check if the user is logged out.
1189 api.previewer.preview.iframe.hide();
1190 api.previewer.login().done( function() {
1191 self.updateWidget( args );
1192 api.previewer.preview.iframe.show();
1197 // Check for cheaters.
1199 api.previewer.cheatin();
1204 sanitizedForm = $( '<div>' + r.data.form + '</div>' );
1205 $sanitizedInputs = self._getInputs( sanitizedForm );
1206 hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs );
1208 // Restore live update mode if sanitized fields are now aligned with the existing fields
1209 if ( hasSameInputsInResponse && ! self.liveUpdateMode ) {
1210 self.liveUpdateMode = true;
1211 self.container.removeClass( 'widget-form-disabled' );
1212 self.container.find( 'input[name="savewidget"]' ).hide();
1215 // Sync sanitized field states to existing fields if they are aligned
1216 if ( hasSameInputsInResponse && self.liveUpdateMode ) {
1217 $inputs.each( function( i ) {
1218 var $input = $( this ),
1219 $sanitizedInput = $( $sanitizedInputs[i] ),
1220 submittedState, sanitizedState, canUpdateState;
1222 submittedState = $input.data( 'state' + updateNumber );
1223 sanitizedState = self._getInputState( $sanitizedInput );
1224 $input.data( 'sanitized', sanitizedState );
1226 canUpdateState = ( ! _.isEqual( submittedState, sanitizedState ) && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) ) );
1227 if ( canUpdateState ) {
1228 self._setInputState( $input, sanitizedState );
1232 $( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] );
1234 // Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled
1235 } else if ( self.liveUpdateMode ) {
1236 self.liveUpdateMode = false;
1237 self.container.find( 'input[name="savewidget"]' ).show();
1238 isLiveUpdateAborted = true;
1240 // Otherwise, replace existing form with the sanitized form
1242 $widgetContent.html( r.data.form );
1244 self.container.removeClass( 'widget-form-disabled' );
1246 $( document ).trigger( 'widget-updated', [ $widgetRoot ] );
1250 * If the old instance is identical to the new one, there is nothing new
1251 * needing to be rendered, and so we can preempt the event for the
1252 * preview finishing loading.
1254 isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance );
1256 self.isWidgetUpdating = true; // suppress triggering another updateWidget
1257 self.setting( r.data.instance );
1258 self.isWidgetUpdating = false;
1260 // no change was made, so stop the spinner now instead of when the preview would updates
1261 self.container.removeClass( 'previewer-loading' );
1264 if ( completeCallback ) {
1265 completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } );
1268 // General error message
1269 message = l10n.error;
1271 if ( r.data && r.data.message ) {
1272 message = r.data.message;
1275 if ( completeCallback ) {
1276 completeCallback.call( self, message );
1278 $widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' );
1283 jqxhr.fail( function( jqXHR, textStatus ) {
1284 if ( completeCallback ) {
1285 completeCallback.call( self, textStatus );
1289 jqxhr.always( function() {
1290 self.container.removeClass( 'widget-form-loading' );
1292 $inputs.each( function() {
1293 $( this ).removeData( 'state' + updateNumber );
1296 processing( processing() - 1 );
1301 * Expand the accordion section containing a control
1303 expandControlSection: function() {
1304 api.Control.prototype.expand.call( this );
1310 * @param {Boolean} expanded
1311 * @param {Object} [params]
1312 * @returns {Boolean} false if state already applied
1314 _toggleExpanded: api.Section.prototype._toggleExpanded,
1319 * @param {Object} [params]
1320 * @returns {Boolean} false if already expanded
1322 expand: api.Section.prototype.expand,
1325 * Expand the widget form control
1327 * @deprecated 4.1.0 Use this.expand() instead.
1329 expandForm: function() {
1336 * @param {Object} [params]
1337 * @returns {Boolean} false if already collapsed
1339 collapse: api.Section.prototype.collapse,
1342 * Collapse the widget form control
1344 * @deprecated 4.1.0 Use this.collapse() instead.
1346 collapseForm: function() {
1351 * Expand or collapse the widget control
1353 * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
1355 * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility
1357 toggleForm: function( showOrHide ) {
1358 if ( typeof showOrHide === 'undefined' ) {
1359 showOrHide = ! this.expanded();
1361 this.expanded( showOrHide );
1365 * Respond to change in the expanded state.
1367 * @param {Boolean} expanded
1368 * @param {Object} args merged on top of this.defaultActiveArguments
1370 onChangeExpanded: function ( expanded, args ) {
1371 var self = this, $widget, $inside, complete, prevComplete, expandControl;
1373 self.embedWidgetControl(); // Make sure the outer form is embedded so that the expanded state can be set in the UI.
1375 self.embedWidgetContent();
1378 // If the expanded state is unchanged only manipulate container expanded states
1379 if ( args.unchanged ) {
1381 api.Control.prototype.expand.call( self, {
1382 completeCallback: args.completeCallback
1388 $widget = this.container.find( 'div.widget:first' );
1389 $inside = $widget.find( '.widget-inside:first' );
1391 expandControl = function() {
1393 // Close all other widget controls before expanding this one
1394 api.control.each( function( otherControl ) {
1395 if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1396 otherControl.collapse();
1400 complete = function() {
1401 self.container.removeClass( 'expanding' );
1402 self.container.addClass( 'expanded' );
1403 self.container.trigger( 'expanded' );
1405 if ( args.completeCallback ) {
1406 prevComplete = complete;
1407 complete = function () {
1409 args.completeCallback();
1413 if ( self.params.is_wide ) {
1414 $inside.fadeIn( args.duration, complete );
1416 $inside.slideDown( args.duration, complete );
1419 self.container.trigger( 'expand' );
1420 self.container.addClass( 'expanding' );
1424 if ( api.section.has( self.section() ) ) {
1425 api.section( self.section() ).expand( {
1426 completeCallback: expandControl
1433 complete = function() {
1434 self.container.removeClass( 'collapsing' );
1435 self.container.removeClass( 'expanded' );
1436 self.container.trigger( 'collapsed' );
1438 if ( args.completeCallback ) {
1439 prevComplete = complete;
1440 complete = function () {
1442 args.completeCallback();
1446 self.container.trigger( 'collapse' );
1447 self.container.addClass( 'collapsing' );
1449 if ( self.params.is_wide ) {
1450 $inside.fadeOut( args.duration, complete );
1452 $inside.slideUp( args.duration, function() {
1453 $widget.css( { width:'', margin:'' } );
1461 * Get the position (index) of the widget in the containing sidebar
1465 getWidgetSidebarPosition: function() {
1466 var sidebarWidgetIds, position;
1468 sidebarWidgetIds = this.getSidebarWidgetsControl().setting();
1469 position = _.indexOf( sidebarWidgetIds, this.params.widget_id );
1471 if ( position === -1 ) {
1479 * Move widget up one in the sidebar
1481 moveUp: function() {
1482 this._moveWidgetByOne( -1 );
1486 * Move widget up one in the sidebar
1488 moveDown: function() {
1489 this._moveWidgetByOne( 1 );
1495 * @param {Number} offset 1|-1
1497 _moveWidgetByOne: function( offset ) {
1498 var i, sidebarWidgetsSetting, sidebarWidgetIds, adjacentWidgetId;
1500 i = this.getWidgetSidebarPosition();
1502 sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting;
1503 sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // clone
1504 adjacentWidgetId = sidebarWidgetIds[i + offset];
1505 sidebarWidgetIds[i + offset] = this.params.widget_id;
1506 sidebarWidgetIds[i] = adjacentWidgetId;
1508 sidebarWidgetsSetting( sidebarWidgetIds );
1512 * Toggle visibility of the widget move area
1514 * @param {Boolean} [showOrHide]
1516 toggleWidgetMoveArea: function( showOrHide ) {
1517 var self = this, $moveWidgetArea;
1519 $moveWidgetArea = this.container.find( '.move-widget-area' );
1521 if ( typeof showOrHide === 'undefined' ) {
1522 showOrHide = ! $moveWidgetArea.hasClass( 'active' );
1526 // reset the selected sidebar
1527 $moveWidgetArea.find( '.selected' ).removeClass( 'selected' );
1529 $moveWidgetArea.find( 'li' ).filter( function() {
1530 return $( this ).data( 'id' ) === self.params.sidebar_id;
1531 } ).addClass( 'selected' );
1533 this.container.find( '.move-widget-btn' ).prop( 'disabled', true );
1536 $moveWidgetArea.toggleClass( 'active', showOrHide );
1540 * Highlight the widget control and section
1542 highlightSectionAndControl: function() {
1545 if ( this.container.is( ':hidden' ) ) {
1546 $target = this.container.closest( '.control-section' );
1548 $target = this.container;
1551 $( '.highlighted' ).removeClass( 'highlighted' );
1552 $target.addClass( 'highlighted' );
1554 setTimeout( function() {
1555 $target.removeClass( 'highlighted' );
1561 * wp.customize.Widgets.WidgetsPanel
1563 * Customizer panel containing the widget area sections.
1567 api.Widgets.WidgetsPanel = api.Panel.extend({
1570 * Add and manage the display of the no-rendered-areas notice.
1574 ready: function () {
1577 api.Panel.prototype.ready.call( panel );
1579 panel.deferred.embedded.done(function() {
1580 var panelMetaContainer, noRenderedAreasNotice, shouldShowNotice;
1581 panelMetaContainer = panel.container.find( '.panel-meta' );
1582 noRenderedAreasNotice = $( '<div></div>', {
1583 'class': 'no-widget-areas-rendered-notice'
1585 noRenderedAreasNotice.append( $( '<em></em>', {
1586 text: l10n.noAreasRendered
1588 panelMetaContainer.append( noRenderedAreasNotice );
1590 shouldShowNotice = function() {
1591 return ( 0 === _.filter( panel.sections(), function( section ) {
1592 return section.active();
1597 * Set the initial visibility state for rendered notice.
1598 * Update the visibility of the notice whenever a reflow happens.
1600 noRenderedAreasNotice.toggle( shouldShowNotice() );
1601 api.previewer.deferred.active.done( function () {
1602 noRenderedAreasNotice.toggle( shouldShowNotice() );
1604 api.bind( 'pane-contents-reflowed', function() {
1605 var duration = ( 'resolved' === api.previewer.deferred.active.state() ) ? 'fast' : 0;
1606 if ( shouldShowNotice() ) {
1607 noRenderedAreasNotice.slideDown( duration );
1609 noRenderedAreasNotice.slideUp( duration );
1616 * Allow an active widgets panel to be contextually active even when it has no active sections (widget areas).
1618 * This ensures that the widgets panel appears even when there are no
1619 * sidebars displayed on the URL currently being previewed.
1623 * @returns {boolean}
1625 isContextuallyActive: function() {
1627 return panel.active();
1632 * wp.customize.Widgets.SidebarSection
1634 * Customizer section representing a widget area widget
1638 api.Widgets.SidebarSection = api.Section.extend({
1641 * Sync the section's active state back to the Backbone model's is_rendered attribute
1645 ready: function () {
1646 var section = this, registeredSidebar;
1647 api.Section.prototype.ready.call( this );
1648 registeredSidebar = api.Widgets.registeredSidebars.get( section.params.sidebarId );
1649 section.active.bind( function ( active ) {
1650 registeredSidebar.set( 'is_rendered', active );
1652 registeredSidebar.set( 'is_rendered', section.active() );
1657 * wp.customize.Widgets.SidebarControl
1659 * Customizer control for widgets.
1660 * Note that 'sidebar_widgets' must match the WP_Widget_Area_Customize_Control::$type
1665 * @augments wp.customize.Control
1667 api.Widgets.SidebarControl = api.Control.extend({
1670 * Set up the control
1673 this.$controlSection = this.container.closest( '.control-section' );
1674 this.$sectionContent = this.container.closest( '.accordion-section-content' );
1677 this._setupSortable();
1678 this._setupAddition();
1679 this._applyCardinalOrderClassNames();
1683 * Update ordering of widget control forms when the setting is updated
1685 _setupModel: function() {
1688 this.setting.bind( function( newWidgetIds, oldWidgetIds ) {
1689 var widgetFormControls, removedWidgetIds, priority;
1691 removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds );
1693 // Filter out any persistent widget IDs for widgets which have been deactivated
1694 newWidgetIds = _( newWidgetIds ).filter( function( newWidgetId ) {
1695 var parsedWidgetId = parseWidgetId( newWidgetId );
1697 return !! api.Widgets.availableWidgets.findWhere( { id_base: parsedWidgetId.id_base } );
1700 widgetFormControls = _( newWidgetIds ).map( function( widgetId ) {
1701 var widgetFormControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
1703 if ( ! widgetFormControl ) {
1704 widgetFormControl = self.addWidget( widgetId );
1707 return widgetFormControl;
1710 // Sort widget controls to their new positions
1711 widgetFormControls.sort( function( a, b ) {
1712 var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ),
1713 bIndex = _.indexOf( newWidgetIds, b.params.widget_id );
1714 return aIndex - bIndex;
1718 _( widgetFormControls ).each( function ( control ) {
1719 control.priority( priority );
1720 control.section( self.section() );
1723 self.priority( priority ); // Make sure sidebar control remains at end
1725 // Re-sort widget form controls (including widgets form other sidebars newly moved here)
1726 self._applyCardinalOrderClassNames();
1728 // If the widget was dragged into the sidebar, make sure the sidebar_id param is updated
1729 _( widgetFormControls ).each( function( widgetFormControl ) {
1730 widgetFormControl.params.sidebar_id = self.params.sidebar_id;
1733 // Cleanup after widget removal
1734 _( removedWidgetIds ).each( function( removedWidgetId ) {
1736 // Using setTimeout so that when moving a widget to another sidebar, the other sidebars_widgets settings get a chance to update
1737 setTimeout( function() {
1738 var removedControl, wasDraggedToAnotherSidebar, inactiveWidgets, removedIdBase,
1739 widget, isPresentInAnotherSidebar = false;
1741 // Check if the widget is in another sidebar
1742 api.each( function( otherSetting ) {
1743 if ( otherSetting.id === self.setting.id || 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) || otherSetting.id === 'sidebars_widgets[wp_inactive_widgets]' ) {
1747 var otherSidebarWidgets = otherSetting(), i;
1749 i = _.indexOf( otherSidebarWidgets, removedWidgetId );
1751 isPresentInAnotherSidebar = true;
1755 // If the widget is present in another sidebar, abort!
1756 if ( isPresentInAnotherSidebar ) {
1760 removedControl = api.Widgets.getWidgetFormControlForWidget( removedWidgetId );
1762 // Detect if widget control was dragged to another sidebar
1763 wasDraggedToAnotherSidebar = removedControl && $.contains( document, removedControl.container[0] ) && ! $.contains( self.$sectionContent[0], removedControl.container[0] );
1765 // Delete any widget form controls for removed widgets
1766 if ( removedControl && ! wasDraggedToAnotherSidebar ) {
1767 api.control.remove( removedControl.id );
1768 removedControl.container.remove();
1771 // Move widget to inactive widgets sidebar (move it to trash) if has been previously saved
1772 // This prevents the inactive widgets sidebar from overflowing with throwaway widgets
1773 if ( api.Widgets.savedWidgetIds[removedWidgetId] ) {
1774 inactiveWidgets = api.value( 'sidebars_widgets[wp_inactive_widgets]' )().slice();
1775 inactiveWidgets.push( removedWidgetId );
1776 api.value( 'sidebars_widgets[wp_inactive_widgets]' )( _( inactiveWidgets ).unique() );
1779 // Make old single widget available for adding again
1780 removedIdBase = parseWidgetId( removedWidgetId ).id_base;
1781 widget = api.Widgets.availableWidgets.findWhere( { id_base: removedIdBase } );
1782 if ( widget && ! widget.get( 'is_multi' ) ) {
1783 widget.set( 'is_disabled', false );
1792 * Allow widgets in sidebar to be re-ordered, and for the order to be previewed
1794 _setupSortable: function() {
1797 this.isReordering = false;
1800 * Update widget order setting when controls are re-ordered
1802 this.$sectionContent.sortable( {
1803 items: '> .customize-control-widget_form',
1804 handle: '.widget-top',
1806 tolerance: 'pointer',
1807 connectWith: '.accordion-section-content:has(.customize-control-sidebar_widgets)',
1808 update: function() {
1809 var widgetContainerIds = self.$sectionContent.sortable( 'toArray' ), widgetIds;
1811 widgetIds = $.map( widgetContainerIds, function( widgetContainerId ) {
1812 return $( '#' + widgetContainerId ).find( ':input[name=widget-id]' ).val();
1815 self.setting( widgetIds );
1820 * Expand other Customizer sidebar section when dragging a control widget over it,
1821 * allowing the control to be dropped into another section
1823 this.$controlSection.find( '.accordion-section-title' ).droppable({
1824 accept: '.customize-control-widget_form',
1826 var section = api.section( self.section.get() );
1828 allowMultiple: true, // Prevent the section being dragged from to be collapsed
1829 completeCallback: function () {
1830 // @todo It is not clear when refreshPositions should be called on which sections, or if it is even needed
1831 api.section.each( function ( otherSection ) {
1832 if ( otherSection.container.find( '.customize-control-sidebar_widgets' ).length ) {
1833 otherSection.container.find( '.accordion-section-content:first' ).sortable( 'refreshPositions' );
1842 * Keyboard-accessible reordering
1844 this.container.find( '.reorder-toggle' ).on( 'click', function() {
1845 self.toggleReordering( ! self.isReordering );
1850 * Set up UI for adding a new widget
1852 _setupAddition: function() {
1855 this.container.find( '.add-new-widget' ).on( 'click', function() {
1856 var addNewWidgetBtn = $( this );
1858 if ( self.$sectionContent.hasClass( 'reordering' ) ) {
1862 if ( ! $( 'body' ).hasClass( 'adding-widget' ) ) {
1863 addNewWidgetBtn.attr( 'aria-expanded', 'true' );
1864 api.Widgets.availableWidgetsPanel.open( self );
1866 addNewWidgetBtn.attr( 'aria-expanded', 'false' );
1867 api.Widgets.availableWidgetsPanel.close();
1873 * Add classes to the widget_form controls to assist with styling
1875 _applyCardinalOrderClassNames: function() {
1876 var widgetControls = [];
1877 _.each( this.setting(), function ( widgetId ) {
1878 var widgetControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
1879 if ( widgetControl ) {
1880 widgetControls.push( widgetControl );
1884 if ( 0 === widgetControls.length || ( 1 === api.Widgets.registeredSidebars.length && widgetControls.length <= 1 ) ) {
1885 this.container.find( '.reorder-toggle' ).hide();
1888 this.container.find( '.reorder-toggle' ).show();
1891 $( widgetControls ).each( function () {
1893 .removeClass( 'first-widget' )
1894 .removeClass( 'last-widget' )
1895 .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 );
1898 _.first( widgetControls ).container
1899 .addClass( 'first-widget' )
1900 .find( '.move-widget-up' ).prop( 'tabIndex', -1 );
1902 _.last( widgetControls ).container
1903 .addClass( 'last-widget' )
1904 .find( '.move-widget-down' ).prop( 'tabIndex', -1 );
1908 /***********************************************************************
1909 * Begin public API methods
1910 **********************************************************************/
1913 * Enable/disable the reordering UI
1915 * @param {Boolean} showOrHide to enable/disable reordering
1917 * @todo We should have a reordering state instead and rename this to onChangeReordering
1919 toggleReordering: function( showOrHide ) {
1920 var addNewWidgetBtn = this.$sectionContent.find( '.add-new-widget' ),
1921 reorderBtn = this.container.find( '.reorder-toggle' ),
1922 widgetsTitle = this.$sectionContent.find( '.widget-title' );
1924 showOrHide = Boolean( showOrHide );
1926 if ( showOrHide === this.$sectionContent.hasClass( 'reordering' ) ) {
1930 this.isReordering = showOrHide;
1931 this.$sectionContent.toggleClass( 'reordering', showOrHide );
1934 _( this.getWidgetFormControls() ).each( function( formControl ) {
1935 formControl.collapse();
1938 addNewWidgetBtn.attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
1939 reorderBtn.attr( 'aria-label', l10n.reorderLabelOff );
1940 wp.a11y.speak( l10n.reorderModeOn );
1941 // Hide widget titles while reordering: title is already in the reorder controls.
1942 widgetsTitle.attr( 'aria-hidden', 'true' );
1944 addNewWidgetBtn.removeAttr( 'tabindex aria-hidden' );
1945 reorderBtn.attr( 'aria-label', l10n.reorderLabelOn );
1946 wp.a11y.speak( l10n.reorderModeOff );
1947 widgetsTitle.attr( 'aria-hidden', 'false' );
1952 * Get the widget_form Customize controls associated with the current sidebar.
1955 * @return {wp.customize.controlConstructor.widget_form[]}
1957 getWidgetFormControls: function() {
1958 var formControls = [];
1960 _( this.setting() ).each( function( widgetId ) {
1961 var settingId = widgetIdToSettingId( widgetId ),
1962 formControl = api.control( settingId );
1963 if ( formControl ) {
1964 formControls.push( formControl );
1968 return formControls;
1972 * @param {string} widgetId or an id_base for adding a previously non-existing widget
1973 * @returns {object|false} widget_form control instance, or false on error
1975 addWidget: function( widgetId ) {
1976 var self = this, controlHtml, $widget, controlType = 'widget_form', controlContainer, controlConstructor,
1977 parsedWidgetId = parseWidgetId( widgetId ),
1978 widgetNumber = parsedWidgetId.number,
1979 widgetIdBase = parsedWidgetId.id_base,
1980 widget = api.Widgets.availableWidgets.findWhere( {id_base: widgetIdBase} ),
1981 settingId, isExistingWidget, widgetFormControl, sidebarWidgets, settingArgs, setting;
1987 if ( widgetNumber && ! widget.get( 'is_multi' ) ) {
1991 // Set up new multi widget
1992 if ( widget.get( 'is_multi' ) && ! widgetNumber ) {
1993 widget.set( 'multi_number', widget.get( 'multi_number' ) + 1 );
1994 widgetNumber = widget.get( 'multi_number' );
1997 controlHtml = $.trim( $( '#widget-tpl-' + widget.get( 'id' ) ).html() );
1998 if ( widget.get( 'is_multi' ) ) {
1999 controlHtml = controlHtml.replace( /<[^<>]+>/g, function( m ) {
2000 return m.replace( /__i__|%i%/g, widgetNumber );
2003 widget.set( 'is_disabled', true ); // Prevent single widget from being added again now
2006 $widget = $( controlHtml );
2008 controlContainer = $( '<li/>' )
2009 .addClass( 'customize-control' )
2010 .addClass( 'customize-control-' + controlType )
2013 // Remove icon which is visible inside the panel
2014 controlContainer.find( '> .widget-icon' ).remove();
2016 if ( widget.get( 'is_multi' ) ) {
2017 controlContainer.find( 'input[name="widget_number"]' ).val( widgetNumber );
2018 controlContainer.find( 'input[name="multi_number"]' ).val( widgetNumber );
2021 widgetId = controlContainer.find( '[name="widget-id"]' ).val();
2023 controlContainer.hide(); // to be slid-down below
2025 settingId = 'widget_' + widget.get( 'id_base' );
2026 if ( widget.get( 'is_multi' ) ) {
2027 settingId += '[' + widgetNumber + ']';
2029 controlContainer.attr( 'id', 'customize-control-' + settingId.replace( /\]/g, '' ).replace( /\[/g, '-' ) );
2031 // Only create setting if it doesn't already exist (if we're adding a pre-existing inactive widget)
2032 isExistingWidget = api.has( settingId );
2033 if ( ! isExistingWidget ) {
2035 transport: api.Widgets.data.selectiveRefreshableWidgets[ widget.get( 'id_base' ) ] ? 'postMessage' : 'refresh',
2036 previewer: this.setting.previewer
2038 setting = api.create( settingId, settingId, '', settingArgs );
2039 setting.set( {} ); // mark dirty, changing from '' to {}
2042 controlConstructor = api.controlConstructor[controlType];
2043 widgetFormControl = new controlConstructor( settingId, {
2046 'default': settingId
2048 content: controlContainer,
2049 sidebar_id: self.params.sidebar_id,
2050 widget_id: widgetId,
2051 widget_id_base: widget.get( 'id_base' ),
2053 is_new: ! isExistingWidget,
2054 width: widget.get( 'width' ),
2055 height: widget.get( 'height' ),
2056 is_wide: widget.get( 'is_wide' ),
2059 previewer: self.setting.previewer
2061 api.control.add( settingId, widgetFormControl );
2063 // Make sure widget is removed from the other sidebars
2064 api.each( function( otherSetting ) {
2065 if ( otherSetting.id === self.setting.id ) {
2069 if ( 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) ) {
2073 var otherSidebarWidgets = otherSetting().slice(),
2074 i = _.indexOf( otherSidebarWidgets, widgetId );
2077 otherSidebarWidgets.splice( i );
2078 otherSetting( otherSidebarWidgets );
2082 // Add widget to this sidebar
2083 sidebarWidgets = this.setting().slice();
2084 if ( -1 === _.indexOf( sidebarWidgets, widgetId ) ) {
2085 sidebarWidgets.push( widgetId );
2086 this.setting( sidebarWidgets );
2089 controlContainer.slideDown( function() {
2090 if ( isExistingWidget ) {
2091 widgetFormControl.updateWidget( {
2092 instance: widgetFormControl.setting()
2097 return widgetFormControl;
2101 // Register models for custom panel, section, and control types
2102 $.extend( api.panelConstructor, {
2103 widgets: api.Widgets.WidgetsPanel
2105 $.extend( api.sectionConstructor, {
2106 sidebar: api.Widgets.SidebarSection
2108 $.extend( api.controlConstructor, {
2109 widget_form: api.Widgets.WidgetControl,
2110 sidebar_widgets: api.Widgets.SidebarControl
2114 * Init Customizer for widgets.
2116 api.bind( 'ready', function() {
2117 // Set up the widgets panel
2118 api.Widgets.availableWidgetsPanel = new api.Widgets.AvailableWidgetsPanelView({
2119 collection: api.Widgets.availableWidgets
2122 // Highlight widget control
2123 api.previewer.bind( 'highlight-widget-control', api.Widgets.highlightWidgetFormControl );
2125 // Open and focus widget control
2126 api.previewer.bind( 'focus-widget-control', api.Widgets.focusWidgetFormControl );
2130 * Highlight a widget control.
2132 * @param {string} widgetId
2134 api.Widgets.highlightWidgetFormControl = function( widgetId ) {
2135 var control = api.Widgets.getWidgetFormControlForWidget( widgetId );
2138 control.highlightSectionAndControl();
2143 * Focus a widget control.
2145 * @param {string} widgetId
2147 api.Widgets.focusWidgetFormControl = function( widgetId ) {
2148 var control = api.Widgets.getWidgetFormControlForWidget( widgetId );
2156 * Given a widget control, find the sidebar widgets control that contains it.
2157 * @param {string} widgetId
2158 * @return {object|null}
2160 api.Widgets.getSidebarWidgetControlContainingWidget = function( widgetId ) {
2161 var foundControl = null;
2163 // @todo this can use widgetIdToSettingId(), then pass into wp.customize.control( x ).getSidebarWidgetsControl()
2164 api.control.each( function( control ) {
2165 if ( control.params.type === 'sidebar_widgets' && -1 !== _.indexOf( control.setting(), widgetId ) ) {
2166 foundControl = control;
2170 return foundControl;
2174 * Given a widget ID for a widget appearing in the preview, get the widget form control associated with it.
2176 * @param {string} widgetId
2177 * @return {object|null}
2179 api.Widgets.getWidgetFormControlForWidget = function( widgetId ) {
2180 var foundControl = null;
2182 // @todo We can just use widgetIdToSettingId() here
2183 api.control.each( function( control ) {
2184 if ( control.params.type === 'widget_form' && control.params.widget_id === widgetId ) {
2185 foundControl = control;
2189 return foundControl;
2193 * Initialize Edit Menu button in Nav Menu widget.
2195 $( document ).on( 'widget-added', function( event, widgetContainer ) {
2196 var parsedWidgetId, widgetControl, navMenuSelect, editMenuButton;
2197 parsedWidgetId = parseWidgetId( widgetContainer.find( '> .widget-inside > .form > .widget-id' ).val() );
2198 if ( 'nav_menu' !== parsedWidgetId.id_base ) {
2201 widgetControl = api.control( 'widget_nav_menu[' + String( parsedWidgetId.number ) + ']' );
2202 if ( ! widgetControl ) {
2205 navMenuSelect = widgetContainer.find( 'select[name*="nav_menu"]' );
2206 editMenuButton = widgetContainer.find( '.edit-selected-nav-menu > button' );
2207 if ( 0 === navMenuSelect.length || 0 === editMenuButton.length ) {
2210 navMenuSelect.on( 'change', function() {
2211 if ( api.section.has( 'nav_menu[' + navMenuSelect.val() + ']' ) ) {
2212 editMenuButton.parent().show();
2214 editMenuButton.parent().hide();
2217 editMenuButton.on( 'click', function() {
2218 var section = api.section( 'nav_menu[' + navMenuSelect.val() + ']' );
2220 focusConstructWithBreadcrumb( section, widgetControl );
2226 * Focus (expand) one construct and then focus on another construct after the first is collapsed.
2228 * This overrides the back button to serve the purpose of breadcrumb navigation.
2230 * @param {wp.customize.Section|wp.customize.Panel|wp.customize.Control} focusConstruct - The object to initially focus.
2231 * @param {wp.customize.Section|wp.customize.Panel|wp.customize.Control} returnConstruct - The object to return focus.
2233 function focusConstructWithBreadcrumb( focusConstruct, returnConstruct ) {
2234 focusConstruct.focus();
2235 function onceCollapsed( isExpanded ) {
2236 if ( ! isExpanded ) {
2237 focusConstruct.expanded.unbind( onceCollapsed );
2238 returnConstruct.focus();
2241 focusConstruct.expanded.bind( onceCollapsed );
2245 * @param {String} widgetId
2248 function parseWidgetId( widgetId ) {
2249 var matches, parsed = {
2254 matches = widgetId.match( /^(.+)-(\d+)$/ );
2256 parsed.id_base = matches[1];
2257 parsed.number = parseInt( matches[2], 10 );
2259 // likely an old single widget
2260 parsed.id_base = widgetId;
2267 * @param {String} widgetId
2268 * @returns {String} settingId
2270 function widgetIdToSettingId( widgetId ) {
2271 var parsed = parseWidgetId( widgetId ), settingId;
2273 settingId = 'widget_' + parsed.id_base;
2274 if ( parsed.number ) {
2275 settingId += '[' + parsed.number + ']';
2281 })( window.wp, jQuery );