Commit 298f168e by Chris Rodriguez

AC-486 updating tabbed_view to be accessible

parent 7c647e5e
;(function (define) {
'use strict';
define(['backbone',
'underscore',
'jquery',
'text!common/templates/components/tabbed_view.underscore',
'text!common/templates/components/tab.underscore',
'text!common/templates/components/tabpanel.underscore',
], function (
Backbone,
_,
$,
tabbedViewTemplate,
tabTemplate,
tabPanelTemplate
) {
var getTabPanelId = function (id) {
return 'tabpanel-' + id;
};
define([
'backbone',
'underscore',
'jquery',
'edx-ui-toolkit/js/utils/constants',
'text!common/templates/components/tabbed_view.underscore',
'text!common/templates/components/tab.underscore',
'text!common/templates/components/tabpanel.underscore',
], function (
Backbone,
_,
$,
Constants,
tabbedViewTemplate,
tabTemplate,
tabPanelTemplate
) {
var getTabPanelId = function (id) {
return 'tabpanel-' + id;
};
var TabPanelView = Backbone.View.extend({
template: _.template(tabPanelTemplate),
initialize: function (options) {
this.url = options.url;
this.view = options.view;
this.index = options.index;
},
render: function() {
var tabPanelHtml = this.template({
tabId: getTabPanelId(this.url),
index: this.index
});
this.setElement($(tabPanelHtml));
this.$el.append(this.view.render().el);
return this;
}
});
var TabbedView = Backbone.View.extend({
events: {
'click .tab': 'switchTab',
'keydown .tab': 'keydownHandler'
},
/**
* View for a tabbed interface. Expects a list of tabs
* in its options object, each of which should contain the
* following properties:
* view (Backbone.View): the view to render for this tab.
* title (string): The title to display for this tab.
* url (string): The URL fragment which will
* navigate to this tab when a router is
* provided.
* If a router is passed in (via options.router),
* use that router to keep track of history between
* tabs. Backbone.history.start() must be called
* by the router's instantiator after this view is
* initialized.
*/
initialize: function (options) {
this.router = options.router || null;
this.tabs = options.tabs;
this.template = _.template(tabbedViewTemplate)({
viewLabel: this.viewLabel
});
// Convert each view into a TabPanelView
_.each(this.tabs, function(tabInfo, index) {
tabInfo.view = new TabPanelView({
url: tabInfo.url,
view: tabInfo.view,
index: index
});
}, this);
this.urlMap = _.reduce(this.tabs, function (map, value) {
map[value.url] = value;
return map;
}, {});
},
render: function () {
var self = this;
this.$el.html(this.template);
_.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate)({
index: index,
title: tabInfo.title,
url: tabInfo.url,
tabPanelId: getTabPanelId(tabInfo.url)
})),
tabContainerEl = this.$('.tabs');
self.$('.page-content-nav').append(tabEl);
// Render and append the current tab panel
tabContainerEl.append(tabInfo.view.render().$el);
}, this);
// Re-display the default (first) tab if the
// current route does not belong to one of the
// tabs. Otherwise continue displaying the tab
// corresponding to the current URL.
if (!(Backbone.history.getHash() in this.urlMap)) {
this.setActiveTab(0);
}
return this;
},
setActiveTab: function(index) {
var tabMeta = this.getTabMeta(index),
tab = tabMeta.tab,
view = tab.view,
$tabEl = tabMeta.element;
// Hide old tab/tabpanel
this.$('button.is-active')
.removeClass('is-active')
.attr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
this.$('.tabpanel[aria-hidden="false"]')
.addClass('is-hidden')
.attr({
'aria-hidden': 'true'
});
// Show new tab/tabpanel
if (this.router) {
this.router.navigate(tab.url, { replace: true });
}
$tabEl
.addClass('is-active')
.attr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
view.$el
.removeClass('is-hidden')
.attr({
'aria-hidden': 'false',
});
},
switchTab: function(event) {
event.preventDefault();
this.setActiveTab($(event.currentTarget).data('index'));
},
previousTab: function(focused, index) {
var $tab, $panel;
if (index === 0) {
$tab = $(focused).parent().find('.tab').last();
} else {
$tab = $(focused).parent().find('.tab:eq(' + index + ')').prev();
}
$panel = $($tab).data('index');
$tab.focus();
return false;
},
nextTab: function(focused, index, total) {
var $tab, $panel;
if (index === total) {
$tab = $(focused).parent().find('.tab').first();
} else {
$tab = $(focused).parent().find('.tab:eq(' + index + ')').next();
}
$panel = $($tab).data('index');
$tab.focus();
return false;
},
keydownHandler: function(event) {
var key = event.which,
focused = $(event.currentTarget),
index = $(focused).parent().find('.tab').index(focused),
total = $(focused).parent().find('.tab').size() - 1,
$tab = $(focused).data('index');
switch (key) {
case Constants.keyCodes.left:
case Constants.keyCodes.up:
event.preventDefault();
this.previousTab(focused, index);
break;
case Constants.keyCodes.right:
case Constants.keyCodes.down:
event.preventDefault();
this.nextTab(focused, index, total);
break;
case Constants.keyCodes.enter:
case Constants.keyCodes.space:
this.setActiveTab($tab);
break;
default:
return true;
}
},
/**
* Get the tab by name or index. Returns an object
* encapsulating the tab object and its element.
*/
getTabMeta: function (tabNameOrIndex) {
var tab, $element;
var TabPanelView = Backbone.View.extend({
template: _.template(tabPanelTemplate),
initialize: function (options) {
this.url = options.url;
this.view = options.view;
},
render: function () {
var tabPanelHtml = this.template({tabId: getTabPanelId(this.url)});
this.setElement($(tabPanelHtml));
this.$el.append(this.view.render().el);
return this;
}
});
var TabbedView = Backbone.View.extend({
events: {
'click .nav-item.tab': 'switchTab'
},
/**
* View for a tabbed interface. Expects a list of tabs
* in its options object, each of which should contain the
* following properties:
* view (Backbone.View): the view to render for this tab.
* title (string): The title to display for this tab.
* url (string): The URL fragment which will
* navigate to this tab when a router is
* provided.
* If a router is passed in (via options.router),
* use that router to keep track of history between
* tabs. Backbone.history.start() must be called
* by the router's instantiator after this view is
* initialized.
*/
initialize: function (options) {
this.router = options.router || null;
this.tabs = options.tabs;
this.template = _.template(tabbedViewTemplate)({viewLabel: options.viewLabel});
// Convert each view into a TabPanelView
_.each(this.tabs, function (tabInfo) {
tabInfo.view = new TabPanelView({url: tabInfo.url, view: tabInfo.view});
}, this);
this.urlMap = _.reduce(this.tabs, function (map, value) {
map[value.url] = value;
return map;
}, {});
},
render: function () {
var self = this;
this.$el.html(this.template);
_.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate)({
index: index,
title: tabInfo.title,
url: tabInfo.url,
tabPanelId: getTabPanelId(tabInfo.url)
})),
tabContainerEl = this.$('.tabs');
self.$('.page-content-nav').append(tabEl);
// Render and append the current tab panel
tabContainerEl.append(tabInfo.view.render().$el);
}, this);
// Re-display the default (first) tab if the
// current route does not belong to one of the
// tabs. Otherwise continue displaying the tab
// corresponding to the current URL.
if (!(Backbone.history.getHash() in this.urlMap)) {
this.setActiveTab(0);
}
return this;
},
setActiveTab: function (index) {
var tabMeta = this.getTabMeta(index),
tab = tabMeta.tab,
tabEl = tabMeta.element,
view = tab.view;
// Hide old tab/tabpanel
this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false');
this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden');
// Show new tab/tabpanel
tabEl.addClass('is-active').attr('aria-expanded', 'true');
view.$el.attr('aria-expanded', 'true').removeClass('is-hidden');
// This bizarre workaround makes focus work in Chrome.
_.defer(function () {
view.$('.sr-is-focusable.' + getTabPanelId(tab.url)).focus();
});
if (this.router) {
this.router.navigate(tab.url, {replace: true});
}
},
switchTab: function (event) {
event.preventDefault();
this.setActiveTab($(event.currentTarget).data('index'));
},
/**
* Get the tab by name or index. Returns an object
* encapsulating the tab object and its element.
*/
getTabMeta: function (tabNameOrIndex) {
var tab, element;
if (typeof tabNameOrIndex === 'string') {
tab = this.urlMap[tabNameOrIndex];
element = this.$('button[data-url='+tabNameOrIndex+']');
} else {
tab = this.tabs[tabNameOrIndex];
element = this.$('button[data-index='+tabNameOrIndex+']');
}
return {'tab': tab, 'element': element};
}
});
return TabbedView;
});
if (typeof tabNameOrIndex === 'string') {
tab = this.urlMap[tabNameOrIndex];
$element = this.$('button[data-url='+tabNameOrIndex+']');
} else {
tab = this.tabs[tabNameOrIndex];
$element = this.$('button[data-index='+tabNameOrIndex+']');
}
return {'tab': tab, 'element': $element};
}
});
return TabbedView;
});
}).call(this, define || RequireJS.define);
......@@ -4,9 +4,12 @@
define(['jquery',
'underscore',
'backbone',
'common/js/components/views/tabbed_view'
'common/js/components/views/tabbed_view',
'jquery.simulate'
],
function($, _, Backbone, TabbedView) {
var keys = $.simulate.keyCode;
var view,
TestSubview = Backbone.View.extend({
initialize: function (options) {
......@@ -22,7 +25,7 @@
return view.$('.page-content-nav');
},
activeTabPanel = function () {
return view.$('.tabpanel[aria-expanded="true"]');
return view.$('.tabpanel[aria-hidden="false"]');
};
describe('TabbedView component', function () {
......@@ -39,21 +42,10 @@
}],
viewLabel: 'Tabs',
}).render();
// _.defer() is used to make calls to
// jQuery.focus() work in Chrome. _.defer()
// delays the execution of a function until the
// current call stack is clear. That behavior
// will cause tests to fail, so we'll instead
// make _.defer() immediately invoke its
// argument.
spyOn(_, 'defer').and.callFake(function (func) {
func();
});
});
it('can render itself', function () {
expect(view.$el.html()).toContain('<nav class="page-content-nav"');
expect(view.$el.html()).toContain('<div class="page-content-nav"');
});
it('shows its first tab by default', function () {
......@@ -73,66 +65,100 @@
});
it('marks the active tab as selected using aria attributes', function () {
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false');
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
view.$('.nav-item[data-index=1]').click();
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true');
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
});
it('sets focus for screen readers', function () {
spyOn($.fn, 'focus');
view.$('.nav-item[data-url="test-2"]').click();
expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled();
it('works with keyboard navigation RIGHT and ENTER', function() {
view.$('.nav-item[data-index=0]').focus();
view.$('.nav-item[data-index=0]')
.simulate("keydown", { keyCode: keys.RIGHT })
.simulate("keydown", { keyCode: keys.ENTER });
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
});
describe('history', function() {
beforeEach(function () {
spyOn(Backbone.history, 'navigate').and.callThrough();
view = new TabbedView({
tabs: [{
url: 'test 1',
title: 'Test 1',
view: new TestSubview({text: 'this is test text'})
}, {
url: 'test 2',
title: 'Test 2',
view: new TestSubview({text: 'other text'})
}],
router: new Backbone.Router({
routes: {
'test 1': function () {
view.setActiveTab(0);
},
'test 2': function () {
view.setActiveTab(1);
}
}
})
}).render();
Backbone.history.start();
it('works with keyboard navigation DOWN and ENTER', function() {
view.$('.nav-item[data-index=0]').focus();
view.$('.nav-item[data-index=0]')
.simulate("keydown", { keyCode: keys.DOWN })
.simulate("keydown", { keyCode: keys.ENTER });
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
afterEach(function () {
view.router.navigate('');
Backbone.history.stop();
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
it('updates the page URL on tab switches without adding to browser history', function () {
view.$('.nav-item[data-index=1]').click();
expect(Backbone.history.navigate).toHaveBeenCalledWith(
'test 2',
{replace: true}
);
});
it('works with keyboard navigation LEFT and ENTER', function() {
view.$('.nav-item[data-index=1]').focus();
view.$('.nav-item[data-index=1]')
.simulate("keydown", { keyCode: keys.LEFT })
.simulate("keydown", { keyCode: keys.ENTER });
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
it('changes tabs on URL navigation', function () {
expect(view.$('.nav-item.is-active').data('index')).toEqual(0);
Backbone.history.navigate('test 2', {trigger: true});
expect(view.$('.nav-item.is-active').data('index')).toEqual(1);
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
});
it('works with keyboard navigation UP and ENTER', function() {
view.$('.nav-item[data-index=1]').focus();
view.$('.nav-item[data-index=1]')
.simulate("keydown", { keyCode: keys.UP })
.simulate("keydown", { keyCode: keys.ENTER });
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
'aria-expanded': 'false',
'aria-selected': 'false',
'tabindex': '-1'
});
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
'aria-expanded': 'true',
'aria-selected': 'true',
'tabindex': '0'
});
});
});
});
}).call(this, define || RequireJS.define);
<button class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" is-active="false" aria-expanded="false" aria-controls="<%= tabPanelId %>"><%= title %></button>
<button role="tab" class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" aria-selected="false" aria-expanded="false" aria-controls="<%= tabPanelId %>" tabindex="-1" id="tab-<%= index %>"><%= title %></button>
<nav class="page-content-nav" aria-label="<%- viewLabel %>"></nav>
<div class="page-content-nav" role="tablist"></div>
<div class="page-content-main">
<div class="tabs"></div>
</div>
<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false">
<div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div>
</div>
<div role="tabpanel" class="tabpanel is-hidden" id="<%= tabId %>" aria-labelledby="tab-<%= index %>" aria-hidden="true" tabindex="0"></div>
......@@ -826,6 +826,7 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest):
"ignore": [
'section', # TODO: AC-491
'link-href', # TODO: AC-231
'color-contrast', # TODO: AC-231
],
})
profile_page.display_accomplishments()
......
......@@ -71,9 +71,9 @@ define([
expect(teamsTabView.$('.breadcrumbs').length).toBe(0);
});
it('does not interfere with anchor links to #content', function() {
it('does not interfere with anchor links to #main', function () {
var teamsTabView = createTeamsTabView(this);
teamsTabView.router.navigate('#content', {trigger: true});
teamsTabView.router.navigate('#main', {trigger: true});
expect(teamsTabView.$('.wrapper-msg')).toHaveClass('is-hidden');
});
......
......@@ -68,7 +68,7 @@
router = this.router = new Backbone.Router();
_.each([
[':default', _.bind(this.routeNotFound, this)],
['content', _.bind(function () {
['main', _.bind(function () {
// The backbone router unfortunately usurps the
// default behavior of in-page-links. This hack
// prevents the screen reader in-page-link from
......@@ -580,7 +580,7 @@
/**
* Set up the tabbed view and switch tabs.
*/
goToTab: function (tab) {
goToTab: function(tab) {
this.mainView = this.tabbedView;
// Note that `render` should be called first so
// that the tabbed view's element is set
......
<div class="sr-is-focusable sr-teams-view" tabindex="-1"></div>
<div class="sr-is-focusable sr-teams-view" tabindex="-1" aria-label="Tab content"></div>
<div class="teams-paging-header"></div>
<div class="teams-list"></div>
<div class="teams-paging-footer"></div>
<div class="sr-is-focusable sr-topics-view" tabindex="-1"></div>
<div class="sr-is-focusable sr-topics-view" tabindex="-1" aria-label="Tab content"></div>
<div class="topics-paging-header"></div>
<div class="topics-list"></div>
<div class="topics-paging-footer"></div>
......@@ -22,8 +22,10 @@ from openedx.core.djangolib.js_utils import (
<div class="container">
<div class="teams-wrapper">
<section class="teams-content">
</section>
<main id="main" aria-label="Content" tabindex="-1">
<section class="teams-content">
</section>
</main>
</div>
</div>
......
......@@ -8,6 +8,7 @@
// +utility navigation
// +toggling utilities
// +case - calculator spacing
// +tabs
// +notes:
// --------------------
......@@ -128,26 +129,43 @@
%page-content-nav {
margin-bottom: $baseline;
border-bottom: 3px solid $gray-l5;
border-bottom: 1px solid $gray-l5;
.nav-item {
@extend %button-reset;
display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75);
color: $gray-d2;
&.is-active {
border-bottom: 3px solid $gray-d2;
border-bottom: 4px solid $link-color;
color: $gray-d2;
}
// STATE: hover and focus
&:hover,
&:focus {
border-bottom: 3px solid $link-color;
border-bottom: 4px solid $link-color;
color: $link-color;
}
}
}
// +tabs - styles for tabs and tabpanels (teams and learner profile, currently)
// --------------------
.page-content-nav {
.tab {
}
}
.page-content-main {
.tabs {
.tabpanel {
outline: none;
}
}
}
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