/** * Accessibility utilities for the asset management module */ /** * Announces content to screen readers * @param message - The message to announce * @param priority - The priority level (polite or assertive) */ export const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', priority); announcement.setAttribute('aria-atomic', 'true'); announcement.setAttribute('class', 'sr-only'); announcement.textContent = message; document.body.appendChild(announcement); // Remove the announcement after a short delay setTimeout(() => { document.body.removeChild(announcement); }, 1000); }; /** * Manages focus for modal dialogs and overlays */ export class FocusManager { private previousActiveElement: Element | null = null; private focusableElements: NodeListOf | null = null; /** * Traps focus within a container element * @param container - The container element to trap focus within */ trapFocus(container: HTMLElement) { this.previousActiveElement = document.activeElement; // Get all focusable elements within the container this.focusableElements = container.querySelectorAll( 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])' ); if (this.focusableElements.length > 0) { // Focus the first focusable element this.focusableElements[0].focus(); } // Add event listener for tab key container.addEventListener('keydown', this.handleTabKey); } /** * Handles tab key navigation within the focus trap */ private handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab' || !this.focusableElements) return; const firstElement = this.focusableElements[0]; const lastElement = this.focusableElements[this.focusableElements.length - 1]; if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { // Tab if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; /** * Releases the focus trap and restores previous focus */ releaseFocus(container: HTMLElement) { container.removeEventListener('keydown', this.handleTabKey); if (this.previousActiveElement && this.previousActiveElement instanceof HTMLElement) { this.previousActiveElement.focus(); } this.previousActiveElement = null; this.focusableElements = null; } } /** * Checks if an element is visible to screen readers * @param element - The element to check * @returns True if the element is accessible to screen readers */ export const isAccessibleToScreenReaders = (element: HTMLElement): boolean => { // Check if element is hidden if (element.hidden || element.style.display === 'none' || element.style.visibility === 'hidden') { return false; } // Check for aria-hidden if (element.getAttribute('aria-hidden') === 'true') { return false; } // Check if element has accessible text const accessibleName = getAccessibleName(element); return accessibleName.length > 0; }; /** * Gets the accessible name of an element * @param element - The element to get the accessible name for * @returns The accessible name */ export const getAccessibleName = (element: HTMLElement): string => { // Check aria-label const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel) return ariaLabel.trim(); // Check aria-labelledby const ariaLabelledBy = element.getAttribute('aria-labelledby'); if (ariaLabelledBy) { const labelElement = document.getElementById(ariaLabelledBy); if (labelElement) return labelElement.textContent?.trim() || ''; } // Check associated label if (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { const id = element.getAttribute('id'); if (id) { const label = document.querySelector(`label[for="${id}"]`); if (label) return label.textContent?.trim() || ''; } } // Check title attribute const title = element.getAttribute('title'); if (title) return title.trim(); // Check text content return element.textContent?.trim() || ''; }; /** * Validates keyboard navigation for a container * @param container - The container to validate * @returns Array of accessibility issues found */ export const validateKeyboardNavigation = (container: HTMLElement): string[] => { const issues: string[] = []; const focusableElements = container.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusableElements.forEach((element, index) => { const htmlElement = element as HTMLElement; // Check if element is keyboard accessible const tabIndex = htmlElement.getAttribute('tabindex'); if (tabIndex === '-1' && !htmlElement.disabled) { issues.push(`Element at index ${index} (${htmlElement.tagName}) is not keyboard accessible`); } // Check if interactive elements have accessible names if (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'].includes(htmlElement.tagName)) { const accessibleName = getAccessibleName(htmlElement); if (!accessibleName) { issues.push(`Interactive element at index ${index} (${htmlElement.tagName}) lacks accessible name`); } } }); return issues; }; /** * Validates color contrast (basic check) * @param element - The element to check * @returns True if contrast appears adequate */ export const hasAdequateContrast = (element: HTMLElement): boolean => { const styles = window.getComputedStyle(element); const backgroundColor = styles.backgroundColor; const color = styles.color; // This is a simplified check - in a real implementation, // you would calculate the actual contrast ratio return backgroundColor !== color && backgroundColor !== 'transparent'; };