diff --git a/.changeset/search-last-query.md b/.changeset/search-last-query.md new file mode 100644 index 000000000..f83c6f424 --- /dev/null +++ b/.changeset/search-last-query.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Keep the last search or ask query visible after closing its surface and restore it when reopening search or an embed. diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx index 1c1d96096..f146fa9a6 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx @@ -197,8 +197,9 @@ export function EmbeddableIframeTabs(props: { active?: string; baseURL: string; siteTitle: string; + onNavigate?: (href: string) => void; }) { - const { ref, active = 'assistant', baseURL, siteTitle } = props; + const { ref, active = 'assistant', baseURL, siteTitle, onNavigate } = props; const actions = useEmbeddableConfiguration((state) => state.actions); const tabs = useEmbeddableTabs(); @@ -261,6 +262,10 @@ export function EmbeddableIframeTabs(props: { className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5" iconOnly onClick={() => { + if (tab.key !== active && onNavigate) { + onNavigate(tab.href); + return; + } router.push(tab.href); }} tooltipProps={{ @@ -274,7 +279,8 @@ export function EmbeddableIframeTabs(props: { ) : null; } -export function EmbeddableIframeCloseButton() { +export function EmbeddableIframeCloseButton(props: { onClose?: () => void }) { + const { onClose } = props; const { closeButton } = useEmbeddableConfiguration(); if (!closeButton) { @@ -291,6 +297,7 @@ export function EmbeddableIframeCloseButton() { className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5" iconOnly onClick={() => { + onClose?.(); getChannel()?.send({ type: 'close' }); }} tooltipProps={{ diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx index d08c25098..f85c728f0 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx @@ -38,10 +38,13 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { const tabsRef = React.useRef(null); const { askQuery, + abort, + close, cursor, error, fetching, onInputKeyDown, + onResultSelect, query, results, resultsId, @@ -51,7 +54,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { showAsk, withSearchAI, scopeControl, - } = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab }); + } = useSearchController( + { ...searchProps, asEmbeddable: hasDocsTab }, + { restoreLastQueryOnMount: true } + ); return ( @@ -66,6 +72,8 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { results={results} resultsId={resultsId} resultsRef={resultsRef} + onAskSelect={abort} + onResultSelect={onResultSelect} showAsk={showAsk} dataTestId="embed-search" input={ @@ -97,9 +105,10 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { active="search" baseURL={baseURL} siteTitle={siteTitle} + onNavigate={close} /> - + } scopeControl={ diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 57ddb214b..da5062eb9 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -50,6 +50,7 @@ export function SearchContainer({ error, fetching, onInputKeyDown, + onResultSelect, open, query, results, @@ -169,7 +170,8 @@ export function SearchContainer({ results={results} resultsId={resultsId} resultsRef={resultsRef} - onResultSelect={abort} + onAskSelect={abort} + onResultSelect={onResultSelect} showAsk={showAsk} scopeControl={scopeControlNode} fillHeight={usesSideSheet || shouldFillHeight} diff --git a/packages/gitbook/src/components/Search/SearchFrame.tsx b/packages/gitbook/src/components/Search/SearchFrame.tsx index 8247aaae3..ebfe2a56f 100644 --- a/packages/gitbook/src/components/Search/SearchFrame.tsx +++ b/packages/gitbook/src/components/Search/SearchFrame.tsx @@ -29,7 +29,8 @@ export function SearchFrame(props: { results: ResultType[]; resultsId: string; resultsRef: React.Ref; - onResultSelect?: () => void; + onAskSelect?: () => void; + onResultSelect?: (result: ResultType) => void; scopeControl?: React.ReactNode; showAsk: boolean; sidebar?: React.ReactNode; @@ -50,6 +51,7 @@ export function SearchFrame(props: { results, resultsId, resultsRef, + onAskSelect, onResultSelect, scopeControl, showAsk, @@ -126,7 +128,7 @@ export function SearchFrame(props: { assistant={assistant} active={cursor === results.length + index} withShortcut={assistant === assistants[0]} - onSelect={onResultSelect} + onSelect={onAskSelect} /> )) : null} diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 83abc6a28..9c1349bef 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -50,7 +50,7 @@ export const SearchResults = React.forwardRef(function SearchResults( fetching: boolean; cursor: number | null; error: boolean; - onResultSelect?: () => void; + onResultSelect?: (result: ResultType) => void; }, ref: React.Ref ) { @@ -211,7 +211,7 @@ export const SearchResults = React.forwardRef(function SearchResults( addRecentSearchQuery(siteSpaceId, query, 'search'); } - onResultSelect?.(); + onResultSelect?.(item); }; const resultItemProps = { 'aria-posinset': index + 1, diff --git a/packages/gitbook/src/components/Search/last-query.ts b/packages/gitbook/src/components/Search/last-query.ts new file mode 100644 index 000000000..79cb47cb6 --- /dev/null +++ b/packages/gitbook/src/components/Search/last-query.ts @@ -0,0 +1,52 @@ +'use client'; + +import React from 'react'; + +type LastSearchQueryBySiteSpace = Record; + +const listeners = new Set<() => void>(); + +let globalLastSearchQuery: LastSearchQueryBySiteSpace = {}; + +function emitChange() { + listeners.forEach((listener) => listener()); +} + +export function getLastSearchQuery(siteSpaceId: string): string | null { + return globalLastSearchQuery[siteSpaceId] ?? null; +} + +export function setLastSearchQuery(siteSpaceId: string, query: string | null): void { + if (!siteSpaceId) { + return; + } + + const normalizedQuery = query?.trim() || null; + const nextState = { ...globalLastSearchQuery }; + + if (normalizedQuery) { + nextState[siteSpaceId] = normalizedQuery; + } else { + delete nextState[siteSpaceId]; + } + + globalLastSearchQuery = nextState; + emitChange(); +} + +export function clearLastSearchQuery(siteSpaceId: string): void { + setLastSearchQuery(siteSpaceId, null); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useLastSearchQuery(siteSpaceId: string): string | null { + return React.useSyncExternalStore( + subscribe, + () => globalLastSearchQuery[siteSpaceId] ?? null, + () => null + ); +} diff --git a/packages/gitbook/src/components/Search/useSearch.tsx b/packages/gitbook/src/components/Search/useSearch.tsx index 1efe04611..3c50f3a7f 100644 --- a/packages/gitbook/src/components/Search/useSearch.tsx +++ b/packages/gitbook/src/components/Search/useSearch.tsx @@ -61,6 +61,12 @@ function normalizeRawState(values: Values) { return values; } +export function shouldKeepSearchState( + values: Pick, 'q' | 'ask' | 'scope'> +): boolean { + return values.q !== null || values.ask !== null || values.scope !== 'default'; +} + export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement { const { children } = props; @@ -74,7 +80,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea const state = React.useMemo(() => { const normalized = normalizeRawState(rawState); - if (normalized.q === null && normalized.ask === null) { + if (!shouldKeepSearchState(normalized)) { return null; } return { diff --git a/packages/gitbook/src/components/Search/useSearchController.tsx b/packages/gitbook/src/components/Search/useSearchController.tsx index 567987125..67b810760 100644 --- a/packages/gitbook/src/components/Search/useSearchController.tsx +++ b/packages/gitbook/src/components/Search/useSearchController.tsx @@ -7,10 +7,16 @@ import { useAI } from '../AI'; import { useTrackEvent } from '../Insights'; import { useBodyLoaded } from '../primitives'; import type { SearchResultsRef } from './SearchResults'; +import { + clearLastSearchQuery, + getLastSearchQuery, + setLastSearchQuery, + useLastSearchQuery, +} from './last-query'; import { addRecentSearchQuery } from './recent-queries'; import type { SearchBaseProps } from './search-props'; import { useSearchState, useSetSearchState } from './useSearch'; -import { useSearchResults } from './useSearchResults'; +import { type ResultType, useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; function useInitialAskBootstrap(props: { @@ -109,7 +115,10 @@ function useSearchKeyboardNavigation(props: { }; } -export function useSearchController(props: SearchBaseProps) { +export function useSearchController( + props: SearchBaseProps, + options: { restoreLastQueryOnMount?: boolean } = {} +) { const { asEmbeddable, siteSpace, @@ -130,6 +139,32 @@ export function useSearchController(props: SearchBaseProps) { const resultsRef = React.useRef(null); const isLoaded = useBodyLoaded(); + const restoredLastQueryForSiteSpaceRef = React.useRef(null); + React.useEffect(() => { + if ( + !options.restoreLastQueryOnMount || + restoredLastQueryForSiteSpaceRef.current === siteSpace.id + ) { + return; + } + + restoredLastQueryForSiteSpaceRef.current = siteSpace.id; + const restoredQuery = getLastSearchQuery(siteSpace.id); + if (!restoredQuery) { + return; + } + + void setSearchState( + (prev) => + prev ?? { + ask: null, + query: restoredQuery, + scope: 'default', + open: true, + } + ); + }, [options.restoreLastQueryOnMount, setSearchState, siteSpace.id]); + const withAI = assistants.length > 0; const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0; @@ -141,38 +176,46 @@ export function useSearchController(props: SearchBaseProps) { const onClose = React.useCallback( async (to?: string) => { - setSearchState((prev) => - prev - ? { - ...prev, - open: false, - query: prev.query === '' ? null : prev.query, - } - : null - ); + setSearchState((prev) => { + if (!prev) return null; + + if (prev.query !== null) { + setLastSearchQuery(siteSpace.id, prev.query); + } + + return { ...prev, open: false, query: null }; + }); if (to) { router.push(to); } }, - [setSearchState, router] + [setSearchState, router, siteSpace.id] ); const onOpen = React.useCallback(() => { if (state?.open) { return; } - setSearchState((prev) => ({ - ask: withAI ? (prev?.ask ?? null) : null, - scope: prev?.scope ?? 'default', - query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '', - open: true, - })); + setSearchState((prev) => { + const query = + prev?.query ?? + getLastSearchQuery(siteSpace.id) ?? + (withSearchAI || !withAI ? prev?.ask : null) ?? + ''; + + return { + ask: withAI ? (prev?.ask ?? null) : null, + scope: prev?.scope ?? 'default', + query, + open: true, + }; + }); trackEvent({ type: 'search_open', }); - }, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]); + }, [state?.open, setSearchState, siteSpace.id, trackEvent, withAI, withSearchAI]); const setQuery = React.useCallback( (value: string) => { @@ -212,9 +255,25 @@ export function useSearchController(props: SearchBaseProps) { withSections, }); - const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; + const lastSearchQuery = useLastSearchQuery(siteSpace.id); + const searchValue = + state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? lastSearchQuery ?? ''; const searchResultsId = `search-results-${React.useId()}`; + const onResultSelect = React.useCallback( + (result: ResultType) => { + clearLastSearchQuery(siteSpace.id); + abort(); + + if (result.type !== 'recommended-question') { + void setSearchState((prev) => + prev ? { ...prev, query: null, open: false } : null + ); + } + }, + [abort, setSearchState, siteSpace.id] + ); + const askInAssistant = React.useCallback( (assistantIndex = 0) => { const assistant = assistants[assistantIndex]; @@ -260,6 +319,7 @@ export function useSearchController(props: SearchBaseProps) { abort, open: onOpen, close: onClose, + onResultSelect, query: normalizedQuery, results, resultsId: searchResultsId,