| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- /**
- * @fileoverview Live Region Manager for GrabZilla 2.1
- * Manages ARIA live regions for screen reader announcements
- * @author GrabZilla Development Team
- * @version 2.1.0
- * @since 2024-01-01
- */
- /**
- * LIVE REGION MANAGER
- *
- * Manages ARIA live regions for dynamic content announcements
- *
- * Features:
- * - Multiple live regions with different politeness levels
- * - Announcement queuing and throttling
- * - Context-aware announcements
- * - Progress update announcements
- *
- * Dependencies:
- * - None (vanilla JavaScript)
- *
- * State Management:
- * - Tracks announcement queue
- * - Manages announcement timing
- * - Handles region cleanup
- */
- class LiveRegionManager {
- constructor() {
- this.regions = new Map();
- this.announcementQueue = [];
- this.isProcessingQueue = false;
- this.lastAnnouncement = '';
- this.lastAnnouncementTime = 0;
- this.throttleDelay = 1000; // 1 second between similar announcements
-
- this.init();
- }
- /**
- * Initialize live regions
- */
- init() {
- this.createLiveRegions();
- this.setupProgressAnnouncements();
- this.setupStatusMonitoring();
-
- console.log('LiveRegionManager initialized');
- }
- /**
- * Create different types of live regions
- */
- createLiveRegions() {
- // Assertive region for important announcements
- this.createRegion('assertive', {
- 'aria-live': 'assertive',
- 'aria-atomic': 'true',
- 'aria-relevant': 'additions text'
- });
- // Polite region for status updates
- this.createRegion('polite', {
- 'aria-live': 'polite',
- 'aria-atomic': 'false',
- 'aria-relevant': 'additions text'
- });
- // Status region for progress updates
- this.createRegion('status', {
- 'aria-live': 'polite',
- 'aria-atomic': 'true',
- 'aria-relevant': 'text',
- 'role': 'status'
- });
- // Log region for activity logs
- this.createRegion('log', {
- 'aria-live': 'polite',
- 'aria-atomic': 'false',
- 'aria-relevant': 'additions',
- 'role': 'log'
- });
- }
- /**
- * Create a live region with specified attributes
- */
- createRegion(name, attributes) {
- const region = document.createElement('div');
- region.id = `live-region-${name}`;
- region.className = 'sr-only';
-
- // Set ARIA attributes
- Object.entries(attributes).forEach(([key, value]) => {
- region.setAttribute(key, value);
- });
-
- document.body.appendChild(region);
- this.regions.set(name, region);
-
- return region;
- }
- /**
- * Setup progress announcement monitoring
- */
- setupProgressAnnouncements() {
- // Monitor progress changes in status badges
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'childList' || mutation.type === 'characterData') {
- const target = mutation.target;
-
- if (target.classList?.contains('status-badge') ||
- target.parentElement?.classList?.contains('status-badge')) {
- this.handleStatusChange(target);
- }
- }
- });
- });
- // Observe existing status badges
- document.querySelectorAll('.status-badge').forEach(badge => {
- observer.observe(badge, {
- childList: true,
- characterData: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['data-progress']
- });
- });
- // Monitor for new status badges
- const listObserver = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- mutation.addedNodes.forEach((node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- const newBadges = node.querySelectorAll('.status-badge');
- newBadges.forEach(badge => {
- observer.observe(badge, {
- childList: true,
- characterData: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['data-progress']
- });
- });
- }
- });
- });
- });
- const videoList = document.getElementById('videoList');
- if (videoList) {
- listObserver.observe(videoList, { childList: true, subtree: true });
- }
- }
- /**
- * Setup general status monitoring
- */
- setupStatusMonitoring() {
- // Monitor status message changes
- const statusMessage = document.getElementById('statusMessage');
- if (statusMessage) {
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.type === 'childList' || mutation.type === 'characterData') {
- const newText = statusMessage.textContent.trim();
- if (newText && newText !== this.lastStatusMessage) {
- this.announceStatus(newText);
- this.lastStatusMessage = newText;
- }
- }
- });
- });
- observer.observe(statusMessage, {
- childList: true,
- characterData: true,
- subtree: true
- });
- }
- }
- /**
- * Handle status badge changes
- */
- handleStatusChange(statusElement) {
- const statusText = statusElement.textContent || statusElement.innerText;
- if (!statusText) return;
- // Get video context
- const videoItem = statusElement.closest('.video-item');
- let videoTitle = 'Video';
-
- if (videoItem) {
- const titleElement = videoItem.querySelector('.text-sm.text-white.truncate');
- if (titleElement) {
- videoTitle = titleElement.textContent.trim();
- // Truncate long titles for announcements
- if (videoTitle.length > 50) {
- videoTitle = videoTitle.substring(0, 47) + '...';
- }
- }
- }
- // Determine announcement type based on status
- const statusLower = statusText.toLowerCase();
- let announcementType = 'status';
-
- if (statusLower.includes('error') || statusLower.includes('failed')) {
- announcementType = 'assertive';
- } else if (statusLower.includes('completed') || statusLower.includes('finished')) {
- announcementType = 'assertive';
- }
- const announcement = `${videoTitle}: ${statusText}`;
- this.announce(announcement, announcementType);
- } /*
- *
- * Make an announcement to screen readers
- */
- announce(message, regionType = 'polite', options = {}) {
- if (!message || typeof message !== 'string') return;
- const cleanMessage = message.trim();
- if (!cleanMessage) return;
- // Check for duplicate announcements
- if (this.shouldThrottleAnnouncement(cleanMessage)) {
- return;
- }
- const announcement = {
- message: cleanMessage,
- regionType,
- timestamp: Date.now(),
- priority: options.priority || 0,
- context: options.context || null
- };
- this.queueAnnouncement(announcement);
- }
- /**
- * Check if announcement should be throttled
- */
- shouldThrottleAnnouncement(message) {
- const now = Date.now();
-
- // Don't throttle if it's been long enough
- if (now - this.lastAnnouncementTime > this.throttleDelay) {
- return false;
- }
- // Don't throttle if message is different
- if (message !== this.lastAnnouncement) {
- return false;
- }
- return true;
- }
- /**
- * Queue announcement for processing
- */
- queueAnnouncement(announcement) {
- // Insert based on priority
- let insertIndex = this.announcementQueue.length;
- for (let i = 0; i < this.announcementQueue.length; i++) {
- if (this.announcementQueue[i].priority < announcement.priority) {
- insertIndex = i;
- break;
- }
- }
-
- this.announcementQueue.splice(insertIndex, 0, announcement);
-
- if (!this.isProcessingQueue) {
- this.processAnnouncementQueue();
- }
- }
- /**
- * Process queued announcements
- */
- async processAnnouncementQueue() {
- if (this.isProcessingQueue || this.announcementQueue.length === 0) {
- return;
- }
- this.isProcessingQueue = true;
- while (this.announcementQueue.length > 0) {
- const announcement = this.announcementQueue.shift();
- await this.makeAnnouncement(announcement);
-
- // Small delay between announcements
- await this.delay(100);
- }
- this.isProcessingQueue = false;
- }
- /**
- * Make the actual announcement
- */
- async makeAnnouncement(announcement) {
- const region = this.regions.get(announcement.regionType);
- if (!region) {
- console.warn(`Live region '${announcement.regionType}' not found`);
- return;
- }
- // Clear region first for assertive announcements
- if (announcement.regionType === 'assertive') {
- region.textContent = '';
- await this.delay(50);
- }
- // Set the announcement
- region.textContent = announcement.message;
-
- // Update tracking
- this.lastAnnouncement = announcement.message;
- this.lastAnnouncementTime = announcement.timestamp;
- // Log announcement for debugging
- console.log(`[LiveRegion:${announcement.regionType}] ${announcement.message}`);
- }
- /**
- * Utility delay function
- */
- delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- /**
- * Announce status message
- */
- announceStatus(message) {
- this.announce(message, 'status', { priority: 1 });
- }
- /**
- * Announce progress update
- */
- announceProgress(videoTitle, status, progress) {
- let message;
-
- if (progress !== undefined && progress !== null) {
- message = `${videoTitle}: ${status} ${progress}%`;
- } else {
- message = `${videoTitle}: ${status}`;
- }
-
- this.announce(message, 'status', {
- priority: 2,
- context: 'progress'
- });
- }
- /**
- * Announce video list changes
- */
- announceVideoListChange(action, count, videoTitle = '') {
- let message = '';
- let priority = 1;
-
- switch (action) {
- case 'added':
- message = videoTitle ?
- `Added ${videoTitle} to download queue` :
- `Added ${count} video${count !== 1 ? 's' : ''} to download queue`;
- break;
- case 'removed':
- message = videoTitle ?
- `Removed ${videoTitle} from download queue` :
- `Removed ${count} video${count !== 1 ? 's' : ''} from download queue`;
- break;
- case 'cleared':
- message = 'Download queue cleared';
- priority = 2;
- break;
- case 'reordered':
- message = `Video queue reordered`;
- break;
- }
-
- if (message) {
- this.announce(message, 'polite', { priority });
- }
- }
- /**
- * Announce error messages
- */
- announceError(message, context = '') {
- const fullMessage = context ? `${context}: ${message}` : message;
- this.announce(fullMessage, 'assertive', { priority: 3 });
- }
- /**
- * Announce success messages
- */
- announceSuccess(message) {
- this.announce(message, 'assertive', { priority: 2 });
- }
- /**
- * Announce keyboard shortcuts
- */
- announceShortcuts(shortcuts) {
- const shortcutText = shortcuts.map(s => `${s.key}: ${s.description}`).join(', ');
- this.announce(`Available shortcuts: ${shortcutText}`, 'polite');
- }
- /**
- * Clear all regions
- */
- clearAllRegions() {
- this.regions.forEach(region => {
- region.textContent = '';
- });
-
- // Clear queue
- this.announcementQueue = [];
- this.isProcessingQueue = false;
- }
- /**
- * Get region by name
- */
- getRegion(name) {
- return this.regions.get(name);
- }
- /**
- * Remove a region
- */
- removeRegion(name) {
- const region = this.regions.get(name);
- if (region && region.parentNode) {
- region.parentNode.removeChild(region);
- this.regions.delete(name);
- }
- }
- /**
- * Get live region manager instance (singleton)
- */
- static getInstance() {
- if (!LiveRegionManager.instance) {
- LiveRegionManager.instance = new LiveRegionManager();
- }
- return LiveRegionManager.instance;
- }
- }
- // Export for use in other modules
- window.LiveRegionManager = LiveRegionManager;
|