Commit 06ce47dd by Tim Krones

Keyboard accessibility for interacting with items and drop zones.

parent 9a0f8652
...@@ -220,7 +220,7 @@ class DragAndDropBlock(XBlock): ...@@ -220,7 +220,7 @@ class DragAndDropBlock(XBlock):
@XBlock.json_handler @XBlock.json_handler
def do_attempt(self, attempt, suffix=''): def do_attempt(self, attempt, suffix=''):
item = next(i for i in self.data['items'] if i['id'] == attempt['val']) item = self._get_item_definition(attempt['val'])
state = None state = None
feedback = item['feedback']['incorrect'] feedback = item['feedback']['incorrect']
...@@ -250,6 +250,7 @@ class DragAndDropBlock(XBlock): ...@@ -250,6 +250,7 @@ class DragAndDropBlock(XBlock):
is_correct = True is_correct = True
feedback = item['feedback']['correct'] feedback = item['feedback']['correct']
state = { state = {
'zone': attempt['zone'],
'x_percent': attempt['x_percent'], 'x_percent': attempt['x_percent'],
'y_percent': attempt['y_percent'], 'y_percent': attempt['y_percent'],
} }
...@@ -349,8 +350,13 @@ class DragAndDropBlock(XBlock): ...@@ -349,8 +350,13 @@ class DragAndDropBlock(XBlock):
""" Get all user-specific data, and any applicable feedback """ """ Get all user-specific data, and any applicable feedback """
item_state = self._get_item_state() item_state = self._get_item_state()
for item_id, item in item_state.iteritems(): for item_id, item in item_state.iteritems():
definition = next(i for i in self.data['items'] if str(i['id']) == item_id) definition = self._get_item_definition(int(item_id))
item['correct_input'] = self._is_correct_input(definition, item.get('input')) item['correct_input'] = self._is_correct_input(definition, item.get('input'))
# If information about zone is missing
# (because exercise was completed before a11y enhancements were implemented),
# deduce zone in which item is placed from definition:
if item.get('zone') is None:
item['zone'] = definition.get('zone', 'unknown')
is_finished = self._is_finished() is_finished = self._is_finished()
return { return {
...@@ -374,6 +380,12 @@ class DragAndDropBlock(XBlock): ...@@ -374,6 +380,12 @@ class DragAndDropBlock(XBlock):
return state return state
def _get_item_definition(self, item_id):
"""
Returns definition (settings) for item identified by `item_id`.
"""
return next(i for i in self.data['items'] if i['id'] == item_id)
def _get_grade(self): def _get_grade(self):
""" """
Returns the student's grade for this block. Returns the student's grade for this block.
......
...@@ -12,6 +12,18 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -12,6 +12,18 @@ function DragAndDropBlock(runtime, element, configuration) {
var state = undefined; var state = undefined;
var __vdom = virtualDom.h(); // blank virtual DOM var __vdom = virtualDom.h(); // blank virtual DOM
// Keyboard accessibility
var CTRL = 17;
var ESC = 27;
var RET = 13;
var SPC = 32;
var TAB = 9;
var M = 77;
var ctrlDown = false;
var placementMode = false;
var $selectedItem;
var init = function() { var init = function() {
// Load the current user state, and load the image, then render the block. // Load the current user state, and load the image, then render the block.
// We load the user state via AJAX rather than passing it in statically (like we do with // We load the user state via AJAX rather than passing it in statically (like we do with
...@@ -31,7 +43,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -31,7 +43,7 @@ function DragAndDropBlock(runtime, element, configuration) {
applyState(); applyState();
initDroppable(); initDroppable();
$(document).on('mousedown touchstart', closePopup); $(document).on('keydown mousedown touchstart', closePopup);
$element.on('click', '.reset-button', resetExercise); $element.on('click', '.reset-button', resetExercise);
$element.on('click', '.submit-input', submitInput); $element.on('click', '.submit-input', submitInput);
...@@ -127,23 +139,62 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -127,23 +139,62 @@ function DragAndDropBlock(runtime, element, configuration) {
}); });
}; };
var initDroppable = function() { var isCycleKey = function(key) {
$root.find('.zone').droppable({ return !ctrlDown && key === TAB;
accept: '.xblock--drag-and-drop .item-bank .option', };
tolerance: 'pointer',
drop: function(evt, ui) { var isCancelKey = function(key) {
var item_id = ui.draggable.data('value'); return !ctrlDown && key === ESC;
var zone = $(this).data('zone'); };
var isActionKey = function(key) {
if (ctrlDown) {
return key === M;
}
return key === RET || key === SPC;
};
var focusNextZone = function(evt, $currentZone) {
if (evt.shiftKey) { // Going backward
var isFirstZone = $currentZone.prev('.zone').length === 0;
if (isFirstZone) {
evt.preventDefault();
$root.find('.target .zone').last().focus();
}
} else { // Going forward
var isLastZone = $currentZone.next('.zone').length === 0;
if (isLastZone) {
evt.preventDefault();
$root.find('.target .zone').first().focus();
}
}
};
var placeItem = function($zone, $item) {
var item_id;
var $anchor;
if ($item !== undefined) {
item_id = $item.data('value');
// Element was placed using the mouse,
// so use relevant properties of *item* when calculating new position below.
$anchor = $item;
} else {
item_id = $selectedItem.data('value');
// Element was placed using the keyboard,
// so use relevant properties of *zone* when calculating new position below.
$anchor = $zone;
}
var zone = $zone.data('zone');
var $target_img = $root.find('.target-img'); var $target_img = $root.find('.target-img');
// Calculate the position of the center of the dropped element relative to // Calculate the position of the item to place relative to the image.
// the image. var x_pos = $anchor.offset().left + ($anchor.outerWidth()/2) - $target_img.offset().left;
var x_pos = (ui.helper.offset().left + (ui.helper.outerWidth()/2) - $target_img.offset().left); var y_pos = $anchor.offset().top + ($anchor.outerHeight()/2) - $target_img.offset().top;
var x_pos_percent = x_pos / $target_img.width() * 100; var x_pos_percent = x_pos / $target_img.width() * 100;
var y_pos = (ui.helper.offset().top + (ui.helper.outerHeight()/2) - $target_img.offset().top);
var y_pos_percent = y_pos / $target_img.height() * 100; var y_pos_percent = y_pos / $target_img.height() * 100;
state.items[item_id] = { state.items[item_id] = {
zone: zone,
x_percent: x_pos_percent, x_percent: x_pos_percent,
y_percent: y_pos_percent, y_percent: y_pos_percent,
submitting_location: true, submitting_location: true,
...@@ -153,14 +204,77 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -153,14 +204,77 @@ function DragAndDropBlock(runtime, element, configuration) {
applyState(); applyState();
submitLocation(item_id, zone, x_pos_percent, y_pos_percent); submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 0); }, 0);
};
var initDroppable = function() {
// Set up zones for keyboard interaction
$root.find('.zone').each(function() {
var $zone = $(this);
$zone.on('keydown', function(evt) {
if (placementMode) {
var key = evt.which;
if (key === CTRL) {
ctrlDown = true;
return;
}
if (isCycleKey(key)) {
focusNextZone(evt, $zone);
} else if (isCancelKey(key)) {
evt.preventDefault();
placementMode = false;
} else if (isActionKey(key)) {
evt.preventDefault();
placementMode = false;
placeItem($zone);
}
}
});
$zone.on('keyup', function(evt) {
if (evt.which === CTRL) {
ctrlDown = false;
}
});
});
// Make zone accept items that are dropped using the mouse
$root.find('.zone').droppable({
accept: '.xblock--drag-and-drop .item-bank .option',
tolerance: 'pointer',
drop: function(evt, ui) {
var $zone = $(this);
var $item = ui.helper;
placeItem($zone, $item);
} }
}); });
}; };
var initDraggable = function() { var initDraggable = function() {
$root.find('.item-bank .option').not('[data-drag-disabled=true]').each(function() { $root.find('.item-bank .option').not('[data-drag-disabled=true]').each(function() {
var $item = $(this);
// Allow item to be "picked up" using the keyboard
$item.on('keydown', function(evt) {
var key = evt.which;
if (key === CTRL) {
ctrlDown = true;
return;
}
if (isActionKey(key)) {
evt.preventDefault();
placementMode = true;
$selectedItem = $item;
$root.find('.target .zone').first().focus();
}
});
$item.on('keyup', function(evt) {
if (evt.which === CTRL) {
ctrlDown = false;
}
});
// Make item draggable using the mouse
try { try {
$(this).draggable({ $item.draggable({
containment: $root.find('.xblock--drag-and-drop .drag-container'), containment: $root.find('.xblock--drag-and-drop .drag-container'),
cursor: 'move', cursor: 'move',
stack: $root.find('.xblock--drag-and-drop .item-bank .option'), stack: $root.find('.xblock--drag-and-drop .item-bank .option'),
...@@ -198,8 +312,12 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -198,8 +312,12 @@ function DragAndDropBlock(runtime, element, configuration) {
var destroyDraggable = function() { var destroyDraggable = function() {
$root.find('.item-bank .option[data-drag-disabled=true]').each(function() { $root.find('.item-bank .option[data-drag-disabled=true]').each(function() {
var $item = $(this);
$item.off();
try { try {
$(this).draggable('destroy'); $item.draggable('destroy');
} catch (e) { } catch (e) {
// Destroying the draggable will fail if draggable was // Destroying the draggable will fail if draggable was
// not initialized in the first place. Ignore the exception. // not initialized in the first place. Ignore the exception.
...@@ -345,6 +463,7 @@ function DragAndDropBlock(runtime, element, configuration) { ...@@ -345,6 +463,7 @@ function DragAndDropBlock(runtime, element, configuration) {
}; };
if (item_user_state) { if (item_user_state) {
itemProperties.is_placed = true; itemProperties.is_placed = true;
itemProperties.zone = item_user_state.zone;
itemProperties.x_percent = item_user_state.x_percent; itemProperties.x_percent = item_user_state.x_percent;
itemProperties.y_percent = item_user_state.y_percent; itemProperties.y_percent = item_user_state.y_percent;
} }
......
...@@ -53,8 +53,12 @@ ...@@ -53,8 +53,12 @@
}; };
var itemTemplate = function(item) { var itemTemplate = function(item) {
var style = {}; // Define properties
var className = (item.class_name) ? item.class_name : ""; var className = (item.class_name) ? item.class_name : "";
if (item.has_image) {
className += " " + "option-with-image";
}
var style = {};
var tabindex = 0; var tabindex = 0;
if (item.background_color) { if (item.background_color) {
style['background-color'] = item.background_color; style['background-color'] = item.background_color;
...@@ -71,13 +75,28 @@ ...@@ -71,13 +75,28 @@
tabindex = -1; // If an item has been placed it can no longer be interacted with, tabindex = -1; // If an item has been placed it can no longer be interacted with,
// so remove the ability to move focus to it using the keyboard // so remove the ability to move focus to it using the keyboard
} }
if (item.has_image) { // Define children
className += " " + "option-with-image"; var children = [
} itemSpinnerTemplate(item.xhr_active),
var content_html = item.displayName; itemInputTemplate(item.input)
];
var item_content_html = item.displayName;
if (item.imageURL) { if (item.imageURL) {
content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />'; item_content_html = '<img src="' + item.imageURL + '" alt="' + item.imageDescription + '" />';
}
var item_content = h('div', { innerHTML: item_content_html, className: "item-content" });
if (item.is_placed) {
// Insert information about zone in which this item has been placed
var item_description_id = 'item-' + item.value + '-description';
item_content.properties.attributes = { 'aria-describedby': item_description_id };
var item_description = h(
'div',
{ id: item_description_id, className: 'sr' },
gettext('Correctly placed in: ') + item.zone
);
children.splice(1, 0, item_description);
} }
children.splice(1, 0, item_content);
return ( return (
h('div.option', h('div.option',
{ {
...@@ -91,11 +110,7 @@ ...@@ -91,11 +110,7 @@
'data-drag-disabled': item.drag_disabled 'data-drag-disabled': item.drag_disabled
}, },
style: style style: style
}, [ }, children
itemSpinnerTemplate(item.xhr_active),
h('div', {innerHTML: content_html, className: "item-content"}),
itemInputTemplate(item.input)
]
) )
); );
}; };
...@@ -156,10 +171,17 @@ ...@@ -156,10 +171,17 @@
h('section.drag-container', [ h('section.drag-container', [
h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)), h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)),
h('div.target', [ h('div.target', [
h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [ h(
'div.popup',
{
style: {display: ctx.popup_html ? 'block' : 'none'},
attributes: {'aria-live': 'polite'}
},
[
h('div.close.icon-remove-sign.fa-times-circle'), h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}), h('p.popup-content', {innerHTML: ctx.popup_html}),
]), ]
),
h('div.target-img-wrapper', [ h('div.target-img-wrapper', [
h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}), h('img.target-img', {src: ctx.target_img_src, alt: ctx.target_img_description}),
]), ]),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment