[Accessibility] Fix: screen reader does not announce theme change and nested nav label (#6061)

## Why are these changes needed?
fix the accessibility issue that screen reader doesn't announce the
theme when it changes

## Related issue number
#5631 (13) (31) (59)

---------

Co-authored-by: peterychang <49209570+peterychang@users.noreply.github.com>
This commit is contained in:
cheng-tan 2025-03-21 18:36:56 -04:00 committed by GitHub
parent 26364e3dfb
commit 6f784ac186
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,4 +1,6 @@
document.addEventListener('DOMContentLoaded', function () {
let liveRegion = createLiveRegion();
document.querySelectorAll('.copybtn').forEach(button => {
// Return focus to copy button after activation
button.addEventListener('click', async function (event) {
@ -7,6 +9,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Perform the copy action
await copyToClipboard(this);
announceMessage(liveRegion, 'Copied to clipboard');
// Restore the focus
focusedElement.focus();
@ -29,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
// Set active TOCtree elements with aria-current=page
document.querySelectorAll('.bd-sidenav .active').forEach(function(element) {
document.querySelectorAll('.bd-sidenav .active').forEach(function (element) {
element.setAttribute('aria-current', 'page');
});
@ -47,9 +50,27 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
const themeButton = document.querySelector('.theme-switch-button');
if (themeButton) {
themeButton.addEventListener('click', function () {
const mode = document.documentElement.getAttribute('data-mode');
announceMessage(liveRegion, `Theme changed to ${mode}`);
});
}
// Enhance TOC sections for accessibility
document.querySelectorAll('.caption-text').forEach(caption => {
const sectionTitle = caption.textContent.trim();
const captionContainer = caption.closest('p.caption');
if (!captionContainer) return;
// Find and process navigation lists that belong to this section
findSectionNav(captionContainer, sectionTitle);
});
// Version dropdown menu is dynamically generated after page load. Listen for changes to set aria-selected
var observer = new MutationObserver(function() {
document.querySelectorAll('.dropdown-item').forEach(function(element) {
var observer = new MutationObserver(function () {
document.querySelectorAll('.dropdown-item').forEach(function (element) {
if (element.classList.contains('active')) {
element.setAttribute('aria-selected', 'true');
}
@ -61,7 +82,7 @@ document.addEventListener('DOMContentLoaded', function () {
var config = { childList: true, subtree: true };
if (targetNode) {
observer.observe(targetNode, config);
observer.observe(targetNode, config);
}
});
@ -70,29 +91,118 @@ async function copyToClipboard(button) {
const codeBlock = document.querySelector(targetSelector);
try {
await navigator.clipboard.writeText(codeBlock.textContent);
// Add a visually hidden element for screen readers to announce
const srAnnouncement = document.createElement('div');
srAnnouncement.textContent = 'Copied to clipboard';
srAnnouncement.setAttribute('role', 'status');
srAnnouncement.setAttribute('aria-live', 'polite');
srAnnouncement.style.position = 'absolute';
srAnnouncement.style.width = '1px';
srAnnouncement.style.height = '1px';
srAnnouncement.style.padding = '0';
srAnnouncement.style.margin = '-1px';
srAnnouncement.style.overflow = 'hidden';
srAnnouncement.style.clipPath = 'inset(50%)';
srAnnouncement.style.whiteSpace = 'nowrap';
srAnnouncement.style.border = '0';
document.body.appendChild(srAnnouncement);
// Remove the announcement element after it's been read
setTimeout(() => {
document.body.removeChild(srAnnouncement);
}, 3000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
}
function createLiveRegion() {
const liveRegion = document.createElement('div');
liveRegion.setAttribute('role', 'status');
liveRegion.setAttribute('aria-live', 'assertive');
liveRegion.style.position = 'absolute';
liveRegion.style.width = '1px';
liveRegion.style.height = '1px';
liveRegion.style.padding = '0';
liveRegion.style.margin = '-1px';
liveRegion.style.overflow = 'hidden';
liveRegion.style.clipPath = 'inset(50%)';
liveRegion.style.whiteSpace = 'nowrap'; ` `
liveRegion.style.border = '0';
document.body.appendChild(liveRegion);
return liveRegion;
}
function announceMessage(liveRegion, message) {
liveRegion.textContent = '';
setTimeout(() => {
liveRegion.textContent = message;
}, 50);
}
/**
* Find navigation lists belonging to a section and process them
*/
function findSectionNav(captionContainer, sectionTitle) {
let nextElement = captionContainer.nextElementSibling;
while (nextElement) {
if (nextElement.classList && nextElement.classList.contains('caption')) {
break;
}
if (nextElement.matches('ul.bd-sidenav')) {
enhanceNavList(nextElement, sectionTitle);
}
nextElement = nextElement.nextElementSibling;
}
}
/**
* Process a navigation list by enhancing its links for accessibility
*/
function enhanceNavList(navList, sectionTitle) {
const topLevelItems = navList.querySelectorAll(':scope > li');
topLevelItems.forEach(item => {
const link = item.querySelector(':scope > a.reference.internal');
if (!link) return;
const linkText = link.textContent.trim();
link.setAttribute('aria-label', `${sectionTitle}: ${linkText}`);
enhanceExpandableSections(item, link, linkText, sectionTitle);
});
}
/**
* Process expandable sections (details elements) within a navigation item
*/
function enhanceExpandableSections(item, parentLink, parentText, sectionTitle) {
const detailsElements = item.querySelectorAll('details');
detailsElements.forEach(details => {
enhanceToggleButton(details, parentText);
enhanceNestedLinks(details, parentLink, parentText, sectionTitle);
});
}
/**
* Make toggle buttons more accessible by adding appropriate aria labels
*/
function enhanceToggleButton(details, parentText) {
const summary = details.querySelector('summary');
if (!summary) return;
function updateToggleLabel() {
const isExpanded = details.hasAttribute('open');
const action = isExpanded ? 'Collapse' : 'Expand';
summary.setAttribute('aria-label', `${action} ${parentText} section`);
}
updateToggleLabel();
summary.addEventListener('click', () => {
setTimeout(updateToggleLabel, 10);
});
}
/**
* Enhance nested links with hierarchical aria-labels
*/
function enhanceNestedLinks(details, parentLink, parentText, sectionTitle) {
const nestedLinks = details.querySelectorAll('a.reference.internal');
nestedLinks.forEach(link => {
const linkText = link.textContent.trim();
const parentLabel = parentLink.getAttribute('aria-label');
if (parentLabel) {
link.setAttribute('aria-label', `${parentLabel}: ${linkText}`);
} else {
link.setAttribute('aria-label', `${sectionTitle}: ${parentText}: ${linkText}`);
}
});
}