From 7cb495a1667ec666956191f36090d031cde6d095 Mon Sep 17 00:00:00 2001 From: Jean-Michel Claus Date: Thu, 15 Jan 2015 22:03:58 +0100 Subject: [PATCH] TNL 713 (TNL-714, TNL-717, TNL-718): Added accessibility plugin. --- common/templates/edxnotes_wrapper.html | 1 + .../js/edxnotes/plugins/accessibility.js | 320 ++++++++++++++++++ lms/static/js/edxnotes/plugins/scroller.js | 13 +- lms/static/js/edxnotes/views/notes_factory.js | 4 +- lms/static/js/edxnotes/views/shim.js | 133 ++++++-- .../js/edxnotes/views/toggle_notes_factory.js | 23 +- .../js/fixtures/edxnotes/toggle_notes.html | 2 +- .../edxnotes/plugins/accessibility_spec.js | 318 +++++++++++++++++ .../js/spec/edxnotes/views/shim_spec.js | 14 + .../views/toggle_notes_factory_spec.js | 15 +- lms/static/js/spec/main.js | 1 + .../sass/course/modules/_student-notes.scss | 83 ++++- lms/templates/courseware/courseware.html | 4 +- lms/templates/edxnotes/toggle_notes.html | 2 +- 14 files changed, 862 insertions(+), 71 deletions(-) create mode 100644 lms/static/js/edxnotes/plugins/accessibility.js create mode 100644 lms/static/js/spec/edxnotes/plugins/accessibility_spec.js diff --git a/common/templates/edxnotes_wrapper.html b/common/templates/edxnotes_wrapper.html index 4675d4fd59..ac5dc09ab1 100644 --- a/common/templates/edxnotes_wrapper.html +++ b/common/templates/edxnotes_wrapper.html @@ -1,4 +1,5 @@ <%! import json %> +<%! from django.utils.translation import ugettext as _ %> <%! from student.models import anonymous_id_for_user %> <% if user: diff --git a/lms/static/js/edxnotes/plugins/accessibility.js b/lms/static/js/edxnotes/plugins/accessibility.js new file mode 100644 index 0000000000..6349ecb8aa --- /dev/null +++ b/lms/static/js/edxnotes/plugins/accessibility.js @@ -0,0 +1,320 @@ +;(function (define, undefined) { +'use strict'; +define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { + /** + * Adds the Accessibility Plugin + **/ + Annotator.Plugin.Accessibility = function () { + _.bindAll(this, + 'addAriaAttributes', 'onHighlightKeyDown', 'onViewerKeyDown', + 'onEditorKeyDown', 'addDescriptions', 'removeDescription', + 'saveCurrentHighlight', 'focusOnGrabber', 'showViewer', 'onClose' + ); + // Call the Annotator.Plugin constructor this sets up the element and + // options properties. + Annotator.Plugin.apply(this, arguments); + }; + + $.extend(Annotator.Plugin.Accessibility.prototype, new Annotator.Plugin(), { + pluginInit: function () { + this.annotator.subscribe('annotationViewerTextField', this.addAriaAttributes); + this.annotator.subscribe('annotationsLoaded', this.addDescriptions); + this.annotator.subscribe('annotationCreated', this.addDescriptions); + this.annotator.subscribe('annotationDeleted', this.removeDescription); + this.annotator.element.on('keydown.accessibility.hl', '.annotator-hl', this.onHighlightKeyDown); + this.annotator.element.on('keydown.accessibility.viewer', '.annotator-viewer', this.onViewerKeyDown); + this.annotator.element.on('keydown.accessibility.editor', '.annotator-editor', this.onEditorKeyDown); + this.addFocusGrabber(); + this.addTabIndex(); + }, + + destroy: function () { + this.annotator.unsubscribe('annotationViewerTextField', this.addAriaAttributes); + this.annotator.unsubscribe('annotationsLoaded', this.addDescriptions); + this.annotator.unsubscribe('annotationCreated', this.addDescriptions); + this.annotator.unsubscribe('annotationDeleted', this.removeDescription); + this.annotator.element.off('.accessibility'); + this.removeFocusGrabber(); + this.savedHighlights = null; + }, + + addTabIndex: function () { + this.annotator.element + .find('.annotator-edit, .annotator-delete') + .attr('tabindex', 0); + }, + + addFocusGrabber: function () { + this.focusGrabber = $('', { + 'class': 'sr edx-notes-focus-grabber', + 'tabindex': '-1', + 'text': gettext('Focus grabber') + }); + this.annotator.wrapper.before(this.focusGrabber); + }, + + removeFocusGrabber: function () { + if (this.focusGrabber) { + this.focusGrabber.remove(); + this.focusGrabber = null; + } + }, + + focusOnGrabber: function () { + this.annotator.wrapper.siblings('.edx-notes-focus-grabber').focus(); + }, + + addDescriptions: function (annotations) { + if (!_.isArray(annotations)) { + annotations = [annotations]; + } + + _.each(annotations, function (annotation) { + var id = annotation.id || _.uniqueId(); + + this.annotator.wrapper.after($('
', { + 'class': 'aria-note-description sr', + 'id': 'aria-note-description-' + id, + 'text': Annotator.Util.escape(annotation.text) + })); + + $(annotation.highlights).attr({ + 'aria-describedby': 'aria-note-description-' + id + }); + }, this); + }, + + removeDescription: function (annotation) { + var id = $(annotation.highlights).attr('aria-describedby'); + $('#' + id).remove(); + }, + + addAriaAttributes: function (field, annotation) { + // Add ARIA attributes to associated note ie
My note
+ $(field).attr({ + 'tabindex': -1, + 'role': 'note', + 'class': 'annotator-note' + }); + }, + + saveCurrentHighlight: function (annotation) { + if (annotation && annotation.highlights) { + this.savedHighlights = annotation.highlights[0]; + } + }, + + focusOnHighlightedText: function () { + if (this.savedHighlights) { + this.savedHighlights.focus(); + this.savedHighlights = null; + } + }, + + getViewerTabControls: function () { + var viewer, note, viewerControls, editButton, delButton, closeButton, tabControls = []; + + // Viewer elements + viewer = this.annotator.element.find('.annotator-viewer'); + note = viewer.find('.annotator-note'); + viewerControls = viewer.find('.annotator-controls'); + editButton = viewerControls.find('.annotator-edit'); + delButton = viewerControls.find('.annotator-delete'); + closeButton = viewerControls.find('.annotator-close'); + + tabControls.push(note, editButton, delButton, closeButton); + + return tabControls; + }, + + getEditorTabControls: function () { + var editor, editorControls, textArea, saveButton, cancelButton, tabControls = []; + + // Editor elements + editor = this.annotator.element.find('.annotator-editor'); + editorControls = editor.find('.annotator-controls'); + textArea = editor.find('.annotator-listing') + .find('.annotator-item') + .first() + .children('textarea'); + saveButton = editorControls.find('.annotator-save'); + cancelButton = editorControls.find('.annotator-cancel'); + + tabControls.push(textArea, saveButton, cancelButton); + + return tabControls; + }, + + focusOnNextTabControl: function (tabControls, tabControl) { + var nextIndex; + + _.each(tabControls, function (element, index) { + if (element.is(tabControl)) { + nextIndex = index === tabControls.length - 1 ? 0 : index + 1; + tabControls[nextIndex].focus(); + } + }); + }, + + focusOnPreviousTabControl: function (tabControls, tabControl) { + var previousIndex; + _.each(tabControls, function (element, index) { + if (element.is(tabControl)) { + previousIndex = index === 0 ? tabControls.length - 1 : index - 1; + tabControls[previousIndex].focus(); + } + }); + }, + + showViewer: function (position, annotation) { + annotation = $.makeArray(annotation); + this.saveCurrentHighlight(annotation[0]); + this.annotator.showViewer(annotation, position); + this.annotator.element.find('.annotator-listing').focus(); + this.annotator.subscribe('annotationDeleted', this.focusOnGrabber); + }, + + onClose: function () { + this.focusOnHighlightedText(); + this.annotator.unsubscribe('annotationDeleted', this.focusOnGrabber); + }, + + onHighlightKeyDown: function (event) { + var KEY = $.ui.keyCode, + keyCode = event.keyCode, + target = $(event.currentTarget), + annotation, position; + + switch (keyCode) { + case KEY.TAB: + // This happens only when coming from notes page + if (this.annotator.viewer.isShown()) { + this.annotator.element.find('.annotator-listing').focus(); + } + break; + case KEY.ENTER: + case KEY.SPACE: + if (!this.annotator.viewer.isShown()) { + position = target.position(); + this.showViewer(position, target.data('annotation')); + } + break; + case KEY.ESCAPE: + this.annotator.viewer.hide(); + break; + } + // We do not stop propagation and default behavior on a TAB keypress + if (event.keyCode !== KEY.TAB || (event.keyCode === KEY.TAB && this.annotator.viewer.isShown())) { + event.preventDefault(); + event.stopPropagation(); + } + }, + + onViewerKeyDown: function (event) { + var KEY = $.ui.keyCode, + keyCode = event.keyCode, + target = $(event.target), + listing = this.annotator.element.find('.annotator-listing'), + tabControls; + + switch (keyCode) { + case KEY.TAB: + tabControls = this.getViewerTabControls(); + if (event.shiftKey) { // Tabbing backwards + if (target.is(listing)) { + _.last(tabControls).focus(); + } + else { + this.focusOnPreviousTabControl(tabControls, target); + } + } else { // Tabbing forward + if (target.is(listing)) { + _.first(tabControls).focus(); + } + else { + this.focusOnNextTabControl(tabControls, target); + } + } + event.preventDefault(); + event.stopPropagation(); + break; + case KEY.ENTER: + case KEY.SPACE: + if (target.hasClass('annotator-close')) { + this.annotator.viewer.hide(); + this.onClose(); + event.preventDefault(); + } + break; + case KEY.ESCAPE: + this.annotator.viewer.hide(); + this.onClose(); + event.preventDefault(); + break; + } + }, + + onEditorKeyDown: function (event) { + var KEY = $.ui.keyCode, + keyCode = event.keyCode, + target = $(event.target), + editor, form, editorControls, save, cancel, + tabControls; + + editor = this.annotator.element.find('.annotator-editor'); + form = editor.find('.annotator-widget'); + editorControls = editor.find('.annotator-controls'); + save = editorControls.find('.annotator-save'); + cancel = editorControls.find('.annotator-cancel'); + + switch (keyCode) { + case KEY.TAB: + tabControls = this.getEditorTabControls(); + if (event.shiftKey) { // Tabbing backwards + if (target.is(form)) { + _.last(tabControls).focus(); + } else { + this.focusOnPreviousTabControl(tabControls, target); + } + } else { // Tabbing forward + if (target.is(form)) { + _.first(tabControls).focus(); + } else { + this.focusOnNextTabControl(tabControls, target); + } + } + event.preventDefault(); + event.stopPropagation(); + break; + case KEY.ENTER: + if (target.is(save) || event.metaKey || event.ctrlKey) { + this.annotator.editor.submit(); + } else if (target.is(cancel)) { + this.annotator.editor.hide(); + } else { + break; + } + this.onClose(); + event.preventDefault(); + break; + case KEY.SPACE: + if (target.is(save)) { + this.annotator.editor.submit(); + } else if (target.is(cancel)) { + this.annotator.editor.hide(); + } else { + break; + } + this.onClose(); + event.preventDefault(); + break; + case KEY.ESCAPE: + this.annotator.editor.hide(); + this.onClose(); + event.preventDefault(); + break; + } + } + }); +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/plugins/scroller.js b/lms/static/js/edxnotes/plugins/scroller.js index fe73cdb255..a56b7e6ef3 100644 --- a/lms/static/js/edxnotes/plugins/scroller.js +++ b/lms/static/js/edxnotes/plugins/scroller.js @@ -47,10 +47,15 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { highlight = $(note.highlights[0]); offset = highlight.position(); // Open the note - this.annotator.showFrozenViewer([note], { - top: offset.top + 0.5 * highlight.height(), - left: offset.left + 0.5 * highlight.width() - }); + this.annotator.plugins.Accessibility.showViewer( + { + top: offset.top + 0.5 * highlight.height(), + left: offset.left + 0.5 * highlight.width() + }, + note + ); + // Freeze the viewer + this.annotator.freezeAll(); // Scroll to highlight this.scrollIntoView(highlight); } diff --git a/lms/static/js/edxnotes/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js index 880fcee511..800bfac966 100644 --- a/lms/static/js/edxnotes/views/notes_factory.js +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -3,9 +3,9 @@ define([ 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/utils/logger', 'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller', - 'js/edxnotes/plugins/events' + 'js/edxnotes/plugins/events', 'js/edxnotes/plugins/accessibility' ], function ($, _, Annotator, NotesLogger) { - var plugins = ['Auth', 'Store', 'Scroller', 'Events'], + var plugins = ['Auth', 'Store', 'Scroller', 'Events', 'Accessibility'], getOptions, setupPlugins, updateHeaders, getAnnotator; /** diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index 3dd8a4d276..325d0b5867 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -59,13 +59,17 @@ define([ }; /** - * Modifies Annotator.highlightRange to add a "tabindex=0" attribute - * to the markup that encloses the note. - * These are then focusable via the TAB key. + * Modifies Annotator.highlightRange to add "tabindex=0" and role="link" + * attributes to the markup that encloses the + * note. These are then focusable via the TAB key and are accessible to + * screen readers. **/ Annotator.prototype.highlightRange = _.compose( function (results) { - $('.annotator-hl', this.wrapper).attr('tabindex', 0); + $('.annotator-hl', this.wrapper).attr({ + 'tabindex': 0, + 'role': 'link' + }); return results; }, Annotator.prototype.highlightRange @@ -98,24 +102,37 @@ define([ ); /** - * Modifies Annotator.Viewer.html.item template to add an i18n for the - * buttons. - **/ - Annotator.Viewer.prototype.html.item = [ - '
  • ', - '', - '', - _t('View as webpage'), - '', - '', - '', - '', - '
  • ' - ].join(''); + * Modifies Annotator.Viewer.html template to make viewer div focusable. + * Also adds a close button and necessary i18n attributes to all buttons. + **/ + Annotator.Viewer.prototype.html = { + element: [ + '
    ', + '
      ', + '
      ' + ].join(''), + item: [ + '
    • ', + '', + '', + _t('View as webpage'), + '', + '', + '', + '', + '', + '
    • ' + ].join('') + }; /** * Overrides Annotator._setupViewer to add a "click" event on viewer and to @@ -134,8 +151,8 @@ define([ $(field).html(Utils.nl2br(Annotator.Util.escape(annotation.text))); } else { $(field).html('' + _t('No Comment') + ''); - self.publish('annotationViewerTextField', [field, annotation]); } + return self.publish('annotationViewerTextField', [field, annotation]); } }) .element.appendTo(this.wrapper).bind({ @@ -147,6 +164,62 @@ define([ Annotator.Editor.prototype.isShown = Annotator.Viewer.prototype.isShown; + /** + * Modifies Annotator.Editor.html template to add tabindex = -1 to + * form.annotator-widget and reverse order of Save and Cancel buttons. + **/ + Annotator.Editor.prototype.html = [ + '
      ', + '
      ', + '
        ', + '
        ', + '', + '', + '
        ', + '
        ', + '
        ' + ].join(''); + + /** + * Modifies Annotator._setupEditor to add a label for textarea#annotator-field-0. + **/ + Annotator.prototype._setupEditor = _.compose( + function () { + $('').insertBefore( + $('#annotator-field-0', this.wrapper) + ); + return this; + }, + Annotator.prototype._setupEditor + ); + + /** + * Modifies Annotator.Editor.show, in the case of a keydown event, to remove + * focus from Save button and put it on form.annotator-widget instead. + **/ + Annotator.Editor.prototype.show = _.compose( + function (event) { + if (event.type === 'keydown') { + this.element.find('.annotator-save').removeClass(this.classes.focus); + this.element.find('form.annotator-widget').focus(); + } + }, + Annotator.Editor.prototype.show + ); + + /** + * Removes the textarea keydown event handler as it triggers 'processKeypress' + * which hides the viewer on ESC and saves on ENTER. We will define different + * behaviors for these in /plugins/accessibility.js + **/ + delete Annotator.Editor.prototype.events["textarea keydown"]; + /** * Modifies Annotator.onHighlightMouseover to avoid showing the viewer if the * editor is opened. @@ -174,8 +247,6 @@ define([ Annotator.prototype._setupWrapper ); - Annotator.Editor.prototype.isShown = Annotator.Viewer.prototype.isShown; - $.extend(true, Annotator.prototype, { isFrozen: false, uid: _.uniqueId(), @@ -191,11 +262,15 @@ define([ }, onNoteClick: function (event) { + var target = $(event.target); event.stopPropagation(); Annotator.Util.preventEventDefault(event); - if (!$(event.target).is('.annotator-delete')) { + + if (!(target.is('.annotator-delete') || target.is('.annotator-close'))) { Annotator.frozenSrc = this; this.freezeAll(); + } else if (target.is('.annotator-close')) { + this.viewer.hide(); } }, @@ -235,12 +310,6 @@ define([ unfreezeAll: function () { _.invoke(Annotator._instances, 'unfreeze'); return this; - }, - - showFrozenViewer: function (annotations, location) { - this.showViewer(annotations, location); - this.freezeAll(); - return this; } }); }); diff --git a/lms/static/js/edxnotes/views/toggle_notes_factory.js b/lms/static/js/edxnotes/views/toggle_notes_factory.js index 5739439a6b..7a37788dd2 100644 --- a/lms/static/js/edxnotes/views/toggle_notes_factory.js +++ b/lms/static/js/edxnotes/views/toggle_notes_factory.js @@ -12,7 +12,7 @@ define([ errorMessage: gettext("An error has occurred. Make sure that you are connected to the Internet, and then try refreshing the page."), initialize: function (options) { - _.bindAll(this, 'onSuccess', 'onError'); + _.bindAll(this, 'onSuccess', 'onError', 'keyDownToggleHandler'); this.visibility = options.visibility; this.visibilityUrl = options.visibilityUrl; this.label = this.$('.utility-control-label'); @@ -20,6 +20,12 @@ define([ this.actionLink.removeClass('is-disabled'); this.actionToggleMessage = this.$('.action-toggle-message'); this.notification = new Annotator.Notification(); + $(document).on('keydown.edxnotes:togglenotes', this.keyDownToggleHandler); + }, + + remove: function() { + $(document).off('keydown.edxnotes:togglenotes'); + Backbone.View.prototype.remove.call(this); }, toggleHandler: function (event) { @@ -29,6 +35,13 @@ define([ this.toggleNotes(this.visibility); }, + keyDownToggleHandler: function (event) { + // Character '[' has keyCode 219 + if (event.keyCode === 219 && event.ctrlKey && event.shiftKey) { + this.toggleHandler(event); + } + }, + toggleNotes: function (visibility) { if (visibility) { this.enableNotes(); @@ -47,16 +60,16 @@ define([ enableNotes: function () { _.each($('.edx-notes-wrapper'), EdxnotesVisibilityDecorator.enableNote); - this.actionLink.addClass('is-active').attr('aria-pressed', true); + this.actionLink.addClass('is-active'); this.label.text(gettext('Hide notes')); - this.actionToggleMessage.text(gettext('Showing notes')); + this.actionToggleMessage.text(gettext('Notes visible')); }, disableNotes: function () { EdxnotesVisibilityDecorator.disableNotes(); - this.actionLink.removeClass('is-active').attr('aria-pressed', false); + this.actionLink.removeClass('is-active'); this.label.text(gettext('Show notes')); - this.actionToggleMessage.text(gettext('Hiding notes')); + this.actionToggleMessage.text(gettext('Notes hidden')); }, hideErrorMessage: function() { diff --git a/lms/static/js/fixtures/edxnotes/toggle_notes.html b/lms/static/js/fixtures/edxnotes/toggle_notes.html index 7b1d2775a9..7aa88bc965 100644 --- a/lms/static/js/fixtures/edxnotes/toggle_notes.html +++ b/lms/static/js/fixtures/edxnotes/toggle_notes.html @@ -1,5 +1,5 @@
        - Hiding notes + Notes visible
        -