Commit 06ce47dd by Tim Krones

Keyboard accessibility for interacting with items and drop zones.

parent 9a0f8652
......@@ -220,7 +220,7 @@ class DragAndDropBlock(XBlock):
@XBlock.json_handler
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
feedback = item['feedback']['incorrect']
......@@ -250,6 +250,7 @@ class DragAndDropBlock(XBlock):
is_correct = True
feedback = item['feedback']['correct']
state = {
'zone': attempt['zone'],
'x_percent': attempt['x_percent'],
'y_percent': attempt['y_percent'],
}
......@@ -349,8 +350,13 @@ class DragAndDropBlock(XBlock):
""" Get all user-specific data, and any applicable feedback """
item_state = self._get_item_state()
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'))
# 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()
return {
......@@ -374,6 +380,12 @@ class DragAndDropBlock(XBlock):
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):
"""
Returns the student's grade for this block.
......
......@@ -12,6 +12,18 @@ function DragAndDropBlock(runtime, element, configuration) {
var state = undefined;
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() {
// 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
......@@ -31,7 +43,7 @@ function DragAndDropBlock(runtime, element, configuration) {
applyState();
initDroppable();
$(document).on('mousedown touchstart', closePopup);
$(document).on('keydown mousedown touchstart', closePopup);
$element.on('click', '.reset-button', resetExercise);
$element.on('click', '.submit-input', submitInput);
......@@ -127,40 +139,142 @@ function DragAndDropBlock(runtime, element, configuration) {
});
};
var isCycleKey = function(key) {
return !ctrlDown && key === TAB;
};
var isCancelKey = function(key) {
return !ctrlDown && key === ESC;
};
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');
// Calculate the position of the item to place relative to the image.
var x_pos = $anchor.offset().left + ($anchor.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 y_pos_percent = y_pos / $target_img.height() * 100;
state.items[item_id] = {
zone: zone,
x_percent: x_pos_percent,
y_percent: y_pos_percent,
submitting_location: true,
};
// Wrap in setTimeout to let the droppable event finish.
setTimeout(function() {
applyState();
submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 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 item_id = ui.draggable.data('value');
var zone = $(this).data('zone');
var $target_img = $root.find('.target-img');
// Calculate the position of the center of the dropped element relative to
// the image.
var x_pos = (ui.helper.offset().left + (ui.helper.outerWidth()/2) - $target_img.offset().left);
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;
state.items[item_id] = {
x_percent: x_pos_percent,
y_percent: y_pos_percent,
submitting_location: true,
};
// Wrap in setTimeout to let the droppable event finish.
setTimeout(function() {
applyState();
submitLocation(item_id, zone, x_pos_percent, y_pos_percent);
}, 0);
var $zone = $(this);
var $item = ui.helper;
placeItem($zone, $item);
}
});
};
var initDraggable = 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 {
$(this).draggable({
$item.draggable({
containment: $root.find('.xblock--drag-and-drop .drag-container'),
cursor: 'move',
stack: $root.find('.xblock--drag-and-drop .item-bank .option'),
......@@ -198,8 +312,12 @@ function DragAndDropBlock(runtime, element, configuration) {
var destroyDraggable = function() {
$root.find('.item-bank .option[data-drag-disabled=true]').each(function() {
var $item = $(this);
$item.off();
try {
$(this).draggable('destroy');
$item.draggable('destroy');
} catch (e) {
// Destroying the draggable will fail if draggable was
// not initialized in the first place. Ignore the exception.
......@@ -345,6 +463,7 @@ function DragAndDropBlock(runtime, element, configuration) {
};
if (item_user_state) {
itemProperties.is_placed = true;
itemProperties.zone = item_user_state.zone;
itemProperties.x_percent = item_user_state.x_percent;
itemProperties.y_percent = item_user_state.y_percent;
}
......
......@@ -53,8 +53,12 @@
};
var itemTemplate = function(item) {
var style = {};
// Define properties
var className = (item.class_name) ? item.class_name : "";
if (item.has_image) {
className += " " + "option-with-image";
}
var style = {};
var tabindex = 0;
if (item.background_color) {
style['background-color'] = item.background_color;
......@@ -71,13 +75,28 @@
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
}
if (item.has_image) {
className += " " + "option-with-image";
}
var content_html = item.displayName;
// Define children
var children = [
itemSpinnerTemplate(item.xhr_active),
itemInputTemplate(item.input)
];
var item_content_html = item.displayName;
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 (
h('div.option',
{
......@@ -91,11 +110,7 @@
'data-drag-disabled': item.drag_disabled
},
style: style
}, [
itemSpinnerTemplate(item.xhr_active),
h('div', {innerHTML: content_html, className: "item-content"}),
itemInputTemplate(item.input)
]
}, children
)
);
};
......@@ -156,10 +171,17 @@
h('section.drag-container', [
h('div.item-bank', renderCollection(itemTemplate, items_in_bank, ctx)),
h('div.target', [
h('div.popup', {style: {display: ctx.popup_html ? 'block' : 'none'}}, [
h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}),
]),
h(
'div.popup',
{
style: {display: ctx.popup_html ? 'block' : 'none'},
attributes: {'aria-live': 'polite'}
},
[
h('div.close.icon-remove-sign.fa-times-circle'),
h('p.popup-content', {innerHTML: ctx.popup_html}),
]
),
h('div.target-img-wrapper', [
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