Complete 4.5.2: Screen Reader Support - Implement comprehensive ARIA live regions, attributes, and dynamic announcements

This commit is contained in:
VinnyNC 2025-09-30 19:08:23 -04:00
parent 4a9941aa9a
commit 8d7b7dfe80
4 changed files with 382 additions and 2 deletions

340
assets/js/screen-reader.js Normal file
View file

@ -0,0 +1,340 @@
// Screen Reader Announcements Module
// Handles ARIA live region announcements for accessibility
(function() {
'use strict';
AppModules.require('ui-controls');
AppModules.register('screen-reader');
// Announcement regions
let regions = {};
// State tracking to avoid announcement spam
let lastViewerCount = 0;
let lastStreamStatus = 'unknown';
let lastConnectionStatus = 'unknown';
// Announcement queue to prevent rapid-fire announcements
let announcementQueue = [];
let isProcessingQueue = false;
// Configuration
const ANNOUNCEMENT_THRESHOLDS = {
viewerCount: 5, // Only announce when count changes by 5+ users
messageGroup: 3, // Minimum messages for group announcements
activityDelay: 500 // Delay for activity announcements to batch them
};
// Initialize announcement regions
function initializeAnnouncements() {
// Get all announcement region elements
regions = {
stream: document.getElementById('sr-stream-announcer'),
connection: document.getElementById('sr-connection-announcer'),
system: document.getElementById('sr-system-announcer'),
messageGroup: document.getElementById('sr-message-group-announcer'),
viewer: document.getElementById('sr-viewer-announcer'),
activity: document.getElementById('sr-activity-announcer'),
form: document.getElementById('sr-form-announcer')
};
// Verify all regions exist
const missingRegions = Object.entries(regions).filter(([key, element]) => !element);
if (missingRegions.length > 0) {
AppLogger.error('Missing screen reader announcement regions:', missingRegions.map(([key]) => key));
} else {
AppLogger.log('Screen reader announcement regions initialized');
}
}
// Queue announcement to prevent spam
function queueAnnouncement(region, text, options = {}) {
if (!text || !text.trim()) return;
announcementQueue.push({
region,
text: text.trim(),
options: {
priority: options.priority || 'normal', // 'low', 'normal', 'high'
delay: options.delay || 0,
...options
}
});
// Start processing queue if not already running
if (!isProcessingQueue) {
processAnnouncementQueue();
}
}
// Process the announcement queue
async function processAnnouncementQueue() {
if (isProcessingQueue || announcementQueue.length === 0) return;
isProcessingQueue = true;
while (announcementQueue.length > 0) {
const announcement = announcementQueue.shift();
try {
await announce(announcement.region, announcement.text, announcement.options);
} catch (error) {
AppLogger.error('Error processing announcement:', error);
}
}
isProcessingQueue = false;
}
// Actually announce to a region
async function announce(regionName, text, options = {}) {
const regionElement = regions[regionName];
if (!regionElement) {
AppLogger.error(`Unknown announcement region: ${regionName}`);
return;
}
// Apply delay if specified
if (options.delay > 0) {
await new Promise(resolve => setTimeout(resolve, options.delay));
}
// Clear region first for assertive updates
regionElement.textContent = '';
// Small delay to ensure clear is processed
await new Promise(resolve => setTimeout(resolve, 10));
// Set the announcement text
regionElement.textContent = text;
// Log for debugging
AppLogger.log(`Screen reader announcement (${regionName}): ${text}`);
}
// Public announcement functions
const announcements = {
// Stream status changes
streamStatus(status, details = '') {
if (status === lastStreamStatus) return; // Avoid duplicate announcements
lastStreamStatus = status;
let message = '';
switch (status) {
case 'online':
message = 'Stream is now online and available';
break;
case 'offline':
message = 'Stream is currently offline';
break;
case 'reconnecting':
message = 'Attempting to reconnect to stream';
break;
case 'quality-changed':
message = `Stream quality changed to ${details}`;
break;
case 'buffering':
message = 'Stream is buffering';
break;
}
if (message) {
queueAnnouncement('stream', message, { priority: 'high' });
}
},
// Connection status changes
connectionStatus(status, details = '') {
if (status === lastConnectionStatus) return;
lastConnectionStatus = status;
let message = '';
switch (status) {
case 'connected':
message = 'Chat connection established';
break;
case 'disconnected':
message = 'Chat connection lost - attempting to reconnect';
break;
case 'reconnecting':
message = 'Reconnecting to chat';
break;
case 'error':
message = `Connection error: ${details}`;
break;
}
if (message) {
queueAnnouncement('connection', message, { priority: 'high' });
}
},
// Viewer count changes (with threshold)
viewerCount(count) {
const oldCount = lastViewerCount;
const diff = Math.abs(count - oldCount);
// Only announce if significant change (threshold) or crossing boundaries
if (diff >= ANNOUNCEMENT_THRESHOLDS.viewerCount ||
(oldCount === 0 && count > 0) ||
(oldCount > 0 && count === 0)) {
lastViewerCount = count;
let message;
if (count === 0) {
message = 'No viewers currently online';
} else if (count === 1) {
message = '1 viewer online';
} else {
message = `${count} viewers online`;
}
queueAnnouncement('viewer', message, { priority: 'low', delay: 500 });
}
},
// System announcements (admin actions, etc.)
systemMessage(message, priority = 'normal') {
queueAnnouncement('system', message, {
priority: priority,
delay: priority === 'high' ? 0 : 200
});
},
// Message group announcements
messageGroup(messages, action = 'added') {
if (!Array.isArray(messages) || messages.length === 0) return;
const count = messages.length;
let message = '';
if (count >= ANNOUNCEMENT_THRESHOLDS.messageGroup) {
if (action === 'added') {
message = `${count} new messages loaded`;
} else if (action === 'filtered') {
message = `Showing ${count} messages matching filter`;
}
}
if (message) {
queueAnnouncement('messageGroup', message, {
priority: 'low',
delay: 300
});
}
},
// Individual message arrival (for existing messages)
messageReceived(message, isUserMessage = false) {
// Don't announce user's own messages to avoid echo feedback
if (isUserMessage) return;
// For individual messages, let the aria-live="polite" on chat handle it
// Only use this for special announcements
},
// Activity announcements
activity(userAction, details = '') {
let message = '';
switch (userAction) {
case 'joined':
message = `User ${details} joined the chat`;
break;
case 'left':
message = `User ${details} left the chat`;
break;
case 'admin-action':
message = details; // Admin actions passed through
break;
default:
if (details) {
message = details;
}
}
if (message) {
queueAnnouncement('activity', message, {
priority: 'low',
delay: ANNOUNCEMENT_THRESHOLDS.activityDelay
});
}
},
// Form validation messages
formValidation(field, isValid, message) {
if (!message) return;
queueAnnouncement('form', message, {
priority: isValid ? 'low' : 'high',
delay: 100
});
},
// Form success messages
formSuccess(message) {
queueAnnouncement('form', message, {
priority: 'normal',
delay: 100
});
},
// Generic high-priority announcements (errors, critical state changes)
critical(message) {
queueAnnouncement('system', message, { priority: 'high' });
},
// Status updates (dashboard stats changes)
statusUpdate(type, value, unit = '') {
let message = '';
switch (type) {
case 'uptime':
message = `Stream uptime: ${value}`;
break;
case 'quality':
message = `Stream quality: ${value}`;
break;
case 'buffer':
const bufferPercent = Math.round(value * 100);
message = `Buffer ${bufferPercent}%`;
break;
default:
if (unit) {
message = `${type}: ${value} ${unit}`;
} else {
message = `${type}: ${value}`;
}
}
if (message) {
// Use activity region for status updates
queueAnnouncement('activity', message, {
priority: 'low',
delay: 500
});
}
}
};
// Public API
window.ScreenReader = {
...announcements,
initialize: initializeAnnouncements,
queueAnnouncement: queueAnnouncement,
announce: announce // Direct announce function for special cases
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Small delay to ensure DOM is fully ready
setTimeout(initializeAnnouncements, 100);
});
AppLogger.log('Screen reader announcements module loaded');
})();