UNPKG

@sc4rfurryx/proteusjs

Version:

The Modern Web Development Framework for Accessible, Responsive, and High-Performance Applications. Intelligent container queries, fluid typography, WCAG compliance, and performance optimization.

1,607 lines (1,374 loc) 65.5 kB
/** * Comprehensive Accessibility Engine for ProteusJS * WCAG compliance, screen reader support, and cognitive accessibility */ import { logger } from '../utils/Logger'; export interface AccessibilityConfig { wcagLevel: 'AA' | 'AAA'; screenReader: boolean; keyboardNavigation: boolean; motionPreferences: boolean; colorCompliance: boolean; cognitiveAccessibility: boolean; announcements: boolean; focusManagement: boolean; skipLinks: boolean; landmarks: boolean; autoLabeling: boolean; enhanceErrorMessages: boolean; showReadingTime: boolean; simplifyContent: boolean; readingLevel: 'elementary' | 'middle' | 'high' | 'college'; } export interface AccessibilityState { prefersReducedMotion: boolean; prefersHighContrast: boolean; screenReaderActive: boolean; keyboardUser: boolean; focusVisible: boolean; currentFocus: Element | null; announcements: string[]; violations: AccessibilityViolation[]; } export interface AccessibilityViolation { type: 'color-contrast' | 'focus-management' | 'aria-labels' | 'keyboard-navigation' | 'motion-sensitivity' | 'text-alternatives' | 'semantic-structure' | 'timing' | 'seizures'; element: Element; description: string; severity: 'error' | 'warning' | 'info'; wcagCriterion: string; impact: 'minor' | 'moderate' | 'serious' | 'critical'; helpUrl?: string; suggestions: string[]; } export interface AccessibilityReport { score: number; // 0-100 level: 'AA' | 'AAA'; violations: AccessibilityViolation[]; passes: number; incomplete: number; summary: { total: number; errors: number; warnings: number; info: number; }; recommendations: string[]; } export interface WCAGCriterion { id: string; level: 'A' | 'AA' | 'AAA'; title: string; description: string; techniques: string[]; } // Enhanced Helper Classes with Full Implementation class FocusTracker { private focusHistory: Element[] = []; private keyboardUser: boolean = false; constructor(private element: Element) { this.setupKeyboardDetection(); } private setupKeyboardDetection(): void { document.addEventListener('keydown', (e) => { if (e.key === 'Tab') { this.keyboardUser = true; document.body.classList.add('keyboard-user'); } }); document.addEventListener('mousedown', () => { this.keyboardUser = false; document.body.classList.remove('keyboard-user'); }); } activate(): void { this.element.addEventListener('focusin', this.handleFocusIn.bind(this) as EventListener); this.element.addEventListener('focusout', this.handleFocusOut.bind(this) as EventListener); } deactivate(): void { if (this.element && typeof this.element.removeEventListener === 'function') { this.element.removeEventListener('focusin', this.handleFocusIn.bind(this) as EventListener); this.element.removeEventListener('focusout', this.handleFocusOut.bind(this) as EventListener); } } private handleFocusIn(event: Event): void { const target = event.target as Element; this.focusHistory.push(target); // Keep only last 10 focus events if (this.focusHistory.length > 10) { this.focusHistory.shift(); } } private handleFocusOut(event: Event): void { // Handle focus out logic } auditFocus(): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; // Check for focus traps const focusableElements = this.element.querySelectorAll( 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusableElements.forEach((element) => { // Check if element has visible focus indicator const computedStyle = window.getComputedStyle(element); const hasOutline = computedStyle.outline !== 'none' && computedStyle.outline !== '0px'; const hasBoxShadow = computedStyle.boxShadow !== 'none'; if (!hasOutline && !hasBoxShadow) { violations.push({ type: 'focus-management', element, description: 'Focusable element lacks visible focus indicator', severity: 'error', wcagCriterion: '2.4.7', impact: 'serious', suggestions: [ 'Add :focus outline or box-shadow', 'Ensure focus indicator has sufficient contrast', 'Make focus indicator clearly visible' ] }); } }); return violations; } } class ColorAnalyzer { private contrastThresholds = { 'AA': { normal: 4.5, large: 3.0 }, 'AAA': { normal: 7.0, large: 4.5 } }; constructor(private wcagLevel: 'AA' | 'AAA') {} activate(element: Element): void { // Monitor for color changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && (mutation.attributeName === 'style' || mutation.attributeName === 'class')) { this.checkElementContrast(mutation.target as Element); } }); }); observer.observe(element, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] }); } deactivate(): void { // Cleanup observers } auditContrast(element: Element): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; const textElements = element.querySelectorAll('*'); textElements.forEach((el) => { const hasText = el.textContent && el.textContent.trim().length > 0; if (!hasText) return; const contrastRatio = this.calculateContrastRatio(el); const isLargeText = this.isLargeText(el); const threshold = this.contrastThresholds[this.wcagLevel][isLargeText ? 'large' : 'normal']; if (contrastRatio < threshold) { violations.push({ type: 'color-contrast', element: el, description: `Insufficient color contrast: ${contrastRatio.toFixed(2)}:1 (required: ${threshold}:1)`, severity: 'error', wcagCriterion: this.wcagLevel === 'AAA' ? '1.4.6' : '1.4.3', impact: 'serious', suggestions: [ 'Increase color contrast between text and background', 'Use darker text on light backgrounds', 'Use lighter text on dark backgrounds', 'Test with color contrast analyzers' ] }); } }); return violations; } private calculateContrastRatio(element: Element): number { const computedStyle = window.getComputedStyle(element); const textColor = this.parseColor(computedStyle.color); const backgroundColor = this.getBackgroundColor(element); const textLuminance = this.getLuminance(textColor); const backgroundLuminance = this.getLuminance(backgroundColor); const lighter = Math.max(textLuminance, backgroundLuminance); const darker = Math.min(textLuminance, backgroundLuminance); return (lighter + 0.05) / (darker + 0.05); } private parseColor(colorString: string): [number, number, number] { // Simplified color parsing - in production, use a robust color parser const rgb = colorString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgb && rgb[1] && rgb[2] && rgb[3]) { return [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])]; } return [0, 0, 0]; // Default to black } private getBackgroundColor(element: Element): [number, number, number] { let currentElement = element as HTMLElement; while (currentElement && currentElement !== document.body) { const computedStyle = window.getComputedStyle(currentElement); const backgroundColor = computedStyle.backgroundColor; if (backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') { return this.parseColor(backgroundColor); } currentElement = currentElement.parentElement as HTMLElement; } return [255, 255, 255]; // Default to white } private getLuminance([r, g, b]: [number, number, number]): number { const [rs, gs, bs] = [r, g, b].map(c => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * (rs || 0) + 0.7152 * (gs || 0) + 0.0722 * (bs || 0); } private isLargeText(element: Element): boolean { const computedStyle = window.getComputedStyle(element); const fontSize = parseFloat(computedStyle.fontSize); const fontWeight = computedStyle.fontWeight; // Large text is 18pt (24px) or 14pt (18.66px) bold return fontSize >= 24 || (fontSize >= 18.66 && (fontWeight === 'bold' || parseInt(fontWeight) >= 700)); } private checkElementContrast(element: Element): void { const violations = this.auditContrast(element); if (violations.length > 0) { logger.warn('Color contrast violations detected:', violations); } } fixContrast(element: Element): void { const violations = this.auditContrast(element); violations.forEach(violation => { if (violation.type === 'color-contrast') { // Apply automatic contrast fixes const htmlElement = violation.element as HTMLElement; // Simple fix: make text darker or lighter based on background const backgroundColor = this.getBackgroundColor(violation.element); const backgroundLuminance = this.getLuminance(backgroundColor); if (backgroundLuminance > 0.5) { // Light background - use dark text htmlElement.style.color = '#000000'; } else { // Dark background - use light text htmlElement.style.color = '#ffffff'; } logger.info('Applied contrast fix to element:', htmlElement); } }); } updateContrast(highContrast: boolean): void { if (highContrast) { document.body.classList.add('high-contrast'); // Apply high contrast styles const style = document.createElement('style'); style.id = 'proteus-high-contrast'; style.textContent = ` .high-contrast * { background-color: white !important; color: black !important; border-color: black !important; } .high-contrast a { color: blue !important; } .high-contrast button { background-color: white !important; color: black !important; border: 2px solid black !important; } `; if (!document.getElementById('proteus-high-contrast')) { document.head.appendChild(style); } } else { document.body.classList.remove('high-contrast'); const style = document.getElementById('proteus-high-contrast'); if (style) { style.remove(); } } } } export class AccessibilityEngine { private element: Element; private config: Required<AccessibilityConfig>; private state: AccessibilityState; private liveRegion: HTMLElement | null = null; private focusTracker: FocusTracker; private colorAnalyzer: ColorAnalyzer; private motionManager: MotionManager; constructor(element: Element, config: Partial<AccessibilityConfig> = {}) { this.element = element; this.config = { wcagLevel: 'AA', screenReader: true, keyboardNavigation: true, motionPreferences: true, colorCompliance: true, cognitiveAccessibility: true, announcements: true, focusManagement: true, skipLinks: true, landmarks: true, autoLabeling: true, enhanceErrorMessages: false, showReadingTime: false, simplifyContent: false, readingLevel: 'middle', ...config }; this.state = this.createInitialState(); this.focusTracker = new FocusTracker(this.element); this.colorAnalyzer = new ColorAnalyzer(this.config.wcagLevel); this.motionManager = new MotionManager(this.element); } /** * Activate accessibility features */ public activate(): void { this.detectUserPreferences(); this.setupScreenReaderSupport(); this.setupKeyboardNavigation(); this.setupMotionPreferences(); this.setupColorCompliance(); this.setupCognitiveAccessibility(); this.setupResponsiveAnnouncements(); this.auditAccessibility(); } /** * Validate a single element for accessibility issues */ public validateElement(element: Element, options: { level?: 'A' | 'AA' | 'AAA' } = {}): any { const issues: any[] = []; const level = options.level || 'AA'; // Check for alt text on images if (element.tagName === 'IMG' && !element.getAttribute('alt')) { issues.push({ rule: 'img-alt', type: 'error', message: 'Image missing alt text', level }); } // Check for form labels if (element.tagName === 'INPUT' && !element.getAttribute('aria-label') && !element.getAttribute('aria-labelledby')) { const id = element.getAttribute('id'); if (!id || !document.querySelector(`label[for="${id}"]`)) { issues.push({ rule: 'label-required', type: 'error', message: 'Form input missing label', level }); } } // Check touch target sizes (AA and AAA requirements) if ((level === 'AA' || level === 'AAA') && this.isInteractiveElement(element)) { const rect = element.getBoundingClientRect(); const minSize = 44; // WCAG AA requirement: 44x44px minimum if (rect.width < minSize || rect.height < minSize) { issues.push({ rule: 'touch-target-size', type: 'error', message: `Touch target too small: ${rect.width}x${rect.height}px (minimum: ${minSize}x${minSize}px)`, level }); } } // Check contrast ratios for AAA if (level === 'AAA' && this.hasTextContent(element)) { const computedStyle = getComputedStyle(element); const color = computedStyle.color; const backgroundColor = computedStyle.backgroundColor; // Simple contrast check (would need proper color parsing in real implementation) if (color && backgroundColor && color !== backgroundColor) { const contrastRatio = this.calculateContrastRatio(color, backgroundColor); const minContrast = 7.0; // AAA requirement if (contrastRatio < minContrast) { issues.push({ rule: 'color-contrast-enhanced', type: 'error', message: `Insufficient contrast ratio: ${contrastRatio.toFixed(2)} (minimum: ${minContrast})`, level }); } } } return { issues, wcagLevel: level, score: Math.max(0, 100 - issues.length * 10) }; } /** * Validate a container for accessibility issues */ public validateContainer(container: Element): any { const issues: any[] = []; // Check heading hierarchy const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6'); let lastLevel = 0; headings.forEach(heading => { const currentLevel = parseInt(heading.tagName.charAt(1)); if (currentLevel > lastLevel + 1) { issues.push({ rule: 'heading-order', type: 'warning', message: 'Heading hierarchy skipped a level', element: heading }); } lastLevel = currentLevel; }); return { issues, score: Math.max(0, 100 - issues.length * 10), wcagLevel: issues.length === 0 ? 'AAA' : issues.length < 3 ? 'AA' : 'A' }; } /** * Audit an entire page for accessibility */ public auditPage(page: Element): any { const containerReport = this.validateContainer(page); const elements = page.querySelectorAll('*'); const allIssues: any[] = [...containerReport.issues]; elements.forEach(element => { const elementReport = this.validateElement(element); allIssues.push(...elementReport.issues); }); const totalIssues = allIssues.length; return { score: Math.max(0, 100 - totalIssues * 5), issues: allIssues, // Return array of issues, not count recommendations: this.generateAuditRecommendations(allIssues), wcagLevel: totalIssues === 0 ? 'AAA' : totalIssues < 5 ? 'AA' : 'A' }; } /** * Setup responsive breakpoint announcements */ private setupResponsiveAnnouncements(): void { if (!this.config.announcements) return; let currentBreakpoint = this.getCurrentBreakpoint(); // Create ResizeObserver to monitor container size changes if (typeof ResizeObserver !== 'undefined') { const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const newBreakpoint = this.getBreakpointFromWidth(entry.contentRect.width); if (newBreakpoint !== currentBreakpoint) { this.announce(`Layout changed to ${newBreakpoint} view`, 'polite'); currentBreakpoint = newBreakpoint; } } }); resizeObserver.observe(this.element as Element); } else { // Fallback for environments without ResizeObserver window.addEventListener('resize', () => { const newBreakpoint = this.getCurrentBreakpoint(); if (newBreakpoint !== currentBreakpoint) { this.announce(`Layout changed to ${newBreakpoint} view`, 'polite'); currentBreakpoint = newBreakpoint; } }); } } /** * Get current breakpoint based on viewport width */ private getCurrentBreakpoint(): string { const width = window.innerWidth; return this.getBreakpointFromWidth(width); } /** * Get breakpoint name from width */ private getBreakpointFromWidth(width: number): string { if (width < 576) return 'mobile'; if (width < 768) return 'tablet'; if (width < 992) return 'desktop'; return 'large-desktop'; } /** * Add content simplification indicators and tools */ private addContentSimplification(): void { // Find complex content (paragraphs with long sentences) const paragraphs = Array.from(this.element.querySelectorAll('p')); // Check if the root element itself is a paragraph or contains text if (this.element.matches('p') || (this.element.textContent && this.element.textContent.trim().length > 100)) { paragraphs.push(this.element as HTMLParagraphElement); } paragraphs.forEach(paragraph => { const text = paragraph.textContent || ''; const wordCount = text.split(/\s+/).length; const sentenceCount = text.split(/[.!?]+/).length; const avgWordsPerSentence = wordCount / sentenceCount; // Consider content complex if sentences are long or contain complex words const isComplex = avgWordsPerSentence > 15 || text.length > 200 || /\b(subordinate|technical|jargon|complex|multiple|clauses)\b/i.test(text); if (isComplex) { // Add simplification indicator const indicator = document.createElement('div'); indicator.setAttribute('data-simplified', 'true'); indicator.setAttribute('role', 'note'); indicator.setAttribute('aria-label', 'Content simplification available'); indicator.textContent = `📖 Simplified version available (Reading level: ${this.config.readingLevel})`; indicator.style.cssText = 'font-size: 0.85em; color: #0066cc; margin-bottom: 0.5em; cursor: pointer;'; // Add click handler for simplification indicator.addEventListener('click', () => { this.toggleContentSimplification(paragraph); }); // Insert before the paragraph if (paragraph.parentNode) { paragraph.parentNode.insertBefore(indicator, paragraph); } } }); } /** * Toggle between original and simplified content */ private toggleContentSimplification(paragraph: Element): void { const isSimplified = paragraph.getAttribute('data-content-simplified') === 'true'; if (!isSimplified) { // Store original content and show simplified version const originalText = paragraph.textContent || ''; paragraph.setAttribute('data-original-content', originalText); paragraph.setAttribute('data-content-simplified', 'true'); // Create simplified version (basic implementation) const simplified = this.simplifyText(originalText); paragraph.textContent = simplified; } else { // Restore original content const originalText = paragraph.getAttribute('data-original-content'); if (originalText) { paragraph.textContent = originalText; paragraph.removeAttribute('data-content-simplified'); paragraph.removeAttribute('data-original-content'); } } } /** * Simplify text based on reading level */ private simplifyText(text: string): string { // Basic text simplification based on reading level let simplified = text; // Replace complex words with simpler alternatives const replacements: { [key: string]: string } = { 'subordinate': 'secondary', 'technical jargon': 'technical terms', 'multiple clauses': 'several parts', 'difficult': 'hard', 'understand': 'get', 'complex': 'hard' }; Object.entries(replacements).forEach(([complex, simple]) => { simplified = simplified.replace(new RegExp(complex, 'gi'), simple); }); // Break long sentences into shorter ones simplified = simplified.replace(/,\s+/g, '. '); return simplified; } /** * Generate audit recommendations based on issues */ private generateAuditRecommendations(issues: any[]): any[] { const recommendations: any[] = []; issues.forEach(issue => { switch (issue.rule) { case 'touch-target-size': recommendations.push({ type: 'improvement', message: 'Increase touch target size to at least 44x44px', priority: 'high' }); break; case 'color-contrast-enhanced': recommendations.push({ type: 'improvement', message: 'Improve color contrast ratio to meet AAA standards (7:1)', priority: 'medium' }); break; case 'img-alt': recommendations.push({ type: 'fix', message: 'Add descriptive alt text to images', priority: 'high' }); break; case 'label-required': recommendations.push({ type: 'fix', message: 'Add proper labels to form inputs', priority: 'high' }); break; } }); return recommendations; } /** * Destroy the accessibility engine */ public destroy(): void { this.deactivate(); } /** * Deactivate and clean up */ public deactivate(): void { this.cleanupAccessibilityFeatures(); } /** * Get accessibility state */ public getState(): AccessibilityState { return { ...this.state }; } /** * Auto-fix common accessibility issues */ public autoFixIssues(): void { try { // Fix missing alt attributes document.querySelectorAll('img:not([alt])').forEach(img => { img.setAttribute('alt', ''); }); // Fix empty alt attributes for decorative images document.querySelectorAll('img[alt=""]').forEach(img => { img.setAttribute('role', 'presentation'); }); // Fix missing or empty labels for form inputs document.querySelectorAll('input').forEach(input => { const currentLabel = input.getAttribute('aria-label'); const hasLabelledBy = input.hasAttribute('aria-labelledby'); const label = input.id ? document.querySelector(`label[for="${input.id}"]`) : null; // Fix if no label, empty label, or no associated label element if (!hasLabelledBy && (!currentLabel || currentLabel.trim() === '') && !label) { const inputType = input.getAttribute('type') || 'text'; const placeholder = input.getAttribute('placeholder'); const name = input.getAttribute('name'); // Generate a meaningful label let newLabel = placeholder || name || input.id; if (!newLabel) { newLabel = `${inputType} input`; } input.setAttribute('aria-label', newLabel.replace(/[-_]/g, ' ')); } }); // Fix missing button roles document.querySelectorAll('button:not([role])').forEach(button => { button.setAttribute('role', 'button'); }); // Fix missing aria-labels for buttons without text document.querySelectorAll('button:not([aria-label])').forEach(button => { if (!button.textContent?.trim()) { button.setAttribute('aria-label', 'button'); } }); // Fix missing heading hierarchy this.fixHeadingHierarchy(); // Fix color contrast issues this.fixColorContrastIssues(); } catch (error) { logger.warn('Error during auto-fix:', error); } } /** * Fix heading hierarchy issues */ private fixHeadingHierarchy(): void { const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); let expectedLevel = 1; headings.forEach(heading => { const currentLevel = parseInt(heading.tagName.charAt(1)); if (currentLevel > expectedLevel + 1) { // Skip levels detected, add aria-level heading.setAttribute('aria-level', expectedLevel.toString()); } expectedLevel = Math.max(expectedLevel, currentLevel) + 1; }); } /** * Fix color contrast issues */ private fixColorContrastIssues(): void { document.querySelectorAll('*').forEach(element => { const style = window.getComputedStyle(element); const color = style.color; const backgroundColor = style.backgroundColor; if (color && backgroundColor && color !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'rgba(0, 0, 0, 0)') { const contrast = this.calculateContrastRatio(color, backgroundColor); if (contrast < 4.5) { // Add high contrast class element.classList.add('proteus-high-contrast'); } } }); } /** * Generate comprehensive WCAG compliance report */ public generateComplianceReport(): AccessibilityReport { const violations: AccessibilityViolation[] = []; // Collect violations from all auditors violations.push(...this.auditLabels()); violations.push(...this.auditKeyboardNavigation()); violations.push(...this.focusTracker.auditFocus()); violations.push(...this.colorAnalyzer.auditContrast(this.element)); violations.push(...this.motionManager.auditMotion()); violations.push(...this.auditSemanticStructure()); violations.push(...this.auditTextAlternatives()); violations.push(...this.auditTiming()); // Calculate metrics const total = violations.length; const errors = violations.filter(v => v.severity === 'error').length; const warnings = violations.filter(v => v.severity === 'warning').length; const info = violations.filter(v => v.severity === 'info').length; // Calculate score (0-100) const maxPossibleViolations = this.getMaxPossibleViolations(); const score = Math.max(0, Math.round(((maxPossibleViolations - total) / maxPossibleViolations) * 100)); // Generate recommendations const recommendations = this.generateRecommendations(violations); return { score, level: this.config.wcagLevel, violations, passes: maxPossibleViolations - total, incomplete: 0, // For now, assume all tests are complete summary: { total, errors, warnings, info }, recommendations }; } /** * Audit semantic structure (headings, landmarks, etc.) */ private auditSemanticStructure(): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; // Check heading hierarchy const headings = this.element.querySelectorAll('h1, h2, h3, h4, h5, h6'); let lastLevel = 0; headings.forEach((heading) => { const level = parseInt(heading.tagName.charAt(1)); if (level > lastLevel + 1) { violations.push({ type: 'semantic-structure', element: heading, description: `Heading level ${level} skips levels (previous was ${lastLevel})`, severity: 'warning', wcagCriterion: '1.3.1', impact: 'moderate', suggestions: [ 'Use heading levels in sequential order', 'Do not skip heading levels', 'Use CSS for visual styling, not heading levels' ] }); } lastLevel = level; }); // Check for landmarks const landmarks = this.element.querySelectorAll('main, nav, aside, section, article, header, footer, [role="main"], [role="navigation"], [role="complementary"], [role="banner"], [role="contentinfo"]'); if (landmarks.length === 0 && this.element === document.body) { violations.push({ type: 'semantic-structure', element: this.element, description: 'Page lacks landmark elements for navigation', severity: 'warning', wcagCriterion: '1.3.1', impact: 'moderate', suggestions: [ 'Add main element for primary content', 'Use nav elements for navigation', 'Add header and footer elements', 'Use ARIA landmarks where appropriate' ] }); } return violations; } /** * Audit text alternatives for images and media */ private auditTextAlternatives(): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; // Check images const images = this.element.querySelectorAll('img'); images.forEach((img) => { const alt = img.getAttribute('alt'); const ariaLabel = img.getAttribute('aria-label'); const ariaLabelledby = img.getAttribute('aria-labelledby'); if (!alt && !ariaLabel && !ariaLabelledby) { violations.push({ type: 'text-alternatives', element: img, description: 'Image missing alternative text', severity: 'error', wcagCriterion: '1.1.1', impact: 'serious', suggestions: [ 'Add alt attribute with descriptive text', 'Use aria-label for complex images', 'Use aria-labelledby to reference descriptive text', 'Use alt="" for decorative images' ] }); } }); // Check media elements const mediaElements = this.element.querySelectorAll('video, audio'); mediaElements.forEach((media) => { const hasCaption = media.querySelector('track[kind="captions"]'); const hasSubtitles = media.querySelector('track[kind="subtitles"]'); if (!hasCaption && !hasSubtitles) { violations.push({ type: 'text-alternatives', element: media, description: 'Media element missing captions or subtitles', severity: 'error', wcagCriterion: '1.2.2', impact: 'serious', suggestions: [ 'Add caption track for audio content', 'Provide subtitles for video content', 'Include transcript for audio-only content', 'Use WebVTT format for captions' ] }); } }); return violations; } /** * Audit timing and time limits */ private auditTiming(): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; // Check for auto-refreshing content const metaRefresh = document.querySelector('meta[http-equiv="refresh"]'); if (metaRefresh) { violations.push({ type: 'timing', element: metaRefresh, description: 'Page uses automatic refresh which may be disorienting', severity: 'warning', wcagCriterion: '2.2.1', impact: 'moderate', suggestions: [ 'Remove automatic refresh', 'Provide user control over refresh', 'Use manual refresh options instead', 'Warn users before automatic refresh' ] }); } return violations; } /** * Announce message to screen readers */ public announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void { if (!this.config.announcements) return; this.state.announcements.push(message); if (this.liveRegion) { this.liveRegion.setAttribute('aria-live', priority); this.liveRegion.textContent = message; // Clear after announcement setTimeout(() => { if (this.liveRegion) { this.liveRegion.textContent = ''; } }, 1000); } } /** * Audit accessibility compliance */ public auditAccessibility(): AccessibilityViolation[] { const violations: AccessibilityViolation[] = []; // Check color contrast if (this.config.colorCompliance) { violations.push(...this.colorAnalyzer.auditContrast(this.element)); } // Check focus management if (this.config.focusManagement) { violations.push(...this.focusTracker.auditFocus()); } // Check ARIA labels if (this.config.autoLabeling) { violations.push(...this.auditAriaLabels()); } // Check keyboard navigation if (this.config.keyboardNavigation) { violations.push(...this.auditKeyboardNavigation()); } this.state.violations = violations; return violations; } /** * Fix accessibility violations automatically */ public fixViolations(): void { this.state.violations.forEach(violation => { this.fixViolation(violation); }); } /** * Detect user preferences */ private detectUserPreferences(): void { try { // Check if matchMedia is available if (typeof window !== 'undefined' && window.matchMedia) { // Detect reduced motion preference this.state.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Detect high contrast preference this.state.prefersHighContrast = window.matchMedia('(prefers-contrast: high)').matches; // Listen for preference changes window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => { this.state.prefersReducedMotion = e.matches; this.motionManager.updatePreferences(e.matches); }); window.matchMedia('(prefers-contrast: high)').addEventListener('change', (e) => { this.state.prefersHighContrast = e.matches; this.colorAnalyzer.updateContrast(e.matches); }); } else { // Fallback values when matchMedia is not available this.state.prefersReducedMotion = false; this.state.prefersHighContrast = false; } // Detect screen reader this.state.screenReaderActive = this.detectScreenReader(); } catch (error) { logger.warn('Failed to detect user preferences, using defaults', error); this.state.prefersReducedMotion = false; this.state.prefersHighContrast = false; this.state.screenReaderActive = false; } } /** * Setup screen reader support */ private setupScreenReaderSupport(): void { if (!this.config.screenReader) return; // Create live region for announcements this.liveRegion = document.createElement('div'); this.liveRegion.setAttribute('aria-live', 'polite'); this.liveRegion.setAttribute('aria-atomic', 'true'); this.liveRegion.style.cssText = ` position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; `; document.body.appendChild(this.liveRegion); // Auto-generate ARIA labels if (this.config.autoLabeling) { this.generateAriaLabels(); } // Setup landmarks if (this.config.landmarks) { this.setupLandmarks(); } } /** * Setup keyboard navigation */ private setupKeyboardNavigation(): void { if (!this.config.keyboardNavigation) return; this.focusTracker.activate(); // Add skip links if (this.config.skipLinks) { this.addSkipLinks(); } // Enhance focus visibility this.enhanceFocusVisibility(); } /** * Setup motion preferences */ private setupMotionPreferences(): void { if (!this.config.motionPreferences) return; this.motionManager.activate(this.state.prefersReducedMotion); } /** * Setup color compliance */ private setupColorCompliance(): void { if (!this.config.colorCompliance) return; this.colorAnalyzer.activate(this.element); } /** * Setup cognitive accessibility */ private setupCognitiveAccessibility(): void { if (!this.config.cognitiveAccessibility && !this.config.enhanceErrorMessages && !this.config.showReadingTime && !this.config.simplifyContent) return; // Add reading time estimates (can be enabled independently) if (this.config.cognitiveAccessibility || this.config.showReadingTime) { this.addReadingTimeEstimates(); } // Add content simplification (can be enabled independently) if (this.config.cognitiveAccessibility || this.config.simplifyContent) { this.addContentSimplification(); } // Add progress indicators (only with full cognitive accessibility) if (this.config.cognitiveAccessibility) { this.addProgressIndicators(); } // Enhance form validation (can be enabled independently) if (this.config.cognitiveAccessibility || this.config.enhanceErrorMessages) { this.enhanceFormValidation(); } } /** * Detect screen reader */ private detectScreenReader(): boolean { // Check for common screen reader indicators return !!( navigator.userAgent.includes('NVDA') || navigator.userAgent.includes('JAWS') || navigator.userAgent.includes('VoiceOver') || window.speechSynthesis || document.body.classList.contains('screen-reader') ); } /** * Generate ARIA labels automatically */ private generateAriaLabels(): void { // Check the target element itself first if (this.element.tagName.toLowerCase() === 'button') { // Set role if not present if (!this.element.getAttribute('role')) { this.element.setAttribute('role', 'button'); } // Set aria-label if needed if (!this.element.getAttribute('aria-label')) { const textContent = this.element.textContent?.trim() || ''; const hasOnlyIcons = this.isIconOnlyContent(textContent); if (!textContent || hasOnlyIcons) { const label = this.generateButtonLabel(this.element); if (label) { this.element.setAttribute('aria-label', label); } } } } // Label form inputs const inputs = this.element.querySelectorAll('input, select, textarea'); inputs.forEach(input => { if (!input.getAttribute('aria-label') && !input.getAttribute('aria-labelledby')) { const label = this.generateInputLabel(input); if (label) { input.setAttribute('aria-label', label); } } }); // Label buttons within the element const buttons = this.element.querySelectorAll('button'); buttons.forEach(button => { if (!button.getAttribute('aria-label')) { const textContent = button.textContent?.trim() || ''; const hasOnlyIcons = this.isIconOnlyContent(textContent); if (!textContent || hasOnlyIcons) { const label = this.generateButtonLabel(button); if (label) { button.setAttribute('aria-label', label); } } } }); // Label images const images = this.element.querySelectorAll('img'); images.forEach(img => { if (!img.getAttribute('alt')) { const alt = this.generateImageAlt(img); img.setAttribute('alt', alt); } }); } /** * Setup semantic landmarks */ private setupLandmarks(): void { const landmarkSelectors = { 'banner': 'header:not([role])', 'main': 'main:not([role])', 'navigation': 'nav:not([role])', 'complementary': 'aside:not([role])', 'contentinfo': 'footer:not([role])' }; Object.entries(landmarkSelectors).forEach(([role, selector]) => { const elements = this.element.querySelectorAll(selector); elements.forEach(element => { element.setAttribute('role', role); }); }); } /** * Add skip links */ private addSkipLinks(): void { const mainContent = this.element.querySelector('main, [role="main"]'); if (mainContent) { this.addSkipLink('Skip to main content', mainContent); } const navigation = this.element.querySelector('nav, [role="navigation"]'); if (navigation) { this.addSkipLink('Skip to navigation', navigation); } } /** * Add individual skip link */ private addSkipLink(text: string, target: Element): void { const skipLink = document.createElement('a'); skipLink.href = `#${this.ensureId(target)}`; skipLink.textContent = text; skipLink.className = 'proteus-skip-link'; skipLink.style.cssText = ` position: absolute; top: -40px; left: 6px; background: #000; color: #fff; padding: 8px; text-decoration: none; z-index: 1000; transition: top 0.3s; `; skipLink.addEventListener('focus', () => { skipLink.style.top = '6px'; }); skipLink.addEventListener('blur', () => { skipLink.style.top = '-40px'; }); document.body.insertBefore(skipLink, document.body.firstChild); } /** * Enhance focus visibility */ private enhanceFocusVisibility(): void { const style = document.createElement('style'); style.textContent = ` .proteus-focus-visible { outline: 3px solid #005fcc; outline-offset: 2px; } .proteus-focus-visible:focus { outline: 3px solid #005fcc; outline-offset: 2px; } `; document.head.appendChild(style); // Add focus-visible polyfill behavior document.addEventListener('keydown', () => { this.state.keyboardUser = true; }); document.addEventListener('mousedown', () => { this.state.keyboardUser = false; }); } /** * Add reading time estimates */ private addReadingTimeEstimates(): void { // Find articles within the element AND check if the element itself is an article const articles = Array.from(this.element.querySelectorAll('article, .article, [role="article"]')); // Check if the root element itself is an article if (this.element.matches('article, .article, [role="article"]')) { articles.push(this.element); } articles.forEach(article => { const wordCount = this.countWords(article.textContent || ''); const readingTime = Math.ceil(wordCount / 200); // 200 WPM average const estimate = document.createElement('div'); estimate.textContent = `Estimated reading time: ${readingTime} min read`; estimate.setAttribute('data-reading-time', readingTime.toString()); estimate.setAttribute('aria-label', `This article takes approximately ${readingTime} minutes to read`); estimate.style.cssText = 'font-size: 0.9em; color: #666; margin-bottom: 1em;'; // Insert at the beginning of the article if (article.firstChild) { article.insertBefore(estimate, article.firstChild); } else { article.appendChild(estimate); } }); } /** * Enhance form validation */ private enhanceFormValidation(): void { // Always enhance form validation when cognitive accessibility is enabled // or when specifically requested // Find forms within the element AND check if the element itself is a form const forms = Array.from(this.element.querySelectorAll('form')); // Check if the root element itself is a form if (this.element.matches('form')) { forms.push(this.element as HTMLFormElement); } forms.forEach(form => { const inputs = form.querySelectorAll('input, select, textarea'); inputs.forEach(input => { const htmlInput = input as HTMLInputElement; // Set up comprehensive error message linking this.setupErrorMessageLinking(htmlInput, form); input.addEventListener('invalid', (e) => { const target = e.target as HTMLInputElement; const message = target.validationMessage; this.announce(`Validation error: ${message}`, 'assertive'); }); // Handle blur event for comprehensive validation input.addEventListener('blur', (e) => { const target = e.target as HTMLInputElement; this.performInputValidation(target, form); }); // Handle input event for real-time validation input.addEventListener('input', (e) => { const target = e.target as HTMLInputElement; this.performInputValidation(target, form); }); }); }); } /** * Link input to error message using aria-describedby */ private linkInputToErrorMessage(input: HTMLInputElement, form: Element): void { // Find error message for this input const inputId = input.id || input.name; const inputType = input.type; // Look for error message with matching ID pattern let errorMessage = null; if (inputId) { errorMessage = form.querySelector(`[id*="${inputId}"][role="alert"]`) || form.querySelector(`[id*="${inputId}-error"]`) || form.querySelector(`[id*="error"][id*="${inputId}"]`); } // If no ID match, try matching by input type if (!errorMessage && inputType) { errorMessage = form.querySelector(`[id*="${inputType}"][role="alert"]`) || form.querySelector(`[id*="${inputType}-error"]`); } // If still no match, try any error message in the form if (!errorMessage) { errorMessage = form.querySelector('[role="alert"]'); } if (errorMessage) { input.setAttribute('aria-describedby', errorMessage.id); } } /** * Set up comprehensive error message linking for an input */ private setupErrorMessageLinking(input: HTMLInputElement, form: Element): void { // Find potential error messages for this input const errorMessages = this.findErrorMessagesForInput(input, form); // Link the input to error messages immediately if they exist if (errorMessages.length > 0) { const errorIds = errorMessages.map(msg => msg.id).filter(id => id); if (errorIds.length > 0) { input.setAttribute('aria-describedby', errorIds.join(' ')); } } } /** * Find error messages associated with an input */ private findErrorMessagesForInput(input: HTMLInputElement, form: Element): Element[] { const errorMessages: Element[] = []; const inputId = input.id || input.name; const inputType = input.type; // Get all elements with role="alert" in the form const alertElements = Array.from(form.querySelectorAll('[role="alert"]')); for (const element of alertElements) { const elementId = element.id; // Strategy 1: Match by input ID/name if (inputId && elementId && elementId.includes(inputId)) { errorMessages.push(element); continue; } // Strategy 2: Match by input type if (inputType && elementId && elementId.includes(inputType)) { errorMessages.push(element); continue; } // Strategy 3: Match by pattern (e.g., "email-error" for email input) if (inputType && elementId && elementId.includes(`${inputType}-error`)) { errorMessages.push(element); continue; } } // Strategy 4: If no specific match, use any error message in the form if (errorMessages.length === 0 && alertElements.length > 0) { const firstAlert = alertElements[0]; if (firstAlert) { errorMessages.push(firstAlert); } } return errorMessages; } /** * Perform comprehensive input validation */ private performInputValidation(input: HTMLInputElement, form: Element): void { const isValid = this.validateInputValue(input); const errorMessages = this.findErrorMessagesForInput(input, form); if (!isValid && errorMessages.length > 0) { // Show error state const errorIds = errorMessages.map(msg => msg.id).filter(id => id); if (errorIds.length > 0) { input.setAttribute('aria-describedby', errorIds.join(' ')); input.setAttribute('aria-invalid', 'true'); } // Show error messages errorMessages.forEach(msg => { (msg as HTMLElement).style.display = 'block'; msg.setAttribute('aria-live', 'polite'); }); } else { // Hide error state input.removeAttribute('aria-invalid'); // Hide error messages errorMessages.forEach(msg => { (msg as HTMLElement).style.display = 'none'; }); } } /** * Validate input value based on type and constraints */ private validateInputValue(input: HTMLInputElement): boolean { if (!input.value && input.required) { return false; } switch (input.type) { case 'email': return !input.value || input.value.includes('@'); case 'url': return !input.value || input.value.startsWith('http'); case 'tel': return !input.value || /^\+?[\d\s\-\(\)]+$/.test(input.value); default: return true; } } /** * Add progress indicators */ private addProgressIndicators(): void { const forms = this.element.querySelectorAll('form[data-steps]'); forms.forEach(form => { const steps = parseInt(form.getAttribu