Commit 50bcc496 by Daniel Friedman

Merge pull request #9751 from edx/dan-f/make-teams-tabs-accessible

Dan f/make teams tabs accessible
parents 4d0a1275 77384f1a
...@@ -13,8 +13,8 @@ from .fields import FieldsMixin ...@@ -13,8 +13,8 @@ from .fields import FieldsMixin
TOPIC_CARD_CSS = 'div.wrapper-card-core' TOPIC_CARD_CSS = 'div.wrapper-card-core'
CARD_TITLE_CSS = 'h3.card-title' CARD_TITLE_CSS = 'h3.card-title'
MY_TEAMS_BUTTON_CSS = 'a.nav-item[data-index="0"]' MY_TEAMS_BUTTON_CSS = '.nav-item[data-index="0"]'
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]' BROWSE_BUTTON_CSS = '.nav-item[data-index="1"]'
TEAMS_LINK_CSS = '.action-view' TEAMS_LINK_CSS = '.action-view'
TEAMS_HEADER_CSS = '.teams-header' TEAMS_HEADER_CSS = '.teams-header'
CREATE_TEAM_LINK_CSS = '.create-team' CREATE_TEAM_LINK_CSS = '.create-team'
...@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team' ...@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team'
class TeamCardsMixin(object): class TeamCardsMixin(object):
"""Provides common operations on the team card component.""" """Provides common operations on the team card component."""
def _bounded_selector(self, css):
"""Bind the CSS to a particular tabpanel (e.g. My Teams or Browse)."""
return '{tabpanel_id} {css}'.format(tabpanel_id=getattr(self, 'tabpanel_id', ''), css=css)
def view_first_team(self): def view_first_team(self):
"""Click the 'view' button of the first team card on the page.""" """Click the 'view' button of the first team card on the page."""
self.q(css='a.action-view').first.click() self.q(css=self._bounded_selector('a.action-view')).first.click()
@property @property
def team_cards(self): def team_cards(self):
"""Get all the team cards on the page.""" """Get all the team cards on the page."""
return self.q(css='.team-card') return self.q(css=self._bounded_selector('.team-card'))
@property @property
def team_names(self): def team_names(self):
"""Return the names of each team on the page.""" """Return the names of each team on the page."""
return self.q(css='h3.card-title').map(lambda e: e.text).results return self.q(css=self._bounded_selector('h3.card-title')).map(lambda e: e.text).results
@property @property
def team_descriptions(self): def team_descriptions(self):
"""Return the names of each team on the page.""" """Return the names of each team on the page."""
return self.q(css='p.card-description').map(lambda e: e.text).results return self.q(css=self._bounded_selector('p.card-description')).map(lambda e: e.text).results
class BreadcrumbsMixin(object): class BreadcrumbsMixin(object):
...@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin): ...@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
""" """
url_path = "teams/#my-teams" url_path = "teams/#my-teams"
tabpanel_id = '#tabpanel-my-teams'
def is_browser_on_page(self): def is_browser_on_page(self):
"""Check if the "My Teams" tab is being viewed.""" """Check if the "My Teams" tab is being viewed."""
...@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin): ...@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
@property @property
def topic_names(self): def topic_names(self):
"""Return a list of the topic names present on the page.""" """Return a list of the topic names present on the page."""
return self.q(css=CARD_TITLE_CSS).map(lambda e: e.text).results return self.q(css='#tabpanel-browse ' + CARD_TITLE_CSS).map(lambda e: e.text).results
@property @property
def topic_descriptions(self): def topic_descriptions(self):
......
...@@ -4,11 +4,37 @@ ...@@ -4,11 +4,37 @@
'underscore', 'underscore',
'jquery', 'jquery',
'text!templates/components/tabbed/tabbed_view.underscore', 'text!templates/components/tabbed/tabbed_view.underscore',
'text!templates/components/tabbed/tab.underscore'], 'text!templates/components/tabbed/tab.underscore',
function (Backbone, _, $, tabbedViewTemplate, tabTemplate) { 'text!templates/components/tabbed/tabpanel.underscore',
], function (
Backbone,
_,
$,
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;
},
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({ var TabbedView = Backbone.View.extend({
events: { events: {
'click .nav-item[role="tab"]': 'switchTab' 'click .nav-item.tab': 'switchTab'
}, },
template: _.template(tabbedViewTemplate), template: _.template(tabbedViewTemplate),
...@@ -31,6 +57,10 @@ ...@@ -31,6 +57,10 @@
initialize: function (options) { initialize: function (options) {
this.router = options.router || null; this.router = options.router || null;
this.tabs = options.tabs; this.tabs = options.tabs;
// 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) { this.urlMap = _.reduce(this.tabs, function (map, value) {
map[value.url] = value; map[value.url] = value;
return map; return map;
...@@ -42,12 +72,17 @@ ...@@ -42,12 +72,17 @@
this.$el.html(this.template({})); this.$el.html(this.template({}));
_.each(this.tabs, function(tabInfo, index) { _.each(this.tabs, function(tabInfo, index) {
var tabEl = $(_.template(tabTemplate, { var tabEl = $(_.template(tabTemplate, {
index: index, index: index,
title: tabInfo.title, title: tabInfo.title,
url: tabInfo.url url: tabInfo.url,
})); tabPanelId: getTabPanelId(tabInfo.url)
})),
tabContainerEl = this.$('.tabs');
self.$('.page-content-nav').append(tabEl); 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 // Re-display the default (first) tab if the
// current route does not belong to one of the // current route does not belong to one of the
// tabs. Otherwise continue displaying the tab // tabs. Otherwise continue displaying the tab
...@@ -63,10 +98,16 @@ ...@@ -63,10 +98,16 @@
tab = tabMeta.tab, tab = tabMeta.tab,
tabEl = tabMeta.element, tabEl = tabMeta.element,
view = tab.view; view = tab.view;
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false'); // Hide old tab/tabpanel
tabEl.addClass('is-active').attr('aria-selected', 'true'); this.$('button.is-active').removeClass('is-active').attr('aria-expanded', 'false');
view.setElement(this.$('.page-content-main')).render(); this.$('.tabpanel[aria-expanded="true"]').attr('aria-expanded', 'false').addClass('is-hidden');
this.$('.sr-is-focusable.sr-tab').focus(); // 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) { if (this.router) {
this.router.navigate(tab.url, {replace: true}); this.router.navigate(tab.url, {replace: true});
} }
...@@ -85,10 +126,10 @@ ...@@ -85,10 +126,10 @@
var tab, element; var tab, element;
if (typeof tabNameOrIndex === 'string') { if (typeof tabNameOrIndex === 'string') {
tab = this.urlMap[tabNameOrIndex]; tab = this.urlMap[tabNameOrIndex];
element = this.$('a[data-url='+tabNameOrIndex+']'); element = this.$('button[data-url='+tabNameOrIndex+']');
} else { } else {
tab = this.tabs[tabNameOrIndex]; tab = this.tabs[tabNameOrIndex];
element = this.$('a[data-index='+tabNameOrIndex+']'); element = this.$('button[data-index='+tabNameOrIndex+']');
} }
return {'tab': tab, 'element': element}; return {'tab': tab, 'element': element};
} }
......
...@@ -15,20 +15,40 @@ ...@@ -15,20 +15,40 @@
render: function () { render: function () {
this.$el.text(this.text); this.$el.text(this.text);
return this;
} }
}); }),
activeTab = function () {
return view.$('.page-content-nav');
},
activeTabPanel = function () {
return view.$('.tabpanel[aria-expanded="true"]');
};
describe('TabbedView component', function () { describe('TabbedView component', function () {
beforeEach(function () { beforeEach(function () {
view = new TabbedView({ view = new TabbedView({
tabs: [{ tabs: [{
title: 'Test 1', title: 'Test 1',
view: new TestSubview({text: 'this is test text'}) view: new TestSubview({text: 'this is test text'}),
url: 'test-1'
}, { }, {
title: 'Test 2', title: 'Test 2',
view: new TestSubview({text: 'other text'}) view: new TestSubview({text: 'other text'}),
url: 'test-2'
}] }]
}).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').andCallFake(function (func) {
func();
});
}); });
it('can render itself', function () { it('can render itself', function () {
...@@ -36,33 +56,33 @@ ...@@ -36,33 +56,33 @@
}); });
it('shows its first tab by default', function () { it('shows its first tab by default', function () {
expect(view.$el.text()).toContain('this is test text'); expect(activeTabPanel().text()).toContain('this is test text');
expect(view.$el.text()).not.toContain('other text'); expect(activeTabPanel().text()).not.toContain('other text');
}); });
it('displays titles for each tab', function () { it('displays titles for each tab', function () {
expect(view.$el.text()).toContain('Test 1'); expect(activeTab().text()).toContain('Test 1');
expect(view.$el.text()).toContain('Test 2'); expect(activeTab().text()).toContain('Test 2');
}); });
it('can switch tabs', function () { it('can switch tabs', function () {
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-index=1]').click();
expect(view.$el.text()).not.toContain('this is test text'); expect(activeTabPanel().text()).not.toContain('this is test text');
expect(view.$el.text()).toContain('other text'); expect(activeTabPanel().text()).toContain('other text');
}); });
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-selected', 'true'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false');
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-index=1]').click();
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'false'); expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false');
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true'); expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true');
}); });
it('sets focus for screen readers', function () { it('sets focus for screen readers', function () {
spyOn($.fn, 'focus'); spyOn($.fn, 'focus');
view.$('.nav-item[data-index=1]').click(); view.$('.nav-item[data-url="test-2"]').click();
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled(); expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled();
}); });
describe('history', function() { describe('history', function() {
......
...@@ -22,9 +22,12 @@ ...@@ -22,9 +22,12 @@
%button-reset { %button-reset {
box-shadow: none; box-shadow: none;
border: none; border: none;
border-radius: 0;
background-image: none; background-image: none;
background-color: transparent; background-color: transparent;
font-weight: normal; font-family: inherit;
font-size: inherit;
font-weight: inherit;
} }
// layout // layout
...@@ -148,6 +151,7 @@ ...@@ -148,6 +151,7 @@
border-bottom: 3px solid $gray-l5; border-bottom: 3px solid $gray-l5;
.nav-item { .nav-item {
@extend %button-reset;
display: inline-block; display: inline-block;
margin-bottom: -3px; // to match the border margin-bottom: -3px; // to match the border
border-bottom: 3px solid $gray-l5; border-bottom: 3px solid $gray-l5;
...@@ -745,5 +749,3 @@ ...@@ -745,5 +749,3 @@
.create-team.form-actions { .create-team.form-actions {
margin-top: $baseline; margin-top: $baseline;
} }
<a class="nav-item" href="" data-url="<%= url %>" data-index="<%= index %>" role="tab" aria-selected="false"><%= title %></a> <button class="nav-item tab" data-url="<%= url %>" data-index="<%= index %>" is-active="false" aria-expanded="false" aria-controls="<%= tabPanelId %>"><%= title %></button>
<nav class="page-content-nav" aria-label="Teams"></nav> <nav class="page-content-nav" aria-label="Teams"></nav>
<div class="sr-is-focusable sr-tab" tabindex="-1"></div> <div class="page-content-main">
<div class="page-content-main"></div> <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>
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