Skip to content

Commit 133c3c6

Browse files
committed
Accessible search
1 parent 8ff1e3b commit 133c3c6

File tree

7 files changed

+89
-8
lines changed

7 files changed

+89
-8
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ export function SearchContainer(props: SearchContainerProps) {
161161

162162
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
163163

164+
const [resultsState, setResultsState] = React.useState<{
165+
count: number;
166+
showing: boolean;
167+
cursor: number | null;
168+
}>({
169+
count: 0,
170+
showing: false,
171+
cursor: null,
172+
});
173+
const searchResultsId = `search-results-${React.useId()}`;
164174
return (
165175
<SearchAskProvider value={searchAsk}>
166176
<Popover
@@ -177,6 +187,10 @@ export function SearchContainer(props: SearchContainerProps) {
177187
query={normalizedQuery}
178188
global={state?.global ?? false}
179189
siteSpaceId={siteSpaceId}
190+
onResultsChanged={(results, showing, cursor) => {
191+
setResultsState({ count: results.length, showing, cursor });
192+
}}
193+
id={searchResultsId}
180194
/>
181195
) : null}
182196
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
@@ -223,6 +237,10 @@ export function SearchContainer(props: SearchContainerProps) {
223237
withAI={withSearchAI}
224238
isOpen={state?.open ?? false}
225239
className={className}
240+
resultsShowing={resultsState.showing}
241+
resultsCount={resultsState.count}
242+
cursor={resultsState.cursor}
243+
controlsId={searchResultsId}
226244
/>
227245
</Popover>
228246
{assistants

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ interface SearchInputProps {
1616
withAI: boolean;
1717
isOpen: boolean;
1818
className?: string;
19+
resultsCount: number;
20+
cursor: number | null;
21+
resultsShowing: boolean;
22+
controlsId: string;
1923
}
2024

2125
// 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'];
2630
*/
2731
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
2832
function SearchInput(props, ref) {
29-
const { onChange, onKeyDown, onFocus, value, withAI, isOpen, className } = props;
33+
const {
34+
onChange,
35+
onKeyDown,
36+
onFocus,
37+
resultsCount,
38+
resultsShowing,
39+
cursor,
40+
value,
41+
withAI,
42+
isOpen,
43+
className,
44+
controlsId,
45+
} = props;
3046
const inputRef = useRef<HTMLInputElement>(null);
3147

3248
const language = useLanguage();
@@ -84,9 +100,16 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
84100
className="size-4 shrink-0 animate-scale-in"
85101
/>
86102
)}
87-
103+
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
104+
{resultsShowing
105+
? resultsCount > 0
106+
? `${resultsCount} results`
107+
: 'No results'
108+
: ''}
109+
</div>
88110
<input
89111
type="text"
112+
role="combobox"
90113
onFocus={onFocus}
91114
onKeyDown={onKeyDown}
92115
onChange={(event) => onChange(event.target.value)}
@@ -100,6 +123,15 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
100123
'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',
101124
isOpen ? '' : 'max-md:opacity-0'
102125
)}
126+
aria-haspopup="listbox"
127+
aria-controls={controlsId}
128+
autoComplete="off"
129+
aria-autocomplete="list"
130+
aria-expanded={value && isOpen ? 'true' : 'false'}
131+
aria-activedescendant={
132+
cursor !== null ? `${controlsId}-${cursor}` : undefined
133+
}
134+
// Forward
103135
ref={inputRef}
104136
/>
105137
{!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={`Page with 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">

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ const cachedRecommendedQuestions: Map<string, ResultType[]> = new Map();
4949
export const SearchResults = React.forwardRef(function SearchResults(
5050
props: {
5151
children?: React.ReactNode;
52+
id: string;
5253
query: string;
5354
global: boolean;
5455
siteSpaceId: string;
56+
onResultsChanged?: (results: ResultType[], showing: boolean, cursor: number | null) => void;
5557
},
5658
ref: React.Ref<SearchResultsRef>
5759
) {
58-
const { children, query, global, siteSpaceId } = props;
60+
const { children, id, query, global, siteSpaceId, onResultsChanged } = props;
5961

6062
const language = useLanguage();
6163
const trackEvent = useTrackEvent();
@@ -161,6 +163,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
161163
}, [query, global, trackEvent, withAI, siteSpaceId]);
162164

163165
const results: ResultType[] = React.useMemo(() => {
166+
onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor);
164167
if (!withAI) {
165168
return resultsState.results;
166169
}
@@ -179,6 +182,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
179182

180183
// Scroll to the active result.
181184
React.useEffect(() => {
185+
onResultsChanged?.(resultsState.results, !resultsState.fetching, cursor);
182186
if (cursor === null || !refs.current[cursor]) {
183187
return;
184188
}
@@ -258,8 +262,20 @@ export const SearchResults = React.forwardRef(function SearchResults(
258262
)
259263
) : (
260264
<>
261-
<div data-testid="search-results" className="flex flex-col gap-y-1">
265+
<div
266+
data-testid="search-results"
267+
className="flex flex-col gap-y-1"
268+
aria-label={`Search results`}
269+
id={id}
270+
role="listbox"
271+
aria-live="polite"
272+
>
262273
{results.map((item, index) => {
274+
const resultItemProps = {
275+
'aria-posinset': index + 1,
276+
'aria-setsize': results.length,
277+
id: `${id}-${index}`,
278+
};
263279
switch (item.type) {
264280
case 'page': {
265281
return (
@@ -271,6 +287,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
271287
query={query}
272288
item={item}
273289
active={index === cursor}
290+
{...resultItemProps}
274291
/>
275292
);
276293
}
@@ -284,6 +301,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
284301
question={query}
285302
active={index === cursor}
286303
assistant={item.assistant}
304+
{...resultItemProps}
287305
/>
288306
);
289307
}
@@ -298,6 +316,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
298316
active={index === cursor}
299317
assistant={assistants[0]!}
300318
recommended
319+
{...resultItemProps}
301320
/>
302321
);
303322
}
@@ -311,6 +330,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
311330
query={query}
312331
item={item}
313332
active={index === cursor}
333+
{...resultItemProps}
314334
/>
315335
);
316336
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
1313
},
1414
ref: React.Ref<HTMLAnchorElement>
1515
) {
16-
const { query, item, active } = props;
16+
const { query, item, active, ...rest } = props;
1717
const language = useLanguage();
1818

1919
return (
@@ -32,6 +32,8 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
3232
spaceId: item.spaceId,
3333
},
3434
}}
35+
aria-label={`Section${item.title ? ` with title '${item.title}'` : item.body ? ` with content '${getAbbreviatedBody(item.body, query)}'` : ''}`}
36+
{...rest}
3537
>
3638
<div className="grow border-tint-subtle border-l-2 pl-4">
3739
{item.title ? (
@@ -51,7 +53,12 @@ function highlightQueryInBody(body: string, query: string) {
5153
// Ensure the query to be highlighted is visible in the body.
5254
return (
5355
<p className="wrap-anywhere relative line-clamp-3 text-sm">
54-
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
56+
<HighlightQuery query={query} text={idx < 20 ? body : `${body.slice(idx - 10)}`} />
5557
</p>
5658
);
5759
}
60+
61+
function getAbbreviatedBody(body: string, query: string) {
62+
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
63+
return idx < 20 ? body.slice(0, 100) : `…${body.slice(idx - 10, idx + query.length + 30)}…`;
64+
}

0 commit comments

Comments
 (0)