diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9680759f8bf1ad76a9591e352051c21bf74432ff..e2ebb76e53ccb689a8f2447d29a9b931991b9e98 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Improve calculator's tooltip accessibility. Add possibility to navigate + through the hints via arrow keys. BLD-533. + LMS: Add feature for providing background grade report generation via Celery instructor task, with reports uploaded to S3. Feature is visible on the beta instructor dashboard. LMS-58 diff --git a/lms/static/coffee/fixtures/calculator.html b/lms/static/coffee/fixtures/calculator.html index 17d163eb67159094948383e96f36e6e66f43a6a4..638dcc0b1f0c98d16401f40d1976829eacc1d612 100644 --- a/lms/static/coffee/fixtures/calculator.html +++ b/lms/static/coffee/fixtures/calculator.html @@ -6,8 +6,11 @@ <div class="input-wrapper"> <input type="text" id="calculator_input" tabindex="-1" /> <div class="help-wrapper"> - <a id="calculator_hint" href="#" role="button" aria-haspopup="true" aria-controls="calculator_input_help" aria-expanded="false" tabindex="-1">Hints</a> - <div id="calculator_input_help" class="help" role="tooltip" aria-hidden="true"></div> + <a id="calculator_hint" href="#" role="button" aria-haspopup="true" tabindex="-1">Hints</a> + <ul id="calculator_input_help" class="help" aria-activedescendant="hint-integers" role="tooltip" aria-hidden="true"> + <li class="hint-item" id="hint-integers" tabindex="-1"><p><span class="bold">Integers:</span> 2520</p></li> + <li class="hint-item" id="hint-decimals" tabindex="-1"><p><span class="bold">Decimals:</span> 3.14 or .98</p></li> + </ul> </div> </div> <input id="calculator_button" type="submit" title="Calculate" arial-label="Calculate" value="=" tabindex="-1" /> diff --git a/lms/static/coffee/spec/calculator_spec.coffee b/lms/static/coffee/spec/calculator_spec.coffee index 8e41ebcb3b78a572012d31e075ae8ae80ce6ee0a..ed10bb26a605de44631ad2e111a7678273d4676c 100644 --- a/lms/static/coffee/spec/calculator_spec.coffee +++ b/lms/static/coffee/spec/calculator_spec.coffee @@ -1,4 +1,16 @@ describe 'Calculator', -> + + KEY = + TAB : 9 + ENTER : 13 + ALT : 18 + ESC : 27 + SPACE : 32 + LEFT : 37 + UP : 38 + RIGHT : 39 + DOWN : 40 + beforeEach -> loadFixtures 'coffee/fixtures/calculator.html' @calculator = new Calculator @@ -9,15 +21,14 @@ describe 'Calculator', -> it 'bind the help button', -> # These events are bind by $.hover() - expect($('div.help-wrapper a')).toHandle 'mouseover' - expect($('div.help-wrapper a')).toHandle 'mouseout' - expect($('div.help-wrapper')).toHandle 'focusin' - expect($('div.help-wrapper')).toHandle 'focusout' + expect($('#calculator_hint')).toHandle 'mouseover' + expect($('#calculator_hint')).toHandle 'mouseout' + expect($('#calculator_hint')).toHandle 'keydown' it 'prevent default behavior on help button', -> - $('div.help-wrapper a').click (e) -> + $('#calculator_hint').click (e) -> expect(e.isDefaultPrevented()).toBeTruthy() - $('div.help-wrapper a').click() + $('#calculator_hint').click() it 'bind the calculator submit', -> expect($('form#calculator')).toHandleWith 'submit', @calculator.calculate @@ -51,30 +62,261 @@ describe 'Calculator', -> @calculator.toggle(jQuery.Event("click")) expect($('.calc')).not.toHaveClass('closed') - describe 'helpShow', -> + describe 'showHint', -> it 'show the help overlay', -> - @calculator.helpShow() + @calculator.showHint() expect($('.help')).toHaveClass('shown') expect($('.help')).toHaveAttr('aria-hidden', 'false') - describe 'helpHide', -> + + describe 'hideHint', -> it 'show the help overlay', -> - @calculator.helpHide() + @calculator.hideHint() expect($('.help')).not.toHaveClass('shown') expect($('.help')).toHaveAttr('aria-hidden', 'true') - describe 'handleKeyDown', -> - it 'on pressing Esc the hint becomes hidden', -> - @calculator.helpShow() - e = jQuery.Event('keydown', { which: 27 } ); + describe 'handleClickOnDocument', -> + it 'on click out of the hint popup it becomes hidden', -> + @calculator.showHint() + e = jQuery.Event('click'); $(document).trigger(e); expect($('.help')).not.toHaveClass 'shown' - it 'On pressing other buttons the hint continue to show', -> - @calculator.helpShow() - e = jQuery.Event('keydown', { which: 32 } ); - $(document).trigger(e); - expect($('.help')).toHaveClass 'shown' + describe 'selectHint', -> + it 'select correct hint item', -> + spyOn($.fn, 'focus') + element = $('.hint-item').eq(1) + @calculator.selectHint(element) + + expect(element.focus).toHaveBeenCalled() + expect(@calculator.activeHint).toEqual(element) + expect(@calculator.hintPopup).toHaveAttr('aria-activedescendant', element.attr('id')) + + it 'select the first hint if argument element is not passed', -> + @calculator.selectHint() + expect(@calculator.activeHint.attr('id')).toEqual($('.hint-item').first().attr('id')) + + it 'select the first hint if argument element is empty', -> + @calculator.selectHint([]) + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').first().attr('id')) + + describe 'prevHint', -> + + it 'Prev hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + it 'Prev hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + it 'if this was the first item, select the last one', -> + @calculator.activeHint = $('.hint-item').eq(0) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(1).attr('id')) + + describe 'nextHint', -> + + it 'Next hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(0) + @calculator.nextHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(1).attr('id')) + + it 'If this was the last item, select the first one', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.nextHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + describe 'handleKeyDown', -> + assertHintIsHidden = (calc, key) -> + spyOn(calc, 'hideHint') + calc.showHint() + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.hideHint).toHaveBeenCalled + expect(value).toBeFalsy() + expect(e.isDefaultPrevented()).toBeTruthy() + + assertHintIsVisible = (calc, key) -> + spyOn(calc, 'showHint') + spyOn($.fn, 'focus') + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.showHint).toHaveBeenCalled + expect(value).toBeFalsy() + expect(e.isDefaultPrevented()).toBeTruthy() + expect(calc.activeHint.focus).toHaveBeenCalled() + + assertNothingHappens = (calc, key) -> + spyOn(calc, 'showHint') + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.showHint).not.toHaveBeenCalled + expect(value).toBeTruthy() + expect(e.isDefaultPrevented()).toBeFalsy() + + it 'hint popup becomes hidden on press ENTER', -> + assertHintIsHidden(@calculator, KEY.ENTER) + + it 'hint popup becomes visible on press ENTER', -> + assertHintIsVisible(@calculator, KEY.ENTER) + + it 'hint popup becomes hidden on press SPACE', -> + assertHintIsHidden(@calculator, KEY.SPACE) + + it 'hint popup becomes visible on press SPACE', -> + assertHintIsVisible(@calculator, KEY.SPACE) + + it 'Nothing happens on press ALT', -> + assertNothingHappens(@calculator, KEY.ALT) + + it 'Nothing happens on press any other button', -> + assertNothingHappens(@calculator, KEY.DOWN) + + describe 'handleKeyDownOnHint', -> + it 'Navigation works in proper way', -> + calc = @calculator + + eventToShowHint = jQuery.Event('keydown', { keyCode: KEY.ENTER } ); + $('#calculator_hint').trigger(eventToShowHint); + + spyOn(calc, 'hideHint') + spyOn(calc, 'prevHint') + spyOn(calc, 'nextHint') + spyOn($.fn, 'focus') + + cases = + left: + event: + keyCode: KEY.LEFT + shiftKey: false + returnedValue: false + called: + 'prevHint': calc + isPropagationStopped: true + + leftWithShift: + returnedValue: true + event: + keyCode: KEY.LEFT + shiftKey: true + not_called: + 'prevHint': calc + + up: + event: + keyCode: KEY.UP + shiftKey: false + returnedValue: false + called: + 'prevHint': calc + isPropagationStopped: true + + upWithShift: + returnedValue: true + event: + keyCode: KEY.UP + shiftKey: true + not_called: + 'prevHint': calc + + right: + event: + keyCode: KEY.RIGHT + shiftKey: false + returnedValue: false + called: + 'nextHint': calc + isPropagationStopped: true + + rightWithShift: + returnedValue: true + event: + keyCode: KEY.RIGHT + shiftKey: true + not_called: + 'nextHint': calc + + down: + event: + keyCode: KEY.DOWN + shiftKey: false + returnedValue: false + called: + 'nextHint': calc + isPropagationStopped: true + + downWithShift: + returnedValue: true + event: + keyCode: KEY.DOWN + shiftKey: true + not_called: + 'nextHint': calc + + tab: + returnedValue: true + event: + keyCode: KEY.TAB + shiftKey: false + called: + 'hideHint': calc + + esc: + returnedValue: false + event: + keyCode: KEY.ESC + shiftKey: false + called: + 'hideHint': calc + 'focus': $.fn + isPropagationStopped: true + + alt: + returnedValue: true + event: + which: KEY.ALT + not_called: + 'hideHint': calc + 'nextHint': calc + 'prevHint': calc + + $.each(cases, (key, data) -> + calc.hideHint.reset() + calc.prevHint.reset() + calc.nextHint.reset() + $.fn.focus.reset() + + e = jQuery.Event('keydown', data.event or {}); + value = calc.handleKeyDownOnHint(e) + + if data.called + $.each(data.called, (method, obj) -> + expect(obj[method]).toHaveBeenCalled() + ) + + if data.not_called + $.each(data.not_called, (method, obj) -> + expect(obj[method]).not.toHaveBeenCalled() + ) + + if data.isPropagationStopped + expect(e.isPropagationStopped()).toBeTruthy() + else + expect(e.isPropagationStopped()).toBeFalsy() + + expect(value).toBe(data.returnedValue) + ) describe 'calculate', -> beforeEach -> diff --git a/lms/static/coffee/src/calculator.coffee b/lms/static/coffee/src/calculator.coffee index c54a235581bf55d037d0d19d556bc521c0ef31f4..230ff5e922592728c55bbd9b7937593551407de7 100644 --- a/lms/static/coffee/src/calculator.coffee +++ b/lms/static/coffee/src/calculator.coffee @@ -1,21 +1,48 @@ +# Keyboard Support + +# If focus is on the hint button: +# * Enter: Open or close hint popup. Select last focused hint item if opening +# * Space: Open or close hint popup. Select last focused hint item if opening + +# If focus is on a hint item: +# * Left arrow: Select previous hint item +# * Up arrow: Select previous hint item +# * Right arrow: Select next hint item +# * Down arrow: Select next hint item + + class @Calculator constructor: -> + @hintButton = $('#calculator_hint') + @hintPopup = $('.help') + @hintsList = @hintPopup.find('.hint-item') + @selectHint($('#' + @hintPopup.attr('aria-activedescendant'))); + $('.calc').click @toggle $('form#calculator').submit(@calculate).submit (e) -> e.preventDefault() - $('div.help-wrapper a') + @hintButton .hover( - $.proxy(@helpShow, @), - $.proxy(@helpHide, @) + $.proxy(@showHint, @), + $.proxy(@hideHint, @) ) - .click (e) -> - e.preventDefault() + .keydown($.proxy(@handleKeyDown, @)) + .click (e) -> e.preventDefault() + + @hintPopup + .keydown($.proxy(@handleKeyDownOnHint, @)) - $(document).keydown $.proxy(@handleKeyDown, @) + @handleClickOnDocument = $.proxy(@handleClickOnDocument, @) - $('div.help-wrapper') - .focusin($.proxy @helpOnFocus, @) - .focusout($.proxy @helpOnBlur, @) + KEY: + TAB : 9 + ENTER : 13 + ESC : 27 + SPACE : 32 + LEFT : 37 + UP : 38 + RIGHT : 39 + DOWN : 40 toggle: (event) -> event.preventDefault() @@ -49,32 +76,110 @@ class @Calculator $calc.toggleClass 'closed' - helpOnFocus: (e) -> - e.preventDefault() - @isFocusedHelp = true - @helpShow() - - helpOnBlur: (e) -> - e.preventDefault() - @isFocusedHelp = false - @helpHide() - - helpShow: -> - $('.help') + showHint: -> + @hintPopup .addClass('shown') .attr('aria-hidden', false) - helpHide: -> - if not @isFocusedHelp - $('.help') - .removeClass('shown') - .attr('aria-hidden', true) + $(document).on('click', @handleClickOnDocument) + + hideHint: -> + @hintPopup + .removeClass('shown') + .attr('aria-hidden', true) + + $(document).off('click', @handleClickOnDocument) + + selectHint: (element) -> + if not element or (element and element.length == 0) + element = @hintsList.first() + + @activeHint = element; + @activeHint.focus(); + @hintPopup.attr('aria-activedescendant', element.attr('id')); + + prevHint: () -> + prev = @activeHint.prev(); # the previous hint + # if this was the first item + # select the last one in the group. + if @activeHint.index() == 0 + prev = @hintsList.last() + # select the previous hint + @selectHint(prev) + + nextHint: () -> + next = @activeHint.next(); # the next hint + # if this was the last item, + # select the first one in the group. + if @activeHint.index() == @hintsList.length - 1 + next = @hintsList.first() + # give the next hint focus + @selectHint(next) handleKeyDown: (e) -> - ESC = 27 - if e.which is ESC and $('.help').hasClass 'shown' - @isFocusedHelp = false - @helpHide() + if e.altKey + # do nothing + return true + + if e.keyCode == @KEY.ENTER or e.keyCode == @KEY.SPACE + if @hintPopup.hasClass 'shown' + @hideHint() + else + @showHint() + @activeHint.focus() + + e.preventDefault() + return false + + # allow the event to propagate + return true + + handleKeyDownOnHint: (e) -> + if e.altKey + # do nothing + return true + + switch e.keyCode + when @KEY.TAB + # hide popup with hints + @hideHint() + + when @KEY.ESC + # hide popup with hints + @hideHint() + @hintButton.focus() + + e.stopPropagation() + return false + + when @KEY.LEFT, @KEY.UP + if e.shiftKey + # do nothing + return true + + @prevHint() + + e.stopPropagation() + return false + + when @KEY.RIGHT, @KEY.DOWN + if e.shiftKey + # do nothing + return true + + @nextHint() + + e.stopPropagation() + return false + + # allow the event to propagate + return true + + handleClickOnDocument: (e) -> + @hideHint() + + # allow the event to propagate + return true; calculate: -> $.getWithPrefix '/calculate', { equation: $('#calculator_input').val() }, (data) -> diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index 274d8a00c6150a8c3555ad18ad16c563ef683050..b9a5d286effd20f0c9988a70dc6e31ed1461abbd 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -112,15 +112,20 @@ div.calc-main { right: 0; top: 0; - a { + #calculator_hint { background: url("../images/info-icon.png") center center no-repeat; height: 35px; @include hide-text; width: 35px; - display: block; + display: block; + + &:focus { + outline: 5px auto #5b9dd9; + } } .help { + @include transition(none); background: #fff; border-radius: 3px; box-shadow: 0 0 3px #999; @@ -129,11 +134,12 @@ div.calc-main { position: absolute; right: -40px; bottom: 57px; - @include transition(none); width: 600px; overflow: hidden; pointer-events: none; display: none; + margin: 0; + list-style: none; &.shown { display: block; diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 80b52b9d3648cdc8d144b09a8a782f982319fc97..9a6a197de0a10f80c7a86fc3294afd966b0f89b0 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -218,13 +218,14 @@ ${fragment.foot_html()} <input type="text" id="calculator_input" title="${_('Calculator Input Field')}" tabindex="-1" /> <div class="help-wrapper"> - <a id="calculator_hint" href="#" role="button" aria-describedby="calculator_input_help" tabindex="-1">${_("Hints")}</a> - <div id="calculator_input_help" class="help" role="tooltip" aria-hidden="true"> - <p><span class="bold">${_("Integers")}:</span> 2520</p> - <p><span class="bold">${_("Decimals")}:</span> 3.14 or .98</p> - <p><span class="bold">${_("Scientific notation")}:</span> 1.2e-2 (=0.012), -4.4e+5 = -4.4e5 (=-440,000)</p> - <p><span class="bold">${_("Appending SI postfixes")}:</span> 2.25k (=2,250)</p> - <p><span class="bold">${_("Supported SI postfixes")}:</span></p> + <p class="sr" id="hint-instructions">${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}</p> + <a id="calculator_hint" href="#" role="button" aria-haspopup="true" tabindex="-1" aria-describedby="hint-instructions">${_("Hints")}</a> + <ul id="calculator_input_help" class="help" aria-activedescendant="hint-integers" role="tooltip" aria-hidden="true"> + <li class="hint-item" id="hint-integers" tabindex="-1"><p><span class="bold">${_("Integers")}:</span> 2520</p></li> + <li class="hint-item" id="hint-decimals" tabindex="-1"><p><span class="bold">${_("Decimals")}:</span> 3.14 or .98</p></li> + <li class="hint-item" id="hint-scientific-notation" tabindex="-1"><p><span class="bold">${_("Scientific notation")}:</span> 1.2e-2 (=0.012), -4.4e+5 = -4.4e5 (=-440,000)</p></li> + <li class="hint-item" id="hint-appending-postfixes" tabindex="-1"><p><span class="bold">${_("Appending SI postfixes")}:</span> 2.25k (=2,250)</p></li> + <li class="hint-item" id="hint-supported-postfixes" tabindex="-1"><p><span class="bold">${_("Supported SI postfixes")}:</span></p> <table class="calc-postfixes"> <tbody> <tr> @@ -279,49 +280,51 @@ ${fragment.foot_html()} </tr> </tbody> </table> - <p><span class="bold">${_("Operators")}:</span> + - * / ^ and || (${_("parallel resistors function")})</p> - <p><span class="bold">${_("Functions")}:</span> sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs, fact/factorial</p> - <p><span class="bold">${_("Constants")}:</span></p> - <table> - <tbody> - <tr> - <td>j</td> - <td>=</td> - <td>sqrt(-1)</td> - </tr> - <tr> - <td>e</td> - <td>=</td> - <td>${_("Euler's number")}</td> - </tr> - <tr> - <td>pi</td> - <td>=</td> - <td>${_("ratio of a circle's circumference to it's diameter")}</td> - </tr> - <tr> - <td>k</td> - <td>=</td> - <td>${_("Boltzmann constant")}</td> - </tr> - <tr> - <td>c</td> - <td>=</td> - <td>${_("speed of light")}</td> - </tr> - <tr> - <td>T</td> - <td>=</td> - <td>${_("freezing point of water in degrees Kelvin")}</td> - </tr> - <tr> - <td>q</td> - <td>=</td> - <td>${_("fundamental charge")}</td> - </tr> - </tbody> - </table> - </div> + </li> + <li class="hint-item" id="hint-operators" tabindex="-1"><p><span class="bold">${_("Operators")}:</span> + - * / ^ and || (${_("parallel resistors function")})</p></li> + <li class="hint-item" id="hint-functions" tabindex="-1"><p><span class="bold">${_("Functions")}:</span> sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs, fact/factorial</p></li> + <li class="hint-item" id="hint-constants" tabindex="-1"><p><span class="bold">${_("Constants")}:</span></p> + <table> + <tbody> + <tr> + <td>j</td> + <td>=</td> + <td>sqrt(-1)</td> + </tr> + <tr> + <td>e</td> + <td>=</td> + <td>${_("Euler's number")}</td> + </tr> + <tr> + <td>pi</td> + <td>=</td> + <td>${_("ratio of a circle's circumference to it's diameter")}</td> + </tr> + <tr> + <td>k</td> + <td>=</td> + <td>${_("Boltzmann constant")}</td> + </tr> + <tr> + <td>c</td> + <td>=</td> + <td>${_("speed of light")}</td> + </tr> + <tr> + <td>T</td> + <td>=</td> + <td>${_("freezing point of water in degrees Kelvin")}</td> + </tr> + <tr> + <td>q</td> + <td>=</td> + <td>${_("fundamental charge")}</td> + </tr> + </tbody> + </table> + </li> + </ul> </div> </div> <input id="calculator_button" type="submit" title="${_('Calculate')}" value="=" aria-label="${_('Calculate')}" value="=" tabindex="-1" />