Make tabbed view accessible
TNL-3150
This commit is contained in:
@@ -13,8 +13,8 @@ from .fields import FieldsMixin
|
||||
|
||||
TOPIC_CARD_CSS = 'div.wrapper-card-core'
|
||||
CARD_TITLE_CSS = 'h3.card-title'
|
||||
MY_TEAMS_BUTTON_CSS = 'a.nav-item[data-index="0"]'
|
||||
BROWSE_BUTTON_CSS = 'a.nav-item[data-index="1"]'
|
||||
MY_TEAMS_BUTTON_CSS = '.nav-item[data-index="0"]'
|
||||
BROWSE_BUTTON_CSS = '.nav-item[data-index="1"]'
|
||||
TEAMS_LINK_CSS = '.action-view'
|
||||
TEAMS_HEADER_CSS = '.teams-header'
|
||||
CREATE_TEAM_LINK_CSS = '.create-team'
|
||||
@@ -23,24 +23,28 @@ CREATE_TEAM_LINK_CSS = '.create-team'
|
||||
class TeamCardsMixin(object):
|
||||
"""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):
|
||||
"""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
|
||||
def team_cards(self):
|
||||
"""Get all the team cards on the page."""
|
||||
return self.q(css='.team-card')
|
||||
return self.q(css=self._bounded_selector('.team-card'))
|
||||
|
||||
@property
|
||||
def team_names(self):
|
||||
"""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
|
||||
def team_descriptions(self):
|
||||
"""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):
|
||||
@@ -135,6 +139,7 @@ class MyTeamsPage(CoursePage, PaginatedUIMixin, TeamCardsMixin):
|
||||
"""
|
||||
|
||||
url_path = "teams/#my-teams"
|
||||
tabpanel_id = '#tabpanel-my-teams'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
"""Check if the "My Teams" tab is being viewed."""
|
||||
@@ -166,7 +171,7 @@ class BrowseTopicsPage(CoursePage, PaginatedUIMixin):
|
||||
@property
|
||||
def topic_names(self):
|
||||
"""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
|
||||
def topic_descriptions(self):
|
||||
|
||||
@@ -4,11 +4,37 @@
|
||||
'underscore',
|
||||
'jquery',
|
||||
'text!templates/components/tabbed/tabbed_view.underscore',
|
||||
'text!templates/components/tabbed/tab.underscore'],
|
||||
function (Backbone, _, $, tabbedViewTemplate, tabTemplate) {
|
||||
'text!templates/components/tabbed/tab.underscore',
|
||||
'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({
|
||||
events: {
|
||||
'click .nav-item[role="tab"]': 'switchTab'
|
||||
'click .nav-item.tab': 'switchTab'
|
||||
},
|
||||
|
||||
template: _.template(tabbedViewTemplate),
|
||||
@@ -31,6 +57,10 @@
|
||||
initialize: function (options) {
|
||||
this.router = options.router || null;
|
||||
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) {
|
||||
map[value.url] = value;
|
||||
return map;
|
||||
@@ -42,12 +72,17 @@
|
||||
this.$el.html(this.template({}));
|
||||
_.each(this.tabs, function(tabInfo, index) {
|
||||
var tabEl = $(_.template(tabTemplate, {
|
||||
index: index,
|
||||
title: tabInfo.title,
|
||||
url: tabInfo.url
|
||||
}));
|
||||
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
|
||||
@@ -63,10 +98,16 @@
|
||||
tab = tabMeta.tab,
|
||||
tabEl = tabMeta.element,
|
||||
view = tab.view;
|
||||
this.$('a.is-active').removeClass('is-active').attr('aria-selected', 'false');
|
||||
tabEl.addClass('is-active').attr('aria-selected', 'true');
|
||||
view.setElement(this.$('.page-content-main')).render();
|
||||
this.$('.sr-is-focusable.sr-tab').focus();
|
||||
// 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});
|
||||
}
|
||||
@@ -85,10 +126,10 @@
|
||||
var tab, element;
|
||||
if (typeof tabNameOrIndex === 'string') {
|
||||
tab = this.urlMap[tabNameOrIndex];
|
||||
element = this.$('a[data-url='+tabNameOrIndex+']');
|
||||
element = this.$('button[data-url='+tabNameOrIndex+']');
|
||||
} else {
|
||||
tab = this.tabs[tabNameOrIndex];
|
||||
element = this.$('a[data-index='+tabNameOrIndex+']');
|
||||
element = this.$('button[data-index='+tabNameOrIndex+']');
|
||||
}
|
||||
return {'tab': tab, 'element': element};
|
||||
}
|
||||
|
||||
@@ -15,20 +15,40 @@
|
||||
|
||||
render: function () {
|
||||
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 () {
|
||||
beforeEach(function () {
|
||||
view = new TabbedView({
|
||||
tabs: [{
|
||||
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',
|
||||
view: new TestSubview({text: 'other text'})
|
||||
view: new TestSubview({text: 'other text'}),
|
||||
url: 'test-2'
|
||||
}]
|
||||
}).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 () {
|
||||
@@ -36,33 +56,33 @@
|
||||
});
|
||||
|
||||
it('shows its first tab by default', function () {
|
||||
expect(view.$el.text()).toContain('this is test text');
|
||||
expect(view.$el.text()).not.toContain('other text');
|
||||
expect(activeTabPanel().text()).toContain('this is test text');
|
||||
expect(activeTabPanel().text()).not.toContain('other text');
|
||||
});
|
||||
|
||||
it('displays titles for each tab', function () {
|
||||
expect(view.$el.text()).toContain('Test 1');
|
||||
expect(view.$el.text()).toContain('Test 2');
|
||||
expect(activeTab().text()).toContain('Test 1');
|
||||
expect(activeTab().text()).toContain('Test 2');
|
||||
});
|
||||
|
||||
it('can switch tabs', function () {
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$el.text()).not.toContain('this is test text');
|
||||
expect(view.$el.text()).toContain('other text');
|
||||
expect(activeTabPanel().text()).not.toContain('this is test text');
|
||||
expect(activeTabPanel().text()).toContain('other text');
|
||||
});
|
||||
|
||||
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=1]')).toHaveAttr('aria-selected', 'false');
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'true');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'false');
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-selected', 'false');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-selected', 'true');
|
||||
expect(view.$('.nav-item[data-index=0]')).toHaveAttr('aria-expanded', 'false');
|
||||
expect(view.$('.nav-item[data-index=1]')).toHaveAttr('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('sets focus for screen readers', function () {
|
||||
spyOn($.fn, 'focus');
|
||||
view.$('.nav-item[data-index=1]').click();
|
||||
expect(view.$('.sr-is-focusable.sr-tab').focus).toHaveBeenCalled();
|
||||
view.$('.nav-item[data-url="test-2"]').click();
|
||||
expect(view.$('.sr-is-focusable.test-2').focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('history', function() {
|
||||
|
||||
@@ -745,5 +745,3 @@
|
||||
.create-team.form-actions {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<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>
|
||||
<div class="page-content-main">
|
||||
<div class="tabs"></div>
|
||||
</div>
|
||||
|
||||
3
lms/templates/components/tabbed/tabpanel.underscore
Normal file
3
lms/templates/components/tabbed/tabpanel.underscore
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="tabpanel is-hidden" id="<%= tabId %>" aria-expanded="false">
|
||||
<div class="sr-is-focusable <%= tabId %>" tabindex="-1"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user