Skip to content

Commit 6dc9473

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

File tree

13 files changed

+228
-13
lines changed

13 files changed

+228
-13
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'",
@@ -130,12 +131,17 @@ export class MatTabNav extends _MatTabNavBase implements AfterContentInit {
130131
styleUrls: ['tab-link.css'],
131132
host: {
132133
'class': 'mdc-tab mat-mdc-tab-link mat-mdc-focus-indicator',
133-
'[attr.aria-current]': 'active ? "page" : null',
134+
'[attr.aria-controls]': '_getAriaControls()',
135+
'[attr.aria-current]': '_getAriaCurrent()',
134136
'[attr.aria-disabled]': 'disabled',
135-
'[attr.tabIndex]': 'tabIndex',
137+
'[attr.aria-selected]': '_getAriaSelected()',
138+
'[attr.id]': 'id',
139+
'[attr.tabIndex]': '_getTabIndex()',
140+
'[attr.role]': '_getRole()',
136141
'[class.mat-mdc-tab-disabled]': 'disabled',
137142
'[class.mdc-tab--active]': 'active',
138143
'(focus)': '_handleFocus()',
144+
'(keydown)': '_handleKeydown($event)',
139145
},
140146
})
141147
export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit, OnDestroy {
@@ -170,3 +176,29 @@ export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit
170176
this._foundation.destroy();
171177
}
172178
}
179+
180+
// Increasing integer for generating unique ids for tab nav components.
181+
let nextUniqueId = 0;
182+
183+
/**
184+
* Tab panel component associated with MatTabNav.
185+
*/
186+
@Component({
187+
selector: 'mat-tab-nav-panel',
188+
exportAs: 'matTabNavPanel',
189+
template: '<ng-content></ng-content>',
190+
host: {
191+
'[attr.aria-labelledby]': '_activeTabId',
192+
'[attr.id]': 'id',
193+
'role': 'tabpanel',
194+
},
195+
encapsulation: ViewEncapsulation.None,
196+
changeDetection: ChangeDetectionStrategy.OnPush,
197+
})
198+
export class MatTabNavPanel {
199+
/** Unique id for the tab panel. */
200+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
201+
202+
/** Id of the active tab in the nav bar. */
203+
_activeTabId?: string;
204+
}

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, NumberInput} 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'",
@@ -240,6 +261,9 @@ export class _MatTabLinkBase
240261
);
241262
}
242263

264+
/** Unique id for the tab. */
265+
@Input() id = `mat-tab-link-${nextUniqueId++}`;
266+
243267
constructor(
244268
private _tabNavBar: _MatTabNavBase,
245269
/** @docs-private */ public elementRef: ElementRef,
@@ -277,6 +301,42 @@ export class _MatTabLinkBase
277301
this._tabNavBar.focusIndex = this._tabNavBar._items.toArray().indexOf(this);
278302
}
279303

304+
_handleKeydown(event: KeyboardEvent) {
305+
if (this._tabNavBar.tabPanel && event.keyCode === SPACE) {
306+
this.elementRef.nativeElement.click();
307+
}
308+
}
309+
310+
_getAriaControls(): string | null {
311+
return this._tabNavBar.tabPanel
312+
? this._tabNavBar.tabPanel?.id
313+
: this.elementRef.nativeElement.getAttribute('aria-controls');
314+
}
315+
316+
_getAriaSelected(): string | null {
317+
if (this._tabNavBar.tabPanel) {
318+
return this.active ? 'true' : 'false';
319+
} else {
320+
return this.elementRef.nativeElement.getAttribute('aria-selected');
321+
}
322+
}
323+
324+
_getAriaCurrent(): string | null {
325+
return this.active && !this._tabNavBar.tabPanel ? 'page' : null;
326+
}
327+
328+
_getRole(): string | null {
329+
return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role');
330+
}
331+
332+
_getTabIndex(): number {
333+
if (this._tabNavBar.tabPanel) {
334+
return this._isActive ? 0 : -1;
335+
} else {
336+
return this.tabIndex;
337+
}
338+
}
339+
280340
static ngAcceptInputType_active: BooleanInput;
281341
static ngAcceptInputType_disabled: BooleanInput;
282342
static ngAcceptInputType_disableRipple: BooleanInput;
@@ -292,12 +352,17 @@ export class _MatTabLinkBase
292352
inputs: ['disabled', 'disableRipple', 'tabIndex'],
293353
host: {
294354
'class': 'mat-tab-link mat-focus-indicator',
295-
'[attr.aria-current]': 'active ? "page" : null',
355+
'[attr.aria-controls]': '_getAriaControls()',
356+
'[attr.aria-current]': '_getAriaCurrent()',
296357
'[attr.aria-disabled]': 'disabled',
297-
'[attr.tabIndex]': 'tabIndex',
358+
'[attr.aria-selected]': '_getAriaSelected()',
359+
'[attr.id]': 'id',
360+
'[attr.tabIndex]': '_getTabIndex()',
361+
'[attr.role]': '_getRole()',
298362
'[class.mat-tab-disabled]': 'disabled',
299363
'[class.mat-tab-label-active]': 'active',
300364
'(focus)': '_handleFocus()',
365+
'(keydown)': '_handleKeydown($event)',
301366
},
302367
})
303368
export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
@@ -324,3 +389,26 @@ export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
324389
this._tabLinkRipple._removeTriggerEvents();
325390
}
326391
}
392+
393+
/**
394+
* Tab panel component associated with MatTabNav.
395+
*/
396+
@Component({
397+
selector: 'mat-tab-nav-panel',
398+
exportAs: 'matTabNavPanel',
399+
template: '<ng-content></ng-content>',
400+
host: {
401+
'[attr.aria-labelledby]': '_activeTabId',
402+
'[attr.id]': 'id',
403+
'role': 'tabpanel',
404+
},
405+
encapsulation: ViewEncapsulation.None,
406+
changeDetection: ChangeDetectionStrategy.OnPush,
407+
})
408+
export class MatTabNavPanel {
409+
/** Unique id for the tab panel. */
410+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
411+
412+
/** Id of the active tab in the nav bar. */
413+
_activeTabId?: string;
414+
}

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)