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
JavaScript
/**
* 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;
}