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

View file

@ -4,7 +4,7 @@
(function() {
'use strict';
AppModules.require('api', 'ui-controls');
AppModules.require('api', 'ui-controls', 'screen-reader');
AppModules.register('chat');
// Initialize chat system
@ -21,6 +21,9 @@
UIControls.showToast('Admin mode activated');
}
// Announce chat initialization to screen readers
ScreenReader.connectionStatus('connected');
// Set up nickname handling
const nicknameInput = document.getElementById('nickname');
if (nicknameInput) {
@ -163,10 +166,20 @@
const nicknameValidation = validateNickname(nickname);
updateValidationUI('nickname', nicknameErrorElement, nicknameValidation.valid, nicknameValidation.message);
// Announce validation errors
if (!nicknameValidation.valid) {
ScreenReader.formValidation('nickname', false, nicknameValidation.message);
}
// Validate message
const messageValidation = validateMessage(message);
updateValidationUI('messageInput', messageErrorElement, messageValidation.valid, messageValidation.message);
// Announce validation errors
if (!messageValidation.valid) {
ScreenReader.formValidation('messageInput', false, messageValidation.message);
}
// If either validation failed, focus the appropriate field and stop
if (!nicknameValidation.valid) {
nicknameInput.focus();
@ -193,6 +206,9 @@
updateValidationUI('nickname', nicknameErrorElement, true);
updateValidationUI('messageInput', messageErrorElement, true);
// Announce successful message send
ScreenReader.formSuccess('Message sent successfully');
UIControls.showToast('Message sent successfully!');
// Clear success state after a delay
@ -206,12 +222,14 @@
// Show server error
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message: ' + data.error);
UIControls.updateConnectionStatus(false);
ScreenReader.connectionStatus('error', data.error);
}
})
.catch(error => {
AppLogger.error('Error sending message:', error);
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.');
UIControls.updateConnectionStatus(false);
ScreenReader.connectionStatus('error', 'Network error while sending message');
})
.finally(() => {
// End loading state
@ -232,6 +250,7 @@
if (data.success) {
removeMessageFromUI(messageId);
UIControls.showToast('Message deleted');
ScreenReader.systemMessage('Message deleted by admin', 'normal');
} else {
UIControls.showToast('Failed to delete message');
}
@ -252,6 +271,7 @@
.then(data => {
if (data.success) {
UIControls.showToast('User banned');
ScreenReader.systemMessage('User banned from chat by admin', 'normal');
} else {
UIControls.showToast('Failed to ban user');
}
@ -273,6 +293,7 @@
if (data.success) {
clearMessagesUI();
UIControls.showToast('Chat cleared');
ScreenReader.systemMessage('Chat was cleared by admin', 'high');
AppState.lastMessageId = '';
API.sendHeartbeat(); // Force refresh all users
} else {
@ -333,6 +354,12 @@
// Handle initial message load
function handleInitialMessageLoad(data) {
AppState.allMessages = data.all_messages || [];
// Announce message group if loading multiple messages
if (AppState.allMessages.length > 0) {
ScreenReader.messageGroup(AppState.allMessages, 'added');
}
displayAllMessages();
}

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');
})();

View file

@ -4,7 +4,7 @@
(function() {
'use strict';
AppModules.require('api');
AppModules.require('api', 'screen-reader');
AppModules.register('ui-controls');
// Toast notification system
@ -288,6 +288,9 @@
if (viewerElement) {
viewerElement.textContent = count + (count === 1 ? ' viewer' : ' viewers');
}
// Announce viewer count changes to screen readers
ScreenReader.viewerCount(count);
}
// Connection status dot

View file

@ -516,6 +516,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
<!-- Legacy Toast Notification (deprecated) -->
<div class="toast" id="toast" style="display: none;"></div>
<!-- Screen Reader Announcement Regions -->
<div id="sr-stream-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Stream status announcements"></div>
<div id="sr-connection-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Connection status announcements"></div>
<div id="sr-system-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="System announcements"></div>
<div id="sr-message-group-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Message group announcements"></div>
<div id="sr-viewer-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Viewer count announcements"></div>
<div id="sr-activity-announcer" class="sr-only" aria-live="polite" aria-atomic="true" aria-label="Activity announcements"></div>
<div id="sr-form-announcer" class="sr-only" aria-live="assertive" aria-atomic="true" aria-label="Form validation announcements"></div>
<!-- Mobile Bottom Navigation -->
<nav class="mobile-nav" id="mobileNav" role="navigation" aria-label="Mobile navigation">
<button class="mobile-nav-btn" data-action="toggle-chat" aria-label="Toggle chat" aria-expanded="false">
@ -548,6 +557,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
<script src="https://vjs.zencdn.net/8.6.1/video.min.js"></script>
<script defer src="assets/js/app.js?v=1.4.4"></script>
<script defer src="assets/js/api.js?v=1.4.4"></script>
<script defer src="assets/js/screen-reader.js?v=1.4.4"></script>
<script defer src="assets/js/ui-controls.js?v=1.4.4"></script>
<script defer src="assets/js/chat.js?v=1.4.4"></script>
<script defer src="assets/js/video-player.js?v=1.4.4"></script>