microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
docs/docusaurus/src/theme/SearchBar/index.jsx
74lines · modecode
| 1 | import React, { useEffect, useRef } from 'react'; |
| 2 | import SearchBar from '@theme-original/SearchBar'; |
| 3 | |
| 4 | // WI-07 accessibility wrapper for the local search input. |
| 5 | // |
| 6 | // The upstream @easyops-cn/autocomplete.js (via @easyops-cn/docusaurus-search-local) |
| 7 | // already promotes the navbar search input to the WAI-ARIA APG Combobox pattern at |
| 8 | // runtime: it sets role="combobox", aria-autocomplete, aria-expanded (toggled on |
| 9 | // open/close), aria-activedescendant (on arrow navigation), and aria-owns pointing at |
| 10 | // the generated role="listbox" element. The popup items receive role="option". |
| 11 | // |
| 12 | // Two divergences from the current APG Combobox pattern remain, and this wrapper |
| 13 | // closes both without ejecting the upstream component (keeping the swizzle resilient |
| 14 | // to package upgrades): |
| 15 | // 1. The popup is wired with the legacy aria-owns attribute instead of aria-controls, |
| 16 | // so we mirror aria-owns onto aria-controls and keep them in sync. |
| 17 | // 2. The "See all results" footer link is rendered as a bare interactive child of the |
| 18 | // role="listbox" element, which is not an allowed listbox child (WCAG 1.3.1 / axe |
| 19 | // aria-required-children). We tag the footer's anchor itself with role="option" so |
| 20 | // the listbox only owns valid leaf options; tagging the wrapping div instead would |
| 21 | // leave the focusable anchor as a nested interactive descendant (axe nested-interactive). |
| 22 | export default function SearchBarWrapper(props) { |
| 23 | const containerRef = useRef(null); |
| 24 | |
| 25 | useEffect(() => { |
| 26 | const root = containerRef.current; |
| 27 | if (!root) { |
| 28 | return undefined; |
| 29 | } |
| 30 | |
| 31 | const sync = () => { |
| 32 | const input = root.querySelector('input.navbar__search-input'); |
| 33 | if (input) { |
| 34 | const owns = input.getAttribute('aria-owns'); |
| 35 | if (owns) { |
| 36 | if (input.getAttribute('aria-controls') !== owns) { |
| 37 | input.setAttribute('aria-controls', owns); |
| 38 | } |
| 39 | } else if (input.hasAttribute('aria-controls')) { |
| 40 | input.removeAttribute('aria-controls'); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | const listbox = root.querySelector('[role="listbox"]'); |
| 45 | if (listbox) { |
| 46 | const footerLink = listbox.querySelector('[class*="hitFooter"] a'); |
| 47 | if (footerLink && footerLink.getAttribute('role') !== 'option') { |
| 48 | footerLink.setAttribute('role', 'option'); |
| 49 | } |
| 50 | } |
| 51 | }; |
| 52 | |
| 53 | // The combobox attributes are applied lazily, the first time the input is |
| 54 | // focused and the search index loads, and the popup contents are rebuilt on |
| 55 | // every keystroke, so observe the whole search container rather than reading |
| 56 | // the initial state once. |
| 57 | sync(); |
| 58 | const observer = new MutationObserver(sync); |
| 59 | observer.observe(root, { |
| 60 | subtree: true, |
| 61 | childList: true, |
| 62 | attributes: true, |
| 63 | attributeFilter: ['aria-owns'], |
| 64 | }); |
| 65 | |
| 66 | return () => observer.disconnect(); |
| 67 | }, []); |
| 68 | |
| 69 | return ( |
| 70 | <div ref={containerRef} style={{ display: 'contents' }}> |
| 71 | <SearchBar {...props} /> |
| 72 | </div> |
| 73 | ); |
| 74 | } |
| 75 | |