From 6f784ac18609bf187dfe7dbe1a84ec1de59f18b5 Mon Sep 17 00:00:00 2001 From: cheng-tan Date: Fri, 21 Mar 2025 18:36:56 -0400 Subject: [PATCH] [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> --- .../autogen-core/docs/src/_static/custom.js | 162 +++++++++++++++--- 1 file changed, 136 insertions(+), 26 deletions(-) diff --git a/python/packages/autogen-core/docs/src/_static/custom.js b/python/packages/autogen-core/docs/src/_static/custom.js index 8c77eea71..3cfd649c7 100644 --- a/python/packages/autogen-core/docs/src/_static/custom.js +++ b/python/packages/autogen-core/docs/src/_static/custom.js @@ -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}`); + } + }); +}