| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 /** 2 * @output wp-admin/js/customize-widgets.js 3 */ 4 5 /* global _wpCustomizeWidgetsSettings */ 6 7 /** 8 * The WordPress Customizer widgets API. 9 * 10 * @param {Object} wp The WordPress global object. 11 * @param {JQueryStatic} $ The jQuery object. 12 */ 13 (function( wp, $ ){ 14 15 if ( ! wp || ! wp.customize ) { return; } 16 17 // Set up our namespace... 18 var api = wp.customize, 19 l10n; 20 21 /** 22 * @namespace wp.customize.Widgets 23 */ 24 api.Widgets = api.Widgets || {}; 25 api.Widgets.savedWidgetIds = {}; 26 27 // Link settings. 28 api.Widgets.data = _wpCustomizeWidgetsSettings || {}; 29 l10n = api.Widgets.data.l10n; 30 31 /** 32 * wp.customize.Widgets.WidgetModel 33 * 34 * A single widget model. 35 * 36 * @class wp.customize.Widgets.WidgetModel 37 * @augments Backbone.Model 38 */ 39 api.Widgets.WidgetModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.WidgetModel.prototype */{ 40 id: null, 41 temp_id: null, 42 classname: null, 43 control_tpl: null, 44 description: null, 45 is_disabled: null, 46 is_multi: null, 47 multi_number: null, 48 name: null, 49 id_base: null, 50 transport: null, 51 params: [], 52 width: null, 53 height: null, 54 search_matched: true 55 }); 56 57 /** 58 * wp.customize.Widgets.WidgetCollection 59 * 60 * Collection for widget models. 61 * 62 * @class wp.customize.Widgets.WidgetCollection 63 * @augments Backbone.Collection 64 */ 65 api.Widgets.WidgetCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.WidgetCollection.prototype */{ 66 model: api.Widgets.WidgetModel, 67 68 // Controls searching on the current widget collection 69 // and triggers an update event. 70 doSearch: function( value ) { 71 72 // Don't do anything if we've already done this search. 73 // Useful because the search handler fires multiple times per keystroke. 74 if ( this.terms === value ) { 75 return; 76 } 77 78 // Updates terms with the value passed. 79 this.terms = value; 80 81 // If we have terms, run a search... 82 if ( this.terms.length > 0 ) { 83 this.search( this.terms ); 84 } 85 86 // If search is blank, set all the widgets as they matched the search to reset the views. 87 if ( this.terms === '' ) { 88 this.each( function ( widget ) { 89 widget.set( 'search_matched', true ); 90 } ); 91 } 92 }, 93 94 // Performs a search within the collection. 95 // @uses RegExp 96 search: function( term ) { 97 var match, haystack; 98 99 // Escape the term string for RegExp meta characters. 100 term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' ); 101 102 // Consider spaces as word delimiters and match the whole string 103 // so matching terms can be combined. 104 term = term.replace( / /g, ')(?=.*' ); 105 match = new RegExp( '^(?=.*' + term + ').+', 'i' ); 106 107 this.each( function ( data ) { 108 haystack = [ data.get( 'name' ), data.get( 'description' ) ].join( ' ' ); 109 data.set( 'search_matched', match.test( haystack ) ); 110 } ); 111 } 112 }); 113 api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets ); 114 115 /** 116 * wp.customize.Widgets.SidebarModel 117 * 118 * A single sidebar model. 119 * 120 * @class wp.customize.Widgets.SidebarModel 121 * @augments Backbone.Model 122 */ 123 api.Widgets.SidebarModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.SidebarModel.prototype */{ 124 after_title: null, 125 after_widget: null, 126 before_title: null, 127 before_widget: null, 128 'class': null, 129 description: null, 130 id: null, 131 name: null, 132 is_rendered: false 133 }); 134 135 /** 136 * wp.customize.Widgets.SidebarCollection 137 * 138 * Collection for sidebar models. 139 * 140 * @class wp.customize.Widgets.SidebarCollection 141 * @augments Backbone.Collection 142 */ 143 api.Widgets.SidebarCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.SidebarCollection.prototype */{ 144 model: api.Widgets.SidebarModel 145 }); 146 api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars ); 147 148 api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend(/** @lends wp.customize.Widgets.AvailableWidgetsPanelView.prototype */{ 149 150 el: '#available-widgets', 151 152 events: { 153 'input #widgets-search': 'search', 154 'focus .widget-tpl' : 'focus', 155 'click .widget-tpl' : '_submit', 156 'keypress .widget-tpl' : '_submit', 157 'keydown' : 'keyboardAccessible' 158 }, 159 160 // Cache current selected widget. 161 selected: null, 162 163 // Cache sidebar control which has opened panel. 164 currentSidebarControl: null, 165 $search: null, 166 $clearResults: null, 167 searchMatchesCount: null, 168 169 /** 170 * View class for the available widgets panel. 171 * 172 * @constructs wp.customize.Widgets.AvailableWidgetsPanelView 173 * @augments wp.Backbone.View 174 */ 175 initialize: function() { 176 var self = this; 177 178 this.$search = $( '#widgets-search' ); 179 180 this.$clearResults = this.$el.find( '.clear-results' ); 181 182 _.bindAll( this, 'close' ); 183 184 this.listenTo( this.collection, 'change', this.updateList ); 185 186 this.updateList(); 187 188 // Set the initial search count to the number of available widgets. 189 this.searchMatchesCount = this.collection.length; 190 191 /* 192 * If the available widgets panel is open and the customize controls 193 * are interacted with (i.e. available widgets panel is blurred) then 194 * close the available widgets panel. Also close on back button click. 195 */ 196 $( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) { 197 var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' ); 198 if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) { 199 self.close(); 200 } 201 } ); 202 203 // Clear the search results and trigger an `input` event to fire a new search. 204 this.$clearResults.on( 'click', function() { 205 self.$search.val( '' ).trigger( 'focus' ).trigger( 'input' ); 206 } ); 207 208 // Close the panel if the URL in the preview changes. 209 api.previewer.bind( 'url', this.close ); 210 }, 211 212 /** 213 * Performs a search and handles selected widget. 214 */ 215 search: _.debounce( function( event ) { 216 var firstVisible; 217 218 this.collection.doSearch( event.target.value ); 219 // Update the search matches count. 220 this.updateSearchMatchesCount(); 221 // Announce how many search results. 222 this.announceSearchMatches(); 223 224 // Remove a widget from being selected if it is no longer visible. 225 if ( this.selected && ! this.selected.is( ':visible' ) ) { 226 this.selected.removeClass( 'selected' ); 227 this.selected = null; 228 } 229 230 // If a widget was selected but the filter value has been cleared out, clear selection. 231 if ( this.selected && ! event.target.value ) { 232 this.selected.removeClass( 'selected' ); 233 this.selected = null; 234 } 235 236 // If a filter has been entered and a widget hasn't been selected, select the first one shown. 237 if ( ! this.selected && event.target.value ) { 238 firstVisible = this.$el.find( '> .widget-tpl:visible:first' ); 239 if ( firstVisible.length ) { 240 this.select( firstVisible ); 241 } 242 } 243 244 // Toggle the clear search results button. 245 if ( '' !== event.target.value ) { 246 this.$clearResults.addClass( 'is-visible' ); 247 } else if ( '' === event.target.value ) { 248 this.$clearResults.removeClass( 'is-visible' ); 249 } 250 251 // Set a CSS class on the search container when there are no search results. 252 if ( ! this.searchMatchesCount ) { 253 this.$el.addClass( 'no-widgets-found' ); 254 } else { 255 this.$el.removeClass( 'no-widgets-found' ); 256 } 257 }, 500 ), 258 259 /** 260 * Updates the count of the available widgets that have the `search_matched` attribute. 261 */ 262 updateSearchMatchesCount: function() { 263 this.searchMatchesCount = this.collection.where({ search_matched: true }).length; 264 }, 265 266 /** 267 * Sends a message to the aria-live region to announce how many search results. 268 */ 269 announceSearchMatches: function() { 270 var message = l10n.widgetsFound.replace( '%d', this.searchMatchesCount ) ; 271 272 if ( ! this.searchMatchesCount ) { 273 message = l10n.noWidgetsFound; 274 } 275 276 wp.a11y.speak( message ); 277 }, 278 279 /** 280 * Changes visibility of available widgets. 281 */ 282 updateList: function() { 283 this.collection.each( function( widget ) { 284 var widgetTpl = $( '#widget-tpl-' + widget.id ); 285 widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) ); 286 if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) { 287 this.selected = null; 288 } 289 } ); 290 }, 291 292 /** 293 * Highlights a widget. 294 * 295 * @param {JQuery} widgetTpl The widget template to highlight. 296 */ 297 select: function( widgetTpl ) { 298 this.selected = $( widgetTpl ); 299 this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' ); 300 this.selected.addClass( 'selected' ); 301 }, 302 303 /** 304 * Highlights a widget on focus. 305 * 306 * @param {JQuery.Event} event The focus event. 307 */ 308 focus: function( event ) { 309 this.select( $( event.currentTarget ) ); 310 }, 311 312 /** 313 * Handles submit for keypress and click on widget. 314 * 315 * @param {JQuery.Event} event The keypress or click event. 316 */ 317 _submit: function( event ) { 318 // Only proceed with keypress if it is Enter or Spacebar. 319 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { 320 return; 321 } 322 323 this.submit( $( event.currentTarget ) ); 324 }, 325 326 /** 327 * Adds a selected widget to the sidebar. 328 * 329 * @param {JQuery} widgetTpl The widget template to add. 330 */ 331 submit: function( widgetTpl ) { 332 var widgetId, widget, widgetFormControl; 333 334 if ( ! widgetTpl ) { 335 widgetTpl = this.selected; 336 } 337 338 if ( ! widgetTpl || ! this.currentSidebarControl ) { 339 return; 340 } 341 342 this.select( widgetTpl ); 343 344 widgetId = $( this.selected ).data( 'widget-id' ); 345 widget = this.collection.findWhere( { id: widgetId } ); 346 if ( ! widget ) { 347 return; 348 } 349 350 widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) ); 351 if ( widgetFormControl ) { 352 widgetFormControl.focus(); 353 } 354 355 this.close(); 356 }, 357 358 /** 359 * Opens the panel. 360 * 361 * @param {wp.customize.Widgets.SidebarControl} sidebarControl The sidebar control that opened the panel. 362 */ 363 open: function( sidebarControl ) { 364 this.currentSidebarControl = sidebarControl; 365 366 // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens. 367 _( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) { 368 if ( control.params.is_wide ) { 369 control.collapseForm(); 370 } 371 } ); 372 373 if ( api.section.has( 'publish_settings' ) ) { 374 api.section( 'publish_settings' ).collapse(); 375 } 376 377 $( 'body' ).addClass( 'adding-widget' ); 378 379 this.$el.find( '.selected' ).removeClass( 'selected' ); 380 381 // Reset search. 382 this.collection.doSearch( '' ); 383 384 if ( ! api.settings.browser.mobile ) { 385 this.$search.trigger( 'focus' ); 386 } 387 }, 388 389 /** 390 * Closes the panel. 391 * 392 * @param {Object} [options] Options for closing the panel. 393 */ 394 close: function( options ) { 395 options = options || {}; 396 397 if ( options.returnFocus && this.currentSidebarControl ) { 398 this.currentSidebarControl.container.find( '.add-new-widget' ).focus(); 399 } 400 401 this.currentSidebarControl = null; 402 this.selected = null; 403 404 $( 'body' ).removeClass( 'adding-widget' ); 405 406 this.$search.val( '' ).trigger( 'input' ); 407 }, 408 409 /** 410 * Adds keyboard accessibility to the panel. 411 * 412 * @param {JQuery.Event} event The keydown event. 413 */ 414 keyboardAccessible: function( event ) { 415 var isEnter = ( event.which === 13 ), 416 isEsc = ( event.which === 27 ), 417 isDown = ( event.which === 40 ), 418 isUp = ( event.which === 38 ), 419 isTab = ( event.which === 9 ), 420 isShift = ( event.shiftKey ), 421 selected = null, 422 firstVisible = this.$el.find( '> .widget-tpl:visible:first' ), 423 lastVisible = this.$el.find( '> .widget-tpl:visible:last' ), 424 isSearchFocused = $( event.target ).is( this.$search ), 425 isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' ); 426 427 if ( isDown || isUp ) { 428 if ( isDown ) { 429 if ( isSearchFocused ) { 430 selected = firstVisible; 431 } else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) { 432 selected = this.selected.nextAll( '.widget-tpl:visible:first' ); 433 } 434 } else if ( isUp ) { 435 if ( isSearchFocused ) { 436 selected = lastVisible; 437 } else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) { 438 selected = this.selected.prevAll( '.widget-tpl:visible:first' ); 439 } 440 } 441 442 this.select( selected ); 443 444 if ( selected ) { 445 selected.trigger( 'focus' ); 446 } else { 447 this.$search.trigger( 'focus' ); 448 } 449 450 return; 451 } 452 453 // If enter pressed but nothing entered, don't do anything. 454 if ( isEnter && ! this.$search.val() ) { 455 return; 456 } 457 458 if ( isEnter ) { 459 this.submit(); 460 } else if ( isEsc ) { 461 this.close( { returnFocus: true } ); 462 } 463 464 if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) { 465 this.currentSidebarControl.container.find( '.add-new-widget' ).focus(); 466 event.preventDefault(); 467 } 468 } 469 }); 470 471 /** 472 * Handlers for the widget-synced event, organized by widget ID base. 473 * Other widgets may provide their own update handlers by adding 474 * listeners for the widget-synced event. 475 * 476 * @alias wp.customize.Widgets.formSyncHandlers 477 */ 478 api.Widgets.formSyncHandlers = { 479 480 /** 481 * Handles the widget-synced event for RSS widgets. 482 * 483 * @param {JQuery.Event} e The widget-synced event. 484 * @param {JQuery} widget The widget root element. 485 * @param {string} newForm The HTML for the updated widget form. 486 */ 487 rss: function( e, widget, newForm ) { 488 var oldWidgetError = widget.find( '.widget-error:first' ), 489 newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' ); 490 491 if ( oldWidgetError.length && newWidgetError.length ) { 492 oldWidgetError.replaceWith( newWidgetError ); 493 } else if ( oldWidgetError.length ) { 494 oldWidgetError.remove(); 495 } else if ( newWidgetError.length ) { 496 widget.find( '.widget-content:first' ).prepend( newWidgetError ); 497 } 498 } 499 }; 500 501 api.Widgets.WidgetControl = api.Control.extend(/** @lends wp.customize.Widgets.WidgetControl.prototype */{ 502 defaultExpandedArguments: { 503 duration: 'fast', 504 completeCallback: $.noop 505 }, 506 507 /** 508 * wp.customize.Widgets.WidgetControl 509 * 510 * Customizer control for widgets. 511 * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type 512 * 513 * @since 4.1.0 514 * 515 * @constructs wp.customize.Widgets.WidgetControl 516 * @augments wp.customize.Control 517 * 518 * @param {string} id Control ID. 519 * @param {Object} options Control options. 520 */ 521 initialize: function( id, options ) { 522 var control = this; 523 524 control.widgetControlEmbedded = false; 525 control.widgetContentEmbedded = false; 526 control.expanded = new api.Value( false ); 527 control.expandedArgumentsQueue = []; 528 control.expanded.bind( function( expanded ) { 529 var args = control.expandedArgumentsQueue.shift(); 530 args = $.extend( {}, control.defaultExpandedArguments, args ); 531 control.onChangeExpanded( expanded, args ); 532 }); 533 control.altNotice = true; 534 535 api.Control.prototype.initialize.call( control, id, options ); 536 }, 537 538 /** 539 * Set up the control. 540 * 541 * @since 3.9.0 542 */ 543 ready: function() { 544 var control = this; 545 546 /* 547 * Embed a placeholder once the section is expanded. The full widget 548 * form content will be embedded once the control itself is expanded, 549 * and at this point the widget-added event will be triggered. 550 */ 551 if ( ! control.section() ) { 552 control.embedWidgetControl(); 553 } else { 554 api.section( control.section(), function( section ) { 555 var onExpanded = function( isExpanded ) { 556 if ( isExpanded ) { 557 control.embedWidgetControl(); 558 section.expanded.unbind( onExpanded ); 559 } 560 }; 561 if ( section.expanded() ) { 562 onExpanded( true ); 563 } else { 564 section.expanded.bind( onExpanded ); 565 } 566 } ); 567 } 568 }, 569 570 /** 571 * Embed the .widget element inside the li container. 572 * 573 * @since 4.4.0 574 */ 575 embedWidgetControl: function() { 576 var control = this, widgetControl; 577 578 if ( control.widgetControlEmbedded ) { 579 return; 580 } 581 control.widgetControlEmbedded = true; 582 583 widgetControl = $( control.params.widget_control ); 584 control.container.append( widgetControl ); 585 586 control._setupModel(); 587 control._setupWideWidget(); 588 control._setupControlToggle(); 589 590 control._setupWidgetTitle(); 591 control._setupReorderUI(); 592 control._setupHighlightEffects(); 593 control._setupUpdateUI(); 594 control._setupRemoveUI(); 595 }, 596 597 /** 598 * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event. 599 * 600 * @since 4.4.0 601 */ 602 embedWidgetContent: function() { 603 var control = this, widgetContent; 604 605 control.embedWidgetControl(); 606 if ( control.widgetContentEmbedded ) { 607 return; 608 } 609 control.widgetContentEmbedded = true; 610 611 // Update the notification container element now that the widget content has been embedded. 612 control.notifications.container = control.getNotificationsContainerElement(); 613 control.notifications.render(); 614 615 widgetContent = $( control.params.widget_content ); 616 control.container.find( '.widget-content:first' ).append( widgetContent ); 617 618 /* 619 * Trigger widget-added event so that plugins can attach any event 620 * listeners and dynamic UI elements. 621 */ 622 $( document ).trigger( 'widget-added', [ control.container.find( '.widget:first' ) ] ); 623 624 }, 625 626 /** 627 * Handle changes to the setting 628 */ 629 _setupModel: function() { 630 var self = this, rememberSavedWidgetId; 631 632 // Remember saved widgets so we know which to trash (move to inactive widgets sidebar). 633 rememberSavedWidgetId = function() { 634 api.Widgets.savedWidgetIds[self.params.widget_id] = true; 635 }; 636 api.bind( 'ready', rememberSavedWidgetId ); 637 api.bind( 'saved', rememberSavedWidgetId ); 638 639 this._updateCount = 0; 640 this.isWidgetUpdating = false; 641 this.liveUpdateMode = true; 642 643 // Update widget whenever model changes. 644 this.setting.bind( function( to, from ) { 645 if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) { 646 self.updateWidget( { instance: to } ); 647 } 648 } ); 649 }, 650 651 /** 652 * Add special behaviors for wide widget controls 653 */ 654 _setupWideWidget: function() { 655 var self = this, $widgetInside, $widgetForm, $customizeSidebar, 656 $themeControlsContainer, positionWidget; 657 658 if ( ! this.params.is_wide || $( window ).width() <= 640 /* max-width breakpoint in customize-controls.css */ ) { 659 return; 660 } 661 662 $widgetInside = this.container.find( '.widget-inside' ); 663 $widgetForm = $widgetInside.find( '> .form' ); 664 $customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' ); 665 this.container.addClass( 'wide-widget-control' ); 666 667 this.container.find( '.form:first' ).css( { 668 'max-width': this.params.width, 669 'min-height': this.params.height 670 } ); 671 672 /** 673 * Keep the widget-inside positioned so the top of fixed-positioned 674 * element is at the same top position as the widget-top. When the 675 * widget-top is scrolled out of view, keep the widget-top in view; 676 * likewise, don't allow the widget to drop off the bottom of the window. 677 * If a widget is too tall to fit in the window, don't let the height 678 * exceed the window height so that the contents of the widget control 679 * will become scrollable (overflow:auto). 680 */ 681 positionWidget = function() { 682 var offsetTop = self.container.offset().top, 683 windowHeight = $( window ).height(), 684 formHeight = $widgetForm.outerHeight(), 685 top; 686 $widgetInside.css( 'max-height', windowHeight ); 687 top = Math.max( 688 0, // Prevent top from going off screen. 689 Math.min( 690 Math.max( offsetTop, 0 ), // Distance widget in panel is from top of screen. 691 windowHeight - formHeight // Flush up against bottom of screen. 692 ) 693 ); 694 $widgetInside.css( 'top', top ); 695 }; 696 697 $themeControlsContainer = $( '#customize-theme-controls' ); 698 this.container.on( 'expand', function() { 699 positionWidget(); 700 $customizeSidebar.on( 'scroll', positionWidget ); 701 $( window ).on( 'resize', positionWidget ); 702 $themeControlsContainer.on( 'expanded collapsed', positionWidget ); 703 } ); 704 this.container.on( 'collapsed', function() { 705 $customizeSidebar.off( 'scroll', positionWidget ); 706 $( window ).off( 'resize', positionWidget ); 707 $themeControlsContainer.off( 'expanded collapsed', positionWidget ); 708 } ); 709 710 // Reposition whenever a sidebar's widgets are changed. 711 api.each( function( setting ) { 712 if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) { 713 setting.bind( function() { 714 if ( self.container.hasClass( 'expanded' ) ) { 715 positionWidget(); 716 } 717 } ); 718 } 719 } ); 720 }, 721 722 /** 723 * Show/hide the control when clicking on the form title, when clicking 724 * the close button 725 */ 726 _setupControlToggle: function() { 727 var self = this, $closeBtn; 728 729 this.container.find( '.widget-top' ).on( 'click', function( e ) { 730 e.preventDefault(); 731 var sidebarWidgetsControl = self.getSidebarWidgetsControl(); 732 if ( sidebarWidgetsControl.isReordering ) { 733 return; 734 } 735 self.expanded( ! self.expanded() ); 736 } ); 737 738 $closeBtn = this.container.find( '.widget-control-close' ); 739 $closeBtn.on( 'click', function() { 740 self.collapse(); 741 self.container.find( '.widget-top .widget-action:first' ).focus(); // Keyboard accessibility. 742 } ); 743 }, 744 745 /** 746 * Update the title of the form if a title field is entered 747 */ 748 _setupWidgetTitle: function() { 749 var self = this, updateTitle; 750 751 updateTitle = function() { 752 var title = self.setting().title, 753 inWidgetTitle = self.container.find( '.in-widget-title' ); 754 755 if ( title ) { 756 inWidgetTitle.text( ': ' + title ); 757 } else { 758 inWidgetTitle.text( '' ); 759 } 760 }; 761 this.setting.bind( updateTitle ); 762 updateTitle(); 763 }, 764 765 /** 766 * Set up the widget-reorder-nav 767 */ 768 _setupReorderUI: function() { 769 var self = this, selectSidebarItem, $moveWidgetArea, 770 $reorderNav, updateAvailableSidebars, template; 771 772 /** 773 * Selects the provided sidebar list item in the move widget area. 774 * 775 * @param {JQuery} li The sidebar list item to select. 776 */ 777 selectSidebarItem = function( li ) { 778 li.siblings( '.selected' ).removeClass( 'selected' ); 779 li.addClass( 'selected' ); 780 var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id ); 781 self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar ); 782 }; 783 784 /** 785 * Add the widget reordering elements to the widget control 786 */ 787 this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) ); 788 789 790 template = _.template( api.Widgets.data.tpl.moveWidgetArea ); 791 $moveWidgetArea = $( template( { 792 sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' ) 793 } ) 794 ); 795 this.container.find( '.widget-top' ).after( $moveWidgetArea ); 796 797 /** 798 * Update available sidebars when their rendered state changes 799 */ 800 updateAvailableSidebars = function() { 801 var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem, 802 renderedSidebarCount = 0; 803 804 selfSidebarItem = $sidebarItems.filter( function(){ 805 return $( this ).data( 'id' ) === self.params.sidebar_id; 806 } ); 807 808 $sidebarItems.each( function() { 809 var li = $( this ), 810 sidebarId, sidebar, sidebarIsRendered; 811 812 sidebarId = li.data( 'id' ); 813 sidebar = api.Widgets.registeredSidebars.get( sidebarId ); 814 sidebarIsRendered = sidebar.get( 'is_rendered' ); 815 816 li.toggle( sidebarIsRendered ); 817 818 if ( sidebarIsRendered ) { 819 renderedSidebarCount += 1; 820 } 821 822 if ( li.hasClass( 'selected' ) && ! sidebarIsRendered ) { 823 selectSidebarItem( selfSidebarItem ); 824 } 825 } ); 826 827 if ( renderedSidebarCount > 1 ) { 828 self.container.find( '.move-widget' ).show(); 829 } else { 830 self.container.find( '.move-widget' ).hide(); 831 } 832 }; 833 834 updateAvailableSidebars(); 835 api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars ); 836 837 /** 838 * Handle clicks for up/down/move on the reorder nav 839 */ 840 $reorderNav = this.container.find( '.widget-reorder-nav' ); 841 $reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() { 842 $( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' ); 843 } ).on( 'click keypress', function( event ) { 844 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { 845 return; 846 } 847 $( this ).trigger( 'focus' ); 848 849 if ( $( this ).is( '.move-widget' ) ) { 850 self.toggleWidgetMoveArea(); 851 } else { 852 var isMoveDown = $( this ).is( '.move-widget-down' ), 853 isMoveUp = $( this ).is( '.move-widget-up' ), 854 i = self.getWidgetSidebarPosition(); 855 856 if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) { 857 return; 858 } 859 860 if ( isMoveUp ) { 861 self.moveUp(); 862 wp.a11y.speak( l10n.widgetMovedUp ); 863 } else { 864 self.moveDown(); 865 wp.a11y.speak( l10n.widgetMovedDown ); 866 } 867 868 $( this ).trigger( 'focus' ); // Re-focus after the container was moved. 869 } 870 } ); 871 872 /** 873 * Handle selecting a sidebar to move to 874 */ 875 this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( event ) { 876 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) { 877 return; 878 } 879 event.preventDefault(); 880 selectSidebarItem( $( this ) ); 881 } ); 882 883 /** 884 * Move widget to another sidebar 885 */ 886 this.container.find( '.move-widget-btn' ).click( function() { 887 self.getSidebarWidgetsControl().toggleReordering( false ); 888 889 var oldSidebarId = self.params.sidebar_id, 890 newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ), 891 oldSidebarWidgetsSetting, newSidebarWidgetsSetting, 892 oldSidebarWidgetIds, newSidebarWidgetIds, i; 893 894 oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' ); 895 newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' ); 896 oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() ); 897 newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() ); 898 899 i = self.getWidgetSidebarPosition(); 900 oldSidebarWidgetIds.splice( i, 1 ); 901 newSidebarWidgetIds.push( self.params.widget_id ); 902 903 oldSidebarWidgetsSetting( oldSidebarWidgetIds ); 904 newSidebarWidgetsSetting( newSidebarWidgetIds ); 905 906 self.focus(); 907 } ); 908 }, 909 910 /** 911 * Highlight widgets in preview when interacted with in the Customizer 912 */ 913 _setupHighlightEffects: function() { 914 var self = this; 915 916 // Highlight whenever hovering or clicking over the form. 917 this.container.on( 'mouseenter click', function() { 918 self.setting.previewer.send( 'highlight-widget', self.params.widget_id ); 919 } ); 920 921 // Highlight when the setting is updated. 922 this.setting.bind( function() { 923 self.setting.previewer.send( 'highlight-widget', self.params.widget_id ); 924 } ); 925 }, 926 927 /** 928 * Set up event handlers for widget updating 929 */ 930 _setupUpdateUI: function() { 931 var self = this, $widgetRoot, $widgetContent, 932 $saveBtn, updateWidgetDebounced, formSyncHandler; 933 934 $widgetRoot = this.container.find( '.widget:first' ); 935 $widgetContent = $widgetRoot.find( '.widget-content:first' ); 936 937 // Configure update button. 938 $saveBtn = this.container.find( '.widget-control-save' ); 939 $saveBtn.val( l10n.saveBtnLabel ); 940 $saveBtn.attr( 'title', l10n.saveBtnTooltip ); 941 $saveBtn.removeClass( 'button-primary' ); 942 $saveBtn.on( 'click', function( e ) { 943 e.preventDefault(); 944 self.updateWidget( { disable_form: true } ); // @todo disable_form is unused? 945 } ); 946 947 updateWidgetDebounced = _.debounce( function() { 948 self.updateWidget(); 949 }, 250 ); 950 951 // Trigger widget form update when hitting Enter within an input. 952 $widgetContent.on( 'keydown', 'input', function( e ) { 953 if ( 13 === e.which ) { // Enter. 954 e.preventDefault(); 955 self.updateWidget( { ignoreActiveElement: true } ); 956 } 957 } ); 958 959 // Handle widgets that support live previews. 960 $widgetContent.on( 'change input propertychange', ':input', function( e ) { 961 if ( ! self.liveUpdateMode ) { 962 return; 963 } 964 if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) { 965 updateWidgetDebounced(); 966 } 967 } ); 968 969 // Remove loading indicators when the setting is saved and the preview updates. 970 this.setting.previewer.channel.bind( 'synced', function() { 971 self.container.removeClass( 'previewer-loading' ); 972 } ); 973 974 api.previewer.bind( 'widget-updated', function( updatedWidgetId ) { 975 if ( updatedWidgetId === self.params.widget_id ) { 976 self.container.removeClass( 'previewer-loading' ); 977 } 978 } ); 979 980 formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ]; 981 if ( formSyncHandler ) { 982 $( document ).on( 'widget-synced', function( e, widget, ...args ) { 983 if ( $widgetRoot.is( widget ) ) { 984 formSyncHandler.call( document, e, widget, ...args ); 985 } 986 } ); 987 } 988 }, 989 990 /** 991 * Update widget control to indicate whether it is currently rendered. 992 * 993 * Overrides api.Control.toggle() 994 * 995 * @since 4.1.0 996 * 997 * @param {boolean} active Whether the widget is rendered. 998 * @param {Object} args Args. 999 * @param {Function} args.completeCallback Function to call once the class has been toggled. 1000 */ 1001 onChangeActive: function ( active, args ) { 1002 // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments. 1003 this.container.toggleClass( 'widget-rendered', active ); 1004 if ( args.completeCallback ) { 1005 args.completeCallback(); 1006 } 1007 }, 1008 1009 /** 1010 * Set up event handlers for widget removal 1011 */ 1012 _setupRemoveUI: function() { 1013 var self = this, $removeBtn, replaceDeleteWithRemove; 1014 1015 // Configure remove button. 1016 $removeBtn = this.container.find( '.widget-control-remove' ); 1017 $removeBtn.on( 'click', function() { 1018 // Find an adjacent element to add focus to when this widget goes away. 1019 var $adjacentFocusTarget; 1020 if ( self.container.next().is( '.customize-control-widget_form' ) ) { 1021 $adjacentFocusTarget = self.container.next().find( '.widget-action:first' ); 1022 } else if ( self.container.prev().is( '.customize-control-widget_form' ) ) { 1023 $adjacentFocusTarget = self.container.prev().find( '.widget-action:first' ); 1024 } else { 1025 $adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' ); 1026 } 1027 1028 self.container.slideUp( function() { 1029 var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ), 1030 sidebarWidgetIds, i; 1031 1032 if ( ! sidebarsWidgetsControl ) { 1033 return; 1034 } 1035 1036 sidebarWidgetIds = sidebarsWidgetsControl.setting().slice(); 1037 i = _.indexOf( sidebarWidgetIds, self.params.widget_id ); 1038 if ( -1 === i ) { 1039 return; 1040 } 1041 1042 sidebarWidgetIds.splice( i, 1 ); 1043 sidebarsWidgetsControl.setting( sidebarWidgetIds ); 1044 1045 $adjacentFocusTarget.focus(); // Keyboard accessibility. 1046 } ); 1047 } ); 1048 1049 replaceDeleteWithRemove = function() { 1050 $removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the button as "Delete". 1051 $removeBtn.attr( 'title', l10n.removeBtnTooltip ); 1052 }; 1053 1054 if ( this.params.is_new ) { 1055 api.bind( 'saved', replaceDeleteWithRemove ); 1056 } else { 1057 replaceDeleteWithRemove(); 1058 } 1059 }, 1060 1061 /** 1062 * Find all inputs in a widget container that should be considered when 1063 * comparing the loaded form with the sanitized form, whose fields will 1064 * be aligned to copy the sanitized over. The elements returned by this 1065 * are passed into this._getInputsSignature(), and they are iterated 1066 * over when copying sanitized values over to the form loaded. 1067 * 1068 * @param {JQuery} container Element in which to look for inputs. 1069 * @return {JQuery} The inputs found within the container. 1070 * @private 1071 */ 1072 _getInputs: function( container ) { 1073 return $( container ).find( ':input[name]' ); 1074 }, 1075 1076 /** 1077 * Iterate over supplied inputs and create a signature string for all of them together. 1078 * This string can be used to compare whether or not the form has all of the same fields. 1079 * 1080 * @param {JQuery} inputs The inputs to build a signature from. 1081 * @return {string} Signature string for the inputs. 1082 * @private 1083 */ 1084 _getInputsSignature: function( inputs ) { 1085 var inputsSignatures = _( inputs ).map( function( input ) { 1086 var $input = $( input ), signatureParts; 1087 1088 if ( $input.is( ':checkbox, :radio' ) ) { 1089 signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ]; 1090 } else { 1091 signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ]; 1092 } 1093 1094 return signatureParts.join( ',' ); 1095 } ); 1096 1097 return inputsSignatures.join( ';' ); 1098 }, 1099 1100 /** 1101 * Get the state for an input depending on its type. 1102 * 1103 * @param {JQuery|Element} input The input to read the state from. 1104 * @return {string|boolean|string[]|*} State of the input. 1105 * @private 1106 */ 1107 _getInputState: function( input ) { 1108 input = $( input ); 1109 if ( input.is( ':radio, :checkbox' ) ) { 1110 return input.prop( 'checked' ); 1111 } else if ( input.is( 'select[multiple]' ) ) { 1112 return input.find( 'option:selected' ).map( function () { 1113 return $( this ).val(); 1114 } ).get(); 1115 } else { 1116 return input.val(); 1117 } 1118 }, 1119 1120 /** 1121 * Update an input's state based on its type. 1122 * 1123 * @param {JQuery|Element} input The input element to update. 1124 * @param {string|boolean|string[]|*} state The state to apply: the checked state for checkboxes and radio buttons, an array of values for multi-selects, and otherwise the input's value. 1125 * @private 1126 */ 1127 _setInputState: function ( input, state ) { 1128 input = $( input ); 1129 if ( input.is( ':radio, :checkbox' ) ) { 1130 input.prop( 'checked', state ); 1131 } else if ( input.is( 'select[multiple]' ) ) { 1132 if ( ! Array.isArray( state ) ) { 1133 state = []; 1134 } else { 1135 // Make sure all state items are strings since the DOM value is a string. 1136 state = _.map( state, function ( value ) { 1137 return String( value ); 1138 } ); 1139 } 1140 input.find( 'option' ).each( function () { 1141 $( this ).prop( 'selected', -1 !== _.indexOf( state, String( this.value ) ) ); 1142 } ); 1143 } else { 1144 input.val( state ); 1145 } 1146 }, 1147 1148 /*********************************************************************** 1149 * Begin public API methods 1150 **********************************************************************/ 1151 1152 /** 1153 * Get the sidebar widgets control for the sidebar this widget is in. 1154 * 1155 * @return {wp.customize.Widgets.SidebarControl|undefined} The sidebar widgets control, or undefined if not found. 1156 */ 1157 getSidebarWidgetsControl: function() { 1158 var settingId, sidebarWidgetsControl; 1159 1160 settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']'; 1161 sidebarWidgetsControl = api.control( settingId ); 1162 1163 if ( ! sidebarWidgetsControl ) { 1164 return; 1165 } 1166 1167 return sidebarWidgetsControl; 1168 }, 1169 1170 /** 1171 * Submit the widget form via Ajax and get back the updated instance, 1172 * along with the new widget control form to render. 1173 * 1174 * @param {Object} [args] Arguments for the update. 1175 * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used. 1176 * @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. 1177 * @param {boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element. 1178 */ 1179 updateWidget: function( args ) { 1180 var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent, 1181 updateNumber, params, data, $inputs, processing, jqxhr, isChanged; 1182 1183 // The updateWidget logic requires that the form fields to be fully present. 1184 self.embedWidgetContent(); 1185 1186 args = $.extend( { 1187 instance: null, 1188 complete: null, 1189 ignoreActiveElement: false 1190 }, args ); 1191 1192 instanceOverride = args.instance; 1193 completeCallback = args.complete; 1194 1195 this._updateCount += 1; 1196 updateNumber = this._updateCount; 1197 1198 $widgetRoot = this.container.find( '.widget:first' ); 1199 $widgetContent = $widgetRoot.find( '.widget-content:first' ); 1200 1201 // Remove a previous error message. 1202 $widgetContent.find( '.widget-error' ).remove(); 1203 1204 this.container.addClass( 'widget-form-loading' ); 1205 this.container.addClass( 'previewer-loading' ); 1206 processing = api.state( 'processing' ); 1207 processing( processing() + 1 ); 1208 1209 if ( ! this.liveUpdateMode ) { 1210 this.container.addClass( 'widget-form-disabled' ); 1211 } 1212 1213 params = {}; 1214 params.action = 'update-widget'; 1215 params.wp_customize = 'on'; 1216 params.nonce = api.settings.nonce['update-widget']; 1217 params.customize_theme = api.settings.theme.stylesheet; 1218 params.customized = wp.customize.previewer.query().customized; 1219 1220 data = $.param( params ); 1221 $inputs = this._getInputs( $widgetContent ); 1222 1223 /* 1224 * Store the value we're submitting in data so that when the response comes back, 1225 * we know if it got sanitized; if there is no difference in the sanitized value, 1226 * then we do not need to touch the UI and mess up the user's ongoing editing. 1227 */ 1228 $inputs.each( function() { 1229 $( this ).data( 'state' + updateNumber, self._getInputState( this ) ); 1230 } ); 1231 1232 if ( instanceOverride ) { 1233 data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } ); 1234 } else { 1235 data += '&' + $inputs.serialize(); 1236 } 1237 data += '&' + $widgetContent.find( '~ :input' ).serialize(); 1238 1239 if ( this._previousUpdateRequest ) { 1240 this._previousUpdateRequest.abort(); 1241 } 1242 jqxhr = $.post( wp.ajax.settings.url, data ); 1243 this._previousUpdateRequest = jqxhr; 1244 1245 jqxhr.done( function( r ) { 1246 var message, sanitizedForm, $sanitizedInputs, hasSameInputsInResponse, 1247 isLiveUpdateAborted = false; 1248 1249 // Check if the user is logged out. 1250 if ( '0' === r ) { 1251 api.previewer.preview.iframe.hide(); 1252 api.previewer.login().done( function() { 1253 self.updateWidget( args ); 1254 api.previewer.preview.iframe.show(); 1255 } ); 1256 return; 1257 } 1258 1259 // Check for cheaters. 1260 if ( '-1' === r ) { 1261 api.previewer.cheatin(); 1262 return; 1263 } 1264 1265 if ( r.success ) { 1266 sanitizedForm = $( '<div>' + r.data.form + '</div>' ); 1267 $sanitizedInputs = self._getInputs( sanitizedForm ); 1268 hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs ); 1269 1270 // Restore live update mode if sanitized fields are now aligned with the existing fields. 1271 if ( hasSameInputsInResponse && ! self.liveUpdateMode ) { 1272 self.liveUpdateMode = true; 1273 self.container.removeClass( 'widget-form-disabled' ); 1274 self.container.find( 'input[name="savewidget"]' ).hide(); 1275 } 1276 1277 // Sync sanitized field states to existing fields if they are aligned. 1278 if ( hasSameInputsInResponse && self.liveUpdateMode ) { 1279 $inputs.each( function( i ) { 1280 var $input = $( this ), 1281 $sanitizedInput = $( $sanitizedInputs[i] ), 1282 submittedState, sanitizedState, canUpdateState; 1283 1284 submittedState = $input.data( 'state' + updateNumber ); 1285 sanitizedState = self._getInputState( $sanitizedInput ); 1286 $input.data( 'sanitized', sanitizedState ); 1287 1288 canUpdateState = ( ! _.isEqual( submittedState, sanitizedState ) && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) ) ); 1289 if ( canUpdateState ) { 1290 self._setInputState( $input, sanitizedState ); 1291 } 1292 } ); 1293 1294 $( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] ); 1295 1296 // Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled. 1297 } else if ( self.liveUpdateMode ) { 1298 self.liveUpdateMode = false; 1299 self.container.find( 'input[name="savewidget"]' ).show(); 1300 isLiveUpdateAborted = true; 1301 1302 // Otherwise, replace existing form with the sanitized form. 1303 } else { 1304 $widgetContent.html( r.data.form ); 1305 1306 self.container.removeClass( 'widget-form-disabled' ); 1307 1308 $( document ).trigger( 'widget-updated', [ $widgetRoot ] ); 1309 } 1310 1311 /** 1312 * If the old instance is identical to the new one, there is nothing new 1313 * needing to be rendered, and so we can preempt the event for the 1314 * preview finishing loading. 1315 */ 1316 isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance ); 1317 if ( isChanged ) { 1318 self.isWidgetUpdating = true; // Suppress triggering another updateWidget. 1319 self.setting( r.data.instance ); 1320 self.isWidgetUpdating = false; 1321 } else { 1322 // No change was made, so stop the spinner now instead of when the preview would updates. 1323 self.container.removeClass( 'previewer-loading' ); 1324 } 1325 1326 if ( completeCallback ) { 1327 completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } ); 1328 } 1329 } else { 1330 // General error message. 1331 message = l10n.error; 1332 1333 if ( r.data && r.data.message ) { 1334 message = r.data.message; 1335 } 1336 1337 if ( completeCallback ) { 1338 completeCallback.call( self, message ); 1339 } else { 1340 $widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' ); 1341 } 1342 } 1343 } ); 1344 1345 jqxhr.fail( function( jqXHR, textStatus ) { 1346 if ( completeCallback ) { 1347 completeCallback.call( self, textStatus ); 1348 } 1349 } ); 1350 1351 jqxhr.always( function() { 1352 self.container.removeClass( 'widget-form-loading' ); 1353 1354 $inputs.each( function() { 1355 $( this ).removeData( 'state' + updateNumber ); 1356 } ); 1357 1358 processing( processing() - 1 ); 1359 } ); 1360 }, 1361 1362 /** 1363 * Expand the accordion section containing a control 1364 */ 1365 expandControlSection: function() { 1366 api.Control.prototype.expand.call( this ); 1367 }, 1368 1369 /** 1370 * @since 4.1.0 1371 * 1372 * @param {boolean} expanded The new state to apply. 1373 * @param {Object} [params] Object containing options for expand/collapse. 1374 * @return {boolean} False if state already applied. 1375 */ 1376 _toggleExpanded: api.Section.prototype._toggleExpanded, 1377 1378 /** 1379 * @since 4.1.0 1380 * 1381 * @param {Object} [params] Object containing options for expansion. 1382 * @return {boolean} False if already expanded. 1383 */ 1384 expand: api.Section.prototype.expand, 1385 1386 /** 1387 * Expand the widget form control 1388 * 1389 * @deprecated 4.1.0 Use this.expand() instead. 1390 */ 1391 expandForm: function() { 1392 this.expand(); 1393 }, 1394 1395 /** 1396 * @since 4.1.0 1397 * 1398 * @param {Object} [params] Object containing options for collapse. 1399 * @return {boolean} False if already collapsed. 1400 */ 1401 collapse: api.Section.prototype.collapse, 1402 1403 /** 1404 * Collapse the widget form control 1405 * 1406 * @deprecated 4.1.0 Use this.collapse() instead. 1407 */ 1408 collapseForm: function() { 1409 this.collapse(); 1410 }, 1411 1412 /** 1413 * Expand or collapse the widget control 1414 * 1415 * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide ) 1416 * 1417 * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility. 1418 */ 1419 toggleForm: function( showOrHide ) { 1420 if ( typeof showOrHide === 'undefined' ) { 1421 showOrHide = ! this.expanded(); 1422 } 1423 this.expanded( showOrHide ); 1424 }, 1425 1426 /** 1427 * Respond to change in the expanded state. 1428 * 1429 * @param {boolean} expanded The expanded state to transition to. 1430 * @param {Object} args Object containing options for expand/collapse, merged on top of this.defaultExpandedArguments. 1431 * @param {boolean} [args.unchanged] Whether the expanded state is unchanged. 1432 * @param {Function} args.completeCallback Callback to be executed once the expand/collapse action is complete. 1433 */ 1434 onChangeExpanded: function ( expanded, args ) { 1435 var self = this, $widget, $inside, complete, prevComplete, expandControl, $toggleBtn; 1436 1437 self.embedWidgetControl(); // Make sure the outer form is embedded so that the expanded state can be set in the UI. 1438 if ( expanded ) { 1439 self.embedWidgetContent(); 1440 } 1441 1442 // If the expanded state is unchanged only manipulate container expanded states. 1443 if ( args.unchanged ) { 1444 if ( expanded ) { 1445 api.Control.prototype.expand.call( self, { 1446 completeCallback: args.completeCallback 1447 }); 1448 } 1449 return; 1450 } 1451 1452 $widget = this.container.find( 'div.widget:first' ); 1453 $inside = $widget.find( '.widget-inside:first' ); 1454 $toggleBtn = this.container.find( '.widget-top button.widget-action' ); 1455 1456 expandControl = function() { 1457 1458 // Close all other widget controls before expanding this one. 1459 api.control.each( function( otherControl ) { 1460 if ( self.params.type === otherControl.params.type && self !== otherControl ) { 1461 otherControl.collapse(); 1462 } 1463 } ); 1464 1465 complete = function() { 1466 self.container.removeClass( 'expanding' ); 1467 self.container.addClass( 'expanded' ); 1468 $widget.addClass( 'open' ); 1469 $toggleBtn.attr( 'aria-expanded', 'true' ); 1470 self.container.trigger( 'expanded' ); 1471 }; 1472 if ( args.completeCallback ) { 1473 prevComplete = complete; 1474 complete = function () { 1475 prevComplete(); 1476 args.completeCallback(); 1477 }; 1478 } 1479 1480 if ( self.params.is_wide ) { 1481 $inside.fadeIn( args.duration, complete ); 1482 } else { 1483 $inside.slideDown( args.duration, complete ); 1484 } 1485 1486 self.container.trigger( 'expand' ); 1487 self.container.addClass( 'expanding' ); 1488 }; 1489 1490 if ( $toggleBtn.attr( 'aria-expanded' ) === 'false' ) { 1491 if ( api.section.has( self.section() ) ) { 1492 api.section( self.section() ).expand( { 1493 completeCallback: expandControl 1494 } ); 1495 } else { 1496 expandControl(); 1497 } 1498 } else { 1499 complete = function() { 1500 self.container.removeClass( 'collapsing' ); 1501 self.container.removeClass( 'expanded' ); 1502 $widget.removeClass( 'open' ); 1503 $toggleBtn.attr( 'aria-expanded', 'false' ); 1504 self.container.trigger( 'collapsed' ); 1505 }; 1506 if ( args.completeCallback ) { 1507 prevComplete = complete; 1508 complete = function () { 1509 prevComplete(); 1510 args.completeCallback(); 1511 }; 1512 } 1513 1514 self.container.trigger( 'collapse' ); 1515 self.container.addClass( 'collapsing' ); 1516 1517 if ( self.params.is_wide ) { 1518 $inside.fadeOut( args.duration, complete ); 1519 } else { 1520 $inside.slideUp( args.duration, function() { 1521 $widget.css( { width:'', margin:'' } ); 1522 complete(); 1523 } ); 1524 } 1525 } 1526 }, 1527 1528 /** 1529 * Get the position (index) of the widget in the containing sidebar 1530 * 1531 * @return {number|void} Index of the widget in the sidebar, or undefined if not found. 1532 */ 1533 getWidgetSidebarPosition: function() { 1534 var sidebarWidgetIds, position; 1535 1536 sidebarWidgetIds = this.getSidebarWidgetsControl().setting(); 1537 position = _.indexOf( sidebarWidgetIds, this.params.widget_id ); 1538 1539 if ( position === -1 ) { 1540 return; 1541 } 1542 1543 return position; 1544 }, 1545 1546 /** 1547 * Move widget up one in the sidebar 1548 */ 1549 moveUp: function() { 1550 this._moveWidgetByOne( -1 ); 1551 }, 1552 1553 /** 1554 * Move widget up one in the sidebar 1555 */ 1556 moveDown: function() { 1557 this._moveWidgetByOne( 1 ); 1558 }, 1559 1560 /** 1561 * Moves the widget up or down one position in the sidebar. 1562 * 1563 * @private 1564 * 1565 * @param {number} offset The number of positions to move the widget, either 1 or -1. 1566 */ 1567 _moveWidgetByOne: function( offset ) { 1568 var i, sidebarWidgetsSetting, sidebarWidgetIds, adjacentWidgetId; 1569 1570 i = this.getWidgetSidebarPosition(); 1571 1572 sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting; 1573 sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // Clone. 1574 adjacentWidgetId = sidebarWidgetIds[i + offset]; 1575 sidebarWidgetIds[i + offset] = this.params.widget_id; 1576 sidebarWidgetIds[i] = adjacentWidgetId; 1577 1578 sidebarWidgetsSetting( sidebarWidgetIds ); 1579 }, 1580 1581 /** 1582 * Toggle visibility of the widget move area 1583 * 1584 * @param {boolean} [showOrHide] If not supplied, will be inverse of current visibility. 1585 */ 1586 toggleWidgetMoveArea: function( showOrHide ) { 1587 var self = this, $moveWidgetArea; 1588 1589 $moveWidgetArea = this.container.find( '.move-widget-area' ); 1590 1591 if ( typeof showOrHide === 'undefined' ) { 1592 showOrHide = ! $moveWidgetArea.hasClass( 'active' ); 1593 } 1594 1595 if ( showOrHide ) { 1596 // Reset the selected sidebar. 1597 $moveWidgetArea.find( '.selected' ).removeClass( 'selected' ); 1598 1599 $moveWidgetArea.find( 'li' ).filter( function() { 1600 return $( this ).data( 'id' ) === self.params.sidebar_id; 1601 } ).addClass( 'selected' ); 1602 1603 this.container.find( '.move-widget-btn' ).prop( 'disabled', true ); 1604 } 1605 1606 $moveWidgetArea.toggleClass( 'active', showOrHide ); 1607 }, 1608 1609 /** 1610 * Highlight the widget control and section 1611 */ 1612 highlightSectionAndControl: function() { 1613 var $target; 1614 1615 if ( this.container.is( ':hidden' ) ) { 1616 $target = this.container.closest( '.control-section' ); 1617 } else { 1618 $target = this.container; 1619 } 1620 1621 $( '.highlighted' ).removeClass( 'highlighted' ); 1622 $target.addClass( 'highlighted' ); 1623 1624 setTimeout( function() { 1625 $target.removeClass( 'highlighted' ); 1626 }, 500 ); 1627 } 1628 } ); 1629 1630 /** 1631 * wp.customize.Widgets.WidgetsPanel 1632 * 1633 * Customizer panel containing the widget area sections. 1634 * 1635 * @since 4.4.0 1636 * 1637 * @class wp.customize.Widgets.WidgetsPanel 1638 * @augments wp.customize.Panel 1639 */ 1640 api.Widgets.WidgetsPanel = api.Panel.extend(/** @lends wp.customize.Widgets.WidgetsPanel.prototype */{ 1641 1642 /** 1643 * Add and manage the display of the no-rendered-areas notice. 1644 * 1645 * @since 4.4.0 1646 */ 1647 ready: function () { 1648 var panel = this; 1649 1650 api.Panel.prototype.ready.call( panel ); 1651 1652 panel.deferred.embedded.done(function() { 1653 var panelMetaContainer, noticeContainer, updateNotice, getActiveSectionCount, shouldShowNotice; 1654 panelMetaContainer = panel.container.find( '.panel-meta' ); 1655 1656 // @todo This should use the Notifications API introduced to panels. See <https://core.trac.wordpress.org/ticket/38794>. 1657 noticeContainer = $( '<div></div>', { 1658 'class': 'no-widget-areas-rendered-notice', 1659 'role': 'alert' 1660 }); 1661 panelMetaContainer.append( noticeContainer ); 1662 1663 /** 1664 * Get the number of active sections in the panel. 1665 * 1666 * @return {number} Number of active sidebar sections. 1667 */ 1668 getActiveSectionCount = function() { 1669 return _.filter( panel.sections(), function( section ) { 1670 return 'sidebar' === section.params.type && section.active(); 1671 } ).length; 1672 }; 1673 1674 /** 1675 * Determine whether or not the notice should be displayed. 1676 * 1677 * @return {boolean} True if the notice should be displayed, false otherwise. 1678 */ 1679 shouldShowNotice = function() { 1680 var activeSectionCount = getActiveSectionCount(); 1681 if ( 0 === activeSectionCount ) { 1682 return true; 1683 } else { 1684 return activeSectionCount !== api.Widgets.data.registeredSidebars.length; 1685 } 1686 }; 1687 1688 /** 1689 * Update the notice. 1690 * 1691 * @return {void} 1692 */ 1693 updateNotice = function() { 1694 var activeSectionCount = getActiveSectionCount(), someRenderedMessage, nonRenderedAreaCount, registeredAreaCount; 1695 noticeContainer.empty(); 1696 1697 registeredAreaCount = api.Widgets.data.registeredSidebars.length; 1698 if ( activeSectionCount !== registeredAreaCount ) { 1699 1700 if ( 0 !== activeSectionCount ) { 1701 nonRenderedAreaCount = registeredAreaCount - activeSectionCount; 1702 someRenderedMessage = l10n.someAreasShown[ nonRenderedAreaCount ]; 1703 } else { 1704 someRenderedMessage = l10n.noAreasShown; 1705 } 1706 if ( someRenderedMessage ) { 1707 noticeContainer.append( $( '<p></p>', { 1708 text: someRenderedMessage 1709 } ) ); 1710 } 1711 1712 noticeContainer.append( $( '<p></p>', { 1713 text: l10n.navigatePreview 1714 } ) ); 1715 } 1716 }; 1717 updateNotice(); 1718 1719 /* 1720 * Set the initial visibility state for rendered notice. 1721 * Update the visibility of the notice whenever a reflow happens. 1722 */ 1723 noticeContainer.toggle( shouldShowNotice() ); 1724 api.previewer.deferred.active.done( function () { 1725 noticeContainer.toggle( shouldShowNotice() ); 1726 }); 1727 api.bind( 'pane-contents-reflowed', function() { 1728 var duration = ( 'resolved' === api.previewer.deferred.active.state() ) ? 'fast' : 0; 1729 updateNotice(); 1730 if ( shouldShowNotice() ) { 1731 noticeContainer.slideDown( duration ); 1732 } else { 1733 noticeContainer.slideUp( duration ); 1734 } 1735 }); 1736 }); 1737 }, 1738 1739 /** 1740 * Allow an active widgets panel to be contextually active even when it has no active sections (widget areas). 1741 * 1742 * This ensures that the widgets panel appears even when there are no 1743 * sidebars displayed on the URL currently being previewed. 1744 * 1745 * @since 4.4.0 1746 * 1747 * @return {boolean} True if the panel is contextually active, false otherwise. 1748 */ 1749 isContextuallyActive: function() { 1750 var panel = this; 1751 return panel.active(); 1752 } 1753 }); 1754 1755 /** 1756 * wp.customize.Widgets.SidebarSection 1757 * 1758 * Customizer section representing a widget area widget 1759 * 1760 * @since 4.1.0 1761 * 1762 * @class wp.customize.Widgets.SidebarSection 1763 * @augments wp.customize.Section 1764 */ 1765 api.Widgets.SidebarSection = api.Section.extend(/** @lends wp.customize.Widgets.SidebarSection.prototype */{ 1766 1767 /** 1768 * Sync the section's active state back to the Backbone model's is_rendered attribute 1769 * 1770 * @since 4.1.0 1771 */ 1772 ready: function () { 1773 var section = this, registeredSidebar; 1774 api.Section.prototype.ready.call( this ); 1775 registeredSidebar = api.Widgets.registeredSidebars.get( section.params.sidebarId ); 1776 section.active.bind( function ( active ) { 1777 registeredSidebar.set( 'is_rendered', active ); 1778 }); 1779 registeredSidebar.set( 'is_rendered', section.active() ); 1780 } 1781 }); 1782 1783 /** 1784 * wp.customize.Widgets.SidebarControl 1785 * 1786 * Customizer control for widgets. 1787 * Note that 'sidebar_widgets' must match the WP_Widget_Area_Customize_Control::$type 1788 * 1789 * @since 3.9.0 1790 * 1791 * @class wp.customize.Widgets.SidebarControl 1792 * @augments wp.customize.Control 1793 */ 1794 api.Widgets.SidebarControl = api.Control.extend(/** @lends wp.customize.Widgets.SidebarControl.prototype */{ 1795 1796 /** 1797 * Set up the control 1798 */ 1799 ready: function() { 1800 this.$controlSection = this.container.closest( '.control-section' ); 1801 this.$sectionContent = this.container.closest( '.accordion-section-content' ); 1802 1803 this._setupModel(); 1804 this._setupSortable(); 1805 this._setupAddition(); 1806 this._applyCardinalOrderClassNames(); 1807 }, 1808 1809 /** 1810 * Update ordering of widget control forms when the setting is updated 1811 */ 1812 _setupModel: function() { 1813 var self = this; 1814 1815 this.setting.bind( function( newWidgetIds, oldWidgetIds ) { 1816 var widgetFormControls, removedWidgetIds, priority; 1817 1818 removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds ); 1819 1820 // Filter out any persistent widget IDs for widgets which have been deactivated. 1821 newWidgetIds = _( newWidgetIds ).filter( function( newWidgetId ) { 1822 var parsedWidgetId = parseWidgetId( newWidgetId ); 1823 1824 return !! api.Widgets.availableWidgets.findWhere( { id_base: parsedWidgetId.id_base } ); 1825 } ); 1826 1827 widgetFormControls = _( newWidgetIds ).map( function( widgetId ) { 1828 var widgetFormControl = api.Widgets.getWidgetFormControlForWidget( widgetId ); 1829 1830 if ( ! widgetFormControl ) { 1831 widgetFormControl = self.addWidget( widgetId ); 1832 } 1833 1834 return widgetFormControl; 1835 } ); 1836 1837 // Sort widget controls to their new positions. 1838 widgetFormControls.sort( function( a, b ) { 1839 var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ), 1840 bIndex = _.indexOf( newWidgetIds, b.params.widget_id ); 1841 return aIndex - bIndex; 1842 }); 1843 1844 priority = 0; 1845 _( widgetFormControls ).each( function ( control ) { 1846 control.priority( priority ); 1847 control.section( self.section() ); 1848 priority += 1; 1849 }); 1850 self.priority( priority ); // Make sure sidebar control remains at end. 1851 1852 // Re-sort widget form controls (including widgets form other sidebars newly moved here). 1853 self._applyCardinalOrderClassNames(); 1854 1855 // If the widget was dragged into the sidebar, make sure the sidebar_id param is updated. 1856 _( widgetFormControls ).each( function( widgetFormControl ) { 1857 widgetFormControl.params.sidebar_id = self.params.sidebar_id; 1858 } ); 1859 1860 // Cleanup after widget removal. 1861 _( removedWidgetIds ).each( function( removedWidgetId ) { 1862 1863 // Using setTimeout so that when moving a widget to another sidebar, 1864 // the other sidebars_widgets settings get a chance to update. 1865 setTimeout( function() { 1866 var removedControl, wasDraggedToAnotherSidebar, inactiveWidgets, removedIdBase, 1867 widget, isPresentInAnotherSidebar = false; 1868 1869 // Check if the widget is in another sidebar. 1870 api.each( function( otherSetting ) { 1871 if ( otherSetting.id === self.setting.id || 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) || otherSetting.id === 'sidebars_widgets[wp_inactive_widgets]' ) { 1872 return; 1873 } 1874 1875 var otherSidebarWidgets = otherSetting(), i; 1876 1877 i = _.indexOf( otherSidebarWidgets, removedWidgetId ); 1878 if ( -1 !== i ) { 1879 isPresentInAnotherSidebar = true; 1880 } 1881 } ); 1882 1883 // If the widget is present in another sidebar, abort! 1884 if ( isPresentInAnotherSidebar ) { 1885 return; 1886 } 1887 1888 removedControl = api.Widgets.getWidgetFormControlForWidget( removedWidgetId ); 1889 1890 // Detect if widget control was dragged to another sidebar. 1891 wasDraggedToAnotherSidebar = removedControl && $.contains( document, removedControl.container[0] ) && ! $.contains( self.$sectionContent[0], removedControl.container[0] ); 1892 1893 // Delete any widget form controls for removed widgets. 1894 if ( removedControl && ! wasDraggedToAnotherSidebar ) { 1895 api.control.remove( removedControl.id ); 1896 removedControl.container.remove(); 1897 } 1898 1899 // Move widget to inactive widgets sidebar (move it to Trash) if has been previously saved. 1900 // This prevents the inactive widgets sidebar from overflowing with throwaway widgets. 1901 if ( api.Widgets.savedWidgetIds[removedWidgetId] ) { 1902 inactiveWidgets = api.value( 'sidebars_widgets[wp_inactive_widgets]' )().slice(); 1903 inactiveWidgets.push( removedWidgetId ); 1904 api.value( 'sidebars_widgets[wp_inactive_widgets]' )( _( inactiveWidgets ).unique() ); 1905 } 1906 1907 // Make old single widget available for adding again. 1908 removedIdBase = parseWidgetId( removedWidgetId ).id_base; 1909 widget = api.Widgets.availableWidgets.findWhere( { id_base: removedIdBase } ); 1910 if ( widget && ! widget.get( 'is_multi' ) ) { 1911 widget.set( 'is_disabled', false ); 1912 } 1913 } ); 1914 1915 } ); 1916 } ); 1917 }, 1918 1919 /** 1920 * Allow widgets in sidebar to be re-ordered, and for the order to be previewed 1921 */ 1922 _setupSortable: function() { 1923 var self = this; 1924 1925 this.isReordering = false; 1926 1927 /** 1928 * Update widget order setting when controls are re-ordered 1929 */ 1930 this.$sectionContent.sortable( { 1931 items: '> .customize-control-widget_form', 1932 handle: '.widget-top', 1933 axis: 'y', 1934 tolerance: 'pointer', 1935 connectWith: '.accordion-section-content:has(.customize-control-sidebar_widgets)', 1936 update: function() { 1937 var widgetContainerIds = self.$sectionContent.sortable( 'toArray' ), widgetIds; 1938 1939 widgetIds = $.map( widgetContainerIds, function( widgetContainerId ) { 1940 return $( '#' + widgetContainerId ).find( ':input[name=widget-id]' ).val(); 1941 } ); 1942 1943 self.setting( widgetIds ); 1944 } 1945 } ); 1946 1947 /** 1948 * Expand other Customizer sidebar section when dragging a control widget over it, 1949 * allowing the control to be dropped into another section 1950 */ 1951 this.$controlSection.find( '.accordion-section-title' ).droppable({ 1952 accept: '.customize-control-widget_form', 1953 over: function() { 1954 var section = api.section( self.section.get() ); 1955 section.expand({ 1956 allowMultiple: true, // Prevent the section being dragged from to be collapsed. 1957 completeCallback: function () { 1958 // @todo It is not clear when refreshPositions should be called on which sections, or if it is even needed. 1959 api.section.each( function ( otherSection ) { 1960 if ( otherSection.container.find( '.customize-control-sidebar_widgets' ).length ) { 1961 otherSection.container.find( '.accordion-section-content:first' ).sortable( 'refreshPositions' ); 1962 } 1963 } ); 1964 } 1965 }); 1966 } 1967 }); 1968 1969 /** 1970 * Keyboard-accessible reordering 1971 */ 1972 this.container.find( '.reorder-toggle' ).on( 'click', function() { 1973 self.toggleReordering( ! self.isReordering ); 1974 } ); 1975 }, 1976 1977 /** 1978 * Set up UI for adding a new widget 1979 */ 1980 _setupAddition: function() { 1981 var self = this; 1982 1983 this.container.find( '.add-new-widget' ).on( 'click', function() { 1984 var addNewWidgetBtn = $( this ); 1985 1986 if ( self.$sectionContent.hasClass( 'reordering' ) ) { 1987 return; 1988 } 1989 1990 if ( ! $( 'body' ).hasClass( 'adding-widget' ) ) { 1991 addNewWidgetBtn.attr( 'aria-expanded', 'true' ); 1992 api.Widgets.availableWidgetsPanel.open( self ); 1993 } else { 1994 addNewWidgetBtn.attr( 'aria-expanded', 'false' ); 1995 api.Widgets.availableWidgetsPanel.close(); 1996 } 1997 } ); 1998 }, 1999 2000 /** 2001 * Add classes to the widget_form controls to assist with styling 2002 */ 2003 _applyCardinalOrderClassNames: function() { 2004 var widgetControls = []; 2005 _.each( this.setting(), function ( widgetId ) { 2006 var widgetControl = api.Widgets.getWidgetFormControlForWidget( widgetId ); 2007 if ( widgetControl ) { 2008 widgetControls.push( widgetControl ); 2009 } 2010 }); 2011 2012 if ( 0 === widgetControls.length || ( 1 === api.Widgets.registeredSidebars.length && widgetControls.length <= 1 ) ) { 2013 this.container.find( '.reorder-toggle' ).hide(); 2014 return; 2015 } else { 2016 this.container.find( '.reorder-toggle' ).show(); 2017 } 2018 2019 $( widgetControls ).each( function () { 2020 $( this.container ) 2021 .removeClass( 'first-widget' ) 2022 .removeClass( 'last-widget' ) 2023 .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 ); 2024 }); 2025 2026 _.first( widgetControls ).container 2027 .addClass( 'first-widget' ) 2028 .find( '.move-widget-up' ).prop( 'tabIndex', -1 ); 2029 2030 _.last( widgetControls ).container 2031 .addClass( 'last-widget' ) 2032 .find( '.move-widget-down' ).prop( 'tabIndex', -1 ); 2033 }, 2034 2035 2036 /*********************************************************************** 2037 * Begin public API methods 2038 **********************************************************************/ 2039 2040 /** 2041 * Enable/disable the reordering UI 2042 * 2043 * @param {boolean} showOrHide Whether to enable or disable reordering. 2044 * 2045 * @todo We should have a reordering state instead and rename this to onChangeReordering 2046 */ 2047 toggleReordering: function( showOrHide ) { 2048 var addNewWidgetBtn = this.$sectionContent.find( '.add-new-widget' ), 2049 reorderBtn = this.container.find( '.reorder-toggle' ), 2050 widgetsTitle = this.$sectionContent.find( '.widget-title' ); 2051 2052 showOrHide = Boolean( showOrHide ); 2053 2054 if ( showOrHide === this.$sectionContent.hasClass( 'reordering' ) ) { 2055 return; 2056 } 2057 2058 this.isReordering = showOrHide; 2059 this.$sectionContent.toggleClass( 'reordering', showOrHide ); 2060 2061 if ( showOrHide ) { 2062 _( this.getWidgetFormControls() ).each( function( formControl ) { 2063 formControl.collapse(); 2064 } ); 2065 2066 addNewWidgetBtn.attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); 2067 reorderBtn.attr( 'aria-label', l10n.reorderLabelOff ); 2068 wp.a11y.speak( l10n.reorderModeOn ); 2069 // Hide widget titles while reordering: title is already in the reorder controls. 2070 widgetsTitle.attr( 'aria-hidden', 'true' ); 2071 } else { 2072 addNewWidgetBtn.removeAttr( 'tabindex aria-hidden' ); 2073 reorderBtn.attr( 'aria-label', l10n.reorderLabelOn ); 2074 wp.a11y.speak( l10n.reorderModeOff ); 2075 widgetsTitle.attr( 'aria-hidden', 'false' ); 2076 } 2077 }, 2078 2079 /** 2080 * Get the widget_form Customize controls associated with the current sidebar. 2081 * 2082 * @since 3.9.0 2083 * @return {wp.customize.Widgets.WidgetControl[]} Widget form controls associated with the current sidebar. 2084 */ 2085 getWidgetFormControls: function() { 2086 var formControls = []; 2087 2088 _( this.setting() ).each( function( widgetId ) { 2089 var settingId = widgetIdToSettingId( widgetId ), 2090 formControl = api.control( settingId ); 2091 if ( formControl ) { 2092 formControls.push( formControl ); 2093 } 2094 } ); 2095 2096 return formControls; 2097 }, 2098 2099 /** 2100 * Add a widget to the sidebar. 2101 * 2102 * @param {string} widgetId Widget ID, or an id_base for adding a previously non-existing widget. 2103 * @return {wp.customize.Widgets.WidgetControl|false} The widget_form control instance, or false on error. 2104 */ 2105 addWidget: function( widgetId ) { 2106 var self = this, controlHtml, $widget, controlType = 'widget_form', controlContainer, controlConstructor, 2107 parsedWidgetId = parseWidgetId( widgetId ), 2108 widgetNumber = parsedWidgetId.number, 2109 widgetIdBase = parsedWidgetId.id_base, 2110 widget = api.Widgets.availableWidgets.findWhere( {id_base: widgetIdBase} ), 2111 settingId, isExistingWidget, widgetFormControl, sidebarWidgets, settingArgs, setting; 2112 2113 if ( ! widget ) { 2114 return false; 2115 } 2116 2117 if ( widgetNumber && ! widget.get( 'is_multi' ) ) { 2118 return false; 2119 } 2120 2121 // Set up new multi widget. 2122 if ( widget.get( 'is_multi' ) && ! widgetNumber ) { 2123 widget.set( 'multi_number', widget.get( 'multi_number' ) + 1 ); 2124 widgetNumber = widget.get( 'multi_number' ); 2125 } 2126 2127 controlHtml = $( '#widget-tpl-' + widget.get( 'id' ) ).html().trim(); 2128 if ( widget.get( 'is_multi' ) ) { 2129 controlHtml = controlHtml.replace( /<[^<>]+>/g, function( m ) { 2130 return m.replace( /__i__|%i%/g, widgetNumber ); 2131 } ); 2132 } else { 2133 widget.set( 'is_disabled', true ); // Prevent single widget from being added again now. 2134 } 2135 2136 $widget = $( controlHtml ); 2137 2138 controlContainer = $( '<li/>' ) 2139 .addClass( 'customize-control' ) 2140 .addClass( 'customize-control-' + controlType ) 2141 .append( $widget ); 2142 2143 // Remove icon which is visible inside the panel. 2144 controlContainer.find( '> .widget-icon' ).remove(); 2145 2146 if ( widget.get( 'is_multi' ) ) { 2147 controlContainer.find( 'input[name="widget_number"]' ).val( widgetNumber ); 2148 controlContainer.find( 'input[name="multi_number"]' ).val( widgetNumber ); 2149 } 2150 2151 widgetId = controlContainer.find( '[name="widget-id"]' ).val(); 2152 2153 controlContainer.hide(); // To be slid-down below. 2154 2155 settingId = 'widget_' + widget.get( 'id_base' ); 2156 if ( widget.get( 'is_multi' ) ) { 2157 settingId += '[' + widgetNumber + ']'; 2158 } 2159 controlContainer.attr( 'id', 'customize-control-' + settingId.replace( /\]/g, '' ).replace( /\[/g, '-' ) ); 2160 2161 // Only create setting if it doesn't already exist (if we're adding a pre-existing inactive widget). 2162 isExistingWidget = api.has( settingId ); 2163 if ( ! isExistingWidget ) { 2164 settingArgs = { 2165 transport: api.Widgets.data.selectiveRefreshableWidgets[ widget.get( 'id_base' ) ] ? 'postMessage' : 'refresh', 2166 previewer: this.setting.previewer 2167 }; 2168 setting = api.create( settingId, settingId, '', settingArgs ); 2169 setting.set( {} ); // Mark dirty, changing from '' to {}. 2170 } 2171 2172 controlConstructor = api.controlConstructor[controlType]; 2173 widgetFormControl = new controlConstructor( settingId, { 2174 settings: { 2175 'default': settingId 2176 }, 2177 content: controlContainer, 2178 sidebar_id: self.params.sidebar_id, 2179 widget_id: widgetId, 2180 widget_id_base: widget.get( 'id_base' ), 2181 type: controlType, 2182 is_new: ! isExistingWidget, 2183 width: widget.get( 'width' ), 2184 height: widget.get( 'height' ), 2185 is_wide: widget.get( 'is_wide' ) 2186 } ); 2187 api.control.add( widgetFormControl ); 2188 2189 // Make sure widget is removed from the other sidebars. 2190 api.each( function( otherSetting ) { 2191 if ( otherSetting.id === self.setting.id ) { 2192 return; 2193 } 2194 2195 if ( 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) ) { 2196 return; 2197 } 2198 2199 var otherSidebarWidgets = otherSetting().slice(), 2200 i = _.indexOf( otherSidebarWidgets, widgetId ); 2201 2202 if ( -1 !== i ) { 2203 otherSidebarWidgets.splice( i ); 2204 otherSetting( otherSidebarWidgets ); 2205 } 2206 } ); 2207 2208 // Add widget to this sidebar. 2209 sidebarWidgets = this.setting().slice(); 2210 if ( -1 === _.indexOf( sidebarWidgets, widgetId ) ) { 2211 sidebarWidgets.push( widgetId ); 2212 this.setting( sidebarWidgets ); 2213 } 2214 2215 controlContainer.slideDown( function() { 2216 if ( isExistingWidget ) { 2217 widgetFormControl.updateWidget( { 2218 instance: widgetFormControl.setting() 2219 } ); 2220 } 2221 } ); 2222 2223 return widgetFormControl; 2224 } 2225 } ); 2226 2227 // Register models for custom panel, section, and control types. 2228 $.extend( api.panelConstructor, { 2229 widgets: api.Widgets.WidgetsPanel 2230 }); 2231 $.extend( api.sectionConstructor, { 2232 sidebar: api.Widgets.SidebarSection 2233 }); 2234 $.extend( api.controlConstructor, { 2235 widget_form: api.Widgets.WidgetControl, 2236 sidebar_widgets: api.Widgets.SidebarControl 2237 }); 2238 2239 /** 2240 * Init Customizer for widgets. 2241 */ 2242 api.bind( 'ready', function() { 2243 // Set up the widgets panel. 2244 api.Widgets.availableWidgetsPanel = new api.Widgets.AvailableWidgetsPanelView({ 2245 collection: api.Widgets.availableWidgets 2246 }); 2247 2248 // Highlight widget control. 2249 api.previewer.bind( 'highlight-widget-control', api.Widgets.highlightWidgetFormControl ); 2250 2251 // Open and focus widget control. 2252 api.previewer.bind( 'focus-widget-control', api.Widgets.focusWidgetFormControl ); 2253 } ); 2254 2255 /** 2256 * Highlight a widget control. 2257 * 2258 * @param {string} widgetId The ID of the widget to highlight. 2259 */ 2260 api.Widgets.highlightWidgetFormControl = function( widgetId ) { 2261 var control = api.Widgets.getWidgetFormControlForWidget( widgetId ); 2262 2263 if ( control ) { 2264 control.highlightSectionAndControl(); 2265 } 2266 }, 2267 2268 /** 2269 * Focus a widget control. 2270 * 2271 * @param {string} widgetId The ID of the widget to focus. 2272 */ 2273 api.Widgets.focusWidgetFormControl = function( widgetId ) { 2274 var control = api.Widgets.getWidgetFormControlForWidget( widgetId ); 2275 2276 if ( control ) { 2277 control.focus(); 2278 } 2279 }, 2280 2281 /** 2282 * Given a widget control, find the sidebar widgets control that contains it. 2283 * @param {string} widgetId The ID of the widget to find the sidebar widgets control for. 2284 * @return {Object|null} Sidebar widgets control that contains the widget, or null if not found. 2285 */ 2286 api.Widgets.getSidebarWidgetControlContainingWidget = function( widgetId ) { 2287 var foundControl = null; 2288 2289 // @todo This can use widgetIdToSettingId(), then pass into wp.customize.control( x ).getSidebarWidgetsControl(). 2290 api.control.each( function( control ) { 2291 if ( control.params.type === 'sidebar_widgets' && -1 !== _.indexOf( control.setting(), widgetId ) ) { 2292 foundControl = control; 2293 } 2294 } ); 2295 2296 return foundControl; 2297 }; 2298 2299 /** 2300 * Given a widget ID for a widget appearing in the preview, get the widget form control associated with it. 2301 * 2302 * @param {string} widgetId The ID of the widget to find the form control for. 2303 * @return {Object|null} Widget form control associated with the widget, or null if not found. 2304 */ 2305 api.Widgets.getWidgetFormControlForWidget = function( widgetId ) { 2306 var foundControl = null; 2307 2308 // @todo We can just use widgetIdToSettingId() here. 2309 api.control.each( function( control ) { 2310 if ( control.params.type === 'widget_form' && control.params.widget_id === widgetId ) { 2311 foundControl = control; 2312 } 2313 } ); 2314 2315 return foundControl; 2316 }; 2317 2318 /** 2319 * Initialize Edit Menu button in Nav Menu widget. 2320 */ 2321 $( document ).on( 'widget-added', function( event, widgetContainer ) { 2322 var parsedWidgetId, widgetControl, navMenuSelect, editMenuButton; 2323 parsedWidgetId = parseWidgetId( widgetContainer.find( '> .widget-inside > .form > .widget-id' ).val() ); 2324 if ( 'nav_menu' !== parsedWidgetId.id_base ) { 2325 return; 2326 } 2327 widgetControl = api.control( 'widget_nav_menu[' + String( parsedWidgetId.number ) + ']' ); 2328 if ( ! widgetControl ) { 2329 return; 2330 } 2331 navMenuSelect = widgetContainer.find( 'select[name*="nav_menu"]' ); 2332 editMenuButton = widgetContainer.find( '.edit-selected-nav-menu > button' ); 2333 if ( 0 === navMenuSelect.length || 0 === editMenuButton.length ) { 2334 return; 2335 } 2336 navMenuSelect.on( 'change', function() { 2337 if ( api.section.has( 'nav_menu[' + navMenuSelect.val() + ']' ) ) { 2338 editMenuButton.parent().show(); 2339 } else { 2340 editMenuButton.parent().hide(); 2341 } 2342 }); 2343 editMenuButton.on( 'click', function() { 2344 var section = api.section( 'nav_menu[' + navMenuSelect.val() + ']' ); 2345 if ( section ) { 2346 focusConstructWithBreadcrumb( section, widgetControl ); 2347 } 2348 } ); 2349 } ); 2350 2351 /** 2352 * Focus (expand) one construct and then focus on another construct after the first is collapsed. 2353 * 2354 * This overrides the back button to serve the purpose of breadcrumb navigation. 2355 * 2356 * @param {wp.customize.Section|wp.customize.Panel|wp.customize.Control} focusConstruct The object to initially focus. 2357 * @param {wp.customize.Section|wp.customize.Panel|wp.customize.Control} returnConstruct The object to return focus. 2358 */ 2359 function focusConstructWithBreadcrumb( focusConstruct, returnConstruct ) { 2360 focusConstruct.focus(); 2361 /** 2362 * Determines whether to return focus to the returnConstruct after the focusConstruct is collapsed. 2363 * 2364 * @param {boolean} isExpanded Whether the focusConstruct is expanded. 2365 */ 2366 function onceCollapsed( isExpanded ) { 2367 if ( ! isExpanded ) { 2368 focusConstruct.expanded.unbind( onceCollapsed ); 2369 returnConstruct.focus(); 2370 } 2371 } 2372 focusConstruct.expanded.bind( onceCollapsed ); 2373 } 2374 2375 /** 2376 * Parses a widget ID into its id_base and number components. 2377 * 2378 * @param {string} widgetId The widget ID to parse. 2379 * @return {Object} Parsed widget ID with id_base and number properties. 2380 */ 2381 function parseWidgetId( widgetId ) { 2382 var matches, parsed = { 2383 number: null, 2384 id_base: null 2385 }; 2386 2387 matches = widgetId.match( /^(.+)-(\d+)$/ ); 2388 if ( matches ) { 2389 parsed.id_base = matches[1]; 2390 parsed.number = parseInt( matches[2], 10 ); 2391 } else { 2392 // Likely an old single widget. 2393 parsed.id_base = widgetId; 2394 } 2395 2396 return parsed; 2397 } 2398 2399 /** 2400 * Returns the setting ID for a given widget ID. 2401 * 2402 * @param {string} widgetId The widget ID. 2403 * @return {string} The setting ID for the widget. 2404 */ 2405 function widgetIdToSettingId( widgetId ) { 2406 var parsed = parseWidgetId( widgetId ), settingId; 2407 2408 settingId = 'widget_' + parsed.id_base; 2409 if ( parsed.number ) { 2410 settingId += '[' + parsed.number + ']'; 2411 } 2412 2413 return settingId; 2414 } 2415 2416 })( window.wp, jQuery );
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Sun Sep 13 08:20:28 2026 | Cross-referenced by PHPXref |