Commit 8be8937b by Tim Krones

Merge pull request #44 from open-craft/keyboard-a11y

Make DnDv2 keyboard-accessible
parents 9a0f8652 dc0a76ba
......@@ -14,8 +14,9 @@ TOP_ZONE_DESCRIPTION = _("Use this zone to associate an item with the top layer
MIDDLE_ZONE_DESCRIPTION = _("Use this zone to associate an item with the middle layer of the triangle.")
BOTTOM_ZONE_DESCRIPTION = _("Use this zone to associate an item with the bottom layer of the triangle.")
ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.")
ITEM_CORRECT_FEEDBACK = _("Correct! This one belongs to {zone}.")
ITEM_INCORRECT_FEEDBACK = _("No, this item does not belong here. Try again.")
ITEM_NO_ZONE_FEEDBACK = _("You silly, there are no zones for this one.")
START_FEEDBACK = _("Drag the items onto the image above.")
FINISH_FEEDBACK = _("Good work! You have completed this drag and drop exercise.")
......@@ -88,7 +89,7 @@ DEFAULT_DATA = {
{
"displayName": _("I don't belong anywhere"),
"feedback": {
"incorrect": _("You silly, there are no zones for this one."),
"incorrect": ITEM_NO_ZONE_FEEDBACK,
"correct": ""
},
"zone": "none",
......
......@@ -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']
......@@ -228,7 +228,7 @@ class DragAndDropBlock(XBlock):
is_correct = False
is_correct_location = False
if 'input' in attempt:
if 'input' in attempt: # Student submitted numerical value for item
state = self._get_item_state().get(str(item['id']))
if state:
state['input'] = attempt['input']
......@@ -238,7 +238,7 @@ class DragAndDropBlock(XBlock):
feedback = item['feedback']['correct']
else:
is_correct = False
elif item['zone'] == attempt['zone']:
elif item['zone'] == attempt['zone']: # Student placed item in zone
is_correct_location = True
if 'inputOptions' in item:
# Input value will have to be provided for the item.
......@@ -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.
......
......@@ -116,7 +116,8 @@
/* Focused option */
.xblock--drag-and-drop .drag-container .item-bank .option:focus,
.xblock--drag-and-drop .drag-container .item-bank .option:hover {
.xblock--drag-and-drop .drag-container .item-bank .option:hover,
.xblock--drag-and-drop .drag-container .item-bank .option[aria-grabbed='true'] {
outline-width: 2px;
outline-style: solid;
outline-offset: -4px;
......@@ -181,7 +182,6 @@
opacity: 0.5;
}
/*** Drop Target ***/
.xblock--drag-and-drop .target {
display: table;
......@@ -231,7 +231,6 @@
/* W3C */
box-pack:center;
box-align:center;
}
/* Focused zone */
......@@ -263,7 +262,6 @@
/*** FEEDBACK ***/
.xblock--drag-and-drop .feedback {
margin-top: 20px;
border-top: solid 1px #bdbdbd;
}
......@@ -301,10 +299,68 @@
font-size: 18pt;
}
.xblock--drag-and-drop .keyboard-help {
margin-top: 3px;
margin-bottom: 6px;
}
.xblock--drag-and-drop .keyboard-help-dialog {
position: fixed;
left: 50%;
top: 50%;
width: 1px;
height: 1px;
z-index: 1500;
}
.xblock--drag-and-drop .modal-window-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
opacity: 0.5;
z-index: 1500;
}
.xblock--drag-and-drop .modal-window {
display: none;
position: absolute;
width: 600px;
max-width: 90vw;
height: auto;
transform: translate(-50%, -50%);
box-sizing: border-box;
box-shadow: 0 0 7px rgba(0, 0, 0, 0.4);
border-radius: 4px;
padding: 7px;
background-color: #e5e5e5;
text-align: left;
direction: ltr;
z-index: 1500;
}
.xblock--drag-and-drop .modal-content {
border-radius: 5px;
background: white;
margin-bottom: 5px;
padding: 5px;
}
.xblock--drag-and-drop .modal-content li {
margin-left: 2%;
}
.xblock--drag-and-drop .keyboard-help-button,
.xblock--drag-and-drop .reset-button {
cursor: pointer;
float: right;
color: #2d74b3;
}
.xblock--drag-and-drop .reset-button {
float: right;
margin-top: 3px;
}
......
......@@ -53,9 +53,18 @@
};
var itemTemplate = function(item) {
var style = {};
// Define properties
var className = (item.class_name) ? item.class_name : "";
var tabindex = 0;
if (item.has_image) {
className += " " + "option-with-image";
}
var attributes = {
'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed,
'data-value': item.value,
'data-drag-disabled': item.drag_disabled
};
var style = {};
if (item.background_color) {
style['background-color'] = item.background_color;
}
......@@ -68,34 +77,42 @@
if (item.is_placed) {
style.left = item.x_percent + "%";
style.top = item.y_percent + "%";
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";
} else {
// If an item has not been placed it must be possible to move focus to it using the keyboard:
attributes.tabindex = 0;
}
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',
h(
'div.option',
{
key: item.value,
className: className,
attributes: {
'tabindex': tabindex,
'draggable': !item.drag_disabled,
'aria-grabbed': item.grabbed,
'data-value': item.value,
'data-drag-disabled': item.drag_disabled
},
attributes: attributes,
style: style
}, [
itemSpinnerTemplate(item.xhr_active),
h('div', {innerHTML: content_html, className: "item-content"}),
itemInputTemplate(item.input)
]
},
children
)
);
};
......@@ -132,10 +149,44 @@
var properties = { attributes: { 'aria-live': 'polite' } };
return (
h('section.feedback', properties, [
h('div.reset-button', {style: {display: reset_button_display}}, gettext('Reset exercise')),
h('h3.title1', {style: {display: feedback_display}}, gettext('Feedback')),
h('p.message', {style: {display: feedback_display},
innerHTML: ctx.feedback_html})
h(
'a.reset-button',
{ style: { display: reset_button_display }, attributes: { tabindex: 0 }},
gettext('Reset exercise')
),
h('h3.title1', { style: { display: feedback_display } }, gettext('Feedback')),
h('p.message', { style: { display: feedback_display }, innerHTML: ctx.feedback_html })
])
);
};
var keyboardHelpTemplate = function(ctx) {
var dialog_attributes = { role: 'dialog', 'aria-labelledby': 'modal-window-title' };
var dialog_style = {};
return (
h('section.keyboard-help', [
h('a.keyboard-help-button', { attributes: { tabindex: 0 } }, gettext('Keyboard Help')),
h('div.keyboard-help-dialog', [
h('div.modal-window-overlay'),
h('div.modal-window', { attributes: dialog_attributes, style: dialog_style }, [
h('div.modal-header', [
h('h2.modal-window-title', gettext('Keyboard Help'))
]),
h('div.modal-content', [
h('p', gettext('You can complete this exercise using only your keyboard.')),
h('ul', [
h('li', gettext('Use "Tab" and "Shift-Tab" to navigate between items and zones.')),
h('li', gettext('Press "Enter", "Space", "Ctrl-m", or "⌘-m" on an item to select it for dropping, then navigate to the zone you want to drop it on.')),
h('li', gettext('Press "Enter", "Space", "Ctrl-m", or "⌘-m" to drop the item on the current zone.')),
h('li', gettext('Press "Escape" if you want to cancel the drop operation (e.g. because you would like to select a different item).')),
h('li', gettext('Press "?" at any time to bring up this dialog.')),
])
]),
h('div.modal-actions', [
h('button.modal-dismiss-button', gettext("OK"))
])
])
])
])
);
};
......@@ -156,10 +207,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.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}),
]),
......@@ -167,6 +225,7 @@
renderCollection(itemTemplate, items_placed, ctx),
]),
]),
keyboardHelpTemplate(ctx),
feedbackTemplate(ctx),
])
);
......
......@@ -57,6 +57,24 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_zones(self):
return self._page.find_elements_by_css_selector(".drag-container .zone")
def _get_popup(self):
return self._page.find_element_by_css_selector(".popup")
def _get_popup_content(self):
return self._page.find_element_by_css_selector(".popup .popup-content")
def _get_keyboard_help(self):
return self._page.find_element_by_css_selector(".keyboard-help")
def _get_keyboard_help_button(self):
return self._page.find_element_by_css_selector(".keyboard-help .keyboard-help-button")
def _get_keyboard_help_dialog(self):
return self._page.find_element_by_css_selector(".keyboard-help .keyboard-help-dialog")
def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button')
def _get_feedback(self):
return self._page.find_element_by_css_selector(".feedback")
......
......@@ -179,6 +179,30 @@ class TestDragAndDropRender(BaseIntegrationTest):
# Zone description should only be visible to screen readers:
self.assertEqual(zone_description.get_attribute('class'), 'zone-description sr')
def test_popup(self):
self.load_scenario()
popup = self._get_popup()
popup_content = self._get_popup_content()
self.assertFalse(popup.is_displayed())
self.assertEqual(popup.get_attribute('aria-live'), 'polite')
self.assertEqual(popup_content.text, "")
def test_keyboard_help(self):
self.load_scenario()
self._get_keyboard_help()
keyboard_help_button = self._get_keyboard_help_button()
keyboard_help_dialog = self._get_keyboard_help_dialog()
dialog_modal_overlay = keyboard_help_dialog.find_element_by_css_selector('.modal-window-overlay')
dialog_modal = keyboard_help_dialog.find_element_by_css_selector('.modal-window')
self.assertEqual(keyboard_help_button.get_attribute('tabindex'), '0')
self.assertFalse(dialog_modal_overlay.is_displayed())
self.assertFalse(dialog_modal.is_displayed())
self.assertEqual(dialog_modal.get_attribute('role'), 'dialog')
self.assertEqual(dialog_modal.get_attribute('aria-labelledby'), 'modal-window-title')
def test_feedback(self):
self.load_scenario()
......
......@@ -87,6 +87,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
})
def test_do_attempt_with_input(self):
# Drop item that requires numerical input
data = {"val": 1, "zone": self.ZONE_2, "x_percent": "0%", "y_percent": "85%"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -99,13 +100,16 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "correct_input": False},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": False, "zone": self.ZONE_2,
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
# Submit incorrect value
data = {"val": 1, "input": "250"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -118,13 +122,17 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "250", "correct_input": False},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": False, "zone": self.ZONE_2,
"input": "250",
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
}
self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET"))
# Submit correct value
data = {"val": 1, "input": "103"}
res = self.call_handler('do_attempt', data)
self.assertEqual(res, {
......@@ -137,7 +145,10 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
'items': {
"1": {"x_percent": "0%", "y_percent": "85%", "input": "103", "correct_input": True},
"1": {
"x_percent": "0%", "y_percent": "85%", "correct_input": True, "zone": self.ZONE_2,
"input": "103",
},
},
'finished': False,
'overall_feedback': self.initial_feedback(),
......@@ -177,7 +188,7 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True}
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True, "zone": self.ZONE_1}
},
"finished": False,
'overall_feedback': self.initial_feedback(),
......@@ -198,8 +209,13 @@ class BaseDragAndDropAjaxFixture(TestCaseMixin):
expected_state = {
"items": {
"0": {"x_percent": "33%", "y_percent": "11%", "correct_input": True},
"1": {"x_percent": "22%", "y_percent": "22%", "input": "99", "correct_input": True}
"0": {
"x_percent": "33%", "y_percent": "11%", "correct_input": True, "zone": self.ZONE_1,
},
"1": {
"x_percent": "22%", "y_percent": "22%", "correct_input": True, "zone": self.ZONE_2,
"input": "99",
}
},
"finished": True,
'overall_feedback': self.FINAL_FEEDBACK,
......
import unittest
from drag_and_drop_v2.default_data import (
TARGET_IMG_DESCRIPTION, START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
TARGET_IMG_DESCRIPTION, TOP_ZONE_TITLE, MIDDLE_ZONE_TITLE, BOTTOM_ZONE_TITLE,
START_FEEDBACK, FINISH_FEEDBACK, DEFAULT_DATA
)
from ..utils import make_block, TestCaseMixin
......@@ -62,25 +63,25 @@ class BasicTests(TestCaseMixin, unittest.TestCase):
assert_user_state_empty()
# Drag three items into the correct spot:
data = {"val": 0, "zone": "The Top Zone", "x_percent": "33%", "y_percent": "11%"}
data = {"val": 0, "zone": TOP_ZONE_TITLE, "x_percent": "33%", "y_percent": "11%"}
self.call_handler('do_attempt', data)
data = {"val": 1, "zone": "The Middle Zone", "x_percent": "67%", "y_percent": "80%"}
data = {"val": 1, "zone": MIDDLE_ZONE_TITLE, "x_percent": "67%", "y_percent": "80%"}
self.call_handler('do_attempt', data)
data = {"val": 2, "zone": "The Bottom Zone", "x_percent": "99%", "y_percent": "95%"}
data = {"val": 2, "zone": BOTTOM_ZONE_TITLE, "x_percent": "99%", "y_percent": "95%"}
self.call_handler('do_attempt', data)
# Check the result:
self.assertTrue(self.block.completed)
self.assertEqual(self.block.item_state, {
'0': {'x_percent': '33%', 'y_percent': '11%'},
'1': {'x_percent': '67%', 'y_percent': '80%'},
'2': {'x_percent': '99%', 'y_percent': '95%'},
'0': {'x_percent': '33%', 'y_percent': '11%', 'zone': TOP_ZONE_TITLE},
'1': {'x_percent': '67%', 'y_percent': '80%', 'zone': MIDDLE_ZONE_TITLE},
'2': {'x_percent': '99%', 'y_percent': '95%', 'zone': BOTTOM_ZONE_TITLE},
})
self.assertEqual(self.call_handler('get_user_state'), {
'items': {
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct_input': True},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct_input': True},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct_input': True},
'0': {'x_percent': '33%', 'y_percent': '11%', 'correct_input': True, 'zone': TOP_ZONE_TITLE},
'1': {'x_percent': '67%', 'y_percent': '80%', 'correct_input': True, 'zone': MIDDLE_ZONE_TITLE},
'2': {'x_percent': '99%', 'y_percent': '95%', 'correct_input': True, 'zone': BOTTOM_ZONE_TITLE},
},
'finished': True,
'overall_feedback': FINISH_FEEDBACK,
......
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