From 8589fff38fc8faf094d1a5b22c2ac8227d33709c Mon Sep 17 00:00:00 2001 From: Robert Felty Date: Sat, 2 May 2026 23:55:23 -0400 Subject: [PATCH 1/4] Adding auto-complete --- projects/packages/search/src/class-helper.php | 1 + .../search/src/class-rest-controller.php | 15 +- .../components/module-control/index.jsx | 57 +++++ .../components/pages/dashboard-page.jsx | 4 + .../store/actions/jetpack-settings.js | 5 +- .../store/selectors/jetpack-settings.js | 1 + .../instant-search/components/search-app.jsx | 2 + .../instant-search/components/search-box.jsx | 2 + .../instant-search/components/search-form.jsx | 206 ++++++++++++++---- .../components/search-results.jsx | 2 + .../components/search-suggestions.jsx | 44 ++++ .../components/search-suggestions.scss | 29 +++ .../hooks/use-search-suggestions.js | 75 +++++++ 13 files changed, 396 insertions(+), 47 deletions(-) create mode 100644 projects/packages/search/src/instant-search/components/search-suggestions.jsx create mode 100644 projects/packages/search/src/instant-search/components/search-suggestions.scss create mode 100644 projects/packages/search/src/instant-search/hooks/use-search-suggestions.js diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 280f348e7aa2..42e60a8cbb1f 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -928,6 +928,7 @@ public static function generate_initial_javascript_state() { 'locale' => str_replace( '_', '-', self::is_valid_locale( get_locale() ) ? get_locale() : 'en_US' ), 'postsPerPage' => $posts_per_page, 'siteId' => self::get_wpcom_site_id(), + 'searchSuggestionsEnabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ), 'postTypes' => $post_type_labels, 'webpackPublicPath' => plugins_url( '/build/instant-search/', __DIR__ ), 'isPhotonEnabled' => ( $is_wpcom || $is_jetpack_photon_enabled ) && ! $is_private_site, diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php index 0b5b144bb3b6..e23c56df103f 100644 --- a/projects/packages/search/src/class-rest-controller.php +++ b/projects/packages/search/src/class-rest-controller.php @@ -246,8 +246,9 @@ public function update_settings( $request ) { $module_active = isset( $request_body['module_active'] ) ? (bool) $request_body['module_active'] : null; $instant_search_enabled = isset( $request_body['instant_search_enabled'] ) ? (bool) $request_body['instant_search_enabled'] : null; $swap_classic_to_inline_search = isset( $request_body['swap_classic_to_inline_search'] ) ? (bool) $request_body['swap_classic_to_inline_search'] : null; + $search_suggestions_enabled = isset( $request_body['search_suggestions_enabled'] ) ? (bool) $request_body['search_suggestions_enabled'] : null; - $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search ); + $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $search_suggestions_enabled ); if ( is_wp_error( $error ) ) { return $error; @@ -277,6 +278,10 @@ public function update_settings( $request ) { $this->search_module->update_swap_classic_to_inline_search( $swap_classic_to_inline_search ); } + if ( $search_suggestions_enabled !== null ) { + update_option( 'jetpack_search_suggestions_enabled', $search_suggestions_enabled ); + } + if ( ! empty( $errors ) ) { return new WP_Error( 'some_updated', @@ -301,12 +306,17 @@ public function update_settings( $request ) { * @param boolean $module_active - Module status. * @param boolean $instant_search_enabled - Instant Search status. * @param boolean $swap_classic_to_inline_search - New inline search status. + * @param boolean $search_suggestions_enabled - New search suggestions status. */ - protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search ) { + protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $search_suggestions_enabled = null ) { if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search !== null ) { // allow updating 'swap_classic_to_inline_search' without updating/validating other settings. return true; } + if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search === null && $search_suggestions_enabled !== null ) { + // allow updating 'search_suggestions_enabled' without updating/validating other settings. + return true; + } if ( ( true === $instant_search_enabled && false === $module_active ) || ( $module_active === null && $instant_search_enabled === null ) ) { return new WP_Error( 'rest_invalid_arguments', @@ -326,6 +336,7 @@ public function get_settings() { 'module_active' => $this->search_module->is_active(), 'instant_search_enabled' => $this->search_module->is_instant_search_enabled(), 'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(), + 'search_suggestions_enabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ), ) ); } diff --git a/projects/packages/search/src/dashboard/components/module-control/index.jsx b/projects/packages/search/src/dashboard/components/module-control/index.jsx index bd17a75bc586..eb6c45b8ea3f 100644 --- a/projects/packages/search/src/dashboard/components/module-control/index.jsx +++ b/projects/packages/search/src/dashboard/components/module-control/index.jsx @@ -43,6 +43,7 @@ const WIDGETS_EDITOR_URL = 'widgets.php'; * @param {boolean} props.supportsInstantSearch - true if site has plan that supports Instant Search. * @param {boolean} props.isTogglingModule - true if toggling Search module. * @param {boolean} props.isTogglingInstantSearch - true if toggling Instant Search option. + * @param {boolean} props.isSearchSuggestionsEnabled - true if search suggestions (autocomplete) is enabled. * @return {import('react').Component} Search settings component. */ export default function SearchModuleControl( { @@ -59,6 +60,7 @@ export default function SearchModuleControl( { supportsInstantSearch, isTogglingModule, isTogglingInstantSearch, + isSearchSuggestionsEnabled, } ) { const { isUserConnected } = useConnection( { redirectUri: 'admin.php?page=jetpack-search', @@ -108,6 +110,15 @@ export default function SearchModuleControl( { analytics.tracks.recordEvent( 'jetpack_search_instant_toggle', newOption ); }, [ supportsInstantSearch, isInstantSearchEnabled, updateOptions, isDisabledFromOverLimit ] ); + const toggleSearchSuggestions = useCallback( () => { + if ( isDisabledFromOverLimit ) { + return; + } + const newOption = { search_suggestions_enabled: ! isSearchSuggestionsEnabled }; + updateOptions( newOption ); + analytics.tracks.recordEvent( 'jetpack_search_suggestions_toggle', newOption ); + }, [ isSearchSuggestionsEnabled, updateOptions, isDisabledFromOverLimit ] ); + return (
+ + { supportsInstantSearch && isInstantSearchEnabled && ( + + ) }
@@ -297,3 +318,39 @@ const SearchToggle = ( { ); }; + +const SearchSuggestionsToggle = ( { + isSearchSuggestionsEnabled, + isInstantSearchEnabled, + isSavingEitherOption, + isDisabledFromOverLimit, + toggleSearchSuggestions, +} ) => { + const isToggleDisabled = + isSavingEitherOption || ! isInstantSearchEnabled || isDisabledFromOverLimit; + + return ( +
+
+ +
+
+
+

+ { __( + 'Show autocomplete query suggestions as visitors type, instead of updating search results on every keystroke.', + 'jetpack-search-pkg' + ) } +

+
+
+
+ ); +}; diff --git a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx index ac8ece42b301..62caf9f7c2d7 100644 --- a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx +++ b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx @@ -86,6 +86,9 @@ export default function DashboardPage( { isLoading = false } ) { const isTogglingInstantSearch = useSelect( select => select( STORE_ID ).isTogglingInstantSearch() ); + const isSearchSuggestionsEnabled = useSelect( select => + select( STORE_ID ).isSearchSuggestionsEnabled() + ); // Record Meter data const tierMaximumRecords = useSelect( select => select( STORE_ID ).getTierMaximumRecords() ); @@ -182,6 +185,7 @@ export default function DashboardPage( { isLoading = false } ) { isSavingEitherOption={ isSavingEitherOption } isTogglingModule={ isTogglingModule } isTogglingInstantSearch={ isTogglingInstantSearch } + isSearchSuggestionsEnabled={ isSearchSuggestionsEnabled } /> k === 'module_active' || k === 'instant_search_enabled' + ( [ k ] ) => + k === 'module_active' || + k === 'instant_search_enabled' || + k === 'search_suggestions_enabled' ) ); yield setJetpackSettings( oldSettings ); diff --git a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js index 6cd876c4f67f..8098584722f3 100644 --- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js +++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js @@ -2,6 +2,7 @@ const jetpackSettingSelectors = { getSearchModuleStatus: state => state.jetpackSettings, isModuleEnabled: state => state.jetpackSettings.module_active, isInstantSearchEnabled: state => state.jetpackSettings.instant_search_enabled, + isSearchSuggestionsEnabled: state => !! state.jetpackSettings.search_suggestions_enabled, isUpdatingJetpackSettings: state => state.jetpackSettings.is_updating, isTogglingModule: state => state.jetpackSettings.is_toggling_module, isTogglingInstantSearch: state => state.jetpackSettings.is_toggling_instant_search, diff --git a/projects/packages/search/src/instant-search/components/search-app.jsx b/projects/packages/search/src/instant-search/components/search-app.jsx index 63c2fa058c77..5e898506e6f2 100644 --- a/projects/packages/search/src/instant-search/components/search-app.jsx +++ b/projects/packages/search/src/instant-search/components/search-app.jsx @@ -311,6 +311,8 @@ class SearchApp extends Component { enableFallbackImage={ this.state.overlayOptions.enableFallbackImage } fallbackImageUrl={ this.state.overlayOptions.fallbackImageUrl } showProductPrice={ this.state.overlayOptions.enableProductPrice } + suggestionsEnabled={ !! this.props.options.searchSuggestionsEnabled } + siteId={ this.props.options.siteId } /> , document.body diff --git a/projects/packages/search/src/instant-search/components/search-box.jsx b/projects/packages/search/src/instant-search/components/search-box.jsx index 9fc479977aa8..7c97dabbeb72 100644 --- a/projects/packages/search/src/instant-search/components/search-box.jsx +++ b/projects/packages/search/src/instant-search/components/search-box.jsx @@ -43,6 +43,8 @@ const SearchBox = forwardRef( ( props, ref ) => { // IE11 will immediately fire an onChange event when the placeholder contains a unicode character. // Ensure that the search application is visible before invoking the onChange callback to guard against this. onChange={ props.isVisible ? props.onChange : null } + onKeyDown={ props.onKeyDown } + onBlur={ props.onBlur } ref={ inputRef } placeholder={ __( 'Search…', 'jetpack-search-pkg' ) } type="search" diff --git a/projects/packages/search/src/instant-search/components/search-form.jsx b/projects/packages/search/src/instant-search/components/search-form.jsx index 0a07f4f7a2ba..5b26ef9526a0 100644 --- a/projects/packages/search/src/instant-search/components/search-form.jsx +++ b/projects/packages/search/src/instant-search/components/search-form.jsx @@ -1,50 +1,168 @@ import * as React from 'react'; -import { Component, createRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import useSearchSuggestions from '../hooks/use-search-suggestions'; import SearchBox from './search-box'; +import SearchSuggestions from './search-suggestions'; -const noop = event => event.preventDefault(); - -class SearchForm extends Component { - constructor( props ) { - super( props ); - this.searchInputRef = createRef(); - } - - onClear = () => this.props.onChangeSearch( '' ); - onChangeSearch = event => { - // Safari's "Use advanced tracking and fingerprinting protection" privacy setting - // can block access to event.currentTarget.value, returning empty/undefined. - // In such cases, fall back to reading the value directly from the input element via ref. - let value; - try { - value = event.currentTarget.value; - // Check if the value is actually accessible (Safari may return empty string when blocked) - if ( value === undefined || value === null ) { - throw new Error( 'Event value blocked by browser privacy settings' ); - } - } catch { - // Fallback to ref when event.currentTarget.value is blocked - value = this.searchInputRef.current?.value ?? ''; +/** + * Search form with optional autocomplete suggestions dropdown. + * + * @param {object} props - Component props. + * @param {string} props.searchQuery - Committed search query (from Redux). + * @param {Function} props.onChangeSearch - Callback to commit a new query. + * @param {boolean} props.isVisible - Whether the overlay is visible. + * @param {string} props.className - Optional CSS class for the form element. + * @param {boolean} props.suggestionsEnabled - When true, show autocomplete dropdown instead of search-as-you-type. + * @param {string} props.siteId - Site ID used for the suggestions API. + * @return {React.ReactElement} The search form. + */ +export default function SearchForm( { + searchQuery, + onChangeSearch, + isVisible, + className, + suggestionsEnabled = false, + siteId = null, +} ) { + const searchInputRef = useRef( null ); + + // Local input value used only in suggestions mode. + const [ localQuery, setLocalQuery ] = useState( searchQuery ); + const [ showSuggestions, setShowSuggestions ] = useState( false ); + const [ activeIndex, setActiveIndex ] = useState( -1 ); + + // Keep localQuery in sync when the committed query changes externally + // (e.g. user navigates back, query cleared from outside). + useEffect( () => { + setLocalQuery( searchQuery ); + }, [ searchQuery ] ); + + const { suggestions } = useSearchSuggestions( { + query: suggestionsEnabled ? localQuery : '', + siteId, + enabled: suggestionsEnabled, + } ); + + const onClear = useCallback( () => { + if ( suggestionsEnabled ) { + setLocalQuery( '' ); + setShowSuggestions( false ); + setActiveIndex( -1 ); } - this.props.onChangeSearch( value ); - }; - - render() { - return ( -
-
- { + // Safari's "Use advanced tracking and fingerprinting protection" privacy setting + // can block access to event.currentTarget.value, returning empty/undefined. + // In such cases, fall back to reading the value directly from the input element via ref. + let value; + try { + value = event.currentTarget.value; + if ( value === undefined || value === null ) { + throw new Error( 'Event value blocked by browser privacy settings' ); + } + } catch { + value = searchInputRef.current?.value ?? ''; + } + + if ( suggestionsEnabled ) { + setLocalQuery( value ); + setShowSuggestions( value.length >= 2 ); + setActiveIndex( -1 ); + } else { + onChangeSearch( value ); + } + }, + [ suggestionsEnabled, onChangeSearch ] + ); + + const handleSelectSuggestion = useCallback( + suggestion => { + setLocalQuery( suggestion ); + setShowSuggestions( false ); + setActiveIndex( -1 ); + onChangeSearch( suggestion ); + }, + [ onChangeSearch ] + ); + + const handleKeyDown = useCallback( + event => { + if ( ! suggestionsEnabled ) { + return; + } + const count = suggestions.length; + switch ( event.key ) { + case 'ArrowDown': + event.preventDefault(); + setShowSuggestions( true ); + setActiveIndex( i => ( i < count - 1 ? i + 1 : i ) ); + break; + case 'ArrowUp': + event.preventDefault(); + setActiveIndex( i => ( i > 0 ? i - 1 : -1 ) ); + break; + case 'Enter': + if ( showSuggestions && activeIndex >= 0 && activeIndex < count ) { + event.preventDefault(); + handleSelectSuggestion( suggestions[ activeIndex ] ); + } else if ( showSuggestions ) { + // Commit the typed query and run search. + setShowSuggestions( false ); + onChangeSearch( localQuery ); + } + break; + case 'Escape': + setShowSuggestions( false ); + setActiveIndex( -1 ); + break; + default: + break; + } + }, + [ suggestionsEnabled, suggestions, showSuggestions, activeIndex, localQuery, handleSelectSuggestion, onChangeSearch ] + ); + + const handleBlur = useCallback( () => { + // Small delay so a click on a suggestion fires before the list is removed. + setTimeout( () => { + setShowSuggestions( false ); + setActiveIndex( -1 ); + }, 150 ); + }, [] ); + + const noop = event => event.preventDefault(); + const displayQuery = suggestionsEnabled ? localQuery : searchQuery; + + return ( + +
+ + { suggestionsEnabled && showSuggestions && suggestions.length > 0 && ( + -
- - ); - } + ) } +
+ + ); } - -export default SearchForm; diff --git a/projects/packages/search/src/instant-search/components/search-results.jsx b/projects/packages/search/src/instant-search/components/search-results.jsx index 16962f9a57dd..ca09940038ef 100644 --- a/projects/packages/search/src/instant-search/components/search-results.jsx +++ b/projects/packages/search/src/instant-search/components/search-results.jsx @@ -252,6 +252,8 @@ class SearchResults extends Component { isVisible={ this.props.isVisible } onChangeSearch={ this.props.onChangeSearch } searchQuery={ this.props.searchQuery } + suggestionsEnabled={ this.props.suggestionsEnabled } + siteId={ this.props.siteId } />