UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

420 lines (419 loc) â€ĸ 16.3 kB
/** * Comprehensive accessibility audit utilities for ClarityKit components * Implements WCAG 2.1 AA compliance checking and validation */ import { isBrowser, safelyAccessDOM } from './environment'; /** * Comprehensive accessibility audit for a component or element */ export function auditAccessibility(element) { const issues = []; // Run all audit checks issues.push(...checkFormAccessibility(element)); issues.push(...checkKeyboardAccessibility(element)); issues.push(...checkAriaCompliance(element)); issues.push(...checkColorContrast(element)); issues.push(...checkFocusManagement(element)); issues.push(...checkSemanticStructure(element)); issues.push(...checkScreenReaderSupport(element)); // Calculate summary const summary = { errors: issues.filter(i => i.level === 'error').length, warnings: issues.filter(i => i.level === 'warning').length, info: issues.filter(i => i.level === 'info').length }; // Calculate score (100 - penalty points) const errorPenalty = summary.errors * 10; const warningPenalty = summary.warnings * 5; const infoPenalty = summary.info * 1; const score = Math.max(0, 100 - errorPenalty - warningPenalty - infoPenalty); return { passed: summary.errors === 0, issues, score, summary }; } /** * Check form accessibility compliance */ function checkFormAccessibility(element) { const issues = []; const formElements = element.querySelectorAll('input, textarea, select, button'); formElements.forEach(el => { const htmlEl = el; const tagName = htmlEl.tagName.toLowerCase(); // Check for proper labels if (['input', 'textarea', 'select'].includes(tagName)) { const hasLabel = htmlEl.getAttribute('aria-label') || htmlEl.getAttribute('aria-labelledby') || document.querySelector(`label[for="${htmlEl.id}"]`); if (!hasLabel) { issues.push({ level: 'error', rule: 'form-labels', message: `Form element missing accessible label`, element: htmlEl, wcagCriterion: '3.3.2' }); } } // Check for required field indicators if (htmlEl.hasAttribute('required') && !htmlEl.getAttribute('aria-required')) { issues.push({ level: 'warning', rule: 'required-fields', message: 'Required field missing aria-required attribute', element: htmlEl, wcagCriterion: '3.3.2' }); } // Check for error message associations if (htmlEl.getAttribute('aria-invalid') === 'true') { const describedBy = htmlEl.getAttribute('aria-describedby'); if (!describedBy || !describedBy.includes('error')) { issues.push({ level: 'error', rule: 'error-identification', message: 'Invalid field missing error message association', element: htmlEl, wcagCriterion: '3.3.1' }); } } }); return issues; } /** * Check keyboard accessibility */ function checkKeyboardAccessibility(element) { const issues = []; const interactiveElements = element.querySelectorAll('button, a, input, textarea, select, [tabindex], [onclick]'); interactiveElements.forEach(el => { const htmlEl = el; // Check for keyboard accessibility if (htmlEl.onclick && !['button', 'a', 'input', 'textarea', 'select'].includes(htmlEl.tagName.toLowerCase())) { const tabIndex = htmlEl.getAttribute('tabindex'); const role = htmlEl.getAttribute('role'); if (tabIndex === '-1' && !['button', 'link', 'menuitem'].includes(role || '')) { issues.push({ level: 'error', rule: 'keyboard-accessible', message: 'Interactive element not keyboard accessible', element: htmlEl, wcagCriterion: '2.1.1' }); } } // Check for focus indicators const styles = window.getComputedStyle(htmlEl, ':focus'); if (styles.outline === 'none' && styles.boxShadow === 'none' && !styles.border.includes('focus')) { issues.push({ level: 'warning', rule: 'focus-visible', message: 'Interactive element missing visible focus indicator', element: htmlEl, wcagCriterion: '2.4.7' }); } }); return issues; } /** * Check ARIA compliance */ function checkAriaCompliance(element) { const issues = []; const elementsWithAria = element.querySelectorAll('[aria-labelledby], [aria-describedby], [role]'); elementsWithAria.forEach(el => { const htmlEl = el; // Check aria-labelledby references const labelledBy = htmlEl.getAttribute('aria-labelledby'); if (labelledBy) { const labelIds = labelledBy.split(' '); labelIds.forEach(id => { if (!document.getElementById(id)) { issues.push({ level: 'error', rule: 'aria-labelledby-valid', message: `aria-labelledby references non-existent element: ${id}`, element: htmlEl, wcagCriterion: '4.1.2' }); } }); } // Check aria-describedby references const describedBy = htmlEl.getAttribute('aria-describedby'); if (describedBy) { const descIds = describedBy.split(' '); descIds.forEach(id => { if (!document.getElementById(id)) { issues.push({ level: 'error', rule: 'aria-describedby-valid', message: `aria-describedby references non-existent element: ${id}`, element: htmlEl, wcagCriterion: '4.1.2' }); } }); } // Check for valid roles const role = htmlEl.getAttribute('role'); if (role) { const validRoles = [ 'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox', 'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory', 'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation', 'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem' ]; if (!validRoles.includes(role)) { issues.push({ level: 'error', rule: 'valid-aria-role', message: `Invalid ARIA role: ${role}`, element: htmlEl, wcagCriterion: '4.1.2' }); } } }); return issues; } /** * Check color contrast (simplified check) */ function checkColorContrast(element) { const issues = []; const textElements = element.querySelectorAll('*'); textElements.forEach(el => { const htmlEl = el; const styles = window.getComputedStyle(htmlEl); // Skip elements without text content if (!htmlEl.textContent?.trim()) return; const color = styles.color; const backgroundColor = styles.backgroundColor; // This is a simplified check - in a real implementation, you'd calculate actual contrast ratios if (color === backgroundColor) { issues.push({ level: 'error', rule: 'color-contrast', message: 'Text color same as background color', element: htmlEl, wcagCriterion: '1.4.3' }); } // Check for color-only information if (styles.color === 'red' && !htmlEl.getAttribute('aria-label') && !htmlEl.querySelector('[aria-label]')) { issues.push({ level: 'warning', rule: 'color-only-info', message: 'Information conveyed by color alone', element: htmlEl, wcagCriterion: '1.4.1' }); } }); return issues; } /** * Check focus management */ function checkFocusManagement(element) { const issues = []; const modals = element.querySelectorAll('[role="dialog"], [role="alertdialog"], .modal'); modals.forEach(modal => { const htmlModal = modal; // Check for focus trap const focusableElements = htmlModal.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'); if (focusableElements.length === 0) { issues.push({ level: 'warning', rule: 'focus-management', message: 'Modal has no focusable elements', element: htmlModal, wcagCriterion: '2.4.3' }); } // Check for proper ARIA attributes if (!htmlModal.getAttribute('aria-labelledby') && !htmlModal.getAttribute('aria-label')) { issues.push({ level: 'error', rule: 'modal-label', message: 'Modal missing accessible name', element: htmlModal, wcagCriterion: '4.1.2' }); } }); return issues; } /** * Check semantic structure */ function checkSemanticStructure(element) { const issues = []; // Check heading hierarchy const headings = element.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]'); let lastLevel = 0; headings.forEach(heading => { const htmlHeading = heading; let level = 0; if (htmlHeading.tagName.match(/^H[1-6]$/)) { level = parseInt(htmlHeading.tagName.charAt(1)); } else { const ariaLevel = htmlHeading.getAttribute('aria-level'); level = ariaLevel ? parseInt(ariaLevel) : 1; } if (level > lastLevel + 1) { issues.push({ level: 'warning', rule: 'heading-hierarchy', message: `Heading level skipped from ${lastLevel} to ${level}`, element: htmlHeading, wcagCriterion: '1.3.1' }); } lastLevel = level; }); // Check for proper list structure const listItems = element.querySelectorAll('li'); listItems.forEach(li => { const parent = li.parentElement; if (parent && !['ul', 'ol', 'menu'].includes(parent.tagName.toLowerCase()) && parent.getAttribute('role') !== 'list') { issues.push({ level: 'error', rule: 'list-structure', message: 'List item not contained in proper list element', element: li, wcagCriterion: '1.3.1' }); } }); return issues; } /** * Check screen reader support */ function checkScreenReaderSupport(element) { const issues = []; // Check for images without alt text const images = element.querySelectorAll('img'); images.forEach(img => { const htmlImg = img; if (!htmlImg.alt && htmlImg.alt !== '' && !htmlImg.getAttribute('aria-label')) { issues.push({ level: 'error', rule: 'img-alt', message: 'Image missing alternative text', element: htmlImg, wcagCriterion: '1.1.1' }); } }); // Check for decorative images const decorativeImages = element.querySelectorAll('img[alt=""], img[role="presentation"]'); decorativeImages.forEach(img => { const htmlImg = img; if (htmlImg.getAttribute('aria-label') || htmlImg.getAttribute('aria-labelledby')) { issues.push({ level: 'warning', rule: 'decorative-img', message: 'Decorative image has accessible name', element: htmlImg, wcagCriterion: '1.1.1' }); } }); // Check for live regions const liveRegions = element.querySelectorAll('[aria-live]'); liveRegions.forEach(region => { const htmlRegion = region; const liveValue = htmlRegion.getAttribute('aria-live'); if (!['polite', 'assertive', 'off'].includes(liveValue || '')) { issues.push({ level: 'error', rule: 'aria-live-valid', message: `Invalid aria-live value: ${liveValue}`, element: htmlRegion, wcagCriterion: '4.1.2' }); } }); return issues; } /** * Generate accessibility report */ export function generateAccessibilityReport(results) { const totalIssues = results.reduce((sum, result) => sum + result.issues.length, 0); const totalErrors = results.reduce((sum, result) => sum + result.summary.errors, 0); const totalWarnings = results.reduce((sum, result) => sum + result.summary.warnings, 0); const averageScore = results.reduce((sum, result) => sum + result.score, 0) / results.length; let report = `# ClarityKit Accessibility Audit Report\n\n`; report += `## Summary\n`; report += `- **Components Audited**: ${results.length}\n`; report += `- **Average Score**: ${averageScore.toFixed(1)}/100\n`; report += `- **Total Issues**: ${totalIssues}\n`; report += `- **Errors**: ${totalErrors}\n`; report += `- **Warnings**: ${totalWarnings}\n\n`; report += `## Detailed Results\n\n`; results.forEach((result, index) => { report += `### Component ${index + 1}\n`; report += `- **Score**: ${result.score}/100\n`; report += `- **Status**: ${result.passed ? '✅ PASSED' : '❌ FAILED'}\n`; report += `- **Issues**: ${result.issues.length}\n\n`; if (result.issues.length > 0) { report += `#### Issues:\n`; result.issues.forEach(issue => { const icon = issue.level === 'error' ? '🚨' : issue.level === 'warning' ? 'âš ī¸' : 'â„šī¸'; report += `${icon} **${issue.rule}** (${issue.wcagCriterion}): ${issue.message}\n`; }); report += `\n`; } }); return report; } /** * Batch audit multiple components */ export function batchAuditComponents(selectors) { if (!isBrowser) return []; const results = []; selectors.forEach(selector => { safelyAccessDOM(() => { const elements = document.querySelectorAll(selector); elements.forEach(element => { const result = auditAccessibility(element); results.push(result); }); }); }); return results; } /** * Quick accessibility check for development */ export function quickAccessibilityCheck(element) { const result = auditAccessibility(element); if (!result.passed) { console.group('🚨 Accessibility Issues Found'); result.issues.forEach(issue => { const method = issue.level === 'error' ? 'error' : issue.level === 'warning' ? 'warn' : 'info'; console[method](`${issue.rule}: ${issue.message}`, issue.element); }); console.groupEnd(); } return result.passed; }