Skip to content

Commit 2fc2127

Browse files
authored
RND-7985: accessible search (#3637)
1 parent 379d486 commit 2fc2127

File tree

20 files changed

+420
-236
lines changed

20 files changed

+420
-236
lines changed

.changeset/dry-melons-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Make search accessible

packages/gitbook/src/components/Search/SearchContainer.tsx

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { t, useLanguage } from '@/intl/client';
34
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
45
import { useRouter } from 'next/navigation';
56
import React, { useRef } from 'react';
@@ -16,6 +17,8 @@ import { SearchInput } from './SearchInput';
1617
import { SearchResults, type SearchResultsRef } from './SearchResults';
1718
import { SearchScopeToggle } from './SearchScopeToggle';
1819
import { useSearch } from './useSearch';
20+
import { useSearchResults } from './useSearchResults';
21+
import { useSearchResultsCursor } from './useSearchResultsCursor';
1922

2023
interface 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+
}

packages/gitbook/src/components/Search/SearchInput.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface SearchInputProps {
1616
withAI: boolean;
1717
isOpen: boolean;
1818
className?: string;
19+
children?: React.ReactNode;
1920
}
2021

2122
// Size classes for medium size button
@@ -26,7 +27,17 @@ const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
2627
*/
2728
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
2829
function SearchInput(props, ref) {
29-
const { onChange, onKeyDown, onFocus, value, withAI, isOpen, className } = props;
30+
const {
31+
onChange,
32+
onKeyDown,
33+
onFocus,
34+
value,
35+
withAI,
36+
isOpen,
37+
className,
38+
children,
39+
...rest
40+
} = props;
3041
const inputRef = useRef<HTMLInputElement>(null);
3142

3243
const language = useLanguage();
@@ -84,8 +95,9 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
8495
className="size-4 shrink-0 animate-scale-in"
8596
/>
8697
)}
87-
98+
{children}
8899
<input
100+
{...rest}
89101
type="text"
90102
onFocus={onFocus}
91103
onKeyDown={onKeyDown}
@@ -100,6 +112,12 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
100112
'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',
101113
isOpen ? '' : 'max-md:opacity-0'
102114
)}
115+
role="combobox"
116+
autoComplete="off"
117+
aria-autocomplete="list"
118+
aria-haspopup="listbox"
119+
aria-expanded={value && isOpen ? 'true' : 'false'}
120+
// Forward
103121
ref={inputRef}
104122
/>
105123
{!isOpen ? <Shortcut /> : null}

packages/gitbook/src/components/Search/SearchPageResultItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
1414
},
1515
ref: React.Ref<HTMLAnchorElement>
1616
) {
17-
const { query, item, active } = props;
17+
const { query, item, active, ...rest } = props;
1818
const language = useLanguage();
1919

2020
const breadcrumbs =
@@ -41,6 +41,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
4141
spaceId: item.spaceId,
4242
},
4343
}}
44+
aria-label={tString(language, 'search_page_result_title', item.title)}
45+
{...rest}
4446
>
4547
{breadcrumbs.length > 0 ? (
4648
<div

packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
1414
},
1515
ref: React.Ref<HTMLAnchorElement>
1616
) {
17-
const { question, recommended = false, active, assistant } = props;
17+
const { question, recommended = false, active, assistant, ...rest } = props;
1818
const language = useLanguage();
1919
const getLinkProp = useSearchLink();
2020

@@ -38,6 +38,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
3838
active={active}
3939
leadingIcon={recommended ? 'search' : assistant.icon}
4040
className={recommended ? 'pr-1.5' : ''}
41+
{...rest}
4142
>
4243
{recommended ? (
4344
question

packages/gitbook/src/components/Search/SearchResultItem.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
5151
: null,
5252
className
5353
)}
54+
role="option"
5455
{...rest}
5556
>
5657
<div className="size-4 shrink-0 text-tint-subtle">

0 commit comments

Comments
 (0)