diff --git a/package.json b/package.json index 1f12bd982..11b3b8c27 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "react-dom": "^19.0.1", "tsdown": "^0.15.6", "typescript": "^5.5.3", - "usehooks-ts": "^3.1.1" + "usehooks-ts": "^3.1.1", + "vercel": "^50.26.1" } }, "patchedDependencies": { diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index 77d471601..bfe9587af 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile'; import { Button } from '../primitives'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; -const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint +const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint /** * Button to open/close the AI chat. diff --git a/packages/gitbook/src/components/DocumentView/Block.tsx b/packages/gitbook/src/components/DocumentView/Block.tsx index 46a077635..8681232f2 100644 --- a/packages/gitbook/src/components/DocumentView/Block.tsx +++ b/packages/gitbook/src/components/DocumentView/Block.tsx @@ -5,7 +5,6 @@ import { SkeletonHeading, SkeletonImage, SkeletonParagraph, - SkeletonSmall, SkeletonUpdate, } from '@/components/primitives'; import type { ClassValue } from '@/lib/tailwind'; @@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'file': return ; case 'paragraph': - return ; + return ; case 'list-ordered': case 'list-unordered': case 'list-tasks': @@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'tabs': case 'stepper-step': case 'if': - return ; + return ; case 'expandable': case 'table': case 'swagger': @@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'drawing': return ; case 'updates': - return ; + return ; case 'image': case 'code-line': case 'tabs-item': diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx index 1cd445bd7..d08c25098 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableSearch.tsx @@ -61,6 +61,7 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { cursor={cursor} error={error} fetching={fetching} + fillHeight={true} query={query} results={results} resultsId={resultsId} @@ -79,6 +80,9 @@ export function EmbeddableSearch(props: EmbeddableSearchProps) { withAI={withSearchAI} isOpen={true} mode="frame" + resultsCount={results.length} + fetching={fetching} + showAsk={showAsk} > +
{askState?.type === 'answer' ? ( void; +}) { + const { query, assistant, active = false, withShortcut = false, onSelect } = props; const language = useLanguage(); const getSearchLinkProps = useSearchLink(); @@ -23,36 +28,28 @@ export function SearchAskBar(props: { query: string; assistant: Assistant }) { open: assistant.mode === 'search', }, () => { + onSelect?.(); assistant.open(query); } ); return ( - -
- {typeof assistant.icon === 'string' ? ( - - ) : ( - assistant.icon - )} +
+
+ {t(language, 'ai_chat_ask_query', assistant.label, query)} +
+ {withShortcut ? ( + + ) : null}
- - {tString(language, 'ask', '')}{' '} - {assistant.label}{' '} - "{query}" - - - + ); } diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index a023d5fce..0e6911909 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -1,11 +1,15 @@ 'use client'; +import { t, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; import { CustomizationSearchStyle } from '@gitbook/api'; import React, { useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { AIChatButton } from '../AIChat'; import { useIsMobile } from '../hooks/useIsMobile'; -import { Popover } from '../primitives'; +import { Button, Popover } from '../primitives'; +import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; +import { SideSheet } from '../primitives/SideSheet'; import { SearchFrame } from './SearchFrame'; import { SearchInput } from './SearchInput'; import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer'; @@ -29,10 +33,14 @@ export function SearchContainer({ ...searchProps }: SearchContainerProps) { const searchInputRef = useRef(null); - const isMobile = useIsMobile(); + const language = useLanguage(); + const usesSideSheet = useIsMobile(768); const { assistants, askQuery, + askCount, + askInAssistant, + abort, close, cursor, error, @@ -68,6 +76,10 @@ export function SearchContainer({ 'mod+i', (e) => { e.preventDefault(); + if (state?.open && query && askCount > 0) { + askInAssistant(); + return; + } assistants[0]?.open(); }, { @@ -88,81 +100,159 @@ export function SearchContainer({ }; }, [close]); - const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true; - const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined; + const visible = + viewport === 'desktop' ? !usesSideSheet : viewport === 'mobile' ? usesSideSheet : true; + const searchResultsActiveDescendant = + cursor !== null && cursor < results.length ? `${resultsId}-${cursor}` : undefined; + const isSearchOpen = Boolean(visible && (state?.open ?? false)); + const shouldFillHeight = Boolean(query || showAsk); + const shouldShowSearchFrame = usesSideSheet + ? Boolean(state?.open || state?.query || withAI) + : Boolean(state?.query || withAI); + const scopeControlNode = + searchProps.withVariants || searchProps.withSections ? ( + + ) : null; + + const frameInput = ( + + + + ); + + const searchFrame = shouldShowSearchFrame ? ( + + ) : null; return ( <> - - ) : null - } - /> - ) : null - } - rootProps={{ - open: Boolean(visible && (state?.open ?? false)), - modal: false, - }} - contentProps={{ - onOpenAutoFocus: (event) => event.preventDefault(), - align: 'start', - className: - '@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]', - onInteractOutside: (event) => { - // Don't close if clicking on the search input itself - if (searchInputRef.current?.contains(event.target as Node)) { - event.preventDefault(); - return; - } - close(); - }, - sideOffset: 8, - collisionPadding: { - top: 16, - right: 16, - bottom: 32, - left: 16, - }, - hideWhenDetached: true, - }} - triggerProps={{ - asChild: true, - }} - > - + {t(language, withSearchAI ? 'search_or_ask' : 'search')} + +
+ } + active={isSearchOpen} + onClick={open} className={className} - onFocus={open} + aria-expanded={isSearchOpen} + aria-controls={resultsId} + /> + ) : ( + event.preventDefault(), + align: 'start', + className: tcls( + '@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-w-[min(var(--radix-popover-content-available-width),32rem)]', + shouldFillHeight + ? 'h-[min(32rem,var(--radix-popover-content-available-height))]' + : 'max-h-[min(32rem,var(--radix-popover-content-available-height))]' + ), + onInteractOutside: (event) => { + // Don't close if clicking on the search input itself + if (searchInputRef.current?.contains(event.target as Node)) { + event.preventDefault(); + return; + } + close(); + }, + sideOffset: 8, + collisionPadding: { + top: 16, + right: 16, + bottom: 32, + left: 16, + }, + hideWhenDetached: true, + }} + triggerProps={{ + asChild: true, + }} > - - - + + + + + )} + {usesSideSheet ? ( + { + if (!open) { + close(); + } + }} + modal + withOverlay + withCloseButton + className="mx-auto w-96 border-tint-subtle border-l bg-tint-base" + > + {searchFrame} + + ) : null} {uiAssistants.map((assistant, index) => ( ; + onResultSelect?: () => void; scopeControl?: React.ReactNode; showAsk: boolean; sidebar?: React.ReactNode; @@ -40,11 +44,13 @@ export function SearchFrame(props: { dataTestId, error, fetching, + fillHeight = false, input, query, results, resultsId, resultsRef, + onResultSelect, scopeControl, showAsk, sidebar, @@ -56,46 +62,116 @@ export function SearchFrame(props: { return ( {sidebar ? {sidebar} : null} - + +
+
+
{input ? ( {input} ) : null} - - {showAsk ? ( - - ) : ( - +
- {!showAsk && query && assistants.length > 0 - ? assistants.map((assistant) => ( - - )) - : null} - {scopeControl && !showAsk ? ( -
- {scopeControl} + > + + {showAsk ? ( + + ) : ( + + )} + + {!showAsk && query && assistants.length > 0 + ? assistants.map((assistant, index) => ( + + )) + : null} +
+ {scopeControl && !showAsk ? scopeControl : null} +
- ) : null} +
); } + +const SearchFrameKeyboardHints = () => { + return ( +
+ {/* Compact view */} +
+ +
+ +
+
+ / + +
+ +
+
+
+ {/* Normal view */} +
+ + Navigate + + Close +
+
+ ); +}; diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 3810dd947..7fc172c84 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -1,8 +1,9 @@ 'use client'; import React, { useEffect, useRef } from 'react'; -import { tString, useLanguage } from '@/intl/client'; +import { t, tString, useLanguage } from '@/intl/client'; import { Icon } from '@gitbook/icons'; +import { AISearchIcon } from '../AIChat'; import { Input } from '../primitives'; interface SearchInputProps { @@ -17,6 +18,9 @@ interface SearchInputProps { className?: string; children?: React.ReactNode; mode?: 'header' | 'frame'; + resultsCount: number; + fetching: boolean; + showAsk: boolean; } /** @@ -34,6 +38,9 @@ export const SearchInput = React.forwardRef( className, children, mode = 'header', + resultsCount, + fetching, + showAsk, ...rest } = props; const inputRef = useRef(null); @@ -42,17 +49,26 @@ export const SearchInput = React.forwardRef( const language = useLanguage(); useEffect(() => { - if (isOpen) { + if (!isOpen) { + inputRef.current?.blur(); + return; + } + + const focusInput = () => { if (document.activeElement !== inputRef.current) { - // Refocus the input and move the caret to the end – do this only once to avoid scroll jumps on every keystroke inputRef.current?.focus({ preventScroll: true }); - // Place cursor at the end of the input inputRef.current?.setSelectionRange(value.length, value.length); } - } else { - inputRef.current?.blur(); + }; + + if (!isFrame) { + focusInput(); + return; } - }, [isOpen, value]); + + const timeout = window.setTimeout(focusInput, 150); + return () => window.clearTimeout(timeout); + }, [isFrame, isOpen, value.length]); return (
( onFocus={onFocus} onKeyDown={onKeyDown} leading={ - + showAsk ? ( + + ) : ( + + ) } onValueChange={onChange} value={value} @@ -100,14 +120,21 @@ export const SearchInput = React.forwardRef( 'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3', } } + trailing={ + !showAsk && value && resultsCount > 0 ? ( +
+ {t(language, 'search_results_count', resultsCount.toString())} +
+ ) : undefined + } keyboardShortcut={ - isFrame - ? undefined - : { + !value && !isFrame && !isOpen + ? { + keys: ['mod', 'k'], className: - 'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link', - keys: isOpen ? ['esc'] : ['mod', 'k'], + 'bg-tint-base site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link', } + : undefined } {...rest} type="text" diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index b92919524..dcf00c8a8 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -2,6 +2,7 @@ import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; +import { SkeletonParagraph } from '../primitives'; import { Tooltip } from '../primitives'; import { Emoji } from '../primitives/Emoji/Emoji'; import { HighlightQuery } from './HighlightQuery'; @@ -17,10 +18,11 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt query: string; item: PageItem; active: boolean; + style?: React.CSSProperties; }, ref: React.Ref ) { - const { query, item, active, ...rest } = props; + const { query, item, active, style, ...rest } = props; const language = useLanguage(); const bestSection = item.type === 'page' ? item.bestSection : undefined; @@ -41,7 +43,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt ) : icon ? ( ) : ( - + ); const insights = @@ -66,21 +68,62 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt leadingIcon={leadingIcon} insights={insights} aria-label={tString(language, 'search_page_result_title', item.title)} + style={{ + ...style, + }} {...rest} > -

+

- {bestSection?.body ? ( -

- -

- ) : 'description' in item && item.description ? ( -

- -

- ) : null} +
+ {bestSection?.body ? ( +

+ +

+ ) : null} + + {'description' in item && item.description ? ( +

+ +

+ ) : null} + + {item.type === 'local-page' ? ( + + ) : null} +
); }); @@ -149,6 +192,6 @@ const Breadcrumbs = (props: { ...crumbs.slice(-1), ] : crumbs, - 'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint mb-1 text-xxs uppercase leading-snug' + 'text-tint/7 contrast-more:text-tint group-[.is-active]:text-tint text-xs' ); }; diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 9df8bf763..21f271777 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Assistant } from '@/components/AI'; -import { t, tString, useLanguage } from '@/intl/client'; +import { tString, useLanguage } from '@/intl/client'; import { SearchResultItem } from './SearchResultItem'; import { useSearchLink } from './useSearch'; @@ -11,19 +11,20 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion active: boolean; recommended?: boolean; assistant: Assistant; + style?: React.CSSProperties; }, ref: React.Ref ) { - const { question, recommended = false, active, assistant, ...rest } = props; + const { question, recommended = false, active, assistant, style, ...rest } = props; const language = useLanguage(); const getLinkProp = useSearchLink(); return ( - {recommended ? ( - question - ) : ( - <> -
- {t(language, 'search_ask', [question])} -
-
- {t(language, 'search_ask_description', assistant.label)} -
- - )} + {question}
); }); diff --git a/packages/gitbook/src/components/Search/SearchRecordResultItem.tsx b/packages/gitbook/src/components/Search/SearchRecordResultItem.tsx index a5ae33be3..5a9d2d14a 100644 --- a/packages/gitbook/src/components/Search/SearchRecordResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchRecordResultItem.tsx @@ -11,10 +11,11 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu query: string; item: ComputedRecordResult; active: boolean; + style?: React.CSSProperties; }, ref: React.Ref ) { - const { query, item, active, ...rest } = props; + const { query, item, active, style, ...rest } = props; const language = useLanguage(); const domain = getDomain(item.href); @@ -41,6 +42,7 @@ export const SearchRecordResultItem = React.forwardRef(function SearchRecordResu }, }} aria-label={tString(language, 'search_page_result_title', item.title)} + style={{ ...style }} {...rest} >

diff --git a/packages/gitbook/src/components/Search/SearchResultItem.tsx b/packages/gitbook/src/components/Search/SearchResultItem.tsx index 5ef500d7a..02343f80f 100644 --- a/packages/gitbook/src/components/Search/SearchResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchResultItem.tsx @@ -13,6 +13,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem( className?: string; size?: 'small' | 'medium'; leadingIcon?: React.ReactNode; + disabled?: boolean; } & LinkInsightsProps & Omit, keyof LinkInsightsProps>, ref: React.Ref @@ -25,23 +26,25 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem( leadingIcon, size = 'medium', action, + disabled, ...rest } = props; return (

{children}
- {active ? ( + {disabled ? null : active ? ( -
+ ); } const noResults = ( -
{t(language, 'search_no_results_for', query)} -
+ ); return ( -
+ {children} {results.length === 0 ? ( fetching ? null : query ? ( @@ -131,75 +173,183 @@ export const SearchResults = React.forwardRef(function SearchResults( <>
- {results.map((item, index) => { - const resultItemProps = { - 'aria-posinset': index + 1, - 'aria-setsize': results.length, - id: `${id}-${index}`, - }; - switch (item.type) { - case 'local-page': - case 'page': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - {...resultItemProps} - /> - ); + + {results.map((item, index) => { + const itemKey = getResultKey(item); + const shouldAnimateItem = + shouldAnimateResults || !seenResultKeys.current.has(itemKey); + const resultItemProps = { + 'aria-posinset': index + 1, + 'aria-setsize': results.length, + id: `${id}-${index}`, + onClickCapture: () => onResultSelect?.(), + }; + switch (item.type) { + case 'local-page': + case 'page': { + return ( + +
+ { + refs.current[index] = ref; + }} + query={query} + item={item} + active={index === cursor} + style={{ + animationDelay: `${index * 25}ms,${100 + index * 25}ms`, + }} + {...resultItemProps} + /> +
+
+ ); + } + case 'recommended-question': { + if (!primaryAssistant) { + return null; + } + return ( + + { + refs.current[index] = ref; + }} + question={item.question} + active={index === cursor} + assistant={primaryAssistant} + recommended + style={{ + animationDelay: `${index * 25}ms,${100 + index * 25}ms`, + }} + {...resultItemProps} + /> + + ); + } + case 'record': { + return ( + +
+ { + refs.current[index] = ref; + }} + key={itemKey} + query={query} + item={item} + active={index === cursor} + style={{ + animationDelay: `${index * 25}ms,${100 + index * 25}ms`, + }} + {...resultItemProps} + /> +
+
+ ); + } + default: + assertNever(item); } - case 'recommended-question': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - question={item.question} - active={index === cursor} - assistant={assistants[0]!} - recommended - {...resultItemProps} - /> - ); - } - case 'record': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - {...resultItemProps} - /> - ); - } - default: - assertNever(item); - } - })} + })} +
{!fetching && results.length === 0 ? noResults : null} )} {fetching ? ( -
- +
0 ? 'mt-1' : '', + 'flex animate-blur-in flex-col gap-1' + )} + style={{ animationDelay: `${results.length * 25}ms` }} + > +
) : null} -
+
); }); + +const SearchResultsSkeleton = (props: { items: number }) => { + const { items } = props; + const skeletonKeys = Array.from({ length: items }, (_, index) => `skeleton:${index}`); + + return ( + <> + {skeletonKeys.map((key, index) => ( + + } + > + + + ))} + + ); +}; diff --git a/packages/gitbook/src/components/Search/SearchScopeControl.tsx b/packages/gitbook/src/components/Search/SearchScopeControl.tsx index 47de55a8f..5b3fb27cf 100644 --- a/packages/gitbook/src/components/Search/SearchScopeControl.tsx +++ b/packages/gitbook/src/components/Search/SearchScopeControl.tsx @@ -22,7 +22,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) { const state = useSearchState(); - if (!state) { + if (!state?.open) { return null; } @@ -31,7 +31,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) { const variantScopeIsExtended = ['extended', 'all'].includes(state.scope); return ( -
+
{withSections ? ( diff --git a/packages/gitbook/src/components/Search/useSearchController.tsx b/packages/gitbook/src/components/Search/useSearchController.tsx index 05a6c7465..3768c4202 100644 --- a/packages/gitbook/src/components/Search/useSearchController.tsx +++ b/packages/gitbook/src/components/Search/useSearchController.tsx @@ -62,11 +62,15 @@ function useSearchKeyboardNavigation(props: { query: string; results: ReturnType['results']; resultsRef: React.RefObject; + abort: () => void; + askCount: number; + onAskSelect: (index: number) => void; }) { - const { query, results, resultsRef } = props; + const { query, results, resultsRef, abort, askCount, onAskSelect } = props; const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ query, - results, + resultCount: results.length, + totalCount: results.length + askCount, }); const onInputKeyDown = React.useCallback( @@ -79,10 +83,18 @@ function useSearchKeyboardNavigation(props: { moveCursorBy(1); } else if (event.key === 'Enter') { event.preventDefault(); - resultsRef.current?.select(); + if (cursor !== null && cursor >= results.length) { + onAskSelect(cursor - results.length); + return; + } + + if (resultsRef.current?.select()) { + // Stop any in-flight search request only when activating a result. + abort(); + } } }, - [moveCursorBy, resultsRef] + [moveCursorBy, cursor, results.length, onAskSelect, resultsRef, abort] ); return { @@ -179,7 +191,7 @@ export function useSearchController(props: SearchBaseProps) { language: siteSpace.space.language, }); - const { results, fetching, error } = useSearchResults({ + const { results, fetching, error, abort } = useSearchResults({ asEmbeddable, disabled: !(state?.query || withAI), query: normalizedQuery, @@ -195,18 +207,45 @@ export function useSearchController(props: SearchBaseProps) { const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; const searchResultsId = `search-results-${React.useId()}`; + const askInAssistant = React.useCallback( + (assistantIndex = 0) => { + const assistant = assistants[assistantIndex]; + if (!assistant || !normalizedQuery) { + return; + } + + abort(); + assistant.open(normalizedQuery); + setSearchState({ + ask: normalizedQuery, + query: null, + scope: state?.scope ?? 'default', + open: assistant.mode === 'search', + }); + }, + [abort, assistants, normalizedQuery, setSearchState, state?.scope] + ); + + const askCount = normalizedQuery && !showAsk ? assistants.length : 0; + const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({ query: normalizedQuery, results, resultsRef, + abort, + askCount, + onAskSelect: askInAssistant, }); return { assistants, askQuery: normalizedAsk, + askCount, + askInAssistant, cursor, error, fetching, + abort, open: onOpen, close: onClose, query: normalizedQuery, diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 6a81419f3..810f807e2 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -84,6 +84,10 @@ export function useSearchResults(props: { error: boolean; }>({ results: [], fetching: false, error: false }); + // Track the current in-flight fetch so it can be aborted imperatively + // when the user navigates away before the request completes. + const abortRef = React.useRef<(() => void) | null>(null); + const { assistants } = useAI(); const withAI = assistants.length > 0; @@ -227,10 +231,17 @@ export function useSearchResults(props: { } }, 200); + abortRef.current = () => { + cancelled = true; + clearTimeout(timeout); + abortController.abort(); + }; + return () => { cancelled = true; clearTimeout(timeout); abortController.abort(); + abortRef.current = null; }; }, [ query, @@ -245,6 +256,12 @@ export function useSearchResults(props: { asEmbeddable, ]); + const abort = React.useCallback(() => { + abortRef.current?.(); + abortRef.current = null; + setRemoteState((prev) => (prev.fetching ? { ...prev, fetching: false } : prev)); + }, []); + // Merge local and remote results. // Re-runs immediately whenever either result set changes. const results = React.useMemo(() => { @@ -272,6 +289,7 @@ export function useSearchResults(props: { results, fetching: remoteState.fetching, error: remoteState.error, + abort, }; } diff --git a/packages/gitbook/src/components/Search/useSearchResultsCursor.ts b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts index db369c0f6..9cc27d093 100644 --- a/packages/gitbook/src/components/Search/useSearchResultsCursor.ts +++ b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts @@ -1,9 +1,12 @@ import React from 'react'; -import type { ResultType } from './useSearchResults'; -export function useSearchResultsCursor(props: { query: string; results: ResultType[] }) { +export function useSearchResultsCursor(props: { + query: string; + resultCount: number; + totalCount: number; +}) { const [cursor, setCursor] = React.useState(null); - const { query, results } = props; + const { query, resultCount, totalCount } = props; React.useEffect(() => { if (!query) { @@ -13,22 +16,48 @@ export function useSearchResultsCursor(props: { query: string; results: ResultTy }, [query]); React.useEffect(() => { - if (results.length > 0) { - // Auto-focus the first result - setCursor(0); + if (totalCount === 0) { + return; } - }, [results]); + + setCursor((prev) => { + if (resultCount > 0) { + return 0; + } + + return prev ?? 0; + }); + }, [resultCount, totalCount]); + + React.useEffect(() => { + setCursor((prev) => { + if (prev === null) { + return prev; + } + + if (totalCount === 0) { + return null; + } + + return Math.min(prev, totalCount - 1); + }); + }, [totalCount]); const moveBy = React.useCallback( (delta: number) => { setCursor((prev) => { + if (totalCount === 0) { + return null; + } + if (prev === null) { return 0; } - return Math.max(Math.min(prev + delta, results.length - 1), 0); + + return Math.max(Math.min(prev + delta, totalCount - 1), 0); }); }, - [results] + [totalCount] ); return { diff --git a/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx b/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx index 42e4311ea..123a4a2e8 100644 --- a/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx +++ b/packages/gitbook/src/components/SitePage/SitePageSkeleton.tsx @@ -22,7 +22,7 @@ export function SitePageSkeleton() { >
- +
); diff --git a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx index 4042f0b89..35c2520f8 100644 --- a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx +++ b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx @@ -14,7 +14,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) { return (