diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index d133426fb5..2dce290a96 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -525,12 +525,19 @@ div.video { margin-bottom: 8px; padding: 0; line-height: lh(); + outline-width: 0px; + outline-style: none; &.current { color: #333; font-weight: 700; } + &.focused { + outline-width: 1px; + outline-style: dotted; + } + &:hover { color: $blue; } diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 7751b80e51..2273e4be71 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -93,6 +93,7 @@ $('.subtitles li[data-index]').each(function(index, link) { expect($(link)).toHaveData('index', index); expect($(link)).toHaveData('start', captionsData.start[index]); + expect($(link)).toHaveAttr('tabindex', 0); expect($(link)).toHaveText(captionsData.text[index]); }); }); @@ -104,7 +105,13 @@ it('bind all the caption link', function() { $('.subtitles li[data-index]').each(function(index, link) { - expect($(link)).toHandleWith('click', videoCaption.seekPlayer); + expect($(link)).toHandleWith('mouseover', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mouseout', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mousedown', videoCaption.captionMouseDown); + expect($(link)).toHandleWith('click', videoCaption.captionClick); + expect($(link)).toHandleWith('focus', videoCaption.captionFocus); + expect($(link)).toHandleWith('blur', videoCaption.captionBlur); + expect($(link)).toHandleWith('keydown', videoCaption.captionKeyDown); }); }); @@ -278,6 +285,7 @@ $('.subtitles li[data-index]').each(function(index, link) { expect($(link)).toHaveData('index', index); expect($(link)).toHaveData('start', captionsData.start[index]); + expect($(link)).toHaveAttr('tabindex', 0); expect($(link)).toHaveText(captionsData.text[index]); }); }); @@ -289,7 +297,13 @@ it('bind all the caption link', function() { $('.subtitles li[data-index]').each(function(index, link) { - expect($(link)).toHandleWith('click', videoCaption.seekPlayer); + expect($(link)).toHandleWith('mouseover', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mouseout', videoCaption.captionMouseOverOut); + expect($(link)).toHandleWith('mousedown', videoCaption.captionMouseDown); + expect($(link)).toHandleWith('click', videoCaption.captionClick); + expect($(link)).toHandleWith('focus', videoCaption.captionFocus); + expect($(link)).toHandleWith('blur', videoCaption.captionBlur); + expect($(link)).toHandleWith('keydown', videoCaption.captionKeyDown); }); }); @@ -558,6 +572,99 @@ }); }); }); + + describe('caption accessibility', function() { + beforeEach(function() { + initialize(); + }); + + describe('when getting focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + }); + + it('shows an outline around the caption', function() { + expect($('.subtitles li[data-index=0]')).toHaveClass('focused'); + }); + + it('has automatic scrolling disabled', function() { + expect(videoCaption.autoScrolling).toBe(false); + }); + }); + + describe('when loosing focus through TAB key', function() { + beforeEach(function() { + $('.subtitles li[data-index=0]').trigger(jQuery.Event('blur')); + }); + + it('does not show an outline around the caption', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + describe('when same caption gets the focus through mouse after having focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + $('.subtitles li[data-index=0]').trigger(jQuery.Event('mousedown')); + }); + + it('does not show an outline around it', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + describe('when a second caption gets focus through mouse after first had focus through TAB key', function() { + beforeEach(function() { + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + $('.subtitles li[data-index=0]').trigger(jQuery.Event('blur')); + videoCaption.isMouseFocus = true; + $('.subtitles li[data-index=1]').trigger(jQuery.Event('mousedown')); + }); + + it('does not show an outline around the first', function() { + expect($('.subtitles li[data-index=0]')).not.toHaveClass('focused'); + }); + + it('does not show an outline around the second', function() { + expect($('.subtitles li[data-index=1]')).not.toHaveClass('focused'); + }); + + it('has automatic scrolling enabled', function() { + expect(videoCaption.autoScrolling).toBe(true); + }); + }); + + describe('when enter key is pressed on a caption', function() { + beforeEach(function() { + var e; + spyOn(videoCaption, 'seekPlayer').andCallThrough(); + videoCaption.isMouseFocus = false; + $('.subtitles li[data-index=0]').trigger(jQuery.Event('focus')); + e = jQuery.Event('keydown'); + e.which = 13; // ENTER key + $('.subtitles li[data-index=0]').trigger(e); + }); + + it('shows an outline around it', function() { + expect($('.subtitles li[data-index=0]')).toHaveClass('focused'); + }); + + it('calls seekPlayer', function() { + expect(videoCaption.seekPlayer).toHaveBeenCalled(); + }); + }); + }); }); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index 2ebb73c692..3dc675b4c2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -37,31 +37,37 @@ function () { // Functions which will be accessible via 'state' object. When called, these functions will // get the 'state' object as a context. function _makeFunctionsPublic(state) { - state.videoCaption.autoShowCaptions = _.bind(autoShowCaptions, state); - state.videoCaption.autoHideCaptions = _.bind(autoHideCaptions, state); - state.videoCaption.resize = _.bind(resize, state); - state.videoCaption.toggle = _.bind(toggle, state); - state.videoCaption.onMouseEnter = _.bind(onMouseEnter, state); - state.videoCaption.onMouseLeave = _.bind(onMouseLeave, state); - state.videoCaption.onMovement = _.bind(onMovement, state); - state.videoCaption.renderCaption = _.bind(renderCaption, state); - state.videoCaption.captionHeight = _.bind(captionHeight, state); - state.videoCaption.topSpacingHeight = _.bind(topSpacingHeight, state); - state.videoCaption.bottomSpacingHeight = _.bind(bottomSpacingHeight, state); - state.videoCaption.scrollCaption = _.bind(scrollCaption, state); - state.videoCaption.search = _.bind(search, state); - state.videoCaption.play = _.bind(play, state); - state.videoCaption.pause = _.bind(pause, state); - state.videoCaption.seekPlayer = _.bind(seekPlayer, state); - state.videoCaption.hideCaptions = _.bind(hideCaptions, state); - state.videoCaption.calculateOffset = _.bind(calculateOffset, state); - state.videoCaption.updatePlayTime = _.bind(updatePlayTime, state); - state.videoCaption.setSubtitlesHeight = _.bind(setSubtitlesHeight, state); + state.videoCaption.autoShowCaptions = _.bind(autoShowCaptions, state); + state.videoCaption.autoHideCaptions = _.bind(autoHideCaptions, state); + state.videoCaption.resize = _.bind(resize, state); + state.videoCaption.toggle = _.bind(toggle, state); + state.videoCaption.onMouseEnter = _.bind(onMouseEnter, state); + state.videoCaption.onMouseLeave = _.bind(onMouseLeave, state); + state.videoCaption.onMovement = _.bind(onMovement, state); + state.videoCaption.renderCaption = _.bind(renderCaption, state); + state.videoCaption.captionHeight = _.bind(captionHeight, state); + state.videoCaption.topSpacingHeight = _.bind(topSpacingHeight, state); + state.videoCaption.bottomSpacingHeight = _.bind(bottomSpacingHeight, state); + state.videoCaption.scrollCaption = _.bind(scrollCaption, state); + state.videoCaption.search = _.bind(search, state); + state.videoCaption.play = _.bind(play, state); + state.videoCaption.pause = _.bind(pause, state); + state.videoCaption.seekPlayer = _.bind(seekPlayer, state); + state.videoCaption.hideCaptions = _.bind(hideCaptions, state); + state.videoCaption.calculateOffset = _.bind(calculateOffset, state); + state.videoCaption.updatePlayTime = _.bind(updatePlayTime, state); + state.videoCaption.setSubtitlesHeight = _.bind(setSubtitlesHeight, state); - state.videoCaption.renderElements = _.bind(renderElements, state); - state.videoCaption.bindHandlers = _.bind(bindHandlers, state); - state.videoCaption.fetchCaption = _.bind(fetchCaption, state); - state.videoCaption.captionURL = _.bind(captionURL, state); + state.videoCaption.renderElements = _.bind(renderElements, state); + state.videoCaption.bindHandlers = _.bind(bindHandlers, state); + state.videoCaption.fetchCaption = _.bind(fetchCaption, state); + state.videoCaption.captionURL = _.bind(captionURL, state); + state.videoCaption.captionMouseOverOut = _.bind(captionMouseOverOut, state); + state.videoCaption.captionMouseDown = _.bind(captionMouseDown, state); + state.videoCaption.captionClick = _.bind(captionClick, state); + state.videoCaption.captionFocus = _.bind(captionFocus, state); + state.videoCaption.captionBlur = _.bind(captionBlur, state); + state.videoCaption.captionKeyDown = _.bind(captionKeyDown, state); } // *************************************************************** @@ -309,7 +315,8 @@ function () { liEl.attr({ 'data-index': index, - 'data-start': _this.videoCaption.start[index] + 'data-start': _this.videoCaption.start[index], + 'tabindex': 0 }); container.append(liEl); @@ -317,7 +324,33 @@ function () { this.videoCaption.subtitlesEl.html(container.html()); - this.videoCaption.subtitlesEl.find('li[data-index]').on('click', this.videoCaption.seekPlayer); + this.videoCaption.subtitlesEl.find('li[data-index]').on({ + mouseover: this.videoCaption.captionMouseOverOut, + mouseout: this.videoCaption.captionMouseOverOut, + mousedown: this.videoCaption.captionMouseDown, + click: this.videoCaption.captionClick, + focus: this.videoCaption.captionFocus, + blur: this.videoCaption.captionBlur, + keydown: this.videoCaption.captionKeyDown + }); + + // Enables or disables automatic scrolling of the captions when the + // video is playing. This feature has to be disabled when tabbing + // through them as it interferes with that action. Initially, have this + // flag enabled as we assume mouse use. Then, if the first caption + // (through forward tabbing) or the last caption (through backwards + // tabbing) gets the focus, disable that feature. Renable it if tabbing + // then cycles out of the the captions. + this.videoCaption.autoScrolling = true; + // Keeps track of where the focus is situated in the array of captions. + // Used to implement the automatic scrolling behavior and decide if the + // outline around a caption has to be hidden or shown on a mouseenter or + // mouseleave. + this.videoCaption.currentCaptionIndex = 0; + // Used to track if the focus is coming from a click or tabbing. This + // has to be known to decide if, when a caption gets the focus, an + // outline has to be drawn (tabbing) or not (mouse click). + this.videoCaption.isMouseFocus = false; this.videoCaption.subtitlesEl.prepend($('