️(frontend) accessibility improvements on search components

Improve accessibility of search components by adding
appropriate ARIA attributes and enhancing screen reader
compatibility.
This commit is contained in:
Anthony LC
2026-06-10 16:51:02 +02:00
parent 7edbd4efcb
commit 6ce853312f
10 changed files with 69 additions and 39 deletions
@@ -213,7 +213,7 @@ test.describe('Doc grid move', () => {
.getByRole('heading', { name: 'Choose a new parent doc' }), .getByRole('heading', { name: 'Choose a new parent doc' }),
).toBeVisible(); ).toBeVisible();
const input = page.getByRole('combobox', { name: 'Quick search input' }); const input = page.getByRole('combobox', { name: 'Search' });
await input.click(); await input.click();
await input.fill(titleDoc2); await input.fill(titleDoc2);
@@ -303,7 +303,7 @@ test.describe('Doc grid move', () => {
.getByRole('heading', { name: 'Choose a new parent doc' }), .getByRole('heading', { name: 'Choose a new parent doc' }),
).toBeVisible(); ).toBeVisible();
const input = page.getByRole('combobox', { name: 'Quick search input' }); const input = page.getByRole('combobox', { name: 'Search' });
await input.click(); await input.click();
await input.fill(titleDoc2); await input.fill(titleDoc2);
@@ -168,7 +168,7 @@ test.describe('Document search', () => {
// Click on the filter to show all docs // Click on the filter to show all docs
await page await page
.getByLabel('Search results controls') .getByRole('switch', { name: 'Search in all documents' })
.getByText('All docs') .getByText('All docs')
.click(); .click();
@@ -43,6 +43,7 @@ export const QuickSearch = ({
inputValue, inputValue,
showInput = true, showInput = true,
label, label,
loading,
placeholder, placeholder,
beforeList, beforeList,
children, children,
@@ -64,6 +65,7 @@ export const QuickSearch = ({
> >
{showInput && ( {showInput && (
<QuickSearchInput <QuickSearchInput
label={label}
withSeparator={hasChildrens(children)} withSeparator={hasChildrens(children)}
inputValue={inputValue} inputValue={inputValue}
onFilter={onFilter} onFilter={onFilter}
@@ -74,7 +76,7 @@ export const QuickSearch = ({
</QuickSearchInput> </QuickSearchInput>
)} )}
{beforeList} {beforeList}
<Command.List id={listId} aria-label={label} role="listbox"> <Command.List id={listId} aria-busy={loading}>
<Box>{children}</Box> <Box>{children}</Box>
</Command.List> </Command.List>
</Command> </Command>
@@ -19,15 +19,17 @@ export const QuickSearchGroup = <T,>({
}: Props<T>) => { }: Props<T>) => {
return ( return (
<> <>
<Text {group.groupName && (
className="--docs--quick-search-group-title" <Text
as="h2" className="--docs--quick-search-group-title"
$weight="700" as="h2"
$size="sm" $weight="700"
$margin="none" $size="sm"
> $margin="none"
{group.groupName} >
</Text> {group.groupName}
</Text>
)}
<Command.Group <Command.Group
key={group.groupName} key={group.groupName}
forceMount={false} forceMount={false}
@@ -11,6 +11,7 @@ import { Box } from '../Box';
type QuickSearchInputProps = { type QuickSearchInputProps = {
inputValue?: string; inputValue?: string;
label?: string;
onFilter?: (str: string) => void; onFilter?: (str: string) => void;
placeholder?: string; placeholder?: string;
withSeparator?: boolean; withSeparator?: boolean;
@@ -18,6 +19,7 @@ type QuickSearchInputProps = {
}; };
export const QuickSearchInput = ({ export const QuickSearchInput = ({
inputValue, inputValue,
label,
onFilter, onFilter,
placeholder, placeholder,
children, children,
@@ -60,14 +62,12 @@ export const QuickSearchInput = ({
<Command.Input <Command.Input
ref={inputRef} ref={inputRef}
autoFocus={true} autoFocus={true}
aria-label={t('Quick search input')} aria-label={label ?? t('Search')}
aria-controls={listId} aria-controls={listId}
value={inputValue} value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')} placeholder={placeholder ?? t('Search')}
onValueChange={onFilter} onValueChange={onFilter}
maxLength={254} maxLength={254}
minLength={6}
data-testid="quick-search-input" data-testid="quick-search-input"
/> />
</Box> </Box>
@@ -1,10 +1,6 @@
import { createGlobalStyle } from 'styled-components'; import { createGlobalStyle } from 'styled-components';
export const QuickSearchStyle = createGlobalStyle` export const QuickSearchStyle = createGlobalStyle`
& *:focus-visible {
outline: none;
}
.quick-search-container { .quick-search-container {
[cmdk-root] { [cmdk-root] {
width: 100%; width: 100%;
@@ -95,9 +95,11 @@ export const DocSearchContent = ({
: [], : [],
}); });
if (search) { if (search && !loading) {
announce( announce(
t('{{count}} result(s) available', { count: elements.length }), elements.length === 0
? t('No documents found')
: t('{{count}} document found', { count: elements.length }),
'polite', 'polite',
); );
} }
@@ -9,43 +9,56 @@ import { useDocSearchFilterStore } from '../stores/useDocSearchFilterStore';
export const DocSearchFilters = () => { export const DocSearchFilters = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setFilter, filter } = useDocSearchFilterStore(); const { setFilter, filter } = useDocSearchFilterStore();
const isAll = filter === 'all';
const toggle = () => setFilter(isAll ? 'current' : 'all');
return ( return (
/** /**
* The switch is not focusable, so we wrap it in a div that can be focused * The switch is not focusable, so we wrap it in a div that can be focused
* and handle the keydown event to toggle the switch with * and handle the keydown event to toggle the switch with
* space key for accessibility reasons * space or enter key for accessibility reasons
*/ */
<Box <Box
role="switch"
aria-checked={isAll}
aria-label={t('Search in all documents')}
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === ' ') { if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
setFilter(filter === 'all' ? 'current' : 'all'); toggle();
} }
}} }}
$css={css` $css={css`
&:focus-visible .c__switch__rail { &:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 2px
var(--c--contextuals--border--semantic--brand--primary); .c__switch__rail {
outline: none;
box-shadow: 0 0 0 2px
var(--c--contextuals--border--semantic--brand--primary);
}
} }
// Remove the default focus style of the switch component // Remove the default focus style of the switch component
.c__checkbox:focus-within { .c__checkbox {
border: none; border: none;
box-shadow: none; box-shadow: none;
outline: 0; outline: 0;
} }
& *:focus-visible {
outline: none;
}
`} `}
> >
<Switch <Switch
labelSide="right" labelSide="right"
label={t('All docs')} label={t('All docs')}
checked={filter === 'all'} checked={isAll}
onChange={() => setFilter(filter === 'all' ? 'current' : 'all')} onChange={toggle}
aria-label={t( aria-label={t(
'Toggle to search in all documents or only in current document', 'Toggle to search in all documents or only in current document',
)} )}
tabIndex={-1}
/> />
</Box> </Box>
); );
@@ -90,12 +90,25 @@ const DocSearchModalGlobal = ({
closeOnClickOutside closeOnClickOutside
size={isLargeScreen ? ModalSize.LARGE : ModalSize.FULL} size={isLargeScreen ? ModalSize.LARGE : ModalSize.FULL}
hideCloseButton hideCloseButton
aria-describedby="doc-search-modal-title" aria-label={t('Search for a document')}
aria-labelledby="doc-search-modal-title"
aria-describedby="doc-search-modal-description"
title={ title={
<> <>
<Text as="h2" $margin="0" $size="s" $align="flex-start"> <Text
as="h2"
$margin="0"
$size="s"
$align="flex-start"
id="doc-search-modal-title"
>
{t('Search for a document')} {t('Search for a document')}
</Text> </Text>
<Text id="doc-search-modal-description" className="sr-only">
{t(
'Search documents by name, navigate using arrows, and select a result with Enter.',
)}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;"> <Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal <ButtonCloseModal
aria-label={t('Close the search modal')} aria-label={t('Close the search modal')}
@@ -111,7 +124,6 @@ const DocSearchModalGlobal = ({
$justify="space-between" $justify="space-between"
className="--docs--doc-search-modal" className="--docs--doc-search-modal"
$padding={{ bottom: 'base' }} $padding={{ bottom: 'base' }}
aria-label={t('Search modal')}
> >
<QuickSearch <QuickSearch
label={t('Search documents')} label={t('Search documents')}
@@ -120,14 +132,17 @@ const DocSearchModalGlobal = ({
onFilter={handleInputSearch} onFilter={handleInputSearch}
beforeList={ beforeList={
<Box <Box
$margin={{ top: 'sm', horizontal: 'base' }} $margin={{ vertical: 'sm', horizontal: 'base' }}
$justify="space-between" $justify="space-between"
$direction="row" $direction="row"
$align="center" $align="center"
role="group"
aria-label={t('Search results controls')}
> >
<Text $color="textSecondary" $weight="700"> <Text
$color="textSecondary"
$weight="700"
role="status"
aria-live="polite"
>
<DocSearchStateText <DocSearchStateText
hasResults={results.length > 0} hasResults={results.length > 0}
filter={filter} filter={filter}
@@ -179,7 +179,7 @@ export const DocMoveModal = ({
</Text> </Text>
<Box $position="absolute" $css="top: 4px; right: 4px;"> <Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal <ButtonCloseModal
aria-label={t('Close the search modal')} aria-label={t('Close the move modal')}
onClick={onClose} onClick={onClose}
/> />
</Box> </Box>