diff --git a/src/theme/SearchBar/index.js b/src/theme/SearchBar/index.js
index 1f8aa19688b..9e49ccc4be5 100644
--- a/src/theme/SearchBar/index.js
+++ b/src/theme/SearchBar/index.js
@@ -1,306 +1,109 @@
-import React, { useCallback, useMemo, useRef, useState } from 'react';
+import React, { useCallback, useMemo, useRef } from 'react';
import { DocSearchButton, useDocSearchKeyboardEvents } from '@docsearch/react';
import Head from '@docusaurus/Head';
-import Link from '@docusaurus/Link';
import { useHistory } from '@docusaurus/router';
-import { isRegexpStringMatch, useSearchLinkCreator } from '@docusaurus/theme-common';
import {
useAlgoliaContextualFacetFilters,
useSearchResultUrlProcessor,
} from '@docusaurus/theme-search-algolia/client';
-import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import { createPortal } from 'react-dom';
import translations from '@theme/SearchTranslations';
-import aa from 'search-insights';
import { useEffect } from 'react';
-import { getGoogleAnalyticsUserIdFromBrowserCookie } from '../../lib/google/google'
import {useAskAI} from '@site/src/hooks/useAskAI'
-
-let DocSearchModal = null;
-let searchContainer = null;
-
-function Hit({ hit, children }) {
- const handleClick = () => {
- if (hit.queryID) {
- aa('clickedObjectIDsAfterSearch', {
- eventName: 'Search Result Clicked',
- index: hit.__autocomplete_indexName,
- queryID: hit.queryID,
- objectIDs: [hit.objectID],
- positions: [hit.index + 1], // algolia indexes from 1
- });
- }
- };
- return {children};
-}
-
-function ResultsFooter({ state, onClose }) {
- const generateSearchPageLink = useSearchLinkCreator();
-
- const handleKapaClick = useCallback(() => {
- onClose(); // Close search modal first
-
- // Use Kapa's official API to open with query
- if (typeof window !== 'undefined' && window.Kapa) {
- window.Kapa('open', {
- query: state.query || '',
- submit: !!state.query
- });
- } else {
- console.warn('Kapa widget not loaded');
- }
- }, [state.query, onClose]);
-
- return (
-
- {/* Kapa AI Button */}
-
-
- {/* Original "See all results" link */}
-
-
- {'See all {count} results'}
-
-
-
- );
-}
-
-function mergeFacetFilters(f1, f2) {
- const normalize = (f) => (typeof f === 'string' ? [f] : f);
- return [...normalize(f1), ...normalize(f2)];
-}
+import { shouldPreventSearchAction, handleSearchKeyboardConflict } from './utils/aiConflictHandler';
+import { initializeSearchAnalytics, createEnhancedSearchClient } from './utils/searchAnalytics';
+import { useDocSearchModal } from './utils/useDocSearchModal';
+import {
+ createSearchParameters,
+ createSearchNavigator,
+ transformSearchItems
+} from './utils/searchConfig';
+import { SearchHit } from './searchHit';
+import { SearchResultsFooter } from './searchResultsFooter';
function DocSearch({ contextualSearch, externalUrlRegex, ...props }) {
const queryIDRef = useRef(null);
const { siteMetadata, i18n: { currentLocale } } = useDocusaurusContext();
const processSearchResultUrl = useSearchResultUrlProcessor();
const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters();
- const configFacetFilters = props.searchParameters?.facetFilters ?? [];
- const facetFilters = contextualSearch
- ? // Merge contextual search filters with config filters
- mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
- : // ... or use config facetFilters
- configFacetFilters;
- // We add clickAnalyics here
- const searchParameters = {
- ...props.searchParameters,
- facetFilters,
- clickAnalytics: true,
- hitsPerPage: 3,
- };
const { isAskAIOpen, currentMode } = useAskAI();
const history = useHistory();
const searchButtonRef = useRef(null);
- const [isOpen, setIsOpen] = useState(false);
- const [initialQuery, setInitialQuery] = useState(undefined);
+
+ // Use the modal management hook
+ const {
+ isOpen,
+ initialQuery,
+ DocSearchModal,
+ searchContainer,
+ onOpen,
+ onClose,
+ onInput,
+ importDocSearchModalIfNeeded
+ } = useDocSearchModal();
+
+ // Configure search parameters
+ const searchParameters = createSearchParameters(props, contextualSearch, contextualSearchFacetFilters);
useEffect(() => {
- if (typeof window !== "undefined") {
- const userToken = getGoogleAnalyticsUserIdFromBrowserCookie('_ga');
- aa('init', {
- appId: props.appId,
- apiKey: props.apiKey,
- });
- aa('setUserToken', userToken);
- }
+ initializeSearchAnalytics(props.appId, props.apiKey);
}, [props.appId, props.apiKey]);
- const importDocSearchModalIfNeeded = useCallback(() => {
- if (DocSearchModal) {
- return Promise.resolve();
- }
- return Promise.all([
- import('@docsearch/react/modal'),
- import('@docsearch/react/style'),
- import('./styles.css'),
- ]).then(([{ DocSearchModal: Modal }]) => {
- DocSearchModal = Modal;
- });
- }, []);
-
- const onOpen = useCallback(() => {
- importDocSearchModalIfNeeded().then(() => {
- // searchContainer is not null here when the modal is already open
- // this check is needed because ctrl + k shortcut was handled by another instance of SearchBar component
- if (searchContainer) {
- return;
- }
-
- searchContainer = document.createElement('div');
- document.body.insertBefore(
- searchContainer,
- document.body.firstChild,
- );
-
- setIsOpen(true);
- });
- }, [importDocSearchModalIfNeeded, setIsOpen]);
-
- const onClose = useCallback(() => {
- setIsOpen(false);
- searchContainer?.remove();
- searchContainer = null;;
- }, [setIsOpen]);
-
- const onInput = useCallback(
- (event) => {
- importDocSearchModalIfNeeded().then(() => {
- setIsOpen(true);
- setInitialQuery(event.key);
- });
- },
- [importDocSearchModalIfNeeded, setIsOpen, setInitialQuery],
+ // Create navigator for handling result clicks
+ const navigator = useMemo(
+ () => createSearchNavigator(history, externalUrlRegex),
+ [history, externalUrlRegex]
);
- const navigator = useRef({
- navigate({ itemUrl }) {
- // Algolia results could contain URL's from other domains which cannot
- // be served through history and should navigate with window.location
- if (isRegexpStringMatch(externalUrlRegex, itemUrl)) {
- window.location.href = itemUrl;
- } else {
- history.push(itemUrl);
- }
- },
- }).current;
-
- const transformItems = useRef((items, state) => {
- return props.transformItems
- ? props.transformItems(items)
- : items.map((item, index) => {
- return {
- ...item,
- url: currentLocale == 'en' ? processSearchResultUrl(item.url) : item.url, //TODO: temporary - all search results to english for now
- // url: processSearchResultUrl(item.url),
- index, // Adding the index property - needed for click metrics
- queryID: queryIDRef.current
- };
- });
- }).current;
+ // Transform search items with metadata
+ const transformItems = useCallback((items, state) => {
+ return transformSearchItems(items, {
+ transformItems: props.transformItems,
+ processSearchResultUrl,
+ currentLocale,
+ queryIDRef
+ });
+ }, [props.transformItems, processSearchResultUrl, currentLocale]);
const resultsFooterComponent = useMemo(
- () =>
- // eslint-disable-next-line react/no-unstable-nested-components
- (footerProps) =>
- ,
- [onClose],
+ () =>
+ // eslint-disable-next-line react/no-unstable-nested-components
+ (footerProps) =>
+ ,
+ [onClose],
);
const transformSearchClient = useCallback((searchClient) => {
- searchClient.addAlgoliaAgent('docusaurus', siteMetadata.docusaurusVersion);
- // Wrap the search function to intercept responses
- const originalSearch = searchClient.search;
- searchClient.search = async (requests) => {
- const response = await originalSearch(requests);
- // Extract queryID from the response
- if (response.results?.length > 0 && response.results[0].queryID) {
- queryIDRef.current = response.results[0].queryID;
- }
- return response;
- };
- return searchClient;
+ return createEnhancedSearchClient(searchClient, siteMetadata.docusaurusVersion, queryIDRef);
}, [siteMetadata.docusaurusVersion]);
const handleOnOpen = useCallback(() => {
console.log('handleOnOpen called', { isAskAIOpen });
- // Only prevent opening if Kapa is open AND user is not in an input field
- if (isAskAIOpen) {
- const activeElement = document.activeElement;
- const isInInputField = activeElement && (
- activeElement.tagName === 'INPUT' ||
- activeElement.tagName === 'TEXTAREA' ||
- activeElement.id === 'kapa-widget-container' ||
- activeElement.contentEditable === 'true' ||
- activeElement.closest('[contenteditable="true"]') ||
- activeElement.closest('#kapa-widget-container')
- );
-
- console.log('handleOnOpen - in input field:', isInInputField);
- if (!isInInputField) {
- console.log('handleOnOpen - preventing search modal');
- return; // Prevent search from opening
- }
+
+ if (shouldPreventSearchAction(isAskAIOpen)) {
+ console.log('handleOnOpen - preventing search modal');
+ return;
}
+
onOpen();
}, [isAskAIOpen, onOpen]);
const handleOnInput = useCallback((event) => {
- // Only prevent input handling if Kapa is open AND user is not in an input field
- if (isAskAIOpen) {
- const activeElement = document.activeElement;
-
- // Check for input fields, with specific check for Kapa's textarea
- const isInInputField = activeElement && (
- activeElement.tagName === 'INPUT' ||
- activeElement.tagName === 'TEXTAREA' ||
- activeElement.id === 'kapa-ask-ai-input' ||
- activeElement.id === 'kapa-widget-container' ||
- activeElement.closest('#kapa-widget-container')
- );
-
- if (!isInInputField) {
- return; // Prevent search input handling
- }
-
- // If we're in an input field, allow normal typing but don't open search modal
- return;
+ if (shouldPreventSearchAction(isAskAIOpen)) {
+ return; // Prevent search input handling
}
onInput(event);
}, [isAskAIOpen, onInput]);
useDocSearchKeyboardEvents({
isOpen,
- onOpen: handleOnOpen, // Use the new callback
+ onOpen: handleOnOpen,
onClose,
- onInput: handleOnInput, // Use the new callback
+ onInput: handleOnInput,
searchButtonRef,
});
+
return (
<>
@@ -333,7 +136,7 @@ function DocSearch({ contextualSearch, externalUrlRegex, ...props }) {
initialQuery={initialQuery}
navigator={navigator}
transformItems={transformItems}
- hitComponent={Hit}
+ hitComponent={SearchHit}
transformSearchClient={transformSearchClient}
{...(props.searchPagePath && {
resultsFooterComponent,
@@ -356,34 +159,7 @@ export default function SearchBar() {
useEffect(() => {
const handleKeyDown = (event) => {
- // Check for "/" key or Cmd/Ctrl+K
- const isSearchShortcut = (
- event.key === '/' ||
- (event.key === 'k' && (event.metaKey || event.ctrlKey))
- );
-
- if (isSearchShortcut) {
- if (isAskAIOpen) {
- const activeElement = document.activeElement;
-
- const isInInputField = activeElement && (
- activeElement.tagName === 'INPUT' ||
- activeElement.tagName === 'TEXTAREA' ||
- activeElement.id === 'kapa-ask-ai-input' ||
- activeElement.id === 'kapa-widget-container' ||
- activeElement.contentEditable === 'true' ||
- activeElement.closest('[contenteditable="true"]') ||
- activeElement.closest('#kapa-widget-container')
- );
-
- if (isInInputField && event.key === '/') {
- event.stopImmediatePropagation();
- } else {
- event.preventDefault();
- event.stopImmediatePropagation();
- }
- }
- }
+ handleSearchKeyboardConflict(event, isAskAIOpen);
};
// Add listener with capture phase to intercept before DocSearch
diff --git a/src/theme/SearchBar/searchConstants.js b/src/theme/SearchBar/searchConstants.js
new file mode 100644
index 00000000000..128d5d7f62f
--- /dev/null
+++ b/src/theme/SearchBar/searchConstants.js
@@ -0,0 +1,102 @@
+/**
+ * Search-related constants and default configurations
+ */
+
+// Default search parameters
+export const DEFAULT_SEARCH_PARAMS = {
+ clickAnalytics: true,
+ hitsPerPage: 3,
+};
+
+// Keyboard shortcuts
+export const SEARCH_SHORTCUTS = {
+ SLASH: '/',
+ CMD_K: 'k'
+};
+
+// Kapa AI selectors and configuration
+export const KAPA_CONFIG = {
+ SELECTORS: {
+ INPUT: '#kapa-ask-ai-input',
+ CONTAINER: '#kapa-widget-container'
+ },
+ WIDGET_CHECK_TIMEOUT: 100, // ms to wait before checking widget availability
+};
+
+// DocSearch modal configuration
+export const DOCSEARCH_CONFIG = {
+ PRECONNECT_DOMAINS: {
+ getAlgoliaUrl: (appId) => `https://${appId}-dsn.algolia.net`
+ },
+ MODAL_CONTAINER_ID: 'docsearch-modal-container',
+ SCROLL_BEHAVIOR: {
+ CAPTURE_INITIAL: true,
+ RESTORE_ON_CLOSE: true
+ }
+};
+
+// Search analytics configuration
+export const ANALYTICS_CONFIG = {
+ EVENT_NAMES: {
+ SEARCH_RESULT_CLICKED: 'Search Result Clicked'
+ },
+ GA_COOKIE_NAME: '_ga',
+ ALGOLIA_INDEX_OFFSET: 1 // Algolia indexes from 1, not 0
+};
+
+// URL processing configuration
+export const URL_CONFIG = {
+ // TODO: temporary - all search results to english for now
+ FORCE_ENGLISH_RESULTS: true,
+ DEFAULT_LOCALE: 'en'
+};
+
+// AI conflict detection selectors
+export const INPUT_FIELD_SELECTORS = [
+ 'INPUT',
+ 'TEXTAREA',
+ '#kapa-ask-ai-input',
+ '#kapa-widget-container',
+ '[contenteditable="true"]'
+];
+
+// Style constants
+export const SEARCH_STYLES = {
+ FOOTER: {
+ CONTAINER: {
+ padding: '12px 16px',
+ borderTop: '1px solid var(--docsearch-modal-shadow)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px'
+ },
+ AI_BUTTON: {
+ BASE: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
+ padding: '12px 16px',
+ backgroundColor: '#5b4cfe',
+ color: 'white',
+ border: 'none',
+ borderRadius: '6px',
+ fontSize: '14px',
+ cursor: 'pointer',
+ fontWeight: 600,
+ transition: 'all 0.2s ease',
+ transform: 'translateY(0)'
+ },
+ HOVER: {
+ backgroundColor: '#4a3dcc',
+ transform: 'translateY(-1px)'
+ }
+ },
+ SEE_ALL_LINK: {
+ textAlign: 'center',
+ fontSize: '13px',
+ color: 'var(--docsearch-muted-color)',
+ textDecoration: 'none'
+ }
+ }
+};
diff --git a/src/theme/SearchBar/searchHit.jsx b/src/theme/SearchBar/searchHit.jsx
new file mode 100644
index 00000000000..9549f6ac9f4
--- /dev/null
+++ b/src/theme/SearchBar/searchHit.jsx
@@ -0,0 +1,29 @@
+import Link from '@docusaurus/Link';
+import { trackSearchResultClick } from './utils/searchAnalytics';
+
+export function SearchHit({ hit, children }) {
+ const handleClick = () => trackSearchResultClick(hit);
+
+ // Extract multiple URL segments after /docs/ and clean them up
+ const segments = hit.url.split('/docs/')[1]?.split('/').filter(Boolean) || [];
+ const breadcrumbs = segments
+ .slice(0, 3) // Take first 3 segments max
+ .map(segment => segment.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
+
+ return (
+
+ {children}
+ {breadcrumbs.length > 0 && (
+
+ {breadcrumbs.join(' › ')}
+
+ )}
+
+ );
+}
diff --git a/src/theme/SearchBar/searchResultsFooter.jsx b/src/theme/SearchBar/searchResultsFooter.jsx
new file mode 100644
index 00000000000..94f479d4d68
--- /dev/null
+++ b/src/theme/SearchBar/searchResultsFooter.jsx
@@ -0,0 +1,62 @@
+import React, { useCallback } from 'react';
+import Link from '@docusaurus/Link';
+import { useSearchLinkCreator } from '@docusaurus/theme-common';
+import Translate from '@docusaurus/Translate';
+import { SEARCH_STYLES } from './searchConstants';
+
+/**
+ * Footer component for search results with AI integration and "see all" link
+ * @param {Object} state - Current search state
+ * @param {Function} onClose - Function to close the search modal
+ */
+export function SearchResultsFooter({ state, onClose }) {
+ const generateSearchPageLink = useSearchLinkCreator();
+
+ const handleKapaClick = useCallback(() => {
+ onClose(); // Close search modal first
+
+ // Use Kapa's official API to open with query
+ if (typeof window !== 'undefined' && window.Kapa) {
+ window.Kapa('open', {
+ query: state.query || '',
+ submit: !!state.query
+ });
+ } else {
+ console.warn('Kapa widget not loaded');
+ }
+ }, [state.query, onClose]);
+
+ return (
+
+ {/* Kapa AI Button */}
+
+
+ {/* Original "See all results" link */}
+
+
+ {'See all {count} results'}
+
+
+
+ );
+}
diff --git a/src/theme/SearchBar/utils/aiConflictHandler.js b/src/theme/SearchBar/utils/aiConflictHandler.js
new file mode 100644
index 00000000000..f9c099faa07
--- /dev/null
+++ b/src/theme/SearchBar/utils/aiConflictHandler.js
@@ -0,0 +1,60 @@
+import { SEARCH_SHORTCUTS, INPUT_FIELD_SELECTORS } from '../searchConstants';
+
+/**
+ * Check if the active element is an input field
+ * @param {Element} activeElement - The currently active DOM element
+ * @returns {boolean} - True if the element is an input field
+ */
+function isInputField(activeElement) {
+ if (!activeElement) return false;
+
+ return INPUT_FIELD_SELECTORS.some(selector => {
+ if (selector.startsWith('#') || selector.startsWith('[')) {
+ return activeElement.matches?.(selector) || activeElement.closest?.(selector);
+ }
+ return activeElement.tagName === selector;
+ }) || activeElement.contentEditable === 'true';
+}
+
+/**
+ * Determines if search actions should be prevented when AI is open
+ * @param {boolean} isAskAIOpen - Whether the AI chat is currently open
+ * @returns {boolean} - True if search action should be prevented
+ */
+export function shouldPreventSearchAction(isAskAIOpen) {
+ if (!isAskAIOpen) return false;
+
+ const activeElement = document.activeElement;
+ return !isInputField(activeElement);
+}
+
+/**
+ * Check if the keyboard event is a search shortcut
+ * @param {KeyboardEvent} event - The keyboard event
+ * @returns {boolean} - True if it's a search shortcut
+ */
+function isSearchShortcut(event) {
+ return (
+ event.key === SEARCH_SHORTCUTS.SLASH ||
+ (event.key === SEARCH_SHORTCUTS.CMD_K && (event.metaKey || event.ctrlKey))
+ );
+}
+
+/**
+ * Handles keyboard shortcuts when AI might be open
+ * @param {KeyboardEvent} event - The keyboard event
+ * @param {boolean} isAskAIOpen - Whether AI is open
+ */
+export function handleSearchKeyboardConflict(event, isAskAIOpen) {
+ if (!isSearchShortcut(event)) return;
+
+ if (shouldPreventSearchAction(isAskAIOpen)) {
+ // Special case: allow "/" in input fields
+ if (event.key === SEARCH_SHORTCUTS.SLASH && !shouldPreventSearchAction(isAskAIOpen)) {
+ event.stopImmediatePropagation();
+ } else {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ }
+}
diff --git a/src/theme/SearchBar/utils/searchAnalytics.js b/src/theme/SearchBar/utils/searchAnalytics.js
new file mode 100644
index 00000000000..a8db13b0092
--- /dev/null
+++ b/src/theme/SearchBar/utils/searchAnalytics.js
@@ -0,0 +1,58 @@
+import aa from 'search-insights';
+import { getGoogleAnalyticsUserIdFromBrowserCookie } from '../../../lib/google/google';
+
+/**
+ * Initialize Algolia search analytics
+ * @param {string} appId - Algolia app ID
+ * @param {string} apiKey - Algolia API key
+ */
+export function initializeSearchAnalytics(appId, apiKey) {
+ if (typeof window === "undefined") return;
+
+ const userToken = getGoogleAnalyticsUserIdFromBrowserCookie('_ga');
+ aa('init', {
+ appId,
+ apiKey,
+ });
+ aa('setUserToken', userToken);
+}
+
+/**
+ * Track when a user clicks on a search result
+ * @param {Object} hit - The search result that was clicked
+ */
+export function trackSearchResultClick(hit) {
+ if (!hit.queryID) return;
+
+ aa('clickedObjectIDsAfterSearch', {
+ eventName: 'Search Result Clicked',
+ index: hit.__autocomplete_indexName,
+ queryID: hit.queryID,
+ objectIDs: [hit.objectID],
+ positions: [hit.index + 1], // algolia indexes from 1
+ });
+}
+
+/**
+ * Creates a search client wrapper that adds Docusaurus agent and intercepts queries
+ * @param {Object} searchClient - The original Algolia search client
+ * @param {string} docusaurusVersion - Version of Docusaurus
+ * @param {React.MutableRefObject} queryIDRef - Ref to store the current query ID
+ * @returns {Object} - Enhanced search client
+ */
+export function createEnhancedSearchClient(searchClient, docusaurusVersion, queryIDRef) {
+ searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);
+
+ // Wrap the search function to intercept responses
+ const originalSearch = searchClient.search;
+ searchClient.search = async (requests) => {
+ const response = await originalSearch(requests);
+ // Extract queryID from the response
+ if (response.results?.length > 0 && response.results[0].queryID) {
+ queryIDRef.current = response.results[0].queryID;
+ }
+ return response;
+ };
+
+ return searchClient;
+}
diff --git a/src/theme/SearchBar/utils/searchConfig.js b/src/theme/SearchBar/utils/searchConfig.js
new file mode 100644
index 00000000000..ee01e00fc80
--- /dev/null
+++ b/src/theme/SearchBar/utils/searchConfig.js
@@ -0,0 +1,76 @@
+import { isRegexpStringMatch } from '@docusaurus/theme-common';
+import { DEFAULT_SEARCH_PARAMS, URL_CONFIG } from '../searchConstants';
+
+/**
+ * Merge facet filters from different sources
+ * @param {string|string[]} f1 - First set of facet filters
+ * @param {string|string[]} f2 - Second set of facet filters
+ * @returns {string[]} - Merged facet filters
+ */
+export function mergeFacetFilters(f1, f2) {
+ const normalize = (f) => (typeof f === 'string' ? [f] : f);
+ return [...normalize(f1), ...normalize(f2)];
+}
+
+/**
+ * Create search parameters configuration
+ * @param {Object} props - Component props
+ * @param {boolean} contextualSearch - Whether to use contextual search
+ * @param {string[]} contextualSearchFacetFilters - Contextual facet filters
+ * @returns {Object} - Configured search parameters
+ */
+export function createSearchParameters(props, contextualSearch, contextualSearchFacetFilters) {
+ const configFacetFilters = props.searchParameters?.facetFilters ?? [];
+ const facetFilters = contextualSearch
+ ? mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
+ : configFacetFilters;
+
+ return {
+ ...props.searchParameters,
+ facetFilters,
+ ...DEFAULT_SEARCH_PARAMS,
+ };
+}
+
+/**
+ * Create navigator for handling search result clicks
+ * @param {Object} history - React router history object
+ * @param {RegExp} externalUrlRegex - Regex to match external URLs
+ * @returns {Object} - Navigator object
+ */
+export function createSearchNavigator(history, externalUrlRegex) {
+ return {
+ navigate({ itemUrl }) {
+ if (isRegexpStringMatch(externalUrlRegex, itemUrl)) {
+ window.location.href = itemUrl;
+ } else {
+ history.push(itemUrl);
+ }
+ },
+ };
+}
+
+/**
+ * Transform search result items with additional metadata
+ * @param {Array} items - Raw search results
+ * @param {Object} options - Transform options
+ * @param {Function} options.transformItems - Custom transform function from props
+ * @param {Function} options.processSearchResultUrl - URL processor function
+ * @param {string} options.currentLocale - Current locale
+ * @param {Object} options.queryIDRef - Ref containing current query ID
+ * @returns {Array} - Transformed search results
+ */
+export function transformSearchItems(items, options) {
+ const { transformItems, processSearchResultUrl, currentLocale, queryIDRef } = options;
+
+ const baseTransform = (items) => items.map((item, index) => ({
+ ...item,
+ url: (URL_CONFIG.FORCE_ENGLISH_RESULTS && currentLocale === URL_CONFIG.DEFAULT_LOCALE)
+ ? processSearchResultUrl(item.url)
+ : item.url,
+ index, // Adding the index property - needed for click metrics
+ queryID: queryIDRef.current
+ }));
+
+ return transformItems ? transformItems(items) : baseTransform(items);
+}
diff --git a/src/theme/SearchBar/utils/useDocSearchModal.js b/src/theme/SearchBar/utils/useDocSearchModal.js
new file mode 100644
index 00000000000..8c42dd28e2b
--- /dev/null
+++ b/src/theme/SearchBar/utils/useDocSearchModal.js
@@ -0,0 +1,82 @@
+import { useState, useCallback } from 'react';
+
+// Keep these as module-level variables since they need to persist across component unmounts
+let DocSearchModal = null;
+let searchContainer = null;
+
+/**
+ * Custom hook to manage DocSearch modal lifecycle
+ */
+export function useDocSearchModal() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [initialQuery, setInitialQuery] = useState(undefined);
+
+ /**
+ * Dynamically import DocSearch modal components if not already loaded
+ */
+ const importDocSearchModalIfNeeded = useCallback(() => {
+ if (DocSearchModal) {
+ return Promise.resolve();
+ }
+ return Promise.all([
+ import('@docsearch/react/modal'),
+ import('@docsearch/react/style'),
+ import('../styles.css'),
+ ]).then(([{ DocSearchModal: Modal }]) => {
+ DocSearchModal = Modal;
+ });
+ }, []);
+
+ /**
+ * Open the search modal
+ */
+ const onOpen = useCallback(() => {
+ importDocSearchModalIfNeeded().then(() => {
+ // searchContainer is not null here when the modal is already open
+ // this check is needed because ctrl + k shortcut was handled by another instance of SearchBar component
+ if (searchContainer) {
+ return;
+ }
+
+ searchContainer = document.createElement('div');
+ document.body.insertBefore(
+ searchContainer,
+ document.body.firstChild,
+ );
+
+ setIsOpen(true);
+ });
+ }, [importDocSearchModalIfNeeded]);
+
+ /**
+ * Close the search modal and cleanup
+ */
+ const onClose = useCallback(() => {
+ setIsOpen(false);
+ searchContainer?.remove();
+ searchContainer = null;
+ }, []);
+
+ /**
+ * Handle input events that should open the modal
+ */
+ const onInput = useCallback((event) => {
+ importDocSearchModalIfNeeded().then(() => {
+ setIsOpen(true);
+ setInitialQuery(event.key);
+ });
+ }, [importDocSearchModalIfNeeded]);
+
+ return {
+ isOpen,
+ initialQuery,
+ DocSearchModal,
+ searchContainer,
+
+ onOpen,
+ onClose,
+ onInput,
+ setInitialQuery,
+ importDocSearchModalIfNeeded,
+ };
+}