From 2dbc95946fcc0f98bc0c125626a79ed458c0f9f1 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 23 Jun 2021 13:51:28 -0400 Subject: [PATCH] fix(#1999): Improve accessibility in DocSearch results Update to #2016 to improve accessibility in DocSearch results so that they better implement the WAI-ARIA ComboBox design pattern. - Fixes a few other docs issues identified with aXe DevTools. - Fixes a couple console error messages with themeSwitcherButton and hamburgerButton. --- packages/dev/docs/src/Layout.js | 2 +- packages/dev/docs/src/client.js | 135 ++++++++++++++++++++++++++++---- packages/dev/docs/src/docs.css | 33 ++++++++ 3 files changed, 154 insertions(+), 16 deletions(-) diff --git a/packages/dev/docs/src/Layout.js b/packages/dev/docs/src/Layout.js index e43ce83961a..ebfedd6cf42 100644 --- a/packages/dev/docs/src/Layout.js +++ b/packages/dev/docs/src/Layout.js @@ -342,7 +342,7 @@ function Nav({currentPageName, pages}) { } - + diff --git a/packages/dev/docs/src/client.js b/packages/dev/docs/src/client.js index 5a2bcd72eed..4f8d9472e6c 100644 --- a/packages/dev/docs/src/client.js +++ b/packages/dev/docs/src/client.js @@ -70,21 +70,25 @@ function Hamburger() { let nav = document.querySelector('.' + docsStyle.nav); let main = document.querySelector('main'); let themeSwitcher = event.target.parentElement.nextElementSibling; - + let themeSwitcherButton = themeSwitcher.querySelector('button'); nav.classList.toggle(docsStyle.visible); if (nav.classList.contains(docsStyle.visible)) { setIsPressed(true); main.setAttribute('aria-hidden', 'true'); themeSwitcher.setAttribute('aria-hidden', 'true'); - themeSwitcher.querySelector('button').tabIndex = -1; + if (themeSwitcherButton) { + themeSwitcherButton.tabIndex = -1; + } nav.tabIndex = -1; nav.focus(); } else { setIsPressed(false); main.removeAttribute('aria-hidden'); themeSwitcher.removeAttribute('aria-hidden'); - themeSwitcher.querySelector('button').removeAttribute('tabindex'); + if (themeSwitcherButton) { + themeSwitcherButton.removeAttribute('tabindex'); + } nav.removeAttribute('tabindex'); } }; @@ -98,16 +102,21 @@ function Hamburger() { /* remove visible className and aria-attributes that make nav behave as a modal */ let removeVisible = (isNotResponsive = false) => { - hamburgerButton.setAttribute('aria-pressed', 'false'); - - if (nav.contains(document.activeElement) && !isNotResponsive) { - hamburgerButton.focus(); + setIsPressed(false); + let button = hamburgerButton.querySelector('button'); + if (button) { + if (nav.contains(document.activeElement) && !isNotResponsive) { + button.focus(); + } } nav.classList.remove(docsStyle.visible); main.removeAttribute('aria-hidden'); themeSwitcher.removeAttribute('aria-hidden'); - themeSwitcher.querySelector('button').removeAttribute('tabindex'); + let themeSwitcherButton = themeSwitcher.querySelector('button'); + if (themeSwitcherButton) { + themeSwitcherButton.removeAttribute('tabindex'); + } nav.removeAttribute('tabindex'); }; @@ -171,7 +180,7 @@ function Hamburger() { return (
- +
@@ -182,20 +191,116 @@ function DocSearch() { useEffect(() => { // the following comes from docsearch.min.js // eslint-disable-next-line no-undef - docsearch({ + const search = docsearch({ apiKey: '9b5a0967c8bb751b5048ecfc99917979', 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 ( - +
+ +
); } diff --git a/packages/dev/docs/src/docs.css b/packages/dev/docs/src/docs.css index bb669f584f9..521ce368ad3 100644 --- a/packages/dev/docs/src/docs.css +++ b/packages/dev/docs/src/docs.css @@ -587,6 +587,8 @@ h2.sectionHeader { left: 0; } + z-index: 1; + & :global(.algolia-autocomplete .ds-dropdown-menu) { border-color: var(--spectrum-alias-border-color-dark, var(--spectrum-global-color-gray-400)); border-radius: var(--spectrum-global-dimension-size-65); @@ -635,6 +637,17 @@ h2.sectionHeader { background: var(--spectrum-global-color-gray-300); } + & :global(.ds-suggestion.ds-cursor .algolia-docsearch-suggestion--subcategory-column:before) { + background: var(--spectrum-alias-border-color-focus); + width: var(--spectrum-selectlist-border-size-key-focus); + } + + & :global(.ds-suggestion.ds-cursor .algolia-docsearch-suggestion--content:before) { + background: var(--spectrum-alias-border-color-focus); + width: var(--spectrum-selectlist-border-size-key-focus); + left: calc(-1 * var(--spectrum-selectlist-border-size-key-focus)); + } + & :global(.ds-suggestion.ds-cursor .algolia-docsearch-suggestion:not(.suggestion-layout-simple) .algolia-docsearch-suggestion--content) { background-color: var(--spectrum-alias-background-color-hover-overlay); } @@ -649,6 +662,26 @@ h2.sectionHeader { } } +@media (max-width: 768px) { + .pageHeader { + & :global(.algolia-autocomplete) { + & :global(.algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column) { + color: var(--spectrum-global-color-gray-700); + opacity: 1; + } + + & :global(.algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after) { + content: "|\00A0"; + } + + & :global(.ds-suggestion.ds-cursor .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column:after) { + color: var(--spectrum-alias-border-color-focus); + font-weight: bold; + } + } + } +} + .docSearchBox { margin-inline-start: auto; }