live-region-manager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. /**
  2. * @fileoverview Live Region Manager for GrabZilla 2.1
  3. * Manages ARIA live regions for screen reader announcements
  4. * @author GrabZilla Development Team
  5. * @version 2.1.0
  6. * @since 2024-01-01
  7. */
  8. /**
  9. * LIVE REGION MANAGER
  10. *
  11. * Manages ARIA live regions for dynamic content announcements
  12. *
  13. * Features:
  14. * - Multiple live regions with different politeness levels
  15. * - Announcement queuing and throttling
  16. * - Context-aware announcements
  17. * - Progress update announcements
  18. *
  19. * Dependencies:
  20. * - None (vanilla JavaScript)
  21. *
  22. * State Management:
  23. * - Tracks announcement queue
  24. * - Manages announcement timing
  25. * - Handles region cleanup
  26. */
  27. class LiveRegionManager {
  28. constructor() {
  29. this.regions = new Map();
  30. this.announcementQueue = [];
  31. this.isProcessingQueue = false;
  32. this.lastAnnouncement = '';
  33. this.lastAnnouncementTime = 0;
  34. this.throttleDelay = 1000; // 1 second between similar announcements
  35. this.init();
  36. }
  37. /**
  38. * Initialize live regions
  39. */
  40. init() {
  41. this.createLiveRegions();
  42. this.setupProgressAnnouncements();
  43. this.setupStatusMonitoring();
  44. console.log('LiveRegionManager initialized');
  45. }
  46. /**
  47. * Create different types of live regions
  48. */
  49. createLiveRegions() {
  50. // Assertive region for important announcements
  51. this.createRegion('assertive', {
  52. 'aria-live': 'assertive',
  53. 'aria-atomic': 'true',
  54. 'aria-relevant': 'additions text'
  55. });
  56. // Polite region for status updates
  57. this.createRegion('polite', {
  58. 'aria-live': 'polite',
  59. 'aria-atomic': 'false',
  60. 'aria-relevant': 'additions text'
  61. });
  62. // Status region for progress updates
  63. this.createRegion('status', {
  64. 'aria-live': 'polite',
  65. 'aria-atomic': 'true',
  66. 'aria-relevant': 'text',
  67. 'role': 'status'
  68. });
  69. // Log region for activity logs
  70. this.createRegion('log', {
  71. 'aria-live': 'polite',
  72. 'aria-atomic': 'false',
  73. 'aria-relevant': 'additions',
  74. 'role': 'log'
  75. });
  76. }
  77. /**
  78. * Create a live region with specified attributes
  79. */
  80. createRegion(name, attributes) {
  81. const region = document.createElement('div');
  82. region.id = `live-region-${name}`;
  83. region.className = 'sr-only';
  84. // Set ARIA attributes
  85. Object.entries(attributes).forEach(([key, value]) => {
  86. region.setAttribute(key, value);
  87. });
  88. document.body.appendChild(region);
  89. this.regions.set(name, region);
  90. return region;
  91. }
  92. /**
  93. * Setup progress announcement monitoring
  94. */
  95. setupProgressAnnouncements() {
  96. // Monitor progress changes in status badges
  97. const observer = new MutationObserver((mutations) => {
  98. mutations.forEach((mutation) => {
  99. if (mutation.type === 'childList' || mutation.type === 'characterData') {
  100. const target = mutation.target;
  101. if (target.classList?.contains('status-badge') ||
  102. target.parentElement?.classList?.contains('status-badge')) {
  103. this.handleStatusChange(target);
  104. }
  105. }
  106. });
  107. });
  108. // Observe existing status badges
  109. document.querySelectorAll('.status-badge').forEach(badge => {
  110. observer.observe(badge, {
  111. childList: true,
  112. characterData: true,
  113. subtree: true,
  114. attributes: true,
  115. attributeFilter: ['data-progress']
  116. });
  117. });
  118. // Monitor for new status badges
  119. const listObserver = new MutationObserver((mutations) => {
  120. mutations.forEach((mutation) => {
  121. mutation.addedNodes.forEach((node) => {
  122. if (node.nodeType === Node.ELEMENT_NODE) {
  123. const newBadges = node.querySelectorAll('.status-badge');
  124. newBadges.forEach(badge => {
  125. observer.observe(badge, {
  126. childList: true,
  127. characterData: true,
  128. subtree: true,
  129. attributes: true,
  130. attributeFilter: ['data-progress']
  131. });
  132. });
  133. }
  134. });
  135. });
  136. });
  137. const videoList = document.getElementById('videoList');
  138. if (videoList) {
  139. listObserver.observe(videoList, { childList: true, subtree: true });
  140. }
  141. }
  142. /**
  143. * Setup general status monitoring
  144. */
  145. setupStatusMonitoring() {
  146. // Monitor status message changes
  147. const statusMessage = document.getElementById('statusMessage');
  148. if (statusMessage) {
  149. const observer = new MutationObserver((mutations) => {
  150. mutations.forEach((mutation) => {
  151. if (mutation.type === 'childList' || mutation.type === 'characterData') {
  152. const newText = statusMessage.textContent.trim();
  153. if (newText && newText !== this.lastStatusMessage) {
  154. this.announceStatus(newText);
  155. this.lastStatusMessage = newText;
  156. }
  157. }
  158. });
  159. });
  160. observer.observe(statusMessage, {
  161. childList: true,
  162. characterData: true,
  163. subtree: true
  164. });
  165. }
  166. }
  167. /**
  168. * Handle status badge changes
  169. */
  170. handleStatusChange(statusElement) {
  171. const statusText = statusElement.textContent || statusElement.innerText;
  172. if (!statusText) return;
  173. // Get video context
  174. const videoItem = statusElement.closest('.video-item');
  175. let videoTitle = 'Video';
  176. if (videoItem) {
  177. const titleElement = videoItem.querySelector('.text-sm.text-white.truncate');
  178. if (titleElement) {
  179. videoTitle = titleElement.textContent.trim();
  180. // Truncate long titles for announcements
  181. if (videoTitle.length > 50) {
  182. videoTitle = videoTitle.substring(0, 47) + '...';
  183. }
  184. }
  185. }
  186. // Determine announcement type based on status
  187. const statusLower = statusText.toLowerCase();
  188. let announcementType = 'status';
  189. if (statusLower.includes('error') || statusLower.includes('failed')) {
  190. announcementType = 'assertive';
  191. } else if (statusLower.includes('completed') || statusLower.includes('finished')) {
  192. announcementType = 'assertive';
  193. }
  194. const announcement = `${videoTitle}: ${statusText}`;
  195. this.announce(announcement, announcementType);
  196. } /*
  197. *
  198. * Make an announcement to screen readers
  199. */
  200. announce(message, regionType = 'polite', options = {}) {
  201. if (!message || typeof message !== 'string') return;
  202. const cleanMessage = message.trim();
  203. if (!cleanMessage) return;
  204. // Check for duplicate announcements
  205. if (this.shouldThrottleAnnouncement(cleanMessage)) {
  206. return;
  207. }
  208. const announcement = {
  209. message: cleanMessage,
  210. regionType,
  211. timestamp: Date.now(),
  212. priority: options.priority || 0,
  213. context: options.context || null
  214. };
  215. this.queueAnnouncement(announcement);
  216. }
  217. /**
  218. * Check if announcement should be throttled
  219. */
  220. shouldThrottleAnnouncement(message) {
  221. const now = Date.now();
  222. // Don't throttle if it's been long enough
  223. if (now - this.lastAnnouncementTime > this.throttleDelay) {
  224. return false;
  225. }
  226. // Don't throttle if message is different
  227. if (message !== this.lastAnnouncement) {
  228. return false;
  229. }
  230. return true;
  231. }
  232. /**
  233. * Queue announcement for processing
  234. */
  235. queueAnnouncement(announcement) {
  236. // Insert based on priority
  237. let insertIndex = this.announcementQueue.length;
  238. for (let i = 0; i < this.announcementQueue.length; i++) {
  239. if (this.announcementQueue[i].priority < announcement.priority) {
  240. insertIndex = i;
  241. break;
  242. }
  243. }
  244. this.announcementQueue.splice(insertIndex, 0, announcement);
  245. if (!this.isProcessingQueue) {
  246. this.processAnnouncementQueue();
  247. }
  248. }
  249. /**
  250. * Process queued announcements
  251. */
  252. async processAnnouncementQueue() {
  253. if (this.isProcessingQueue || this.announcementQueue.length === 0) {
  254. return;
  255. }
  256. this.isProcessingQueue = true;
  257. while (this.announcementQueue.length > 0) {
  258. const announcement = this.announcementQueue.shift();
  259. await this.makeAnnouncement(announcement);
  260. // Small delay between announcements
  261. await this.delay(100);
  262. }
  263. this.isProcessingQueue = false;
  264. }
  265. /**
  266. * Make the actual announcement
  267. */
  268. async makeAnnouncement(announcement) {
  269. const region = this.regions.get(announcement.regionType);
  270. if (!region) {
  271. console.warn(`Live region '${announcement.regionType}' not found`);
  272. return;
  273. }
  274. // Clear region first for assertive announcements
  275. if (announcement.regionType === 'assertive') {
  276. region.textContent = '';
  277. await this.delay(50);
  278. }
  279. // Set the announcement
  280. region.textContent = announcement.message;
  281. // Update tracking
  282. this.lastAnnouncement = announcement.message;
  283. this.lastAnnouncementTime = announcement.timestamp;
  284. // Log announcement for debugging
  285. console.log(`[LiveRegion:${announcement.regionType}] ${announcement.message}`);
  286. }
  287. /**
  288. * Utility delay function
  289. */
  290. delay(ms) {
  291. return new Promise(resolve => setTimeout(resolve, ms));
  292. }
  293. /**
  294. * Announce status message
  295. */
  296. announceStatus(message) {
  297. this.announce(message, 'status', { priority: 1 });
  298. }
  299. /**
  300. * Announce progress update
  301. */
  302. announceProgress(videoTitle, status, progress) {
  303. let message;
  304. if (progress !== undefined && progress !== null) {
  305. message = `${videoTitle}: ${status} ${progress}%`;
  306. } else {
  307. message = `${videoTitle}: ${status}`;
  308. }
  309. this.announce(message, 'status', {
  310. priority: 2,
  311. context: 'progress'
  312. });
  313. }
  314. /**
  315. * Announce video list changes
  316. */
  317. announceVideoListChange(action, count, videoTitle = '') {
  318. let message = '';
  319. let priority = 1;
  320. switch (action) {
  321. case 'added':
  322. message = videoTitle ?
  323. `Added ${videoTitle} to download queue` :
  324. `Added ${count} video${count !== 1 ? 's' : ''} to download queue`;
  325. break;
  326. case 'removed':
  327. message = videoTitle ?
  328. `Removed ${videoTitle} from download queue` :
  329. `Removed ${count} video${count !== 1 ? 's' : ''} from download queue`;
  330. break;
  331. case 'cleared':
  332. message = 'Download queue cleared';
  333. priority = 2;
  334. break;
  335. case 'reordered':
  336. message = `Video queue reordered`;
  337. break;
  338. }
  339. if (message) {
  340. this.announce(message, 'polite', { priority });
  341. }
  342. }
  343. /**
  344. * Announce error messages
  345. */
  346. announceError(message, context = '') {
  347. const fullMessage = context ? `${context}: ${message}` : message;
  348. this.announce(fullMessage, 'assertive', { priority: 3 });
  349. }
  350. /**
  351. * Announce success messages
  352. */
  353. announceSuccess(message) {
  354. this.announce(message, 'assertive', { priority: 2 });
  355. }
  356. /**
  357. * Announce keyboard shortcuts
  358. */
  359. announceShortcuts(shortcuts) {
  360. const shortcutText = shortcuts.map(s => `${s.key}: ${s.description}`).join(', ');
  361. this.announce(`Available shortcuts: ${shortcutText}`, 'polite');
  362. }
  363. /**
  364. * Clear all regions
  365. */
  366. clearAllRegions() {
  367. this.regions.forEach(region => {
  368. region.textContent = '';
  369. });
  370. // Clear queue
  371. this.announcementQueue = [];
  372. this.isProcessingQueue = false;
  373. }
  374. /**
  375. * Get region by name
  376. */
  377. getRegion(name) {
  378. return this.regions.get(name);
  379. }
  380. /**
  381. * Remove a region
  382. */
  383. removeRegion(name) {
  384. const region = this.regions.get(name);
  385. if (region && region.parentNode) {
  386. region.parentNode.removeChild(region);
  387. this.regions.delete(name);
  388. }
  389. }
  390. /**
  391. * Get live region manager instance (singleton)
  392. */
  393. static getInstance() {
  394. if (!LiveRegionManager.instance) {
  395. LiveRegionManager.instance = new LiveRegionManager();
  396. }
  397. return LiveRegionManager.instance;
  398. }
  399. }
  400. // Export for use in other modules
  401. window.LiveRegionManager = LiveRegionManager;