docsearch icon indicating copy to clipboard operation
docsearch copied to clipboard

DocSearch incorrectly implements WAI-ARIA 1.2 ComboBox design pattern

Open majornista opened this issue 3 years ago • 2 comments

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?

  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:

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>
  );
}

majornista avatar Jun 24 '21 19:06 majornista

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 avatar Jun 24 '21 22:06 Haroenv

@Haroenv Pretty sure v3 will exhibit similar problems.

majornista avatar Jun 28 '21 19:06 majornista