Skip to content

Commit ea78a47

Browse files
authored
feat(material/tabs): Refactor MatTabNav to follow the ARIA tabs pattern (#24062)
by introducing a new tabpanel component.
1 parent 1dd2955 commit ea78a47

23 files changed

+618
-18
lines changed

src/components-examples/material/tabs/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {TabGroupLazyLoadedExample} from './tab-group-lazy-loaded/tab-group-lazy-
2020
import {TabGroupStretchedExample} from './tab-group-stretched/tab-group-stretched-example';
2121
import {TabGroupThemeExample} from './tab-group-theme/tab-group-theme-example';
2222
import {TabNavBarBasicExample} from './tab-nav-bar-basic/tab-nav-bar-basic-example';
23+
import {TabNavBarWithPanelExample} from './tab-nav-bar-with-panel/tab-nav-bar-with-panel-example';
2324

2425
export {
2526
TabGroupAlignExample,
@@ -35,6 +36,7 @@ export {
3536
TabGroupStretchedExample,
3637
TabGroupThemeExample,
3738
TabNavBarBasicExample,
39+
TabNavBarWithPanelExample,
3840
};
3941

4042
const EXAMPLES = [
@@ -51,6 +53,7 @@ const EXAMPLES = [
5153
TabGroupStretchedExample,
5254
TabGroupThemeExample,
5355
TabNavBarBasicExample,
56+
TabNavBarWithPanelExample,
5457
];
5558

5659
@NgModule({
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.example-action-button {
2+
margin-top: 8px;
3+
margin-right: 8px;
4+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!-- #docregion mat-tab-nav -->
2+
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
3+
<a mat-tab-link *ngFor="let link of links"
4+
(click)="activeLink = link"
5+
[active]="activeLink == link"> {{link}} </a>
6+
<a mat-tab-link disabled>Disabled Link</a>
7+
</nav>
8+
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
9+
<!-- #enddocregion mat-tab-nav -->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {Component} from '@angular/core';
2+
3+
/**
4+
* @title Use of the tab nav bar with the dedicated panel component.
5+
*/
6+
@Component({
7+
selector: 'tab-nav-bar-with-panel-example',
8+
templateUrl: 'tab-nav-bar-with-panel-example.html',
9+
styleUrls: ['tab-nav-bar-with-panel-example.css'],
10+
})
11+
export class TabNavBarWithPanelExample {
12+
links = ['First', 'Second', 'Third'];
13+
activeLink = this.links[0];
14+
}

src/dev-app/mdc-tabs/mdc-tabs-demo.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,13 @@ <h2>Tab nav bar</h2>
127127
[active]="activeLink == link">{{link}}</a>
128128
<a mat-tab-link disabled>Disabled Link</a>
129129
</nav>
130+
131+
<h2>Tab nav bar with panel</h2>
132+
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
133+
<a mat-tab-link *ngFor="let link of links"
134+
(click)="activeLink = link"
135+
[active]="activeLink == link">{{link}}</a>
136+
<a mat-tab-link disabled>Disabled Link</a>
137+
</nav>
138+
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
130139
</div>

src/dev-app/tabs/tabs-demo.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ <h3>Tab group stretched</h3>
1818
<tab-group-stretched-example></tab-group-stretched-example>
1919
<h3>Tab group theming</h3>
2020
<tab-group-theme-example></tab-group-theme-example>
21-
<h3>Tab Navigation Bar basic</h3>
21+
<h3>Tab navigation bar basic</h3>
2222
<tab-nav-bar-basic-example></tab-nav-bar-basic-example>
23+
<h3>Tab navigation bar with panel</h3>
24+
<tab-nav-bar-with-panel-example></tab-nav-bar-with-panel-example>

src/material-experimental/mdc-tabs/module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {MatTabLabelWrapper} from './tab-label-wrapper';
1919
import {MatTab} from './tab';
2020
import {MatTabHeader} from './tab-header';
2121
import {MatTabGroup} from './tab-group';
22-
import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar';
22+
import {MatTabNav, MatTabNavPanel, MatTabLink} from './tab-nav-bar/tab-nav-bar';
2323

2424
@NgModule({
2525
imports: [
@@ -37,6 +37,7 @@ import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar';
3737
MatTab,
3838
MatTabGroup,
3939
MatTabNav,
40+
MatTabNavPanel,
4041
MatTabLink,
4142
],
4243
declarations: [
@@ -45,6 +46,7 @@ import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar';
4546
MatTab,
4647
MatTabGroup,
4748
MatTabNav,
49+
MatTabNavPanel,
4850
MatTabLink,
4951

5052
// Private directives, should not be exported.

src/material-experimental/mdc-tabs/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export {MatTab} from './tab';
1515
export {MatInkBar} from './ink-bar';
1616
export {MatTabHeader} from './tab-header';
1717
export {MatTabGroup} from './tab-group';
18-
export {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar';
18+
export {MatTabNav, MatTabNavPanel, MatTabLink} from './tab-nav-bar/tab-nav-bar';
1919

2020
export {
2121
MatTabBodyPositionState,

src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import {SPACE} from '@angular/cdk/keycodes';
12
import {waitForAsync, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
23
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
34
import {
45
MAT_RIPPLE_GLOBAL_OPTIONS,
56
RippleGlobalOptions,
67
} from '@angular/material-experimental/mdc-core';
78
import {By} from '@angular/platform-browser';
8-
import {dispatchFakeEvent, dispatchMouseEvent} from '../../../cdk/testing/private';
9+
import {
10+
dispatchFakeEvent,
11+
dispatchKeyboardEvent,
12+
dispatchMouseEvent,
13+
} from '../../../cdk/testing/private';
914
import {Direction, Directionality} from '@angular/cdk/bidi';
1015
import {Subject} from 'rxjs';
1116
import {MatTabsModule} from '../module';
@@ -30,6 +35,7 @@ describe('MDC-based MatTabNavBar', () => {
3035
TabLinkWithTabIndexBinding,
3136
TabLinkWithNativeTabindexAttr,
3237
TabBarWithInactiveTabsOnInit,
38+
TabBarWithPanel,
3339
],
3440
providers: [
3541
{provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions},
@@ -309,6 +315,123 @@ describe('MDC-based MatTabNavBar', () => {
309315
expect(instance.tabNavBar.selectedIndex).toBe(1);
310316
});
311317

318+
describe('without panel', () => {
319+
let fixture: ComponentFixture<SimpleTabNavBarTestApp>;
320+
321+
beforeEach(() => {
322+
fixture = TestBed.createComponent(SimpleTabNavBarTestApp);
323+
fixture.detectChanges();
324+
});
325+
326+
it('should have no explicit roles', () => {
327+
const tabBar = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-bar')!;
328+
expect(tabBar.getAttribute('role')).toBe(null);
329+
330+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
331+
expect(tabLinks[0].getAttribute('role')).toBe(null);
332+
expect(tabLinks[1].getAttribute('role')).toBe(null);
333+
expect(tabLinks[2].getAttribute('role')).toBe(null);
334+
});
335+
336+
it('should not setup aria-controls', () => {
337+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
338+
expect(tabLinks[0].getAttribute('aria-controls')).toBe(null);
339+
expect(tabLinks[1].getAttribute('aria-controls')).toBe(null);
340+
expect(tabLinks[2].getAttribute('aria-controls')).toBe(null);
341+
});
342+
343+
it('should not manage aria-selected', () => {
344+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
345+
expect(tabLinks[0].getAttribute('aria-selected')).toBe(null);
346+
expect(tabLinks[1].getAttribute('aria-selected')).toBe(null);
347+
expect(tabLinks[2].getAttribute('aria-selected')).toBe(null);
348+
});
349+
350+
it('should not activate a link when space is pressed', () => {
351+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
352+
expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false);
353+
354+
dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE);
355+
fixture.detectChanges();
356+
357+
expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false);
358+
});
359+
});
360+
361+
describe('with panel', () => {
362+
let fixture: ComponentFixture<TabBarWithPanel>;
363+
364+
beforeEach(() => {
365+
fixture = TestBed.createComponent(TabBarWithPanel);
366+
fixture.detectChanges();
367+
});
368+
369+
it('should have the proper roles', () => {
370+
const tabBar = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-bar')!;
371+
expect(tabBar.getAttribute('role')).toBe('tablist');
372+
373+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
374+
expect(tabLinks[0].getAttribute('role')).toBe('tab');
375+
expect(tabLinks[1].getAttribute('role')).toBe('tab');
376+
expect(tabLinks[2].getAttribute('role')).toBe('tab');
377+
378+
const tabPanel = fixture.nativeElement.querySelector('.mat-mdc-tab-nav-panel')!;
379+
expect(tabPanel.getAttribute('role')).toBe('tabpanel');
380+
});
381+
382+
it('should manage tabindex properly', () => {
383+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
384+
expect(tabLinks[0].tabIndex).toBe(0);
385+
expect(tabLinks[1].tabIndex).toBe(-1);
386+
expect(tabLinks[2].tabIndex).toBe(-1);
387+
388+
tabLinks[1].click();
389+
fixture.detectChanges();
390+
391+
expect(tabLinks[0].tabIndex).toBe(-1);
392+
expect(tabLinks[1].tabIndex).toBe(0);
393+
expect(tabLinks[2].tabIndex).toBe(-1);
394+
});
395+
396+
it('should setup aria-controls properly', () => {
397+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
398+
expect(tabLinks[0].getAttribute('aria-controls')).toBe('tab-panel');
399+
expect(tabLinks[1].getAttribute('aria-controls')).toBe('tab-panel');
400+
expect(tabLinks[2].getAttribute('aria-controls')).toBe('tab-panel');
401+
});
402+
403+
it('should not manage aria-current', () => {
404+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
405+
expect(tabLinks[0].getAttribute('aria-current')).toBe(null);
406+
expect(tabLinks[1].getAttribute('aria-current')).toBe(null);
407+
expect(tabLinks[2].getAttribute('aria-current')).toBe(null);
408+
});
409+
410+
it('should manage aria-selected properly', () => {
411+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
412+
expect(tabLinks[0].getAttribute('aria-selected')).toBe('true');
413+
expect(tabLinks[1].getAttribute('aria-selected')).toBe('false');
414+
expect(tabLinks[2].getAttribute('aria-selected')).toBe('false');
415+
416+
tabLinks[1].click();
417+
fixture.detectChanges();
418+
419+
expect(tabLinks[0].getAttribute('aria-selected')).toBe('false');
420+
expect(tabLinks[1].getAttribute('aria-selected')).toBe('true');
421+
expect(tabLinks[2].getAttribute('aria-selected')).toBe('false');
422+
});
423+
424+
it('should activate a link when space is pressed', () => {
425+
const tabLinks = fixture.nativeElement.querySelectorAll('.mat-mdc-tab-link');
426+
expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(false);
427+
428+
dispatchKeyboardEvent(tabLinks[1], 'keydown', SPACE);
429+
fixture.detectChanges();
430+
431+
expect(tabLinks[1].classList.contains('mdc-tab--active')).toBe(true);
432+
});
433+
});
434+
312435
describe('ripples', () => {
313436
let fixture: ComponentFixture<SimpleTabNavBarTestApp>;
314437

@@ -532,3 +655,21 @@ class TabLinkWithNativeTabindexAttr {}
532655
class TabBarWithInactiveTabsOnInit {
533656
tabs = [0, 1, 2];
534657
}
658+
659+
@Component({
660+
template: `
661+
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
662+
<a mat-tab-link
663+
*ngFor="let tab of tabs; let index = index"
664+
[active]="index === activeIndex"
665+
(click)="activeIndex = index">
666+
Tab link
667+
</a>
668+
</nav>
669+
<mat-tab-nav-panel #tabPanel id="tab-panel">Tab panel</mat-tab-nav-panel>
670+
`,
671+
})
672+
class TabBarWithPanel {
673+
tabs = [0, 1, 2];
674+
activeIndex = 0;
675+
}

src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {takeUntil} from 'rxjs/operators';
5656
templateUrl: 'tab-nav-bar.html',
5757
styleUrls: ['tab-nav-bar.css'],
5858
host: {
59+
'[attr.role]': '_getRole()',
5960
'class': 'mat-mdc-tab-nav-bar mat-mdc-tab-header',
6061
'[class.mat-mdc-tab-header-pagination-controls-enabled]': '_showPaginationControls',
6162
'[class.mat-mdc-tab-header-rtl]': "_getLayoutDirection() == 'rtl'",
@@ -127,12 +128,17 @@ export class MatTabNav extends _MatTabNavBase implements AfterContentInit {
127128
styleUrls: ['tab-link.css'],
128129
host: {
129130
'class': 'mdc-tab mat-mdc-tab-link mat-mdc-focus-indicator',
130-
'[attr.aria-current]': 'active ? "page" : null',
131+
'[attr.aria-controls]': '_getAriaControls()',
132+
'[attr.aria-current]': '_getAriaCurrent()',
131133
'[attr.aria-disabled]': 'disabled',
132-
'[attr.tabIndex]': 'tabIndex',
134+
'[attr.aria-selected]': '_getAriaSelected()',
135+
'[attr.id]': 'id',
136+
'[attr.tabIndex]': '_getTabIndex()',
137+
'[attr.role]': '_getRole()',
133138
'[class.mat-mdc-tab-disabled]': 'disabled',
134139
'[class.mdc-tab--active]': 'active',
135140
'(focus)': '_handleFocus()',
141+
'(keydown)': '_handleKeydown($event)',
136142
},
137143
})
138144
export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit, OnDestroy {
@@ -167,3 +173,30 @@ export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit
167173
this._foundation.destroy();
168174
}
169175
}
176+
177+
// Increasing integer for generating unique ids for tab nav components.
178+
let nextUniqueId = 0;
179+
180+
/**
181+
* Tab panel component associated with MatTabNav.
182+
*/
183+
@Component({
184+
selector: 'mat-tab-nav-panel',
185+
exportAs: 'matTabNavPanel',
186+
template: '<ng-content></ng-content>',
187+
host: {
188+
'[attr.aria-labelledby]': '_activeTabId',
189+
'[attr.id]': 'id',
190+
'class': 'mat-mdc-tab-nav-panel',
191+
'role': 'tabpanel',
192+
},
193+
encapsulation: ViewEncapsulation.None,
194+
changeDetection: ChangeDetectionStrategy.OnPush,
195+
})
196+
export class MatTabNavPanel {
197+
/** Unique id for the tab panel. */
198+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
199+
200+
/** Id of the active tab in the nav bar. */
201+
_activeTabId?: string;
202+
}

src/material-experimental/mdc-tabs/testing/tab-harness-filters.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ export interface TabLinkHarnessFilters extends BaseHarnessFilters {
2727

2828
/** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */
2929
export interface TabNavBarHarnessFilters extends BaseHarnessFilters {}
30+
31+
/** A set of criteria that can be used to filter a list of `MatTabNavBarHarness` instances. */
32+
export interface TabNavPanelHarnessFilters extends BaseHarnessFilters {}

src/material-experimental/mdc-tabs/testing/tab-nav-bar-harness.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77
*/
88

99
import {ComponentHarness, HarnessPredicate, parallel} from '@angular/cdk/testing';
10-
import {TabNavBarHarnessFilters, TabLinkHarnessFilters} from './tab-harness-filters';
10+
import {
11+
TabNavBarHarnessFilters,
12+
TabNavPanelHarnessFilters,
13+
TabLinkHarnessFilters,
14+
} from './tab-harness-filters';
1115
import {MatTabLinkHarness} from './tab-link-harness';
16+
import {MatTabNavPanelHarness} from './tab-nav-panel-harness';
1217

1318
/** Harness for interacting with an MDC-based mat-tab-nav-bar in tests. */
1419
export class MatTabNavBarHarness extends ComponentHarness {
@@ -57,4 +62,17 @@ export class MatTabNavBarHarness extends ComponentHarness {
5762
}
5863
await tabs[0].click();
5964
}
65+
66+
/** Gets the panel associated with the nav bar. */
67+
async getPanel(): Promise<MatTabNavPanelHarness> {
68+
const link = await this.getActiveLink();
69+
const host = await link.host();
70+
const panelId = await host.getAttribute('aria-controls');
71+
if (!panelId) {
72+
throw Error('No panel is controlled by the nav bar.');
73+
}
74+
75+
const filter: TabNavPanelHarnessFilters = {selector: `#${panelId}`};
76+
return await this.documentRootLocatorFactory().locatorFor(MatTabNavPanelHarness.with(filter))();
77+
}
6078
}

0 commit comments

Comments
 (0)