/** * Accessibility Manager for LiveComponents * * Manages accessibility features for dynamic content updates: * - ARIA live regions for screen reader announcements * - Focus management with data-lc-keep-focus attribute * - Keyboard navigation preservation * - Screen reader-friendly update notifications * * WCAG 2.1 Compliance: * - 4.1.3 Status Messages (Level AA) * - 2.4.3 Focus Order (Level A) * - 2.1.1 Keyboard (Level A) */ export class AccessibilityManager { constructor() { /** * ARIA live region element for announcements * @type {HTMLElement|null} */ this.liveRegion = null; /** * Component-specific live regions * Map */ this.componentLiveRegions = new Map(); /** * Focus tracking for restoration * Map */ this.focusStates = new Map(); /** * Announcement queue for throttling * @type {Array<{message: string, priority: string}>} */ this.announcementQueue = []; /** * Announcement throttle timer * @type {number|null} */ this.announceTimer = null; /** * Throttle delay in milliseconds * @type {number} */ this.throttleDelay = 500; } /** * Initialize accessibility features * * Creates global live region and sets up initial state. */ initialize() { // Create global live region if not exists if (!this.liveRegion) { this.liveRegion = this.createLiveRegion('livecomponent-announcer', 'polite'); document.body.appendChild(this.liveRegion); } console.log('[AccessibilityManager] Initialized with ARIA live regions'); } /** * Create ARIA live region element * * @param {string} id - Element ID * @param {string} politeness - ARIA politeness level (polite|assertive) * @returns {HTMLElement} Live region element */ createLiveRegion(id, politeness = 'polite') { const region = document.createElement('div'); region.id = id; region.setAttribute('role', 'status'); region.setAttribute('aria-live', politeness); region.setAttribute('aria-atomic', 'true'); region.className = 'sr-only'; // Screen reader only styling // Add screen reader only styles region.style.position = 'absolute'; region.style.left = '-10000px'; region.style.width = '1px'; region.style.height = '1px'; region.style.overflow = 'hidden'; return region; } /** * Create component-specific live region * * Each component can have its own live region for isolated announcements. * * @param {string} componentId - Component identifier * @param {HTMLElement} container - Component container element * @param {string} politeness - ARIA politeness level * @returns {HTMLElement} Component live region */ createComponentLiveRegion(componentId, container, politeness = 'polite') { // Check if already exists let liveRegion = this.componentLiveRegions.get(componentId); if (!liveRegion) { liveRegion = this.createLiveRegion(`livecomponent-${componentId}-announcer`, politeness); container.appendChild(liveRegion); this.componentLiveRegions.set(componentId, liveRegion); } return liveRegion; } /** * Announce update to screen readers * * Uses ARIA live regions to announce updates without stealing focus. * Supports throttling to prevent announcement spam. * * @param {string} message - Message to announce * @param {string} priority - Priority level (polite|assertive) * @param {string|null} componentId - Optional component-specific announcement */ announce(message, priority = 'polite', componentId = null) { // Add to queue this.announcementQueue.push({ message, priority, componentId }); // Throttle announcements if (this.announceTimer) { clearTimeout(this.announceTimer); } this.announceTimer = setTimeout(() => { this.flushAnnouncements(); }, this.throttleDelay); } /** * Flush announcement queue * * Processes queued announcements and clears the queue. */ flushAnnouncements() { if (this.announcementQueue.length === 0) return; // Get most recent announcement (others are outdated) const announcement = this.announcementQueue[this.announcementQueue.length - 1]; this.announcementQueue = []; // Determine target live region let liveRegion = this.liveRegion; if (announcement.componentId) { const componentRegion = this.componentLiveRegions.get(announcement.componentId); if (componentRegion) { liveRegion = componentRegion; } } if (!liveRegion) { console.warn('[AccessibilityManager] No live region available for announcement'); return; } // Update politeness if needed if (announcement.priority === 'assertive') { liveRegion.setAttribute('aria-live', 'assertive'); } else { liveRegion.setAttribute('aria-live', 'polite'); } // Clear and set new message liveRegion.textContent = ''; // Use setTimeout to ensure screen reader picks up the change setTimeout(() => { liveRegion.textContent = announcement.message; }, 100); console.log(`[AccessibilityManager] Announced: "${announcement.message}" (${announcement.priority})`); } /** * Capture focus state before update * * Saves current focus state for potential restoration after update. * * @param {string} componentId - Component identifier * @param {HTMLElement} container - Component container element * @returns {FocusState} Focus state object */ captureFocusState(componentId, container) { const activeElement = document.activeElement; // Check if focus is within container if (!activeElement || !container.contains(activeElement)) { return null; } const focusState = { selector: this.getElementSelector(activeElement, container), tagName: activeElement.tagName, name: activeElement.name || null, id: activeElement.id || null, selectionStart: activeElement.selectionStart || null, selectionEnd: activeElement.selectionEnd || null, scrollTop: activeElement.scrollTop || 0, scrollLeft: activeElement.scrollLeft || 0, keepFocus: activeElement.hasAttribute('data-lc-keep-focus') }; this.focusStates.set(componentId, focusState); console.log(`[AccessibilityManager] Captured focus state for ${componentId}`, focusState); return focusState; } /** * Restore focus after update * * Restores focus to element marked with data-lc-keep-focus or previously focused element. * * @param {string} componentId - Component identifier * @param {HTMLElement} container - Component container element * @returns {boolean} True if focus was restored */ restoreFocus(componentId, container) { const focusState = this.focusStates.get(componentId); if (!focusState) { return false; } try { // Find element to focus let elementToFocus = null; // Priority 1: Element with data-lc-keep-focus attribute if (focusState.keepFocus) { elementToFocus = container.querySelector('[data-lc-keep-focus]'); } // Priority 2: Element matching saved selector if (!elementToFocus && focusState.selector) { elementToFocus = container.querySelector(focusState.selector); } // Priority 3: Element with same ID if (!elementToFocus && focusState.id) { elementToFocus = container.querySelector(`#${focusState.id}`); } // Priority 4: Element with same name if (!elementToFocus && focusState.name) { elementToFocus = container.querySelector(`[name="${focusState.name}"]`); } if (elementToFocus && elementToFocus.focus) { elementToFocus.focus(); // Restore selection for inputs/textareas if (elementToFocus.setSelectionRange && focusState.selectionStart !== null && focusState.selectionEnd !== null) { elementToFocus.setSelectionRange( focusState.selectionStart, focusState.selectionEnd ); } // Restore scroll position if (focusState.scrollTop > 0) { elementToFocus.scrollTop = focusState.scrollTop; } if (focusState.scrollLeft > 0) { elementToFocus.scrollLeft = focusState.scrollLeft; } console.log(`[AccessibilityManager] Restored focus to ${focusState.selector}`); return true; } } catch (error) { console.debug('[AccessibilityManager] Could not restore focus:', error); } finally { // Clean up focus state this.focusStates.delete(componentId); } return false; } /** * Get CSS selector for an element within a container * * @param {HTMLElement} element - Element to get selector for * @param {HTMLElement} container - Container element * @returns {string|null} CSS selector or null */ getElementSelector(element, container) { // Try ID (most specific) if (element.id) { return `#${element.id}`; } // Try name attribute if (element.name) { return `[name="${element.name}"]`; } // Try data-lc-key const lcKey = element.getAttribute('data-lc-key'); if (lcKey) { return `[data-lc-key="${lcKey}"]`; } // Try to build a path from container const path = []; let current = element; while (current && current !== container && current !== document.body) { let selector = current.tagName.toLowerCase(); // Add class if available if (current.className && typeof current.className === 'string') { const classes = current.className.split(' ').filter(c => c.trim()); if (classes.length > 0) { selector += '.' + classes.join('.'); } } // Add nth-child if needed for specificity if (current.parentElement) { const siblings = Array.from(current.parentElement.children); const index = siblings.indexOf(current) + 1; if (siblings.length > 1) { selector += `:nth-child(${index})`; } } path.unshift(selector); current = current.parentElement; } return path.length > 0 ? path.join(' > ') : null; } /** * Announce component update * * Convenience method for announcing component updates. * * @param {string} componentId - Component identifier * @param {string} updateType - Type of update (fragment|full|action) * @param {Object} metadata - Additional metadata */ announceUpdate(componentId, updateType, metadata = {}) { let message = ''; switch (updateType) { case 'fragment': message = `Updated ${metadata.fragmentName || 'content'}`; break; case 'full': message = 'Component updated'; break; case 'action': message = metadata.actionMessage || 'Action completed'; break; default: message = 'Content updated'; } this.announce(message, 'polite', componentId); } /** * Check if element should preserve keyboard navigation * * @param {HTMLElement} element - Element to check * @returns {boolean} True if keyboard navigation should be preserved */ shouldPreserveKeyboardNav(element) { // Interactive elements that should always preserve keyboard nav const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A']; if (interactiveTags.includes(element.tagName)) { return true; } // Elements with tabindex if (element.hasAttribute('tabindex')) { return true; } // Elements with role const interactiveRoles = [ 'button', 'link', 'textbox', 'searchbox', 'combobox', 'listbox', 'option', 'tab' ]; const role = element.getAttribute('role'); if (role && interactiveRoles.includes(role)) { return true; } return false; } /** * Cleanup component accessibility features * * @param {string} componentId - Component identifier */ cleanup(componentId) { // Remove component live region const liveRegion = this.componentLiveRegions.get(componentId); if (liveRegion && liveRegion.parentElement) { liveRegion.parentElement.removeChild(liveRegion); } this.componentLiveRegions.delete(componentId); // Clear focus state this.focusStates.delete(componentId); console.log(`[AccessibilityManager] Cleaned up accessibility for ${componentId}`); } /** * Get accessibility stats * * @returns {Object} Statistics about accessibility manager state */ getStats() { return { has_global_live_region: this.liveRegion !== null, component_live_regions: this.componentLiveRegions.size, tracked_focus_states: this.focusStates.size, pending_announcements: this.announcementQueue.length }; } } // Create singleton instance export const accessibilityManager = new AccessibilityManager(); export default accessibilityManager;