accessibility-manager.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  1. /**
  2. * @fileoverview Accessibility Manager for GrabZilla 2.1
  3. * Handles keyboard navigation, focus management, ARIA labels, and live regions
  4. * @author GrabZilla Development Team
  5. * @version 2.1.0
  6. * @since 2024-01-01
  7. */
  8. /**
  9. * ACCESSIBILITY MANAGER
  10. *
  11. * Manages keyboard navigation, focus states, and screen reader announcements
  12. *
  13. * Features:
  14. * - Full keyboard navigation for all interactive elements
  15. * - Focus management with visible indicators
  16. * - ARIA live regions for status announcements
  17. * - Screen reader support with proper labels
  18. *
  19. * Dependencies:
  20. * - None (vanilla JavaScript)
  21. *
  22. * State Management:
  23. * - Tracks current focus position
  24. * - Manages keyboard navigation state
  25. * - Handles live region announcements
  26. */
  27. class AccessibilityManager {
  28. constructor() {
  29. this.focusableElements = [];
  30. this.currentFocusIndex = -1;
  31. this.liveRegion = null;
  32. this.statusRegion = null;
  33. this.keyboardNavigationEnabled = true;
  34. this.lastAnnouncementTime = 0;
  35. this.announcementThrottle = 1000; // 1 second between announcements
  36. this.init();
  37. }
  38. /**
  39. * Initialize accessibility features
  40. */
  41. init() {
  42. this.createLiveRegions();
  43. this.setupKeyboardNavigation();
  44. this.setupFocusManagement();
  45. this.setupARIALabels();
  46. this.setupStatusAnnouncements();
  47. console.log('AccessibilityManager initialized');
  48. }
  49. /**
  50. * Create ARIA live regions for announcements
  51. */
  52. createLiveRegions() {
  53. // Create assertive live region for important announcements
  54. this.liveRegion = document.createElement('div');
  55. this.liveRegion.setAttribute('aria-live', 'assertive');
  56. this.liveRegion.setAttribute('aria-atomic', 'true');
  57. this.liveRegion.setAttribute('class', 'sr-only');
  58. this.liveRegion.setAttribute('id', 'live-announcements');
  59. document.body.appendChild(this.liveRegion);
  60. // Create polite live region for status updates
  61. this.statusRegion = document.createElement('div');
  62. this.statusRegion.setAttribute('aria-live', 'polite');
  63. this.statusRegion.setAttribute('aria-atomic', 'false');
  64. this.statusRegion.setAttribute('class', 'sr-only');
  65. this.statusRegion.setAttribute('id', 'status-announcements');
  66. document.body.appendChild(this.statusRegion);
  67. }
  68. /**
  69. * Setup keyboard navigation for all interactive elements
  70. */
  71. setupKeyboardNavigation() {
  72. // Define keyboard shortcuts
  73. const keyboardShortcuts = {
  74. 'Tab': this.handleTabNavigation.bind(this),
  75. 'Shift+Tab': this.handleShiftTabNavigation.bind(this),
  76. 'Enter': this.handleEnterKey.bind(this),
  77. 'Space': this.handleSpaceKey.bind(this),
  78. 'Escape': this.handleEscapeKey.bind(this),
  79. 'ArrowUp': this.handleArrowUp.bind(this),
  80. 'ArrowDown': this.handleArrowDown.bind(this),
  81. 'ArrowLeft': this.handleArrowLeft.bind(this),
  82. 'ArrowRight': this.handleArrowRight.bind(this),
  83. 'Home': this.handleHomeKey.bind(this),
  84. 'End': this.handleEndKey.bind(this),
  85. 'Delete': this.handleDeleteKey.bind(this),
  86. 'Ctrl+a': this.handleSelectAll.bind(this),
  87. 'Ctrl+d': this.handleDownloadShortcut.bind(this)
  88. };
  89. // Add global keyboard event listener
  90. document.addEventListener('keydown', (event) => {
  91. const key = this.getKeyString(event);
  92. if (keyboardShortcuts[key]) {
  93. const handled = keyboardShortcuts[key](event);
  94. if (handled) {
  95. event.preventDefault();
  96. event.stopPropagation();
  97. }
  98. }
  99. });
  100. // Update focusable elements when DOM changes
  101. this.updateFocusableElements();
  102. // Set up mutation observer to track DOM changes
  103. const observer = new MutationObserver(() => {
  104. this.updateFocusableElements();
  105. });
  106. observer.observe(document.body, {
  107. childList: true,
  108. subtree: true,
  109. attributes: true,
  110. attributeFilter: ['tabindex', 'disabled', 'aria-hidden']
  111. });
  112. }
  113. /**
  114. * Get keyboard shortcut string from event
  115. */
  116. getKeyString(event) {
  117. const parts = [];
  118. if (event.ctrlKey) parts.push('Ctrl');
  119. if (event.shiftKey) parts.push('Shift');
  120. if (event.altKey) parts.push('Alt');
  121. if (event.metaKey) parts.push('Meta');
  122. parts.push(event.key);
  123. return parts.join('+');
  124. }
  125. /**
  126. * Update list of focusable elements
  127. */
  128. updateFocusableElements() {
  129. const focusableSelectors = [
  130. 'button:not([disabled]):not([aria-hidden="true"])',
  131. 'input:not([disabled]):not([aria-hidden="true"])',
  132. 'textarea:not([disabled]):not([aria-hidden="true"])',
  133. 'select:not([disabled]):not([aria-hidden="true"])',
  134. '[tabindex]:not([tabindex="-1"]):not([disabled]):not([aria-hidden="true"])',
  135. 'a[href]:not([aria-hidden="true"])'
  136. ].join(', ');
  137. this.focusableElements = Array.from(document.querySelectorAll(focusableSelectors))
  138. .filter(el => this.isVisible(el))
  139. .sort((a, b) => {
  140. const aIndex = parseInt(a.getAttribute('tabindex')) || 0;
  141. const bIndex = parseInt(b.getAttribute('tabindex')) || 0;
  142. return aIndex - bIndex;
  143. });
  144. }
  145. /**
  146. * Check if element is visible and focusable
  147. */
  148. isVisible(element) {
  149. const style = window.getComputedStyle(element);
  150. return style.display !== 'none' &&
  151. style.visibility !== 'hidden' &&
  152. element.offsetParent !== null;
  153. }
  154. /**
  155. * Setup focus management system
  156. */
  157. setupFocusManagement() {
  158. // Track focus changes
  159. document.addEventListener('focusin', (event) => {
  160. this.currentFocusIndex = this.focusableElements.indexOf(event.target);
  161. this.announceElementFocus(event.target);
  162. });
  163. // Add focus indicators to all interactive elements
  164. this.addFocusIndicators();
  165. }
  166. /**
  167. * Add visible focus indicators
  168. */
  169. addFocusIndicators() {
  170. const style = document.createElement('style');
  171. style.textContent = `
  172. /* Enhanced focus indicators for accessibility */
  173. button:focus-visible,
  174. input:focus-visible,
  175. textarea:focus-visible,
  176. select:focus-visible,
  177. [tabindex]:focus-visible {
  178. outline: 3px solid var(--primary-blue) !important;
  179. outline-offset: 2px !important;
  180. box-shadow: 0 0 0 1px rgba(21, 93, 252, 0.3) !important;
  181. }
  182. /* Video item focus indicators */
  183. .video-item:focus-within {
  184. outline: 2px solid var(--primary-blue) !important;
  185. outline-offset: 1px !important;
  186. background-color: rgba(21, 93, 252, 0.1) !important;
  187. }
  188. /* High contrast mode support */
  189. @media (prefers-contrast: high) {
  190. button:focus-visible,
  191. input:focus-visible,
  192. textarea:focus-visible,
  193. select:focus-visible,
  194. [tabindex]:focus-visible {
  195. outline: 3px solid #ffffff !important;
  196. outline-offset: 2px !important;
  197. }
  198. }
  199. `;
  200. document.head.appendChild(style);
  201. }
  202. /**
  203. * Setup comprehensive ARIA labels and descriptions
  204. */
  205. setupARIALabels() {
  206. // Header section
  207. const header = document.querySelector('header');
  208. if (header) {
  209. header.setAttribute('role', 'banner');
  210. header.setAttribute('aria-label', 'GrabZilla application header');
  211. }
  212. // Main content area
  213. const main = document.querySelector('main');
  214. if (main) {
  215. main.setAttribute('role', 'main');
  216. main.setAttribute('aria-label', 'Video download queue');
  217. }
  218. // Input section
  219. const inputSection = document.querySelector('section');
  220. if (inputSection) {
  221. inputSection.setAttribute('role', 'region');
  222. inputSection.setAttribute('aria-label', 'Video URL input and configuration');
  223. }
  224. // Control panel
  225. const footer = document.querySelector('footer');
  226. if (footer) {
  227. footer.setAttribute('role', 'contentinfo');
  228. footer.setAttribute('aria-label', 'Download controls and actions');
  229. }
  230. // Video list table
  231. const videoList = document.getElementById('videoList');
  232. if (videoList) {
  233. videoList.setAttribute('role', 'grid');
  234. videoList.setAttribute('aria-label', 'Video download queue');
  235. videoList.setAttribute('aria-describedby', 'video-list-description');
  236. // Add description for video list
  237. const description = document.createElement('div');
  238. description.id = 'video-list-description';
  239. description.className = 'sr-only';
  240. description.textContent = 'Use arrow keys to navigate between videos, Enter to select, Space to toggle selection, Delete to remove videos';
  241. videoList.parentNode.insertBefore(description, videoList);
  242. }
  243. // Setup video item ARIA labels
  244. this.setupVideoItemARIA();
  245. // Setup button ARIA labels
  246. this.setupButtonARIA();
  247. // Setup form control ARIA labels
  248. this.setupFormControlARIA();
  249. }
  250. /**
  251. * Setup ARIA labels for video items
  252. */
  253. setupVideoItemARIA() {
  254. const videoItems = document.querySelectorAll('.video-item');
  255. videoItems.forEach((item, index) => {
  256. item.setAttribute('role', 'gridcell');
  257. item.setAttribute('tabindex', '0');
  258. item.setAttribute('aria-rowindex', index + 1);
  259. item.setAttribute('aria-describedby', `video-${index}-description`);
  260. // Create description for each video
  261. const title = item.querySelector('.text-sm.text-white.truncate')?.textContent || 'Unknown video';
  262. const duration = item.querySelector('.text-sm.text-\\[\\#cad5e2\\]')?.textContent || 'Unknown duration';
  263. const status = item.querySelector('.status-badge')?.textContent || 'Unknown status';
  264. const description = document.createElement('div');
  265. description.id = `video-${index}-description`;
  266. description.className = 'sr-only';
  267. description.textContent = `Video: ${title}, Duration: ${duration}, Status: ${status}`;
  268. item.appendChild(description);
  269. });
  270. }
  271. /**
  272. * Setup ARIA labels for buttons
  273. */
  274. setupButtonARIA() {
  275. const buttonLabels = {
  276. 'addVideoBtn': 'Add video from URL input to download queue',
  277. 'importUrlsBtn': 'Import multiple URLs from file',
  278. 'savePathBtn': 'Select directory for downloaded videos',
  279. 'cookieFileBtn': 'Select cookie file for authentication',
  280. 'clearListBtn': 'Remove all videos from download queue',
  281. 'updateDepsBtn': 'Update yt-dlp and ffmpeg to latest versions',
  282. 'cancelDownloadsBtn': 'Cancel all active downloads',
  283. 'downloadVideosBtn': 'Start downloading all videos in queue'
  284. };
  285. Object.entries(buttonLabels).forEach(([id, label]) => {
  286. const button = document.getElementById(id);
  287. if (button) {
  288. button.setAttribute('aria-label', label);
  289. // Add keyboard shortcut hints
  290. if (id === 'downloadVideosBtn') {
  291. button.setAttribute('aria-keyshortcuts', 'Ctrl+d');
  292. }
  293. }
  294. });
  295. }
  296. /**
  297. * Setup ARIA labels for form controls
  298. */
  299. setupFormControlARIA() {
  300. // URL input
  301. const urlInput = document.getElementById('urlInput');
  302. if (urlInput) {
  303. urlInput.setAttribute('aria-describedby', 'url-help url-instructions');
  304. const instructions = document.createElement('div');
  305. instructions.id = 'url-instructions';
  306. instructions.className = 'sr-only';
  307. instructions.textContent = 'Enter YouTube or Vimeo URLs, one per line. Press Ctrl+Enter to add videos quickly.';
  308. urlInput.parentNode.appendChild(instructions);
  309. }
  310. // Quality and format dropdowns
  311. const defaultQuality = document.getElementById('defaultQuality');
  312. if (defaultQuality) {
  313. defaultQuality.setAttribute('aria-describedby', 'quality-help');
  314. const qualityHelp = document.createElement('div');
  315. qualityHelp.id = 'quality-help';
  316. qualityHelp.className = 'sr-only';
  317. qualityHelp.textContent = 'Default video quality for new downloads. Can be changed per video.';
  318. defaultQuality.parentNode.appendChild(qualityHelp);
  319. }
  320. const defaultFormat = document.getElementById('defaultFormat');
  321. if (defaultFormat) {
  322. defaultFormat.setAttribute('aria-describedby', 'format-help');
  323. const formatHelp = document.createElement('div');
  324. formatHelp.id = 'format-help';
  325. formatHelp.className = 'sr-only';
  326. formatHelp.textContent = 'Default conversion format. None means no conversion, Audio only extracts audio.';
  327. defaultFormat.parentNode.appendChild(formatHelp);
  328. }
  329. // Filename pattern
  330. const filenamePattern = document.getElementById('filenamePattern');
  331. if (filenamePattern) {
  332. filenamePattern.setAttribute('aria-label', 'Filename pattern for downloaded videos');
  333. filenamePattern.setAttribute('aria-describedby', 'filename-help');
  334. const filenameHelp = document.createElement('div');
  335. filenameHelp.id = 'filename-help';
  336. filenameHelp.className = 'sr-only';
  337. filenameHelp.textContent = 'Pattern for naming downloaded files. %(title)s uses video title, %(ext)s uses file extension.';
  338. filenamePattern.parentNode.appendChild(filenameHelp);
  339. }
  340. }
  341. /**
  342. * Setup status announcements for download progress
  343. */
  344. setupStatusAnnouncements() {
  345. // Monitor status changes in video items
  346. const observer = new MutationObserver((mutations) => {
  347. mutations.forEach((mutation) => {
  348. if (mutation.type === 'childList' || mutation.type === 'characterData') {
  349. const target = mutation.target;
  350. if (target.classList?.contains('status-badge') ||
  351. target.parentElement?.classList?.contains('status-badge')) {
  352. this.announceStatusChange(target);
  353. }
  354. }
  355. });
  356. });
  357. // Observe status badge changes
  358. const statusBadges = document.querySelectorAll('.status-badge');
  359. statusBadges.forEach(badge => {
  360. observer.observe(badge, {
  361. childList: true,
  362. characterData: true,
  363. subtree: true
  364. });
  365. });
  366. // Monitor for new status badges
  367. const listObserver = new MutationObserver((mutations) => {
  368. mutations.forEach((mutation) => {
  369. mutation.addedNodes.forEach((node) => {
  370. if (node.nodeType === Node.ELEMENT_NODE) {
  371. const newBadges = node.querySelectorAll('.status-badge');
  372. newBadges.forEach(badge => {
  373. observer.observe(badge, {
  374. childList: true,
  375. characterData: true,
  376. subtree: true
  377. });
  378. });
  379. }
  380. });
  381. });
  382. });
  383. const videoList = document.getElementById('videoList');
  384. if (videoList) {
  385. listObserver.observe(videoList, { childList: true, subtree: true });
  386. }
  387. }
  388. /**
  389. * Keyboard navigation handlers
  390. */
  391. handleTabNavigation(event) {
  392. // Let default tab behavior work, but update our tracking
  393. setTimeout(() => {
  394. this.updateFocusableElements();
  395. this.currentFocusIndex = this.focusableElements.indexOf(document.activeElement);
  396. }, 0);
  397. return false; // Don't prevent default
  398. }
  399. handleShiftTabNavigation(event) {
  400. // Let default shift+tab behavior work
  401. setTimeout(() => {
  402. this.updateFocusableElements();
  403. this.currentFocusIndex = this.focusableElements.indexOf(document.activeElement);
  404. }, 0);
  405. return false; // Don't prevent default
  406. }
  407. handleEnterKey(event) {
  408. const activeElement = document.activeElement;
  409. // Handle video item selection
  410. if (activeElement.classList.contains('video-item')) {
  411. this.toggleVideoSelection(activeElement);
  412. return true;
  413. }
  414. // Handle button activation
  415. if (activeElement.tagName === 'BUTTON') {
  416. activeElement.click();
  417. return true;
  418. }
  419. return false;
  420. } ha
  421. ndleSpaceKey(event) {
  422. const activeElement = document.activeElement;
  423. // Handle video item selection toggle
  424. if (activeElement.classList.contains('video-item')) {
  425. this.toggleVideoSelection(activeElement);
  426. return true;
  427. }
  428. // Handle button activation for buttons that don't have default space behavior
  429. if (activeElement.tagName === 'BUTTON' && !activeElement.type) {
  430. activeElement.click();
  431. return true;
  432. }
  433. return false;
  434. }
  435. handleEscapeKey(event) {
  436. // Clear all selections
  437. this.clearAllSelections();
  438. // Focus the URL input
  439. const urlInput = document.getElementById('urlInput');
  440. if (urlInput) {
  441. urlInput.focus();
  442. }
  443. this.announce('Selections cleared, focus moved to URL input');
  444. return true;
  445. }
  446. handleArrowUp(event) {
  447. const activeElement = document.activeElement;
  448. // Navigate between video items
  449. if (activeElement.classList.contains('video-item')) {
  450. const videoItems = Array.from(document.querySelectorAll('.video-item'));
  451. const currentIndex = videoItems.indexOf(activeElement);
  452. if (currentIndex > 0) {
  453. videoItems[currentIndex - 1].focus();
  454. return true;
  455. }
  456. }
  457. return false;
  458. }
  459. handleArrowDown(event) {
  460. const activeElement = document.activeElement;
  461. // Navigate between video items
  462. if (activeElement.classList.contains('video-item')) {
  463. const videoItems = Array.from(document.querySelectorAll('.video-item'));
  464. const currentIndex = videoItems.indexOf(activeElement);
  465. if (currentIndex < videoItems.length - 1) {
  466. videoItems[currentIndex + 1].focus();
  467. return true;
  468. }
  469. }
  470. return false;
  471. }
  472. handleArrowLeft(event) {
  473. const activeElement = document.activeElement;
  474. // Navigate between controls within a video item
  475. if (activeElement.closest('.video-item')) {
  476. const videoItem = activeElement.closest('.video-item');
  477. const controls = Array.from(videoItem.querySelectorAll('button, select'));
  478. const currentIndex = controls.indexOf(activeElement);
  479. if (currentIndex > 0) {
  480. controls[currentIndex - 1].focus();
  481. return true;
  482. }
  483. }
  484. return false;
  485. }
  486. handleArrowRight(event) {
  487. const activeElement = document.activeElement;
  488. // Navigate between controls within a video item
  489. if (activeElement.closest('.video-item')) {
  490. const videoItem = activeElement.closest('.video-item');
  491. const controls = Array.from(videoItem.querySelectorAll('button, select'));
  492. const currentIndex = controls.indexOf(activeElement);
  493. if (currentIndex < controls.length - 1) {
  494. controls[currentIndex + 1].focus();
  495. return true;
  496. }
  497. }
  498. return false;
  499. } handl
  500. eHomeKey(event) {
  501. const videoItems = document.querySelectorAll('.video-item');
  502. if (videoItems.length > 0) {
  503. videoItems[0].focus();
  504. return true;
  505. }
  506. return false;
  507. }
  508. handleEndKey(event) {
  509. const videoItems = document.querySelectorAll('.video-item');
  510. if (videoItems.length > 0) {
  511. videoItems[videoItems.length - 1].focus();
  512. return true;
  513. }
  514. return false;
  515. }
  516. handleDeleteKey(event) {
  517. const activeElement = document.activeElement;
  518. // Delete focused video item
  519. if (activeElement.classList.contains('video-item')) {
  520. const videoId = activeElement.getAttribute('data-video-id');
  521. if (videoId && window.videoManager) {
  522. window.videoManager.removeVideo(videoId);
  523. this.announce('Video removed from queue');
  524. return true;
  525. }
  526. }
  527. return false;
  528. }
  529. handleSelectAll(event) {
  530. const videoItems = document.querySelectorAll('.video-item');
  531. videoItems.forEach(item => {
  532. item.classList.add('selected');
  533. const checkbox = item.querySelector('.video-checkbox');
  534. if (checkbox) {
  535. checkbox.classList.add('checked');
  536. }
  537. });
  538. this.announce(`All ${videoItems.length} videos selected`);
  539. return true;
  540. }
  541. handleDownloadShortcut(event) {
  542. const downloadBtn = document.getElementById('downloadVideosBtn');
  543. if (downloadBtn && !downloadBtn.disabled) {
  544. downloadBtn.click();
  545. return true;
  546. }
  547. return false;
  548. }
  549. /**
  550. * Toggle video selection state
  551. */
  552. toggleVideoSelection(videoItem) {
  553. const isSelected = videoItem.classList.contains('selected');
  554. if (isSelected) {
  555. videoItem.classList.remove('selected');
  556. const checkbox = videoItem.querySelector('.video-checkbox');
  557. if (checkbox) {
  558. checkbox.classList.remove('checked');
  559. }
  560. this.announce('Video deselected');
  561. } else {
  562. videoItem.classList.add('selected');
  563. const checkbox = videoItem.querySelector('.video-checkbox');
  564. if (checkbox) {
  565. checkbox.classList.add('checked');
  566. }
  567. this.announce('Video selected');
  568. }
  569. }
  570. /**
  571. * Clear all video selections
  572. */
  573. clearAllSelections() {
  574. const selectedItems = document.querySelectorAll('.video-item.selected');
  575. selectedItems.forEach(item => {
  576. item.classList.remove('selected');
  577. const checkbox = item.querySelector('.video-checkbox');
  578. if (checkbox) {
  579. checkbox.classList.remove('checked');
  580. }
  581. });
  582. }
  583. /**
  584. * Announce element focus for screen readers
  585. */
  586. announceElementFocus(element) {
  587. if (!element) return;
  588. let announcement = '';
  589. // Get element description
  590. if (element.getAttribute('aria-label')) {
  591. announcement = element.getAttribute('aria-label');
  592. } else if (element.getAttribute('aria-labelledby')) {
  593. const labelId = element.getAttribute('aria-labelledby');
  594. const labelElement = document.getElementById(labelId);
  595. if (labelElement) {
  596. announcement = labelElement.textContent;
  597. }
  598. } else if (element.textContent) {
  599. announcement = element.textContent.trim();
  600. }
  601. // Add element type context
  602. const tagName = element.tagName.toLowerCase();
  603. if (tagName === 'button') {
  604. announcement += ', button';
  605. } else if (tagName === 'select') {
  606. announcement += ', dropdown menu';
  607. } else if (tagName === 'input') {
  608. announcement += ', input field';
  609. } else if (tagName === 'textarea') {
  610. announcement += ', text area';
  611. }
  612. // Add state information
  613. if (element.disabled) {
  614. announcement += ', disabled';
  615. }
  616. if (element.getAttribute('aria-expanded')) {
  617. const expanded = element.getAttribute('aria-expanded') === 'true';
  618. announcement += expanded ? ', expanded' : ', collapsed';
  619. }
  620. // Throttle announcements to avoid spam
  621. const now = Date.now();
  622. if (now - this.lastAnnouncementTime > 500) {
  623. this.announcePolite(announcement);
  624. this.lastAnnouncementTime = now;
  625. }
  626. }
  627. /**
  628. * Announce status changes
  629. */
  630. announceStatusChange(statusElement) {
  631. const statusText = statusElement.textContent || statusElement.innerText;
  632. if (!statusText) return;
  633. // Find the video title for context
  634. const videoItem = statusElement.closest('.video-item');
  635. let videoTitle = 'Video';
  636. if (videoItem) {
  637. const titleElement = videoItem.querySelector('.text-sm.text-white.truncate');
  638. if (titleElement) {
  639. videoTitle = titleElement.textContent.trim();
  640. }
  641. }
  642. const announcement = `${videoTitle}: ${statusText}`;
  643. this.announcePolite(announcement);
  644. }
  645. /**
  646. * Make assertive announcement (interrupts screen reader)
  647. */
  648. announce(message) {
  649. if (!message || !this.liveRegion) return;
  650. // Clear and set new message
  651. this.liveRegion.textContent = '';
  652. setTimeout(() => {
  653. this.liveRegion.textContent = message;
  654. }, 100);
  655. }
  656. /**
  657. * Make polite announcement (waits for screen reader to finish)
  658. */
  659. announcePolite(message) {
  660. if (!message || !this.statusRegion) return;
  661. // Throttle announcements
  662. const now = Date.now();
  663. if (now - this.lastAnnouncementTime < this.announcementThrottle) {
  664. return;
  665. }
  666. this.statusRegion.textContent = message;
  667. this.lastAnnouncementTime = now;
  668. }
  669. /**
  670. * Update video item accessibility when new videos are added
  671. */
  672. updateVideoItemAccessibility(videoItem, index) {
  673. if (!videoItem) return;
  674. videoItem.setAttribute('role', 'gridcell');
  675. videoItem.setAttribute('tabindex', '0');
  676. videoItem.setAttribute('aria-rowindex', index + 1);
  677. // Add keyboard event handlers
  678. videoItem.addEventListener('keydown', (event) => {
  679. if (event.key === 'Enter' || event.key === ' ') {
  680. event.preventDefault();
  681. this.toggleVideoSelection(videoItem);
  682. }
  683. });
  684. // Update ARIA description
  685. const title = videoItem.querySelector('.text-sm.text-white.truncate')?.textContent || 'Unknown video';
  686. const duration = videoItem.querySelector('.text-sm.text-\\[\\#cad5e2\\]')?.textContent || 'Unknown duration';
  687. const status = videoItem.querySelector('.status-badge')?.textContent || 'Unknown status';
  688. let description = videoItem.querySelector(`#video-${index}-description`);
  689. if (!description) {
  690. description = document.createElement('div');
  691. description.id = `video-${index}-description`;
  692. description.className = 'sr-only';
  693. videoItem.appendChild(description);
  694. }
  695. description.textContent = `Video: ${title}, Duration: ${duration}, Status: ${status}. Press Enter or Space to select, Delete to remove.`;
  696. videoItem.setAttribute('aria-describedby', `video-${index}-description`);
  697. // Setup dropdown accessibility
  698. const qualitySelect = videoItem.querySelector('select');
  699. const formatSelect = videoItem.querySelectorAll('select')[1];
  700. if (qualitySelect) {
  701. qualitySelect.setAttribute('aria-label', `Quality for ${title}`);
  702. }
  703. if (formatSelect) {
  704. formatSelect.setAttribute('aria-label', `Format for ${title}`);
  705. }
  706. // Setup checkbox accessibility
  707. const checkbox = videoItem.querySelector('.video-checkbox');
  708. if (checkbox) {
  709. checkbox.setAttribute('role', 'checkbox');
  710. checkbox.setAttribute('aria-checked', 'false');
  711. checkbox.setAttribute('aria-label', `Select ${title}`);
  712. checkbox.setAttribute('tabindex', '0');
  713. checkbox.addEventListener('click', () => {
  714. this.toggleVideoSelection(videoItem);
  715. });
  716. checkbox.addEventListener('keydown', (event) => {
  717. if (event.key === 'Enter' || event.key === ' ') {
  718. event.preventDefault();
  719. this.toggleVideoSelection(videoItem);
  720. }
  721. });
  722. }
  723. }
  724. /**
  725. * Announce download progress updates
  726. */
  727. announceProgress(videoTitle, status, progress) {
  728. if (progress !== undefined) {
  729. const message = `${videoTitle}: ${status} ${progress}%`;
  730. this.announcePolite(message);
  731. } else {
  732. const message = `${videoTitle}: ${status}`;
  733. this.announcePolite(message);
  734. }
  735. }
  736. /**
  737. * Announce when videos are added or removed
  738. */
  739. announceVideoListChange(action, count, videoTitle = '') {
  740. let message = '';
  741. switch (action) {
  742. case 'added':
  743. message = videoTitle ?
  744. `Added ${videoTitle} to download queue` :
  745. `Added ${count} video${count !== 1 ? 's' : ''} to download queue`;
  746. break;
  747. case 'removed':
  748. message = videoTitle ?
  749. `Removed ${videoTitle} from download queue` :
  750. `Removed ${count} video${count !== 1 ? 's' : ''} from download queue`;
  751. break;
  752. case 'cleared':
  753. message = 'Download queue cleared';
  754. break;
  755. }
  756. if (message) {
  757. this.announce(message);
  758. }
  759. }
  760. /**
  761. * Get accessibility manager instance (singleton)
  762. */
  763. static getInstance() {
  764. if (!AccessibilityManager.instance) {
  765. AccessibilityManager.instance = new AccessibilityManager();
  766. }
  767. return AccessibilityManager.instance;
  768. }
  769. }
  770. // Export for use in other modules
  771. window.AccessibilityManager = AccessibilityManager;