Skip to content

Commit 08a903b

Browse files
Copilotpelikhan
andauthored
docs: add aria-live enhancement for search results accessibility (#issue) (#21019)
* Initial plan * docs: add aria-live enhancement script for search results accessibility Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 1cb4a5a commit 08a903b

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

docs/src/components/CustomHead.astro

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ const filteredHead = head.filter(({ tag, attrs }) => {
7474
import '../scripts/responsive-tables.ts';
7575
</script>
7676

77+
<!-- Search accessibility enhancement: adds aria-live to the dynamic results container -->
78+
<script>
79+
import '../scripts/search-aria.ts';
80+
</script>
81+
7782
<script is:inline>
7883
(function() {
7984
const storedTheme = localStorage.getItem('starlight-theme') || 'auto';

docs/src/scripts/search-aria.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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

Comments
 (0)