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:
parent
87e8548b1e
commit
2e8e2c4155
|
@ -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
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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.
|
||||
#
|
||||
|
|
|
@ -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}]) %>
|
||||
|
|
|
@ -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">
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue