keyboard-navigation.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. /**
  2. * @fileoverview Keyboard Navigation Utilities for GrabZilla 2.1
  3. * Handles advanced keyboard navigation patterns and focus management
  4. * @author GrabZilla Development Team
  5. * @version 2.1.0
  6. * @since 2024-01-01
  7. */
  8. /**
  9. * KEYBOARD NAVIGATION UTILITIES
  10. *
  11. * Advanced keyboard navigation for complex UI interactions
  12. *
  13. * Features:
  14. * - Grid navigation for video list
  15. * - Tab trapping for modal dialogs
  16. * - Focus restoration after actions
  17. * - Keyboard shortcuts management
  18. *
  19. * Dependencies:
  20. * - AccessibilityManager for announcements
  21. *
  22. * State Management:
  23. * - Tracks navigation context
  24. * - Manages focus history
  25. * - Handles keyboard mode detection
  26. */
  27. class KeyboardNavigation {
  28. constructor() {
  29. this.isKeyboardMode = false;
  30. this.focusHistory = [];
  31. this.currentContext = null;
  32. this.shortcuts = new Map();
  33. this.init();
  34. }
  35. /**
  36. * Initialize keyboard navigation
  37. */
  38. init() {
  39. this.setupKeyboardModeDetection();
  40. this.setupGlobalShortcuts();
  41. this.setupGridNavigation();
  42. this.setupFocusTrapping();
  43. console.log('KeyboardNavigation initialized');
  44. }
  45. /**
  46. * Detect when user is using keyboard vs mouse
  47. */
  48. setupKeyboardModeDetection() {
  49. // Enable keyboard mode on first tab press
  50. document.addEventListener('keydown', (event) => {
  51. if (event.key === 'Tab') {
  52. this.enableKeyboardMode();
  53. }
  54. });
  55. // Disable keyboard mode on mouse interaction
  56. document.addEventListener('mousedown', () => {
  57. this.disableKeyboardMode();
  58. });
  59. }
  60. /**
  61. * Enable keyboard navigation mode
  62. */
  63. enableKeyboardMode() {
  64. if (!this.isKeyboardMode) {
  65. this.isKeyboardMode = true;
  66. document.body.classList.add('keyboard-navigation-active');
  67. // Announce keyboard mode to screen readers
  68. if (window.accessibilityManager) {
  69. window.accessibilityManager.announcePolite('Keyboard navigation active');
  70. }
  71. }
  72. }
  73. /**
  74. * Disable keyboard navigation mode
  75. */
  76. disableKeyboardMode() {
  77. if (this.isKeyboardMode) {
  78. this.isKeyboardMode = false;
  79. document.body.classList.remove('keyboard-navigation-active');
  80. }
  81. }
  82. /**
  83. * Setup global keyboard shortcuts
  84. */
  85. setupGlobalShortcuts() {
  86. // Register common shortcuts
  87. this.registerShortcut('Ctrl+d', () => {
  88. const downloadBtn = document.getElementById('downloadVideosBtn');
  89. if (downloadBtn && !downloadBtn.disabled) {
  90. downloadBtn.click();
  91. return true;
  92. }
  93. return false;
  94. }, 'Start downloads');
  95. this.registerShortcut('Ctrl+a', (event) => {
  96. // Only handle in video list context
  97. if (this.isInVideoList(event.target)) {
  98. this.selectAllVideos();
  99. return true;
  100. }
  101. return false;
  102. }, 'Select all videos');
  103. this.registerShortcut('Escape', () => {
  104. this.clearSelections();
  105. this.focusUrlInput();
  106. return true;
  107. }, 'Clear selections and focus URL input');
  108. this.registerShortcut('Ctrl+Enter', () => {
  109. const urlInput = document.getElementById('urlInput');
  110. if (urlInput && urlInput.value.trim()) {
  111. const addBtn = document.getElementById('addVideoBtn');
  112. if (addBtn) {
  113. addBtn.click();
  114. return true;
  115. }
  116. }
  117. return false;
  118. }, 'Add video from URL input');
  119. // Listen for shortcut keys
  120. document.addEventListener('keydown', (event) => {
  121. const shortcutKey = this.getShortcutKey(event);
  122. const handler = this.shortcuts.get(shortcutKey);
  123. if (handler && handler.callback(event)) {
  124. event.preventDefault();
  125. event.stopPropagation();
  126. }
  127. });
  128. }
  129. /**
  130. * Register a keyboard shortcut
  131. */
  132. registerShortcut(key, callback, description) {
  133. this.shortcuts.set(key, { callback, description });
  134. }
  135. /**
  136. * Get shortcut key string from event
  137. */
  138. getShortcutKey(event) {
  139. const parts = [];
  140. if (event.ctrlKey) parts.push('Ctrl');
  141. if (event.shiftKey) parts.push('Shift');
  142. if (event.altKey) parts.push('Alt');
  143. if (event.metaKey) parts.push('Meta');
  144. parts.push(event.key);
  145. return parts.join('+');
  146. }
  147. /**
  148. * Setup grid navigation for video list
  149. */
  150. setupGridNavigation() {
  151. const videoList = document.getElementById('videoList');
  152. if (!videoList) return;
  153. videoList.addEventListener('keydown', (event) => {
  154. if (!this.isKeyboardMode) return;
  155. const currentItem = event.target.closest('.video-item');
  156. if (!currentItem) return;
  157. switch (event.key) {
  158. case 'ArrowUp':
  159. event.preventDefault();
  160. this.navigateToVideo(currentItem, 'up');
  161. break;
  162. case 'ArrowDown':
  163. event.preventDefault();
  164. this.navigateToVideo(currentItem, 'down');
  165. break;
  166. case 'ArrowLeft':
  167. event.preventDefault();
  168. this.navigateWithinVideo(currentItem, 'left');
  169. break;
  170. case 'ArrowRight':
  171. event.preventDefault();
  172. this.navigateWithinVideo(currentItem, 'right');
  173. break;
  174. case 'Home':
  175. event.preventDefault();
  176. this.navigateToFirstVideo();
  177. break;
  178. case 'End':
  179. event.preventDefault();
  180. this.navigateToLastVideo();
  181. break;
  182. }
  183. });
  184. } /*
  185. *
  186. * Navigate between video items
  187. */
  188. navigateToVideo(currentItem, direction) {
  189. const videoItems = Array.from(document.querySelectorAll('.video-item'));
  190. const currentIndex = videoItems.indexOf(currentItem);
  191. let targetIndex;
  192. if (direction === 'up') {
  193. targetIndex = Math.max(0, currentIndex - 1);
  194. } else if (direction === 'down') {
  195. targetIndex = Math.min(videoItems.length - 1, currentIndex + 1);
  196. }
  197. if (targetIndex !== undefined && videoItems[targetIndex]) {
  198. videoItems[targetIndex].focus();
  199. this.scrollIntoViewIfNeeded(videoItems[targetIndex]);
  200. }
  201. }
  202. /**
  203. * Navigate within a video item (between controls)
  204. */
  205. navigateWithinVideo(videoItem, direction) {
  206. const focusableElements = Array.from(videoItem.querySelectorAll(
  207. 'button, select, input, [tabindex]:not([tabindex="-1"])'
  208. ));
  209. const currentElement = document.activeElement;
  210. const currentIndex = focusableElements.indexOf(currentElement);
  211. let targetIndex;
  212. if (direction === 'left') {
  213. targetIndex = Math.max(0, currentIndex - 1);
  214. } else if (direction === 'right') {
  215. targetIndex = Math.min(focusableElements.length - 1, currentIndex + 1);
  216. }
  217. if (targetIndex !== undefined && focusableElements[targetIndex]) {
  218. focusableElements[targetIndex].focus();
  219. }
  220. }
  221. /**
  222. * Navigate to first video
  223. */
  224. navigateToFirstVideo() {
  225. const firstVideo = document.querySelector('.video-item');
  226. if (firstVideo) {
  227. firstVideo.focus();
  228. this.scrollIntoViewIfNeeded(firstVideo);
  229. }
  230. }
  231. /**
  232. * Navigate to last video
  233. */
  234. navigateToLastVideo() {
  235. const videoItems = document.querySelectorAll('.video-item');
  236. const lastVideo = videoItems[videoItems.length - 1];
  237. if (lastVideo) {
  238. lastVideo.focus();
  239. this.scrollIntoViewIfNeeded(lastVideo);
  240. }
  241. }
  242. /**
  243. * Scroll element into view if needed
  244. */
  245. scrollIntoViewIfNeeded(element) {
  246. const container = document.getElementById('videoList');
  247. if (!container) return;
  248. const containerRect = container.getBoundingClientRect();
  249. const elementRect = element.getBoundingClientRect();
  250. if (elementRect.top < containerRect.top) {
  251. element.scrollIntoView({ behavior: 'smooth', block: 'start' });
  252. } else if (elementRect.bottom > containerRect.bottom) {
  253. element.scrollIntoView({ behavior: 'smooth', block: 'end' });
  254. }
  255. }
  256. /**
  257. * Setup focus trapping for modal dialogs
  258. */
  259. setupFocusTrapping() {
  260. // This will be used when modal dialogs are implemented
  261. this.trapFocus = (container) => {
  262. const focusableElements = container.querySelectorAll(
  263. 'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
  264. );
  265. const firstElement = focusableElements[0];
  266. const lastElement = focusableElements[focusableElements.length - 1];
  267. container.addEventListener('keydown', (event) => {
  268. if (event.key === 'Tab') {
  269. if (event.shiftKey) {
  270. if (document.activeElement === firstElement) {
  271. event.preventDefault();
  272. lastElement.focus();
  273. }
  274. } else {
  275. if (document.activeElement === lastElement) {
  276. event.preventDefault();
  277. firstElement.focus();
  278. }
  279. }
  280. }
  281. });
  282. // Focus first element
  283. if (firstElement) {
  284. firstElement.focus();
  285. }
  286. };
  287. } /*
  288. *
  289. * Check if element is in video list context
  290. */
  291. isInVideoList(element) {
  292. return element.closest('#videoList') !== null;
  293. }
  294. /**
  295. * Select all videos
  296. */
  297. selectAllVideos() {
  298. const videoItems = document.querySelectorAll('.video-item');
  299. let selectedCount = 0;
  300. videoItems.forEach(item => {
  301. if (!item.classList.contains('selected')) {
  302. item.classList.add('selected');
  303. const checkbox = item.querySelector('.video-checkbox');
  304. if (checkbox) {
  305. checkbox.classList.add('checked');
  306. checkbox.setAttribute('aria-checked', 'true');
  307. }
  308. selectedCount++;
  309. }
  310. });
  311. if (window.accessibilityManager) {
  312. window.accessibilityManager.announce(`Selected all ${videoItems.length} videos`);
  313. }
  314. }
  315. /**
  316. * Clear all selections
  317. */
  318. clearSelections() {
  319. const selectedItems = document.querySelectorAll('.video-item.selected');
  320. selectedItems.forEach(item => {
  321. item.classList.remove('selected');
  322. const checkbox = item.querySelector('.video-checkbox');
  323. if (checkbox) {
  324. checkbox.classList.remove('checked');
  325. checkbox.setAttribute('aria-checked', 'false');
  326. }
  327. });
  328. if (selectedItems.length > 0 && window.accessibilityManager) {
  329. window.accessibilityManager.announce('All selections cleared');
  330. }
  331. }
  332. /**
  333. * Focus URL input
  334. */
  335. focusUrlInput() {
  336. const urlInput = document.getElementById('urlInput');
  337. if (urlInput) {
  338. urlInput.focus();
  339. }
  340. }
  341. /**
  342. * Save current focus for restoration
  343. */
  344. saveFocus() {
  345. const activeElement = document.activeElement;
  346. if (activeElement && activeElement !== document.body) {
  347. this.focusHistory.push(activeElement);
  348. }
  349. }
  350. /**
  351. * Restore previously saved focus
  352. */
  353. restoreFocus() {
  354. if (this.focusHistory.length > 0) {
  355. const elementToFocus = this.focusHistory.pop();
  356. if (elementToFocus && document.contains(elementToFocus)) {
  357. elementToFocus.focus();
  358. return true;
  359. }
  360. }
  361. return false;
  362. }
  363. /**
  364. * Get list of available keyboard shortcuts
  365. */
  366. getShortcutList() {
  367. const shortcuts = [];
  368. for (const [key, handler] of this.shortcuts) {
  369. shortcuts.push({
  370. key,
  371. description: handler.description
  372. });
  373. }
  374. return shortcuts;
  375. }
  376. /**
  377. * Announce available shortcuts
  378. */
  379. announceShortcuts() {
  380. const shortcuts = this.getShortcutList();
  381. const shortcutText = shortcuts.map(s => `${s.key}: ${s.description}`).join(', ');
  382. if (window.accessibilityManager) {
  383. window.accessibilityManager.announce(`Available shortcuts: ${shortcutText}`);
  384. }
  385. }
  386. /**
  387. * Get keyboard navigation instance (singleton)
  388. */
  389. static getInstance() {
  390. if (!KeyboardNavigation.instance) {
  391. KeyboardNavigation.instance = new KeyboardNavigation();
  392. }
  393. return KeyboardNavigation.instance;
  394. }
  395. }
  396. // Export for use in other modules
  397. window.KeyboardNavigation = KeyboardNavigation;