From ea78a473a17e0b5c23936af7772914f9db8cd058 Mon Sep 17 00:00:00 2001 From: Zack Elliott <4220717+zelliott@users.noreply.github.com> Date: Fri, 7 Jan 2022 08:46:09 -0800 Subject: [PATCH] feat(material/tabs): Refactor MatTabNav to follow the ARIA tabs pattern (#24062) by introducing a new tabpanel component. --- .../material/tabs/index.ts | 3 + .../tab-nav-bar-with-panel-example.css | 4 + .../tab-nav-bar-with-panel-example.html | 9 ++ .../tab-nav-bar-with-panel-example.ts | 14 ++ src/dev-app/mdc-tabs/mdc-tabs-demo.html | 9 ++ src/dev-app/tabs/tabs-demo.html | 4 +- src/material-experimental/mdc-tabs/module.ts | 4 +- .../mdc-tabs/public-api.ts | 2 +- .../mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts | 143 +++++++++++++++++- .../mdc-tabs/tab-nav-bar/tab-nav-bar.ts | 37 ++++- .../mdc-tabs/testing/tab-harness-filters.ts | 3 + .../mdc-tabs/testing/tab-nav-bar-harness.ts | 20 ++- .../mdc-tabs/testing/tab-nav-panel-harness.ts | 31 ++++ src/material/tabs/public-api.ts | 8 +- .../tabs/tab-nav-bar/tab-nav-bar.spec.ts | 143 +++++++++++++++++- src/material/tabs/tab-nav-bar/tab-nav-bar.ts | 95 +++++++++++- src/material/tabs/tabs-module.ts | 4 +- .../tabs/testing/tab-harness-filters.ts | 3 + .../tabs/testing/tab-nav-bar-harness.ts | 20 ++- .../tabs/testing/tab-nav-bar-shared.spec.ts | 11 +- .../tabs/testing/tab-nav-panel-harness.ts | 31 ++++ .../public_api_guard/material/tabs-testing.md | 5 + tools/public_api_guard/material/tabs.md | 33 +++- 23 files changed, 618 insertions(+), 18 deletions(-) create mode 100644 src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.css create mode 100644 src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.html create mode 100644 src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.ts create mode 100644 src/material-experimental/mdc-tabs/testing/tab-nav-panel-harness.ts create mode 100644 src/material/tabs/testing/tab-nav-panel-harness.ts diff --git a/src/components-examples/material/tabs/index.ts b/src/components-examples/material/tabs/index.ts index 6190a8240f2a..cb5279b3487b 100644 --- a/src/components-examples/material/tabs/index.ts +++ b/src/components-examples/material/tabs/index.ts @@ -20,6 +20,7 @@ import {TabGroupLazyLoadedExample} from './tab-group-lazy-loaded/tab-group-lazy- import {TabGroupStretchedExample} from './tab-group-stretched/tab-group-stretched-example'; import {TabGroupThemeExample} from './tab-group-theme/tab-group-theme-example'; import {TabNavBarBasicExample} from './tab-nav-bar-basic/tab-nav-bar-basic-example'; +import {TabNavBarWithPanelExample} from './tab-nav-bar-with-panel/tab-nav-bar-with-panel-example'; export { TabGroupAlignExample, @@ -35,6 +36,7 @@ export { TabGroupStretchedExample, TabGroupThemeExample, TabNavBarBasicExample, + TabNavBarWithPanelExample, }; const EXAMPLES = [ @@ -51,6 +53,7 @@ const EXAMPLES = [ TabGroupStretchedExample, TabGroupThemeExample, TabNavBarBasicExample, + TabNavBarWithPanelExample, ]; @NgModule({ diff --git a/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.css b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.css new file mode 100644 index 000000000000..e7f8daa5cd3b --- /dev/null +++ b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.css @@ -0,0 +1,4 @@ +.example-action-button { + margin-top: 8px; + margin-right: 8px; +} diff --git a/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.html b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.html new file mode 100644 index 000000000000..1ab8a3c4da0f --- /dev/null +++ b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.html @@ -0,0 +1,9 @@ + + + + diff --git a/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.ts b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.ts new file mode 100644 index 000000000000..b7beb7ae2e2f --- /dev/null +++ b/src/components-examples/material/tabs/tab-nav-bar-with-panel/tab-nav-bar-with-panel-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; + +/** + * @title Use of the tab nav bar with the dedicated panel component. + */ +@Component({ + selector: 'tab-nav-bar-with-panel-example', + templateUrl: 'tab-nav-bar-with-panel-example.html', + styleUrls: ['tab-nav-bar-with-panel-example.css'], +}) +export class TabNavBarWithPanelExample { + links = ['First', 'Second', 'Third']; + activeLink = this.links[0]; +} diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.html b/src/dev-app/mdc-tabs/mdc-tabs-demo.html index 70156eec4d1c..daddf891f4f1 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.html +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.html @@ -127,4 +127,13 @@

Tab nav bar

[active]="activeLink == link">{{link}} Disabled Link + +

Tab nav bar with panel

+ + diff --git a/src/dev-app/tabs/tabs-demo.html b/src/dev-app/tabs/tabs-demo.html index 52d62976e2a8..9531a56be77a 100644 --- a/src/dev-app/tabs/tabs-demo.html +++ b/src/dev-app/tabs/tabs-demo.html @@ -18,5 +18,7 @@

Tab group stretched

Tab group theming

-

Tab Navigation Bar basic

+

Tab navigation bar basic

+

Tab navigation bar with panel

+ diff --git a/src/material-experimental/mdc-tabs/module.ts b/src/material-experimental/mdc-tabs/module.ts index 2398beea8cbe..43dec04f334f 100644 --- a/src/material-experimental/mdc-tabs/module.ts +++ b/src/material-experimental/mdc-tabs/module.ts @@ -19,7 +19,7 @@ import {MatTabLabelWrapper} from './tab-label-wrapper'; import {MatTab} from './tab'; import {MatTabHeader} from './tab-header'; import {MatTabGroup} from './tab-group'; -import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; +import {MatTabNav, MatTabNavPanel, MatTabLink} from './tab-nav-bar/tab-nav-bar'; @NgModule({ imports: [ @@ -37,6 +37,7 @@ import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; MatTab, MatTabGroup, MatTabNav, + MatTabNavPanel, MatTabLink, ], declarations: [ @@ -45,6 +46,7 @@ import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; MatTab, MatTabGroup, MatTabNav, + MatTabNavPanel, MatTabLink, // Private directives, should not be exported. diff --git a/src/material-experimental/mdc-tabs/public-api.ts b/src/material-experimental/mdc-tabs/public-api.ts index 4e6b1629af0e..6d05b9539377 100644 --- a/src/material-experimental/mdc-tabs/public-api.ts +++ b/src/material-experimental/mdc-tabs/public-api.ts @@ -15,7 +15,7 @@ export {MatTab} from './tab'; export {MatInkBar} from './ink-bar'; export {MatTabHeader} from './tab-header'; export {MatTabGroup} from './tab-group'; -export {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; +export {MatTabNav, MatTabNavPanel, MatTabLink} from './tab-nav-bar/tab-nav-bar'; export { MatTabBodyPositionState, diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts index d53b020ecf29..3b5dd8cdb19a 100644 --- a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -1,3 +1,4 @@ +import {SPACE} from '@angular/cdk/keycodes'; import {waitForAsync, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core'; import { @@ -5,7 +6,11 @@ import { RippleGlobalOptions, } from '@angular/material-experimental/mdc-core'; import {By} from '@angular/platform-browser'; -import {dispatchFakeEvent, dispatchMouseEvent} from '../../../cdk/testing/private'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, +} from '../../../cdk/testing/private'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {Subject} from 'rxjs'; import {MatTabsModule} from '../module'; @@ -30,6 +35,7 @@ describe('MDC-based MatTabNavBar', () => { TabLinkWithTabIndexBinding, TabLinkWithNativeTabindexAttr, TabBarWithInactiveTabsOnInit, + TabBarWithPanel, ], providers: [ {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, @@ -309,6 +315,123 @@ describe('MDC-based MatTabNavBar', () => { expect(instance.tabNavBar.selectedIndex).toBe(1); }); + describe('without panel', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.detectChanges(); + }); + + it('should have no explicit roles', () => { + const tabBar = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-bar')!; + expect(tabBar.getAttribute('role')).toBe(null); + + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('role')).toBe(null); + expect(tabLinks[1].getAttribute('role')).toBe(null); + expect(tabLinks[2].getAttribute('role')).toBe(null); + }); + + it('should not setup aria-controls', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('aria-controls')).toBe(null); + expect(tabLinks[1].getAttribute('aria-controls')).toBe(null); + expect(tabLinks[2].getAttribute('aria-controls')).toBe(null); + }); + + it('should not manage aria-selected', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('aria-selected')).toBe(null); + expect(tabLinks[1].getAttribute('aria-selected')).toBe(null); + expect(tabLinks[2].getAttribute('aria-selected')).toBe(null); + }); + + it('should not activate a link when space is pressed', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false); + + dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false); + }); + }); + + describe('with panel', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(TabBarWithPanel); + fixture.detectChanges(); + }); + + it('should have the proper roles', () => { + const tabBar = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-bar')!; + expect(tabBar.getAttribute('role')).toBe('tablist'); + + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('role')).toBe('tab'); + expect(tabLinks[1].getAttribute('role')).toBe('tab'); + expect(tabLinks[2].getAttribute('role')).toBe('tab'); + + const tabPanel = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-panel')!; + expect(tabPanel.getAttribute('role')).toBe('tabpanel'); + }); + + it('should manage tabindex properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].tabIndex).toBe(0); + expect(tabLinks[1].tabIndex).toBe(-1); + expect(tabLinks[2].tabIndex).toBe(-1); + + tabLinks[1].click(); + fixture.detectChanges(); + + expect(tabLinks[0].tabIndex).toBe(-1); + expect(tabLinks[1].tabIndex).toBe(0); + expect(tabLinks[2].tabIndex).toBe(-1); + }); + + it('should setup aria-controls properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('aria-controls')).toBe('tab-panel'); + expect(tabLinks[1].getAttribute('aria-controls')).toBe('tab-panel'); + expect(tabLinks[2].getAttribute('aria-controls')).toBe('tab-panel'); + }); + + it('should not manage aria-current', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('aria-current')).toBe(null); + expect(tabLinks[1].getAttribute('aria-current')).toBe(null); + expect(tabLinks[2].getAttribute('aria-current')).toBe(null); + }); + + it('should manage aria-selected properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[0].getAttribute('aria-selected')).toBe('true'); + expect(tabLinks[1].getAttribute('aria-selected')).toBe('false'); + expect(tabLinks[2].getAttribute('aria-selected')).toBe('false'); + + tabLinks[1].click(); + fixture.detectChanges(); + + expect(tabLinks[0].getAttribute('aria-selected')).toBe('false'); + expect(tabLinks[1].getAttribute('aria-selected')).toBe('true'); + expect(tabLinks[2].getAttribute('aria-selected')).toBe('false'); + }); + + it('should activate a link when space is pressed', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link'); + expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false); + + dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(true); + }); + }); + describe('ripples', () => { let fixture: ComponentFixture; @@ -532,3 +655,21 @@ class TabLinkWithNativeTabindexAttr {} class TabBarWithInactiveTabsOnInit { tabs = [0, 1, 2]; } + +@Component({ + template: ` + + Tab panel + `, +}) +class TabBarWithPanel { + tabs = [0, 1, 2]; + activeIndex = 0; +} diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts index ca883653cf59..bb88dce83f2c 100644 --- a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts @@ -56,6 +56,7 @@ import {takeUntil} from 'rxjs/operators'; templateUrl: 'tab-nav-bar.html', styleUrls: ['tab-nav-bar.css'], host: { + '[attr.role]': '_getRole()', 'class': 'mat-mdc-tab-nav-bar mat-mdc-tab-header', '[class.mat-mdc-tab-header-pagination-controls-enabled]': '_showPaginationControls', '[class.mat-mdc-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", @@ -127,12 +128,17 @@ export class MatTabNav extends _MatTabNavBase implements AfterContentInit { styleUrls: ['tab-link.css'], host: { 'class': 'mdc-tab mat-mdc-tab-link mat-mdc-focus-indicator', - '[attr.aria-current]': 'active ? "page" : null', + '[attr.aria-controls]': '_getAriaControls()', + '[attr.aria-current]': '_getAriaCurrent()', '[attr.aria-disabled]': 'disabled', - '[attr.tabIndex]': 'tabIndex', + '[attr.aria-selected]': '_getAriaSelected()', + '[attr.id]': 'id', + '[attr.tabIndex]': '_getTabIndex()', + '[attr.role]': '_getRole()', '[class.mat-mdc-tab-disabled]': 'disabled', '[class.mdc-tab--active]': 'active', '(focus)': '_handleFocus()', + '(keydown)': '_handleKeydown($event)', }, }) export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit, OnDestroy { @@ -167,3 +173,30 @@ export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit this._foundation.destroy(); } } + +// Increasing integer for generating unique ids for tab nav components. +let nextUniqueId = 0; + +/** + * Tab panel component associated with MatTabNav. + */ +@Component({ + selector: 'mat-tab-nav-panel', + exportAs: 'matTabNavPanel', + template: '', + host: { + '[attr.aria-labelledby]': '_activeTabId', + '[attr.id]': 'id', + 'class': 'mat-mdc-tab-nav-panel', + 'role': 'tabpanel', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatTabNavPanel { + /** Unique id for the tab panel. */ + @Input() id = `mat-tab-nav-panel-${nextUniqueId++}`; + + /** Id of the active tab in the nav bar. */ + _activeTabId?: string; +} diff --git a/src/material-experimental/mdc-tabs/testing/tab-harness-filters.ts b/src/material-experimental/mdc-tabs/testing/tab-harness-filters.ts index 65ada6d2e4cb..727365af322f 100644 --- a/src/material-experimental/mdc-tabs/testing/tab-harness-filters.ts +++ b/src/material-experimental/mdc-tabs/testing/tab-harness-filters.ts @@ -27,3 +27,6 @@ export interface TabLinkHarnessFilters extends BaseHarnessFilters { /** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */ export interface TabNavBarHarnessFilters extends BaseHarnessFilters {} + +/** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */ +export interface TabNavPanelHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material-experimental/mdc-tabs/testing/tab-nav-bar-harness.ts b/src/material-experimental/mdc-tabs/testing/tab-nav-bar-harness.ts index f0ea4723aae5..75482b61bf43 100644 --- a/src/material-experimental/mdc-tabs/testing/tab-nav-bar-harness.ts +++ b/src/material-experimental/mdc-tabs/testing/tab-nav-bar-harness.ts @@ -7,8 +7,13 @@ */ import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {TabNavBarHarnessFilters, TabLinkHarnessFilters} from './tab-harness-filters'; +import { + TabNavBarHarnessFilters, + TabNavPanelHarnessFilters, + TabLinkHarnessFilters, +} from './tab-harness-filters'; import {MatTabLinkHarness} from './tab-link-harness'; +import {MatTabNavPanelHarness} from './tab-nav-panel-harness'; /** Harness for interacting with an MDC-based mat-tab-nav-bar in tests. */ export class MatTabNavBarHarness extends ComponentHarness { @@ -57,4 +62,17 @@ export class MatTabNavBarHarness extends ComponentHarness { } await tabs[0].click(); } + + /** Gets the panel associated with the nav bar. */ + async getPanel(): Promise { + const link = await this.getActiveLink(); + const host = await link.host(); + const panelId = await host.getAttribute('aria-controls'); + if (!panelId) { + throw Error('No panel is controlled by the nav bar.'); + } + + const filter: TabNavPanelHarnessFilters = {selector: `#${panelId}`}; + return await this.documentRootLocatorFactory().locatorFor(MatTabNavPanelHarness.with(filter))(); + } } diff --git a/src/material-experimental/mdc-tabs/testing/tab-nav-panel-harness.ts b/src/material-experimental/mdc-tabs/testing/tab-nav-panel-harness.ts new file mode 100644 index 000000000000..40afef7436b5 --- /dev/null +++ b/src/material-experimental/mdc-tabs/testing/tab-nav-panel-harness.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ContentContainerComponentHarness, HarnessPredicate} from '@angular/cdk/testing'; +import {TabNavPanelHarnessFilters} from './tab-harness-filters'; + +/** Harness for interacting with a standard mat-tab-nav-panel in tests. */ +export class MatTabNavPanelHarness extends ContentContainerComponentHarness { + /** The selector for the host element of a `MatTabNavPanel` instance. */ + static hostSelector = '.mat-mdc-tab-nav-panel'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatTabNavPanel` that meets + * certain criteria. + * @param options Options for filtering which tab nav panel instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: TabNavPanelHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatTabNavPanelHarness, options); + } + + /** Gets the tab panel text content. */ + async getTextContent(): Promise { + return (await this.host()).text(); + } +} diff --git a/src/material/tabs/public-api.ts b/src/material/tabs/public-api.ts index 7c14d0697dd8..9fdca2961430 100644 --- a/src/material/tabs/public-api.ts +++ b/src/material/tabs/public-api.ts @@ -20,7 +20,13 @@ export {MatTabHeader, _MatTabHeaderBase} from './tab-header'; export {MatTabLabelWrapper} from './tab-label-wrapper'; export {MatTab, MAT_TAB_GROUP} from './tab'; export {MatTabLabel, MAT_TAB} from './tab-label'; -export {MatTabNav, MatTabLink, _MatTabNavBase, _MatTabLinkBase} from './tab-nav-bar/index'; +export { + MatTabNav, + MatTabLink, + MatTabNavPanel, + _MatTabNavBase, + _MatTabLinkBase, +} from './tab-nav-bar/index'; export {MatTabContent} from './tab-content'; export {ScrollDirection} from './paginated-tab-header'; export * from './tabs-animations'; diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts index 99ddc9657259..ba4c4f1e8d0a 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -1,8 +1,13 @@ +import {SPACE} from '@angular/cdk/keycodes'; import {waitForAsync, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {Component, ViewChild, ViewChildren, QueryList} from '@angular/core'; import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; import {By} from '@angular/platform-browser'; -import {dispatchFakeEvent, dispatchMouseEvent} from '../../../cdk/testing/private'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, +} from '../../../cdk/testing/private'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {Subject} from 'rxjs'; import {MatTabLink, MatTabNav, MatTabsModule} from '../index'; @@ -24,6 +29,7 @@ describe('MatTabNavBar', () => { TabLinkWithTabIndexBinding, TabLinkWithNativeTabindexAttr, TabBarWithInactiveTabsOnInit, + TabBarWithPanel, ], providers: [ {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, @@ -295,6 +301,123 @@ describe('MatTabNavBar', () => { expect(instance.tabNavBar.selectedIndex).toBe(1); }); + describe('without panel', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.detectChanges(); + }); + + it('should have no explicit roles', () => { + const tabBar = fixture.nativeElement.querySelector('.mat-tab-nav-bar')!; + expect(tabBar.getAttribute('role')).toBe(null); + + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('role')).toBe(null); + expect(tabLinks[1].getAttribute('role')).toBe(null); + expect(tabLinks[2].getAttribute('role')).toBe(null); + }); + + it('should not setup aria-controls', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('aria-controls')).toBe(null); + expect(tabLinks[1].getAttribute('aria-controls')).toBe(null); + expect(tabLinks[2].getAttribute('aria-controls')).toBe(null); + }); + + it('should not manage aria-selected', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('aria-selected')).toBe(null); + expect(tabLinks[1].getAttribute('aria-selected')).toBe(null); + expect(tabLinks[2].getAttribute('aria-selected')).toBe(null); + }); + + it('should not activate a link when space is pressed', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[1].classList.contains('mat-tab-label-active')).toBe(false); + + dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(tabLinks[1].classList.contains('mat-tab-label-active')).toBe(false); + }); + }); + + describe('with panel', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(TabBarWithPanel); + fixture.detectChanges(); + }); + + it('should have the proper roles', () => { + const tabBar = fixture.nativeElement.querySelector('.mat-tab-nav-bar')!; + expect(tabBar.getAttribute('role')).toBe('tablist'); + + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('role')).toBe('tab'); + expect(tabLinks[1].getAttribute('role')).toBe('tab'); + expect(tabLinks[2].getAttribute('role')).toBe('tab'); + + const tabPanel = fixture.nativeElement.querySelector('.mat-tab-nav-panel')!; + expect(tabPanel.getAttribute('role')).toBe('tabpanel'); + }); + + it('should manage tabindex properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].tabIndex).toBe(0); + expect(tabLinks[1].tabIndex).toBe(-1); + expect(tabLinks[2].tabIndex).toBe(-1); + + tabLinks[1].click(); + fixture.detectChanges(); + + expect(tabLinks[0].tabIndex).toBe(-1); + expect(tabLinks[1].tabIndex).toBe(0); + expect(tabLinks[2].tabIndex).toBe(-1); + }); + + it('should setup aria-controls properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('aria-controls')).toBe('tab-panel'); + expect(tabLinks[1].getAttribute('aria-controls')).toBe('tab-panel'); + expect(tabLinks[2].getAttribute('aria-controls')).toBe('tab-panel'); + }); + + it('should not manage aria-current', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('aria-current')).toBe(null); + expect(tabLinks[1].getAttribute('aria-current')).toBe(null); + expect(tabLinks[2].getAttribute('aria-current')).toBe(null); + }); + + it('should manage aria-selected properly', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[0].getAttribute('aria-selected')).toBe('true'); + expect(tabLinks[1].getAttribute('aria-selected')).toBe('false'); + expect(tabLinks[2].getAttribute('aria-selected')).toBe('false'); + + tabLinks[1].click(); + fixture.detectChanges(); + + expect(tabLinks[0].getAttribute('aria-selected')).toBe('false'); + expect(tabLinks[1].getAttribute('aria-selected')).toBe('true'); + expect(tabLinks[2].getAttribute('aria-selected')).toBe('false'); + }); + + it('should activate a link when space is pressed', () => { + const tabLinks = fixture.nativeElement.querySelectorAll('.mat-tab-link'); + expect(tabLinks[1].classList.contains('mat-tab-label-active')).toBe(false); + + dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE); + fixture.detectChanges(); + + expect(tabLinks[1].classList.contains('mat-tab-label-active')).toBe(true); + }); + }); + describe('ripples', () => { let fixture: ComponentFixture; @@ -449,3 +572,21 @@ class TabLinkWithNativeTabindexAttr {} class TabBarWithInactiveTabsOnInit { tabs = [0, 1, 2]; } + +@Component({ + template: ` + + Tab panel + `, +}) +class TabBarWithPanel { + tabs = [0, 1, 2]; + activeIndex = 0; +} diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts index ffc5b573535a..cde06f52e2f7 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {FocusableOption, FocusMonitor} from '@angular/cdk/a11y'; +import {SPACE} from '@angular/cdk/keycodes'; import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; @@ -50,6 +51,9 @@ import {startWith, takeUntil} from 'rxjs/operators'; import {MatInkBar} from '../ink-bar'; import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header'; +// Increasing integer for generating unique ids for tab nav components. +let nextUniqueId = 0; + /** * Base class with all of the `MatTabNav` functionality. * @docs-private @@ -60,7 +64,7 @@ export abstract class _MatTabNavBase implements AfterContentChecked, AfterContentInit, OnDestroy { /** Query list of all tab links of the tab navigation. */ - abstract override _items: QueryList; + abstract override _items: QueryList; /** Background color of the tab nav. */ @Input() @@ -92,6 +96,13 @@ export abstract class _MatTabNavBase /** Theme color of the nav bar. */ @Input() color: ThemePalette = 'primary'; + /** + * Associated tab panel controlled by the nav bar. If not provided, then the nav bar + * follows the ARIA link / navigation landmark pattern. If provided, it follows the + * ARIA tabs design pattern. + */ + @Input() tabPanel?: MatTabNavPanel; + constructor( elementRef: ElementRef, @Optional() dir: Directionality, @@ -130,6 +141,11 @@ export abstract class _MatTabNavBase if (items[i].active) { this.selectedIndex = i; this._changeDetectorRef.markForCheck(); + + if (this.tabPanel) { + this.tabPanel._activeTabId = items[i].id; + } + return; } } @@ -138,6 +154,10 @@ export abstract class _MatTabNavBase this.selectedIndex = -1; this._inkBar.hide(); } + + _getRole(): string | null { + return this.tabPanel ? 'tablist' : this._elementRef.nativeElement.getAttribute('role'); + } } /** @@ -151,6 +171,7 @@ export abstract class _MatTabNavBase templateUrl: 'tab-nav-bar.html', styleUrls: ['tab-nav-bar.css'], host: { + '[attr.role]': '_getRole()', 'class': 'mat-tab-nav-bar mat-tab-header', '[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls', '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", @@ -238,6 +259,9 @@ export class _MatTabLinkBase ); } + /** Unique id for the tab. */ + @Input() id = `mat-tab-link-${nextUniqueId++}`; + constructor( private _tabNavBar: _MatTabNavBase, /** @docs-private */ public elementRef: ElementRef, @@ -274,6 +298,42 @@ export class _MatTabLinkBase // have to update the focused index whenever the link receives focus. this._tabNavBar.focusIndex = this._tabNavBar._items.toArray().indexOf(this); } + + _handleKeydown(event: KeyboardEvent) { + if (this._tabNavBar.tabPanel && event.keyCode === SPACE) { + this.elementRef.nativeElement.click(); + } + } + + _getAriaControls(): string | null { + return this._tabNavBar.tabPanel + ? this._tabNavBar.tabPanel?.id + : this.elementRef.nativeElement.getAttribute('aria-controls'); + } + + _getAriaSelected(): string | null { + if (this._tabNavBar.tabPanel) { + return this.active ? 'true' : 'false'; + } else { + return this.elementRef.nativeElement.getAttribute('aria-selected'); + } + } + + _getAriaCurrent(): string | null { + return this.active && !this._tabNavBar.tabPanel ? 'page' : null; + } + + _getRole(): string | null { + return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role'); + } + + _getTabIndex(): number { + if (this._tabNavBar.tabPanel) { + return this._isActive ? 0 : -1; + } else { + return this.tabIndex; + } + } } /** @@ -285,12 +345,17 @@ export class _MatTabLinkBase inputs: ['disabled', 'disableRipple', 'tabIndex'], host: { 'class': 'mat-tab-link mat-focus-indicator', - '[attr.aria-current]': 'active ? "page" : null', + '[attr.aria-controls]': '_getAriaControls()', + '[attr.aria-current]': '_getAriaCurrent()', '[attr.aria-disabled]': 'disabled', - '[attr.tabIndex]': 'tabIndex', + '[attr.aria-selected]': '_getAriaSelected()', + '[attr.id]': 'id', + '[attr.tabIndex]': '_getTabIndex()', + '[attr.role]': '_getRole()', '[class.mat-tab-disabled]': 'disabled', '[class.mat-tab-label-active]': 'active', '(focus)': '_handleFocus()', + '(keydown)': '_handleKeydown($event)', }, }) export class MatTabLink extends _MatTabLinkBase implements OnDestroy { @@ -317,3 +382,27 @@ export class MatTabLink extends _MatTabLinkBase implements OnDestroy { this._tabLinkRipple._removeTriggerEvents(); } } + +/** + * Tab panel component associated with MatTabNav. + */ +@Component({ + selector: 'mat-tab-nav-panel', + exportAs: 'matTabNavPanel', + template: '', + host: { + '[attr.aria-labelledby]': '_activeTabId', + '[attr.id]': 'id', + 'class': 'mat-tab-nav-panel', + 'role': 'tabpanel', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatTabNavPanel { + /** Unique id for the tab panel. */ + @Input() id = `mat-tab-nav-panel-${nextUniqueId++}`; + + /** Id of the active tab in the nav bar. */ + _activeTabId?: string; +} diff --git a/src/material/tabs/tabs-module.ts b/src/material/tabs/tabs-module.ts index f6fb760eac74..e932044d5dd6 100644 --- a/src/material/tabs/tabs-module.ts +++ b/src/material/tabs/tabs-module.ts @@ -20,7 +20,7 @@ import {MatTabGroup} from './tab-group'; import {MatTabHeader} from './tab-header'; import {MatTabLabel} from './tab-label'; import {MatTabLabelWrapper} from './tab-label-wrapper'; -import {MatTabLink, MatTabNav} from './tab-nav-bar/tab-nav-bar'; +import {MatTabLink, MatTabNav, MatTabNavPanel} from './tab-nav-bar/tab-nav-bar'; @NgModule({ imports: [ @@ -38,6 +38,7 @@ import {MatTabLink, MatTabNav} from './tab-nav-bar/tab-nav-bar'; MatTabLabel, MatTab, MatTabNav, + MatTabNavPanel, MatTabLink, MatTabContent, ], @@ -48,6 +49,7 @@ import {MatTabLink, MatTabNav} from './tab-nav-bar/tab-nav-bar'; MatInkBar, MatTabLabelWrapper, MatTabNav, + MatTabNavPanel, MatTabLink, MatTabBody, MatTabBodyPortal, diff --git a/src/material/tabs/testing/tab-harness-filters.ts b/src/material/tabs/testing/tab-harness-filters.ts index 6421982988c3..1ba0149fe04e 100644 --- a/src/material/tabs/testing/tab-harness-filters.ts +++ b/src/material/tabs/testing/tab-harness-filters.ts @@ -27,3 +27,6 @@ export interface TabLinkHarnessFilters extends BaseHarnessFilters { /** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */ export interface TabNavBarHarnessFilters extends BaseHarnessFilters {} + +/** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */ +export interface TabNavPanelHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/tabs/testing/tab-nav-bar-harness.ts b/src/material/tabs/testing/tab-nav-bar-harness.ts index 39f62879b369..9b1ba673dd97 100644 --- a/src/material/tabs/testing/tab-nav-bar-harness.ts +++ b/src/material/tabs/testing/tab-nav-bar-harness.ts @@ -7,8 +7,13 @@ */ import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing'; -import {TabNavBarHarnessFilters, TabLinkHarnessFilters} from './tab-harness-filters'; +import { + TabNavBarHarnessFilters, + TabNavPanelHarnessFilters, + TabLinkHarnessFilters, +} from './tab-harness-filters'; import {MatTabLinkHarness} from './tab-link-harness'; +import {MatTabNavPanelHarness} from './tab-nav-panel-harness'; /** Harness for interacting with a standard mat-tab-nav-bar in tests. */ export class MatTabNavBarHarness extends ComponentHarness { @@ -57,4 +62,17 @@ export class MatTabNavBarHarness extends ComponentHarness { } await tabs[0].click(); } + + /** Gets the panel associated with the nav bar. */ + async getPanel(): Promise { + const link = await this.getActiveLink(); + const host = await link.host(); + const panelId = await host.getAttribute('aria-controls'); + if (!panelId) { + throw Error('No panel is controlled by the nav bar.'); + } + + const filter: TabNavPanelHarnessFilters = {selector: `#${panelId}`}; + return await this.documentRootLocatorFactory().locatorFor(MatTabNavPanelHarness.with(filter))(); + } } diff --git a/src/material/tabs/testing/tab-nav-bar-shared.spec.ts b/src/material/tabs/testing/tab-nav-bar-shared.spec.ts index b5101e0c578f..c6b5b62cc54c 100644 --- a/src/material/tabs/testing/tab-nav-bar-shared.spec.ts +++ b/src/material/tabs/testing/tab-nav-bar-shared.spec.ts @@ -92,11 +92,17 @@ export function runTabNavBarHarnessTests( expect(await links[1].isActive()).toBe(true); expect(await links[2].isActive()).toBe(false); }); + + it('should be able to get the associated tab panel', async () => { + const navBar = await loader.getHarness(tabNavBarHarness); + const navPanel = await navBar.getPanel(); + expect(await navPanel.getTextContent()).toBe('Tab content'); + }); } @Component({ template: ` -