context modules drag-and-drop keyboard accessibility

comparable to the course navigation ui (CNVS-288)

test plan:
* on the course modules page:
 * read hidden instructions at top of navigation tab
 * In Jaws: Use Insert-Z to turn virtal cursor off
 * use the tab key to focus on one of the modules
 or module items
 * use up/down arrow keys to change focus between items
 * use spacebar to start dragging the module/item
 * use up/down again to move around
 * use spacebar again to drop it after the currently
   focused module/item

fixes #CNVS-4944

Change-Id: Ie8e2e7dbca5268718d1802ab01602cd6f6d4218d
Reviewed-on: https://gerrit.instructure.com/19293
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Adam Phillipps <adam@instructure.com>
Product-Review: Dana Morley <dana@instructure.com>
This commit is contained in:
James Williams 2013-04-02 13:27:47 -06:00
parent 87e8548b1e
commit 2e8e2c4155
7 changed files with 204 additions and 49 deletions

View File

@ -3,8 +3,10 @@ define [
'jquery'
'i18n!context_modules'
'jquery.loadingImg'
], (Backbone, $, I18n) ->
], (Backbone, $, I18n) ->
class ContextModules extends Backbone.View
@optionProperty 'modules'
#events:
# 'click .change-workflow-state-link' : 'toggleWorkflowState'
@ -100,3 +102,125 @@ define [
@$context_module.removeClass 'unpublished_module'
# Drag-And-Drop Accessibility:
keyCodes:
32: 'Space'
38: 'UpArrow'
40: 'DownArrow'
moduleSelector: "div.context_module"
itemSelector: "table.context_module_item"
initialize: ->
@$contextModules = $("#context_modules")
@$contextModules.parent().on 'keydown', @onKeyDown
onKeyDown: (e) =>
$target = $(e.target)
fn = "on#{@keyCodes[e.keyCode]}Key"
if @[fn]
e.preventDefault()
@[fn].call(this, e, $target)
getFocusedElement: (el) ->
parent = el.parents(@itemSelector).first()
el = parent unless @empty(parent)
unless el.is(@itemSelector)
parent = el.parents(@moduleSelector).first()
el = parent unless @empty(parent)
unless el.is(@moduleSelector)
el = @$contextModules
el
# Internal: move to the previous element
# returns nothing
onUpArrowKey: (e, $target) ->
el = @getFocusedElement($target)
if el.is(@itemSelector)
prev = el.prev(@itemSelector)
if @empty(prev) || @$contextModules.data('dragModule')
prev = el.parents(@moduleSelector).first()
else if el.is(@moduleSelector)
if @$contextModules.data('dragItem')
prev = @$contextModules.data('dragItemModule')
else
prev = el.prev(@moduleSelector)
if @empty(prev)
prev = @$contextModules
else if !@$contextModules.data('dragModule')
lastChild = prev.find(@itemSelector).last()
prev = lastChild unless @empty(lastChild)
prev.focus() if prev && !@empty(prev)
# Internal: move to the next element
# returns nothing
onDownArrowKey: (e, $target) ->
el = @getFocusedElement($target)
if el.is(@itemSelector)
next = el.next(@itemSelector)
if @empty(next) && !@$contextModules.data('dragItem')
parent = el.parents(@moduleSelector).first()
next = parent.next(@moduleSelector)
else if el.is(@moduleSelector)
next = el.find(@itemSelector).first()
if @empty(next) || @$contextModules.data('dragModule')
next = el.next(@moduleSelector)
else
next = @$contextModules.find(@moduleSelector).first()
next.focus() if next && !@empty(next)
# Internal: mark the current element to begin dragging
# or drop the current element
# returns nothing
onSpaceKey: (e, $target) ->
el = @getFocusedElement($target)
if dragItem = @$contextModules.data('dragItem')
unless el.is(dragItem)
parentModule = @$contextModules.data('dragItemModule')
if el.is(@itemSelector) && !@empty(el.parents(parentModule)) # i.e. it's an item in the same module
el.after(dragItem)
else
parentModule.find('.items').prepend(dragItem)
modules.updateModuleItemPositions(null, item: dragItem.parent())
dragItem.attr('aria-grabbed', false)
@$contextModules.data('dragItem', null)
@$contextModules.data('dragItemModule', null)
dragItem.focus()
else if dragModule = @$contextModules.data('dragModule')
if el.is(@itemSelector)
el = el.parents(@moduleSelector).first()
if !el.is(dragModule)
if @empty(el) || el.is(@$contextModules)
@$contextModules.prepend(dragModule)
else
el.after(dragModule)
modules.updateModulePositions()
dragModule.attr('aria-grabbed', false)
@$contextModules.data('dragModule', null)
dragModule.focus()
else if !el.is(@$contextModules)
el.attr('aria-grabbed', true)
if el.is(@itemSelector)
@$contextModules.data('dragItem', el)
@$contextModules.data('dragItemModule', el.parents(@moduleSelector).first())
else if el.is(@moduleSelector)
@$contextModules.data('dragModule', el)
el.blur()
el.focus()
# Internal: returns whether the selector is empty
empty: (selector) ->
selector.length == 0

View File

@ -75,13 +75,15 @@ define [
$target.attr('tabindex', -1)
$target.unbind('keydown')
dragObject.attr('aria-grabbed', false)
dragObject.attr('aria-grabbed', false)
@$el.data('drag', null)
dragObject.focus()
else if $target.is('li.navitem')
$target.attr('aria-grabbed', true)
dragObject = $target
@$el.data('drag', dragObject)
dragObject.blur()
dragObject.focus()
# Internal: returns whether the selector is empty
empty: (selector) ->

View File

@ -151,6 +151,8 @@ define [
model: $target.data('view').model
parent: $target.parent().data('view')
$sidebar.data('drag', dragObject)
$target.blur()
$target.focus()
# Internal: Cancel a drag and drop action.
#

View File

@ -37,8 +37,24 @@ TEXT
<p><%= t('help.no_modules', %{No modules have been defined for this course.}) %></p>
<% end %>
</div>
<div class="hidden-readable" tabindex="0" aria-label="keyboard instructions">
<%= t('modules_keyboard_hint',
'To change the order of the course modules and module items,
first turn the cursor off on your screen reader. Insert-z in JAWS.
Press tab to select the first module.
Press up and down to choose a module or module item.
Press space to select the module or module item to start dragging.
Then press up and down to select a destination.
Then press space a second time to drop selection after destination.') %>
</div>
<% keyboard_navigation([
{:key => t('keycodes.next_module_item', 'Up'), :description => t('keycode_descriptions.next_module_item', 'Select next module or module item')},
{:key => t('keycodes.previous_module_item', 'Down'), :description => t('keycode_descriptions.previous_module_item', 'Select previous module or module item')},
{:key => t('keycodes.toggle_module_dragging', 'Space'), :description => t('keycode_descriptions.toggle_module_dragging', 'Select item to begin dragging, or drop previously selected item')}
]) %>
<div id="context_modules_sortable_container">
<div id="context_modules" class="<%= 'editable' if can_do(@context, @current_user, :manage_content) %>">
<div id="context_modules" tabindex="0" aria-label="<%= t('headings.course_modules', %{Course Modules}) %>" class="<%= 'editable' if can_do(@context, @current_user, :manage_content) %>">
<% editable = can_do(@context, @current_user, :manage_grades) || can_do(@context, @current_user, :manage_content) %>
<% cache([@context.cache_key, editable, 'all_context_modules', collection_cache_key(@modules)].join('/')) do %>
<% ContextModule.send(:preload_associations, @modules, [:context_module_progressions, {:content_tags => :content}]) %>

View File

@ -5,7 +5,7 @@
%>
<% cache_if_module(context_module, editable) do %>
<div class="context_module bordered <%= 'unpublished_module' if workflow_state == "unpublished" %> <%= 'editable_context_module' if editable %>" data-workflow-state="<%= context_module ? context_module.workflow_state : "{{ workflow_state }}"%>" data-module-url="<%= context_url(@context, :context_url) %>/modules/<%= context_module ? context_module.id : "{{ id }}" %>" data-module-id="<%= context_module ? context_module.id : "{{ id }}" %>" id="context_module_<%= context_module ? context_module.id : "blank" %>" style="<%= hidden unless context_module %>">
<div class="context_module bordered <%= 'unpublished_module' if workflow_state == "unpublished" %> <%= 'editable_context_module' if editable %>" tabindex="0" aria-label="<%= context_module ? context_module.name : "" %>" data-workflow-state="<%= context_module ? context_module.workflow_state : "{{ workflow_state }}"%>" data-module-url="<%= context_url(@context, :context_url) %>/modules/<%= context_module ? context_module.id : "{{ id }}" %>" data-module-id="<%= context_module ? context_module.id : "{{ id }}" %>" id="context_module_<%= context_module ? context_module.id : "blank" %>" style="<%= hidden unless context_module %>">
<a name="module_<%= context_module.id if context_module %>"></a>
<div class="header context-module-header clearfix">

View File

@ -15,7 +15,7 @@
<% criterion = completion_criteria && completion_criteria.find{|c| c[:id] == tag.id}
item_class = "#{module_item.content_type}_#{module_item.content_id}" if module_item
%>
<table id="context_module_item_<%= tag ? tag.id : "blank" %>" class="context_module_item <%= module_item.content_type_class if module_item %> <%= 'also_assignment' if module_item && module_item.graded? %> indent_<%= tag.try_rescue(:indent) || '0' %> <%= 'progression_requirement' if criterion %> <%= criterion[:type] if criterion %>_requirement <%= item_class %>" style="<%= hidden unless module_item %>">
<table id="context_module_item_<%= tag ? tag.id : "blank" %>" tabindex="0" aria-label="<%= tag ? tag.title : "" %>" class="context_module_item <%= module_item.content_type_class if module_item %> <%= 'also_assignment' if module_item && module_item.graded? %> indent_<%= tag.try_rescue(:indent) || '0' %> <%= 'progression_requirement' if criterion %> <%= criterion[:type] if criterion %>_requirement <%= item_class %>" style="<%= hidden unless module_item %>">
<tr>
<td class="module_item_icons">
<div class="nobr">

View File

@ -56,6 +56,49 @@ define([
}
return indent;
},
updateModulePositions: function() {
var ids = []
$("#context_modules .context_module").each(function() {
ids.push($(this).attr('id').substring('context_module_'.length));
});
var url = $(".reorder_modules_url").attr('href');
$("#context_modules").loadingImage();
$.ajaxJSON(url, 'POST', {order: ids.join(",")}, function(data) {
$("#context_modules").loadingImage('remove');
for(var idx in data) {
var module = data[idx];
$("#context_module_" + module.context_module.id).triggerHandler('update', module);
}
}, function(data) {
$("#context_modules").loadingImage('remove');
});
},
updateModuleItemPositions: function(event, ui) {
var $module = ui.item.parents(".context_module");
var url = $module.find(".reorder_items_url").attr('href');
$module.find(".content").loadingImage();
var items = [];
$module.find(".context_module_items .context_module_item").each(function() {
items.push($(this).getTemplateData({textValues: ['id']}).id);
});
$.ajaxJSON(url, 'POST', {order: items.join(",")}, function(data) {
$module.find(".content").loadingImage('remove');
if(data && data.context_module && data.context_module.content_tags) {
for(var idx in data.context_module.content_tags) {
var tag = data.context_module.content_tags[idx].content_tag;
$module.find("#context_module_item_" + tag.id).fillTemplateData({
data: {position: tag.position}
});
}
}
}, function(data) {
$module.find(".content").loadingImage('remove');
$module.find(".content").errorBox(I18n.t('errors.reorder', 'Reorder failed, please try again.'));
});
},
refreshProgressions: function(show_links) {
if (ENV.NO_MODULE_PROGRESSIONS) return;
@ -285,6 +328,7 @@ define([
}
$item.addClass(data.type + "_" + data.id);
$item.addClass(data.type);
$item.attr('aria-label', data.title);
$item.fillTemplateData({
data: data,
id: 'context_module_item_' + data.id,
@ -411,30 +455,7 @@ define([
placeholder: 'context_module_placeholder',
forcePlaceholderSize: true,
axis: 'y',
containment: "#context_modules",
update: function(event, ui) {
var $module = ui.item.parents(".context_module");
var url = $module.find(".reorder_items_url").attr('href');
$module.find(".content").loadingImage();
var items = [];
$module.find(".context_module_items .context_module_item").each(function() {
items.push($(this).getTemplateData({textValues: ['id']}).id);
});
$.ajaxJSON(url, 'POST', {order: items.join(",")}, function(data) {
$module.find(".content").loadingImage('remove');
if(data && data.context_module && data.context_module.content_tags) {
for(var idx in data.context_module.content_tags) {
var tag = data.context_module.content_tags[idx].content_tag;
$module.find("#context_module_item_" + tag.id).fillTemplateData({
data: {position: tag.position}
});
}
}
}, function(data) {
$module.find(".content").loadingImage('remove');
$module.find(".content").errorBox(I18n.t('errors.reorder', 'Reorder failed, please try again.'));
});
}
containment: "#context_modules"
}
};
})();
@ -443,7 +464,8 @@ define([
modules.initModuleManagement = function() {
// Create the context modules backbone view to manage the publish button.
var context_modules_view = new ContextModulesView({
el: $("#content")
el: $("#content"),
modules: modules
});
$("#unlock_module_at").change(function() {
@ -460,6 +482,7 @@ define([
$(".context_module").bind('update', function(event, data) {
data.context_module.unlock_at = $.parseFromISO(data.context_module.unlock_at).datetime_formatted;
var $module = $("#context_module_" + data.context_module.id);
$module.attr('aria-label', data.context_module.name);
$module.find(".header").fillTemplateData({
data: data.context_module,
hrefValues: ['id']
@ -750,7 +773,9 @@ define([
event.preventDefault();
var $module = $("#context_module_blank").clone(true).attr('id', 'context_module_new');
$("#context_modules").append($module);
$module.find(".context_module_items").sortable(modules.sortable_module_options);
var opts = modules.sortable_module_options;
opts['update'] = modules.updateModuleItemPositions;
$module.find(".context_module_items").sortable(opts);
$("#context_modules.ui-sortable").sortable('refresh');
$("#context_modules .context_module .context_module_items.ui-sortable").each(function() {
$(this).sortable('refresh');
@ -873,7 +898,9 @@ define([
var next = function() {
if($items.length > 0) {
var $item = $items.shift();
$item.sortable(modules.sortable_module_options);
var opts = modules.sortable_module_options;
opts['update'] = modules.updateModuleItemPositions;
$item.sortable(opts);
setTimeout(next, 10);
}
};
@ -883,23 +910,7 @@ define([
helper: 'clone',
containment: '#context_modules_sortable_container',
axis: 'y',
update: function(event, ui) {
var ids = []
$("#context_modules .context_module").each(function() {
ids.push($(this).attr('id').substring('context_module_'.length));
});
var url = $(".reorder_modules_url").attr('href');
$("#context_modules").loadingImage();
$.ajaxJSON(url, 'POST', {order: ids.join(",")}, function(data) {
$("#context_modules").loadingImage('remove');
for(var idx in data) {
var module = data[idx];
$("#context_module_" + module.context_module.id).triggerHandler('update', module);
}
}, function(data) {
$("#context_modules").loadingImage('remove');
});
}
update: modules.updateModulePositions
});
modules.refreshModuleList();
modules.refreshed = true;