diff --git a/xmodule/assets/video/public/js/09_video_caption.js b/xmodule/assets/video/public/js/09_video_caption.js index 309abb70e8ea..301ba75a7c5a 100644 --- a/xmodule/assets/video/public/js/09_video_caption.js +++ b/xmodule/assets/video/public/js/09_video_caption.js @@ -35,7 +35,8 @@ let VideoCaption = function(state) { 'previousLanguageMenuItem', 'nextLanguageMenuItem', 'handleCaptionToggle', 'showClosedCaptions', 'hideClosedCaptions', 'toggleClosedCaptions', 'updateCaptioningCookie', 'handleCaptioningCookie', 'handleTranscriptToggle', - 'listenForDragDrop', 'setTranscriptVisibility', 'updateTranscriptCookie', + 'listenForDragDrop', 'handleCaptionKeydown', 'setTranscriptVisibility', + 'updateTranscriptCookie', 'updateGoogleDisclaimer', 'toggleGoogleDisclaimer', 'updateProblematicCaptionsContent' ); @@ -64,6 +65,9 @@ VideoCaption.prototype = { destroy: this.destroy }) .removeClass('is-captions-rendered'); + if (this.captionDisplayEl) { + this.captionDisplayEl.off('keydown', this.handleCaptionKeydown); + } if (this.fetchXHR && this.fetchXHR.abort) { this.fetchXHR.abort(); } @@ -1353,6 +1357,20 @@ VideoCaption.prototype = { listenForDragDrop: function() { let captions = this.captionDisplayEl['0']; + // Make the caption overlay focusable and describe its behavior to + // assistive technology. WCAG 2.1.1: provide a keyboard alternative to + // the mouse-only Draggabilly repositioning. See issue #36800. + this.captionDisplayEl.attr({ + tabindex: '0', + role: 'region', + 'aria-label': gettext( + 'Closed captions. Use the arrow keys to reposition. ' + + 'Hold Shift for larger steps. Press Home to reset.' + ) + }); + + this.captionDisplayEl.on('keydown', this.handleCaptionKeydown); + if (typeof Draggabilly === 'function') { // eslint-disable-next-line no-new new Draggabilly(captions, {containment: true}); @@ -1361,6 +1379,59 @@ VideoCaption.prototype = { } }, + /** + * @desc Reposition the closed-caption overlay with the keyboard. + * Arrow keys nudge by 10px (or 40px with Shift); Home resets to the + * default CSS position. Mirrors Draggabilly's containment behavior. + * WCAG 2.1.1 keyboard alternative to Draggabilly. See issue #36800. + * + * @param {jQuery.Event} event - keydown event on .closed-captions + */ + handleCaptionKeydown: function(event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode, + step = event.shiftKey ? 40 : 10, + el = this.captionDisplayEl, + parent = el.parent(), + pos = el.position(), + left = pos.left, + top = pos.top, + maxLeft = parent.width() - el.outerWidth(), + maxTop = parent.height() - el.outerHeight(), + handled = true; + + switch (keyCode) { + case KEY.LEFT: + left -= step; + break; + case KEY.RIGHT: + left += step; + break; + case KEY.UP: + top -= step; + break; + case KEY.DOWN: + top += step; + break; + case KEY.HOME: + el.css({left: '', top: ''}); + event.preventDefault(); + return; + default: + handled = false; + } + + if (!handled) { + return; + } + + event.preventDefault(); + el.css({ + left: Math.max(0, Math.min(left, maxLeft)) + 'px', + top: Math.max(0, Math.min(top, maxTop)) + 'px' + }); + }, + /** * @desc Shows/Hides the transcript panel. * diff --git a/xmodule/js/spec/video/video_caption_spec.js b/xmodule/js/spec/video/video_caption_spec.js index 757267a3a587..4340ef90ce1b 100644 --- a/xmodule/js/spec/video/video_caption_spec.js +++ b/xmodule/js/spec/video/video_caption_spec.js @@ -1374,5 +1374,86 @@ expect(Caption.shouldShowGoogleDisclaimer).toBe(true); }); }); + + describe('captionKeyboardDragDrop (bug #36800)', function() { + var keydownEvent = function(key, shiftKey) { + return $.Event('keydown', {keyCode: key, shiftKey: !!shiftKey}); + }; + + beforeEach(function() { + state = jasmine.initializePlayer(); + // Make captions visible so listenForDragDrop's attrs are applied. + $('.toggle-captions').click(); + }); + + it('unit: marks the caption overlay as focusable (WCAG 2.1.1)', function() { + var $cc = $('.closed-captions'); + expect($cc).toHaveAttr('tabindex', '0'); + expect($cc).toHaveAttr('role', 'region'); + expect($cc.attr('aria-label')).toMatch(/arrow keys/i); + }); + + it('unit: registers a keydown handler on the caption overlay', function() { + var events = $._data($('.closed-captions')[0], 'events'); + expect(events && events.keydown).toBeTruthy(); + }); + + it('integration: nudges captions on arrow keys by 10px', function() { + var $cc = $('.closed-captions'); + $cc.css({position: 'absolute', left: '100px', top: '100px'}); + + $cc.trigger(keydownEvent($.ui.keyCode.RIGHT)); + expect(parseInt($cc.css('left'), 10)).toBe(110); + + $cc.trigger(keydownEvent($.ui.keyCode.LEFT)); + expect(parseInt($cc.css('left'), 10)).toBe(100); + + $cc.trigger(keydownEvent($.ui.keyCode.DOWN)); + expect(parseInt($cc.css('top'), 10)).toBe(110); + + $cc.trigger(keydownEvent($.ui.keyCode.UP)); + expect(parseInt($cc.css('top'), 10)).toBe(100); + }); + + it('integration: uses a 40px step when Shift is held', function() { + var $cc = $('.closed-captions'); + $cc.css({position: 'absolute', left: '100px', top: '100px'}); + + $cc.trigger(keydownEvent($.ui.keyCode.RIGHT, true)); + expect(parseInt($cc.css('left'), 10)).toBe(140); + }); + + it('integration: clamps position to the containing player', function() { + var $cc = $('.closed-captions'); + $cc.css({position: 'absolute', left: '0px', top: '0px'}); + + $cc.trigger(keydownEvent($.ui.keyCode.LEFT)); + expect(parseInt($cc.css('left'), 10) || 0).toBe(0); + + $cc.trigger(keydownEvent($.ui.keyCode.UP)); + expect(parseInt($cc.css('top'), 10) || 0).toBe(0); + }); + + it('integration: resets position when Home is pressed', function() { + var $cc = $('.closed-captions'); + $cc.css({left: '200px', top: '200px'}); + + $cc.trigger(keydownEvent($.ui.keyCode.HOME)); + expect($cc[0].style.left).toBe(''); + expect($cc[0].style.top).toBe(''); + }); + + it('bug_36800 regression: calls preventDefault on handled keys only', function() { + var ev = keydownEvent($.ui.keyCode.RIGHT); + spyOn(ev, 'preventDefault'); + $('.closed-captions').trigger(ev); + expect(ev.preventDefault).toHaveBeenCalled(); + + var tabEv = keydownEvent($.ui.keyCode.TAB); + spyOn(tabEv, 'preventDefault'); + $('.closed-captions').trigger(tabEv); + expect(tabEv.preventDefault).not.toHaveBeenCalled(); + }); + }); }); }).call(this); diff --git a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css index 892c99429531..e2ecde58ee2e 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css @@ -277,6 +277,17 @@ opacity: 1; } +/* + * Keyboard focus indicator for the draggable closed-captions overlay. + * WCAG 2.1.1 keyboard accessibility (see issue #36800). + */ +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:focus-visible { + background: black; + outline: 2px solid var(--yellow, #e2c01f); + outline-offset: 2px; +} + .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player { overflow: hidden; min-height: 158px;