Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion xmodule/assets/video/public/js/09_video_caption.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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});
Expand All @@ -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.
*
Expand Down
81 changes: 81 additions & 0 deletions xmodule/js/spec/video/video_caption_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
11 changes: 11 additions & 0 deletions xmodule/static/css-builtin-blocks/VideoBlockDisplay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down