algolia / algolia/docsearch

DocSearch incorrectly implements WAI-ARIA 1.2 ComboBox design pattern

Open
#1,014 2 comments 11 reactions 0 assignees View on GitHub
v2
Dominant language
TypeScript
Stars
4.4k
Forks
439
Avg merge
14h 32m
Merged PRs (30d)
19

Description

The default DocSearch autocomplete implementation returns results to a listbox that incorrectly implements the [WAI-ARIA 1.2 ComboBox design pattern](https://w3c.github.io/aria-practices/#combobox).

**What is the current behavior?**

1. Interactive elements with `[role="option"]` contain a template that renders `a[href]` elements, which results in nested interactive controls. `[role="option"]` and `[aria-selected]` should be applied to the `a[href]` rather than the containing element.
2. The element referenced by `[aria-activedescendant]` on the `input[role="combobox"]` should be one of the `a[href][role="option"]` elements.
3. `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.
4. Non-semantic container `div` and `span` elements within the `[role="listbox"]` element should have `[role="presentation"]`, to maintain proper semantic structure for the listbox.
5. 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.
6. 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:

```js
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 (




);
}
```
[1]: https://github.com/algolia/docsearch-configs
[2]: https://docsearch.algolia.com/docs/config-file
[3]: https://jsfiddle.net/

Contributor guide

Open the contributing guide

Research direction

Start with the default DocSearch autocomplete implementation and inspect the rendered dropdown with debug enabled, focusing on the autocomplete:opened, autocomplete:updated, and autocomplete:cursorchanged entry points described here. Done means the combobox and listbox follow the cited WAI-ARIA 1.2 pattern, including valid option nesting, distinct accessible names, correct active-descendant behavior, and no double voicing.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.