Merge pull request #6778 from edx/jmclaus/edxnotes-add-a11y
TNL 713: Added accessibility plugin.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<%! import json %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from student.models import anonymous_id_for_user %>
|
||||
<%
|
||||
if user:
|
||||
|
||||
320
lms/static/js/edxnotes/plugins/accessibility.js
Normal file
320
lms/static/js/edxnotes/plugins/accessibility.js
Normal file
@@ -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 = $('<span />', {
|
||||
'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($('<div />', {
|
||||
'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 <div>My note</div>
|
||||
$(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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,13 +59,17 @@ define([
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies Annotator.highlightRange to add a "tabindex=0" attribute
|
||||
* to the <span class="annotator-hl"> 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 <span class="annotator-hl"> 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 = [
|
||||
'<li class="annotator-annotation annotator-item">',
|
||||
'<span class="annotator-controls">',
|
||||
'<a href="#" title="', _t('View as webpage'), '" class="annotator-link">',
|
||||
_t('View as webpage'),
|
||||
'</a>',
|
||||
'<button title="', _t('Edit'), '" class="annotator-edit">',
|
||||
_t('Edit'),
|
||||
'</button>',
|
||||
'<button title="', _t('Delete'), '" class="annotator-delete">',
|
||||
_t('Delete'),
|
||||
'</button>',
|
||||
'</span>',
|
||||
'</li>'
|
||||
].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: [
|
||||
'<div class="annotator-outer annotator-viewer">',
|
||||
'<ul class="annotator-widget annotator-listing" tabindex="-1"></ul>',
|
||||
'</div>'
|
||||
].join(''),
|
||||
item: [
|
||||
'<li class="annotator-annotation annotator-item">',
|
||||
'<span class="annotator-controls">',
|
||||
'<a href="#" title="', _t('View as webpage'), '" class="annotator-link">',
|
||||
_t('View as webpage'),
|
||||
'</a>',
|
||||
'<button class="annotator-edit">',
|
||||
_t('Edit'),
|
||||
'<span class="sr">', _t('Note'), '</span>',
|
||||
'</button>',
|
||||
'<button class="annotator-delete">',
|
||||
_t('Delete'),
|
||||
'<span class="sr">', _t('Note'), '</span>',
|
||||
'</button>',
|
||||
'<button class="annotator-close">',
|
||||
_t('Close'),
|
||||
'<span class="sr">', _t('Note'), '</span>',
|
||||
'</button>',
|
||||
'</span>',
|
||||
'</li>'
|
||||
].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('<i>' + _t('No Comment') + '</i>');
|
||||
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 = [
|
||||
'<div class="annotator-outer annotator-editor">',
|
||||
'<form class="annotator-widget" tabindex="-1">',
|
||||
'<ul class="annotator-listing"></ul>',
|
||||
'<div class="annotator-controls">',
|
||||
'<button class="annotator-save">',
|
||||
_t('Save'),
|
||||
'<span class="sr">', _t('Note'), '</span>',
|
||||
'</button>',
|
||||
'<button class="annotator-cancel">',
|
||||
_t('Cancel'),
|
||||
'<span class="sr">', _t('Note'), '</span>',
|
||||
'</button>',
|
||||
'</div>',
|
||||
'</form>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
/**
|
||||
* Modifies Annotator._setupEditor to add a label for textarea#annotator-field-0.
|
||||
**/
|
||||
Annotator.prototype._setupEditor = _.compose(
|
||||
function () {
|
||||
$('<label class="sr" for="annotator-field-0">Edit note</label>').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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="wrapper-utility edx-notes-visibility">
|
||||
<span class="action-toggle-message">Hiding notes</span>
|
||||
<span class="action-toggle-message">Notes visible</span>
|
||||
<button class="utility-control utility-control-button action-toggle-notes is-disabled is-active" aria-pressed="true">
|
||||
<i class="icon fa fa-pencil"></i>
|
||||
<span class="utility-control-label sr">Hide notes</span>
|
||||
|
||||
318
lms/static/js/spec/edxnotes/plugins/accessibility_spec.js
Normal file
318
lms/static/js/spec/edxnotes/plugins/accessibility_spec.js
Normal file
@@ -0,0 +1,318 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'annotator_1.2.9', 'logger', 'js/edxnotes/views/notes_factory', 'js/spec/edxnotes/custom_matchers'
|
||||
], function($, _, Annotator, Logger, NotesFactory, customMatchers) {
|
||||
'use strict';
|
||||
describe('EdxNotes Accessibility Plugin', function() {
|
||||
function keyDownEvent (key) {
|
||||
return $.Event('keydown', {keyCode: key});
|
||||
}
|
||||
|
||||
function tabBackwardEvent () {
|
||||
return $.Event('keydown', {keyCode: $.ui.keyCode.TAB, shiftKey: true});
|
||||
}
|
||||
|
||||
function tabForwardEvent () {
|
||||
return $.Event('keydown', {keyCode: $.ui.keyCode.TAB, shiftKey: false});
|
||||
}
|
||||
|
||||
function enterMetaKeyEvent () {
|
||||
return $.Event('keydown', {keyCode: $.ui.keyCode.ENTER, metaKey: true});
|
||||
}
|
||||
|
||||
function enterControlKeyEvent () {
|
||||
return $.Event('keydown', {keyCode: $.ui.keyCode.ENTER, ctrlKey: true});
|
||||
}
|
||||
|
||||
beforeEach(function() {
|
||||
this.KEY = $.ui.keyCode;
|
||||
customMatchers(this);
|
||||
loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html');
|
||||
this.annotator = NotesFactory.factory(
|
||||
$('div#edx-notes-wrapper-123').get(0), {
|
||||
endpoint: 'http://example.com/'
|
||||
}
|
||||
);
|
||||
this.plugin = this.annotator.plugins.Accessibility;
|
||||
spyOn(Logger, 'log');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
_.invoke(Annotator._instances, 'destroy');
|
||||
});
|
||||
|
||||
describe('destroy', function () {
|
||||
it('should unbind all events', function () {
|
||||
spyOn($.fn, 'off');
|
||||
spyOn(this.annotator, 'unsubscribe');
|
||||
this.plugin.destroy();
|
||||
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
|
||||
'annotationViewerTextField', this.plugin.addAriaAttributes
|
||||
);
|
||||
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
|
||||
'annotationsLoaded', this.plugin.addDescriptions
|
||||
);
|
||||
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
|
||||
'annotationCreated', this.plugin.addDescriptions
|
||||
);
|
||||
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
|
||||
'annotationDeleted', this.plugin.removeDescription
|
||||
);
|
||||
expect($.fn.off).toHaveBeenCalledWith('.accessibility');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a11y attributes', function () {
|
||||
var highlight, annotation, note;
|
||||
|
||||
beforeEach(function() {
|
||||
highlight = $('<span class="annotator-hl" tabindex="0"/>').appendTo(this.annotator.element);
|
||||
annotation = {
|
||||
id: '01',
|
||||
text: 'Test text',
|
||||
highlights: [highlight.get(0)]
|
||||
};
|
||||
});
|
||||
|
||||
it('should be added to highlighted text and associated note', function () {
|
||||
this.annotator.viewer.load([annotation]);
|
||||
note = $('.annotator-note');
|
||||
expect(note).toExist();
|
||||
expect(note).toHaveAttr('tabindex', -1);
|
||||
expect(note).toHaveAttr('role', 'note');
|
||||
expect(note).toHaveAttr('class', 'annotator-note');
|
||||
});
|
||||
|
||||
it('should create aria-descriptions when annotations are loaded', function () {
|
||||
this.annotator.publish('annotationsLoaded', [[annotation]]);
|
||||
expect(highlight).toHaveAttr('aria-describedby', 'aria-note-description-01');
|
||||
expect($('#aria-note-description-01')).toContainText('Test text');
|
||||
});
|
||||
|
||||
it('should create aria-description when new annotation is created', function () {
|
||||
this.annotator.publish('annotationCreated', [annotation]);
|
||||
expect(highlight).toHaveAttr('aria-describedby', 'aria-note-description-01');
|
||||
expect($('#aria-note-description-01')).toContainText('Test text');
|
||||
});
|
||||
|
||||
it('should remove aria-description when the annotation is removed', function () {
|
||||
this.annotator.publish('annotationDeleted', [annotation]);
|
||||
expect($('#aria-note-description-01')).not.toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keydown events on highlighted text', function () {
|
||||
var highlight, annotation, note;
|
||||
|
||||
beforeEach(function() {
|
||||
highlight = $('<span class="annotator-hl" tabindex="0"/>').appendTo(this.annotator.element);
|
||||
annotation = {
|
||||
id: '01',
|
||||
text: 'Test text',
|
||||
highlights: [highlight.get(0)]
|
||||
};
|
||||
highlight.data('annotation', annotation);
|
||||
spyOn(this.annotator, 'showViewer').andCallThrough();
|
||||
spyOn(this.annotator.viewer, 'hide').andCallThrough();
|
||||
spyOn(this.plugin, 'focusOnGrabber').andCallThrough();
|
||||
});
|
||||
|
||||
it('should open the viewer on SPACE keydown and focus on note', function () {
|
||||
highlight.trigger(keyDownEvent(this.KEY.SPACE));
|
||||
expect(this.annotator.showViewer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open the viewer on ENTER keydown and focus on note', function () {
|
||||
highlight.trigger(keyDownEvent(this.KEY.ENTER));
|
||||
expect(this.annotator.showViewer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// This happens only when coming from notes page
|
||||
it('should open focus on viewer on TAB keydown if viewer is opened', function () {
|
||||
this.annotator.viewer.load([annotation]);
|
||||
highlight.trigger(keyDownEvent(this.KEY.TAB));
|
||||
expect(this.annotator.element.find('.annotator-listing')).toBeFocused();
|
||||
});
|
||||
|
||||
it('should focus highlighted text after closing', function () {
|
||||
var note;
|
||||
highlight.trigger(keyDownEvent(this.KEY.ENTER));
|
||||
expect(this.plugin.savedHighlights).toBeDefined();
|
||||
note = this.annotator.element.find('.annotator-edit');
|
||||
note.trigger(keyDownEvent(this.KEY.ESCAPE));
|
||||
expect(highlight).toBeFocused();
|
||||
expect(this.plugin.savedHighlights).toBeNull();
|
||||
});
|
||||
|
||||
it('should focus on grabber after being deleted', function () {
|
||||
highlight.trigger(keyDownEvent(this.KEY.ENTER));
|
||||
this.annotator.publish('annotationDeleted', {});
|
||||
expect(this.plugin.focusGrabber).toBeFocused();
|
||||
});
|
||||
|
||||
it('should not focus on grabber when the viewer is hidden', function () {
|
||||
this.annotator.publish('annotationDeleted', {});
|
||||
expect(this.plugin.focusGrabber).not.toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keydown events on viewer', function () {
|
||||
var highlight, annotation, listing, note, edit, del, close;
|
||||
|
||||
beforeEach(function() {
|
||||
highlight = $('<span class="annotator-hl" tabindex="0"/>').appendTo(this.annotator.element);
|
||||
annotation = {
|
||||
id: '01',
|
||||
text: "Test text",
|
||||
highlights: [highlight.get(0)]
|
||||
};
|
||||
highlight.data('annotation', annotation);
|
||||
this.annotator.viewer.load([annotation]);
|
||||
listing = this.annotator.element.find('.annotator-listing').first(),
|
||||
note = this.annotator.element.find('.annotator-note').first();
|
||||
edit= this.annotator.element.find('.annotator-edit').first();
|
||||
del = this.annotator.element.find('.annotator-delete').first();
|
||||
close = this.annotator.element.find('.annotator-close').first();
|
||||
spyOn(this.annotator.viewer, 'hide').andCallThrough();;
|
||||
});
|
||||
|
||||
it('should give focus to Note on Listing TAB keydown', function () {
|
||||
listing.focus();
|
||||
listing.trigger(tabForwardEvent());
|
||||
expect(note).toBeFocused();
|
||||
});
|
||||
|
||||
it('should give focus to Close on Listing SHIFT + TAB keydown', function () {
|
||||
listing.focus();
|
||||
listing.trigger(tabBackwardEvent());
|
||||
expect(close).toBeFocused();
|
||||
});
|
||||
|
||||
it('should cycle forward through Note, Edit, Delete, and Close on TAB keydown', function () {
|
||||
note.focus();
|
||||
note.trigger(tabForwardEvent());
|
||||
expect(edit).toBeFocused();
|
||||
edit.trigger(tabForwardEvent());
|
||||
expect(del).toBeFocused();
|
||||
del.trigger(tabForwardEvent());
|
||||
expect(close).toBeFocused();
|
||||
close.trigger(tabForwardEvent());
|
||||
expect(note).toBeFocused();
|
||||
});
|
||||
|
||||
it('should cycle backward through Note, Edit, Delete, and Close on SHIFT + TAB keydown', function () {
|
||||
note.focus();
|
||||
note.trigger(tabBackwardEvent());
|
||||
expect(close).toBeFocused();
|
||||
close.trigger(tabBackwardEvent());
|
||||
expect(del).toBeFocused();
|
||||
del.trigger(tabBackwardEvent());
|
||||
expect(edit).toBeFocused();
|
||||
edit.trigger(tabBackwardEvent());
|
||||
expect(note).toBeFocused();
|
||||
});
|
||||
|
||||
it('should hide on ESCAPE keydown', function () {
|
||||
var tabControls = [listing, note, edit, del, close];
|
||||
|
||||
_.each(tabControls, function (control) {
|
||||
control.focus();
|
||||
control.trigger(keyDownEvent(this.KEY.ESCAPE));
|
||||
}, this);
|
||||
expect(this.annotator.viewer.hide.callCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keydown events on editor', function () {
|
||||
var highlight, annotation, form, textArea, save, cancel;
|
||||
|
||||
beforeEach(function() {
|
||||
highlight = $('<span class="annotator-hl" tabindex="0"/>').appendTo(this.annotator.element);
|
||||
annotation = {
|
||||
id: '01',
|
||||
text: "Test text",
|
||||
highlights: [highlight.get(0)]
|
||||
};
|
||||
highlight.data('annotation', annotation);
|
||||
this.annotator.editor.show(annotation, {'left': 0, 'top': 0});
|
||||
form = this.annotator.element.find('form.annotator-widget');
|
||||
textArea = this.annotator.element.find('.annotator-item').first().children('textarea');
|
||||
save = this.annotator.element.find('.annotator-save');
|
||||
cancel = this.annotator.element.find('.annotator-cancel');
|
||||
spyOn(this.annotator.editor, 'submit').andCallThrough();
|
||||
spyOn(this.annotator.editor, 'hide').andCallThrough();
|
||||
});
|
||||
|
||||
it('should give focus to TextArea on Form TAB keydown', function () {
|
||||
form.focus();
|
||||
form.trigger(tabForwardEvent());
|
||||
expect(textArea).toBeFocused();
|
||||
});
|
||||
|
||||
it('should give focus to Cancel on Form SHIFT + TAB keydown', function () {
|
||||
form.focus();
|
||||
form.trigger(tabBackwardEvent());
|
||||
expect(cancel).toBeFocused();
|
||||
});
|
||||
|
||||
it('should cycle forward through texarea, save, and cancel on TAB keydown', function () {
|
||||
textArea.focus();
|
||||
textArea.trigger(tabForwardEvent());
|
||||
expect(save).toBeFocused();
|
||||
save.trigger(tabForwardEvent());
|
||||
expect(cancel).toBeFocused();
|
||||
cancel.trigger(tabForwardEvent());
|
||||
expect(textArea).toBeFocused();
|
||||
});
|
||||
|
||||
it('should cycle back through texarea, save, and cancel on SHIFT + TAB keydown', function () {
|
||||
textArea.focus();
|
||||
textArea.trigger(tabBackwardEvent());
|
||||
expect(cancel).toBeFocused();
|
||||
cancel.trigger(tabBackwardEvent());
|
||||
expect(save).toBeFocused();
|
||||
save.trigger(tabBackwardEvent());
|
||||
expect(textArea).toBeFocused();
|
||||
});
|
||||
|
||||
it('should submit if target is Save on ENTER or SPACE keydown', function () {
|
||||
save.focus();
|
||||
save.trigger(keyDownEvent(this.KEY.ENTER));
|
||||
expect(this.annotator.editor.submit).toHaveBeenCalled();
|
||||
this.annotator.editor.submit.reset();
|
||||
save.focus();
|
||||
save.trigger(keyDownEvent(this.KEY.SPACE));
|
||||
expect(this.annotator.editor.submit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should submit on META or CONTROL + ENTER keydown', function () {
|
||||
textArea.focus();
|
||||
textArea.trigger(enterMetaKeyEvent());
|
||||
expect(this.annotator.editor.submit).toHaveBeenCalled();
|
||||
this.annotator.editor.submit.reset();
|
||||
textArea.focus();
|
||||
textArea.trigger(enterControlKeyEvent());
|
||||
expect(this.annotator.editor.submit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide if target is Cancel on ENTER or SPACE keydown', function () {
|
||||
cancel.focus();
|
||||
cancel.trigger(keyDownEvent(this.KEY.ENTER));
|
||||
expect(this.annotator.editor.hide).toHaveBeenCalled();
|
||||
this.annotator.editor.hide.reset();
|
||||
cancel.focus();
|
||||
save.trigger(keyDownEvent(this.KEY.SPACE));
|
||||
expect(this.annotator.editor.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide on ESCAPE keydown', function () {
|
||||
var tabControls = [textArea, save, cancel];
|
||||
|
||||
_.each(tabControls, function (control) {
|
||||
control.focus();
|
||||
control.trigger(keyDownEvent(this.KEY.ESCAPE));
|
||||
}, this);
|
||||
expect(this.annotator.editor.hide.callCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,20 @@ define([
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide viewer when close button is clicked', function() {
|
||||
var close,
|
||||
annotation = {
|
||||
id: '01',
|
||||
text: "Test text",
|
||||
highlights: [highlights[0].get(0)]
|
||||
};
|
||||
|
||||
annotators[0].viewer.load([annotation]);
|
||||
close = annotators[0].viewer.element.find('.annotator-close');
|
||||
close.click();
|
||||
expect($('#edx-notes-wrapper-123 .annotator-viewer')).toHaveClass('annotator-hide');
|
||||
});
|
||||
|
||||
describe('_setupViewer', function () {
|
||||
var mockViewer = null;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ define([
|
||||
this.button = $('.action-toggle-notes');
|
||||
this.label = this.button.find('.utility-control-label');
|
||||
this.toggleMessage = $('.action-toggle-message');
|
||||
spyOn(this.toggleNotes, 'toggleHandler').andCallThrough();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@@ -49,14 +50,13 @@ define([
|
||||
expect(this.button).toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'true');
|
||||
expect(this.toggleMessage).not.toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Hiding notes');
|
||||
expect(this.toggleMessage).toContainText('Notes visible');
|
||||
|
||||
this.button.click();
|
||||
expect(this.label).toContainText('Show notes');
|
||||
expect(this.button).not.toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'false');
|
||||
expect(this.toggleMessage).toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Hiding notes');
|
||||
expect(this.toggleMessage).toContainText('Notes hidden');
|
||||
expect(Annotator._instances).toHaveLength(0);
|
||||
|
||||
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
|
||||
@@ -67,9 +67,8 @@ define([
|
||||
this.button.click();
|
||||
expect(this.label).toContainText('Hide notes');
|
||||
expect(this.button).toHaveClass('is-active');
|
||||
expect(this.button).toHaveAttr('aria-pressed', 'true');
|
||||
expect(this.toggleMessage).toHaveClass('is-fleeting');
|
||||
expect(this.toggleMessage).toContainText('Showing notes');
|
||||
expect(this.toggleMessage).toContainText('Notes visible');
|
||||
expect(Annotator._instances).toHaveLength(2);
|
||||
|
||||
AjaxHelpers.expectJsonRequest(requests, 'PUT', '/test_url', {
|
||||
@@ -95,5 +94,11 @@ define([
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
expect(errorContainer).not.toHaveClass('annotator-notice-show');
|
||||
});
|
||||
|
||||
it('toggles notes when CTRL + SHIFT + [ keydown on document', function () {
|
||||
// Character '[' has keyCode 219
|
||||
$(document).trigger($.Event('keydown', {keyCode: 219, ctrlKey: true, shiftKey: true}));
|
||||
expect(this.toggleNotes.toggleHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -569,6 +569,7 @@
|
||||
'lms/include/js/spec/edxnotes/views/toggle_notes_factory_spec.js',
|
||||
'lms/include/js/spec/edxnotes/models/tab_spec.js',
|
||||
'lms/include/js/spec/edxnotes/models/note_spec.js',
|
||||
'lms/include/js/spec/edxnotes/plugins/accessibility_spec.js',
|
||||
'lms/include/js/spec/edxnotes/plugins/events_spec.js',
|
||||
'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
|
||||
'lms/include/js/spec/edxnotes/collections/notes_spec.js',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -243,9 +243,7 @@ ${fragment.foot_html()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}">
|
||||
<h2 class="sr nav-utilities-title">${_('Course Utilities Navigation')}</h2>
|
||||
|
||||
<nav class="nav-utilities ${"has-utility-calculator" if course.show_calculator else ""}" area-label="${_('Course Utilities')}">
|
||||
## Utility: Chat
|
||||
% if show_chat:
|
||||
<%include file="/chat/toggle_chat.html" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
%>
|
||||
<div class="wrapper-utility edx-notes-visibility">
|
||||
<span class="action-toggle-message" aria-live="polite"></span>
|
||||
<button class="utility-control utility-control-button action-toggle-notes is-disabled ${"is-active" if edxnotes_visibility else ""}" aria-pressed="${"true" if edxnotes_visibility else "false"}">
|
||||
<button class="utility-control utility-control-button action-toggle-notes is-disabled ${"is-active" if edxnotes_visibility else ""}">
|
||||
<i class="icon fa fa-pencil"></i>
|
||||
% if edxnotes_visibility:
|
||||
<span class="utility-control-label sr">${_("Hide notes")}</span>
|
||||
|
||||
Reference in New Issue
Block a user