Complete 4.5.2: Screen Reader Support - Implement comprehensive ARIA live regions, attributes, and dynamic announcements
This commit is contained in:
parent
4a9941aa9a
commit
8d7b7dfe80
4 changed files with 382 additions and 2 deletions
|
|
@ -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
340
assets/js/screen-reader.js
Normal 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');
|
||||
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
10
index.php
10
index.php
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue