/wp-admin/js/customize-nav-menus.js
JavaScript | 1625 lines | 1115 code | 218 blank | 292 comment | 188 complexity | 074e06ecc789adcc5c08459a7252c714 MD5 | raw file
1/* global _wpCustomizeNavMenusSettings, wpNavMenu, console */
2( function( api, wp, $ ) {
3 'use strict';
4
5 /**
6 * Set up wpNavMenu for drag and drop.
7 */
8 wpNavMenu.originalInit = wpNavMenu.init;
9 wpNavMenu.options.menuItemDepthPerLevel = 20;
10 wpNavMenu.options.sortableItems = '> .customize-control-nav_menu_item';
11 wpNavMenu.options.targetTolerance = 10;
12 wpNavMenu.init = function() {
13 this.jQueryExtensions();
14 };
15
16 api.Menus = api.Menus || {};
17
18 // Link settings.
19 api.Menus.data = {
20 itemTypes: [],
21 l10n: {},
22 settingTransport: 'refresh',
23 phpIntMax: 0,
24 defaultSettingValues: {
25 nav_menu: {},
26 nav_menu_item: {}
27 },
28 locationSlugMappedToName: {}
29 };
30 if ( 'undefined' !== typeof _wpCustomizeNavMenusSettings ) {
31 $.extend( api.Menus.data, _wpCustomizeNavMenusSettings );
32 }
33
34 /**
35 * Newly-created Nav Menus and Nav Menu Items have negative integer IDs which
36 * serve as placeholders until Save & Publish happens.
37 *
38 * @return {number}
39 */
40 api.Menus.generatePlaceholderAutoIncrementId = function() {
41 return -Math.ceil( api.Menus.data.phpIntMax * Math.random() );
42 };
43
44 /**
45 * wp.customize.Menus.AvailableItemModel
46 *
47 * A single available menu item model. See PHP's WP_Customize_Nav_Menu_Item_Setting class.
48 *
49 * @constructor
50 * @augments Backbone.Model
51 */
52 api.Menus.AvailableItemModel = Backbone.Model.extend( $.extend(
53 {
54 id: null // This is only used by Backbone.
55 },
56 api.Menus.data.defaultSettingValues.nav_menu_item
57 ) );
58
59 /**
60 * wp.customize.Menus.AvailableItemCollection
61 *
62 * Collection for available menu item models.
63 *
64 * @constructor
65 * @augments Backbone.Model
66 */
67 api.Menus.AvailableItemCollection = Backbone.Collection.extend({
68 model: api.Menus.AvailableItemModel,
69
70 sort_key: 'order',
71
72 comparator: function( item ) {
73 return -item.get( this.sort_key );
74 },
75
76 sortByField: function( fieldName ) {
77 this.sort_key = fieldName;
78 this.sort();
79 }
80 });
81 api.Menus.availableMenuItems = new api.Menus.AvailableItemCollection( api.Menus.data.availableMenuItems );
82
83 /**
84 * wp.customize.Menus.AvailableMenuItemsPanelView
85 *
86 * View class for the available menu items panel.
87 *
88 * @constructor
89 * @augments wp.Backbone.View
90 * @augments Backbone.View
91 */
92 api.Menus.AvailableMenuItemsPanelView = wp.Backbone.View.extend({
93
94 el: '#available-menu-items',
95
96 events: {
97 'input #menu-items-search': 'debounceSearch',
98 'keyup #menu-items-search': 'debounceSearch',
99 'focus .menu-item-tpl': 'focus',
100 'click .menu-item-tpl': '_submit',
101 'click #custom-menu-item-submit': '_submitLink',
102 'keypress #custom-menu-item-name': '_submitLink',
103 'keydown': 'keyboardAccessible'
104 },
105
106 // Cache current selected menu item.
107 selected: null,
108
109 // Cache menu control that opened the panel.
110 currentMenuControl: null,
111 debounceSearch: null,
112 $search: null,
113 searchTerm: '',
114 rendered: false,
115 pages: {},
116 sectionContent: '',
117 loading: false,
118
119 initialize: function() {
120 var self = this;
121
122 if ( ! api.panel.has( 'nav_menus' ) ) {
123 return;
124 }
125
126 this.$search = $( '#menu-items-search' );
127 this.sectionContent = this.$el.find( '.accordion-section-content' );
128
129 this.debounceSearch = _.debounce( self.search, 500 );
130
131 _.bindAll( this, 'close' );
132
133 // If the available menu items panel is open and the customize controls are
134 // interacted with (other than an item being deleted), then close the
135 // available menu items panel. Also close on back button click.
136 $( '#customize-controls, .customize-section-back' ).on( 'click keydown', function( e ) {
137 var isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ),
138 isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' );
139 if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) {
140 self.close();
141 }
142 } );
143
144 // Clear the search results.
145 $( '.clear-results' ).on( 'click keydown', function( event ) {
146 if ( event.type === 'keydown' && ( 13 !== event.which && 32 !== event.which ) ) { // "return" or "space" keys only
147 return;
148 }
149
150 event.preventDefault();
151
152 $( '#menu-items-search' ).val( '' ).focus();
153 event.target.value = '';
154 self.search( event );
155 } );
156
157 this.$el.on( 'input', '#custom-menu-item-name.invalid, #custom-menu-item-url.invalid', function() {
158 $( this ).removeClass( 'invalid' );
159 });
160
161 // Load available items if it looks like we'll need them.
162 api.panel( 'nav_menus' ).container.bind( 'expanded', function() {
163 if ( ! self.rendered ) {
164 self.initList();
165 self.rendered = true;
166 }
167 });
168
169 // Load more items.
170 this.sectionContent.scroll( function() {
171 var totalHeight = self.$el.find( '.accordion-section.open .accordion-section-content' ).prop( 'scrollHeight' ),
172 visibleHeight = self.$el.find( '.accordion-section.open' ).height();
173
174 if ( ! self.loading && $( this ).scrollTop() > 3 / 4 * totalHeight - visibleHeight ) {
175 var type = $( this ).data( 'type' ),
176 object = $( this ).data( 'object' );
177
178 if ( 'search' === type ) {
179 if ( self.searchTerm ) {
180 self.doSearch( self.pages.search );
181 }
182 } else {
183 self.loadItems( type, object );
184 }
185 }
186 });
187
188 // Close the panel if the URL in the preview changes
189 api.previewer.bind( 'url', this.close );
190
191 self.delegateEvents();
192 },
193
194 // Search input change handler.
195 search: function( event ) {
196 var $searchSection = $( '#available-menu-items-search' ),
197 $otherSections = $( '#available-menu-items .accordion-section' ).not( $searchSection );
198
199 if ( ! event ) {
200 return;
201 }
202
203 if ( this.searchTerm === event.target.value ) {
204 return;
205 }
206
207 if ( '' !== event.target.value && ! $searchSection.hasClass( 'open' ) ) {
208 $otherSections.fadeOut( 100 );
209 $searchSection.find( '.accordion-section-content' ).slideDown( 'fast' );
210 $searchSection.addClass( 'open' );
211 $searchSection.find( '.clear-results' )
212 .prop( 'tabIndex', 0 )
213 .addClass( 'is-visible' );
214 } else if ( '' === event.target.value ) {
215 $searchSection.removeClass( 'open' );
216 $otherSections.show();
217 $searchSection.find( '.clear-results' )
218 .prop( 'tabIndex', -1 )
219 .removeClass( 'is-visible' );
220 }
221
222 this.searchTerm = event.target.value;
223 this.pages.search = 1;
224 this.doSearch( 1 );
225 },
226
227 // Get search results.
228 doSearch: function( page ) {
229 var self = this, params,
230 $section = $( '#available-menu-items-search' ),
231 $content = $section.find( '.accordion-section-content' ),
232 itemTemplate = wp.template( 'available-menu-item' );
233
234 if ( self.currentRequest ) {
235 self.currentRequest.abort();
236 }
237
238 if ( page < 0 ) {
239 return;
240 } else if ( page > 1 ) {
241 $section.addClass( 'loading-more' );
242 $content.attr( 'aria-busy', 'true' );
243 wp.a11y.speak( api.Menus.data.l10n.itemsLoadingMore );
244 } else if ( '' === self.searchTerm ) {
245 $content.html( '' );
246 wp.a11y.speak( '' );
247 return;
248 }
249
250 $section.addClass( 'loading' );
251 self.loading = true;
252 params = {
253 'customize-menus-nonce': api.settings.nonce['customize-menus'],
254 'wp_customize': 'on',
255 'search': self.searchTerm,
256 'page': page
257 };
258
259 self.currentRequest = wp.ajax.post( 'search-available-menu-items-customizer', params );
260
261 self.currentRequest.done(function( data ) {
262 var items;
263 if ( 1 === page ) {
264 // Clear previous results as it's a new search.
265 $content.empty();
266 }
267 $section.removeClass( 'loading loading-more' );
268 $content.attr( 'aria-busy', 'false' );
269 $section.addClass( 'open' );
270 self.loading = false;
271 items = new api.Menus.AvailableItemCollection( data.items );
272 self.collection.add( items.models );
273 items.each( function( menuItem ) {
274 $content.append( itemTemplate( menuItem.attributes ) );
275 } );
276 if ( 20 > items.length ) {
277 self.pages.search = -1; // Up to 20 posts and 20 terms in results, if <20, no more results for either.
278 } else {
279 self.pages.search = self.pages.search + 1;
280 }
281 if ( items && page > 1 ) {
282 wp.a11y.speak( api.Menus.data.l10n.itemsFoundMore.replace( '%d', items.length ) );
283 } else if ( items && page === 1 ) {
284 wp.a11y.speak( api.Menus.data.l10n.itemsFound.replace( '%d', items.length ) );
285 }
286 });
287
288 self.currentRequest.fail(function( data ) {
289 // data.message may be undefined, for example when typing slow and the request is aborted.
290 if ( data.message ) {
291 $content.empty().append( $( '<p class="nothing-found"></p>' ).text( data.message ) );
292 wp.a11y.speak( data.message );
293 }
294 self.pages.search = -1;
295 });
296
297 self.currentRequest.always(function() {
298 $section.removeClass( 'loading loading-more' );
299 $content.attr( 'aria-busy', 'false' );
300 self.loading = false;
301 self.currentRequest = null;
302 });
303 },
304
305 // Render the individual items.
306 initList: function() {
307 var self = this;
308
309 // Render the template for each item by type.
310 _.each( api.Menus.data.itemTypes, function( itemType ) {
311 self.pages[ itemType.type + ':' + itemType.object ] = 0;
312 self.loadItems( itemType.type, itemType.object ); // @todo we need to combine these Ajax requests.
313 } );
314 },
315
316 // Load available menu items.
317 loadItems: function( type, object ) {
318 var self = this, params, request, itemTemplate, availableMenuItemContainer;
319 itemTemplate = wp.template( 'available-menu-item' );
320
321 if ( -1 === self.pages[ type + ':' + object ] ) {
322 return;
323 }
324 availableMenuItemContainer = $( '#available-menu-items-' + type + '-' + object );
325 availableMenuItemContainer.find( '.accordion-section-title' ).addClass( 'loading' );
326 self.loading = true;
327 params = {
328 'customize-menus-nonce': api.settings.nonce['customize-menus'],
329 'wp_customize': 'on',
330 'type': type,
331 'object': object,
332 'page': self.pages[ type + ':' + object ]
333 };
334 request = wp.ajax.post( 'load-available-menu-items-customizer', params );
335
336 request.done(function( data ) {
337 var items, typeInner;
338 items = data.items;
339 if ( 0 === items.length ) {
340 if ( 0 === self.pages[ type + ':' + object ] ) {
341 availableMenuItemContainer
342 .addClass( 'cannot-expand' )
343 .removeClass( 'loading' )
344 .find( '.accordion-section-title > button' )
345 .prop( 'tabIndex', -1 );
346 }
347 self.pages[ type + ':' + object ] = -1;
348 return;
349 }
350 items = new api.Menus.AvailableItemCollection( items ); // @todo Why is this collection created and then thrown away?
351 self.collection.add( items.models );
352 typeInner = availableMenuItemContainer.find( '.accordion-section-content' );
353 items.each(function( menuItem ) {
354 typeInner.append( itemTemplate( menuItem.attributes ) );
355 });
356 self.pages[ type + ':' + object ] += 1;
357 });
358 request.fail(function( data ) {
359 if ( typeof console !== 'undefined' && console.error ) {
360 console.error( data );
361 }
362 });
363 request.always(function() {
364 availableMenuItemContainer.find( '.accordion-section-title' ).removeClass( 'loading' );
365 self.loading = false;
366 });
367 },
368
369 // Adjust the height of each section of items to fit the screen.
370 itemSectionHeight: function() {
371 var sections, totalHeight, accordionHeight, diff;
372 totalHeight = window.innerHeight;
373 sections = this.$el.find( '.accordion-section:not( #available-menu-items-search ) .accordion-section-content' );
374 accordionHeight = 46 * ( 2 + sections.length ) - 13; // Magic numbers.
375 diff = totalHeight - accordionHeight;
376 if ( 120 < diff && 290 > diff ) {
377 sections.css( 'max-height', diff );
378 }
379 },
380
381 // Highlights a menu item.
382 select: function( menuitemTpl ) {
383 this.selected = $( menuitemTpl );
384 this.selected.siblings( '.menu-item-tpl' ).removeClass( 'selected' );
385 this.selected.addClass( 'selected' );
386 },
387
388 // Highlights a menu item on focus.
389 focus: function( event ) {
390 this.select( $( event.currentTarget ) );
391 },
392
393 // Submit handler for keypress and click on menu item.
394 _submit: function( event ) {
395 // Only proceed with keypress if it is Enter or Spacebar
396 if ( 'keypress' === event.type && ( 13 !== event.which && 32 !== event.which ) ) {
397 return;
398 }
399
400 this.submit( $( event.currentTarget ) );
401 },
402
403 // Adds a selected menu item to the menu.
404 submit: function( menuitemTpl ) {
405 var menuitemId, menu_item;
406
407 if ( ! menuitemTpl ) {
408 menuitemTpl = this.selected;
409 }
410
411 if ( ! menuitemTpl || ! this.currentMenuControl ) {
412 return;
413 }
414
415 this.select( menuitemTpl );
416
417 menuitemId = $( this.selected ).data( 'menu-item-id' );
418 menu_item = this.collection.findWhere( { id: menuitemId } );
419 if ( ! menu_item ) {
420 return;
421 }
422
423 this.currentMenuControl.addItemToMenu( menu_item.attributes );
424
425 $( menuitemTpl ).find( '.menu-item-handle' ).addClass( 'item-added' );
426 },
427
428 // Submit handler for keypress and click on custom menu item.
429 _submitLink: function( event ) {
430 // Only proceed with keypress if it is Enter.
431 if ( 'keypress' === event.type && 13 !== event.which ) {
432 return;
433 }
434
435 this.submitLink();
436 },
437
438 // Adds the custom menu item to the menu.
439 submitLink: function() {
440 var menuItem,
441 itemName = $( '#custom-menu-item-name' ),
442 itemUrl = $( '#custom-menu-item-url' );
443
444 if ( ! this.currentMenuControl ) {
445 return;
446 }
447
448 if ( '' === itemName.val() ) {
449 itemName.addClass( 'invalid' );
450 return;
451 } else if ( '' === itemUrl.val() || 'http://' === itemUrl.val() ) {
452 itemUrl.addClass( 'invalid' );
453 return;
454 }
455
456 menuItem = {
457 'title': itemName.val(),
458 'url': itemUrl.val(),
459 'type': 'custom',
460 'type_label': api.Menus.data.l10n.custom_label,
461 'object': ''
462 };
463
464 this.currentMenuControl.addItemToMenu( menuItem );
465
466 // Reset the custom link form.
467 itemUrl.val( 'http://' );
468 itemName.val( '' );
469 },
470
471 // Opens the panel.
472 open: function( menuControl ) {
473 this.currentMenuControl = menuControl;
474
475 this.itemSectionHeight();
476
477 $( 'body' ).addClass( 'adding-menu-items' );
478
479 // Collapse all controls.
480 _( this.currentMenuControl.getMenuItemControls() ).each( function( control ) {
481 control.collapseForm();
482 } );
483
484 this.$el.find( '.selected' ).removeClass( 'selected' );
485
486 this.$search.focus();
487 },
488
489 // Closes the panel
490 close: function( options ) {
491 options = options || {};
492
493 if ( options.returnFocus && this.currentMenuControl ) {
494 this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
495 }
496
497 this.currentMenuControl = null;
498 this.selected = null;
499
500 $( 'body' ).removeClass( 'adding-menu-items' );
501 $( '#available-menu-items .menu-item-handle.item-added' ).removeClass( 'item-added' );
502
503 this.$search.val( '' );
504 },
505
506 // Add a few keyboard enhancements to the panel.
507 keyboardAccessible: function( event ) {
508 var isEnter = ( 13 === event.which ),
509 isEsc = ( 27 === event.which ),
510 isBackTab = ( 9 === event.which && event.shiftKey ),
511 isSearchFocused = $( event.target ).is( this.$search );
512
513 // If enter pressed but nothing entered, don't do anything
514 if ( isEnter && ! this.$search.val() ) {
515 return;
516 }
517
518 if ( isSearchFocused && isBackTab ) {
519 this.currentMenuControl.container.find( '.add-new-menu-item' ).focus();
520 event.preventDefault(); // Avoid additional back-tab.
521 } else if ( isEsc ) {
522 this.close( { returnFocus: true } );
523 }
524 }
525 });
526
527 /**
528 * wp.customize.Menus.MenusPanel
529 *
530 * Customizer panel for menus. This is used only for screen options management.
531 * Note that 'menus' must match the WP_Customize_Menu_Panel::$type.
532 *
533 * @constructor
534 * @augments wp.customize.Panel
535 */
536 api.Menus.MenusPanel = api.Panel.extend({
537
538 attachEvents: function() {
539 api.Panel.prototype.attachEvents.call( this );
540
541 var panel = this,
542 panelMeta = panel.container.find( '.panel-meta' ),
543 help = panelMeta.find( '.customize-help-toggle' ),
544 content = panelMeta.find( '.customize-panel-description' ),
545 options = $( '#screen-options-wrap' ),
546 button = panelMeta.find( '.customize-screen-options-toggle' );
547 button.on( 'click keydown', function( event ) {
548 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
549 return;
550 }
551 event.preventDefault();
552
553 // Hide description
554 if ( content.not( ':hidden' ) ) {
555 content.slideUp( 'fast' );
556 help.attr( 'aria-expanded', 'false' );
557 }
558
559 if ( 'true' === button.attr( 'aria-expanded' ) ) {
560 button.attr( 'aria-expanded', 'false' );
561 panelMeta.removeClass( 'open' );
562 panelMeta.removeClass( 'active-menu-screen-options' );
563 options.slideUp( 'fast' );
564 } else {
565 button.attr( 'aria-expanded', 'true' );
566 panelMeta.addClass( 'open' );
567 panelMeta.addClass( 'active-menu-screen-options' );
568 options.slideDown( 'fast' );
569 }
570
571 return false;
572 } );
573
574 // Help toggle
575 help.on( 'click keydown', function( event ) {
576 if ( api.utils.isKeydownButNotEnterEvent( event ) ) {
577 return;
578 }
579 event.preventDefault();
580
581 if ( 'true' === button.attr( 'aria-expanded' ) ) {
582 button.attr( 'aria-expanded', 'false' );
583 help.attr( 'aria-expanded', 'true' );
584 panelMeta.addClass( 'open' );
585 panelMeta.removeClass( 'active-menu-screen-options' );
586 options.slideUp( 'fast' );
587 content.slideDown( 'fast' );
588 }
589 } );
590 },
591
592 /**
593 * Show/hide/save screen options (columns). From common.js.
594 */
595 ready: function() {
596 var panel = this;
597 this.container.find( '.hide-column-tog' ).click( function() {
598 var $t = $( this ), column = $t.val();
599 if ( $t.prop( 'checked' ) ) {
600 panel.checked( column );
601 } else {
602 panel.unchecked( column );
603 }
604
605 panel.saveManageColumnsState();
606 });
607 this.container.find( '.hide-column-tog' ).each( function() {
608 var $t = $( this ), column = $t.val();
609 if ( $t.prop( 'checked' ) ) {
610 panel.checked( column );
611 } else {
612 panel.unchecked( column );
613 }
614 });
615 },
616
617 saveManageColumnsState: _.debounce( function() {
618 var panel = this;
619 if ( panel._updateHiddenColumnsRequest ) {
620 panel._updateHiddenColumnsRequest.abort();
621 }
622
623 panel._updateHiddenColumnsRequest = wp.ajax.post( 'hidden-columns', {
624 hidden: panel.hidden(),
625 screenoptionnonce: $( '#screenoptionnonce' ).val(),
626 page: 'nav-menus'
627 } );
628 panel._updateHiddenColumnsRequest.always( function() {
629 panel._updateHiddenColumnsRequest = null;
630 } );
631 }, 2000 ),
632
633 checked: function( column ) {
634 this.container.addClass( 'field-' + column + '-active' );
635 },
636
637 unchecked: function( column ) {
638 this.container.removeClass( 'field-' + column + '-active' );
639 },
640
641 hidden: function() {
642 return $( '.hide-column-tog' ).not( ':checked' ).map( function() {
643 var id = this.id;
644 return id.substring( 0, id.length - 5 );
645 }).get().join( ',' );
646 }
647 } );
648
649 /**
650 * wp.customize.Menus.MenuSection
651 *
652 * Customizer section for menus. This is used only for lazy-loading child controls.
653 * Note that 'nav_menu' must match the WP_Customize_Menu_Section::$type.
654 *
655 * @constructor
656 * @augments wp.customize.Section
657 */
658 api.Menus.MenuSection = api.Section.extend({
659
660 /**
661 * @since Menu Customizer 0.3
662 *
663 * @param {String} id
664 * @param {Object} options
665 */
666 initialize: function( id, options ) {
667 var section = this;
668 api.Section.prototype.initialize.call( section, id, options );
669 section.deferred.initSortables = $.Deferred();
670 },
671
672 /**
673 *
674 */
675 ready: function() {
676 var section = this;
677
678 if ( 'undefined' === typeof section.params.menu_id ) {
679 throw new Error( 'params.menu_id was not defined' );
680 }
681
682 /*
683 * Since newly created sections won't be registered in PHP, we need to prevent the
684 * preview's sending of the activeSections to result in this control
685 * being deactivated when the preview refreshes. So we can hook onto
686 * the setting that has the same ID and its presence can dictate
687 * whether the section is active.
688 */
689 section.active.validate = function() {
690 if ( ! api.has( section.id ) ) {
691 return false;
692 }
693 return !! api( section.id ).get();
694 };
695
696 section.populateControls();
697
698 section.navMenuLocationSettings = {};
699 section.assignedLocations = new api.Value( [] );
700
701 api.each(function( setting, id ) {
702 var matches = id.match( /^nav_menu_locations\[(.+?)]/ );
703 if ( matches ) {
704 section.navMenuLocationSettings[ matches[1] ] = setting;
705 setting.bind( function() {
706 section.refreshAssignedLocations();
707 });
708 }
709 });
710
711 section.assignedLocations.bind(function( to ) {
712 section.updateAssignedLocationsInSectionTitle( to );
713 });
714
715 section.refreshAssignedLocations();
716
717 api.bind( 'pane-contents-reflowed', function() {
718 // Skip menus that have been removed.
719 if ( ! section.container.parent().length ) {
720 return;
721 }
722 section.container.find( '.menu-item .menu-item-reorder-nav button' ).attr({ 'tabindex': '0', 'aria-hidden': 'false' });
723 section.container.find( '.menu-item.move-up-disabled .menus-move-up' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
724 section.container.find( '.menu-item.move-down-disabled .menus-move-down' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
725 section.container.find( '.menu-item.move-left-disabled .menus-move-left' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
726 section.container.find( '.menu-item.move-right-disabled .menus-move-right' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' });
727 } );
728 },
729
730 populateControls: function() {
731 var section = this, menuNameControlId, menuAutoAddControlId, menuControl, menuNameControl, menuAutoAddControl;
732
733 // Add the control for managing the menu name.
734 menuNameControlId = section.id + '[name]';
735 menuNameControl = api.control( menuNameControlId );
736 if ( ! menuNameControl ) {
737 menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, {
738 params: {
739 type: 'nav_menu_name',
740 content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '-name" class="customize-control customize-control-nav_menu_name"></li>', // @todo core should do this for us; see #30741
741 label: api.Menus.data.l10n.menuNameLabel,
742 active: true,
743 section: section.id,
744 priority: 0,
745 settings: {
746 'default': section.id
747 }
748 }
749 } );
750 api.control.add( menuNameControl.id, menuNameControl );
751 menuNameControl.active.set( true );
752 }
753
754 // Add the menu control.
755 menuControl = api.control( section.id );
756 if ( ! menuControl ) {
757 menuControl = new api.controlConstructor.nav_menu( section.id, {
758 params: {
759 type: 'nav_menu',
760 content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '" class="customize-control customize-control-nav_menu"></li>', // @todo core should do this for us; see #30741
761 section: section.id,
762 priority: 998,
763 active: true,
764 settings: {
765 'default': section.id
766 },
767 menu_id: section.params.menu_id
768 }
769 } );
770 api.control.add( menuControl.id, menuControl );
771 menuControl.active.set( true );
772 }
773
774 // Add the control for managing the menu auto_add.
775 menuAutoAddControlId = section.id + '[auto_add]';
776 menuAutoAddControl = api.control( menuAutoAddControlId );
777 if ( ! menuAutoAddControl ) {
778 menuAutoAddControl = new api.controlConstructor.nav_menu_auto_add( menuAutoAddControlId, {
779 params: {
780 type: 'nav_menu_auto_add',
781 content: '<li id="customize-control-' + section.id.replace( '[', '-' ).replace( ']', '' ) + '-auto-add" class="customize-control customize-control-nav_menu_auto_add"></li>', // @todo core should do this for us
782 label: '',
783 active: true,
784 section: section.id,
785 priority: 999,
786 settings: {
787 'default': section.id
788 }
789 }
790 } );
791 api.control.add( menuAutoAddControl.id, menuAutoAddControl );
792 menuAutoAddControl.active.set( true );
793 }
794
795 },
796
797 /**
798 *
799 */
800 refreshAssignedLocations: function() {
801 var section = this,
802 menuTermId = section.params.menu_id,
803 currentAssignedLocations = [];
804 _.each( section.navMenuLocationSettings, function( setting, themeLocation ) {
805 if ( setting() === menuTermId ) {
806 currentAssignedLocations.push( themeLocation );
807 }
808 });
809 section.assignedLocations.set( currentAssignedLocations );
810 },
811
812 /**
813 * @param {array} themeLocations
814 */
815 updateAssignedLocationsInSectionTitle: function( themeLocationSlugs ) {
816 var section = this,
817 $title;
818
819 $title = section.container.find( '.accordion-section-title:first' );
820 $title.find( '.menu-in-location' ).remove();
821 _.each( themeLocationSlugs, function( themeLocationSlug ) {
822 var $label, locationName;
823 $label = $( '<span class="menu-in-location"></span>' );
824 locationName = api.Menus.data.locationSlugMappedToName[ themeLocationSlug ];
825 $label.text( api.Menus.data.l10n.menuLocation.replace( '%s', locationName ) );
826 $title.append( $label );
827 });
828
829 section.container.toggleClass( 'assigned-to-menu-location', 0 !== themeLocationSlugs.length );
830
831 },
832
833 onChangeExpanded: function( expanded, args ) {
834 var section = this;
835
836 if ( expanded ) {
837 wpNavMenu.menuList = section.container.find( '.accordion-section-content:first' );
838 wpNavMenu.targetList = wpNavMenu.menuList;
839
840 // Add attributes needed by wpNavMenu
841 $( '#menu-to-edit' ).removeAttr( 'id' );
842 wpNavMenu.menuList.attr( 'id', 'menu-to-edit' ).addClass( 'menu' );
843
844 _.each( api.section( section.id ).controls(), function( control ) {
845 if ( 'nav_menu_item' === control.params.type ) {
846 control.actuallyEmbed();
847 }
848 } );
849
850 if ( 'resolved' !== section.deferred.initSortables.state() ) {
851 wpNavMenu.initSortables(); // Depends on menu-to-edit ID being set above.
852 section.deferred.initSortables.resolve( wpNavMenu.menuList ); // Now MenuControl can extend the sortable.
853
854 // @todo Note that wp.customize.reflowPaneContents() is debounced, so this immediate change will show a slight flicker while priorities get updated.
855 api.control( 'nav_menu[' + String( section.params.menu_id ) + ']' ).reflowMenuItems();
856 }
857 }
858 api.Section.prototype.onChangeExpanded.call( section, expanded, args );
859 }
860 });
861
862 /**
863 * wp.customize.Menus.NewMenuSection
864 *
865 * Customizer section for new menus.
866 * Note that 'new_menu' must match the WP_Customize_New_Menu_Section::$type.
867 *
868 * @constructor
869 * @augments wp.customize.Section
870 */
871 api.Menus.NewMenuSection = api.Section.extend({
872
873 /**
874 * Add behaviors for the accordion section.
875 *
876 * @since Menu Customizer 0.3
877 */
878 attachEvents: function() {
879 var section = this;
880 this.container.on( 'click', '.add-menu-toggle', function() {
881 if ( section.expanded() ) {
882 section.collapse();
883 } else {
884 section.expand();
885 }
886 });
887 },
888
889 /**
890 * Update UI to reflect expanded state.
891 *
892 * @since 4.1.0
893 *
894 * @param {Boolean} expanded
895 */
896 onChangeExpanded: function( expanded ) {
897 var section = this,
898 button = section.container.find( '.add-menu-toggle' ),
899 content = section.container.find( '.new-menu-section-content' ),
900 customizer = section.container.closest( '.wp-full-overlay-sidebar-content' );
901 if ( expanded ) {
902 button.addClass( 'open' );
903 button.attr( 'aria-expanded', 'true' );
904 content.slideDown( 'fast', function() {
905 customizer.scrollTop( customizer.height() );
906 });
907 } else {
908 button.removeClass( 'open' );
909 button.attr( 'aria-expanded', 'false' );
910 content.slideUp( 'fast' );
911 content.find( '.menu-name-field' ).removeClass( 'invalid' );
912 }
913 }
914 });
915
916 /**
917 * wp.customize.Menus.MenuLocationControl
918 *
919 * Customizer control for menu locations (rendered as a <select>).
920 * Note that 'nav_menu_location' must match the WP_Customize_Nav_Menu_Location_Control::$type.
921 *
922 * @constructor
923 * @augments wp.customize.Control
924 */
925 api.Menus.MenuLocationControl = api.Control.extend({
926 initialize: function( id, options ) {
927 var control = this,
928 matches = id.match( /^nav_menu_locations\[(.+?)]/ );
929 control.themeLocation = matches[1];
930 api.Control.prototype.initialize.call( control, id, options );
931 },
932
933 ready: function() {
934 var control = this, navMenuIdRegex = /^nav_menu\[(-?\d+)]/;
935
936 // @todo It would be better if this was added directly on the setting itself, as opposed to the control.
937 control.setting.validate = function( value ) {
938 return parseInt( value, 10 );
939 };
940
941 // Add/remove menus from the available options when they are added and removed.
942 api.bind( 'add', function( setting ) {
943 var option, menuId, matches = setting.id.match( navMenuIdRegex );
944 if ( ! matches || false === setting() ) {
945 return;
946 }
947 menuId = matches[1];
948 option = new Option( displayNavMenuName( setting().name ), menuId );
949 control.container.find( 'select' ).append( option );
950 });
951 api.bind( 'remove', function( setting ) {
952 var menuId, matches = setting.id.match( navMenuIdRegex );
953 if ( ! matches ) {
954 return;
955 }
956 menuId = parseInt( matches[1], 10 );
957 if ( control.setting() === menuId ) {
958 control.setting.set( '' );
959 }
960 control.container.find( 'option[value=' + menuId + ']' ).remove();
961 });
962 api.bind( 'change', function( setting ) {
963 var menuId, matches = setting.id.match( navMenuIdRegex );
964 if ( ! matches ) {
965 return;
966 }
967 menuId = parseInt( matches[1], 10 );
968 if ( false === setting() ) {
969 if ( control.setting() === menuId ) {
970 control.setting.set( '' );
971 }
972 control.container.find( 'option[value=' + menuId + ']' ).remove();
973 } else {
974 control.container.find( 'option[value=' + menuId + ']' ).text( displayNavMenuName( setting().name ) );
975 }
976 });
977 }
978 });
979
980 /**
981 * wp.customize.Menus.MenuItemControl
982 *
983 * Customizer control for menu items.
984 * Note that 'menu_item' must match the WP_Customize_Menu_Item_Control::$type.
985 *
986 * @constructor
987 * @augments wp.customize.Control
988 */
989 api.Menus.MenuItemControl = api.Control.extend({
990
991 /**
992 * @inheritdoc
993 */
994 initialize: function( id, options ) {
995 var control = this;
996 api.Control.prototype.initialize.call( control, id, options );
997 control.active.validate = function() {
998 var value, section = api.section( control.section() );
999 if ( section ) {
1000 value = section.active();
1001 } else {
1002 value = false;
1003 }
1004 return value;
1005 };
1006 },
1007
1008 /**
1009 * @since Menu Customizer 0.3
1010 *
1011 * Override the embed() method to do nothing,
1012 * so that the control isn't embedded on load,
1013 * unless the containing section is already expanded.
1014 */
1015 embed: function() {
1016 var control = this,
1017 sectionId = control.section(),
1018 section;
1019 if ( ! sectionId ) {
1020 return;
1021 }
1022 section = api.section( sectionId );
1023 if ( ( section && section.expanded() ) || api.settings.autofocus.control === control.id ) {
1024 control.actuallyEmbed();
1025 }
1026 },
1027
1028 /**
1029 * This function is called in Section.onChangeExpanded() so the control
1030 * will only get embedded when the Section is first expanded.
1031 *
1032 * @since Menu Customizer 0.3
1033 */
1034 actuallyEmbed: function() {
1035 var control = this;
1036 if ( 'resolved' === control.deferred.embedded.state() ) {
1037 return;
1038 }
1039 control.renderContent();
1040 control.deferred.embedded.resolve(); // This triggers control.ready().
1041 },
1042
1043 /**
1044 * Set up the control.
1045 */
1046 ready: function() {
1047 if ( 'undefined' === typeof this.params.menu_item_id ) {
1048 throw new Error( 'params.menu_item_id was not defined' );
1049 }
1050
1051 this._setupControlToggle();
1052 this._setupReorderUI();
1053 this._setupUpdateUI();
1054 this._setupRemoveUI();
1055 this._setupLinksUI();
1056 this._setupTitleUI();
1057 },
1058
1059 /**
1060 * Show/hide the settings when clicking on the menu item handle.
1061 */
1062 _setupControlToggle: function() {
1063 var control = this;
1064
1065 this.container.find( '.menu-item-handle' ).on( 'click', function( e ) {
1066 e.preventDefault();
1067 e.stopPropagation();
1068 var menuControl = control.getMenuControl();
1069 if ( menuControl.isReordering || menuControl.isSorting ) {
1070 return;
1071 }
1072 control.toggleForm();
1073 } );
1074 },
1075
1076 /**
1077 * Set up the menu-item-reorder-nav
1078 */
1079 _setupReorderUI: function() {
1080 var control = this, template, $reorderNav;
1081
1082 template = wp.template( 'menu-item-reorder-nav' );
1083
1084 // Add the menu item reordering elements to the menu item control.
1085 control.container.find( '.item-controls' ).after( template );
1086
1087 // Handle clicks for up/down/left-right on the reorder nav.
1088 $reorderNav = control.container.find( '.menu-item-reorder-nav' );
1089 $reorderNav.find( '.menus-move-up, .menus-move-down, .menus-move-left, .menus-move-right' ).on( 'click', function() {
1090 var moveBtn = $( this );
1091 moveBtn.focus();
1092
1093 var isMoveUp = moveBtn.is( '.menus-move-up' ),
1094 isMoveDown = moveBtn.is( '.menus-move-down' ),
1095 isMoveLeft = moveBtn.is( '.menus-move-left' ),
1096 isMoveRight = moveBtn.is( '.menus-move-right' );
1097
1098 if ( isMoveUp ) {
1099 control.moveUp();
1100 } else if ( isMoveDown ) {
1101 control.moveDown();
1102 } else if ( isMoveLeft ) {
1103 control.moveLeft();
1104 } else if ( isMoveRight ) {
1105 control.moveRight();
1106 }
1107
1108 moveBtn.focus(); // Re-focus after the container was moved.
1109 } );
1110 },
1111
1112 /**
1113 * Set up event handlers for menu item updating.
1114 */
1115 _setupUpdateUI: function() {
1116 var control = this,
1117 settingValue = control.setting();
1118
1119 control.elements = {};
1120 control.elements.url = new api.Element( control.container.find( '.edit-menu-item-url' ) );
1121 control.elements.title = new api.Element( control.container.find( '.edit-menu-item-title' ) );
1122 control.elements.attr_title = new api.Element( control.container.find( '.edit-menu-item-attr-title' ) );
1123 control.elements.target = new api.Element( control.container.find( '.edit-menu-item-target' ) );
1124 control.elements.classes = new api.Element( control.container.find( '.edit-menu-item-classes' ) );
1125 control.elements.xfn = new api.Element( control.container.find( '.edit-menu-item-xfn' ) );
1126 control.elements.description = new api.Element( control.container.find( '.edit-menu-item-description' ) );
1127 // @todo allow other elements, added by plugins, to be automatically picked up here; allow additional values to be added to setting array.
1128
1129 _.each( control.elements, function( element, property ) {
1130 element.bind(function( value ) {
1131 if ( element.element.is( 'input[type=checkbox]' ) ) {
1132 value = ( value ) ? element.element.val() : '';
1133 }
1134
1135 var settingValue = control.setting();
1136 if ( settingValue && settingValue[ property ] !== value ) {
1137 settingValue = _.clone( settingValue );
1138 settingValue[ property ] = value;
1139 control.setting.set( settingValue );
1140 }
1141 });
1142 if ( settingValue ) {
1143 if ( ( property === 'classes' || property === 'xfn' ) && _.isArray( settingValue[ property ] ) ) {
1144 element.set( settingValue[ property ].join( ' ' ) );
1145 } else {
1146 element.set( settingValue[ property ] );
1147 }
1148 }
1149 });
1150
1151 control.setting.bind(function( to, from ) {
1152 var itemId = control.params.menu_item_id,
1153 followingSiblingItemControls = [],
1154 childrenItemControls = [],
1155 menuControl;
1156
1157 if ( false === to ) {
1158 menuControl = api.control( 'nav_menu[' + String( from.nav_menu_term_id ) + ']' );
1159 control.container.remove();
1160
1161 _.each( menuControl.getMenuItemControls(), function( otherControl ) {
1162 if ( from.menu_item_parent === otherControl.setting().menu_item_parent && otherControl.setting().position > from.position ) {
1163 followingSiblingItemControls.push( otherControl );
1164 } else if ( otherControl.setting().menu_item_parent === itemId ) {
1165 childrenItemControls.push( otherControl );
1166 }
1167 });
1168
1169 // Shift all following siblings by the number of children this item has.
1170 _.each( followingSiblingItemControls, function( followingSiblingItemControl ) {
1171 var value = _.clone( followingSiblingItemControl.setting() );
1172 value.position += childrenItemControls.length;
1173 followingSiblingItemControl.setting.set( value );
1174 });
1175
1176 // Now move the children up to be the new subsequent siblings.
1177 _.each( childrenItemControls, function( childrenItemControl, i ) {
1178 var value = _.clone( childrenItemControl.setting() );
1179 value.position = from.position + i;
1180 value.menu_item_parent = from.menu_item_parent;
1181 childrenItemControl.setting.set( value );
1182 });
1183
1184 menuControl.debouncedReflowMenuItems();
1185 } else {
1186 // Update the elements' values to match the new setting properties.
1187 _.each( to, function( value, key ) {
1188 if ( control.elements[ key] ) {
1189 control.elements[ key ].set( to[ key ] );
1190 }
1191 } );
1192 control.container.find( '.menu-item-data-parent-id' ).val( to.menu_item_parent );
1193
1194 // Handle UI updates when the position or depth (parent) change.
1195 if ( to.position !== from.position || to.menu_item_parent !== from.menu_item_parent ) {
1196 control.getMenuControl().debouncedReflowMenuItems();
1197 }
1198 }
1199 });
1200 },
1201
1202 /**
1203 * Set up event handlers for menu item deletion.
1204 */
1205 _setupRemoveUI: function() {
1206 var control = this, $removeBtn;
1207
1208 // Configure delete button.
1209 $removeBtn = control.container.find( '.item-delete' );
1210
1211 $removeBtn.on( 'click', function() {
1212 // Find an adjacent element to add focus to when this menu item goes away
1213 var addingItems = true, $adjacentFocusTarget, $next, $prev;
1214
1215 if ( ! $( 'body' ).hasClass( 'adding-menu-items' ) ) {
1216 addingItems = false;
1217 }
1218
1219 $next = control.container.nextAll( '.customize-control-nav_menu_item:visible' ).first();
1220 $prev = control.container.prevAll( '.customize-control-nav_menu_item:visible' ).first();
1221
1222 if ( $next.length ) {
1223 $adjacentFocusTarget = $next.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1224 } else if ( $prev.length ) {
1225 $adjacentFocusTarget = $prev.find( false === addingItems ? '.item-edit' : '.item-delete' ).first();
1226 } else {
1227 $adjacentFocusTarget = control.container.nextAll( '.customize-control-nav_menu' ).find( '.add-new-menu-item' ).first();
1228 }
1229
1230 control.container.slideUp( function() {
1231 control.setting.set( false );
1232 wp.a11y.speak( api.Menus.data.l10n.itemDeleted );
1233 $adjacentFocusTarget.focus(); // keyboard accessibility
1234 } );
1235 } );
1236 },
1237
1238 _setupLinksUI: function() {
1239 var $origBtn;
1240
1241 // Configure original link.
1242 $origBtn = this.container.find( 'a.original-link' );
1243
1244 $origBtn.on( 'click', function( e ) {
1245 e.preventDefault();
1246 api.previewer.previewUrl( e.target.toString() );
1247 } );
1248 },
1249
1250 /**
1251 * Update item handle title when changed.
1252 */
1253 _setupTitleUI: function() {
1254 var control = this;
1255
1256 control.setting.bind( function( item ) {
1257 if ( ! item ) {
1258 return;
1259 }
1260
1261 var titleEl = control.container.find( '.menu-item-title' ),
1262 titleText = item.title || api.Menus.data.l10n.untitled;
1263
1264 if ( item._invalid ) {
1265 titleText = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', titleText );
1266 }
1267
1268 // Don't update to an empty title.
1269 if ( item.title ) {
1270 titleEl
1271 .text( titleText )
1272 .removeClass( 'no-title' );
1273 } else {
1274 titleEl
1275 .text( titleText )
1276 .addClass( 'no-title' );
1277 }
1278 } );
1279 },
1280
1281 /**
1282 *
1283 * @returns {number}
1284 */
1285 getDepth: function() {
1286 var control = this, setting = control.setting(), depth = 0;
1287 if ( ! setting ) {
1288 return 0;
1289 }
1290 while ( setting && setting.menu_item_parent ) {
1291 depth += 1;
1292 control = api.control( 'nav_menu_item[' + setting.menu_item_parent + ']' );
1293 if ( ! control ) {
1294 break;
1295 }
1296 setting = control.setting();
1297 }
1298 return depth;
1299 },
1300
1301 /**
1302 * Amend the control's params with the data necessary for the JS template just in time.
1303 */
1304 renderContent: function() {
1305 var control = this,
1306 settingValue = control.setting(),
1307 containerClasses;
1308
1309 control.params.title = settingValue.title || '';
1310 control.params.depth = control.getDepth();
1311 control.container.data( 'item-depth', control.params.depth );
1312 containerClasses = [
1313 'menu-item',
1314 'menu-item-depth-' + String( control.params.depth ),
1315 'menu-item-' + settingValue.object,
1316 'menu-item-edit-inactive'
1317 ];
1318
1319 if ( settingValue._invalid ) {
1320 containerClasses.push( 'menu-item-invalid' );
1321 control.params.title = api.Menus.data.l10n.invalidTitleTpl.replace( '%s', control.params.title );
1322 } else if ( 'draft' === settingValue.status ) {
1323 containerClasses.push( 'pending' );
1324 control.params.title = api.Menus.data.pendingTitleTpl.replace( '%s', control.params.title );
1325 }
1326
1327 control.params.el_classes = containerClasses.join( ' ' );
1328 control.params.item_type_label = settingValue.type_label;
1329 control.params.item_type = settingValue.type;
1330 control.params.url = settingValue.url;
1331 control.params.target = settingValue.target;
1332 control.params.attr_title = settingValue.attr_title;
1333 control.params.classes = _.isArray( settingValue.classes ) ? settingValue.classes.join( ' ' ) : settingValue.classes;
1334 control.params.attr_title = settingValue.attr_title;
1335 control.params.xfn = settingValue.xfn;
1336 control.params.description = settingValue.description;
1337 control.params.parent = settingValue.menu_item_parent;
1338 control.params.original_title = settingValue.original_title || '';
1339
1340 control.container.addClass( control.params.el_classes );
1341
1342 api.Control.prototype.renderContent.call( control );
1343 },
1344
1345 /***********************************************************************
1346 * Begin public API methods
1347 **********************************************************************/
1348
1349 /**
1350 * @return {wp.customize.controlConstructor.nav_menu|null}
1351 */
1352 getMenuControl: function() {
1353 var control = this, settingValue = control.setting();
1354 if ( settingValue && settingValue.nav_menu_term_id ) {
1355 return api.control( 'nav_menu[' + settingValue.nav_menu_term_id + ']' );
1356 } else {
1357 return null;
1358 }
1359 },
1360
1361 /**
1362 * Expand the accordion section containing a control
1363 */
1364 expandControlSection: function() {
1365 var $section = this.container.closest( '.accordion-section' );
1366
1367 if ( ! $section.hasClass( 'open' ) ) {
1368 $section.find( '.accordion-section-title:first' ).trigger( 'click' );
1369 }
1370 },
1371
1372 /**
1373 * Expand the menu item form control.
1374 *
1375 * @since 4.5.0 Added params.completeCallback.
1376 *
1377 * @param {Object} [params] - Optional params.
1378 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1379 */
1380 expandForm: function( params ) {
1381 this.toggleForm( true, params );
1382 },
1383
1384 /**
1385 * Collapse the menu item form control.
1386 *
1387 * @since 4.5.0 Added params.completeCallback.
1388 *
1389 * @param {Object} [params] - Optional params.
1390 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1391 */
1392 collapseForm: function( params ) {
1393 this.toggleForm( false, params );
1394 },
1395
1396 /**
1397 * Expand or collapse the menu item control.
1398 *
1399 * @since 4.5.0 Added params.completeCallback.
1400 *
1401 * @param {boolean} [showOrHide] - If not supplied, will be inverse of current visibility
1402 * @param {Object} [params] - Optional params.
1403 * @param {Function} [params.completeCallback] - Function to call when the form toggle has finished animating.
1404 */
1405 toggleForm: function( showOrHide, params ) {
1406 var self = this, $menuitem, $inside, complete;
1407
1408 $menuitem = this.container;
1409 $inside = $menuitem.find( '.menu-item-settings:first' );
1410 if ( 'undefined' === typeof showOrHide ) {
1411 showOrHide = ! $inside.is( ':visible' );
1412 }
1413
1414 // Already expanded or collapsed.
1415 if ( $inside.is( ':visible' ) === showOrHide ) {
1416 if ( params && params.completeCallback ) {
1417 params.completeCallback();
1418 }
1419 return;
1420 }
1421
1422 if ( showOrHide ) {
1423 // Close all other menu item controls before expanding this one.
1424 api.control.each( function( otherControl ) {
1425 if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1426 otherControl.collapseForm();
1427 }
1428 } );
1429
1430 complete = function() {
1431 $menuitem
1432 .removeClass( 'menu-item-edit-inactive' )
1433 .addClass( 'menu-item-edit-active' );
1434 self.container.trigger( 'expanded' );
1435
1436 if ( params && params.completeCallback ) {
1437 params.completeCallback();
1438 }
1439 };
1440
1441 $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'true' );
1442 $inside.slideDown( 'fast', complete );
1443
1444 self.container.trigger( 'expand' );
1445 } else {
1446 complete = function() {
1447 $menuitem
1448 .addClass( 'menu-item-edit-inactive' )
1449 .removeClass( 'menu-item-edit-active' );
1450 self.container.trigger( 'collapsed' );
1451
1452 if ( params && params.completeCallback ) {
1453 params.completeCallback();
1454 }
1455 };
1456
1457 self.container.trigger( 'collapse' );
1458
1459 $menuitem.find( '.item-edit' ).attr( 'aria-expanded', 'false' );
1460 $inside.slideUp( 'fast', complete );
1461 }
1462 },
1463
1464 /**
1465 * Expand the containing menu section, expand the form, and focus on
1466 * the first input in the control.
1467 *
1468 * @since 4.5.0 Added params.completeCallback.
1469 *
1470 * @param {Object} [params] - Params object.
1471 * @param {Function} [params.completeCallback] - Optional callback function when focus has completed.
1472 */
1473 focus: function( params ) {
1474 params = params || {};
1475 var control = this, originalCompleteCallback = params.completeCallback;
1476
1477 control.expandControlSection();
1478
1479 params.completeCallback = function() {
1480 var focusable;
1481
1482 // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583
1483 focusable = control.container.find( '.menu-item-settings' ).find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' );
1484 focusable.first().focus();
1485
1486 if ( originalCompleteCallback ) {
1487 originalCompleteCallback();
1488 }
1489 };
1490
1491 control.expandForm( params );
1492 },
1493
1494 /**
1495 * Move menu item up one in the menu.
1496 */
1497 moveUp: function() {
1498 this._changePosition( -1 );
1499 wp.a11y.speak( api.Menus.data.l10n.movedUp );
1500 },
1501
1502 /**
1503 * Move menu item up one in the menu.
1504 */
1505 moveDown: function() {
1506 this._changePosition( 1 );
1507 wp.a11y.speak( api.Menus.data.l10n.movedDown );
1508 },
1509 /**
1510 * Move menu item and all children up one level of depth.
1511 */
1512 moveLeft: function() {
1513 this._changeDepth( -1 );
1514 wp.a11y.speak( api.Menus.data.l10n.movedLeft );
1515 },
1516
1517 /**
1518 * Move menu item and children one level deeper, as a submenu of the previous item.
1519 */
1520 moveRight: function() {
1521 this._changeDepth( 1 );
1522 wp.a11y.speak( api.Menus.data.l10n.movedRight );
1523 },
1524
1525 /**
1526 * Note that this will trigger a UI update, causing child items to
1527 * move as well and cardinal order class names to be updated.
1528 *
1529 * @private
1530 *
1531 * @param {Number} offset 1|-1
1532 */
1533 _changePosition: function( offset ) {
1534 var control = this,
1535 adjacentSetting,
1536 settingValue = _.clone( control.setting() ),
1537 siblingSettings = [],
1538 realPosition;
1539
1540 if ( 1 !== offset && -1 !== offset ) {
1541 throw new Error( 'Offset changes by 1 are only supported.' );
1542 }
1543
1544 // Skip moving deleted items.
1545 if ( ! control.setting() ) {
1546 return;
1547 }
1548
1549 // Locate the other items under the same parent (siblings).
1550 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1551 if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1552 siblingSettings.push( otherControl.setting );
1553 }
1554 });
1555 siblingSettings.sort(function( a, b ) {
1556 return a().position - b().position;
1557 });
1558
1559 realPosition = _.indexOf( siblingSettings, control.setting );
1560 if ( -1 === realPosition ) {
1561 throw new Error( 'Expected setting to be among siblings.' );
1562 }
1563
1564 // Skip doing anything if the item is already at the edge in the desired direction.
1565 if ( ( realPosition === 0 && offset < 0 ) || ( realPosition === siblingSettings.length - 1 && offset > 0 ) ) {
1566 // @todo Should we allow a menu item to be moved up to break it out of a parent? Adopt with previous or following parent?
1567 return;
1568 }
1569
1570 // Update any adjacent menu item setting to take on this item's position.
1571 adjacentSetting = siblingSettings[ realPosition + offset ];
1572 if ( adjacentSetting ) {
1573 adjacentSetting.set( $.extend(
1574 _.clone( adjacentSetting() ),
1575 {
1576 position: settingValue.position
1577 }
1578 ) );
1579 }
1580
1581 settingValue.position += offset;
1582 control.setting.set( settingValue );
1583 },
1584
1585 /**
1586 * Note that this will trigger a UI update, causing child items to
1587 * move as well and cardinal order class names to be updated.
1588 *
1589 * @private
1590 *
1591 * @param {Number} offset 1|-1
1592 */
1593 _changeDepth: function( offset ) {
1594 if ( 1 !== offset && -1 !== offset ) {
1595 throw new Error( 'Offset changes by 1 are only supported.' );
1596 }
1597 var control = this,
1598 settingValue = _.clone( control.setting() ),
1599 siblingControls = [],
1600 realPosition,
1601 siblingControl,
1602 parentControl;
1603
1604 // Locate the other items under the same parent (siblings).
1605 _( control.getMenuControl().getMenuItemControls() ).each(function( otherControl ) {
1606 if ( otherControl.setting().menu_item_parent === settingValue.menu_item_parent ) {
1607 siblingControls.push( otherControl );
1608 }
1609 });
1610 siblingControls.sort(function( a, b ) {
1611 return a.setting().position - b.setting().position;
1612 });
1613
1614 realPosition = _.indexOf( siblingControls, control );
1615 if ( -1 === realPosition ) {
1616 throw new Error( 'Expected control to be among siblings.' );
1617 }
1618
1619 if ( -1 === offset ) {
1620 // Skip moving left an item that is already at the top level.
1621 if ( ! settingValue.menu_item_parent ) {
1622 return;
1623 }
1624
1625 parentControl = api.control( 'nav_menu_item[' + set