PageRenderTime 57ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 1ms

/src/js/_enqueues/wp/customize/widgets.js

https://gitlab.com/morganestes/wordpress-develop
JavaScript | 1687 lines | 1020 code | 260 blank | 407 comment | 190 complexity | 0691e7f87ebfb1db9b9d2c5765dd7575 MD5 | raw file
  1. /* global _wpCustomizeWidgetsSettings */
  2. (function( wp, $ ){
  3. if ( ! wp || ! wp.customize ) { return; }
  4. // Set up our namespace...
  5. var api = wp.customize,
  6. l10n;
  7. /**
  8. * @namespace wp.customize.Widgets
  9. */
  10. api.Widgets = api.Widgets || {};
  11. api.Widgets.savedWidgetIds = {};
  12. // Link settings
  13. api.Widgets.data = _wpCustomizeWidgetsSettings || {};
  14. l10n = api.Widgets.data.l10n;
  15. /**
  16. * wp.customize.Widgets.WidgetModel
  17. *
  18. * A single widget model.
  19. *
  20. * @class wp.customize.Widgets.WidgetModel
  21. * @augments Backbone.Model
  22. */
  23. api.Widgets.WidgetModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.WidgetModel.prototype */{
  24. id: null,
  25. temp_id: null,
  26. classname: null,
  27. control_tpl: null,
  28. description: null,
  29. is_disabled: null,
  30. is_multi: null,
  31. multi_number: null,
  32. name: null,
  33. id_base: null,
  34. transport: null,
  35. params: [],
  36. width: null,
  37. height: null,
  38. search_matched: true
  39. });
  40. /**
  41. * wp.customize.Widgets.WidgetCollection
  42. *
  43. * Collection for widget models.
  44. *
  45. * @class wp.customize.Widgets.WidgetCollection
  46. * @augments Backbone.Collection
  47. */
  48. api.Widgets.WidgetCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.WidgetCollection.prototype */{
  49. model: api.Widgets.WidgetModel,
  50. // Controls searching on the current widget collection
  51. // and triggers an update event
  52. doSearch: function( value ) {
  53. // Don't do anything if we've already done this search
  54. // Useful because the search handler fires multiple times per keystroke
  55. if ( this.terms === value ) {
  56. return;
  57. }
  58. // Updates terms with the value passed
  59. this.terms = value;
  60. // If we have terms, run a search...
  61. if ( this.terms.length > 0 ) {
  62. this.search( this.terms );
  63. }
  64. // If search is blank, set all the widgets as they matched the search to reset the views.
  65. if ( this.terms === '' ) {
  66. this.each( function ( widget ) {
  67. widget.set( 'search_matched', true );
  68. } );
  69. }
  70. },
  71. // Performs a search within the collection
  72. // @uses RegExp
  73. search: function( term ) {
  74. var match, haystack;
  75. // Escape the term string for RegExp meta characters
  76. term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
  77. // Consider spaces as word delimiters and match the whole string
  78. // so matching terms can be combined
  79. term = term.replace( / /g, ')(?=.*' );
  80. match = new RegExp( '^(?=.*' + term + ').+', 'i' );
  81. this.each( function ( data ) {
  82. haystack = [ data.get( 'name' ), data.get( 'id' ), data.get( 'description' ) ].join( ' ' );
  83. data.set( 'search_matched', match.test( haystack ) );
  84. } );
  85. }
  86. });
  87. api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
  88. /**
  89. * wp.customize.Widgets.SidebarModel
  90. *
  91. * A single sidebar model.
  92. *
  93. * @class wp.customize.Widgets.SidebarModel
  94. * @augments Backbone.Model
  95. */
  96. api.Widgets.SidebarModel = Backbone.Model.extend(/** @lends wp.customize.Widgets.SidebarModel.prototype */{
  97. after_title: null,
  98. after_widget: null,
  99. before_title: null,
  100. before_widget: null,
  101. 'class': null,
  102. description: null,
  103. id: null,
  104. name: null,
  105. is_rendered: false
  106. });
  107. /**
  108. * wp.customize.Widgets.SidebarCollection
  109. *
  110. * Collection for sidebar models.
  111. *
  112. * @class wp.customize.Widgets.SidebarCollection
  113. * @augments Backbone.Collection
  114. */
  115. api.Widgets.SidebarCollection = Backbone.Collection.extend(/** @lends wp.customize.Widgets.SidebarCollection.prototype */{
  116. model: api.Widgets.SidebarModel
  117. });
  118. api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
  119. api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend(/** @lends wp.customize.Widgets.AvailableWidgetsPanelView.prototype */{
  120. el: '#available-widgets',
  121. events: {
  122. 'input #widgets-search': 'search',
  123. 'keyup #widgets-search': 'search',
  124. 'focus .widget-tpl' : 'focus',
  125. 'click .widget-tpl' : '_submit',
  126. 'keypress .widget-tpl' : '_submit',
  127. 'keydown' : 'keyboardAccessible'
  128. },
  129. // Cache current selected widget
  130. selected: null,
  131. // Cache sidebar control which has opened panel
  132. currentSidebarControl: null,
  133. $search: null,
  134. $clearResults: null,
  135. searchMatchesCount: null,
  136. /**
  137. * View class for the available widgets panel.
  138. *
  139. * @constructs wp.customize.Widgets.AvailableWidgetsPanelView
  140. * @augments wp.Backbone.View
  141. */
  142. initialize: function() {
  143. var self = this;
  144. this.$search = $( '#widgets-search' );
  145. this.$clearResults = this.$el.find( '.clear-results' );
  146. _.bindAll( this, 'close' );
  147. this.listenTo( this.collection, 'change', this.updateList );
  148. this.updateList();
  149. // Set the initial search count to the number of available widgets.
  150. this.searchMatchesCount = this.collection.length;
  151. // If the available widgets panel is open and the customize controls are
  152. // interacted with (i.e. available widgets panel is blurred) then close the
  153. // available widgets panel. Also close on back button click.
  154. $( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) {
  155. var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
  156. if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
  157. self.close();
  158. }
  159. } );
  160. // Clear the search results and trigger a `keyup` event to fire a new search.
  161. this.$clearResults.on( 'click', function() {
  162. self.$search.val( '' ).focus().trigger( 'keyup' );
  163. } );
  164. // Close the panel if the URL in the preview changes
  165. api.previewer.bind( 'url', this.close );
  166. },
  167. /**
  168. * Performs a search and handles selected widget.
  169. */
  170. search: function( event ) {
  171. var firstVisible;
  172. this.collection.doSearch( event.target.value );
  173. // Update the search matches count.
  174. this.updateSearchMatchesCount();
  175. // Announce how many search results.
  176. this.announceSearchMatches();
  177. // Remove a widget from being selected if it is no longer visible
  178. if ( this.selected && ! this.selected.is( ':visible' ) ) {
  179. this.selected.removeClass( 'selected' );
  180. this.selected = null;
  181. }
  182. // If a widget was selected but the filter value has been cleared out, clear selection
  183. if ( this.selected && ! event.target.value ) {
  184. this.selected.removeClass( 'selected' );
  185. this.selected = null;
  186. }
  187. // If a filter has been entered and a widget hasn't been selected, select the first one shown
  188. if ( ! this.selected && event.target.value ) {
  189. firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
  190. if ( firstVisible.length ) {
  191. this.select( firstVisible );
  192. }
  193. }
  194. // Toggle the clear search results button.
  195. if ( '' !== event.target.value ) {
  196. this.$clearResults.addClass( 'is-visible' );
  197. } else if ( '' === event.target.value ) {
  198. this.$clearResults.removeClass( 'is-visible' );
  199. }
  200. // Set a CSS class on the search container when there are no search results.
  201. if ( ! this.searchMatchesCount ) {
  202. this.$el.addClass( 'no-widgets-found' );
  203. } else {
  204. this.$el.removeClass( 'no-widgets-found' );
  205. }
  206. },
  207. /**
  208. * Updates the count of the available widgets that have the `search_matched` attribute.
  209. */
  210. updateSearchMatchesCount: function() {
  211. this.searchMatchesCount = this.collection.where({ search_matched: true }).length;
  212. },
  213. /**
  214. * Sends a message to the aria-live region to announce how many search results.
  215. */
  216. announceSearchMatches: _.debounce( function() {
  217. var message = l10n.widgetsFound.replace( '%d', this.searchMatchesCount ) ;
  218. if ( ! this.searchMatchesCount ) {
  219. message = l10n.noWidgetsFound;
  220. }
  221. wp.a11y.speak( message );
  222. }, 500 ),
  223. /**
  224. * Changes visibility of available widgets.
  225. */
  226. updateList: function() {
  227. this.collection.each( function( widget ) {
  228. var widgetTpl = $( '#widget-tpl-' + widget.id );
  229. widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
  230. if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
  231. this.selected = null;
  232. }
  233. } );
  234. },
  235. /**
  236. * Highlights a widget.
  237. */
  238. select: function( widgetTpl ) {
  239. this.selected = $( widgetTpl );
  240. this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
  241. this.selected.addClass( 'selected' );
  242. },
  243. /**
  244. * Highlights a widget on focus.
  245. */
  246. focus: function( event ) {
  247. this.select( $( event.currentTarget ) );
  248. },
  249. /**
  250. * Handles submit for keypress and click on widget.
  251. */
  252. _submit: function( event ) {
  253. // Only proceed with keypress if it is Enter or Spacebar
  254. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  255. return;
  256. }
  257. this.submit( $( event.currentTarget ) );
  258. },
  259. /**
  260. * Adds a selected widget to the sidebar.
  261. */
  262. submit: function( widgetTpl ) {
  263. var widgetId, widget, widgetFormControl;
  264. if ( ! widgetTpl ) {
  265. widgetTpl = this.selected;
  266. }
  267. if ( ! widgetTpl || ! this.currentSidebarControl ) {
  268. return;
  269. }
  270. this.select( widgetTpl );
  271. widgetId = $( this.selected ).data( 'widget-id' );
  272. widget = this.collection.findWhere( { id: widgetId } );
  273. if ( ! widget ) {
  274. return;
  275. }
  276. widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
  277. if ( widgetFormControl ) {
  278. widgetFormControl.focus();
  279. }
  280. this.close();
  281. },
  282. /**
  283. * Opens the panel.
  284. */
  285. open: function( sidebarControl ) {
  286. this.currentSidebarControl = sidebarControl;
  287. // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens
  288. _( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
  289. if ( control.params.is_wide ) {
  290. control.collapseForm();
  291. }
  292. } );
  293. if ( api.section.has( 'publish_settings' ) ) {
  294. api.section( 'publish_settings' ).collapse();
  295. }
  296. $( 'body' ).addClass( 'adding-widget' );
  297. this.$el.find( '.selected' ).removeClass( 'selected' );
  298. // Reset search
  299. this.collection.doSearch( '' );
  300. if ( ! api.settings.browser.mobile ) {
  301. this.$search.focus();
  302. }
  303. },
  304. /**
  305. * Closes the panel.
  306. */
  307. close: function( options ) {
  308. options = options || {};
  309. if ( options.returnFocus && this.currentSidebarControl ) {
  310. this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
  311. }
  312. this.currentSidebarControl = null;
  313. this.selected = null;
  314. $( 'body' ).removeClass( 'adding-widget' );
  315. this.$search.val( '' );
  316. },
  317. /**
  318. * Adds keyboard accessiblity to the panel.
  319. */
  320. keyboardAccessible: function( event ) {
  321. var isEnter = ( event.which === 13 ),
  322. isEsc = ( event.which === 27 ),
  323. isDown = ( event.which === 40 ),
  324. isUp = ( event.which === 38 ),
  325. isTab = ( event.which === 9 ),
  326. isShift = ( event.shiftKey ),
  327. selected = null,
  328. firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
  329. lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
  330. isSearchFocused = $( event.target ).is( this.$search ),
  331. isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
  332. if ( isDown || isUp ) {
  333. if ( isDown ) {
  334. if ( isSearchFocused ) {
  335. selected = firstVisible;
  336. } else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
  337. selected = this.selected.nextAll( '.widget-tpl:visible:first' );
  338. }
  339. } else if ( isUp ) {
  340. if ( isSearchFocused ) {
  341. selected = lastVisible;
  342. } else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
  343. selected = this.selected.prevAll( '.widget-tpl:visible:first' );
  344. }
  345. }
  346. this.select( selected );
  347. if ( selected ) {
  348. selected.focus();
  349. } else {
  350. this.$search.focus();
  351. }
  352. return;
  353. }
  354. // If enter pressed but nothing entered, don't do anything
  355. if ( isEnter && ! this.$search.val() ) {
  356. return;
  357. }
  358. if ( isEnter ) {
  359. this.submit();
  360. } else if ( isEsc ) {
  361. this.close( { returnFocus: true } );
  362. }
  363. if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
  364. this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
  365. event.preventDefault();
  366. }
  367. }
  368. });
  369. /**
  370. * Handlers for the widget-synced event, organized by widget ID base.
  371. * Other widgets may provide their own update handlers by adding
  372. * listeners for the widget-synced event.
  373. *
  374. * @alias wp.customize.Widgets.formSyncHandlers
  375. */
  376. api.Widgets.formSyncHandlers = {
  377. /**
  378. * @param {jQuery.Event} e
  379. * @param {jQuery} widget
  380. * @param {String} newForm
  381. */
  382. rss: function( e, widget, newForm ) {
  383. var oldWidgetError = widget.find( '.widget-error:first' ),
  384. newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
  385. if ( oldWidgetError.length && newWidgetError.length ) {
  386. oldWidgetError.replaceWith( newWidgetError );
  387. } else if ( oldWidgetError.length ) {
  388. oldWidgetError.remove();
  389. } else if ( newWidgetError.length ) {
  390. widget.find( '.widget-content:first' ).prepend( newWidgetError );
  391. }
  392. }
  393. };
  394. api.Widgets.WidgetControl = api.Control.extend(/** @lends wp.customize.Widgets.WidgetControl.prototype */{
  395. defaultExpandedArguments: {
  396. duration: 'fast',
  397. completeCallback: $.noop
  398. },
  399. /**
  400. * wp.customize.Widgets.WidgetControl
  401. *
  402. * Customizer control for widgets.
  403. * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
  404. *
  405. * @since 4.1.0
  406. *
  407. * @constructs wp.customize.Widgets.WidgetControl
  408. * @augments wp.customize.Control
  409. */
  410. initialize: function( id, options ) {
  411. var control = this;
  412. control.widgetControlEmbedded = false;
  413. control.widgetContentEmbedded = false;
  414. control.expanded = new api.Value( false );
  415. control.expandedArgumentsQueue = [];
  416. control.expanded.bind( function( expanded ) {
  417. var args = control.expandedArgumentsQueue.shift();
  418. args = $.extend( {}, control.defaultExpandedArguments, args );
  419. control.onChangeExpanded( expanded, args );
  420. });
  421. control.altNotice = true;
  422. api.Control.prototype.initialize.call( control, id, options );
  423. },
  424. /**
  425. * Set up the control.
  426. *
  427. * @since 3.9.0
  428. */
  429. ready: function() {
  430. var control = this;
  431. /*
  432. * Embed a placeholder once the section is expanded. The full widget
  433. * form content will be embedded once the control itself is expanded,
  434. * and at this point the widget-added event will be triggered.
  435. */
  436. if ( ! control.section() ) {
  437. control.embedWidgetControl();
  438. } else {
  439. api.section( control.section(), function( section ) {
  440. var onExpanded = function( isExpanded ) {
  441. if ( isExpanded ) {
  442. control.embedWidgetControl();
  443. section.expanded.unbind( onExpanded );
  444. }
  445. };
  446. if ( section.expanded() ) {
  447. onExpanded( true );
  448. } else {
  449. section.expanded.bind( onExpanded );
  450. }
  451. } );
  452. }
  453. },
  454. /**
  455. * Embed the .widget element inside the li container.
  456. *
  457. * @since 4.4.0
  458. */
  459. embedWidgetControl: function() {
  460. var control = this, widgetControl;
  461. if ( control.widgetControlEmbedded ) {
  462. return;
  463. }
  464. control.widgetControlEmbedded = true;
  465. widgetControl = $( control.params.widget_control );
  466. control.container.append( widgetControl );
  467. control._setupModel();
  468. control._setupWideWidget();
  469. control._setupControlToggle();
  470. control._setupWidgetTitle();
  471. control._setupReorderUI();
  472. control._setupHighlightEffects();
  473. control._setupUpdateUI();
  474. control._setupRemoveUI();
  475. },
  476. /**
  477. * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event.
  478. *
  479. * @since 4.4.0
  480. */
  481. embedWidgetContent: function() {
  482. var control = this, widgetContent;
  483. control.embedWidgetControl();
  484. if ( control.widgetContentEmbedded ) {
  485. return;
  486. }
  487. control.widgetContentEmbedded = true;
  488. // Update the notification container element now that the widget content has been embedded.
  489. control.notifications.container = control.getNotificationsContainerElement();
  490. control.notifications.render();
  491. widgetContent = $( control.params.widget_content );
  492. control.container.find( '.widget-content:first' ).append( widgetContent );
  493. /*
  494. * Trigger widget-added event so that plugins can attach any event
  495. * listeners and dynamic UI elements.
  496. */
  497. $( document ).trigger( 'widget-added', [ control.container.find( '.widget:first' ) ] );
  498. },
  499. /**
  500. * Handle changes to the setting
  501. */
  502. _setupModel: function() {
  503. var self = this, rememberSavedWidgetId;
  504. // Remember saved widgets so we know which to trash (move to inactive widgets sidebar)
  505. rememberSavedWidgetId = function() {
  506. api.Widgets.savedWidgetIds[self.params.widget_id] = true;
  507. };
  508. api.bind( 'ready', rememberSavedWidgetId );
  509. api.bind( 'saved', rememberSavedWidgetId );
  510. this._updateCount = 0;
  511. this.isWidgetUpdating = false;
  512. this.liveUpdateMode = true;
  513. // Update widget whenever model changes
  514. this.setting.bind( function( to, from ) {
  515. if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) {
  516. self.updateWidget( { instance: to } );
  517. }
  518. } );
  519. },
  520. /**
  521. * Add special behaviors for wide widget controls
  522. */
  523. _setupWideWidget: function() {
  524. var self = this, $widgetInside, $widgetForm, $customizeSidebar,
  525. $themeControlsContainer, positionWidget;
  526. if ( ! this.params.is_wide || $( window ).width() <= 640 /* max-width breakpoint in customize-controls.css */ ) {
  527. return;
  528. }
  529. $widgetInside = this.container.find( '.widget-inside' );
  530. $widgetForm = $widgetInside.find( '> .form' );
  531. $customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' );
  532. this.container.addClass( 'wide-widget-control' );
  533. this.container.find( '.form:first' ).css( {
  534. 'max-width': this.params.width,
  535. 'min-height': this.params.height
  536. } );
  537. /**
  538. * Keep the widget-inside positioned so the top of fixed-positioned
  539. * element is at the same top position as the widget-top. When the
  540. * widget-top is scrolled out of view, keep the widget-top in view;
  541. * likewise, don't allow the widget to drop off the bottom of the window.
  542. * If a widget is too tall to fit in the window, don't let the height
  543. * exceed the window height so that the contents of the widget control
  544. * will become scrollable (overflow:auto).
  545. */
  546. positionWidget = function() {
  547. var offsetTop = self.container.offset().top,
  548. windowHeight = $( window ).height(),
  549. formHeight = $widgetForm.outerHeight(),
  550. top;
  551. $widgetInside.css( 'max-height', windowHeight );
  552. top = Math.max(
  553. 0, // prevent top from going off screen
  554. Math.min(
  555. Math.max( offsetTop, 0 ), // distance widget in panel is from top of screen
  556. windowHeight - formHeight // flush up against bottom of screen
  557. )
  558. );
  559. $widgetInside.css( 'top', top );
  560. };
  561. $themeControlsContainer = $( '#customize-theme-controls' );
  562. this.container.on( 'expand', function() {
  563. positionWidget();
  564. $customizeSidebar.on( 'scroll', positionWidget );
  565. $( window ).on( 'resize', positionWidget );
  566. $themeControlsContainer.on( 'expanded collapsed', positionWidget );
  567. } );
  568. this.container.on( 'collapsed', function() {
  569. $customizeSidebar.off( 'scroll', positionWidget );
  570. $( window ).off( 'resize', positionWidget );
  571. $themeControlsContainer.off( 'expanded collapsed', positionWidget );
  572. } );
  573. // Reposition whenever a sidebar's widgets are changed
  574. api.each( function( setting ) {
  575. if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) {
  576. setting.bind( function() {
  577. if ( self.container.hasClass( 'expanded' ) ) {
  578. positionWidget();
  579. }
  580. } );
  581. }
  582. } );
  583. },
  584. /**
  585. * Show/hide the control when clicking on the form title, when clicking
  586. * the close button
  587. */
  588. _setupControlToggle: function() {
  589. var self = this, $closeBtn;
  590. this.container.find( '.widget-top' ).on( 'click', function( e ) {
  591. e.preventDefault();
  592. var sidebarWidgetsControl = self.getSidebarWidgetsControl();
  593. if ( sidebarWidgetsControl.isReordering ) {
  594. return;
  595. }
  596. self.expanded( ! self.expanded() );
  597. } );
  598. $closeBtn = this.container.find( '.widget-control-close' );
  599. $closeBtn.on( 'click', function( e ) {
  600. e.preventDefault();
  601. self.collapse();
  602. self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility
  603. } );
  604. },
  605. /**
  606. * Update the title of the form if a title field is entered
  607. */
  608. _setupWidgetTitle: function() {
  609. var self = this, updateTitle;
  610. updateTitle = function() {
  611. var title = self.setting().title,
  612. inWidgetTitle = self.container.find( '.in-widget-title' );
  613. if ( title ) {
  614. inWidgetTitle.text( ': ' + title );
  615. } else {
  616. inWidgetTitle.text( '' );
  617. }
  618. };
  619. this.setting.bind( updateTitle );
  620. updateTitle();
  621. },
  622. /**
  623. * Set up the widget-reorder-nav
  624. */
  625. _setupReorderUI: function() {
  626. var self = this, selectSidebarItem, $moveWidgetArea,
  627. $reorderNav, updateAvailableSidebars, template;
  628. /**
  629. * select the provided sidebar list item in the move widget area
  630. *
  631. * @param {jQuery} li
  632. */
  633. selectSidebarItem = function( li ) {
  634. li.siblings( '.selected' ).removeClass( 'selected' );
  635. li.addClass( 'selected' );
  636. var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id );
  637. self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar );
  638. };
  639. /**
  640. * Add the widget reordering elements to the widget control
  641. */
  642. this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) );
  643. template = _.template( api.Widgets.data.tpl.moveWidgetArea );
  644. $moveWidgetArea = $( template( {
  645. sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' )
  646. } )
  647. );
  648. this.container.find( '.widget-top' ).after( $moveWidgetArea );
  649. /**
  650. * Update available sidebars when their rendered state changes
  651. */
  652. updateAvailableSidebars = function() {
  653. var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem,
  654. renderedSidebarCount = 0;
  655. selfSidebarItem = $sidebarItems.filter( function(){
  656. return $( this ).data( 'id' ) === self.params.sidebar_id;
  657. } );
  658. $sidebarItems.each( function() {
  659. var li = $( this ),
  660. sidebarId, sidebar, sidebarIsRendered;
  661. sidebarId = li.data( 'id' );
  662. sidebar = api.Widgets.registeredSidebars.get( sidebarId );
  663. sidebarIsRendered = sidebar.get( 'is_rendered' );
  664. li.toggle( sidebarIsRendered );
  665. if ( sidebarIsRendered ) {
  666. renderedSidebarCount += 1;
  667. }
  668. if ( li.hasClass( 'selected' ) && ! sidebarIsRendered ) {
  669. selectSidebarItem( selfSidebarItem );
  670. }
  671. } );
  672. if ( renderedSidebarCount > 1 ) {
  673. self.container.find( '.move-widget' ).show();
  674. } else {
  675. self.container.find( '.move-widget' ).hide();
  676. }
  677. };
  678. updateAvailableSidebars();
  679. api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars );
  680. /**
  681. * Handle clicks for up/down/move on the reorder nav
  682. */
  683. $reorderNav = this.container.find( '.widget-reorder-nav' );
  684. $reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() {
  685. $( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' );
  686. } ).on( 'click keypress', function( event ) {
  687. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  688. return;
  689. }
  690. $( this ).focus();
  691. if ( $( this ).is( '.move-widget' ) ) {
  692. self.toggleWidgetMoveArea();
  693. } else {
  694. var isMoveDown = $( this ).is( '.move-widget-down' ),
  695. isMoveUp = $( this ).is( '.move-widget-up' ),
  696. i = self.getWidgetSidebarPosition();
  697. if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) {
  698. return;
  699. }
  700. if ( isMoveUp ) {
  701. self.moveUp();
  702. wp.a11y.speak( l10n.widgetMovedUp );
  703. } else {
  704. self.moveDown();
  705. wp.a11y.speak( l10n.widgetMovedDown );
  706. }
  707. $( this ).focus(); // re-focus after the container was moved
  708. }
  709. } );
  710. /**
  711. * Handle selecting a sidebar to move to
  712. */
  713. this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( event ) {
  714. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  715. return;
  716. }
  717. event.preventDefault();
  718. selectSidebarItem( $( this ) );
  719. } );
  720. /**
  721. * Move widget to another sidebar
  722. */
  723. this.container.find( '.move-widget-btn' ).click( function() {
  724. self.getSidebarWidgetsControl().toggleReordering( false );
  725. var oldSidebarId = self.params.sidebar_id,
  726. newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ),
  727. oldSidebarWidgetsSetting, newSidebarWidgetsSetting,
  728. oldSidebarWidgetIds, newSidebarWidgetIds, i;
  729. oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' );
  730. newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' );
  731. oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() );
  732. newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() );
  733. i = self.getWidgetSidebarPosition();
  734. oldSidebarWidgetIds.splice( i, 1 );
  735. newSidebarWidgetIds.push( self.params.widget_id );
  736. oldSidebarWidgetsSetting( oldSidebarWidgetIds );
  737. newSidebarWidgetsSetting( newSidebarWidgetIds );
  738. self.focus();
  739. } );
  740. },
  741. /**
  742. * Highlight widgets in preview when interacted with in the Customizer
  743. */
  744. _setupHighlightEffects: function() {
  745. var self = this;
  746. // Highlight whenever hovering or clicking over the form
  747. this.container.on( 'mouseenter click', function() {
  748. self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
  749. } );
  750. // Highlight when the setting is updated
  751. this.setting.bind( function() {
  752. self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
  753. } );
  754. },
  755. /**
  756. * Set up event handlers for widget updating
  757. */
  758. _setupUpdateUI: function() {
  759. var self = this, $widgetRoot, $widgetContent,
  760. $saveBtn, updateWidgetDebounced, formSyncHandler;
  761. $widgetRoot = this.container.find( '.widget:first' );
  762. $widgetContent = $widgetRoot.find( '.widget-content:first' );
  763. // Configure update button
  764. $saveBtn = this.container.find( '.widget-control-save' );
  765. $saveBtn.val( l10n.saveBtnLabel );
  766. $saveBtn.attr( 'title', l10n.saveBtnTooltip );
  767. $saveBtn.removeClass( 'button-primary' );
  768. $saveBtn.on( 'click', function( e ) {
  769. e.preventDefault();
  770. self.updateWidget( { disable_form: true } ); // @todo disable_form is unused?
  771. } );
  772. updateWidgetDebounced = _.debounce( function() {
  773. self.updateWidget();
  774. }, 250 );
  775. // Trigger widget form update when hitting Enter within an input
  776. $widgetContent.on( 'keydown', 'input', function( e ) {
  777. if ( 13 === e.which ) { // Enter
  778. e.preventDefault();
  779. self.updateWidget( { ignoreActiveElement: true } );
  780. }
  781. } );
  782. // Handle widgets that support live previews
  783. $widgetContent.on( 'change input propertychange', ':input', function( e ) {
  784. if ( ! self.liveUpdateMode ) {
  785. return;
  786. }
  787. if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) {
  788. updateWidgetDebounced();
  789. }
  790. } );
  791. // Remove loading indicators when the setting is saved and the preview updates
  792. this.setting.previewer.channel.bind( 'synced', function() {
  793. self.container.removeClass( 'previewer-loading' );
  794. } );
  795. api.previewer.bind( 'widget-updated', function( updatedWidgetId ) {
  796. if ( updatedWidgetId === self.params.widget_id ) {
  797. self.container.removeClass( 'previewer-loading' );
  798. }
  799. } );
  800. formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ];
  801. if ( formSyncHandler ) {
  802. $( document ).on( 'widget-synced', function( e, widget ) {
  803. if ( $widgetRoot.is( widget ) ) {
  804. formSyncHandler.apply( document, arguments );
  805. }
  806. } );
  807. }
  808. },
  809. /**
  810. * Update widget control to indicate whether it is currently rendered.
  811. *
  812. * Overrides api.Control.toggle()
  813. *
  814. * @since 4.1.0
  815. *
  816. * @param {Boolean} active
  817. * @param {Object} args
  818. * @param {function} args.completeCallback
  819. */
  820. onChangeActive: function ( active, args ) {
  821. // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments
  822. this.container.toggleClass( 'widget-rendered', active );
  823. if ( args.completeCallback ) {
  824. args.completeCallback();
  825. }
  826. },
  827. /**
  828. * Set up event handlers for widget removal
  829. */
  830. _setupRemoveUI: function() {
  831. var self = this, $removeBtn, replaceDeleteWithRemove;
  832. // Configure remove button
  833. $removeBtn = this.container.find( '.widget-control-remove' );
  834. $removeBtn.on( 'click', function( e ) {
  835. e.preventDefault();
  836. // Find an adjacent element to add focus to when this widget goes away
  837. var $adjacentFocusTarget;
  838. if ( self.container.next().is( '.customize-control-widget_form' ) ) {
  839. $adjacentFocusTarget = self.container.next().find( '.widget-action:first' );
  840. } else if ( self.container.prev().is( '.customize-control-widget_form' ) ) {
  841. $adjacentFocusTarget = self.container.prev().find( '.widget-action:first' );
  842. } else {
  843. $adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' );
  844. }
  845. self.container.slideUp( function() {
  846. var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ),
  847. sidebarWidgetIds, i;
  848. if ( ! sidebarsWidgetsControl ) {
  849. return;
  850. }
  851. sidebarWidgetIds = sidebarsWidgetsControl.setting().slice();
  852. i = _.indexOf( sidebarWidgetIds, self.params.widget_id );
  853. if ( -1 === i ) {
  854. return;
  855. }
  856. sidebarWidgetIds.splice( i, 1 );
  857. sidebarsWidgetsControl.setting( sidebarWidgetIds );
  858. $adjacentFocusTarget.focus(); // keyboard accessibility
  859. } );
  860. } );
  861. replaceDeleteWithRemove = function() {
  862. $removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the button as "Delete"
  863. $removeBtn.attr( 'title', l10n.removeBtnTooltip );
  864. };
  865. if ( this.params.is_new ) {
  866. api.bind( 'saved', replaceDeleteWithRemove );
  867. } else {
  868. replaceDeleteWithRemove();
  869. }
  870. },
  871. /**
  872. * Find all inputs in a widget container that should be considered when
  873. * comparing the loaded form with the sanitized form, whose fields will
  874. * be aligned to copy the sanitized over. The elements returned by this
  875. * are passed into this._getInputsSignature(), and they are iterated
  876. * over when copying sanitized values over to the form loaded.
  877. *
  878. * @param {jQuery} container element in which to look for inputs
  879. * @returns {jQuery} inputs
  880. * @private
  881. */
  882. _getInputs: function( container ) {
  883. return $( container ).find( ':input[name]' );
  884. },
  885. /**
  886. * Iterate over supplied inputs and create a signature string for all of them together.
  887. * This string can be used to compare whether or not the form has all of the same fields.
  888. *
  889. * @param {jQuery} inputs
  890. * @returns {string}
  891. * @private
  892. */
  893. _getInputsSignature: function( inputs ) {
  894. var inputsSignatures = _( inputs ).map( function( input ) {
  895. var $input = $( input ), signatureParts;
  896. if ( $input.is( ':checkbox, :radio' ) ) {
  897. signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ];
  898. } else {
  899. signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ];
  900. }
  901. return signatureParts.join( ',' );
  902. } );
  903. return inputsSignatures.join( ';' );
  904. },
  905. /**
  906. * Get the state for an input depending on its type.
  907. *
  908. * @param {jQuery|Element} input
  909. * @returns {string|boolean|array|*}
  910. * @private
  911. */
  912. _getInputState: function( input ) {
  913. input = $( input );
  914. if ( input.is( ':radio, :checkbox' ) ) {
  915. return input.prop( 'checked' );
  916. } else if ( input.is( 'select[multiple]' ) ) {
  917. return input.find( 'option:selected' ).map( function () {
  918. return $( this ).val();
  919. } ).get();
  920. } else {
  921. return input.val();
  922. }
  923. },
  924. /**
  925. * Update an input's state based on its type.
  926. *
  927. * @param {jQuery|Element} input
  928. * @param {string|boolean|array|*} state
  929. * @private
  930. */
  931. _setInputState: function ( input, state ) {
  932. input = $( input );
  933. if ( input.is( ':radio, :checkbox' ) ) {
  934. input.prop( 'checked', state );
  935. } else if ( input.is( 'select[multiple]' ) ) {
  936. if ( ! $.isArray( state ) ) {
  937. state = [];
  938. } else {
  939. // Make sure all state items are strings since the DOM value is a string
  940. state = _.map( state, function ( value ) {
  941. return String( value );
  942. } );
  943. }
  944. input.find( 'option' ).each( function () {
  945. $( this ).prop( 'selected', -1 !== _.indexOf( state, String( this.value ) ) );
  946. } );
  947. } else {
  948. input.val( state );
  949. }
  950. },
  951. /***********************************************************************
  952. * Begin public API methods
  953. **********************************************************************/
  954. /**
  955. * @return {wp.customize.controlConstructor.sidebar_widgets[]}
  956. */
  957. getSidebarWidgetsControl: function() {
  958. var settingId, sidebarWidgetsControl;
  959. settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']';
  960. sidebarWidgetsControl = api.control( settingId );
  961. if ( ! sidebarWidgetsControl ) {
  962. return;
  963. }
  964. return sidebarWidgetsControl;
  965. },
  966. /**
  967. * Submit the widget form via Ajax and get back the updated instance,
  968. * along with the new widget control form to render.
  969. *
  970. * @param {object} [args]
  971. * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used
  972. * @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.
  973. * @param {Boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element.
  974. */
  975. updateWidget: function( args ) {
  976. var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent,
  977. updateNumber, params, data, $inputs, processing, jqxhr, isChanged;
  978. // The updateWidget logic requires that the form fields to be fully present.
  979. self.embedWidgetContent();
  980. args = $.extend( {
  981. instance: null,
  982. complete: null,
  983. ignoreActiveElement: false
  984. }, args );
  985. instanceOverride = args.instance;
  986. completeCallback = args.complete;
  987. this._updateCount += 1;
  988. updateNumber = this._updateCount;
  989. $widgetRoot = this.container.find( '.widget:first' );
  990. $widgetContent = $widgetRoot.find( '.widget-content:first' );
  991. // Remove a previous error message
  992. $widgetContent.find( '.widget-error' ).remove();
  993. this.container.addClass( 'widget-form-loading' );
  994. this.container.addClass( 'previewer-loading' );
  995. processing = api.state( 'processing' );
  996. processing( processing() + 1 );
  997. if ( ! this.liveUpdateMode ) {
  998. this.container.addClass( 'widget-form-disabled' );
  999. }
  1000. params = {};
  1001. params.action = 'update-widget';
  1002. params.wp_customize = 'on';
  1003. params.nonce = api.settings.nonce['update-widget'];
  1004. params.customize_theme = api.settings.theme.stylesheet;
  1005. params.customized = wp.customize.previewer.query().customized;
  1006. data = $.param( params );
  1007. $inputs = this._getInputs( $widgetContent );
  1008. // Store the value we're submitting in data so that when the response comes back,
  1009. // we know if it got sanitized; if there is no difference in the sanitized value,
  1010. // then we do not need to touch the UI and mess up the user's ongoing editing.
  1011. $inputs.each( function() {
  1012. $( this ).data( 'state' + updateNumber, self._getInputState( this ) );
  1013. } );
  1014. if ( instanceOverride ) {
  1015. data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } );
  1016. } else {
  1017. data += '&' + $inputs.serialize();
  1018. }
  1019. data += '&' + $widgetContent.find( '~ :input' ).serialize();
  1020. if ( this._previousUpdateRequest ) {
  1021. this._previousUpdateRequest.abort();
  1022. }
  1023. jqxhr = $.post( wp.ajax.settings.url, data );
  1024. this._previousUpdateRequest = jqxhr;
  1025. jqxhr.done( function( r ) {
  1026. var message, sanitizedForm, $sanitizedInputs, hasSameInputsInResponse,
  1027. isLiveUpdateAborted = false;
  1028. // Check if the user is logged out.
  1029. if ( '0' === r ) {
  1030. api.previewer.preview.iframe.hide();
  1031. api.previewer.login().done( function() {
  1032. self.updateWidget( args );
  1033. api.previewer.preview.iframe.show();
  1034. } );
  1035. return;
  1036. }
  1037. // Check for cheaters.
  1038. if ( '-1' === r ) {
  1039. api.previewer.cheatin();
  1040. return;
  1041. }
  1042. if ( r.success ) {
  1043. sanitizedForm = $( '<div>' + r.data.form + '</div>' );
  1044. $sanitizedInputs = self._getInputs( sanitizedForm );
  1045. hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs );
  1046. // Restore live update mode if sanitized fields are now aligned with the existing fields
  1047. if ( hasSameInputsInResponse && ! self.liveUpdateMode ) {
  1048. self.liveUpdateMode = true;
  1049. self.container.removeClass( 'widget-form-disabled' );
  1050. self.container.find( 'input[name="savewidget"]' ).hide();
  1051. }
  1052. // Sync sanitized field states to existing fields if they are aligned
  1053. if ( hasSameInputsInResponse && self.liveUpdateMode ) {
  1054. $inputs.each( function( i ) {
  1055. var $input = $( this ),
  1056. $sanitizedInput = $( $sanitizedInputs[i] ),
  1057. submittedState, sanitizedState, canUpdateState;
  1058. submittedState = $input.data( 'state' + updateNumber );
  1059. sanitizedState = self._getInputState( $sanitizedInput );
  1060. $input.data( 'sanitized', sanitizedState );
  1061. canUpdateState = ( ! _.isEqual( submittedState, sanitizedState ) && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) ) );
  1062. if ( canUpdateState ) {
  1063. self._setInputState( $input, sanitizedState );
  1064. }
  1065. } );
  1066. $( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] );
  1067. // Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled
  1068. } else if ( self.liveUpdateMode ) {
  1069. self.liveUpdateMode = false;
  1070. self.container.find( 'input[name="savewidget"]' ).show();
  1071. isLiveUpdateAborted = true;
  1072. // Otherwise, replace existing form with the sanitized form
  1073. } else {
  1074. $widgetContent.html( r.data.form );
  1075. self.container.removeClass( 'widget-form-disabled' );
  1076. $( document ).trigger( 'widget-updated', [ $widgetRoot ] );
  1077. }
  1078. /**
  1079. * If the old instance is identical to the new one, there is nothing new
  1080. * needing to be rendered, and so we can preempt the event for the
  1081. * preview finishing loading.
  1082. */
  1083. isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance );
  1084. if ( isChanged ) {
  1085. self.isWidgetUpdating = true; // suppress triggering another updateWidget
  1086. self.setting( r.data.instance );
  1087. self.isWidgetUpdating = false;
  1088. } else {
  1089. // no change was made, so stop the spinner now instead of when the preview would updates
  1090. self.container.removeClass( 'previewer-loading' );
  1091. }
  1092. if ( completeCallback ) {
  1093. completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } );
  1094. }
  1095. } else {
  1096. // General error message
  1097. message = l10n.error;
  1098. if ( r.data && r.data.message ) {
  1099. message = r.data.message;
  1100. }
  1101. if ( completeCallback ) {
  1102. completeCallback.call( self, message );
  1103. } else {
  1104. $widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' );
  1105. }
  1106. }
  1107. } );
  1108. jqxhr.fail( function( jqXHR, textStatus ) {
  1109. if ( completeCallback ) {
  1110. completeCallback.call( self, textStatus );
  1111. }
  1112. } );
  1113. jqxhr.always( function() {
  1114. self.container.removeClass( 'widget-form-loading' );
  1115. $inputs.each( function() {
  1116. $( this ).removeData( 'state' + updateNumber );
  1117. } );
  1118. processing( processing() - 1 );
  1119. } );
  1120. },
  1121. /**
  1122. * Expand the accordion section containing a control
  1123. */
  1124. expandControlSection: function() {
  1125. api.Control.prototype.expand.call( this );
  1126. },
  1127. /**
  1128. * @since 4.1.0
  1129. *
  1130. * @param {Boolean} expanded
  1131. * @param {Object} [params]
  1132. * @returns {Boolean} false if state already applied
  1133. */
  1134. _toggleExpanded: api.Section.prototype._toggleExpanded,
  1135. /**
  1136. * @since 4.1.0
  1137. *
  1138. * @param {Object} [params]
  1139. * @returns {Boolean} false if already expanded
  1140. */
  1141. expand: api.Section.prototype.expand,
  1142. /**
  1143. * Expand the widget form control
  1144. *
  1145. * @deprecated 4.1.0 Use this.expand() instead.
  1146. */
  1147. expandForm: function() {
  1148. this.expand();
  1149. },
  1150. /**
  1151. * @since 4.1.0
  1152. *
  1153. * @param {Object} [params]
  1154. * @returns {Boolean} false if already collapsed
  1155. */
  1156. collapse: api.Section.prototype.collapse,
  1157. /**
  1158. * Collapse the widget form control
  1159. *
  1160. * @deprecated 4.1.0 Use this.collapse() instead.
  1161. */
  1162. collapseForm: function() {
  1163. this.collapse();
  1164. },
  1165. /**
  1166. * Expand or collapse the widget control
  1167. *
  1168. * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
  1169. *
  1170. * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility
  1171. */
  1172. toggleForm: function( showOrHide ) {
  1173. if ( typeof showOrHide === 'undefined' ) {
  1174. showOrHide = ! this.expanded();
  1175. }
  1176. this.expanded( showOrHide );
  1177. },
  1178. /**
  1179. * Respond to change in the expanded state.
  1180. *
  1181. * @param {Boolean} expanded
  1182. * @param {Object} args merged on top of this.defaultActiveArguments
  1183. */
  1184. onChangeExpanded: function ( expanded, args ) {
  1185. var self = this, $widget, $inside, complete, prevComplete, expandControl, $toggleBtn;
  1186. self.embedWidgetControl(); // Make sure the outer form is embedded so that the expanded state can be set in the UI.
  1187. if ( expanded ) {
  1188. self.embedWidgetContent();
  1189. }
  1190. // If the expanded state is unchanged only manipulate container expanded states
  1191. if ( args.unchanged ) {
  1192. if ( expanded ) {
  1193. api.Control.prototype.expand.call( self, {
  1194. completeCallback: args.completeCallback
  1195. });
  1196. }
  1197. return;
  1198. }
  1199. $widget = this.container.find( 'div.widget:first' );
  1200. $inside = $widget.find( '.widget-inside:first' );
  1201. $toggleBtn = this.container.find( '.widget-top button.widget-action' );
  1202. expandControl = function() {
  1203. // Close all other widget controls before expanding this one
  1204. api.control.each( function( otherControl ) {
  1205. if ( self.params.type === otherControl.params.type && self !== otherControl ) {
  1206. otherControl.collapse();
  1207. }
  1208. } );
  1209. complete = function() {
  1210. self.container.removeClass( 'expanding' );
  1211. self.container.addClass( 'expanded' );
  1212. $widget.addClass( 'open' );
  1213. $toggleBtn.attr( 'aria-expanded', 'true' );
  1214. self.container.trigger( 'expanded' );
  1215. };
  1216. if ( args.completeCallback ) {
  1217. prevComplete = complete;
  1218. complete = function () {
  1219. prevComplete();
  1220. args.completeCallback();
  1221. };
  1222. }
  1223. if ( self.params.is_wide ) {
  1224. $inside.fadeIn( args.duration, complete );
  1225. } else {
  1226. $inside.slideDown( args.duration, complete );
  1227. }
  1228. self.container.trigger( 'expand' );
  1229. self.container.addClass( 'expanding' );
  1230. };
  1231. if ( expanded ) {
  1232. if ( api.section.has( self.section() ) ) {
  1233. api.section( self.section() ).expand( {
  1234. completeCallback: expandControl
  1235. } );
  1236. } else {
  1237. expandControl();
  1238. }
  1239. } else {
  1240. complete = function() {
  1241. self.container.removeClass( 'collapsing' );
  1242. self.container.removeClass( 'expanded' );
  1243. $widget.removeClass( 'open' );
  1244. $toggleBtn.attr( 'aria-expanded', 'false' );
  1245. self.container.trigger( 'collapsed' );
  1246. };
  1247. if ( args.completeCallback ) {
  1248. prevComplete = complete;
  1249. complete = function () {
  1250. prevComplete();
  1251. args.completeCallback();
  1252. };
  1253. }
  1254. self.container.trigger( 'collapse' );
  1255. self.container.addClass( 'collapsing' );
  1256. if ( self.params.is_wide ) {
  1257. $inside.fadeOut( args.duration, complete );
  1258. } else {
  1259. $inside.slideUp( args.duration, function() {
  1260. $widget.css( { width:'', margin:'' } );
  1261. complete();
  1262. } );
  1263. }
  1264. }
  1265. },
  1266. /**
  1267. * Get the position (index) of the widget in the containing sidebar
  1268. *
  1269. * @returns {Number}
  1270. */
  1271. getWidgetSidebarPosition: function() {
  1272. var sidebarWidgetIds, position;
  1273. sidebarWidgetIds = this.getSidebarWidgetsControl().setting();
  1274. position = _.indexOf( sidebarWidgetIds, this.params.widget_id );
  1275. if ( position === -1 ) {
  1276. return;
  1277. }
  1278. return position;
  1279. },
  1280. /**
  1281. * Move widget up one in the sidebar
  1282. */
  1283. moveUp: function() {
  1284. this._moveWidgetByOne( -1 );
  1285. },
  1286. /**
  1287. * Move widget up one in the sidebar
  1288. */
  1289. moveDown: function() {
  1290. this._moveWidgetByOne( 1 );
  1291. },
  1292. /**
  1293. * @private
  1294. *
  1295. * @param {Number} offset 1|-1
  1296. */
  1297. _moveWidgetByOne: function( offset ) {
  1298. var i, sidebarWidgetsSetting, sidebarWidgetIds, adjacentWidgetId;
  1299. i = this.getWidgetSidebarPosition();
  1300. sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting;
  1301. sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // clone
  1302. adjacentWidgetId = sidebarWidgetIds[i + offset];
  1303. sidebarWidgetIds[i + offset] = this.params.widget_id;
  1304. sidebarWidgetIds[i] = adjacentWidgetId;
  1305. sidebarWidgetsSetting( sidebarWidgetIds );
  1306. },
  1307. /**
  1308. * Toggle visibility of the widget move area
  1309. *
  1310. * @param {Boolean} [showOrHide]
  1311. */
  1312. toggleWidgetMoveArea: function( showOrHide ) {
  1313. var self = this, $moveWidgetArea;
  1314. $moveWidgetArea = this.container.find( '.move-widget-area' );
  1315. if ( typeof showOrHide === 'undefined' ) {
  1316. showOrHide = ! $moveWidgetArea.hasClass( 'active' );
  1317. }
  1318. if ( showOrHide ) {
  1319. // reset the selected sidebar
  1320. $moveWidgetArea.find( '.selected' ).removeClass( 'selected' );
  1321. $moveWidgetArea.find( 'li' ).filter( function() {
  1322. return $( this ).data( 'id' ) === self.params.sidebar_id;
  1323. } ).addClass( 'selected' );
  1324. this.container.find( '.move-widget-btn' ).prop( 'disabled', true );
  1325. }
  1326. $moveWidgetArea.toggleClass( 'active', showOrHide );
  1327. },
  1328. /**
  1329. * Highlight the widget control and section
  1330. */
  1331. highlightSectionAndControl: function() {
  1332. var $target;
  1333. if ( this.container.is( ':hidden' ) ) {
  1334. $target = this.container.closest( '.control-section' );
  1335. } else {
  1336. $target = this.container;
  1337. }
  1338. $( '.highlighted' ).removeClass( 'highlighted' );
  1339. $target.addClass( 'highlighted' );
  1340. setTimeout( function() {
  1341. $target.removeClass( 'highlighted' );
  1342. }, 500 );
  1343. }
  1344. } );
  1345. /**
  1346. * wp.customize.Widgets.WidgetsPanel
  1347. *
  1348. * Customizer panel containing the widget area sections.
  1349. *
  1350. * @since 4.4.0
  1351. *
  1352. * @class wp.customize.Widgets.WidgetsPanel
  1353. * @augments wp.customize.Panel
  1354. */
  1355. api.Widgets.WidgetsPanel = api.Panel.extend(/** @lends wp.customize.Widgets.WigetsPanel.prototype */{
  1356. /**
  1357. * Add and manage the display of the no-rendered-areas notice.
  1358. *
  1359. * @since 4.4.0
  1360. */
  1361. ready: function () {
  1362. var panel = this;
  1363. api.Panel.prototype.ready.call( panel );
  1364. panel.deferred.embedded.done(function() {
  1365. var panelMetaContainer, noticeContainer, updateNotice, getActiveSectionCount, shouldShowNotice;
  1366. panelMetaContainer = panel.container.find( '.panel-meta' );
  1367. // @todo This should use the Notifications API introduced to panels. See <https://core.trac.wordpress.org/ticket/38794>.
  1368. noticeContainer = $( '<div></div>', {
  1369. 'class': 'no-widget-areas-rendered-notice'
  1370. });
  1371. panelMetaContainer.append( noticeContainer );
  1372. /**
  1373. * Get the number of active sections in the panel.
  1374. *
  1375. * @return {number} Number of active sidebar sections.
  1376. */
  1377. getActiveSectionCount = function() {
  1378. return _.filter( panel.sections(), function( section ) {
  1379. return section.active();
  1380. } ).length;
  1381. };
  1382. /**
  1383. * Determine whether or not the notice should be displayed.
  1384. *
  1385. * @return {boolean}
  1386. */
  1387. shouldShowNotice = function() {
  1388. var activeSectionCount = getActiveSectionCount();
  1389. if ( 0 === activeSectionCount ) {
  1390. return true;
  1391. } else {
  1392. return activeSectionCount !== api.Widgets.data.registeredSidebars.length;
  1393. }
  1394. };
  1395. /**
  1396. * Update the notice.
  1397. *
  1398. * @returns {void}
  1399. */
  1400. updateNotice = function() {
  1401. var activeSectionCount = getActiveSectionCount(), someRenderedMessage, nonRenderedAreaCount, registeredAreaCount;
  1402. noticeContainer.empty();
  1403. registeredAreaCount = api.Widgets.data.registeredSidebars.length;
  1404. if ( activeSectionCount !== registeredAreaCount ) {
  1405. if ( 0 !== activeSectionCount ) {
  1406. nonRenderedAreaCount = registeredAreaCount - activeSectionCount;
  1407. someRenderedMessage = l10n.someAreasShown[ nonRenderedAreaCount ];
  1408. } else {
  1409. someRenderedMessage = l10n.noAreasShown;
  1410. }
  1411. if ( someRenderedMessage ) {
  1412. noticeContainer.append( $( '<p></p>', {
  1413. text: someRenderedMessage
  1414. } ) );
  1415. }
  1416. noticeContainer.append( $( '<p></p>', {
  1417. text: l10n.navigatePreview
  1418. } ) );
  1419. }
  1420. };
  1421. updateNotice();
  1422. /*
  1423. * Set the initial visibility state for rendered notice.
  1424. * Update the visibility of the notice whenever a reflow happens.
  1425. */
  1426. noticeContainer.toggle( shouldShowNotice() );
  1427. api.previewer.deferred.active.done( function