customize-widgets.js 69 KB

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