customize-widgets.js 70 KB

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