@ordojs/core
Version:
Core compiler and runtime for OrdoJS framework
917 lines • 30.2 kB
JavaScript
/**
* @fileoverview OrdoJS Core - Accessibility Manager
*/
/**
* Accessibility Manager for OrdoJS applications
*/
export class AccessibilityManager {
config;
keyboardConfig;
focusConfig;
focusHistory = [];
currentFocusIndex = -1;
ariaIdCounter = 0;
constructor(config = {}) {
this.config = {
enableAriaGeneration: true,
enableKeyboardNavigation: true,
enableFocusManagement: true,
enableScreenReaderSupport: true,
enableSemanticHTML: true,
enableAccessibilityTesting: true,
ariaLabels: {},
keyboardShortcuts: {},
focusOrder: [],
...config
};
this.keyboardConfig = {
enableTabNavigation: true,
enableArrowKeys: true,
enableEnterKey: true,
enableEscapeKey: true,
enableSpaceKey: true,
customShortcuts: {},
focusTraps: [],
skipLinks: []
};
this.focusConfig = {
enableFocusTrapping: true,
enableFocusRestoration: true,
enableFocusIndicators: true,
focusOrder: [],
focusableSelectors: [
'button', 'input', 'select', 'textarea', 'a[href]',
'[tabindex]:not([tabindex="-1"])', '[contenteditable]'
],
skipFocusSelectors: [
'[aria-hidden="true"]', '[hidden]', '[disabled]'
]
};
this.initialize();
}
/**
* Initialize accessibility features
*/
initialize() {
if (this.config.enableKeyboardNavigation) {
this.setupKeyboardNavigation();
}
if (this.config.enableFocusManagement) {
this.setupFocusManagement();
}
if (this.config.enableScreenReaderSupport) {
this.setupScreenReaderSupport();
}
if (this.config.enableAccessibilityTesting) {
this.setupAccessibilityTesting();
}
}
/**
* Generate ARIA attributes for an element
*/
generateAriaAttributes(element) {
const attributes = {};
// Handle input elements
if (element instanceof HTMLInputElement) {
if ('checked' in element && element.checked !== undefined) {
attributes['aria-checked'] = element.checked;
}
if ('disabled' in element && element.disabled !== undefined) {
attributes['aria-disabled'] = element.disabled;
}
if (element.required !== undefined) {
attributes['aria-required'] = element.required;
}
if (element.readOnly !== undefined) {
attributes['aria-readonly'] = element.readOnly;
}
if (element.value !== undefined) {
const value = parseFloat(element.value);
if (!isNaN(value)) {
attributes['aria-valuenow'] = value;
}
}
}
// Handle textarea elements
if (element instanceof HTMLTextAreaElement) {
if (element.disabled !== undefined) {
attributes['aria-disabled'] = element.disabled;
}
if (element.required !== undefined) {
attributes['aria-required'] = element.required;
}
if (element.readOnly !== undefined) {
attributes['aria-readonly'] = element.readOnly;
}
attributes['aria-multiline'] = true;
}
// Handle select elements
if (element instanceof HTMLSelectElement) {
if (element.disabled !== undefined) {
attributes['aria-disabled'] = element.disabled;
}
if (element.required !== undefined) {
attributes['aria-required'] = element.required;
}
}
return attributes;
}
/**
* Determine the appropriate ARIA role for an element
*/
determineRole(element, context) {
const tagName = element.tagName.toLowerCase();
const className = element.className;
// Semantic HTML roles
const semanticRoles = {
'button': 'button',
'input': 'textbox',
'select': 'combobox',
'textarea': 'textbox',
'a': 'link',
'nav': 'navigation',
'main': 'main',
'aside': 'complementary',
'header': 'banner',
'footer': 'contentinfo',
'section': 'region',
'article': 'article',
'form': 'form',
'table': 'table',
'ul': 'list',
'ol': 'list',
'li': 'listitem',
'h1': 'heading',
'h2': 'heading',
'h3': 'heading',
'h4': 'heading',
'h5': 'heading',
'h6': 'heading'
};
// Check for explicit role
const explicitRole = element.getAttribute('role');
if (explicitRole) {
return explicitRole;
}
// Check semantic role
if (semanticRoles[tagName]) {
return semanticRoles[tagName];
}
// Check for common patterns
if (className.includes('button') || className.includes('btn')) {
return 'button';
}
if (className.includes('modal') || className.includes('dialog')) {
return 'dialog';
}
if (className.includes('menu')) {
return 'menu';
}
if (className.includes('tab')) {
return 'tab';
}
if (className.includes('toolbar')) {
return 'toolbar';
}
if (className.includes('tooltip')) {
return 'tooltip';
}
if (className.includes('progress')) {
return 'progressbar';
}
if (className.includes('slider')) {
return 'slider';
}
if (className.includes('switch') || className.includes('toggle')) {
return 'switch';
}
if (className.includes('checkbox')) {
return 'checkbox';
}
if (className.includes('radio')) {
return 'radio';
}
if (className.includes('combobox')) {
return 'combobox';
}
if (className.includes('listbox')) {
return 'listbox';
}
if (className.includes('tree')) {
return 'tree';
}
if (className.includes('grid')) {
return 'grid';
}
if (className.includes('table')) {
return 'table';
}
if (className.includes('tablist')) {
return 'tablist';
}
if (className.includes('tabpanel')) {
return 'tabpanel';
}
return undefined;
}
/**
* Generate appropriate label for an element
*/
generateLabel(element, context) {
// Check for explicit label
const explicitLabel = element.getAttribute('aria-label');
if (explicitLabel) {
return explicitLabel;
}
// Check for associated label
const id = element.id;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label && label.textContent) {
return label.textContent.trim();
}
}
// Check for placeholder
const placeholder = element.getAttribute('placeholder');
if (placeholder) {
return placeholder;
}
// Check for title attribute
const title = element.getAttribute('title');
if (title) {
return title;
}
// Check for alt text on images
if (element.tagName === 'IMG') {
const alt = element.getAttribute('alt');
if (alt) {
return alt;
}
}
// Check for text content
const textContent = element.textContent?.trim();
if (textContent && textContent.length > 0 && textContent.length < 100) {
return textContent;
}
// Check context for label
if (context.label) {
return context.label;
}
return undefined;
}
/**
* Generate description for an element
*/
generateDescription(element, context) {
// Check for explicit description
const explicitDesc = element.getAttribute('aria-describedby');
if (explicitDesc) {
return explicitDesc;
}
// Check for help text
const helpText = element.getAttribute('data-help');
if (helpText) {
return helpText;
}
// Check context for description
if (context.description) {
return context.description;
}
return undefined;
}
/**
* Check if element is interactive
*/
isInteractiveElement(element) {
const interactiveTags = ['button', 'a', 'input', 'select', 'textarea'];
const tagName = element.tagName.toLowerCase();
if (interactiveTags.includes(tagName)) {
return true;
}
const role = element.getAttribute('role');
const interactiveRoles = ['button', 'link', 'menuitem', 'tab', 'checkbox', 'radio', 'switch'];
if (role && interactiveRoles.includes(role)) {
return true;
}
return element.onclick !== null || element.onkeydown !== null;
}
/**
* Check if element is a form element
*/
isFormElement(element) {
const formTags = ['input', 'select', 'textarea', 'button'];
const tagName = element.tagName.toLowerCase();
return formTags.includes(tagName);
}
/**
* Check if element is a navigation element
*/
isNavigationElement(element) {
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute('role');
return tagName === 'nav' || role === 'navigation' ||
element.closest('nav') !== null ||
element.className.includes('nav');
}
/**
* Check if element is a list element
*/
isListElement(element) {
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute('role');
return tagName === 'ul' || tagName === 'ol' || tagName === 'li' ||
role === 'list' || role === 'listitem';
}
/**
* Add ARIA attributes for interactive elements
*/
addInteractiveAriaAttributes(attributes, element, context) {
const tagName = element.tagName.toLowerCase();
const role = element.getAttribute('role');
// Handle button-like elements
if (tagName === 'button' || role === 'button') {
if (element.getAttribute('aria-pressed') === null) {
attributes['aria-pressed'] = false;
}
}
// Handle expandable elements
if (context.expandable) {
attributes['aria-expanded'] = context.expanded || false;
}
// Handle selected state
if (context.selected !== undefined) {
attributes['aria-selected'] = context.selected;
}
// Handle checked state
if (tagName === 'input' && element.getAttribute('type') === 'checkbox') {
attributes['aria-checked'] = element.checked;
}
if (tagName === 'input' && element.getAttribute('type') === 'radio') {
attributes['aria-checked'] = element.checked;
}
// Handle disabled state
if (element.disabled) {
attributes['aria-disabled'] = true;
}
// Handle required state
if (element.hasAttribute('required')) {
attributes['aria-required'] = true;
}
// Handle invalid state
if (element.hasAttribute('aria-invalid')) {
attributes['aria-invalid'] = element.getAttribute('aria-invalid') === 'true';
}
}
/**
* Add ARIA attributes for form elements
*/
addFormAriaAttributes(attributes, element, context) {
const tagName = element.tagName.toLowerCase();
if (tagName === 'input') {
const type = element.getAttribute('type');
if (type === 'range') {
attributes.role = 'slider';
attributes['aria-valuemin'] = parseFloat(element.getAttribute('min') || '0');
attributes['aria-valuemax'] = parseFloat(element.getAttribute('max') || '100');
attributes['aria-valuenow'] = parseFloat(element.value || '0');
}
if (type === 'search') {
attributes['aria-autocomplete'] = 'list';
}
if (type === 'email' || type === 'tel' || type === 'url') {
attributes['aria-autocomplete'] = 'inline';
}
}
if (tagName === 'select') {
attributes['aria-haspopup'] = 'listbox';
}
if (tagName === 'textarea') {
const rows = element.getAttribute('rows');
if (rows && parseInt(rows) > 1) {
attributes['aria-multiline'] = true;
}
}
}
/**
* Add ARIA attributes for navigation elements
*/
addNavigationAriaAttributes(attributes, element, context) {
const tagName = element.tagName.toLowerCase();
if (tagName === 'nav') {
attributes.role = 'navigation';
}
// Handle breadcrumbs
if (element.className.includes('breadcrumb')) {
attributes.role = 'navigation';
attributes['aria-label'] = 'Breadcrumb';
}
// Handle pagination
if (element.className.includes('pagination')) {
attributes.role = 'navigation';
attributes['aria-label'] = 'Pagination';
}
// Handle tabs
if (element.className.includes('tab')) {
attributes.role = 'tab';
if (context.selected) {
attributes['aria-selected'] = true;
}
}
}
/**
* Add ARIA attributes for list elements
*/
addListAriaAttributes(attributes, element, context) {
const tagName = element.tagName.toLowerCase();
if (tagName === 'ul' || tagName === 'ol') {
attributes.role = 'list';
}
if (tagName === 'li') {
attributes.role = 'listitem';
}
// Handle list size
if (context.listSize) {
attributes['aria-setsize'] = context.listSize;
}
if (context.listPosition) {
attributes['aria-posinset'] = context.listPosition;
}
}
/**
* Setup keyboard navigation
*/
setupKeyboardNavigation() {
document.addEventListener('keydown', (event) => {
this.handleKeyboardNavigation(event);
});
}
/**
* Handle keyboard navigation
*/
handleKeyboardNavigation(event) {
const target = event.target;
// Handle Tab navigation
if (event.key === 'Tab' && this.keyboardConfig.enableTabNavigation) {
this.handleTabNavigation(event);
}
// Handle Arrow keys
if (this.keyboardConfig.enableArrowKeys && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
this.handleArrowKeyNavigation(event);
}
// Handle Enter key
if (event.key === 'Enter' && this.keyboardConfig.enableEnterKey) {
this.handleEnterKey(event);
}
// Handle Escape key
if (event.key === 'Escape' && this.keyboardConfig.enableEscapeKey) {
this.handleEscapeKey(event);
}
// Handle Space key
if (event.key === ' ' && this.keyboardConfig.enableSpaceKey) {
this.handleSpaceKey(event);
}
// Handle custom shortcuts
this.handleCustomShortcuts(event);
}
/**
* Handle Tab navigation
*/
handleTabNavigation(event) {
const focusableElements = this.getFocusableElements();
if (event.shiftKey) {
// Shift+Tab - move backwards
this.moveFocusBackward(event, focusableElements);
}
else {
// Tab - move forwards
this.moveFocusForward(event, focusableElements);
}
}
/**
* Handle Arrow key navigation
*/
handleArrowKeyNavigation(event) {
const target = event.target;
const role = target.getAttribute('role');
if (role === 'menuitem' || role === 'tab' || role === 'option') {
event.preventDefault();
const container = target.closest('[role="menu"], [role="tablist"], [role="listbox"]');
if (container) {
const items = Array.from(container.querySelectorAll(`[role="${role}"]`));
const currentIndex = items.indexOf(target);
let nextIndex = currentIndex;
switch (event.key) {
case 'ArrowUp':
case 'ArrowLeft':
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
break;
case 'ArrowDown':
case 'ArrowRight':
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
break;
}
if (nextIndex !== currentIndex) {
items[nextIndex].focus();
}
}
}
}
/**
* Handle Enter key
*/
handleEnterKey(event) {
const target = event.target;
const role = target.getAttribute('role');
if (role === 'menuitem' || role === 'option') {
event.preventDefault();
target.click();
}
}
/**
* Handle Escape key
*/
handleEscapeKey(event) {
const target = event.target;
const dialog = target.closest('[role="dialog"]');
if (dialog) {
event.preventDefault();
this.closeDialog(dialog);
}
}
/**
* Handle Space key
*/
handleSpaceKey(event) {
const target = event.target;
const role = target.getAttribute('role');
if (role === 'button' || role === 'checkbox' || role === 'radio') {
event.preventDefault();
target.click();
}
}
/**
* Handle custom shortcuts
*/
handleCustomShortcuts(event) {
const key = this.getKeyCombo(event);
if (this.keyboardConfig.customShortcuts[key]) {
event.preventDefault();
this.keyboardConfig.customShortcuts[key]();
}
}
/**
* Get key combination string
*/
getKeyCombo(event) {
const parts = [];
if (event.ctrlKey)
parts.push('Ctrl');
if (event.altKey)
parts.push('Alt');
if (event.shiftKey)
parts.push('Shift');
if (event.metaKey)
parts.push('Meta');
parts.push(event.key);
return parts.join('+');
}
/**
* Setup focus management
*/
setupFocusManagement() {
document.addEventListener('focusin', (event) => {
this.handleFocusIn(event);
});
document.addEventListener('focusout', (event) => {
this.handleFocusOut(event);
});
}
/**
* Handle focus in
*/
handleFocusIn(event) {
const target = event.target;
// Add to focus history
this.focusHistory.push(target);
if (this.focusHistory.length > 10) {
this.focusHistory.shift();
}
// Add focus indicator
if (this.focusConfig.enableFocusIndicators) {
target.classList.add('focus-visible');
}
}
/**
* Handle focus out
*/
handleFocusOut(event) {
const target = event.target;
// Remove focus indicator
if (this.focusConfig.enableFocusIndicators) {
target.classList.remove('focus-visible');
}
}
/**
* Setup screen reader support
*/
setupScreenReaderSupport() {
// Create live region for announcements
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.position = 'absolute';
liveRegion.style.left = '-10000px';
liveRegion.style.width = '1px';
liveRegion.style.height = '1px';
liveRegion.style.overflow = 'hidden';
document.body.appendChild(liveRegion);
}
/**
* Setup accessibility testing
*/
setupAccessibilityTesting() {
// Add accessibility testing utilities
window.accessibilityTest = {
checkAriaAttributes: () => this.checkAriaAttributes(),
checkKeyboardNavigation: () => this.checkKeyboardNavigation(),
checkFocusManagement: () => this.checkFocusManagement(),
checkScreenReaderSupport: () => this.checkScreenReaderSupport(),
generateAccessibilityReport: () => this.generateAccessibilityReport()
};
}
/**
* Get focusable elements
*/
getFocusableElements() {
const selectors = this.focusConfig.focusableSelectors.join(', ');
const elements = Array.from(document.querySelectorAll(selectors));
return elements.filter(element => {
// Skip hidden elements
if (element.offsetParent === null)
return false;
// Skip disabled elements
if (element.hasAttribute('disabled'))
return false;
// Skip elements with aria-hidden
if (element.getAttribute('aria-hidden') === 'true')
return false;
return true;
});
}
/**
* Move focus forward
*/
moveFocusForward(event, elements) {
const currentElement = event.target;
const currentIndex = elements.indexOf(currentElement);
const nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0;
elements[nextIndex].focus();
}
/**
* Move focus backward
*/
moveFocusBackward(event, elements) {
const currentElement = event.target;
const currentIndex = elements.indexOf(currentElement);
const prevIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1;
elements[prevIndex].focus();
}
/**
* Close dialog
*/
closeDialog(dialog) {
const closeButton = dialog.querySelector('[aria-label*="close"], [aria-label*="Close"]');
if (closeButton) {
closeButton.click();
}
else {
dialog.style.display = 'none';
}
}
/**
* Generate unique ARIA ID
*/
generateAriaId(prefix) {
return `${prefix}-${++this.ariaIdCounter}`;
}
/**
* Check ARIA attributes
*/
checkAriaAttributes() {
const issues = [];
const elements = document.querySelectorAll('[role]');
elements.forEach(element => {
const role = element.getAttribute('role');
const requiredAttributes = this.getRequiredAriaAttributes(role);
requiredAttributes.forEach(attr => {
if (!element.hasAttribute(attr)) {
issues.push({
element: element,
issue: `Missing required ARIA attribute: ${attr}`,
severity: 'error'
});
}
});
});
return issues;
}
/**
* Get required ARIA attributes for a role
*/
getRequiredAriaAttributes(role) {
const requirements = {
'button': [],
'link': [],
'menuitem': ['aria-label'],
'tab': ['aria-selected'],
'tabpanel': ['aria-labelledby'],
'combobox': ['aria-expanded'],
'listbox': ['aria-multiselectable'],
'option': ['aria-selected'],
'slider': ['aria-valuemin', 'aria-valuemax', 'aria-valuenow'],
'progressbar': ['aria-valuemin', 'aria-valuemax', 'aria-valuenow'],
'checkbox': ['aria-checked'],
'radio': ['aria-checked'],
'switch': ['aria-checked'],
'dialog': ['aria-labelledby'],
'alert': ['aria-live'],
'status': ['aria-live'],
'log': ['aria-live'],
'timer': ['aria-live']
};
return requirements[role] || [];
}
/**
* Check keyboard navigation
*/
checkKeyboardNavigation() {
const issues = [];
const focusableElements = this.getFocusableElements();
// Check if all focusable elements are reachable via keyboard
focusableElements.forEach(element => {
const tabIndex = element.getAttribute('tabindex');
if (tabIndex === '-1') {
issues.push({
element: element,
issue: 'Element has tabindex="-1" but is focusable',
severity: 'warning'
});
}
});
return issues;
}
/**
* Check focus management
*/
checkFocusManagement() {
const issues = [];
// Check for focus traps
const focusTraps = document.querySelectorAll('[data-focus-trap]');
focusTraps.forEach(trap => {
const focusableElements = trap.querySelectorAll(this.focusConfig.focusableSelectors.join(', '));
if (focusableElements.length === 0) {
issues.push({
element: trap,
issue: 'Focus trap has no focusable elements',
severity: 'error'
});
}
});
return issues;
}
/**
* Check screen reader support
*/
checkScreenReaderSupport() {
const issues = [];
// Check for images without alt text
const images = document.querySelectorAll('img');
images.forEach(img => {
if (!img.hasAttribute('alt')) {
issues.push({
element: img,
issue: 'Image missing alt text',
severity: 'error'
});
}
});
// Check for form labels
const inputs = document.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const id = input.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (!label) {
issues.push({
element: input,
issue: 'Form control missing associated label',
severity: 'error'
});
}
}
});
return issues;
}
/**
* Generate accessibility report
*/
generateAccessibilityReport() {
return {
ariaIssues: this.checkAriaAttributes(),
keyboardIssues: this.checkKeyboardNavigation(),
focusIssues: this.checkFocusManagement(),
screenReaderIssues: this.checkScreenReaderSupport(),
summary: {
totalIssues: 0,
errors: 0,
warnings: 0,
suggestions: 0
}
};
}
/**
* Announce to screen readers
*/
announce(message, priority = 'polite') {
const liveRegion = document.querySelector('[aria-live]');
if (liveRegion) {
liveRegion.setAttribute('aria-live', priority);
liveRegion.textContent = message;
// Clear the message after a short delay
setTimeout(() => {
liveRegion.textContent = '';
}, 1000);
}
}
/**
* Set focus to element
*/
setFocus(element) {
element.focus();
this.announce(`Focused on ${element.textContent || element.getAttribute('aria-label') || 'element'}`);
}
/**
* Restore previous focus
*/
restoreFocus() {
if (this.focusHistory.length > 0) {
const previousFocus = this.focusHistory.pop();
if (previousFocus && document.contains(previousFocus)) {
previousFocus.focus();
}
}
}
/**
* Add custom keyboard shortcut
*/
addKeyboardShortcut(keyCombo, callback) {
this.keyboardConfig.customShortcuts[keyCombo] = callback;
}
/**
* Remove custom keyboard shortcut
*/
removeKeyboardShortcut(keyCombo) {
delete this.keyboardConfig.customShortcuts[keyCombo];
}
/**
* Update configuration
*/
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
}
navigateToNextItem(items, currentIndex) {
const nextIndex = (currentIndex + 1) % items.length;
const nextItem = items[nextIndex];
if (nextItem) {
nextItem.focus();
}
}
navigateToPreviousItem(items, currentIndex) {
const prevIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
const prevItem = items[prevIndex];
if (prevItem) {
prevItem.focus();
}
}
validateAccessibility(component) {
const issues = [];
// ... existing validation logic ...
return issues;
}
validateKeyboardNavigation(component) {
const issues = [];
// ... existing validation logic ...
return issues;
}
validateScreenReaderSupport(component) {
const issues = [];
// ... existing validation logic ...
return issues;
}
validateSemanticHTML(component) {
const issues = [];
// ... existing validation logic ...
return issues;
}
}
//# sourceMappingURL=accessibility-manager.js.map