From 0c129f9df70e5aac56692cb37a5f66c858e9425b Mon Sep 17 00:00:00 2001 From: Dominic Tran Date: Tue, 19 Aug 2025 16:32:20 -0500 Subject: [PATCH 1/2] adding url breadcrumbs to search, splitting search index into multiple files as it was getting pretty wild --- src/theme/SearchBar/index.js | 340 +++--------------- src/theme/SearchBar/searchConstants.js | 102 ++++++ src/theme/SearchBar/searchHit.jsx | 29 ++ src/theme/SearchBar/searchResultsFooter.jsx | 62 ++++ .../SearchBar/utils/aiConflictHandler.js | 60 ++++ src/theme/SearchBar/utils/searchAnalytics.js | 58 +++ src/theme/SearchBar/utils/searchConfig.js | 76 ++++ .../SearchBar/utils/useDocSearchModal.js | 82 +++++ 8 files changed, 527 insertions(+), 282 deletions(-) create mode 100644 src/theme/SearchBar/searchConstants.js create mode 100644 src/theme/SearchBar/searchHit.jsx create mode 100644 src/theme/SearchBar/searchResultsFooter.jsx create mode 100644 src/theme/SearchBar/utils/aiConflictHandler.js create mode 100644 src/theme/SearchBar/utils/searchAnalytics.js create mode 100644 src/theme/SearchBar/utils/searchConfig.js create mode 100644 src/theme/SearchBar/utils/useDocSearchModal.js 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..bb75a65a1dc --- /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, + }; +} From 4e71923151c547b44d014e987a2a0cb2c8cda162 Mon Sep 17 00:00:00 2001 From: Dominic Tran Date: Wed, 20 Aug 2025 09:04:55 -0500 Subject: [PATCH 2/2] Update src/theme/SearchBar/searchHit.jsx Co-authored-by: Shaun Struwig <41984034+Blargian@users.noreply.github.com> --- src/theme/SearchBar/searchHit.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/theme/SearchBar/searchHit.jsx b/src/theme/SearchBar/searchHit.jsx index bb75a65a1dc..9549f6ac9f4 100644 --- a/src/theme/SearchBar/searchHit.jsx +++ b/src/theme/SearchBar/searchHit.jsx @@ -19,7 +19,7 @@ export function SearchHit({ hit, children }) { color: '#888', display: 'block', lineHeight: '1', - marginTop: '2px' + marginBottom: '12px' }}> {breadcrumbs.join(' › ')}