- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
454 lines
14 KiB
JavaScript
454 lines
14 KiB
JavaScript
/**
|
|
* Accessibility Manager for LiveComponents
|
|
*
|
|
* Manages accessibility features for dynamic content updates:
|
|
* - ARIA live regions for screen reader announcements
|
|
* - Focus management with data-lc-keep-focus attribute
|
|
* - Keyboard navigation preservation
|
|
* - Screen reader-friendly update notifications
|
|
*
|
|
* WCAG 2.1 Compliance:
|
|
* - 4.1.3 Status Messages (Level AA)
|
|
* - 2.4.3 Focus Order (Level A)
|
|
* - 2.1.1 Keyboard (Level A)
|
|
*/
|
|
|
|
export class AccessibilityManager {
|
|
constructor() {
|
|
/**
|
|
* ARIA live region element for announcements
|
|
* @type {HTMLElement|null}
|
|
*/
|
|
this.liveRegion = null;
|
|
|
|
/**
|
|
* Component-specific live regions
|
|
* Map<componentId, HTMLElement>
|
|
*/
|
|
this.componentLiveRegions = new Map();
|
|
|
|
/**
|
|
* Focus tracking for restoration
|
|
* Map<componentId, FocusState>
|
|
*/
|
|
this.focusStates = new Map();
|
|
|
|
/**
|
|
* Announcement queue for throttling
|
|
* @type {Array<{message: string, priority: string}>}
|
|
*/
|
|
this.announcementQueue = [];
|
|
|
|
/**
|
|
* Announcement throttle timer
|
|
* @type {number|null}
|
|
*/
|
|
this.announceTimer = null;
|
|
|
|
/**
|
|
* Throttle delay in milliseconds
|
|
* @type {number}
|
|
*/
|
|
this.throttleDelay = 500;
|
|
}
|
|
|
|
/**
|
|
* Initialize accessibility features
|
|
*
|
|
* Creates global live region and sets up initial state.
|
|
*/
|
|
initialize() {
|
|
// Create global live region if not exists
|
|
if (!this.liveRegion) {
|
|
this.liveRegion = this.createLiveRegion('livecomponent-announcer', 'polite');
|
|
document.body.appendChild(this.liveRegion);
|
|
}
|
|
|
|
console.log('[AccessibilityManager] Initialized with ARIA live regions');
|
|
}
|
|
|
|
/**
|
|
* Create ARIA live region element
|
|
*
|
|
* @param {string} id - Element ID
|
|
* @param {string} politeness - ARIA politeness level (polite|assertive)
|
|
* @returns {HTMLElement} Live region element
|
|
*/
|
|
createLiveRegion(id, politeness = 'polite') {
|
|
const region = document.createElement('div');
|
|
region.id = id;
|
|
region.setAttribute('role', 'status');
|
|
region.setAttribute('aria-live', politeness);
|
|
region.setAttribute('aria-atomic', 'true');
|
|
region.className = 'sr-only'; // Screen reader only styling
|
|
|
|
// Add screen reader only styles
|
|
region.style.position = 'absolute';
|
|
region.style.left = '-10000px';
|
|
region.style.width = '1px';
|
|
region.style.height = '1px';
|
|
region.style.overflow = 'hidden';
|
|
|
|
return region;
|
|
}
|
|
|
|
/**
|
|
* Create component-specific live region
|
|
*
|
|
* Each component can have its own live region for isolated announcements.
|
|
*
|
|
* @param {string} componentId - Component identifier
|
|
* @param {HTMLElement} container - Component container element
|
|
* @param {string} politeness - ARIA politeness level
|
|
* @returns {HTMLElement} Component live region
|
|
*/
|
|
createComponentLiveRegion(componentId, container, politeness = 'polite') {
|
|
// Check if already exists
|
|
let liveRegion = this.componentLiveRegions.get(componentId);
|
|
|
|
if (!liveRegion) {
|
|
liveRegion = this.createLiveRegion(`livecomponent-${componentId}-announcer`, politeness);
|
|
container.appendChild(liveRegion);
|
|
this.componentLiveRegions.set(componentId, liveRegion);
|
|
}
|
|
|
|
return liveRegion;
|
|
}
|
|
|
|
/**
|
|
* Announce update to screen readers
|
|
*
|
|
* Uses ARIA live regions to announce updates without stealing focus.
|
|
* Supports throttling to prevent announcement spam.
|
|
*
|
|
* @param {string} message - Message to announce
|
|
* @param {string} priority - Priority level (polite|assertive)
|
|
* @param {string|null} componentId - Optional component-specific announcement
|
|
*/
|
|
announce(message, priority = 'polite', componentId = null) {
|
|
// Add to queue
|
|
this.announcementQueue.push({ message, priority, componentId });
|
|
|
|
// Throttle announcements
|
|
if (this.announceTimer) {
|
|
clearTimeout(this.announceTimer);
|
|
}
|
|
|
|
this.announceTimer = setTimeout(() => {
|
|
this.flushAnnouncements();
|
|
}, this.throttleDelay);
|
|
}
|
|
|
|
/**
|
|
* Flush announcement queue
|
|
*
|
|
* Processes queued announcements and clears the queue.
|
|
*/
|
|
flushAnnouncements() {
|
|
if (this.announcementQueue.length === 0) return;
|
|
|
|
// Get most recent announcement (others are outdated)
|
|
const announcement = this.announcementQueue[this.announcementQueue.length - 1];
|
|
this.announcementQueue = [];
|
|
|
|
// Determine target live region
|
|
let liveRegion = this.liveRegion;
|
|
|
|
if (announcement.componentId) {
|
|
const componentRegion = this.componentLiveRegions.get(announcement.componentId);
|
|
if (componentRegion) {
|
|
liveRegion = componentRegion;
|
|
}
|
|
}
|
|
|
|
if (!liveRegion) {
|
|
console.warn('[AccessibilityManager] No live region available for announcement');
|
|
return;
|
|
}
|
|
|
|
// Update politeness if needed
|
|
if (announcement.priority === 'assertive') {
|
|
liveRegion.setAttribute('aria-live', 'assertive');
|
|
} else {
|
|
liveRegion.setAttribute('aria-live', 'polite');
|
|
}
|
|
|
|
// Clear and set new message
|
|
liveRegion.textContent = '';
|
|
|
|
// Use setTimeout to ensure screen reader picks up the change
|
|
setTimeout(() => {
|
|
liveRegion.textContent = announcement.message;
|
|
}, 100);
|
|
|
|
console.log(`[AccessibilityManager] Announced: "${announcement.message}" (${announcement.priority})`);
|
|
}
|
|
|
|
/**
|
|
* Capture focus state before update
|
|
*
|
|
* Saves current focus state for potential restoration after update.
|
|
*
|
|
* @param {string} componentId - Component identifier
|
|
* @param {HTMLElement} container - Component container element
|
|
* @returns {FocusState} Focus state object
|
|
*/
|
|
captureFocusState(componentId, container) {
|
|
const activeElement = document.activeElement;
|
|
|
|
// Check if focus is within container
|
|
if (!activeElement || !container.contains(activeElement)) {
|
|
return null;
|
|
}
|
|
|
|
const focusState = {
|
|
selector: this.getElementSelector(activeElement, container),
|
|
tagName: activeElement.tagName,
|
|
name: activeElement.name || null,
|
|
id: activeElement.id || null,
|
|
selectionStart: activeElement.selectionStart || null,
|
|
selectionEnd: activeElement.selectionEnd || null,
|
|
scrollTop: activeElement.scrollTop || 0,
|
|
scrollLeft: activeElement.scrollLeft || 0,
|
|
keepFocus: activeElement.hasAttribute('data-lc-keep-focus')
|
|
};
|
|
|
|
this.focusStates.set(componentId, focusState);
|
|
|
|
console.log(`[AccessibilityManager] Captured focus state for ${componentId}`, focusState);
|
|
|
|
return focusState;
|
|
}
|
|
|
|
/**
|
|
* Restore focus after update
|
|
*
|
|
* Restores focus to element marked with data-lc-keep-focus or previously focused element.
|
|
*
|
|
* @param {string} componentId - Component identifier
|
|
* @param {HTMLElement} container - Component container element
|
|
* @returns {boolean} True if focus was restored
|
|
*/
|
|
restoreFocus(componentId, container) {
|
|
const focusState = this.focusStates.get(componentId);
|
|
|
|
if (!focusState) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Find element to focus
|
|
let elementToFocus = null;
|
|
|
|
// Priority 1: Element with data-lc-keep-focus attribute
|
|
if (focusState.keepFocus) {
|
|
elementToFocus = container.querySelector('[data-lc-keep-focus]');
|
|
}
|
|
|
|
// Priority 2: Element matching saved selector
|
|
if (!elementToFocus && focusState.selector) {
|
|
elementToFocus = container.querySelector(focusState.selector);
|
|
}
|
|
|
|
// Priority 3: Element with same ID
|
|
if (!elementToFocus && focusState.id) {
|
|
elementToFocus = container.querySelector(`#${focusState.id}`);
|
|
}
|
|
|
|
// Priority 4: Element with same name
|
|
if (!elementToFocus && focusState.name) {
|
|
elementToFocus = container.querySelector(`[name="${focusState.name}"]`);
|
|
}
|
|
|
|
if (elementToFocus && elementToFocus.focus) {
|
|
elementToFocus.focus();
|
|
|
|
// Restore selection for inputs/textareas
|
|
if (elementToFocus.setSelectionRange &&
|
|
focusState.selectionStart !== null &&
|
|
focusState.selectionEnd !== null) {
|
|
elementToFocus.setSelectionRange(
|
|
focusState.selectionStart,
|
|
focusState.selectionEnd
|
|
);
|
|
}
|
|
|
|
// Restore scroll position
|
|
if (focusState.scrollTop > 0) {
|
|
elementToFocus.scrollTop = focusState.scrollTop;
|
|
}
|
|
if (focusState.scrollLeft > 0) {
|
|
elementToFocus.scrollLeft = focusState.scrollLeft;
|
|
}
|
|
|
|
console.log(`[AccessibilityManager] Restored focus to ${focusState.selector}`);
|
|
|
|
return true;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.debug('[AccessibilityManager] Could not restore focus:', error);
|
|
} finally {
|
|
// Clean up focus state
|
|
this.focusStates.delete(componentId);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get CSS selector for an element within a container
|
|
*
|
|
* @param {HTMLElement} element - Element to get selector for
|
|
* @param {HTMLElement} container - Container element
|
|
* @returns {string|null} CSS selector or null
|
|
*/
|
|
getElementSelector(element, container) {
|
|
// Try ID (most specific)
|
|
if (element.id) {
|
|
return `#${element.id}`;
|
|
}
|
|
|
|
// Try name attribute
|
|
if (element.name) {
|
|
return `[name="${element.name}"]`;
|
|
}
|
|
|
|
// Try data-lc-key
|
|
const lcKey = element.getAttribute('data-lc-key');
|
|
if (lcKey) {
|
|
return `[data-lc-key="${lcKey}"]`;
|
|
}
|
|
|
|
// Try to build a path from container
|
|
const path = [];
|
|
let current = element;
|
|
|
|
while (current && current !== container && current !== document.body) {
|
|
let selector = current.tagName.toLowerCase();
|
|
|
|
// Add class if available
|
|
if (current.className && typeof current.className === 'string') {
|
|
const classes = current.className.split(' ').filter(c => c.trim());
|
|
if (classes.length > 0) {
|
|
selector += '.' + classes.join('.');
|
|
}
|
|
}
|
|
|
|
// Add nth-child if needed for specificity
|
|
if (current.parentElement) {
|
|
const siblings = Array.from(current.parentElement.children);
|
|
const index = siblings.indexOf(current) + 1;
|
|
if (siblings.length > 1) {
|
|
selector += `:nth-child(${index})`;
|
|
}
|
|
}
|
|
|
|
path.unshift(selector);
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return path.length > 0 ? path.join(' > ') : null;
|
|
}
|
|
|
|
/**
|
|
* Announce component update
|
|
*
|
|
* Convenience method for announcing component updates.
|
|
*
|
|
* @param {string} componentId - Component identifier
|
|
* @param {string} updateType - Type of update (fragment|full|action)
|
|
* @param {Object} metadata - Additional metadata
|
|
*/
|
|
announceUpdate(componentId, updateType, metadata = {}) {
|
|
let message = '';
|
|
|
|
switch (updateType) {
|
|
case 'fragment':
|
|
message = `Updated ${metadata.fragmentName || 'content'}`;
|
|
break;
|
|
case 'full':
|
|
message = 'Component updated';
|
|
break;
|
|
case 'action':
|
|
message = metadata.actionMessage || 'Action completed';
|
|
break;
|
|
default:
|
|
message = 'Content updated';
|
|
}
|
|
|
|
this.announce(message, 'polite', componentId);
|
|
}
|
|
|
|
/**
|
|
* Check if element should preserve keyboard navigation
|
|
*
|
|
* @param {HTMLElement} element - Element to check
|
|
* @returns {boolean} True if keyboard navigation should be preserved
|
|
*/
|
|
shouldPreserveKeyboardNav(element) {
|
|
// Interactive elements that should always preserve keyboard nav
|
|
const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'];
|
|
|
|
if (interactiveTags.includes(element.tagName)) {
|
|
return true;
|
|
}
|
|
|
|
// Elements with tabindex
|
|
if (element.hasAttribute('tabindex')) {
|
|
return true;
|
|
}
|
|
|
|
// Elements with role
|
|
const interactiveRoles = [
|
|
'button', 'link', 'textbox', 'searchbox',
|
|
'combobox', 'listbox', 'option', 'tab'
|
|
];
|
|
|
|
const role = element.getAttribute('role');
|
|
if (role && interactiveRoles.includes(role)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Cleanup component accessibility features
|
|
*
|
|
* @param {string} componentId - Component identifier
|
|
*/
|
|
cleanup(componentId) {
|
|
// Remove component live region
|
|
const liveRegion = this.componentLiveRegions.get(componentId);
|
|
if (liveRegion && liveRegion.parentElement) {
|
|
liveRegion.parentElement.removeChild(liveRegion);
|
|
}
|
|
this.componentLiveRegions.delete(componentId);
|
|
|
|
// Clear focus state
|
|
this.focusStates.delete(componentId);
|
|
|
|
console.log(`[AccessibilityManager] Cleaned up accessibility for ${componentId}`);
|
|
}
|
|
|
|
/**
|
|
* Get accessibility stats
|
|
*
|
|
* @returns {Object} Statistics about accessibility manager state
|
|
*/
|
|
getStats() {
|
|
return {
|
|
has_global_live_region: this.liveRegion !== null,
|
|
component_live_regions: this.componentLiveRegions.size,
|
|
tracked_focus_states: this.focusStates.size,
|
|
pending_announcements: this.announcementQueue.length
|
|
};
|
|
}
|
|
}
|
|
|
|
// Create singleton instance
|
|
export const accessibilityManager = new AccessibilityManager();
|
|
|
|
export default accessibilityManager;
|