Commit 298f168e by Chris Rodriguez

AC-486 updating tabbed_view to be accessible

parent 7c647e5e
;(function (define) { ;(function (define) {
'use strict'; 'use strict';
define(['backbone',
'underscore', define([
'jquery', 'backbone',
'text!common/templates/components/tabbed_view.underscore', 'underscore',
'text!common/templates/components/tab.underscore', 'jquery',
'text!common/templates/components/tabpanel.underscore', 'edx-ui-toolkit/js/utils/constants',
], function ( 'text!common/templates/components/tabbed_view.underscore',
Backbone, 'text!common/templates/components/tab.underscore',
_, 'text!common/templates/components/tabpanel.underscore',
$, ], function (
tabbedViewTemplate, Backbone,
tabTemplate, _,
tabPanelTemplate $,
) { Constants,
var getTabPanelId = function (id) { tabbedViewTemplate,
return 'tabpanel-' + id; 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({ if (typeof tabNameOrIndex === 'string') {
template: _.template(tabPanelTemplate), tab = this.urlMap[tabNameOrIndex];
initialize: function (options) { $element = this.$('button[data-url='+tabNameOrIndex+']');
this.url = options.url; } else {
this.view = options.view; tab = this.tabs[tabNameOrIndex];
}, $element = this.$('button[data-index='+tabNameOrIndex+']');
render: function () { }
var tabPanelHtml = this.template({tabId: getTabPanelId(this.url)}); return {'tab': tab, 'element': $element};
this.setElement($(tabPanelHtml)); }
this.$el.append(this.view.render().el); });
return this; return TabbedView;
} });
});
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;
});
}).call(this, define || RequireJS.define); }).call(this, define || RequireJS.define);
...@@ -4,9 +4,12 @@ ...@@ -4,9 +4,12 @@
define(['jquery', define(['jquery',
'underscore', 'underscore',
'backbone', 'backbone',
'common/js/components/views/tabbed_view' 'common/js/components/views/tabbed_view',
'jquery.simulate'
], ],
function($, _, Backbone, TabbedView) { function($, _, Backbone, TabbedView) {
var keys = $.simulate.keyCode;
var view, var view,
TestSubview = Backbone.View.extend({ TestSubview = Backbone.View.extend({
initialize: function (options) { initialize: function (options) {
...@@ -22,7 +25,7 @@ ...@@ -22,7 +25,7 @@
return view.$('.page-content-nav'); return view.$('.page-content-nav');
}, },
activeTabPanel = function () { activeTabPanel = function () {
return view.$('.tabpanel[aria-expanded="true"]'); return view.$('.tabpanel[aria-hidden="false"]');
}; };
describe('TabbedView component', function () { describe('TabbedView component', function () {
...@@ -39,21 +42,10 @@ ...@@ -39,21 +42,10 @@
}], }],
viewLabel: 'Tabs', viewLabel: 'Tabs',
}).render(); }).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 () { 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 () { it('shows its first tab by default', function () {
...@@ -73,66 +65,100 @@ ...@@ -73,66 +65,100 @@
}); });
it('marks the active tab as selected using aria attributes', function () { 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=0]')).toHaveAttr({
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false'); '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(); view.$('.nav-item[data-index=1]').click();
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true'); '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 () { it('works with keyboard navigation RIGHT and ENTER', function() {
spyOn($.fn, 'focus'); view.$('.nav-item[data-index=0]').focus();
view.$('.nav-item[data-url="test-2"]').click(); view.$('.nav-item[data-index=0]')
expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled(); .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() { it('works with keyboard navigation DOWN and ENTER', function() {
beforeEach(function () { view.$('.nav-item[data-index=0]').focus();
spyOn(Backbone.history, 'navigate').and.callThrough(); view.$('.nav-item[data-index=0]')
view = new TabbedView({ .simulate("keydown", { keyCode: keys.DOWN })
tabs: [{ .simulate("keydown", { keyCode: keys.ENTER });
url: 'test 1',
title: 'Test 1', expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
view: new TestSubview({text: 'this is test text'}) 'aria-expanded': 'false',
}, { 'aria-selected': 'false',
url: 'test 2', 'tabindex': '-1'
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();
}); });
expect(view.$('.nav-item[data-index=1]')).toHaveAttr({
afterEach(function () { 'aria-expanded': 'true',
view.router.navigate(''); 'aria-selected': 'true',
Backbone.history.stop(); 'tabindex': '0'
}); });
});
it('updates the page URL on tab switches without adding to browser history', function () {
view.$('.nav-item[data-index=1]').click(); it('works with keyboard navigation LEFT and ENTER', function() {
expect(Backbone.history.navigate).toHaveBeenCalledWith( view.$('.nav-item[data-index=1]').focus();
'test 2', view.$('.nav-item[data-index=1]')
{replace: true} .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'
}); });
expect(view.$('.nav-item[data-index=0]')).toHaveAttr({
it('changes tabs on URL navigation', function () { 'aria-expanded': 'true',
expect(view.$('.nav-item.is-active').data('index')).toEqual(0); 'aria-selected': 'true',
Backbone.history.navigate('test 2', {trigger: true}); 'tabindex': '0'
expect(view.$('.nav-item.is-active').data('index')).toEqual(1); });
});
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); }).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="page-content-main">
<div class="tabs"></div> <div class="tabs"></div>
</div> </div>
<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false"> <div role="tabpanel" class="tabpanel is-hidden" id="<%= tabId %>" aria-labelledby="tab-<%= index %>" aria-hidden="true" tabindex="0"></div>
<div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div>
</div>
...@@ -826,6 +826,7 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest): ...@@ -826,6 +826,7 @@ class LearnerProfileA11yTest(LearnerProfileTestMixin, WebAppTest):
"ignore": [ "ignore": [
'section', # TODO: AC-491 'section', # TODO: AC-491
'link-href', # TODO: AC-231 'link-href', # TODO: AC-231
'color-contrast', # TODO: AC-231
], ],
}) })
profile_page.display_accomplishments() profile_page.display_accomplishments()
......
...@@ -71,9 +71,9 @@ define([ ...@@ -71,9 +71,9 @@ define([
expect(teamsTabView.$('.breadcrumbs').length).toBe(0); 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); var teamsTabView = createTeamsTabView(this);
teamsTabView.router.navigate('#content', {trigger: true}); teamsTabView.router.navigate('#main', {trigger: true});
expect(teamsTabView.$('.wrapper-msg')).toHaveClass('is-hidden'); expect(teamsTabView.$('.wrapper-msg')).toHaveClass('is-hidden');
}); });
......
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
router = this.router = new Backbone.Router(); router = this.router = new Backbone.Router();
_.each([ _.each([
[':default', _.bind(this.routeNotFound, this)], [':default', _.bind(this.routeNotFound, this)],
['content', _.bind(function () { ['main', _.bind(function () {
// The backbone router unfortunately usurps the // The backbone router unfortunately usurps the
// default behavior of in-page-links. This hack // default behavior of in-page-links. This hack
// prevents the screen reader in-page-link from // prevents the screen reader in-page-link from
...@@ -580,7 +580,7 @@ ...@@ -580,7 +580,7 @@
/** /**
* Set up the tabbed view and switch tabs. * Set up the tabbed view and switch tabs.
*/ */
goToTab: function (tab) { goToTab: function(tab) {
this.mainView = this.tabbedView; this.mainView = this.tabbedView;
// Note that `render` should be called first so // Note that `render` should be called first so
// that the tabbed view's element is set // 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-paging-header"></div>
<div class="teams-list"></div> <div class="teams-list"></div>
<div class="teams-paging-footer"></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-paging-header"></div>
<div class="topics-list"></div> <div class="topics-list"></div>
<div class="topics-paging-footer"></div> <div class="topics-paging-footer"></div>
...@@ -22,8 +22,10 @@ from openedx.core.djangolib.js_utils import ( ...@@ -22,8 +22,10 @@ from openedx.core.djangolib.js_utils import (
<div class="container"> <div class="container">
<div class="teams-wrapper"> <div class="teams-wrapper">
<section class="teams-content"> <main id="main" aria-label="Content" tabindex="-1">
</section> <section class="teams-content">
</section>
</main>
</div> </div>
</div> </div>
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
// +utility navigation // +utility navigation
// +toggling utilities // +toggling utilities
// +case - calculator spacing // +case - calculator spacing
// +tabs
// +notes: // +notes:
// -------------------- // --------------------
...@@ -128,26 +129,43 @@ ...@@ -128,26 +129,43 @@
%page-content-nav { %page-content-nav {
margin-bottom: $baseline; margin-bottom: $baseline;
border-bottom: 3px solid $gray-l5; border-bottom: 1px solid $gray-l5;
.nav-item { .nav-item {
@extend %button-reset; @extend %button-reset;
display: inline-block; display: inline-block;
margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5;
padding: ($baseline*.75); padding: ($baseline*.75);
color: $gray-d2; color: $gray-d2;
&.is-active { &.is-active {
border-bottom: 3px solid $gray-d2; border-bottom: 4px solid $link-color;
color: $gray-d2; color: $gray-d2;
} }
// STATE: hover and focus // STATE: hover and focus
&:hover, &:hover,
&:focus { &:focus {
border-bottom: 3px solid $link-color; border-bottom: 4px solid $link-color;
color: $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