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: `
-