diff --git a/projects/packages/search/changelog/jps3-search-suggestions b/projects/packages/search/changelog/jps3-search-suggestions
new file mode 100644
index 000000000000..71a040378ae6
--- /dev/null
+++ b/projects/packages/search/changelog/jps3-search-suggestions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add auto-complete search suggestions feature
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 (
-
+ );
}
-
-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 }
/>