From 133c3c626b84032e59fb6e10a1abe2d34765cbe7 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Wed, 10 Sep 2025 15:06:48 +0100 Subject: [PATCH 1/8] Accessible search --- .../src/components/Search/SearchContainer.tsx | 18 ++++++++++ .../src/components/Search/SearchInput.tsx | 36 +++++++++++++++++-- .../Search/SearchPageResultItem.tsx | 4 ++- .../Search/SearchQuestionResultItem.tsx | 3 +- .../components/Search/SearchResultItem.tsx | 1 + .../src/components/Search/SearchResults.tsx | 24 +++++++++++-- .../Search/SearchSectionResultItem.tsx | 11 ++++-- 7 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index dc70365445..080182a9bf 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -161,6 +161,16 @@ export function SearchContainer(props: SearchContainerProps) { const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true; + const [resultsState, setResultsState] = React.useState<{ + count: number; + showing: boolean; + cursor: number | null; + }>({ + count: 0, + showing: false, + cursor: null, + }); + const searchResultsId = `search-results-${React.useId()}`; return ( { + setResultsState({ count: results.length, showing, cursor }); + }} + id={searchResultsId} /> ) : null} {showAsk ? : null} @@ -223,6 +237,10 @@ export function SearchContainer(props: SearchContainerProps) { withAI={withSearchAI} isOpen={state?.open ?? false} className={className} + resultsShowing={resultsState.showing} + resultsCount={resultsState.count} + cursor={resultsState.cursor} + controlsId={searchResultsId} /> {assistants diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 8f25c6fa2a..0a2dc6f6cc 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -16,6 +16,10 @@ interface SearchInputProps { withAI: boolean; isOpen: boolean; className?: string; + resultsCount: number; + cursor: number | null; + resultsShowing: boolean; + controlsId: string; } // Size classes for medium size button @@ -26,7 +30,19 @@ const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4']; */ export const SearchInput = React.forwardRef( function SearchInput(props, ref) { - const { onChange, onKeyDown, onFocus, value, withAI, isOpen, className } = props; + const { + onChange, + onKeyDown, + onFocus, + resultsCount, + resultsShowing, + cursor, + value, + withAI, + isOpen, + className, + controlsId, + } = props; const inputRef = useRef(null); const language = useLanguage(); @@ -84,9 +100,16 @@ export const SearchInput = React.forwardRef( className="size-4 shrink-0 animate-scale-in" /> )} - +
+ {resultsShowing + ? resultsCount > 0 + ? `${resultsCount} results` + : 'No results' + : ''} +
onChange(event.target.value)} @@ -100,6 +123,15 @@ export const SearchInput = React.forwardRef( 'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-hidden transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7', isOpen ? '' : 'max-md:opacity-0' )} + aria-haspopup="listbox" + aria-controls={controlsId} + autoComplete="off" + aria-autocomplete="list" + aria-expanded={value && isOpen ? 'true' : 'false'} + aria-activedescendant={ + cursor !== null ? `${controlsId}-${cursor}` : undefined + } + // Forward ref={inputRef} /> {!isOpen ? : null} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 1df80813a4..37e0550af8 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -14,7 +14,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt }, ref: React.Ref ) { - const { query, item, active } = props; + const { query, item, active, ...rest } = props; const language = useLanguage(); const breadcrumbs = @@ -41,6 +41,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt spaceId: item.spaceId, }, }} + aria-label={`Page with title '${item.title}'`} + {...rest} > {breadcrumbs.length > 0 ? (
) { - const { question, recommended = false, active, assistant } = props; + const { question, recommended = false, active, assistant, ...rest } = props; const language = useLanguage(); const getLinkProp = useSearchLink(); @@ -38,6 +38,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion active={active} leadingIcon={recommended ? 'search' : assistant.icon} className={recommended ? 'pr-1.5' : ''} + {...rest} > {recommended ? ( question diff --git a/packages/gitbook/src/components/Search/SearchResultItem.tsx b/packages/gitbook/src/components/Search/SearchResultItem.tsx index d39a9088e3..c9d3bac9e1 100644 --- a/packages/gitbook/src/components/Search/SearchResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchResultItem.tsx @@ -51,6 +51,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem( : null, className )} + role="option" {...rest} >
diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 687f31f383..a9bb0af3f7 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -49,13 +49,15 @@ const cachedRecommendedQuestions: Map = new Map(); export const SearchResults = React.forwardRef(function SearchResults( props: { children?: React.ReactNode; + id: string; query: string; global: boolean; siteSpaceId: string; + onResultsChanged?: (results: ResultType[], showing: boolean, cursor: number | null) => void; }, ref: React.Ref ) { - const { children, query, global, siteSpaceId } = props; + const { children, id, query, global, siteSpaceId, onResultsChanged } = props; const language = useLanguage(); const trackEvent = useTrackEvent(); @@ -161,6 +163,7 @@ export const SearchResults = React.forwardRef(function SearchResults( }, [query, global, trackEvent, withAI, siteSpaceId]); const results: ResultType[] = React.useMemo(() => { + onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor); if (!withAI) { return resultsState.results; } @@ -179,6 +182,7 @@ export const SearchResults = React.forwardRef(function SearchResults( // Scroll to the active result. React.useEffect(() => { + onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor); if (cursor === null || !refs.current[cursor]) { return; } @@ -258,8 +262,20 @@ 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 'page': { return ( @@ -271,6 +287,7 @@ export const SearchResults = React.forwardRef(function SearchResults( query={query} item={item} active={index === cursor} + {...resultItemProps} /> ); } @@ -284,6 +301,7 @@ export const SearchResults = React.forwardRef(function SearchResults( question={query} active={index === cursor} assistant={item.assistant} + {...resultItemProps} /> ); } @@ -298,6 +316,7 @@ export const SearchResults = React.forwardRef(function SearchResults( active={index === cursor} assistant={assistants[0]!} recommended + {...resultItemProps} /> ); } @@ -311,6 +330,7 @@ export const SearchResults = React.forwardRef(function SearchResults( query={query} item={item} active={index === cursor} + {...resultItemProps} /> ); } diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index 917786ad50..0ee8f23272 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -13,7 +13,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe }, ref: React.Ref ) { - const { query, item, active } = props; + const { query, item, active, ...rest } = props; const language = useLanguage(); return ( @@ -32,6 +32,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe spaceId: item.spaceId, }, }} + aria-label={`Section${item.title ? ` with title '${item.title}'` : item.body ? ` with content '${getAbbreviatedBody(item.body, query)}'` : ''}`} + {...rest} >
{item.title ? ( @@ -51,7 +53,12 @@ function highlightQueryInBody(body: string, query: string) { // Ensure the query to be highlighted is visible in the body. return (

- +

); } + +function getAbbreviatedBody(body: string, query: string) { + const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()); + return idx < 20 ? body.slice(0, 100) : `…${body.slice(idx - 10, idx + query.length + 30)}…`; +} From 4d2a083862245a2fa75bfb1ab49e777619ceb324 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Wed, 10 Sep 2025 15:08:15 +0100 Subject: [PATCH 2/8] chore: changeset --- .changeset/dry-melons-joke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-melons-joke.md diff --git a/.changeset/dry-melons-joke.md b/.changeset/dry-melons-joke.md new file mode 100644 index 0000000000..c2b1c27fd7 --- /dev/null +++ b/.changeset/dry-melons-joke.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Make search accessible From 99da0557c899691e2336d4f828044779ecc409e3 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Wed, 10 Sep 2025 15:28:11 +0100 Subject: [PATCH 3/8] Translate aria-labels --- packages/gitbook/src/components/Search/SearchInput.tsx | 6 +++--- .../src/components/Search/SearchPageResultItem.tsx | 2 +- packages/gitbook/src/components/Search/SearchResults.tsx | 1 - .../src/components/Search/SearchSectionResultItem.tsx | 8 +++++++- packages/gitbook/src/intl/translations/de.ts | 4 ++++ packages/gitbook/src/intl/translations/en.ts | 4 ++++ packages/gitbook/src/intl/translations/es.ts | 4 ++++ packages/gitbook/src/intl/translations/fr.ts | 4 ++++ packages/gitbook/src/intl/translations/ja.ts | 4 ++++ packages/gitbook/src/intl/translations/nl.ts | 4 ++++ packages/gitbook/src/intl/translations/no.ts | 4 ++++ packages/gitbook/src/intl/translations/pt-br.ts | 4 ++++ packages/gitbook/src/intl/translations/ru.ts | 4 ++++ packages/gitbook/src/intl/translations/zh.ts | 4 ++++ 14 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 0a2dc6f6cc..761b55cd00 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useEffect, useRef, useState } from 'react'; -import { tString, useLanguage } from '@/intl/client'; +import { t, tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import { Button, variantClasses } from '../primitives'; @@ -103,8 +103,8 @@ export const SearchInput = React.forwardRef(
{resultsShowing ? resultsCount > 0 - ? `${resultsCount} results` - : 'No results' + ? t(language, 'search_results_count', resultsCount) + : t(language, 'search_no_results') : ''}
{breadcrumbs.length > 0 ? ( diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index a9bb0af3f7..b261081db4 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -265,7 +265,6 @@ export const SearchResults = React.forwardRef(function SearchResults(
diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index 23a38e2fc1..a7ce02ae0d 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -12,6 +12,10 @@ export const de = { search_no_results_for: 'Keine Ergebnisse für "${1}".', search_no_results: 'Keine Ergebnisse', search_results_count: '${1} Ergebnisse', + search_page_result_title: 'Seite mit Titel ${1}', + search_section_result_title: 'Abschnitt mit Titel ${1}', + search_section_result_content: 'Abschnitt mit Inhalt ${1}', + search_section_result_default: 'Abschnitt', search_scope_space: '${1}', search_scope_all: 'Alle Inhalte', ask: 'Fragen', diff --git a/packages/gitbook/src/intl/translations/en.ts b/packages/gitbook/src/intl/translations/en.ts index 2f1d494de1..77ecb0e1bb 100644 --- a/packages/gitbook/src/intl/translations/en.ts +++ b/packages/gitbook/src/intl/translations/en.ts @@ -12,6 +12,10 @@ export const en = { search_no_results_for: 'No results for "${1}".', search_no_results: 'No results', search_results_count: '${1} results', + search_page_result_title: 'Page with title ${1}', + search_section_result_title: 'Section with title ${1}', + search_section_result_content: 'Section with content ${1}', + search_section_result_default: 'Section', search_scope_space: '${1}', search_scope_all: 'All content', ask: 'Ask', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 83b56bd801..0fa373b37b 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -14,6 +14,10 @@ export const es: TranslationLanguage = { search_no_results_for: 'No hay resultados para "${1}".', search_no_results: 'No hay resultados', search_results_count: '${1} resultados', + search_page_result_title: 'Página con título ${1}', + search_section_result_title: 'Sección con título ${1}', + search_section_result_content: 'Sección con contenido ${1}', + search_section_result_default: 'Sección', search_scope_space: '${1}', search_scope_all: 'Todo el contenido', ask: 'Preguntar', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 1a34af09d2..a576492301 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -12,6 +12,10 @@ export const fr = { search_no_results_for: 'Aucun résultat pour « ${1} ».', search_no_results: 'Aucun résultat', search_results_count: '${1} résultats', + search_page_result_title: 'Page avec titre ${1}', + search_section_result_title: 'Section avec titre ${1}', + search_section_result_content: 'Section avec contenu ${1}', + search_section_result_default: 'Section', search_scope_space: '${1}', search_scope_all: 'Tous les contenus', ask: 'Poser une question', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index 1b7e6dd351..da7cda1bed 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -14,6 +14,10 @@ export const ja: TranslationLanguage = { search_no_results_for: '"${1}" の結果はありません。', search_no_results: '結果がありません', search_results_count: '${1}件の結果', + search_page_result_title: 'タイトル「${1}」のページ', + search_section_result_title: 'タイトル「${1}」のセクション', + search_section_result_content: 'コンテンツ「${1}」のセクション', + search_section_result_default: 'セクション', search_scope_space: '${1}', search_scope_all: '全てのコンテンツ', ask: '質問する', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index d5def507d9..6125531e75 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -14,6 +14,10 @@ export const nl: TranslationLanguage = { search_no_results_for: 'Geen resultaten voor "${1}".', search_no_results: 'Geen resultaten', search_results_count: '${1} resultaten', + search_page_result_title: 'Pagina met titel ${1}', + search_section_result_title: 'Sectie met titel ${1}', + search_section_result_content: 'Sectie met inhoud ${1}', + search_section_result_default: 'Sectie', search_scope_space: '${1}', search_scope_all: 'Alle inhoud', ask: 'Vragen', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 34771f805d..0121ee664d 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -14,6 +14,10 @@ export const no: TranslationLanguage = { search_no_results_for: 'Ingen resultater for "${1}".', search_no_results: 'Ingen resultater', search_results_count: '${1} resultater', + search_page_result_title: 'Side med tittel ${1}', + search_section_result_title: 'Seksjon med tittel ${1}', + search_section_result_content: 'Seksjon med innhold ${1}', + search_section_result_default: 'Seksjon', search_scope_space: '${1}', search_scope_all: 'Alt innhold', ask: 'Spør', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index b0b5212f82..dfc079659c 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -12,6 +12,10 @@ export const pt_br = { search_no_results_for: 'Sem resultados para "${1}".', search_no_results: 'Sem resultados', search_results_count: '${1} resultados', + search_page_result_title: 'Página com título ${1}', + search_section_result_title: 'Seção com título ${1}', + search_section_result_content: 'Seção com conteúdo ${1}', + search_section_result_default: 'Seção', search_scope_space: '${1}', search_scope_all: 'Todo o conteúdo', ask: 'Perguntar', diff --git a/packages/gitbook/src/intl/translations/ru.ts b/packages/gitbook/src/intl/translations/ru.ts index 8335a99951..f6e99193ce 100644 --- a/packages/gitbook/src/intl/translations/ru.ts +++ b/packages/gitbook/src/intl/translations/ru.ts @@ -12,6 +12,10 @@ export const ru = { search_no_results_for: 'Нет результатов для "${1}".', search_no_results: 'Нет результатов', search_results_count: '${1} — число результатов', + search_page_result_title: 'Страница с заголовком ${1}', + search_section_result_title: 'Раздел с заголовком ${1}', + search_section_result_content: 'Раздел с контентом ${1}', + search_section_result_default: 'Раздел', search_scope_space: '${1}', search_scope_all: 'Все материалы', ask: 'Спросить', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index b17cfefdc4..92db81b439 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -14,6 +14,10 @@ export const zh: TranslationLanguage = { search_no_results_for: '没有找到"${1}"的结果。', search_no_results: '没有找到结果', search_results_count: '${1} 个结果', + search_page_result_title: '标题为"${1}"的页面', + search_section_result_title: '标题为"${1}"的章节', + search_section_result_content: '内容为"${1}"的章节', + search_section_result_default: '章节', search_scope_space: '${1}', search_scope_all: '所有内容', ask: '询问', From 27046b3d94914a0ee19479e4c92962aeaf5c2c89 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Wed, 10 Sep 2025 15:36:51 +0100 Subject: [PATCH 4/8] chore: format --- .../src/components/Search/SearchSectionResultItem.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index 018b84c869..95df9f16e4 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -36,8 +36,12 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe item.title ? tString(language, 'search_section_result_title', item.title) : item.body - ? tString(language, 'search_section_result_content', getAbbreviatedBody(item.body, query)) - : tString(language, 'search_section_result_default') + ? tString( + language, + 'search_section_result_content', + getAbbreviatedBody(item.body, query) + ) + : tString(language, 'search_section_result_default') } {...rest} > From ee37215eba54df67de86c01cd51299b2dfd67f73 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Fri, 12 Sep 2025 10:00:23 +0100 Subject: [PATCH 5/8] fix setState error --- .../src/components/Search/SearchContainer.tsx | 105 +++++---- .../src/components/Search/SearchInput.tsx | 30 +-- .../src/components/Search/SearchResults.tsx | 202 ++---------------- .../src/components/Search/useSearchResults.ts | 173 +++++++++++++++ .../Search/useSearchResultsCursor.ts | 35 +++ 5 files changed, 297 insertions(+), 248 deletions(-) create mode 100644 packages/gitbook/src/components/Search/useSearchResults.ts create mode 100644 packages/gitbook/src/components/Search/useSearchResultsCursor.ts diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 080182a9bf..2bbb07e9b5 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -1,5 +1,6 @@ 'use client'; +import { t, useLanguage } from '@/intl/client'; import { CustomizationSearchStyle } from '@gitbook/api'; import { useRouter } from 'next/navigation'; import React, { useRef } from 'react'; @@ -16,6 +17,9 @@ import { SearchInput } from './SearchInput'; import { SearchResults, type SearchResultsRef } from './SearchResults'; import { SearchScopeToggle } from './SearchScopeToggle'; import { useSearch } from './useSearch'; +import { useSearchResults } from './useSearchResults'; +import { useSearchResultsCursor } from './useSearchResultsCursor'; +import { on } from 'events'; interface SearchContainerProps { siteSpaceId: string; @@ -61,6 +65,8 @@ export function SearchContainer(props: SearchContainerProps) { const onClose = React.useCallback( async (to?: string) => { + console.log('Closing search', state); + console.trace(); setSearchState((prev) => prev ? { @@ -103,6 +109,7 @@ export function SearchContainer(props: SearchContainerProps) { ); const onOpen = React.useCallback(() => { + console.log("Opening search", state); if (state?.open) { return; } @@ -131,19 +138,6 @@ export function SearchContainer(props: SearchContainerProps) { }; }, [onClose]); - const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'ArrowUp') { - event.preventDefault(); - resultsRef.current?.moveUp(); - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - resultsRef.current?.moveDown(); - } else if (event.key === 'Enter') { - event.preventDefault(); - resultsRef.current?.select(); - } - }; - const onChange = (value: string) => { setSearchState((prev) => ({ ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined) @@ -157,20 +151,37 @@ export function SearchContainer(props: SearchContainerProps) { const normalizedQuery = state?.query?.trim() ?? ''; const normalizedAsk = state?.ask?.trim() ?? ''; - const showAsk = withSearchAI && normalizedAsk; // withSearchAI && normalizedAsk; + const showAsk = withSearchAI && normalizedAsk; const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true; - const [resultsState, setResultsState] = React.useState<{ - count: number; - showing: boolean; - cursor: number | null; - }>({ - count: 0, - showing: false, - cursor: null, - }); const searchResultsId = `search-results-${React.useId()}`; + const { results, fetching } = useSearchResults({ + disabled: !(state?.query || withAI), + query: normalizedQuery, + siteSpaceId, + global: state?.global ?? false, + withAI: withAI, + }); + const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''; + + const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({ + query: normalizedQuery, + results, + }); + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveCursorBy(-1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + moveCursorBy(1); + } else if (event.key === 'Enter') { + event.preventDefault(); + resultsRef.current?.select(); + } + }; + return ( { - setResultsState({ count: results.length, showing, cursor }); - }} id={searchResultsId} + fetching={fetching} + results={results} + cursor={cursor} /> ) : null} {showAsk ? : null} @@ -198,9 +207,10 @@ export function SearchContainer(props: SearchContainerProps) { ) : null } rootProps={{ - open: visible && (state?.open ?? false), + defaultOpen: Boolean(visible && (state?.open ?? false)), onOpenChange: (open) => { - open ? onOpen() : onClose(); + if (open) { onOpen(); } + else { onClose(); } }, modal: isMobile, }} @@ -230,18 +240,23 @@ export function SearchContainer(props: SearchContainerProps) { > + aria-controls={searchResultsId} + aria-activedescendant={ + cursor !== null ? `${searchResultsId}-${cursor}` : undefined + } + > + + {assistants .filter((assistant) => assistant.ui === true) @@ -259,3 +274,21 @@ export function SearchContainer(props: SearchContainerProps) { ); } + +/* + * Screen reader announcement for search results. + * Without it there is no feedback for screen reader users when a search returns no results. + */ +function LiveResultsAnnouncer(props: { count: number; showing: boolean }) { + const { count, showing } = props; + const language = useLanguage(); + return ( +
+ {showing + ? count > 0 + ? t(language, 'search_results_count', count) + : t(language, 'search_no_results') + : ''} +
+ ); +} diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx index 761b55cd00..84fc6a67bf 100644 --- a/packages/gitbook/src/components/Search/SearchInput.tsx +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useEffect, useRef, useState } from 'react'; -import { t, tString, useLanguage } from '@/intl/client'; +import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon } from '@gitbook/icons'; import { Button, variantClasses } from '../primitives'; @@ -16,10 +16,7 @@ interface SearchInputProps { withAI: boolean; isOpen: boolean; className?: string; - resultsCount: number; - cursor: number | null; - resultsShowing: boolean; - controlsId: string; + children?: React.ReactNode; } // Size classes for medium size button @@ -34,14 +31,12 @@ export const SearchInput = React.forwardRef( onChange, onKeyDown, onFocus, - resultsCount, - resultsShowing, - cursor, value, withAI, isOpen, className, - controlsId, + children, + ...rest } = props; const inputRef = useRef(null); @@ -100,16 +95,10 @@ export const SearchInput = React.forwardRef( className="size-4 shrink-0 animate-scale-in" /> )} -
- {resultsShowing - ? resultsCount > 0 - ? t(language, 'search_results_count', resultsCount) - : t(language, 'search_no_results') - : ''} -
+ {children} onChange(event.target.value)} @@ -123,14 +112,11 @@ export const SearchInput = React.forwardRef( 'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-hidden transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7', isOpen ? '' : 'max-md:opacity-0' )} - aria-haspopup="listbox" - aria-controls={controlsId} + role="combobox" autoComplete="off" aria-autocomplete="list" + aria-haspopup="listbox" aria-expanded={value && isOpen ? 'true' : 'false'} - aria-activedescendant={ - cursor !== null ? `${controlsId}-${cursor}` : undefined - } // Forward ref={inputRef} /> diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index b261081db4..b61b6d53d9 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -1,29 +1,19 @@ 'use client'; -import { readStreamableValue } from 'ai/rsc'; import assertNever from 'assert-never'; import React from 'react'; -import type { Assistant } from '@/components/AI'; +import { type Assistant, useAI } from '@/components/AI'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { assert } from 'ts-essentials'; -import { useAI } from '../AI'; -import { useTrackEvent } from '../Insights'; + import { Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchSectionResultItem } from './SearchSectionResultItem'; -import { - type OrderedComputedResult, - searchAllSiteContent, - searchSiteSpaceContent, - streamRecommendedQuestions, -} from './server-actions'; +import type { OrderedComputedResult } from './server-actions'; export interface SearchResultsRef { - moveUp(): void; - moveDown(): void; select(): void; } @@ -32,14 +22,6 @@ type ResultType = | { type: 'question'; id: string; query: string; assistant: Assistant } | { type: 'recommended-question'; id: string; question: string }; -/** - * We cache the recommended questions globally to avoid calling the API multiple times - * when re-opening the search modal. The cache is per space, so that we can - * have different recommended questions for different spaces of the same site. - * It should not be used outside of an useEffect. - */ -const cachedRecommendedQuestions: Map = new Map(); - /** * Fetch the results of the keyboard navigable elements to display for a query: * - Recommended questions if no query is provided. @@ -51,138 +33,20 @@ export const SearchResults = React.forwardRef(function SearchResults( children?: React.ReactNode; id: string; query: string; - global: boolean; - siteSpaceId: string; - onResultsChanged?: (results: ResultType[], showing: boolean, cursor: number | null) => void; + results: ResultType[]; + fetching: boolean; + cursor: number | null; }, ref: React.Ref ) { - const { children, id, query, global, siteSpaceId, onResultsChanged } = props; + const { children, id, query, results, fetching, cursor } = props; const language = useLanguage(); - const trackEvent = useTrackEvent(); - const [resultsState, setResultsState] = React.useState<{ - results: ResultType[]; - fetching: boolean; - }>({ results: [], fetching: true }); - const [cursor, setCursor] = React.useState(null); - const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); - - const { assistants } = useAI(); - const withAI = assistants.length > 0; - - React.useEffect(() => { - if (!query) { - if (!withAI) { - setResultsState({ results: [], fetching: false }); - return; - } - - if (cachedRecommendedQuestions.has(siteSpaceId)) { - const results = cachedRecommendedQuestions.get(siteSpaceId); - assert( - results, - `Cached recommended questions should be set for site-space ${siteSpaceId}` - ); - setResultsState({ results, fetching: false }); - return; - } - - setResultsState({ results: [], fetching: false }); - - let cancelled = false; - - // We currently have a bug where the same question can be returned multiple times. - // This is a workaround to avoid that. - const questions = new Set(); - const recommendedQuestions: ResultType[] = []; - - const timeout = setTimeout(async () => { - if (cancelled) { - return; - } - - const response = await streamRecommendedQuestions({ siteSpaceId }); - for await (const entry of readStreamableValue(response.stream)) { - if (!entry) { - continue; - } - - const { question } = entry; - if (questions.has(question)) { - continue; - } - - questions.add(question); - recommendedQuestions.push({ - type: 'recommended-question', - id: question, - question, - }); - cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); - - if (!cancelled) { - setResultsState({ results: [...recommendedQuestions], fetching: false }); - } - } - }, 100); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - } - setResultsState((prev) => ({ results: prev.results, fetching: true })); - let cancelled = false; - const timeout = setTimeout(async () => { - const results = await (global - ? searchAllSiteContent(query) - : searchSiteSpaceContent(query)); - - if (cancelled) { - return; - } - - if (!results) { - setResultsState({ results: [], fetching: false }); - return; - } - - setResultsState({ results, fetching: false }); - - trackEvent({ - type: 'search_type_query', - query, - }); - }, 350); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - }, [query, global, trackEvent, withAI, siteSpaceId]); - const results: ResultType[] = React.useMemo(() => { - onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor); - if (!withAI) { - return resultsState.results; - } - return withAskTriggers(resultsState.results, query, assistants); - }, [resultsState.results, query, withAI]); - - React.useEffect(() => { - if (!query) { - // Reset the cursor when there's no query - setCursor(null); - } else if (results.length > 0) { - // Auto-focus the first result - setCursor(0); - } - }, [results, query]); + const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); // Scroll to the active result. React.useEffect(() => { - onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor); if (cursor === null || !refs.current[cursor]) { return; } @@ -193,19 +57,6 @@ export const SearchResults = React.forwardRef(function SearchResults( }); }, [cursor]); - const moveBy = React.useCallback( - (delta: number) => { - setCursor((prev) => { - if (prev === null) { - return 0; - } - - return Math.max(Math.min(prev + delta, results.length - 1), 0); - }); - }, - [results] - ); - const select = React.useCallback(() => { if (cursor === null || !refs.current[cursor]) { return; @@ -217,18 +68,14 @@ export const SearchResults = React.forwardRef(function SearchResults( React.useImperativeHandle( ref, () => ({ - moveUp: () => { - moveBy(-1); - }, - moveDown: () => { - moveBy(1); - }, select, }), - [moveBy, select] + [select] ); - if (resultsState.fetching) { + const { assistants } = useAI(); + + if (fetching) { return (
@@ -344,28 +191,3 @@ export const SearchResults = React.forwardRef(function SearchResults(
); }); - -/** - * Add a "Ask " item at the top of the results list. - */ -function withAskTriggers( - results: ResultType[], - query: string, - assistants: Assistant[] -): ResultType[] { - const without = results.filter((result) => result.type !== 'question'); - - if (query.length === 0) { - return without; - } - - return [ - ...assistants.map((assistant, index) => ({ - type: 'question' as const, - id: `question-${index}`, - query, - assistant, - })), - ...(without ?? []), - ]; -} diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts new file mode 100644 index 0000000000..680031a029 --- /dev/null +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -0,0 +1,173 @@ +import { readStreamableValue } from 'ai/rsc'; +import React from 'react'; + +import { assert } from 'ts-essentials'; + +import { + type OrderedComputedResult, + searchAllSiteContent, + searchSiteSpaceContent, + streamRecommendedQuestions, +} from './server-actions'; + +import { type Assistant, useAI } from '@/components/AI'; +import { useTrackEvent } from '../Insights'; + +export type ResultType = + | OrderedComputedResult + | { type: 'question'; id: string; query: string; assistant: Assistant } + | { type: 'recommended-question'; id: string; question: string }; + +/** + * We cache the recommended questions globally to avoid calling the API multiple times + * when re-opening the search modal. The cache is per space, so that we can + * have different recommended questions for different spaces of the same site. + * It should not be used outside of an useEffect. + */ +const cachedRecommendedQuestions: Map = new Map(); + +export function useSearchResults(props: { + disabled: boolean; + query: string; + siteSpaceId: string; + global: boolean; + withAI: boolean; +}) { + const { disabled, query, siteSpaceId, global } = props; + + const trackEvent = useTrackEvent(); + + const [resultsState, setResultsState] = React.useState<{ + results: ResultType[]; + fetching: boolean; + }>({ results: [], fetching: false }); + + const { assistants } = useAI(); + const withAI = assistants.length > 0; + + React.useEffect(() => { + if (disabled) { return; } + if (!query) { + if (!withAI) { + setResultsState({ results: [], fetching: false }); + return; + } + + if (cachedRecommendedQuestions.has(siteSpaceId)) { + const results = cachedRecommendedQuestions.get(siteSpaceId); + assert( + results, + `Cached recommended questions should be set for site-space ${siteSpaceId}` + ); + setResultsState({ results, fetching: false }); + return; + } + + setResultsState({ results: [], fetching: false }); + + let cancelled = false; + + // We currently have a bug where the same question can be returned multiple times. + // This is a workaround to avoid that. + const questions = new Set(); + const recommendedQuestions: ResultType[] = []; + + const timeout = setTimeout(async () => { + if (cancelled) { + return; + } + + const response = await streamRecommendedQuestions({ siteSpaceId }); + for await (const entry of readStreamableValue(response.stream)) { + if (!entry) { + continue; + } + + const { question } = entry; + if (questions.has(question)) { + continue; + } + + questions.add(question); + recommendedQuestions.push({ + type: 'recommended-question', + id: question, + question, + }); + cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); + + if (!cancelled) { + setResultsState({ results: [...recommendedQuestions], fetching: false }); + } + } + }, 100); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + } + setResultsState((prev) => ({ results: prev.results, fetching: true })); + let cancelled = false; + const timeout = setTimeout(async () => { + const results = await (global + ? searchAllSiteContent(query) + : searchSiteSpaceContent(query)); + + if (cancelled) { + return; + } + + if (!results) { + setResultsState({ results: [], fetching: false }); + return; + } + + setResultsState({ results, fetching: false }); + + trackEvent({ + type: 'search_type_query', + query, + }); + }, 350); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [disabled, query, global, trackEvent, withAI, siteSpaceId]); + + const aiEnrichedResults: ResultType[] = React.useMemo(() => { + if (!withAI) { + return resultsState.results; + } + return withAskTriggers(resultsState.results, query, assistants); + }, [resultsState.results, query, withAI]); + + return { ...resultsState, results: aiEnrichedResults }; +} + +/** + * Add a "Ask " item at the top of the results list. + */ +function withAskTriggers( + results: ResultType[], + query: string, + assistants: Assistant[] +): ResultType[] { + const without = results.filter((result) => result.type !== 'question'); + + if (query.length === 0) { + return without; + } + + return [ + ...assistants.map((assistant, index) => ({ + type: 'question' as const, + id: `question-${index}`, + query, + assistant, + })), + ...(without ?? []), + ]; +} diff --git a/packages/gitbook/src/components/Search/useSearchResultsCursor.ts b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts new file mode 100644 index 0000000000..8fe3d5f92b --- /dev/null +++ b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts @@ -0,0 +1,35 @@ +import React from 'react'; +import type { ResultType } from './useSearchResults'; + +export function useSearchResultsCursor(props: { query: string; results: ResultType[] }) { + const [cursor, setCursor] = React.useState(null); + const { query, results } = props; + + React.useEffect(() => { + if (!props.query) { + // Reset the cursor when there's no query + setCursor(null); + } else if (results.length > 0) { + // Auto-focus the first result + setCursor(0); + } + }, [results, query]); + + const moveBy = React.useCallback( + (delta: number) => { + setCursor((prev) => { + if (prev === null) { + return 0; + } + return Math.max(Math.min(prev + delta, results.length - 1), 0); + }); + }, + [results] + ); + + return { + cursor, + setCursor, + moveBy, + }; +} From c05d54bec0416d035689c8f62dcda2f847767975 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Fri, 12 Sep 2025 10:43:02 +0100 Subject: [PATCH 6/8] chore: remove console and format --- .../src/components/Search/SearchContainer.tsx | 12 +++--------- .../src/components/Search/useSearchResults.ts | 4 +++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index 2bbb07e9b5..3ecf6a5872 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -19,7 +19,6 @@ import { SearchScopeToggle } from './SearchScopeToggle'; import { useSearch } from './useSearch'; import { useSearchResults } from './useSearchResults'; import { useSearchResultsCursor } from './useSearchResultsCursor'; -import { on } from 'events'; interface SearchContainerProps { siteSpaceId: string; @@ -65,8 +64,6 @@ export function SearchContainer(props: SearchContainerProps) { const onClose = React.useCallback( async (to?: string) => { - console.log('Closing search', state); - console.trace(); setSearchState((prev) => prev ? { @@ -109,7 +106,6 @@ export function SearchContainer(props: SearchContainerProps) { ); const onOpen = React.useCallback(() => { - console.log("Opening search", state); if (state?.open) { return; } @@ -207,11 +203,7 @@ export function SearchContainer(props: SearchContainerProps) { ) : null } rootProps={{ - defaultOpen: Boolean(visible && (state?.open ?? false)), - onOpenChange: (open) => { - if (open) { onOpen(); } - else { onClose(); } - }, + open: Boolean(visible && (state?.open ?? false)), modal: isMobile, }} contentProps={{ @@ -222,8 +214,10 @@ export function SearchContainer(props: SearchContainerProps) { onInteractOutside: (event) => { // Don't close if clicking on the search input itself if (searchInputRef.current?.contains(event.target as Node)) { + event.preventDefault(); return; } + onClose(); }, sideOffset: 8, collisionPadding: { diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 680031a029..2bffd5f5e8 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -46,7 +46,9 @@ export function useSearchResults(props: { const withAI = assistants.length > 0; React.useEffect(() => { - if (disabled) { return; } + if (disabled) { + return; + } if (!query) { if (!withAI) { setResultsState({ results: [], fetching: false }); From 9d1b70271aad8b115db87961edf87fc9f1b68fa5 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Mon, 22 Sep 2025 11:50:08 +0100 Subject: [PATCH 7/8] resolve conflict --- .../src/components/Search/useSearchResults.ts | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 2bffd5f5e8..cc3de7fa25 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -6,12 +6,14 @@ import { assert } from 'ts-essentials'; import { type OrderedComputedResult, searchAllSiteContent, - searchSiteSpaceContent, + searchCurrentSiteSpaceContent, + searchSpecificSiteSpaceContent, streamRecommendedQuestions, } from './server-actions'; import { type Assistant, useAI } from '@/components/AI'; import { useTrackEvent } from '../Insights'; +import type { SearchScope } from './useSearch'; export type ResultType = | OrderedComputedResult @@ -30,10 +32,11 @@ export function useSearchResults(props: { disabled: boolean; query: string; siteSpaceId: string; - global: boolean; + siteSpaceIds: string[]; + scope: SearchScope; withAI: boolean; }) { - const { disabled, query, siteSpaceId, global } = props; + const { disabled, query, siteSpaceId, siteSpaceIds, scope } = props; const trackEvent = useTrackEvent(); @@ -112,9 +115,25 @@ export function useSearchResults(props: { setResultsState((prev) => ({ results: prev.results, fetching: true })); let cancelled = false; const timeout = setTimeout(async () => { - const results = await (global - ? searchAllSiteContent(query) - : searchSiteSpaceContent(query)); + const results = await (() => { + if (scope === 'all') { + // Search all content on the site + return searchAllSiteContent(query); + } + if (scope === 'default') { + // Search the current section's variant + matched/default variant for other sections + return searchCurrentSiteSpaceContent(query, siteSpaceId); + } + if (scope === 'extended') { + // Search all variants of the current section + return searchSpecificSiteSpaceContent(query, siteSpaceIds); + } + if (scope === 'current') { + // Search only the current section's current variant + return searchSpecificSiteSpaceContent(query, [siteSpaceId]); + } + throw new Error(`Unhandled search scope: ${scope}`); + })(); if (cancelled) { return; @@ -137,14 +156,14 @@ export function useSearchResults(props: { cancelled = true; clearTimeout(timeout); }; - }, [disabled, query, global, trackEvent, withAI, siteSpaceId]); + }, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds, disabled]); const aiEnrichedResults: ResultType[] = React.useMemo(() => { if (!withAI) { return resultsState.results; } return withAskTriggers(resultsState.results, query, assistants); - }, [resultsState.results, query, withAI]); + }, [resultsState.results, query, withAI, assistants]); return { ...resultsState, results: aiEnrichedResults }; } From 77efddf4f4e211e90c5d7d5aaf513f5a75ddc439 Mon Sep 17 00:00:00 2001 From: Brett Jephson Date: Mon, 22 Sep 2025 12:27:25 +0100 Subject: [PATCH 8/8] fix cursor --- .../gitbook/src/components/Search/useSearchResults.ts | 2 +- .../src/components/Search/useSearchResultsCursor.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index cc3de7fa25..a2354e0b9b 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -163,7 +163,7 @@ export function useSearchResults(props: { return resultsState.results; } return withAskTriggers(resultsState.results, query, assistants); - }, [resultsState.results, query, withAI, assistants]); + }, [resultsState.results, query, withAI]); return { ...resultsState, results: aiEnrichedResults }; } diff --git a/packages/gitbook/src/components/Search/useSearchResultsCursor.ts b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts index 8fe3d5f92b..db369c0f63 100644 --- a/packages/gitbook/src/components/Search/useSearchResultsCursor.ts +++ b/packages/gitbook/src/components/Search/useSearchResultsCursor.ts @@ -6,14 +6,18 @@ export function useSearchResultsCursor(props: { query: string; results: ResultTy const { query, results } = props; React.useEffect(() => { - if (!props.query) { + if (!query) { // Reset the cursor when there's no query setCursor(null); - } else if (results.length > 0) { + } + }, [query]); + + React.useEffect(() => { + if (results.length > 0) { // Auto-focus the first result setCursor(0); } - }, [results, query]); + }, [results]); const moveBy = React.useCallback( (delta: number) => {