docsearch
docsearch copied to clipboard
DocSearch incorrectly implements WAI-ARIA 1.2 ComboBox design pattern
The default DocSearch autocomplete implementation returns results to a listbox that incorrectly implements the WAI-ARIA 1.2 ComboBox design pattern.
What is the current behavior?
- Interactive elements with
[role="option"]
contain a template that rendersa[href]
elements, which results in nested interactive controls.[role="option"]
and[aria-selected]
should be applied to thea[href]
rather than the containing element. - The element referenced by
[aria-activedescendant]
on theinput[role="combobox"]
should be one of thea[href][role="option"]
elements. -
a[href]
elements should not all have the same[aria-label="Link to the result"]
attribute, which makes it impossible to distinguish between the suggestions. - Non-semantic container
div
andspan
elements within the[role="listbox"]
element should have[role="presentation"]
, to maintain proper semantic structure for the listbox. - The Algolia logo link should not be a descendant of the
[role="listbox"]
. A listbox can only contain options or groups of options as children. - We should reduce double voicing of options when a subcategory matches suggestion title.
What is the expected behavior?
Here are the event handlers I added to fix these problems in the rendered DOM:
function DocSearch() {
useEffect(() => {
// the following comes from docsearch.min.js
// eslint-disable-next-line no-undef
const search = docsearch({
apiKey: 'MY_API_KEY',
indexName: 'react-spectrum',
inputSelector: '#algolia-doc-search',
debug: false // Set debug to true to inspect the dropdown
});
// autocomplete:opened event handler
search.autocomplete.on('autocomplete:opened', event => {
const input = event.target;
// WAI-ARIA 1.2 uses aria-controls rather than aria-owns on combobox.
if (!input.hasAttribute('aria-controls') && input.hasAttribute('aria-owns')) {
input.setAttribute('aria-controls', input.getAttribute('aria-owns'));
}
// Listbox dropdown should have an accessibility name.
const listbox = input.parentElement.querySelector(`#${input.getAttribute('aria-controls')}`);
listbox.setAttribute('aria-label', 'Search results');
});
// autocomplete:updated event handler
search.autocomplete.on('autocomplete:updated', event => {
const input = event.target;
const listbox = input.parentElement.querySelector(`#${input.getAttribute('aria-controls')}`);
// Add aria-hidden to the logo in the footer so that it does not break the listbox accessibility tree structure.
const footer = listbox.querySelector('.algolia-docsearch-footer');
if (footer && !footer.hasAttribute('aria-hidden')) {
footer.setAttribute('aria-hidden', 'true');
footer.querySelector('a[href]').tabIndex = -1;
}
// With no results, the message should be an option in the listbox.
const noResults = listbox.querySelector('.algolia-docsearch-suggestion--no-results');
if (noResults) {
noResults.setAttribute('role', 'option');
// Use aria-live to ensure that the noResults message gets announced.
noResults.querySelector('.algolia-docsearch-suggestion--title').setAttribute('aria-live', 'assertive');
}
// Clean up WAI-ARIA listbox structure by setting role=presentation to non-semantic div and span elements.
[...listbox.querySelectorAll('div:not([role]), span:not([role])')].forEach(element => element.setAttribute('role', 'presentation'));
// Clean up WAI-ARIA listbox structure by correcting improper nesting of interactive controls.
[...listbox.querySelectorAll('.ds-suggestion[role="option"]')].forEach(element => {
const link = element.querySelector('a.algolia-docsearch-suggestion');
if (link) {
// Remove static aria-label="Link to the result" that causes all options to be named the same.
link.removeAttribute('aria-label');
// The interactive element should have role="option", a unique id, and tabIndex.
link.setAttribute('role', 'option');
link.id = `${element.id}-link`;
link.tabIndex = -1;
// containing element should have role="presentation"
element.setAttribute('role', 'presentation');
// Move aria-selected to the link, and update aria-activedescendant on input.
if (element.hasAttribute('aria-selected')) {
link.setAttribute('aria-selected', element.getAttribute('aria-selected'));
element.removeAttribute('aria-selected');
input.setAttribute('aria-activedescendant', link.id);
}
// Fix double voicing of options when subcategory matches suggestion title.
const subcategoryColumn = link.querySelector('.algolia-docsearch-suggestion--subcategory-column');
const suggestionTitle = link.querySelector('.algolia-docsearch-suggestion--title');
if (subcategoryColumn.textContent.trim() === suggestionTitle.textContent.trim()) {
subcategoryColumn.setAttribute('aria-hidden', 'true');
}
}
});
});
// When navigating listbox, move aria-selected to link.
search.autocomplete.on('autocomplete:cursorchanged', event => {
const input = event.target;
const listbox = input.parentElement.querySelector(`#${input.getAttribute('aria-controls')}`);
let element = listbox.querySelector('a.algolia-docsearch-suggestion[aria-selected]');
if (element) {
element.removeAttribute('aria-selected');
}
element = listbox.querySelector('.ds-suggestion.ds-cursor[aria-selected]');
if (element) {
let link = element.querySelector('a.algolia-docsearch-suggestion');
// Move aria-selected to the link, and update aria-activedescendant on input.
if (link) {
link.id = `${element.id}-link`;
link.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', link.id);
element.removeAttribute('aria-selected');
}
}
});
}, []);
return (
<div role="search">
<SearchField
aria-label="Search"
UNSAFE_className={docsStyle.docSearchBox}
id="algolia-doc-search"
placeholder="Search" />
</div>
);
}
For when we investigate the root causes / solutions, this is relating to docsearch v2, not v3 (which possibly also has issues, but it would surprise me if it's the same).
Thanks a lot for filing @majornista!
@Haroenv Pretty sure v3 will exhibit similar problems.