@helptheweb/accessibility-engine
Version:
Custom accessibility testing engine for WCAG compliance
699 lines (615 loc) • 28 kB
JavaScript
/**
* WCAG 2.2 Principle 2: Operable - Complete Rules
* User interface components and navigation must be operable.
*/
export const operableRules = {
// 2.1.1 Keyboard (Level A)
'keyboard-accessible': {
id: 'keyboard-accessible',
selector: 'a, button, input, select, textarea, [onclick], [onmousedown], [onmouseup]',
tags: ['wcag22a', 'wcag211', 'operable'],
impact: 'critical',
description: 'All functionality must be accessible via keyboard',
help: 'Ensure all interactive elements are keyboard accessible',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html',
explanation: 'Everything you can do with a mouse should also work with just a keyboard. Some people cannot use a mouse and navigate entirely with keyboards.',
evaluate: (element) => {
const hasClickHandler = element.hasAttribute('onclick') ||
element.hasAttribute('onmousedown') ||
element.hasAttribute('onmouseup');
const isNativelyFocusable = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(element.tagName);
const hasTabindex = element.hasAttribute('tabindex');
const isDisabled = element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true';
if (isDisabled) {
return { passed: true };
}
if (hasClickHandler && !isNativelyFocusable && !hasTabindex) {
return {
passed: false,
message: 'Element with click handler is not keyboard accessible'
};
}
return { passed: true };
}
},
// 2.1.2 No Keyboard Trap (Level A)
'no-keyboard-trap': {
id: 'no-keyboard-trap',
selector: '[tabindex]',
tags: ['wcag22a', 'wcag212', 'operable'],
impact: 'critical',
description: 'Keyboard focus must not be trapped',
help: 'Users must be able to navigate away from any component using only keyboard',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html',
explanation: 'Users should never get "stuck" in one part of a page when using keyboard navigation. They need to be able to move freely through all content.',
evaluate: (element) => {
// This is a basic check - comprehensive testing would require interaction testing
const tabindex = parseInt(element.getAttribute('tabindex'));
// Elements with tabindex > 0 can create confusing navigation
if (tabindex > 0) {
return {
passed: false,
message: 'Positive tabindex can create keyboard navigation issues'
};
}
return { passed: true };
}
},
// 2.1.4 Character Key Shortcuts (Level A)
'character-key-shortcuts': {
id: 'character-key-shortcuts',
selector: 'body',
tags: ['wcag22a', 'wcag214', 'operable'],
impact: 'serious',
description: 'Single character shortcuts must be configurable',
help: 'Provide a way to turn off or remap single character shortcuts',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html',
explanation: 'Single letter keyboard shortcuts can be triggered accidentally by speech recognition users. Users need a way to turn them off or change them.',
evaluate: () => {
// Check for single character event listeners
const scripts = document.querySelectorAll('script');
let hasSingleCharShortcuts = false;
scripts.forEach(script => {
const content = script.textContent;
if (content && /keypress|keydown/.test(content) && /key\s*===?\s*['"][a-z]['"]/.test(content)) {
hasSingleCharShortcuts = true;
}
});
return {
passed: !hasSingleCharShortcuts,
message: hasSingleCharShortcuts ? 'Single character shortcuts detected - ensure they can be disabled' : null,
incomplete: true
};
}
},
// 2.2.1 Timing Adjustable (Level A)
'timing-adjustable': {
id: 'timing-adjustable',
selector: 'body',
tags: ['wcag22a', 'wcag221', 'operable'],
impact: 'critical',
description: 'Users must be able to control time limits',
help: 'Provide options to turn off, adjust, or extend time limits',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html',
explanation: 'If your site has time limits (like forms that expire), users need to be able to extend the time. Some people need more time to read or complete tasks.',
evaluate: (element) => {
// Check for meta refresh
const metaRefresh = document.querySelector('meta[http-equiv="refresh"]');
if (metaRefresh) {
const content = metaRefresh.getAttribute('content');
const seconds = parseInt(content);
if (seconds > 0 && seconds < 20) {
return {
passed: false,
message: 'Page has automatic refresh under 20 seconds'
};
}
}
return { passed: true };
}
},
// 2.2.2 Pause, Stop, Hide (Level A)
'pause-stop-hide': {
id: 'pause-stop-hide',
selector: '[class*="carousel"], [class*="slider"], [class*="marquee"], marquee',
tags: ['wcag22a', 'wcag222', 'operable'],
impact: 'serious',
description: 'Moving content must be pausable',
help: 'Provide controls for moving, blinking, or scrolling content',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
explanation: 'Moving or auto-playing content can be distracting and make it hard to read other parts of the page. Users need a way to pause it.',
evaluate: (element) => {
// Check for deprecated marquee element
if (element.tagName === 'MARQUEE') {
return {
passed: false,
message: 'Marquee element should not be used'
};
}
// Check for pause/stop controls
const controls = element.querySelectorAll('[class*="pause"], [class*="stop"], [aria-label*="pause"], [aria-label*="stop"]');
return {
passed: controls.length > 0,
message: controls.length === 0 ? 'Moving content lacks pause/stop controls' : null
};
}
},
// 2.3.1 Three Flashes or Below Threshold (Level A)
'three-flashes': {
id: 'three-flashes',
selector: 'video, [class*="flash"], [class*="blink"]',
tags: ['wcag22a', 'wcag231', 'operable'],
impact: 'critical',
description: 'Content must not flash more than three times per second',
help: 'Avoid content that flashes rapidly',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html',
explanation: 'Flashing content can trigger seizures in people with photosensitive epilepsy. Content should not flash more than 3 times per second.',
evaluate: (element) => {
// This requires manual review - automated testing can only flag potential issues
const classes = element.className || '';
const hasFlashClass = /flash|blink|strobe/.test(classes);
return {
passed: !hasFlashClass,
message: hasFlashClass ? 'Element may contain flashing content - manual review needed' : null,
incomplete: hasFlashClass
};
}
},
// 2.4.1 Bypass Blocks (Level A)
'bypass-blocks': {
id: 'bypass-blocks',
selector: 'body',
tags: ['wcag22a', 'wcag241', 'operable'],
impact: 'moderate',
description: 'Page must have a way to bypass repeated blocks',
help: 'Provide skip links or landmark regions',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html',
explanation: 'Pages need a way to skip repetitive content like navigation menus. This helps keyboard users get to main content quickly without tabbing through everything.',
evaluate: (element) => {
// Check for skip link
const skipLinks = document.querySelectorAll('a[href^="#"]');
const hasSkipLink = Array.from(skipLinks).some(link => {
const text = link.textContent.toLowerCase();
return text.includes('skip') || text.includes('jump');
});
// Check for landmark regions
const landmarks = document.querySelectorAll('main, [role="main"], nav, [role="navigation"], header, [role="banner"]');
return {
passed: hasSkipLink || landmarks.length >= 2,
message: !hasSkipLink && landmarks.length < 2 ? 'Page lacks skip mechanism or sufficient landmarks' : null
};
}
},
// 2.4.2 Page Titled (Level A)
'page-title': {
id: 'page-title',
selector: 'html',
tags: ['wcag22a', 'wcag242', 'operable'],
impact: 'serious',
description: 'Page must have a descriptive title',
help: 'Provide a descriptive page title',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html',
explanation: 'Page titles help users understand where they are. They appear in browser tabs and are announced by screen readers when switching between pages.',
evaluate: () => {
const title = document.querySelector('title');
const titleText = title?.textContent?.trim();
if (!title || !titleText) {
return {
passed: false,
message: !title ? 'Page missing title element' : 'Page title is empty'
};
}
// Check for generic titles
const genericTitles = ['untitled', 'page', 'document', 'home'];
if (genericTitles.includes(titleText.toLowerCase())) {
return {
passed: false,
message: 'Page title is not descriptive'
};
}
return { passed: true };
}
},
// 2.4.3 Focus Order (Level A)
'focus-order': {
id: 'focus-order',
selector: '[tabindex]',
tags: ['wcag22a', 'wcag243', 'operable'],
impact: 'moderate',
description: 'Focus order must be logical',
help: 'Ensure tab order follows visual/logical flow',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html',
explanation: 'When users press Tab to navigate, the focus should move in a logical order that matches how the page is laid out visually.',
evaluate: (element) => {
const tabindex = parseInt(element.getAttribute('tabindex'));
// Positive values can disrupt natural flow
if (tabindex > 0) {
return {
passed: false,
message: `Tabindex ${tabindex} may disrupt logical focus order`
};
}
return { passed: true };
}
},
// 2.4.4 Link Purpose (In Context) (Level A)
'link-purpose': {
id: 'link-purpose',
selector: 'a[href]',
tags: ['wcag22a', 'wcag244', 'operable'],
impact: 'moderate',
description: 'Link purpose must be clear from link text or context',
help: 'Make link text descriptive or provide context',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html',
explanation: 'Link text should describe where the link goes. Avoid vague text like "click here" - instead use descriptive text like "download annual report".',
evaluate: (element) => {
const linkText = element.textContent.trim();
const ariaLabel = element.getAttribute('aria-label');
const title = element.getAttribute('title');
// Check for non-descriptive link text
const vaguePhrases = ['click here', 'read more', 'more', 'link', 'here'];
const text = (ariaLabel || linkText || title || '').toLowerCase();
if (vaguePhrases.includes(text)) {
// Check if context provides clarity
const parent = element.parentElement;
const parentText = parent?.textContent || '';
if (parentText.length < 50) {
return {
passed: false,
message: 'Link text is not descriptive'
};
}
}
return { passed: true };
}
},
// 2.4.5 Multiple Ways (Level AA)
'multiple-ways': {
id: 'multiple-ways',
selector: 'body',
tags: ['wcag22aa', 'wcag245', 'operable'],
impact: 'moderate',
description: 'Multiple ways to find pages must be available',
help: 'Provide site map, search, or navigation',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html',
explanation: 'Users should have more than one way to find content on your site, like a search box, site map, or navigation menu.',
evaluate: () => {
const hasSearch = document.querySelector('[type="search"], [role="search"], [class*="search"]');
const hasSitemap = document.querySelector('a[href*="sitemap"]');
const hasNav = document.querySelector('nav, [role="navigation"]');
const hasBreadcrumb = document.querySelector('[aria-label*="breadcrumb"], .breadcrumb');
const waysCount = [hasSearch, hasSitemap, hasNav, hasBreadcrumb].filter(Boolean).length;
return {
passed: waysCount >= 2,
message: waysCount < 2 ? 'Page should provide multiple ways to find content' : null
};
}
},
// 2.4.6 Headings and Labels (Level AA)
'headings-and-labels': {
id: 'headings-and-labels',
selector: 'h1, h2, h3, h4, h5, h6, label',
tags: ['wcag22aa', 'wcag246', 'operable'],
impact: 'moderate',
description: 'Headings and labels must be descriptive',
help: 'Use clear and descriptive headings and labels',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html',
explanation: 'Headings and form labels should clearly describe what follows. Avoid generic text like "Section 1" - use descriptive text like "Contact Information".',
evaluate: (element) => {
// Check visible text first
const text = element.textContent.trim();
// Also check for aria-label as alternative
const ariaLabel = element.getAttribute('aria-label');
const ariaLabelledBy = element.getAttribute('aria-labelledby');
// For headings with images (like logos), check if image has alt text
const img = element.querySelector('img');
const imgAlt = img ? img.getAttribute('alt') : null;
// Determine the effective label
let effectiveLabel = text;
if (!text && ariaLabel) {
effectiveLabel = ariaLabel;
} else if (!text && ariaLabelledBy) {
const labelElement = document.getElementById(ariaLabelledBy);
if (labelElement) {
effectiveLabel = labelElement.textContent.trim();
}
} else if (!text && imgAlt) {
effectiveLabel = imgAlt;
}
if (!effectiveLabel) {
// Special case: heading might contain only decorative image
if (img && img.getAttribute('alt') === '') {
return { passed: true }; // Decorative image is OK
}
return {
passed: false,
message: `${element.tagName} is empty and has no accessible label`
};
}
// Check for generic text
const genericText = ['heading', 'label', 'title', 'untitled', 'section'];
if (genericText.includes(effectiveLabel.toLowerCase())) {
return {
passed: false,
message: `${element.tagName} text is not descriptive`
};
}
return { passed: true };
}
},
// 2.4.7 Focus Visible (Level AA)
'focus-visible': {
id: 'focus-visible',
selector: 'a, button, input, select, textarea, [tabindex="0"]',
tags: ['wcag22aa', 'wcag247', 'operable'],
impact: 'serious',
description: 'Keyboard focus indicator must be visible',
help: 'Ensure focus indicators are visible',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html',
explanation: 'When users tab through a page, they need to see which element has focus. There should be a visible outline or highlight.',
evaluate: (element) => {
// Skip if not visible
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') {
return null;
}
// Skip disabled elements
if (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') {
return null;
}
// Check for modern :focus-visible support
const supportsFocusVisible = CSS.supports && CSS.supports('selector(:focus-visible)');
// If browser supports :focus-visible, this is the modern recommended pattern
if (supportsFocusVisible) {
// Only flag actual problems like inline styles that remove ALL focus indicators
const inlineStyle = element.getAttribute('style') || '';
const hasProblematicInline = /outline\s*:\s*none|outline\s*:\s*0/.test(inlineStyle);
if (hasProblematicInline) {
return {
passed: false,
message: 'Inline style removes focus outline - this prevents all focus indicators'
};
}
// For modern browsers, :focus-visible is the recommended approach
// We cannot test pseudo-classes from JavaScript, so we trust the implementation
return {
passed: true,
message: 'Modern browser with :focus-visible support detected'
};
}
// For older browsers without :focus-visible, check computed styles
const outline = style.outline || '';
const outlineWidth = style.outlineWidth || '0';
const outlineStyle = style.outlineStyle || 'none';
// Check if outline is completely removed
const noOutline = outline === 'none' ||
outline.includes('0px') ||
outlineWidth === '0px' ||
outlineStyle === 'none';
if (noOutline) {
// Look for alternative focus indicators
const hasBoxShadow = style.boxShadow && style.boxShadow !== 'none';
const hasBorderHighlight = element.getAttribute('data-focus-border') === 'true';
const hasBackgroundHighlight = element.getAttribute('data-focus-bg') === 'true';
if (!hasBoxShadow && !hasBorderHighlight && !hasBackgroundHighlight) {
return {
passed: false,
message: 'Focus outline removed without alternative (browser lacks :focus-visible support)'
};
}
}
return { passed: true };
}
},
// 2.4.11 Focus Not Obscured (Minimum) (Level AA) - New in WCAG 2.2
'focus-not-obscured': {
id: 'focus-not-obscured',
selector: 'a, button, input, select, textarea, [tabindex="0"]',
tags: ['wcag22aa', 'wcag2411', 'operable'],
impact: 'serious',
description: 'Focused elements must not be completely hidden',
help: 'Ensure focused elements are at least partially visible',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
explanation: 'When an element receives keyboard focus, it should not be completely hidden by other content like sticky headers or popups.',
evaluate: (element) => {
// Check for fixed/sticky elements that might obscure focus
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position: sticky"]');
if (fixedElements.length > 0) {
return {
passed: true,
incomplete: true,
message: 'Fixed/sticky elements detected - manual check needed for focus obscuration'
};
}
return { passed: true };
}
},
// 2.5.1 Pointer Gestures (Level A)
'pointer-gestures': {
id: 'pointer-gestures',
selector: '[class*="swipe"], [class*="pinch"], [class*="drag"]',
tags: ['wcag22a', 'wcag251', 'operable'],
impact: 'serious',
description: 'Functionality must not rely on complex gestures',
help: 'Provide alternatives to multi-point or path-based gestures',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html',
explanation: 'Features that use gestures like pinch-to-zoom or swipe should also work with simple clicks or taps. Not everyone can perform complex gestures.',
evaluate: (element) => {
const classes = element.className || '';
const hasGestureClass = /swipe|pinch|drag|gesture/.test(classes);
return {
passed: !hasGestureClass,
message: hasGestureClass ? 'Element may use complex gestures - ensure alternatives exist' : null,
incomplete: hasGestureClass
};
}
},
// 2.5.2 Pointer Cancellation (Level A)
'pointer-cancellation': {
id: 'pointer-cancellation',
selector: 'button, a, [role="button"], [onclick]',
tags: ['wcag22a', 'wcag252', 'operable'],
impact: 'moderate',
description: 'Actions must not trigger on down-event alone',
help: 'Use click events instead of mousedown for triggering actions',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html',
explanation: 'Actions should happen when users release the mouse button (click), not when they press it down. This lets users cancel by moving away before releasing.',
evaluate: (element) => {
const hasMousedown = element.hasAttribute('onmousedown');
const hasPointerdown = element.hasAttribute('onpointerdown');
const hasTouchstart = element.hasAttribute('ontouchstart');
if (hasMousedown || hasPointerdown || hasTouchstart) {
return {
passed: false,
message: 'Action triggers on down event - use click/pointerup instead'
};
}
return { passed: true };
}
},
// 2.5.3 Label in Name (Level A)
'label-in-name': {
id: 'label-in-name',
selector: 'button, a, [role="button"], [role="link"]',
tags: ['wcag22a', 'wcag253', 'operable'],
impact: 'serious',
description: 'Accessible name must contain visible label text',
help: 'Ensure aria-label includes the visible text',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
explanation: 'If a button shows "Submit" but has a different aria-label like "Send form", voice control users cannot activate it by saying "click submit".',
evaluate: (element) => {
const visibleText = element.textContent.trim().toLowerCase();
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel && visibleText) {
const ariaLabelLower = ariaLabel.toLowerCase();
if (!ariaLabelLower.includes(visibleText)) {
return {
passed: false,
message: 'Accessible name does not contain visible label text'
};
}
}
return { passed: true };
}
},
// 2.5.4 Motion Actuation (Level A)
'motion-actuation': {
id: 'motion-actuation',
selector: 'body',
tags: ['wcag22a', 'wcag254', 'operable'],
impact: 'serious',
description: 'Functionality triggered by motion must have alternatives',
help: 'Provide conventional controls for motion-activated features',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html',
explanation: 'Features that work by shaking or tilting the device need button alternatives. Some people cannot move their devices or have them mounted.',
evaluate: () => {
// Check for device motion listeners
const scripts = document.querySelectorAll('script');
let hasMotionListeners = false;
scripts.forEach(script => {
const content = script.textContent;
if (content && /devicemotion|deviceorientation|accelerometer/.test(content)) {
hasMotionListeners = true;
}
});
return {
passed: !hasMotionListeners,
message: hasMotionListeners ? 'Motion sensors detected - ensure UI alternatives exist' : null,
incomplete: hasMotionListeners
};
}
},
// 2.5.7 Dragging Movements (Level AA) - New in WCAG 2.2
'dragging-movements': {
id: 'dragging-movements',
selector: '[draggable="true"], [class*="drag"], [class*="sortable"]',
tags: ['wcag22aa', 'wcag257', 'operable'],
impact: 'serious',
description: 'Dragging actions must have alternatives',
help: 'Provide click alternatives to drag-and-drop',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html',
explanation: 'Drag-and-drop features need alternatives like arrow buttons or menus. Some people cannot perform dragging motions accurately.',
evaluate: (element) => {
const isDraggable = element.getAttribute('draggable') === 'true';
const classes = element.className || '';
const hasDragClass = /drag|sortable|reorder/.test(classes);
if (isDraggable || hasDragClass) {
return {
passed: false,
message: 'Draggable element detected - ensure non-dragging alternatives exist',
incomplete: true
};
}
return { passed: true };
}
},
// 2.5.8 Target Size (Minimum) (Level AA) - New in WCAG 2.2
'target-size': {
id: 'target-size',
selector: 'a, button, input[type="checkbox"], input[type="radio"], [role="button"]',
tags: ['wcag22aa', 'wcag258', 'operable'],
impact: 'serious',
description: 'Interactive targets must be at least 24x24 pixels',
help: 'Ensure clickable elements are large enough',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html',
explanation: 'Buttons and links need to be at least 24x24 pixels so people with motor difficulties can click them easily. Think of it like making buttons finger-friendly.',
evaluate: (element) => {
// Skip hidden elements
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') {
return null;
}
// Skip elements that are likely not visible or not rendered
const rect = element.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
// If element has no dimensions, it's likely not visible or not yet rendered
if (width === 0 && height === 0) {
// Check if it's actually meant to be invisible
const opacity = parseFloat(style.opacity);
if (opacity === 0) {
return null;
}
// Check if parent is hidden
let parent = element.parentElement;
while (parent) {
const parentStyle = window.getComputedStyle(parent);
if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') {
return null;
}
parent = parent.parentElement;
}
// Element might not be rendered yet, mark as incomplete
return {
passed: true,
incomplete: true,
message: 'Element dimensions could not be determined'
};
}
// Check if element is inline (exception)
if (style.display === 'inline') {
return { passed: true };
}
// Check for exceptions in WCAG 2.2
// Exception 1: Inline targets in text
if (element.parentElement && window.getComputedStyle(element.parentElement).display === 'inline') {
const siblingText = element.previousSibling?.nodeType === 3 || element.nextSibling?.nodeType === 3;
if (siblingText) {
return { passed: true };
}
}
// Exception 2: User agent default styling (unstyled controls)
const hasCustomStyling = element.className || element.getAttribute('style');
if (!hasCustomStyling && ['INPUT', 'SELECT'].includes(element.tagName)) {
return { passed: true };
}
if (width < 24 || height < 24) {
return {
passed: false,
message: `Target size ${Math.round(width)}x${Math.round(height)}px is below 24x24px minimum`
};
}
return { passed: true };
}
}
};