diff --git a/common/test/acceptance/pages/lms/teams.py b/common/test/acceptance/pages/lms/teams.py index f6d9760127..3157625128 100644 --- a/common/test/acceptance/pages/lms/teams.py +++ b/common/test/acceptance/pages/lms/teams.py @@ -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): diff --git a/lms/static/js/components/tabbed/views/tabbed_view.js b/lms/static/js/components/tabbed/views/tabbed_view.js index e0de186cc5..70d4afe43c 100644 --- a/lms/static/js/components/tabbed/views/tabbed_view.js +++ b/lms/static/js/components/tabbed/views/tabbed_view.js @@ -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}; } diff --git a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js b/lms/static/js/spec/components/tabbed/tabbed_view_spec.js index 7c46819f42..cebe44f6d6 100644 --- a/lms/static/js/spec/components/tabbed/tabbed_view_spec.js +++ b/lms/static/js/spec/components/tabbed/tabbed_view_spec.js @@ -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() { diff --git a/lms/static/sass/views/_teams.scss b/lms/static/sass/views/_teams.scss index 6be88b85f0..2ca8d8a154 100644 --- a/lms/static/sass/views/_teams.scss +++ b/lms/static/sass/views/_teams.scss @@ -745,5 +745,3 @@ .create-team.form-actions { margin-top: $baseline; } - - diff --git a/lms/templates/components/tabbed/tab.underscore b/lms/templates/components/tabbed/tab.underscore index 19936e3b32..d7c5e37c7e 100644 --- a/lms/templates/components/tabbed/tab.underscore +++ b/lms/templates/components/tabbed/tab.underscore @@ -1 +1 @@ -<%= title %> + diff --git a/lms/templates/components/tabbed/tabbed_view.underscore b/lms/templates/components/tabbed/tabbed_view.underscore index 80a39c2655..669fa98421 100644 --- a/lms/templates/components/tabbed/tabbed_view.underscore +++ b/lms/templates/components/tabbed/tabbed_view.underscore @@ -1,3 +1,4 @@ -
- +