/* global _wpCustomizeNavMenusSettings, wpNavMenu, console */ ( function( api, wp, $ ) { 'use strict'; /** * Set up wpNavMenu for drag and drop. */ wpNavMenu.originalInit = wpNavMenu.init; wpNavMenu.options.menuItemDepthPerLevel = 20; wpNavMenu.options.sortableItems = '> .customize-control-nav_menu_item'; wpNavMenu.options.targetTolerance = 10; wpNavMenu.init = function() { this.jQueryExtensions(); }; api.Menus = api.Menus || {}; // Link settings. api.Menus.data = { nonce: '', itemTypes: [], l10n: {}, menuItemTransport: 'postMessage', phpIntMax: 0, defaultSettingValues: { nav_menu: {}, nav_menu_item: {} } }; if ( 'undefined' !== typeof _wpCustomizeNavMenusSettings ) { $.extend( api.Menus.data, _wpCustomizeNavMenusSettings ); } /** * Newly-created Nav Menus and Nav Menu Items have negative integer IDs which * serve as placeholders until Save & Publish happens. * * @return {number} */ api.Menus.generatePlaceholderAutoIncrementId = function() { return -Math.ceil( api.Menus.data.phpIntMax * Math.random() ); }; /** * wp.customize.Menus.AvailableItemModel * * A single available menu item model. See PHP's WP_Customize_Nav_Menu_Item_Setting class. * * @constructor * @augments Backbone.Model */ api.Menus.AvailableItemModel = Backbone.Model.extend( $.extend( { id: null // This is only used by Backbone. }, api.Menus.data.defaultSettingValues.nav_menu_item ) ); /** * wp.customize.Menus.AvailableItemCollection * * Collection for available menu item models. * * @constructor * @augments Backbone.Model */ api.Menus.AvailableItemCollection = Backbone.Collection.extend({ model: api.Menus.AvailableItemModel, sort_key: 'order', comparator: function( item ) { return -item.get( this.sort_key ); }, sortByField: function( fieldName ) { this.sort_key = fieldName; this.sort(); } }); api.Menus.availableMenuItems = new api.Menus.AvailableItemCollection( api.Menus.data.availableMenuItems ); /** * wp.customize.Menus.AvailableMenuItemsPanelView * * View class for the available menu items panel. * * @constructor * @augments wp.Backbone.View * @augments Backbone.View */ api.Menus.AvailableMenuItemsPanelView = wp.Backbone.View.extend({ el: '#available-menu-items', events: { 'input #menu-items-search': 'debounceSearch', 'keyup #menu-items-search': 'debounceSearch', 'click #menu-items-search': 'debounceSearch', 'focus .menu-item-tpl': 'focus', 'click .menu-item-tpl': '_submit', 'click #custom-menu-item-submit': '_submitLink', 'keypress #custom-menu-item-name': '_submitLink', 'keydown': 'keyboardAccessible' }, // Cache current selected menu item. selected: null, // Cache menu control that opened the panel. currentMenuControl: null, debounceSearch: null, $search: null, searchTerm: '', rendered: false, pages: {}, sectionContent: '', loading: false, initialize: function() { var self = this; this.$search = $( '#menu-items-search' ); this.sectionContent = this.$el.find( '.accordion-section-content' ); this.debounceSearch = _.debounce( self.search, 500 ); _.bindAll( this, 'close' ); // If the available menu items panel is open and the customize controls are // interacted with (other than an item being deleted), then close the // available menu items panel. Also close on back button click. $( '#customize-controls, .customize-section-back' ).on( 'click keydown', function( e ) { var isDeleteBtn = $( e.target ).is( '.item-delete, .item-delete *' ), isAddNewBtn = $( e.target ).is( '.add-new-menu-item, .add-new-menu-item *' ); if ( $( 'body' ).hasClass( 'adding-menu-items' ) && ! isDeleteBtn && ! isAddNewBtn ) { self.close(); } } ); this.$el.on( 'input', '#custom-menu-item-name.invalid, #custom-menu-item-url.invalid', function() { $( this ).removeClass( 'invalid' ); }); // Load available items if it looks like we'll need them. api.panel( 'nav_menus' ).container.bind( 'expanded', function() { if ( ! self.rendered ) { self.initList(); self.rendered = true; } }); // Load more items. this.sectionContent.scroll( function() { var totalHeight = self.$el.find( '.accordion-section.open .accordion-section-content' ).prop( 'scrollHeight' ), visibleHeight = self.$el.find( '.accordion-section.open' ).height(); if ( ! self.loading && $( this ).scrollTop() > 3 / 4 * totalHeight - visibleHeight ) { var type = $( this ).data( 'type' ), object = $( this ).data( 'object' ); if ( 'search' === type ) { if ( self.searchTerm ) { self.doSearch( self.pages.search ); } } else { self.loadItems( type, object ); } } }); // Close the panel if the URL in the preview changes api.previewer.bind( 'url', this.close ); }, // Search input change handler. search: function( event ) { var $searchSection = $( '#available-menu-items-search' ), $openSections = $( '#available-menu-items .accordion-section.open' ); if ( ! event ) { return; } // Manual accordion-opening behavior. if ( this.searchTerm && ! $searchSection.hasClass( 'open' ) ) { $openSections.find( '.accordion-section-content' ).slideUp( 'fast' ); $searchSection.find( '.accordion-section-content' ).slideDown( 'fast' ); $openSections.find( '[aria-expanded]' ).first().attr( 'aria-expanded', 'false' ); $openSections.removeClass( 'open' ); $searchSection.addClass( 'open' ); } if ( '' === event.target.value ) { $searchSection.removeClass( 'open' ); } if ( this.searchTerm === event.target.value ) { return; } this.searchTerm = event.target.value; this.pages.search = 1; this.doSearch( 1 ); }, // Get search results. doSearch: function( page ) { var self = this, params, $section = $( '#available-menu-items-search' ), $content = $section.find( '.accordion-section-content' ), itemTemplate = wp.template( 'available-menu-item' ); if ( self.currentRequest ) { self.currentRequest.abort(); } if ( page < 0 ) { return; } else if ( page > 1 ) { $section.addClass( 'loading-more' ); $content.attr( 'aria-busy', 'true' ); wp.a11y.speak( api.Menus.data.l10n.itemsLoadingMore ); } else if ( '' === self.searchTerm ) { $content.html( '' ); wp.a11y.speak( '' ); return; } $section.addClass( 'loading' ); self.loading = true; params = { 'customize-menus-nonce': api.Menus.data.nonce, 'wp_customize': 'on', 'search': self.searchTerm, 'page': page }; self.currentRequest = wp.ajax.post( 'search-available-menu-items-customizer', params ); self.currentRequest.done(function( data ) { var items; if ( 1 === page ) { // Clear previous results as it's a new search. $content.empty(); } $section.removeClass( 'loading loading-more' ); $content.attr( 'aria-busy', 'false' ); $section.addClass( 'open' ); self.loading = false; items = new api.Menus.AvailableItemCollection( data.items ); self.collection.add( items.models ); items.each( function( menuItem ) { $content.append( itemTemplate( menuItem.attributes ) ); } ); if ( 20 > items.length ) { self.pages.search = -1; // Up to 20 posts and 20 terms in results, if <20, no more results for either. } else { self.pages.search = self.pages.search + 1; } if ( items && page > 1 ) { wp.a11y.speak( api.Menus.data.l10n.itemsFoundMore.replace( '%d', items.length ) ); } else if ( items && page === 1 ) { wp.a11y.speak( api.Menus.data.l10n.itemsFound.replace( '%d', items.length ) ); } }); self.currentRequest.fail(function( data ) { // data.message may be undefined, for example when typing slow and the request is aborted. if ( data.message ) { $content.empty().append( $( '

' ).text( data.message ) ); wp.a11y.speak( data.message ); } self.pages.search = -1; }); self.currentRequest.always(function() { $section.removeClass( 'loading loading-more' ); $content.attr( 'aria-busy', 'false' ); self.loading = false; self.currentRequest = null; }); }, // Render the individual items. initList: function() { var self = this; // Render the template for each item by type. _.each( api.Menus.data.itemTypes, function( itemType ) { self.pages[ itemType.type + ':' + itemType.object ] = 0; self.loadItems( itemType.type, itemType.object ); // @todo we need to combine these Ajax requests. } ); }, // Load available menu items. loadItems: function( type, object ) { var self = this, params, request, itemTemplate, availableMenuItemContainer; itemTemplate = wp.template( 'available-menu-item' ); if ( -1 === self.pages[ type + ':' + object ] ) { return; } availableMenuItemContainer = $( '#available-menu-items-' + type + '-' + object ); availableMenuItemContainer.find( '.accordion-section-title' ).addClass( 'loading' ); self.loading = true; params = { 'customize-menus-nonce': api.Menus.data.nonce, 'wp_customize': 'on', 'type': type, 'object': object, 'page': self.pages[ type + ':' + object ] }; request = wp.ajax.post( 'load-available-menu-items-customizer', params ); request.done(function( data ) { var items, typeInner; items = data.items; if ( 0 === items.length ) { if ( 0 === self.pages[ type + ':' + object ] ) { availableMenuItemContainer .addClass( 'cannot-expand' ) .removeClass( 'loading' ) .find( '.accordion-section-title > button' ) .prop( 'tabIndex', -1 ); } self.pages[ type + ':' + object ] = -1; return; } items = new api.Menus.AvailableItemCollection( items ); // @todo Why is this collection created and then thrown away? self.collection.add( items.models ); typeInner = availableMenuItemContainer.find( '.accordion-section-content' ); items.each(function( menuItem ) { typeInner.append( itemTemplate( menuItem.attributes ) ); }); self.pages[ type + ':' + object ] += 1; }); request.fail(function( data ) { if ( typeof console !== 'undefined' && console.error ) { console.error( data ); } }); request.always(function() { availableMenuItemContainer.find( '.accordion-section-title' ).removeClass( 'loading' ); self.loading = false; }); }, // Adjust the height of each section of items to fit the screen. itemSectionHeight: function() { var sections, totalHeight, accordionHeight, diff; totalHeight = window.innerHeight; sections = this.$el.find( '.accordion-section-content' ); accordionHeight = 46 * ( 1 + sections.length ) - 16; // Magic numbers. diff = totalHeight - accordionHeight; if ( 120 < diff && 290 > diff ) { sections.css( 'max-height', diff ); } else if ( 120 >= diff ) { this.$el.addClass( 'allow-scroll' ); } }, // Highlights a menu item. select: function( menuitemTpl ) { this.selected = $( menuitemTpl ); this.selected.siblings( '.menu-item-tpl' ).removeClass( 'selected' ); this.selected.addClass( 'selected' ); }, // Highlights a menu item on focus. focus: function( event ) { this.select( $( event.currentTarget ) ); }, // Submit handler for keypress and click on menu item. _submit: function( event ) { // Only proceed with keypress if it is Enter or Spacebar if ( 'keypress' === event.type && ( 13 !== event.which && 32 !== event.which ) ) { return; } this.submit( $( event.currentTarget ) ); }, // Adds a selected menu item to the menu. submit: function( menuitemTpl ) { var menuitemId, menu_item; if ( ! menuitemTpl ) { menuitemTpl = this.selected; } if ( ! menuitemTpl || ! this.currentMenuControl ) { return; } this.select( menuitemTpl ); menuitemId = $( this.selected ).data( 'menu-item-id' ); menu_item = this.collection.findWhere( { id: menuitemId } ); if ( ! menu_item ) { return; } this.currentMenuControl.addItemToMenu( menu_item.attributes ); $( menuitemTpl ).find( '.menu-item-handle' ).addClass( 'item-added' ); }, // Submit handler for keypress and click on custom menu item. _submitLink: function( event ) { // Only proceed with keypress if it is Enter. if ( 'keypress' === event.type && 13 !== event.which ) { return; } this.submitLink(); }, // Adds the custom menu item to the menu. submitLink: function() { var menuItem, itemName = $( '#custom-menu-item-name' ), itemUrl = $( '#custom-menu-item-url' ); if ( ! this.currentMenuControl ) { return; } if ( '' === itemName.val() ) { itemName.addClass( 'invalid' ); return; } else if ( '' === itemUrl.val() || 'http://' === itemUrl.val() ) { itemUrl.addClass( 'invalid' ); return; } menuItem = { 'title': itemName.val(), 'url': itemUrl.val(), 'type': 'custom', 'type_label': api.Menus.data.l10n.custom_label, 'object': '' }; this.currentMenuControl.addItemToMenu( menuItem ); // Reset the custom link form. itemUrl.val( 'http://' ); itemName.val( '' ); }, // Opens the panel. open: function( menuControl ) { this.currentMenuControl = menuControl; this.itemSectionHeight(); $( 'body' ).addClass( 'adding-menu-items' ); // Collapse all controls. _( this.currentMenuControl.getMenuItemControls() ).each( function( control ) { control.collapseForm(); } ); this.$el.find( '.selected' ).removeClass( 'selected' ); this.$search.focus(); }, // Closes the panel close: function( options ) { options = options || {}; if ( options.returnFocus && this.currentMenuControl ) { this.currentMenuControl.container.find( '.add-new-menu-item' ).focus(); } this.currentMenuControl = null; this.selected = null; $( 'body' ).removeClass( 'adding-menu-items' ); $( '#available-menu-items .menu-item-handle.item-added' ).removeClass( 'item-added' ); this.$search.val( '' ); }, // Add a few keyboard enhancements to the panel. keyboardAccessible: function( event ) { var isEnter = ( 13 === event.which ), isEsc = ( 27 === event.which ), isBackTab = ( 9 === event.which && event.shiftKey ), isSearchFocused = $( event.target ).is( this.$search ); // If enter pressed but nothing entered, don't do anything if ( isEnter && ! this.$search.val() ) { return; } if ( isSearchFocused && isBackTab ) { this.currentMenuControl.container.find( '.add-new-menu-item' ).focus(); event.preventDefault(); // Avoid additional back-tab. } else if ( isEsc ) { this.close( { returnFocus: true } ); } } }); /** * wp.customize.Menus.MenusPanel * * Customizer panel for menus. This is used only for screen options management. * Note that 'menus' must match the WP_Customize_Menu_Panel::$type. * * @constructor * @augments wp.customize.Panel */ api.Menus.MenusPanel = api.Panel.extend({ attachEvents: function() { api.Panel.prototype.attachEvents.call( this ); var panel = this, panelMeta = panel.container.find( '.panel-meta' ), help = panelMeta.find( '.customize-help-toggle' ), content = panelMeta.find( '.customize-panel-description' ), options = $( '#screen-options-wrap' ), button = panelMeta.find( '.customize-screen-options-toggle' ); button.on( 'click', function() { // Hide description if ( content.not( ':hidden' ) ) { content.slideUp( 'fast' ); help.attr( 'aria-expanded', 'false' ); } if ( 'true' === button.attr( 'aria-expanded' ) ) { button.attr( 'aria-expanded', 'false' ); panelMeta.removeClass( 'open' ); panelMeta.removeClass( 'active-menu-screen-options' ); options.slideUp( 'fast' ); } else { button.attr( 'aria-expanded', 'true' ); panelMeta.addClass( 'open' ); panelMeta.addClass( 'active-menu-screen-options' ); options.slideDown( 'fast' ); } return false; } ); // Help toggle help.on( 'click', function() { if ( 'true' === button.attr( 'aria-expanded' ) ) { button.attr( 'aria-expanded', 'false' ); help.attr( 'aria-expanded', 'true' ); panelMeta.addClass( 'open' ); panelMeta.removeClass( 'active-menu-screen-options' ); options.slideUp( 'fast' ); content.slideDown( 'fast' ); } } ); }, /** * Show/hide/save screen options (columns). From common.js. */ ready: function() { var panel = this; this.container.find( '.hide-column-tog' ).click( function() { var $t = $( this ), column = $t.val(); if ( $t.prop( 'checked' ) ) { panel.checked( column ); } else { panel.unchecked( column ); } panel.saveManageColumnsState(); }); this.container.find( '.hide-column-tog' ).each( function() { var $t = $( this ), column = $t.val(); if ( $t.prop( 'checked' ) ) { panel.checked( column ); } else { panel.unchecked( column ); } }); }, saveManageColumnsState: function() { var hidden = this.hidden(); $.post( wp.ajax.settings.url, { action: 'hidden-columns', hidden: hidden, screenoptionnonce: $( '#screenoptionnonce' ).val(), page: 'nav-menus' }); }, checked: function( column ) { this.container.addClass( 'field-' + column + '-active' ); }, unchecked: function( column ) { this.container.removeClass( 'field-' + column + '-active' ); }, hidden: function() { this.hidden = function() { return $( '.hide-column-tog' ).not( ':checked' ).map( function() { var id = this.id; return id.substring( id, id.length - 5 ); }).get().join( ',' ); }; } } ); /** * wp.customize.Menus.MenuSection * * Customizer section for menus. This is used only for lazy-loading child controls. * Note that 'nav_menu' must match the WP_Customize_Menu_Section::$type. * * @constructor * @augments wp.customize.Section */ api.Menus.MenuSection = api.Section.extend({ /** * @since Menu Customizer 0.3 * * @param {String} id * @param {Object} options */ initialize: function( id, options ) { var section = this; api.Section.prototype.initialize.call( section, id, options ); section.deferred.initSortables = $.Deferred(); }, /** * */ ready: function() { var section = this; if ( 'undefined' === typeof section.params.menu_id ) { throw new Error( 'params.menu_id was not defined' ); } /* * Since newly created sections won't be registered in PHP, we need to prevent the * preview's sending of the activeSections to result in this control * being deactivated when the preview refreshes. So we can hook onto * the setting that has the same ID and its presence can dictate * whether the section is active. */ section.active.validate = function() { if ( ! api.has( section.id ) ) { return false; } return !! api( section.id ).get(); }; section.populateControls(); section.navMenuLocationSettings = {}; section.assignedLocations = new api.Value( [] ); api.each(function( setting, id ) { var matches = id.match( /^nav_menu_locations\[(.+?)]/ ); if ( matches ) { section.navMenuLocationSettings[ matches[1] ] = setting; setting.bind( function() { section.refreshAssignedLocations(); }); } }); section.assignedLocations.bind(function( to ) { section.updateAssignedLocationsInSectionTitle( to ); }); section.refreshAssignedLocations(); api.bind( 'pane-contents-reflowed', function() { // Skip menus that have been removed. if ( ! section.container.parent().length ) { return; } section.container.find( '.menu-item .menu-item-reorder-nav button' ).attr({ 'tabindex': '0', 'aria-hidden': 'false' }); section.container.find( '.menu-item.move-up-disabled .menus-move-up' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); section.container.find( '.menu-item.move-down-disabled .menus-move-down' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); section.container.find( '.menu-item.move-left-disabled .menus-move-left' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); section.container.find( '.menu-item.move-right-disabled .menus-move-right' ).attr({ 'tabindex': '-1', 'aria-hidden': 'true' }); } ); }, populateControls: function() { var section = this, menuNameControlId, menuAutoAddControlId, menuControl, menuNameControl, menuAutoAddControl; // Add the control for managing the menu name. menuNameControlId = section.id + '[name]'; menuNameControl = api.control( menuNameControlId ); if ( ! menuNameControl ) { menuNameControl = new api.controlConstructor.nav_menu_name( menuNameControlId, { params: { type: 'nav_menu_name', content: '
  • ', // @todo core should do this for us; see #30741 label: api.Menus.data.l10n.menuNameLabel, active: true, section: section.id, priority: 0, settings: { 'default': section.id } } } ); api.control.add( menuNameControl.id, menuNameControl ); menuNameControl.active.set( true ); } // Add the menu control. menuControl = api.control( section.id ); if ( ! menuControl ) { menuControl = new api.controlConstructor.nav_menu( section.id, { params: { type: 'nav_menu', content: '
  • ', // @todo core should do this for us; see #30741 section: section.id, priority: 998, active: true, settings: { 'default': section.id }, menu_id: section.params.menu_id } } ); api.control.add( menuControl.id, menuControl ); menuControl.active.set( true ); } // Add the control for managing the menu auto_add. menuAutoAddControlId = section.id + '[auto_add]'; menuAutoAddControl = api.control( menuAutoAddControlId ); if ( ! menuAutoAddControl ) { menuAutoAddControl = new api.controlConstructor.nav_menu_auto_add( menuAutoAddControlId, { params: { type: 'nav_menu_auto_add', content: '
  • ', // @todo core should do this for us label: '', active: true, section: section.id, priority: 999, settings: { 'default': section.id } } } ); api.control.add( menuAutoAddControl.id, menuAutoAddControl ); menuAutoAddControl.active.set( true ); } }, /** * */ refreshAssignedLocations: function() { var section = this, menuTermId = section.params.menu_id, currentAssignedLocations = []; _.each( section.navMenuLocationSettings, function( setting, themeLocation ) { if ( setting() === menuTermId ) { currentAssignedLocations.push( themeLocation ); } }); section.assignedLocations.set( currentAssignedLocations ); }, /** * @param {array} themeLocations */ updateAssignedLocationsInSectionTitle: function( themeLocations ) { var section = this, $title; $title = section.container.find( '.accordion-section-title:first' ); $title.find( '.menu-in-location' ).remove(); _.each( themeLocations, function( themeLocation ) { var $label = $( '' ); $label.text( api.Menus.data.l10n.menuLocation.replace( '%s', themeLocation ) ); $title.append( $label ); }); section.container.toggleClass( 'assigned-to-menu-location', 0 !== themeLocations.length ); }, onChangeExpanded: function( expanded, args ) { var section = this; if ( expanded ) { wpNavMenu.menuList = section.container.find( '.accordion-section-content:first' ); wpNavMenu.targetList = wpNavMenu.menuList; // Add attributes needed by wpNavMenu $( '#menu-to-edit' ).removeAttr( 'id' ); wpNavMenu.menuList.attr( 'id', 'menu-to-edit' ).addClass( 'menu' ); _.each( api.section( section.id ).controls(), function( control ) { if ( 'nav_menu_item' === control.params.type ) { control.actuallyEmbed(); } } ); if ( 'resolved' !== section.deferred.initSortables.state() ) { wpNavMenu.initSortables(); // Depends on menu-to-edit ID being set above. section.deferred.initSortables.resolve( wpNavMenu.menuList ); // Now MenuControl can extend the sortable. // @todo Note that wp.customize.reflowPaneContents() is debounced, so this immediate change will show a slight flicker while priorities get updated. api.control( 'nav_menu[' + String( section.params.menu_id ) + ']' ).reflowMenuItems(); } } api.Section.prototype.onChangeExpanded.call( section, expanded, args ); } }); /** * wp.customize.Menus.NewMenuSection * * Customizer section for new menus. * Note that 'new_menu' must match the WP_Customize_New_Menu_Section::$type. * * @constructor * @augments wp.customize.Section */ api.Menus.NewMenuSection = api.Section.extend({ /** * Add behaviors for the accordion section. * * @since Menu Customizer 0.3 */ attachEvents: function() { var section = this; this.container.on( 'click', '.add-menu-toggle', function() { if ( section.expanded() ) { section.collapse(); } else { section.expand(); } }); }, /** * Update UI to reflect expanded state. * * @since 4.1.0 * * @param {Boolean} expanded */ onChangeExpanded: function( expanded ) { var section = this, button = section.container.find( '.add-menu-toggle' ), content = section.container.find( '.new-menu-section-content' ), customizer = section.container.closest( '.wp-full-overlay-sidebar-content' ); if ( expanded ) { button.addClass( 'open' ); button.attr( 'aria-expanded', 'true' ); content.slideDown( 'fast', function() { customizer.scrollTop( customizer.height() ); }); } else { button.removeClass( 'open' ); button.attr( 'aria-expanded', 'false' ); content.slideUp( 'fast' ); content.find( '.menu-name-field' ).removeClass( 'invalid' ); } } }); /** * wp.customize.Menus.MenuLocationControl * * Customizer control for menu locations (rendered as a