diff --git a/src/js/_enqueues/lib/nav-menu.js b/src/js/_enqueues/lib/nav-menu.js index 79917c8447f1a..ea2561b403bfb 100644 --- a/src/js/_enqueues/lib/nav-menu.js +++ b/src/js/_enqueues/lib/nav-menu.js @@ -34,7 +34,6 @@ menusChanged : false, isRTL: !! ( 'undefined' != typeof isRtl && isRtl ), negateIfRTL: ( 'undefined' != typeof isRtl && isRtl ) ? -1 : 1, - lastSearch: '', // Functions that run on init. init : function() { @@ -1380,70 +1379,176 @@ }, attachQuickSearchListeners : function() { - var searchTimer; - // Prevent form submission. $( '#nav-menu-meta' ).on( 'submit', function( event ) { event.preventDefault(); }); $( '#nav-menu-meta' ).on( 'input', '.quick-search', function() { - var $this = $( this ); + var input = $( this ), + panel = input.parents( '.tabs-panel' ), + checklist = $( '.categorychecklist', panel ), + loadMore = $( '.quick-search-load-more', panel ), + previousState = panel.data( 'quick-search-state' ) || {}, + q = input.val(), + state; + + input.attr( 'autocomplete', 'off' ); + + if ( previousState.query === q ) { + return; + } + + if ( previousState.timer ) { + clearTimeout( previousState.timer ); + } + + state = { + query: q, + page: 0, + hasMore: false, + loading: false, + retry: false + }; + panel.data( 'quick-search-state', state ); - $this.attr( 'autocomplete', 'off' ); + if ( previousState.request ) { + previousState.request.abort(); + } - if ( searchTimer ) { - clearTimeout( searchTimer ); + $( '.spinner', panel ).removeClass( 'is-active' ); + checklist.removeAttr( 'aria-busy' ).empty(); + loadMore + .removeAttr( 'aria-disabled' ) + .removeClass( 'disabled' ) + .prop( 'hidden', true ) + .text( loadMore.data( 'load-more-text' ) ); + + if ( q.length <= 1 ) { + wp.a11y.speak( wp.i18n.__( 'Search results cleared' ) ); + return; } - searchTimer = setTimeout( function() { - api.updateQuickSearchResults( $this ); + state.timer = setTimeout( function() { + state.timer = null; + api.updateQuickSearchResults( input ); }, 500 ); - }).on( 'blur', '.quick-search', function() { - api.lastSearch = ''; + }).on( 'click', '.quick-search-load-more', function() { + var panel = $( this ).parents( '.tabs-panel' ), + state = panel.data( 'quick-search-state' ); + + api.updateQuickSearchResults( panel.find( '.quick-search' ), ! state || ! state.retry ); }); }, - updateQuickSearchResults : function(input) { - var panel, params, - minSearchLength = 1, + /** + * Updates menu quick search results. + * + * @param {jQuery} input Search input. + * @param {boolean} append Whether to append the next page of results. + */ + updateQuickSearchResults : function( input, append ) { + var params, request, previousState, firstNewItem, loadMoreHadFocus, q = input.val(), - pageSearchChecklist = $( '#page-search-checklist' ); + panel = input.parents( '.tabs-panel' ), + checklist = $( '.categorychecklist', panel ), + loadMore = $( '.quick-search-load-more', panel ), + state = panel.data( 'quick-search-state' ), + itemCount = checklist.children( 'li' ).length, + paged; + + if ( ! append && ( ! state || state.query !== q ) ) { + previousState = state; + state = { + query: q, + page: 0, + hasMore: false, + loading: false, + retry: false + }; + panel.data( 'quick-search-state', state ); + loadMore + .removeAttr( 'aria-disabled' ) + .removeClass( 'disabled' ) + .prop( 'hidden', true ) + .text( loadMore.data( 'load-more-text' ) ); + + if ( previousState && previousState.timer ) { + clearTimeout( previousState.timer ); + } + if ( previousState && previousState.request ) { + previousState.request.abort(); + } + } - /* - * Avoid a new Ajax search when the pressed key (e.g. arrows) - * doesn't change the searched term. - */ - if ( api.lastSearch == q ) { + if ( ! state || state.loading || ( append && ( state.query !== q || ! state.hasMore ) ) ) { return; } - /* - * Reset results when search is less than or equal to - * minimum characters for searched term. - */ - if ( q.length <= minSearchLength ) { - pageSearchChecklist.empty(); + if ( q.length <= 1 ) { + checklist.removeAttr( 'aria-busy' ).empty(); + $( '.spinner', panel ).removeClass( 'is-active' ); wp.a11y.speak( wp.i18n.__( 'Search results cleared' ) ); return; } - api.lastSearch = q; - - panel = input.parents('.tabs-panel'); + paged = append ? state.page + 1 : 1; params = { 'action': 'menu-quick-search', 'response-format': 'markup', - 'menu': $('#menu').val(), - 'menu-settings-column-nonce': $('#menu-settings-column-nonce').val(), + 'menu': $( '#menu' ).val(), + 'menu-settings-column-nonce': $( '#menu-settings-column-nonce' ).val(), 'q': q, - 'type': input.attr('name') + 'type': input.attr( 'name' ), + 'paged': paged }; + state.loading = true; + state.retry = false; + checklist.attr( 'aria-busy', 'true' ); $( '.spinner', panel ).addClass( 'is-active' ); + loadMore + .attr( 'aria-disabled', 'true' ) + .addClass( 'disabled' ) + .text( loadMore.data( 'load-more-text' ) ); - $.post( ajaxurl, params, function(menuMarkup) { - api.processQuickSearchQueryResponse(menuMarkup, params, panel); + request = $.post( ajaxurl, params ); + state.request = request; + + request.done( function( menuMarkup, textStatus, response ) { + var added; + + if ( panel.data( 'quick-search-state' ) !== state || state.request !== request ) { + return; + } + + loadMoreHadFocus = loadMore.is( ':focus' ); + added = api.processQuickSearchQueryResponse( menuMarkup, params, panel, append ); + state.page = paged; + state.hasMore = '1' === response.getResponseHeader( 'X-WP-Menu-Quick-Search-Has-More' ); + loadMore.prop( 'hidden', ! state.hasMore ); + + if ( ! state.hasMore && loadMoreHadFocus ) { + firstNewItem = checklist.children( 'li' ).eq( append ? itemCount : 0 ).find( ':input' ).first(); + ( added ? firstNewItem : input ).trigger( 'focus' ); + } + }).fail( function( response, status ) { + if ( 'abort' !== status && panel.data( 'quick-search-state' ) === state ) { + loadMore.prop( 'hidden', false ); + if ( ! append ) { + state.retry = true; + loadMore.text( wp.i18n.__( 'Try again' ) ); + } + wp.a11y.speak( wp.i18n.__( 'An error occurred. Please try again.' ), 'assertive' ); + } + }).always( function() { + if ( panel.data( 'quick-search-state' ) === state && state.request === request ) { + state.loading = false; + state.request = null; + checklist.removeAttr( 'aria-busy' ); + $( '.spinner', panel ).removeClass( 'is-active' ); + loadMore.removeAttr( 'aria-disabled' ).removeClass( 'disabled' ); + } }); }, @@ -1764,64 +1869,84 @@ }, /** - * Process the quick search response into a search result + * Process the quick search response into search results. * - * @param string resp The server response to the query. - * @param object req The request arguments. - * @param jQuery panel The tabs panel we're searching in. + * @param {string} resp The server response to the query. + * @param {Object} req The request arguments. + * @param {jQuery} panel The tabs panel being searched. + * @param {boolean} append Whether to append the results. + * @return {number} Number of results added. */ - processQuickSearchQueryResponse : function(resp, req, panel) { - var matched, newID, - takenIDs = {}, - form = document.getElementById('nav-menu-meta'), - pattern = /menu-item[(\[^]\]*/, - $items = $('
', { text: noResults } ); - li.append( p ); - $('.categorychecklist', panel).empty().append( li ); - $( '.spinner', panel ).removeClass( 'is-active' ); + processQuickSearchQueryResponse : function( resp, req, panel, append ) { + var matched, newID, message, + takenIDs = {}, + form = document.getElementById( 'nav-menu-meta' ), + pattern = /menu-item\[(-?\d+)\]/, + $items = $( '
', { text: message } ) ) + ); wrapper.addClass( 'has-no-menu-item' ); - wp.a11y.speak( noResults, 'assertive' ); - return; + wp.a11y.speak( message, 'assertive' ); + return 0; } - $items.each(function(){ - $item = $(this); + $items.each( function() { + $item = $( this ); // Make a unique DB ID number. - matched = pattern.exec($item.html()); + matched = pattern.exec( $item.html() ); if ( matched && matched[1] ) { newID = matched[1]; - while( form.elements['menu-item[' + newID + '][menu-item-type]'] || takenIDs[ newID ] ) { + while ( form.elements['menu-item[' + newID + '][menu-item-type]'] || takenIDs[ newID ] ) { newID--; } - takenIDs[newID] = true; + takenIDs[ newID ] = true; if ( newID != matched[1] ) { - $item.html( $item.html().replace(new RegExp( - 'menu-item\\[' + matched[1] + '\\]', 'g'), + $item.html( $item.html().replace( new RegExp( + 'menu-item\\[' + matched[1] + '\\]', 'g' ), 'menu-item[' + newID + ']' ) ); } } }); - $('.categorychecklist', panel).html( $items ); - wp.a11y.speak( wp.i18n.sprintf( wp.i18n.__( '%d Search Results Found' ), $items.length ), 'assertive' ); - $( '.spinner', panel ).removeClass( 'is-active' ); + if ( append ) { + checklist.append( $items ); + message = wp.i18n.sprintf( + wp.i18n._n( '%d additional search result loaded.', '%d additional search results loaded.', $items.length ), + $items.length + ); + } else { + checklist.html( $items ); + message = wp.i18n.sprintf( + wp.i18n._n( '%d search result found.', '%d search results found.', $items.length ), + $items.length + ); + } + + wp.a11y.speak( message ); wrapper.removeClass( 'has-no-menu-item' ); if ( selectAll.is( ':checked' ) ) { selectAll.prop( 'checked', false ); } + + return $items.length; }, /** diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php index f26d63d528e78..781079a197834 100644 --- a/src/wp-admin/includes/nav-menu.php +++ b/src/wp-admin/includes/nav-menu.php @@ -26,6 +26,8 @@ function _wp_ajax_menu_quick_search( $request = array() ) { $object_type = $request['object_type'] ?? ''; $query = $request['q'] ?? ''; $response_format = $request['response-format'] ?? ''; + $is_paginated = isset( $request['paged'] ); + $paged = max( 1, absint( $request['paged'] ?? 1 ) ); if ( ! $response_format || ! in_array( $response_format, array( 'json', 'markup' ), true ) ) { $response_format = 'json'; @@ -92,6 +94,11 @@ function _wp_ajax_menu_quick_search( $request = array() ) { 's' => $query, 'search_columns' => array( 'post_title' ), ); + + if ( $is_paginated ) { + $query_args['paged'] = $paged; + } + /** * Filter the menu quick search arguments. * @@ -104,6 +111,7 @@ function _wp_ajax_menu_quick_search( $request = array() ) { * @type boolean $update_post_meta_cache Whether to update post meta cache. Default false. * @type boolean $update_post_term_cache Whether to update post term cache. Default false. * @type int $posts_per_page Number of posts to return. Default 10. + * @type int $paged Current page of results, if requested. * @type string $post_type Type of post to return. * @type string $s Search query. * @type array $search_columns Which post table columns to query. @@ -116,14 +124,53 @@ function _wp_ajax_menu_quick_search( $request = array() ) { $args = array_merge( $args, (array) $post_type_obj->_default_query ); } + if ( $is_paginated ) { + $posts_per_page = (int) ( $args['posts_per_page'] ?? 10 ); + + // Query one extra post to determine whether another page exists. + if ( 0 < $posts_per_page ) { + $args['offset'] = $posts_per_page * ( $paged - 1 ); + $args['posts_per_page'] = $posts_per_page + 1; + } + + $args['_wp_menu_quick_search'] = true; + $orderby_filter = static function ( $orderby, $query ) { + global $wpdb; + + if ( ! $query->get( '_wp_menu_quick_search' ) ) { + return $orderby; + } + + $order = 'ASC' === strtoupper( $query->get( 'order' ) ) ? 'ASC' : 'DESC'; + + return $orderby ? "$orderby, {$wpdb->posts}.ID $order" : "{$wpdb->posts}.ID $order"; + }; + add_filter( 'posts_orderby', $orderby_filter, 10, 2 ); + } + $search_results_query = new WP_Query( $args ); - if ( ! $search_results_query->have_posts() ) { - return; + + if ( $is_paginated ) { + remove_filter( 'posts_orderby', $orderby_filter ); + } + + $posts = $search_results_query->posts; + $has_more = false; + + if ( $is_paginated && 0 < $posts_per_page ) { + $has_more = count( $posts ) > $posts_per_page; + $posts = array_slice( $posts, 0, $posts_per_page ); + } + + if ( $is_paginated && ! headers_sent() ) { + header( 'X-WP-Menu-Quick-Search-Has-More: ' . (int) $has_more ); } - while ( $search_results_query->have_posts() ) { - $post = $search_results_query->next_post(); + if ( empty( $posts ) ) { + return; + } + foreach ( $posts as $post ) { if ( 'markup' === $response_format ) { $var_by_ref = $post->ID; echo walk_nav_menu_tree( @@ -143,15 +190,48 @@ function _wp_ajax_menu_quick_search( $request = array() ) { } } } elseif ( 'taxonomy' === $matches[1] ) { - $terms = get_terms( - array( - 'taxonomy' => $matches[2], - 'name__like' => $query, - 'number' => 10, - 'hide_empty' => false, - ) + $terms_per_page = 10; + $term_query_args = array( + 'taxonomy' => $matches[2], + 'name__like' => $query, + 'number' => $terms_per_page, + 'hide_empty' => false, ); + if ( $is_paginated ) { + // Query one extra term to determine whether another page exists. + $term_query_args['number'] = $terms_per_page + 1; + $term_query_args['offset'] = $terms_per_page * ( $paged - 1 ); + $term_query_args['_wp_menu_quick_search'] = true; + $get_terms_orderby_filter = static function ( $orderby, $args ) { + if ( empty( $args['_wp_menu_quick_search'] ) ) { + return $orderby; + } + + $order = 'DESC' === strtoupper( $args['order'] ) ? 'DESC' : 'ASC'; + + return $orderby ? "$orderby $order, t.term_id" : 't.term_id'; + }; + add_filter( 'get_terms_orderby', $get_terms_orderby_filter, 10, 2 ); + } + + $terms = get_terms( $term_query_args ); + + if ( $is_paginated ) { + remove_filter( 'get_terms_orderby', $get_terms_orderby_filter ); + } + + $has_more = false; + + if ( $is_paginated && ! is_wp_error( $terms ) ) { + $has_more = count( $terms ) > $terms_per_page; + $terms = array_slice( $terms, 0, $terms_per_page ); + } + + if ( $is_paginated && ! headers_sent() ) { + header( 'X-WP-Menu-Quick-Search-Has-More: ' . (int) $has_more ); + } + if ( empty( $terms ) || is_wp_error( $terms ) ) { return; } @@ -737,6 +817,12 @@ class="categorychecklist form-no-clear"