Commit 1c0c2904 by Matjaz Gregoric

[TNL-6028][TNL-6020] Implement back to beginning button.

The 'Go to Beginning' button is only exposed to keyboard and
screenreader users. It automatically receives focus on next TAB press
after dropping an item, except when a feedback popup is shown after
dropping the item, in which case the focus moves to the 'close' button
of the popup.

Clicking any of these two buttons moves focus back to the first item in
the item bank.
parent 8821e1b1
......@@ -310,21 +310,22 @@ function DragAndDropTemplates(configuration) {
);
};
var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, disabled, spinner) {
if (spinner) {
var sidebarButtonTemplate = function(buttonClass, iconClass, buttonText, options) {
options = options || {};
if (options.spinner) {
iconClass = 'fa-spin.fa-spinner';
}
return (
h('span.sidebar-button-wrapper', {}, [
h(
'button.unbutton.btn-default.btn-small.'+buttonClass,
{disabled: disabled || spinner || false, attributes: {tabindex: 0}},
'button.unbutton.btn-default.btn-small',
{
className: buttonClass,
disabled: options.disabled || options.spinner || false,
attributes: {tabindex: 0}
},
[
h(
"span.btn-icon.fa." + iconClass,
{attributes: {"aria-hidden": true}},
[]
),
h("span.btn-icon.fa", {className: iconClass, attributes: {"aria-hidden": true}}),
buttonText
]
)
......@@ -335,17 +336,35 @@ function DragAndDropTemplates(configuration) {
var sidebarTemplate = function(ctx) {
var showAnswerButton = null;
if (ctx.show_show_answer) {
var options = {
disabled: ctx.showing_answer ? true : ctx.disable_show_answer_button,
spinner: ctx.show_answer_spinner
};
showAnswerButton = sidebarButtonTemplate(
"show-answer-button",
"fa-info-circle",
gettext('Show Answer'),
ctx.showing_answer ? true : ctx.disable_show_answer_button,
ctx.show_answer_spinner
options
);
}
var go_to_beginning_button_class = 'go-to-beginning-button';
if (!ctx.show_go_to_beginning_button) {
go_to_beginning_button_class += ' sr';
}
return(
h("section.action-toolbar-item.sidebar-buttons", {}, [
sidebarButtonTemplate("reset-button", "fa-refresh", gettext('Reset'), ctx.disable_reset_button),
sidebarButtonTemplate(
go_to_beginning_button_class,
"fa-arrow-up",
gettext("Go to Beginning"),
{disabled: ctx.disable_go_to_beginning_button}
),
sidebarButtonTemplate(
"reset-button",
"fa-refresh",
gettext('Reset'),
{disabled: ctx.disable_reset_button}
),
showAnswerButton,
])
)
......@@ -357,8 +376,6 @@ function DragAndDropTemplates(configuration) {
var have_messages = msgs.length > 0;
var popup_content;
var close_button_describedby_id = "close-popup-"+configuration.url_name;
if (msgs.length > 0 && !ctx.last_action_correct) {
popupSelector += '.popup-incorrect';
}
......@@ -613,6 +630,9 @@ function DragAndDropBlock(runtime, element, configuration) {
// Set up event handlers:
$element.on('click', '.item-feedback-popup .close-feedback-popup-button', closePopupEventHandler);
$element.on('keydown', '.item-feedback-popup .close-feedback-popup-button', registerPopupCloseButtonKeydown);
$element.on('keyup', '.item-feedback-popup .close-feedback-popup-button', preventFauxPopupCloseButtonClick);
$element.on('click', '.submit-answer-button', doAttempt);
$element.on('click', '.keyboard-help-button', showKeyboardHelp);
$element.on('keydown', '.keyboard-help-button', function(evt) {
......@@ -627,6 +647,20 @@ function DragAndDropBlock(runtime, element, configuration) {
runOnKey(evt, RET, showAnswer);
});
// We need to register both mousedown and click event handlers because in some browsers the blur
// event is emitted right after mousedown, hiding our button and preventing the click event from
// being emitted.
// We still need the click handler to catch keydown events (other than RET which is handled below),
// since in some browser/OS combinations some other keyboard button presses (for example space bar)
// are also treated as clicks,
$element.on('mousedown click', '.go-to-beginning-button', onGoToBeginningButtonClick);
$element.on('keydown', '.go-to-beginning-button', function(evt) {
runOnKey(evt, RET, onGoToBeginningButtonClick);
});
// Go to Beginning button should only be visible when it has focus.
$element.on('focus', '.go-to-beginning-button', showGoToBeginningButton);
$element.on('blur', '.go-to-beginning-button', hideGoToBeginningButton);
// For the next one, we need to use addEventListener with useCapture 'true' in order
// to watch for load events on any child element, since load events do not bubble.
element.addEventListener('load', webkitFix, true);
......@@ -671,6 +705,55 @@ function DragAndDropBlock(runtime, element, configuration) {
}
};
var onGoToBeginningButtonClick = function(evt) {
evt.preventDefault();
// In theory the blur event handler should hide the button,
// but the blur event does not fire consistently in all browsers,
// so invoke hideGoToBeginningButton now to make sure it gets hidden.
// Invoking hideGoToBeginningButton multiple times is harmless.
hideGoToBeginningButton();
focusFirstDraggable();
};
var showGoToBeginningButton = function() {
if (!state.go_to_beginning_button_visible) {
state.go_to_beginning_button_visible = true;
applyState();
}
};
var hideGoToBeginningButton = function() {
if (state.go_to_beginning_button_visible) {
state.go_to_beginning_button_visible = false;
applyState();
}
};
// Browsers will emulate click events on keyboard keyup events.
// The feedback popup is shown very quickly after the user drops the item on the board.
// If the user uses the keyboard to drop the item, and the popup gets displayed and focused
// *before* the user releases the key, most browsers will emit an emulated click event on the
// close popup button. We prevent these from happenning by only letting the browser emulate
// a click event on keyup if the close button received a keydown event prior to the keyup.
var _popup_close_button_keydown_received = false;
var registerPopupCloseButtonKeydown = function(evt) {
_popup_close_button_keydown_received = true;
};
var preventFauxPopupCloseButtonClick = function(evt) {
if (_popup_close_button_keydown_received) {
// The close button received a keydown event prior to this keyup,
// so this event is genuine.
_popup_close_button_keydown_received = false;
} else {
// There was no keydown prior to this keyup, so the keydown must have happend *before*
// the popup was displayed and focused and the keypress is still in progress.
// Make the browser ignore this keyup event.
evt.preventDefault();
}
};
var focusModalButton = function() {
$root.find('.keyboard-help-dialog .modal-dismiss-button ').focus();
};
......@@ -851,6 +934,11 @@ function DragAndDropBlock(runtime, element, configuration) {
return key === SPC;
};
var isTabKey = function(evt) {
var key = evt.which;
return key === TAB;
};
var focusNextZone = function(evt, $currentZone) {
var zones = $root.find('.target .zone').toArray();
// In assessment mode, item bank is a valid drop zone
......@@ -873,6 +961,10 @@ function DragAndDropBlock(runtime, element, configuration) {
zones[idx].focus();
};
var focusGoToBeginningButton = function() {
$root.find('.go-to-beginning-button').focus();
};
var focusFirstDraggable = function() {
$root.find('.item-bank .option').first().focus();
};
......@@ -880,7 +972,7 @@ function DragAndDropBlock(runtime, element, configuration) {
var focusItemFeedbackPopup = function() {
var popup = $root.find('.item-feedback-popup');
if (popup.length && popup.is(":visible")) {
popup.focus();
popup.find('.close-feedback-popup-button').focus();
return true;
}
return false;
......@@ -925,6 +1017,11 @@ function DragAndDropBlock(runtime, element, configuration) {
}).length;
};
var canGoToBeginning = function() {
var all_items_placed = configuration.items.length === Object.keys(state.items).length;
return !all_items_placed && !state.finished;
};
var initDroppable = function() {
// Set up zones for keyboard interaction
$root.find('.zone, .item-bank').each(function() {
......@@ -949,6 +1046,13 @@ function DragAndDropBlock(runtime, element, configuration) {
placeItem($zone);
}
}
} else if (isTabKey(evt) && !evt.shiftKey) {
// If the user just dropped an item to this zone, next TAB keypress
// should move focus to "Go to Beginning" button.
if (state.tab_to_go_to_beginning_button && canGoToBeginning()) {
evt.preventDefault();
focusGoToBeginningButton();
}
} else if (isSpaceKey(evt)) {
// Pressing the space bar moves the page down by default in most browsers.
// That can be distracting while moving items with the keyboard, so prevent
......@@ -956,6 +1060,9 @@ function DragAndDropBlock(runtime, element, configuration) {
evt.preventDefault();
}
});
$zone.on('blur', function() {
delete state.tab_to_go_to_beginning_button;
});
});
// Make zones accept items that are dropped using the mouse
......@@ -1112,6 +1219,13 @@ function DragAndDropBlock(runtime, element, configuration) {
}
}
applyState();
if (state.feedback && state.feedback.length > 0) {
// Move focus the the close button of the feedback popup.
focusItemFeedbackPopup();
} else {
// Next tab press should take us to the "Go to Beginning" button.
state.tab_to_go_to_beginning_button = true;
}
})
.fail(function (data) {
delete state.items[item_id];
......@@ -1133,8 +1247,13 @@ function DragAndDropBlock(runtime, element, configuration) {
return;
}
closePopup(target.is(Selector.close_button) || target.parent().is(Selector.close_button));
var manually_closed = target.is(Selector.close_button) || target.parent().is(Selector.close_button);
closePopup(manually_closed);
applyState();
if (manually_closed) {
focusFirstDraggable();
}
};
var closePopup = function(manually_closed) {
......@@ -1327,7 +1446,9 @@ function DragAndDropBlock(runtime, element, configuration) {
disable_submit_button: !canSubmitAttempt(),
submit_spinner: state.submit_spinner,
showing_answer: state.showing_answer,
show_answer_spinner: state.show_answer_spinner
show_answer_spinner: state.show_answer_spinner,
disable_go_to_beginning_button: !canGoToBeginning(),
show_go_to_beginning_button: state.go_to_beginning_button_visible
};
return renderView(context);
......
......@@ -531,6 +531,10 @@ msgid "Close"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Go to Beginning"
msgstr ""
#: public/js/drag_and_drop.js
msgid "Problem"
msgstr ""
......
......@@ -631,6 +631,10 @@ msgid "Close"
msgstr "Çlösé Ⱡ'σя#"
#: public/js/drag_and_drop.js
msgid "Go to Beginning"
msgstr "Gö tö Bégïnnïng Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
#: public/js/drag_and_drop.js
msgid "Problem"
msgstr "Prößlém Ⱡ'σяєм ιρѕυм #"
......
......@@ -124,6 +124,9 @@ class BaseIntegrationTest(SeleniumBaseTest):
def _get_keyboard_help_dialog(self):
return self._page.find_element_by_css_selector(".keyboard-help-dialog")
def _get_go_to_beginning_button(self):
return self._page.find_element_by_css_selector('.go-to-beginning-button')
def _get_reset_button(self):
return self._page.find_element_by_css_selector('.reset-button')
......@@ -152,6 +155,14 @@ class BaseIntegrationTest(SeleniumBaseTest):
query = 'return $("{selector}").get(0).style.{style}'
return self.browser.execute_script(query.format(selector=selector, style=style))
def assertFocused(self, element):
focused_element = self.browser.switch_to.active_element
self.assertTrue(element == focused_element, 'expected element to have focus')
def assertNotFocused(self, element):
focused_element = self.browser.switch_to.active_element
self.assertTrue(element != focused_element, 'expected element to not have focus')
@staticmethod
def get_element_html(element):
return element.get_attribute('innerHTML').strip()
......@@ -310,10 +321,6 @@ class InteractionTestBase(object):
def assertNotDraggable(self, item_value):
self.assertFalse(self._get_draggable_property(item_value))
def assertFocused(self, element):
focused_element = self.browser.switch_to.active_element
self.assertTrue(element == focused_element, 'expected element to have focus')
@staticmethod
def wait_until_ondrop_xhr_finished(elem):
"""
......
......@@ -63,7 +63,7 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, BaseEventsT
{
'name': 'edx.drag_and_drop_v2.feedback.closed',
'data': {
'manually': False,
'manually': True,
'content': ITEM_CORRECT_FEEDBACK.format(zone=TOP_ZONE_TITLE),
'truncated': False,
},
......
......@@ -27,6 +27,22 @@ ITEM_DRAG_KEYBOARD_KEYS = (None, Keys.RETURN, Keys.CONTROL+'m')
class ParameterizedTestsMixin(object):
def _test_popup_focus_and_close(self, popup):
dismiss_popup_button = popup.find_element_by_css_selector('.close-feedback-popup-button')
self.assertFocused(dismiss_popup_button)
dismiss_popup_button.click()
self.assertFalse(popup.is_displayed())
# Assert focus moves to first enabled button in item bank after closing the popup.
focusable_items_in_bank = [item for item in self._get_items() if item.get_attribute('tabindex') == '0']
if len(focusable_items_in_bank) > 0:
self.assertFocused(focusable_items_in_bank[0])
def _test_next_tab_goes_to_go_to_beginning_button(self):
go_to_beginning_button = self._get_go_to_beginning_button()
self.assertNotFocused(go_to_beginning_button)
ActionChains(self.browser).send_keys(Keys.TAB).perform()
self.assertFocused(go_to_beginning_button)
def parameterized_item_positive_feedback_on_good_move(
self, items_map, scroll_down=100, action_key=None, assessment_mode=False
):
......@@ -44,10 +60,14 @@ class ParameterizedTestsMixin(object):
if assessment_mode:
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
if action_key:
# Next TAB keypress should move focus to "Go to Beginning button"
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.assertEqual(feedback_popup_html, "<p>{}</p>".format(definition.feedback_positive))
self.assert_popup_correct(popup)
self.assertTrue(popup.is_displayed())
self._test_popup_focus_and_close(popup)
def parameterized_item_negative_feedback_on_bad_move(
self, items_map, all_zones, scroll_down=100, action_key=None, assessment_mode=False
......@@ -75,11 +95,14 @@ class ParameterizedTestsMixin(object):
self.assertEqual(feedback_popup_html, '')
self.assertFalse(popup.is_displayed())
self.assert_placed_item(definition.item_id, zone_title, assessment_mode=True)
if action_key:
self._test_next_tab_goes_to_go_to_beginning_button()
else:
self.wait_until_html_in(definition.feedback_negative, feedback_popup_content)
self.assert_popup_incorrect(popup)
self.assertTrue(popup.is_displayed())
self.assert_reverted_item(definition.item_id)
self._test_popup_focus_and_close(popup)
def parameterized_move_items_between_zones(self, items_map, all_zones, scroll_down=100, action_key=None):
# Scroll drop zones into view to make sure Selenium can successfully drop items
......@@ -90,6 +113,8 @@ class ParameterizedTestsMixin(object):
for zone_id, zone_title in all_zones:
self.place_item(item_key, zone_id, action_key)
self.assert_placed_item(item_key, zone_title, assessment_mode=True)
if action_key:
self._test_next_tab_goes_to_go_to_beginning_button()
# Finally, move them all back to the bank.
self.place_item(item_key, None, action_key)
self.assert_reverted_item(item_key)
......
......@@ -2,6 +2,7 @@
from ddt import ddt, unpack, data
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.keys import Keys
from xblockutils.resources import ResourceLoader
......@@ -210,6 +211,30 @@ class TestDragAndDropRender(BaseIntegrationTest):
self.assertEqual(popup_content.text, "")
self.assertEqual(popup_wrapper.get_attribute('aria-live'), 'polite')
@data(None, Keys.RETURN)
def test_go_to_beginning_button(self, action_key):
self.load_scenario()
self.scroll_down(250)
button = self._get_go_to_beginning_button()
self.assertEqual(button.get_attribute('tabindex'), '0')
# Button is only visible to screen reader users by default.
self.assertIn('sr', button.get_attribute('class').split())
# Set focus to the element (cannot find a way to do this without execute_script).
self.browser.execute_script('$("button.go-to-beginning-button").focus()')
self.assertFocused(button)
# Button should be visible when focused.
self.assertNotIn('sr', button.get_attribute('class').split())
# Click/activate the button to move focus to the top.
if action_key:
button.send_keys(action_key)
else:
button.click()
first_focusable_item = self._get_items()[0]
self.assertFocused(first_focusable_item)
# Button should only be visible to screen readers again.
self.assertIn('sr', button.get_attribute('class').split())
def test_keyboard_help(self):
self.load_scenario()
......
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