Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-melons-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Make search accessible
89 changes: 67 additions & 22 deletions packages/gitbook/src/components/Search/SearchContainer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +17,8 @@ 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';

interface SearchContainerProps {
siteSpaceId: string;
Expand Down Expand Up @@ -131,19 +134,6 @@ export function SearchContainer(props: SearchContainerProps) {
};
}, [onClose]);

const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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)
Expand All @@ -157,10 +147,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 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<HTMLInputElement>) => {
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 (
<SearchAskProvider value={searchAsk}>
<Popover
Expand All @@ -175,19 +192,18 @@ export function SearchContainer(props: SearchContainerProps) {
<SearchResults
ref={resultsRef}
query={normalizedQuery}
global={state?.global ?? false}
siteSpaceId={siteSpaceId}
id={searchResultsId}
fetching={fetching}
results={results}
cursor={cursor}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
</React.Suspense>
) : null
}
rootProps={{
open: visible && (state?.open ?? false),
onOpenChange: (open) => {
open ? onOpen() : onClose();
},
open: Boolean(visible && (state?.open ?? false)),
modal: isMobile,
}}
contentProps={{
Expand All @@ -198,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: {
Expand All @@ -216,14 +234,23 @@ export function SearchContainer(props: SearchContainerProps) {
>
<SearchInput
ref={searchInputRef}
value={state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''}
value={searchValue}
onFocus={onOpen}
onChange={onChange}
onKeyDown={onKeyDown}
withAI={withSearchAI}
isOpen={state?.open ?? false}
className={className}
/>
aria-controls={searchResultsId}
aria-activedescendant={
cursor !== null ? `${searchResultsId}-${cursor}` : undefined
}
>
<LiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
</Popover>
{assistants
.filter((assistant) => assistant.ui === true)
Expand All @@ -241,3 +268,21 @@ export function SearchContainer(props: SearchContainerProps) {
</SearchAskProvider>
);
}

/*
* 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 (
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
{showing
? count > 0
? t(language, 'search_results_count', count)
: t(language, 'search_no_results')
: ''}
</div>
);
}
22 changes: 20 additions & 2 deletions packages/gitbook/src/components/Search/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface SearchInputProps {
withAI: boolean;
isOpen: boolean;
className?: string;
children?: React.ReactNode;
}

// Size classes for medium size button
Expand All @@ -26,7 +27,17 @@ const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
*/
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
function SearchInput(props, ref) {
const { onChange, onKeyDown, onFocus, value, withAI, isOpen, className } = props;
const {
onChange,
onKeyDown,
onFocus,
value,
withAI,
isOpen,
className,
children,
...rest
} = props;
const inputRef = useRef<HTMLInputElement>(null);

const language = useLanguage();
Expand Down Expand Up @@ -84,8 +95,9 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
className="size-4 shrink-0 animate-scale-in"
/>
)}

{children}
<input
{...rest}
type="text"
onFocus={onFocus}
onKeyDown={onKeyDown}
Expand All @@ -100,6 +112,12 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
'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'
)}
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
// Forward
ref={inputRef}
/>
{!isOpen ? <Shortcut /> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
},
ref: React.Ref<HTMLAnchorElement>
) {
const { query, item, active } = props;
const { query, item, active, ...rest } = props;
const language = useLanguage();

const breadcrumbs =
Expand All @@ -41,6 +41,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
spaceId: item.spaceId,
},
}}
aria-label={tString(language, 'search_page_result_title', item.title)}
{...rest}
>
{breadcrumbs.length > 0 ? (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion
},
ref: React.Ref<HTMLAnchorElement>
) {
const { question, recommended = false, active, assistant } = props;
const { question, recommended = false, active, assistant, ...rest } = props;
const language = useLanguage();
const getLinkProp = useSearchLink();

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const SearchResultItem = React.forwardRef(function SearchResultItem(
: null,
className
)}
role="option"
{...rest}
>
<div className="size-4 shrink-0 text-tint-subtle">
Expand Down
Loading