|
| 1 | +/** |
| 2 | + * Search Accessibility Enhancement |
| 3 | + * |
| 4 | + * Starlight/pagefind renders the search results list dynamically via JavaScript. |
| 5 | + * Static analysis tools cannot detect the `aria-live` region on the results |
| 6 | + * container because it doesn't exist in the initial HTML. This script adds |
| 7 | + * `aria-live="polite"` and `aria-atomic="false"` to the results container after |
| 8 | + * it is inserted into the DOM, ensuring screen readers announce result counts |
| 9 | + * as the user types. |
| 10 | + * |
| 11 | + * The observer disconnects itself once the element is enhanced to avoid |
| 12 | + * unnecessary DOM observation overhead. On Astro client-side navigation the |
| 13 | + * observer is replaced so only one observer is active at any time. |
| 14 | + */ |
| 15 | + |
| 16 | +const RESULTS_SELECTORS = [ |
| 17 | + // Starlight ≥ 0.20 pagefind-ui results wrapper |
| 18 | + '.pagefind-ui__results', |
| 19 | + // Fallback: the generic results list inside the search dialog |
| 20 | + 'dialog[aria-label] ul[role="listbox"]', |
| 21 | + 'dialog[aria-label] [role="status"]', |
| 22 | +]; |
| 23 | + |
| 24 | +/** Active observer — only one exists at a time. */ |
| 25 | +let activeObserver: MutationObserver | null = null; |
| 26 | + |
| 27 | +/** |
| 28 | + * Tries to find and enhance the search results container. |
| 29 | + * Returns true when the element was found and enhanced. |
| 30 | + */ |
| 31 | +function applyAriaLive(): boolean { |
| 32 | + for (const selector of RESULTS_SELECTORS) { |
| 33 | + const el = document.querySelector(selector); |
| 34 | + if (el && !el.getAttribute('aria-live')) { |
| 35 | + el.setAttribute('aria-live', 'polite'); |
| 36 | + el.setAttribute('aria-atomic', 'false'); |
| 37 | + return true; |
| 38 | + } |
| 39 | + } |
| 40 | + return false; |
| 41 | +} |
| 42 | + |
| 43 | +function observeSearchDialog(): void { |
| 44 | + // Disconnect any previous observer before starting a new one. |
| 45 | + if (activeObserver) { |
| 46 | + activeObserver.disconnect(); |
| 47 | + activeObserver = null; |
| 48 | + } |
| 49 | + |
| 50 | + // If the element is already present (e.g. revisiting a page via back/forward |
| 51 | + // cache), enhance it immediately without spinning up an observer. |
| 52 | + if (applyAriaLive()) { |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + // Watch for the search dialog / results container being added to the DOM. |
| 57 | + // Disconnect as soon as the element is found and enhanced. |
| 58 | + activeObserver = new MutationObserver(() => { |
| 59 | + if (applyAriaLive()) { |
| 60 | + activeObserver?.disconnect(); |
| 61 | + activeObserver = null; |
| 62 | + } |
| 63 | + }); |
| 64 | + |
| 65 | + activeObserver.observe(document.body, { childList: true, subtree: true }); |
| 66 | +} |
| 67 | + |
| 68 | +// Run on initial page load |
| 69 | +if (document.readyState === 'loading') { |
| 70 | + document.addEventListener('DOMContentLoaded', observeSearchDialog); |
| 71 | +} else { |
| 72 | + observeSearchDialog(); |
| 73 | +} |
| 74 | + |
| 75 | +// Re-run on Astro client-side navigation (replaces the previous observer) |
| 76 | +document.addEventListener('astro:page-load', observeSearchDialog); |
0 commit comments