pmtool / src /utils /accessibility.ts
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
6.41 kB
/**
* 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<HTMLElement> | 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';
};