/** * ScreenReaderOptimization - WCAG 2.1 AA Compliant Screen Reader Support * * Features: * - Screen reader detection and optimization * - Live region management * - ARIA announcements * - Content adaptation for screen readers * - Voice control support * - WCAG 2.1 AA compliance */ class ScreenReaderOptimization { constructor() { this.isScreenReaderActive = false; this.liveRegion = null; this.announcementQueue = []; this.isAnnouncing = false; this.voiceControlEnabled = false; this.init(); } /** * Initialize screen reader optimization */ init() { this.detectScreenReader(); this.createLiveRegion(); this.setupVoiceControl(); this.optimizeContent(); this.setupEventListeners(); } /** * Detect if screen reader is active */ detectScreenReader() { // Multiple detection methods const methods = [ this.detectByNavigator, this.detectByAria, this.detectByTiming, this.detectByBehavior ]; let positiveDetections = 0; methods.forEach(method => { if (method.call(this)) { positiveDetections++; } }); // Consider screen reader active if majority of methods detect it this.isScreenReaderActive = positiveDetections >= 2; if (this.isScreenReaderActive) { document.body.classList.add('screen-reader-active'); this.announceToScreenReader('Screen reader detected, accessibility features enabled'); } } /** * Detect screen reader by navigator properties */ detectByNavigator() { // Check for common screen reader indicators return window.speechSynthesis !== undefined || window.navigator.userAgent.includes('JAWS') || window.navigator.userAgent.includes('NVDA') || window.navigator.userAgent.includes('VoiceOver') || window.navigator.userAgent.includes('TalkBack'); } /** * Detect screen reader by ARIA support */ detectByAria() { // Check if ARIA attributes are supported and used const testElement = document.createElement('div'); testElement.setAttribute('role', 'region'); testElement.setAttribute('aria-live', 'polite'); return testElement.getAttribute('role') === 'region' && testElement.getAttribute('aria-live') === 'polite'; } /** * Detect screen reader by timing analysis */ detectByTiming() { // Screen readers often have different timing patterns const startTime = performance.now(); // Create a test element that screen readers would process differently const testElement = document.createElement('div'); testElement.setAttribute('aria-hidden', 'false'); testElement.textContent = 'Screen reader test'; document.body.appendChild(testElement); const endTime = performance.now(); document.body.removeChild(testElement); // If processing takes unusually long, might indicate screen reader return (endTime - startTime) > 50; } /** * Detect screen reader by user behavior */ detectByBehavior() { // Check for keyboard-only navigation patterns let keyboardOnly = true; document.addEventListener('mousedown', () => { keyboardOnly = false; }, { once: true }); // If user navigates with keyboard extensively, likely screen reader user setTimeout(() => { if (keyboardOnly) { this.isScreenReaderActive = true; } }, 5000); return false; // Async detection } /** * Create live region for announcements */ createLiveRegion() { this.liveRegion = document.createElement('div'); this.liveRegion.setAttribute('aria-live', 'polite'); this.liveRegion.setAttribute('aria-atomic', 'true'); this.liveRegion.className = 'sr-only live-region'; this.liveRegion.style.position = 'absolute'; this.liveRegion.style.left = '-10000px'; this.liveRegion.style.width = '1px'; this.liveRegion.style.height = '1px'; this.liveRegion.style.overflow = 'hidden'; document.body.appendChild(this.liveRegion); } /** * Setup voice control */ setupVoiceControl() { if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { this.voiceControlEnabled = true; this.initializeVoiceRecognition(); } } /** * Initialize voice recognition */ initializeVoiceRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; this.recognition = new SpeechRecognition(); this.recognition.continuous = false; this.recognition.interimResults = false; this.recognition.lang = document.documentElement.lang || 'nl-NL'; this.recognition.onresult = (event) => { const command = event.results[0][0].transcript.toLowerCase(); this.handleVoiceCommand(command); }; this.recognition.onerror = (event) => { console.log('Voice recognition error:', event.error); }; } /** * Handle voice commands * * @param {string} command Voice command */ handleVoiceCommand(command) { const commands = { 'zoeken': () => this.focusSearch(), 'navigatie': () => this.focusNavigation(), 'hoofdinhoud': () => this.focusMainContent(), 'home': () => this.goHome(), 'terug': () => this.goBack(), 'volgende': () => this.goNext(), 'vorige': () => this.goPrevious(), 'stop': () => this.stopReading() }; for (const [keyword, action] of Object.entries(commands)) { if (command.includes(keyword)) { action(); this.announceToScreenReader(`Voice command: ${keyword}`); break; } } } /** * Optimize content for screen readers */ optimizeContent() { this.addMissingLabels(); this.enhanceHeadings(); this.improveTableAccessibility(); this.optimizeImages(); this.enhanceLinks(); this.addLandmarks(); } /** * Add missing labels to form elements */ addMissingLabels() { const inputs = document.querySelectorAll('input, select, textarea'); inputs.forEach(input => { if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) { const id = input.id || 'input-' + Math.random().toString(36).substr(2, 9); input.id = id; // Try to find associated label let label = document.querySelector(`label[for="${id}"]`); if (!label) { // Create label from placeholder or name const labelText = input.placeholder || input.name || input.type || 'Input'; label = document.createElement('label'); label.textContent = labelText; label.setAttribute('for', id); label.className = 'sr-only'; input.parentNode.insertBefore(label, input); } input.setAttribute('aria-label', label.textContent); } }); } /** * Enhance headings for better structure */ enhanceHeadings() { const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); headings.forEach((heading, index) => { // Add proper ARIA attributes heading.setAttribute('role', 'heading'); heading.setAttribute('aria-level', heading.tagName.substring(1)); // Add unique ID for navigation if (!heading.id) { heading.id = 'heading-' + index; } // Add heading anchor for navigation if (!heading.querySelector('.heading-anchor')) { const anchor = document.createElement('a'); anchor.href = '#' + heading.id; anchor.className = 'heading-anchor sr-only'; anchor.textContent = 'Link to this heading'; anchor.setAttribute('aria-label', 'Link to this heading'); heading.appendChild(anchor); } }); } /** * Improve table accessibility */ improveTableAccessibility() { const tables = document.querySelectorAll('table'); tables.forEach(table => { // Add table caption if missing if (!table.querySelector('caption')) { const caption = document.createElement('caption'); caption.textContent = 'Tabel ' + (tables.indexOf(table) + 1); caption.className = 'sr-only'; table.insertBefore(caption, table.firstChild); } // Add scope to headers const headers = table.querySelectorAll('th'); headers.forEach(header => { if (!header.getAttribute('scope')) { const scope = header.parentElement.tagName === 'THEAD' ? 'col' : 'row'; header.setAttribute('scope', scope); } }); // Add table description if (!table.getAttribute('aria-describedby')) { const description = document.createElement('div'); description.id = 'table-desc-' + Math.random().toString(36).substr(2, 9); description.className = 'sr-only'; description.textContent = 'Data table with ' + headers.length + ' columns'; table.parentNode.insertBefore(description, table); table.setAttribute('aria-describedby', description.id); } }); } /** * Optimize images for screen readers */ optimizeImages() { const images = document.querySelectorAll('img'); images.forEach(img => { // Ensure alt text exists if (!img.alt && !img.getAttribute('aria-label')) { // Try to get alt text from nearby text const nearbyText = this.getNearbyText(img); img.alt = nearbyText || 'Afbeelding'; img.setAttribute('role', 'img'); } // Add long description if needed if (img.title && !img.getAttribute('aria-describedby')) { const descId = 'img-desc-' + Math.random().toString(36).substr(2, 9); const description = document.createElement('div'); description.id = descId; description.className = 'sr-only'; description.textContent = img.title; img.parentNode.insertBefore(description, img.nextSibling); img.setAttribute('aria-describedby', descId); } }); } /** * Enhance links for screen readers */ enhanceLinks() { const links = document.querySelectorAll('a'); links.forEach(link => { // Ensure accessible name if (!link.textContent.trim() && !link.getAttribute('aria-label')) { const href = link.getAttribute('href') || ''; link.setAttribute('aria-label', 'Link: ' + href); } // Add external link indication if (link.hostname !== window.location.hostname) { if (!link.getAttribute('aria-label')?.includes('external')) { const currentLabel = link.getAttribute('aria-label') || link.textContent; link.setAttribute('aria-label', currentLabel + ' (externe link)'); } } // Add file type and size for file links const href = link.getAttribute('href'); if (href && this.isFileLink(href)) { const fileInfo = this.getFileInfo(href); if (!link.getAttribute('aria-label')?.includes(fileInfo.type)) { const currentLabel = link.getAttribute('aria-label') || link.textContent; link.setAttribute('aria-label', currentLabel + ` (${fileInfo.type}, ${fileInfo.size})`); } } }); } /** * Add landmarks for better navigation */ addLandmarks() { // Add main landmark if missing if (!document.querySelector('[role="main"], main')) { const content = document.querySelector('article, .content, #content'); if (content) { content.setAttribute('role', 'main'); content.id = 'main-content'; } } // Add navigation landmark if missing if (!document.querySelector('[role="navigation"], nav')) { const nav = document.querySelector('.nav, .navigation, #navigation'); if (nav) { nav.setAttribute('role', 'navigation'); nav.setAttribute('aria-label', 'Hoofdmenu'); } } // Add search landmark if missing if (!document.querySelector('[role="search"]')) { const search = document.querySelector('.search, #search, [type="search"]'); if (search) { search.setAttribute('role', 'search'); search.setAttribute('aria-label', 'Zoeken'); } } // Add contentinfo landmark if missing if (!document.querySelector('[role="contentinfo"], footer')) { const footer = document.querySelector('footer'); if (footer) { footer.setAttribute('role', 'contentinfo'); footer.setAttribute('aria-label', 'Voettekst'); } } } /** * Setup event listeners for dynamic content */ setupEventListeners() { // Monitor DOM changes for new content const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { this.optimizeNode(node); } }); } }); }); observer.observe(document.body, { childList: true, subtree: true }); // Handle page changes window.addEventListener('popstate', () => { setTimeout(() => this.optimizeContent(), 100); }); // Handle AJAX content loading window.addEventListener('load', () => { setTimeout(() => this.optimizeContent(), 100); }); } /** * Optimize a specific node * * @param {Node} node Node to optimize */ optimizeNode(node) { if (node.nodeType !== Node.ELEMENT_NODE) return; // Optimize based on tag type switch (node.tagName.toLowerCase()) { case 'img': this.optimizeImages(); break; case 'a': this.enhanceLinks(); break; case 'table': this.improveTableAccessibility(); break; case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': this.enhanceHeadings(); break; case 'input': case 'select': case 'textarea': this.addMissingLabels(); break; } } /** * Get nearby text for an element * * @param {Element} element Element to check * @return {string} Nearby text */ getNearbyText(element) { // Check parent text content let parent = element.parentElement; if (parent) { const text = parent.textContent.replace(element.alt || '', '').trim(); if (text) return text; } // Check previous sibling let prev = element.previousElementSibling; if (prev && prev.textContent.trim()) { return prev.textContent.trim(); } // Check next sibling let next = element.nextElementSibling; if (next && next.textContent.trim()) { return next.textContent.trim(); } return ''; } /** * Check if link is a file link * * @param {string} href Link href * @return {boolean} True if file link */ isFileLink(href) { const fileExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.zip', '.rar']; return fileExtensions.some(ext => href.toLowerCase().includes(ext)); } /** * Get file information * * @param {string} href File link * @return {object} File information */ getFileInfo(href) { const extension = href.split('.').pop().toLowerCase(); const types = { 'pdf': { type: 'PDF document', size: '' }, 'doc': { type: 'Word document', size: '' }, 'docx': { type: 'Word document', size: '' }, 'xls': { type: 'Excel spreadsheet', size: '' }, 'xlsx': { type: 'Excel spreadsheet', size: '' }, 'ppt': { type: 'PowerPoint presentation', size: '' }, 'pptx': { type: 'PowerPoint presentation', size: '' }, 'zip': { type: 'ZIP archive', size: '' }, 'rar': { type: 'RAR archive', size: '' } }; return types[extension] || { type: 'File', size: '' }; } /** * Announce message to screen readers * * @param {string} message Message to announce * @param {string} priority Priority level */ announceToScreenReader(message, priority = 'polite') { if (!this.isScreenReaderActive) return; // Queue announcement if currently announcing if (this.isAnnouncing) { this.announcementQueue.push({ message, priority }); return; } this.isAnnouncing = true; // Create temporary live region if needed const tempRegion = document.createElement('div'); tempRegion.setAttribute('aria-live', priority); tempRegion.setAttribute('aria-atomic', 'true'); tempRegion.className = 'sr-only'; tempRegion.textContent = message; document.body.appendChild(tempRegion); // Remove after announcement setTimeout(() => { document.body.removeChild(tempRegion); this.isAnnouncing = false; // Process next announcement in queue if (this.announcementQueue.length > 0) { const next = this.announcementQueue.shift(); this.announceToScreenReader(next.message, next.priority); } }, 1000); } /** * Voice control methods */ startVoiceRecognition() { if (this.voiceControlEnabled && this.recognition) { this.recognition.start(); this.announceToScreenReader('Voice control activated'); } } stopVoiceRecognition() { if (this.voiceControlEnabled && this.recognition) { this.recognition.stop(); this.announceToScreenReader('Voice control deactivated'); } } /** * Navigation methods for voice control */ focusSearch() { const searchInput = document.getElementById('search-input'); if (searchInput) { searchInput.focus(); this.announceToScreenReader('Search focused'); } } focusNavigation() { const navigation = document.querySelector('[role="navigation"]'); if (navigation) { navigation.focus(); this.announceToScreenReader('Navigation focused'); } } focusMainContent() { const mainContent = document.getElementById('main-content'); if (mainContent) { mainContent.focus(); this.announceToScreenReader('Main content focused'); } } goHome() { window.location.href = '/'; } goBack() { window.history.back(); } goNext() { // Implementation depends on context const nextButton = document.querySelector('.next, [aria-label*="next"]'); if (nextButton) { nextButton.click(); } } goPrevious() { // Implementation depends on context const prevButton = document.querySelector('.previous, [aria-label*="previous"]'); if (prevButton) { prevButton.click(); } } stopReading() { // Stop any ongoing screen reader activity window.speechSynthesis.cancel(); this.announceToScreenReader('Reading stopped'); } } // Initialize screen reader optimization when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.screenReaderOptimization = new ScreenReaderOptimization(); });