11'use client' ;
22
3+ import { t , useLanguage } from '@/intl/client' ;
34import { CustomizationSearchStyle , type SiteSection } from '@gitbook/api' ;
45import { useRouter } from 'next/navigation' ;
56import React , { useRef } from 'react' ;
@@ -16,6 +17,8 @@ import { SearchInput } from './SearchInput';
1617import { SearchResults , type SearchResultsRef } from './SearchResults' ;
1718import { SearchScopeToggle } from './SearchScopeToggle' ;
1819import { useSearch } from './useSearch' ;
20+ import { useSearchResults } from './useSearchResults' ;
21+ import { useSearchResultsCursor } from './useSearchResultsCursor' ;
1922
2023interface SearchContainerProps {
2124 /** The current site space id. */
@@ -160,19 +163,6 @@ export function SearchContainer(props: SearchContainerProps) {
160163 } ;
161164 } , [ onClose ] ) ;
162165
163- const onKeyDown = ( event : React . KeyboardEvent < HTMLInputElement > ) => {
164- if ( event . key === 'ArrowUp' ) {
165- event . preventDefault ( ) ;
166- resultsRef . current ?. moveUp ( ) ;
167- } else if ( event . key === 'ArrowDown' ) {
168- event . preventDefault ( ) ;
169- resultsRef . current ?. moveDown ( ) ;
170- } else if ( event . key === 'Enter' ) {
171- event . preventDefault ( ) ;
172- resultsRef . current ?. select ( ) ;
173- }
174- } ;
175-
176166 const onChange = ( value : string ) => {
177167 setSearchState ( ( prev ) => ( {
178168 ask : withAI && ! withSearchAI ? ( prev ?. ask ?? null ) : null , // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
@@ -186,10 +176,38 @@ export function SearchContainer(props: SearchContainerProps) {
186176 const normalizedQuery = state ?. query ?. trim ( ) ?? '' ;
187177 const normalizedAsk = state ?. ask ?. trim ( ) ?? '' ;
188178
189- const showAsk = withSearchAI && normalizedAsk ; // withSearchAI && normalizedAsk;
179+ const showAsk = withSearchAI && normalizedAsk ;
190180
191181 const visible = viewport === 'desktop' ? ! isMobile : viewport === 'mobile' ? isMobile : true ;
192182
183+ const searchResultsId = `search-results-${ React . useId ( ) } ` ;
184+ const { results, fetching } = useSearchResults ( {
185+ disabled : ! ( state ?. query || withAI ) ,
186+ query : normalizedQuery ,
187+ siteSpaceId,
188+ siteSpaceIds,
189+ scope : state ?. scope ?? 'default' ,
190+ withAI : withAI ,
191+ } ) ;
192+ const searchValue = state ?. query ?? ( withSearchAI || ! withAI ? state ?. ask : null ) ?? '' ;
193+
194+ const { cursor, moveBy : moveCursorBy } = useSearchResultsCursor ( {
195+ query : normalizedQuery ,
196+ results,
197+ } ) ;
198+ const onKeyDown = ( event : React . KeyboardEvent < HTMLInputElement > ) => {
199+ if ( event . key === 'ArrowUp' ) {
200+ event . preventDefault ( ) ;
201+ moveCursorBy ( - 1 ) ;
202+ } else if ( event . key === 'ArrowDown' ) {
203+ event . preventDefault ( ) ;
204+ moveCursorBy ( 1 ) ;
205+ } else if ( event . key === 'Enter' ) {
206+ event . preventDefault ( ) ;
207+ resultsRef . current ?. select ( ) ;
208+ }
209+ } ;
210+
193211 return (
194212 < SearchAskProvider value = { searchAsk } >
195213 < Popover
@@ -210,20 +228,18 @@ export function SearchContainer(props: SearchContainerProps) {
210228 < SearchResults
211229 ref = { resultsRef }
212230 query = { normalizedQuery }
213- scope = { state ?. scope ?? 'default' }
214- siteSpaceId = { siteSpaceId }
215- siteSpaceIds = { siteSpaceIds }
231+ id = { searchResultsId }
232+ fetching = { fetching }
233+ results = { results }
234+ cursor = { cursor }
216235 />
217236 ) : null }
218237 { showAsk ? < SearchAskAnswer query = { normalizedAsk } /> : null }
219238 </ React . Suspense >
220239 ) : null
221240 }
222241 rootProps = { {
223- open : visible && ( state ?. open ?? false ) ,
224- onOpenChange : ( open ) => {
225- open ? onOpen ( ) : onClose ( ) ;
226- } ,
242+ open : Boolean ( visible && ( state ?. open ?? false ) ) ,
227243 modal : isMobile ,
228244 } }
229245 contentProps = { {
@@ -234,8 +250,10 @@ export function SearchContainer(props: SearchContainerProps) {
234250 onInteractOutside : ( event ) => {
235251 // Don't close if clicking on the search input itself
236252 if ( searchInputRef . current ?. contains ( event . target as Node ) ) {
253+ event . preventDefault ( ) ;
237254 return ;
238255 }
256+ onClose ( ) ;
239257 } ,
240258 sideOffset : 8 ,
241259 collisionPadding : {
@@ -252,14 +270,23 @@ export function SearchContainer(props: SearchContainerProps) {
252270 >
253271 < SearchInput
254272 ref = { searchInputRef }
255- value = { state ?. query ?? ( withSearchAI || ! withAI ? state ?. ask : null ) ?? '' }
273+ value = { searchValue }
256274 onFocus = { onOpen }
257275 onChange = { onChange }
258276 onKeyDown = { onKeyDown }
259277 withAI = { withSearchAI }
260278 isOpen = { state ?. open ?? false }
261279 className = { className }
262- />
280+ aria-controls = { searchResultsId }
281+ aria-activedescendant = {
282+ cursor !== null ? `${ searchResultsId } -${ cursor } ` : undefined
283+ }
284+ >
285+ < LiveResultsAnnouncer
286+ count = { results . length }
287+ showing = { Boolean ( searchValue ) && ! fetching }
288+ />
289+ </ SearchInput >
263290 </ Popover >
264291 { assistants
265292 . filter ( ( assistant ) => assistant . ui === true )
@@ -277,3 +304,21 @@ export function SearchContainer(props: SearchContainerProps) {
277304 </ SearchAskProvider >
278305 ) ;
279306}
307+
308+ /*
309+ * Screen reader announcement for search results.
310+ * Without it there is no feedback for screen reader users when a search returns no results.
311+ */
312+ function LiveResultsAnnouncer ( props : { count : number ; showing : boolean } ) {
313+ const { count, showing } = props ;
314+ const language = useLanguage ( ) ;
315+ return (
316+ < div className = "sr-only" aria-live = "assertive" role = "alert" aria-relevant = "all" >
317+ { showing
318+ ? count > 0
319+ ? t ( language , 'search_results_count' , count )
320+ : t ( language , 'search_no_results' )
321+ : '' }
322+ </ div >
323+ ) ;
324+ }
0 commit comments