UNPKG

@ordojs/core

Version:

Core compiler and runtime for OrdoJS framework

917 lines 30.2 kB
/** * @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