/** * KeyboardNavigation - WCAG 2.1 AA Compliant Keyboard Navigation * * Features: * - Full keyboard navigation support * - Focus management * - Skip links functionality * - Custom keyboard shortcuts * - Focus trap for modals * - WCAG 2.1 AA compliance */ class KeyboardNavigation { constructor() { this.focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; this.currentFocusIndex = -1; this.focusableElementsList = []; this.modalOpen = false; this.lastFocusedElement = null; this.init(); } /** * Initialize keyboard navigation */ init() { this.setupEventListeners(); this.setupSkipLinks(); this.setupFocusManagement(); this.setupKeyboardShortcuts(); this.announceToScreenReader('Keyboard navigation initialized'); } /** * Setup event listeners for keyboard navigation */ setupEventListeners() { document.addEventListener('keydown', (e) => this.handleKeyDown(e)); document.addEventListener('focus', (e) => this.handleFocus(e), true); document.addEventListener('blur', (e) => this.handleBlur(e), true); // Handle focus for dynamic content const observer = new MutationObserver(() => { this.updateFocusableElements(); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['tabindex', 'disabled', 'aria-hidden'] }); } /** * Handle keyboard events * * @param {KeyboardEvent} e Keyboard event */ handleKeyDown(e) { switch (e.key) { case 'Tab': this.handleTabNavigation(e); break; case 'Enter': case ' ': this.handleActivation(e); break; case 'Escape': this.handleEscape(e); break; case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': case 'ArrowRight': this.handleArrowNavigation(e); break; case 'Home': case 'End': this.handleHomeEndNavigation(e); break; default: this.handleCustomShortcuts(e); } } /** * Handle Tab navigation * * @param {KeyboardEvent} e Keyboard event */ handleTabNavigation(e) { if (e.ctrlKey || e.altKey) return; this.updateFocusableElements(); if (this.focusableElementsList.length === 0) return; const currentIndex = this.focusableElementsList.indexOf(document.activeElement); let nextIndex; if (e.shiftKey) { // Shift+Tab - Previous element nextIndex = currentIndex <= 0 ? this.focusableElementsList.length - 1 : currentIndex - 1; } else { // Tab - Next element nextIndex = currentIndex >= this.focusableElementsList.length - 1 ? 0 : currentIndex + 1; } e.preventDefault(); this.focusElement(this.focusableElementsList[nextIndex]); } /** * Handle activation (Enter/Space) * * @param {KeyboardEvent} e Keyboard event */ handleActivation(e) { const element = document.activeElement; if (e.key === ' ' && (element.tagName === 'BUTTON' || element.role === 'button')) { e.preventDefault(); element.click(); this.announceToScreenReader('Button activated'); } if (e.key === 'Enter' && element.tagName === 'A' && element.getAttribute('role') === 'menuitem') { e.preventDefault(); element.click(); this.announceToScreenReader('Link activated'); } } /** * Handle Escape key * * @param {KeyboardEvent} e Keyboard event */ handleEscape(e) { if (this.modalOpen) { this.closeModal(); this.announceToScreenReader('Modal closed'); } else { // Return focus to main content const mainContent = document.getElementById('main-content'); if (mainContent) { this.focusElement(mainContent); this.announceToScreenReader('Returned to main content'); } } } /** * Handle arrow key navigation * * @param {KeyboardEvent} e Keyboard event */ handleArrowNavigation(e) { const element = document.activeElement; // Handle menu navigation if (element.getAttribute('role') === 'menuitem' || element.classList.contains('dropdown-item')) { e.preventDefault(); this.navigateMenu(e.key); } // Handle tab navigation in tab lists if (element.getAttribute('role') === 'tab') { e.preventDefault(); this.navigateTabs(e.key); } // Handle grid navigation if (element.getAttribute('role') === 'gridcell') { e.preventDefault(); this.navigateGrid(e.key); } } /** * Handle Home/End navigation * * @param {KeyboardEvent} e Keyboard event */ handleHomeEndNavigation(e) { if (e.ctrlKey || e.altKey) return; this.updateFocusableElements(); if (this.focusableElementsList.length === 0) return; const targetIndex = e.key === 'Home' ? 0 : this.focusableElementsList.length - 1; e.preventDefault(); this.focusElement(this.focusableElementsList[targetIndex]); this.announceToScreenReader(`Moved to ${e.key === 'Home' ? 'first' : 'last'} element`); } /** * Setup skip links functionality */ setupSkipLinks() { const skipLinks = document.querySelectorAll('.skip-link'); skipLinks.forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const targetId = link.getAttribute('href').substring(1); const targetElement = document.getElementById(targetId); if (targetElement) { this.focusElement(targetElement); this.announceToScreenReader(`Skipped to ${link.textContent}`); } }); }); } /** * Setup focus management */ setupFocusManagement() { // Add focus indicators const style = document.createElement('style'); style.textContent = ` :focus { outline: 3px solid #0056b3 !important; outline-offset: 2px !important; } .skip-link:focus { position: static !important; width: auto !important; height: auto !important; overflow: visible !important; clip: auto !important; clip-path: none !important; white-space: normal !important; } [aria-hidden="true"] { display: none !important; } .sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } .keyboard-user *:focus { outline: 3px solid #0056b3 !important; outline-offset: 2px !important; } `; document.head.appendChild(style); // Detect keyboard user document.addEventListener('keydown', () => { document.body.classList.add('keyboard-user'); }, { once: true }); // Remove keyboard class on mouse use document.addEventListener('mousedown', () => { document.body.classList.remove('keyboard-user'); }); } /** * Setup custom keyboard shortcuts */ setupKeyboardShortcuts() { // Alt+S - Focus search this.addShortcut('Alt+s', () => { const searchInput = document.getElementById('search-input'); if (searchInput) { this.focusElement(searchInput); this.announceToScreenReader('Search focused'); } }); // Alt+N - Focus navigation this.addShortcut('Alt+n', () => { const navigation = document.getElementById('main-navigation'); if (navigation) { this.focusElement(navigation.querySelector('[role="menuitem"]')); this.announceToScreenReader('Navigation focused'); } }); // Alt+M - Focus main content this.addShortcut('Alt+m', () => { const mainContent = document.getElementById('main-content'); if (mainContent) { this.focusElement(mainContent); this.announceToScreenReader('Main content focused'); } }); // Alt+H - Go home this.addShortcut('Alt+h', () => { window.location.href = '/'; }); // Alt+1-9 - Quick navigation for (let i = 1; i <= 9; i++) { this.addShortcut(`Alt+${i}`, () => { this.quickNavigate(i); }); } } /** * Add keyboard shortcut * * @param {string} shortcut Shortcut combination * @param {Function} callback Callback function */ addShortcut(shortcut, callback) { document.addEventListener('keydown', (e) => { if (this.matchesShortcut(e, shortcut)) { e.preventDefault(); callback(); } }); } /** * Check if event matches shortcut * * @param {KeyboardEvent} e Keyboard event * @param {string} shortcut Shortcut string * @return {boolean} True if matches */ matchesShortcut(e, shortcut) { const parts = shortcut.toLowerCase().split('+'); const key = parts.pop(); if (e.key.toLowerCase() !== key) return false; const altRequired = parts.includes('alt'); const ctrlRequired = parts.includes('ctrl'); const shiftRequired = parts.includes('shift'); return e.altKey === altRequired && e.ctrlKey === ctrlRequired && e.shiftKey === shiftRequired; } /** * Update focusable elements list */ updateFocusableElements() { this.focusableElementsList = Array.from(document.querySelectorAll(this.focusableElements)) .filter(element => { // Filter out hidden elements const style = window.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && element.getAttribute('aria-hidden') !== 'true' && !element.disabled; }); } /** * Focus element with accessibility * * @param {Element} element Element to focus */ focusElement(element) { if (!element) return; element.focus(); // Scroll into view if needed element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } /** * Handle focus events * * @param {FocusEvent} e Focus event */ handleFocus(e) { this.currentFocusIndex = this.focusableElementsList.indexOf(e.target); // Announce focus changes to screen readers const announcement = this.getFocusAnnouncement(e.target); if (announcement) { setTimeout(() => { this.announceToScreenReader(announcement); }, 100); } } /** * Handle blur events * * @param {FocusEvent} e Blur event */ handleBlur(e) { // Store last focused element for modal restoration if (!this.modalOpen) { this.lastFocusedElement = e.target; } } /** * Get focus announcement for screen readers * * @param {Element} element Focused element * @return {string} Announcement text */ getFocusAnnouncement(element) { const tagName = element.tagName.toLowerCase(); const role = element.getAttribute('role'); const label = element.getAttribute('aria-label') || element.textContent || ''; if (role === 'button') { return `Button, ${label}`; } else if (role === 'link') { return `Link, ${label}`; } else if (tagName === 'input') { const type = element.type || 'text'; return `${type} input, ${label}`; } else if (role === 'menuitem') { return `Menu item, ${label}`; } else if (role === 'tab') { return `Tab, ${label}`; } return label || ''; } /** * Announce to screen readers * * @param {string} message Message to announce */ announceToScreenReader(message) { const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', 'polite'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } /** * Navigate menu with arrow keys * * @param {string} direction Arrow direction */ navigateMenu(direction) { const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')); const currentIndex = menuItems.indexOf(document.activeElement); let nextIndex; if (direction === 'ArrowDown' || direction === 'ArrowRight') { nextIndex = currentIndex >= menuItems.length - 1 ? 0 : currentIndex + 1; } else { nextIndex = currentIndex <= 0 ? menuItems.length - 1 : currentIndex - 1; } this.focusElement(menuItems[nextIndex]); } /** * Navigate tabs with arrow keys * * @param {string} direction Arrow direction */ navigateTabs(direction) { const tabs = Array.from(document.querySelectorAll('[role="tab"]')); const currentIndex = tabs.indexOf(document.activeElement); let nextIndex; if (direction === 'ArrowRight' || direction === 'ArrowDown') { nextIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1; } else { nextIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1; } this.focusElement(tabs[nextIndex]); tabs[nextIndex].click(); } /** * Navigate grid with arrow keys * * @param {string} direction Arrow direction */ navigateGrid(direction) { // Implementation for grid navigation // This would need to be customized based on specific grid structure } /** * Quick navigation with Alt+number * * @param {number} number Number key */ quickNavigate(number) { const targets = [ { selector: '#main-navigation', name: 'navigation' }, { selector: '#search-input', name: 'search' }, { selector: '#main-content', name: 'main content' }, { selector: 'h1', name: 'heading' }, { selector: '.breadcrumb', name: 'breadcrumb' }, { selector: 'footer', name: 'footer' }, { selector: '.sidebar', name: 'sidebar' }, { selector: '.btn-primary', name: 'primary button' }, { selector: 'form', name: 'form' } ]; if (number <= targets.length) { const target = targets[number - 1]; const element = document.querySelector(target.selector); if (element) { this.focusElement(element); this.announceToScreenReader(`Quick navigation to ${target.name}`); } } } /** * Handle custom shortcuts * * @param {KeyboardEvent} e Keyboard event */ handleCustomShortcuts(e) { // Additional custom shortcuts can be added here } /** * Open modal with focus trap * * @param {string} modalId Modal ID */ openModal(modalId) { const modal = document.getElementById(modalId); if (!modal) return; this.modalOpen = true; this.lastFocusedElement = document.activeElement; modal.setAttribute('aria-hidden', 'false'); modal.style.display = 'block'; // Focus first focusable element in modal const firstFocusable = modal.querySelector(this.focusableElements); if (firstFocusable) { this.focusElement(firstFocusable); } this.announceToScreenReader('Modal opened'); } /** * Close modal and restore focus */ closeModal() { if (!this.modalOpen) return; const modal = document.querySelector('[role="dialog"][aria-hidden="false"]'); if (!modal) return; modal.setAttribute('aria-hidden', 'true'); modal.style.display = 'none'; this.modalOpen = false; // Restore focus to last focused element if (this.lastFocusedElement) { this.focusElement(this.lastFocusedElement); } } } // Initialize keyboard navigation when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.keyboardNavigation = new KeyboardNavigation(); });