Skip to content

Commit 5163cd8

Browse files
committed
feat(material/tabs): Refactor MatTabNav to follow the ARIA tabs pattern
by introducing a new tabpanel component.
1 parent c5c994b commit 5163cd8

File tree

13 files changed

+229
-14
lines changed

13 files changed

+229
-14
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!-- #docregion mat-tab-nav -->
2+
<nav mat-tab-nav-bar [backgroundColor]="background" [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 -->
10+
11+
<button mat-raised-button class="example-action-button" (click)="toggleBackground()">
12+
Toggle background
13+
</button>
14+
<button mat-raised-button class="example-action-button" (click)="addLink()">
15+
Add link
16+
</button>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {Component} from '@angular/core';
2+
import {ThemePalette} from '@angular/material/core';
3+
4+
/**
5+
* @title Use of the tab nav bar with the dedicated panel component.
6+
*/
7+
@Component({
8+
selector: 'tab-nav-bar-with-panel-example',
9+
templateUrl: 'tab-nav-bar-with-panel-example.html',
10+
styleUrls: ['tab-nav-bar-with-panel-example.css'],
11+
})
12+
export class TabNavBarWithPanelExample {
13+
links = ['First', 'Second', 'Third'];
14+
activeLink = this.links[0];
15+
background: ThemePalette = undefined;
16+
17+
toggleBackground() {
18+
this.background = this.background ? undefined : 'primary';
19+
}
20+
21+
addLink() {
22+
this.links.push(`Link ${this.links.length + 1}`);
23+
}
24+
}

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.ts

Lines changed: 34 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,29 @@ 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+
'role': 'tabpanel',
191+
},
192+
encapsulation: ViewEncapsulation.None,
193+
changeDetection: ChangeDetectionStrategy.OnPush,
194+
})
195+
export class MatTabNavPanel {
196+
/** Unique id for the tab panel. */
197+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
198+
199+
/** Id of the active tab in the nav bar. */
200+
_activeTabId?: string;
201+
}

src/material/tabs/public-api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ export {MatTabHeader, _MatTabHeaderBase} from './tab-header';
2020
export {MatTabLabelWrapper} from './tab-label-wrapper';
2121
export {MatTab, MAT_TAB_GROUP} from './tab';
2222
export {MatTabLabel, MAT_TAB} from './tab-label';
23-
export {MatTabNav, MatTabLink, _MatTabNavBase, _MatTabLinkBase} from './tab-nav-bar/index';
23+
export {
24+
MatTabNav,
25+
MatTabLink,
26+
MatTabNavPanel,
27+
_MatTabNavBase,
28+
_MatTabLinkBase,
29+
} from './tab-nav-bar/index';
2430
export {MatTabContent} from './tab-content';
2531
export {ScrollDirection} from './paginated-tab-header';
2632
export * from './tabs-animations';

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

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import {FocusableOption, FocusMonitor} from '@angular/cdk/a11y';
9+
import {SPACE} from '@angular/cdk/keycodes';
910
import {Directionality} from '@angular/cdk/bidi';
1011
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
1112
import {Platform} from '@angular/cdk/platform';
@@ -50,6 +51,9 @@ import {startWith, takeUntil} from 'rxjs/operators';
5051
import {MatInkBar} from '../ink-bar';
5152
import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header';
5253

54+
// Increasing integer for generating unique ids for tab nav components.
55+
let nextUniqueId = 0;
56+
5357
/**
5458
* Base class with all of the `MatTabNav` functionality.
5559
* @docs-private
@@ -60,7 +64,7 @@ export abstract class _MatTabNavBase
6064
implements AfterContentChecked, AfterContentInit, OnDestroy
6165
{
6266
/** Query list of all tab links of the tab navigation. */
63-
abstract override _items: QueryList<MatPaginatedTabHeaderItem & {active: boolean}>;
67+
abstract override _items: QueryList<MatPaginatedTabHeaderItem & {active: boolean; id: string}>;
6468

6569
/** Background color of the tab nav. */
6670
@Input()
@@ -92,6 +96,13 @@ export abstract class _MatTabNavBase
9296
/** Theme color of the nav bar. */
9397
@Input() color: ThemePalette = 'primary';
9498

99+
/**
100+
* Associated tab panel controlled by the nav bar. If not provided, then the nav bar
101+
* follows the ARIA link / navigation landmark pattern. If provided, it follows the
102+
* ARIA tabs design pattern.
103+
*/
104+
@Input() tabPanel?: MatTabNavPanel;
105+
95106
constructor(
96107
elementRef: ElementRef,
97108
@Optional() dir: Directionality,
@@ -130,6 +141,11 @@ export abstract class _MatTabNavBase
130141
if (items[i].active) {
131142
this.selectedIndex = i;
132143
this._changeDetectorRef.markForCheck();
144+
145+
if (this.tabPanel) {
146+
this.tabPanel._activeTabId = items[i].id;
147+
}
148+
133149
return;
134150
}
135151
}
@@ -138,6 +154,10 @@ export abstract class _MatTabNavBase
138154
this.selectedIndex = -1;
139155
this._inkBar.hide();
140156
}
157+
158+
_getRole(): string | null {
159+
return this.tabPanel ? 'tablist' : this._elementRef.nativeElement.getAttribute('role');
160+
}
141161
}
142162

143163
/**
@@ -151,6 +171,7 @@ export abstract class _MatTabNavBase
151171
templateUrl: 'tab-nav-bar.html',
152172
styleUrls: ['tab-nav-bar.css'],
153173
host: {
174+
'[attr.role]': '_getRole()',
154175
'class': 'mat-tab-nav-bar mat-tab-header',
155176
'[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls',
156177
'[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'",
@@ -238,6 +259,9 @@ export class _MatTabLinkBase
238259
);
239260
}
240261

262+
/** Unique id for the tab. */
263+
@Input() id = `mat-tab-link-${nextUniqueId++}`;
264+
241265
constructor(
242266
private _tabNavBar: _MatTabNavBase,
243267
/** @docs-private */ public elementRef: ElementRef,
@@ -274,6 +298,42 @@ export class _MatTabLinkBase
274298
// have to update the focused index whenever the link receives focus.
275299
this._tabNavBar.focusIndex = this._tabNavBar._items.toArray().indexOf(this);
276300
}
301+
302+
_handleKeydown(event: KeyboardEvent) {
303+
if (this._tabNavBar.tabPanel && event.keyCode === SPACE) {
304+
this.elementRef.nativeElement.click();
305+
}
306+
}
307+
308+
_getAriaControls(): string | null {
309+
return this._tabNavBar.tabPanel
310+
? this._tabNavBar.tabPanel?.id
311+
: this.elementRef.nativeElement.getAttribute('aria-controls');
312+
}
313+
314+
_getAriaSelected(): string | null {
315+
if (this._tabNavBar.tabPanel) {
316+
return this.active ? 'true' : 'false';
317+
} else {
318+
return this.elementRef.nativeElement.getAttribute('aria-selected');
319+
}
320+
}
321+
322+
_getAriaCurrent(): string | null {
323+
return this.active && !this._tabNavBar.tabPanel ? 'page' : null;
324+
}
325+
326+
_getRole(): string | null {
327+
return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role');
328+
}
329+
330+
_getTabIndex(): number {
331+
if (this._tabNavBar.tabPanel) {
332+
return this._isActive ? 0 : -1;
333+
} else {
334+
return this.tabIndex;
335+
}
336+
}
277337
}
278338

279339
/**
@@ -285,12 +345,17 @@ export class _MatTabLinkBase
285345
inputs: ['disabled', 'disableRipple', 'tabIndex'],
286346
host: {
287347
'class': 'mat-tab-link mat-focus-indicator',
288-
'[attr.aria-current]': 'active ? "page" : null',
348+
'[attr.aria-controls]': '_getAriaControls()',
349+
'[attr.aria-current]': '_getAriaCurrent()',
289350
'[attr.aria-disabled]': 'disabled',
290-
'[attr.tabIndex]': 'tabIndex',
351+
'[attr.aria-selected]': '_getAriaSelected()',
352+
'[attr.id]': 'id',
353+
'[attr.tabIndex]': '_getTabIndex()',
354+
'[attr.role]': '_getRole()',
291355
'[class.mat-tab-disabled]': 'disabled',
292356
'[class.mat-tab-label-active]': 'active',
293357
'(focus)': '_handleFocus()',
358+
'(keydown)': '_handleKeydown($event)',
294359
},
295360
})
296361
export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
@@ -317,3 +382,26 @@ export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
317382
this._tabLinkRipple._removeTriggerEvents();
318383
}
319384
}
385+
386+
/**
387+
* Tab panel component associated with MatTabNav.
388+
*/
389+
@Component({
390+
selector: 'mat-tab-nav-panel',
391+
exportAs: 'matTabNavPanel',
392+
template: '<ng-content></ng-content>',
393+
host: {
394+
'[attr.aria-labelledby]': '_activeTabId',
395+
'[attr.id]': 'id',
396+
'role': 'tabpanel',
397+
},
398+
encapsulation: ViewEncapsulation.None,
399+
changeDetection: ChangeDetectionStrategy.OnPush,
400+
})
401+
export class MatTabNavPanel {
402+
/** Unique id for the tab panel. */
403+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
404+
405+
/** Id of the active tab in the nav bar. */
406+
_activeTabId?: string;
407+
}

src/material/tabs/tabs-module.ts

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

2525
@NgModule({
2626
imports: [
@@ -38,6 +38,7 @@ import {MatTabLink, MatTabNav} from './tab-nav-bar/tab-nav-bar';
3838
MatTabLabel,
3939
MatTab,
4040
MatTabNav,
41+
MatTabNavPanel,
4142
MatTabLink,
4243
MatTabContent,
4344
],
@@ -48,6 +49,7 @@ import {MatTabLink, MatTabNav} from './tab-nav-bar/tab-nav-bar';
4849
MatInkBar,
4950
MatTabLabelWrapper,
5051
MatTabNav,
52+
MatTabNavPanel,
5153
MatTabLink,
5254
MatTabBody,
5355
MatTabBodyPortal,

0 commit comments

Comments
 (0)