Skip to content

Commit b30cc24

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

File tree

13 files changed

+216
-14
lines changed

13 files changed

+216
-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: 33 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]': 'panel ? "tablist" : null',
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,16 @@ 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]': '_tabNavBar.tabPanel ? _tabNavBar.tabPanel.id : null',
135+
'[attr.aria-current]': '(active && !_tabNavBar.tabPanel) ? "page" : null',
134136
'[attr.aria-disabled]': 'disabled',
135-
'[attr.tabIndex]': 'tabIndex',
137+
'[attr.aria-selected]': '_tabNavBar.tabPanel ? (active ? "true" : "false") : null',
138+
'[attr.id]': 'id',
139+
'[attr.tabIndex]': '_getTabIndex()',
136140
'[class.mat-mdc-tab-disabled]': 'disabled',
137141
'[class.mdc-tab--active]': 'active',
138142
'(focus)': '_handleFocus()',
143+
'(keydown)': '_handleKeydown($event)',
139144
},
140145
})
141146
export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit, OnDestroy {
@@ -170,3 +175,29 @@ export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnInit
170175
this._foundation.destroy();
171176
}
172177
}
178+
179+
// Increasing integer for generating unique ids for tab nav components.
180+
let nextUniqueId = 0;
181+
182+
/**
183+
* Tab panel component associated with MatTabNav.
184+
*/
185+
@Component({
186+
selector: 'mat-tab-nav-panel',
187+
exportAs: 'matTabNavPanel',
188+
template: '<ng-content></ng-content>',
189+
host: {
190+
'[attr.aria-labelledby]': '_activeTabId',
191+
'[attr.id]': 'id',
192+
'role': 'tabpanel',
193+
},
194+
encapsulation: ViewEncapsulation.None,
195+
changeDetection: ChangeDetectionStrategy.OnPush,
196+
})
197+
export class MatTabNavPanel {
198+
/** Unique id for the tab panel. */
199+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
200+
201+
/** Id of the active tab in the nav bar. */
202+
_activeTabId?: string;
203+
}

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: 90 additions & 4 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,9 @@ 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<
68+
MatPaginatedTabHeaderItem & {active: boolean; id: string; index: number}
69+
>;
6470

6571
/** Background color of the tab nav. */
6672
@Input()
@@ -92,6 +98,13 @@ export abstract class _MatTabNavBase
9298
/** Theme color of the nav bar. */
9399
@Input() color: ThemePalette = 'primary';
94100

101+
/**
102+
* Associated tab panel controlled by the nav bar. If not provided, then the nav bar
103+
* follows the ARIA link / navigation landmark pattern. If provided, it follows the
104+
* ARIA tabs design pattern.
105+
*/
106+
@Input() tabPanel?: MatTabNavPanel;
107+
95108
constructor(
96109
elementRef: ElementRef,
97110
@Optional() dir: Directionality,
@@ -113,6 +126,7 @@ export abstract class _MatTabNavBase
113126
// selectedIndex is up-to-date by the time the super class starts looking for it.
114127
this._items.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
115128
this.updateActiveLink();
129+
this.updateIndices();
116130
});
117131

118132
super.ngAfterContentInit();
@@ -130,6 +144,11 @@ export abstract class _MatTabNavBase
130144
if (items[i].active) {
131145
this.selectedIndex = i;
132146
this._changeDetectorRef.markForCheck();
147+
148+
if (this.tabPanel) {
149+
this.tabPanel._activeTabId = items[i].id;
150+
}
151+
133152
return;
134153
}
135154
}
@@ -138,6 +157,19 @@ export abstract class _MatTabNavBase
138157
this.selectedIndex = -1;
139158
this._inkBar.hide();
140159
}
160+
161+
/** Updates the indices of the tabs within the nav bar. */
162+
updateIndices() {
163+
if (!this._items) {
164+
return;
165+
}
166+
167+
const items = this._items.toArray();
168+
169+
for (let i = 0; i < items.length; i++) {
170+
items[i].index = i;
171+
}
172+
}
141173
}
142174

143175
/**
@@ -151,6 +183,7 @@ export abstract class _MatTabNavBase
151183
templateUrl: 'tab-nav-bar.html',
152184
styleUrls: ['tab-nav-bar.css'],
153185
host: {
186+
'[attr.role]': 'panel ? "tablist" : null',
154187
'class': 'mat-tab-nav-bar mat-tab-header',
155188
'[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls',
156189
'[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'",
@@ -240,6 +273,12 @@ export class _MatTabLinkBase
240273
);
241274
}
242275

276+
/** Unique id for the tab. */
277+
@Input() id = `mat-tab-link-${nextUniqueId++}`;
278+
279+
/** Index of the tab within the nav bar. Managed by the nav bar. */
280+
index = -1;
281+
243282
constructor(
244283
private _tabNavBar: _MatTabNavBase,
245284
/** @docs-private */ public elementRef: ElementRef,
@@ -274,7 +313,26 @@ export class _MatTabLinkBase
274313
_handleFocus() {
275314
// Since we allow navigation through tabbing in the nav bar, we
276315
// have to update the focused index whenever the link receives focus.
277-
this._tabNavBar.focusIndex = this._tabNavBar._items.toArray().indexOf(this);
316+
this._tabNavBar.focusIndex = this.index;
317+
}
318+
319+
_handleKeydown(event: KeyboardEvent) {
320+
if (this._tabNavBar.tabPanel && event.keyCode === SPACE) {
321+
this.elementRef.nativeElement.click();
322+
}
323+
}
324+
325+
/** Returns the tab's tabindex. */
326+
_getTabIndex(): number {
327+
if (!this._tabNavBar.tabPanel) {
328+
return this.tabIndex;
329+
}
330+
331+
if (!this._tabNavBar._items) {
332+
return this._isActive ? 0 : -1;
333+
}
334+
335+
return this._tabNavBar.focusIndex === this.index ? 0 : -1;
278336
}
279337

280338
static ngAcceptInputType_active: BooleanInput;
@@ -292,12 +350,17 @@ export class _MatTabLinkBase
292350
inputs: ['disabled', 'disableRipple', 'tabIndex'],
293351
host: {
294352
'class': 'mat-tab-link mat-focus-indicator',
295-
'[attr.aria-current]': 'active ? "page" : null',
353+
'[attr.aria-controls]': '_tabNavBar.tabPanel ? _tabNavBar.tabPanel.id : null',
354+
'[attr.aria-current]': '(active && !_tabNavBar.tabPanel) ? "page" : null',
296355
'[attr.aria-disabled]': 'disabled',
297-
'[attr.tabIndex]': 'tabIndex',
356+
'[attr.aria-selected]': '_tabNavBar.tabPanel ? (active ? "true" : "false") : null',
357+
'[attr.id]': 'id',
358+
'[attr.tabIndex]': '_getTabIndex()',
359+
'[attr.role]': '_tabNavBar.tabPanel ? "tab" : null',
298360
'[class.mat-tab-disabled]': 'disabled',
299361
'[class.mat-tab-label-active]': 'active',
300362
'(focus)': '_handleFocus()',
363+
'(keydown)': '_handleKeydown($event)',
301364
},
302365
})
303366
export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
@@ -324,3 +387,26 @@ export class MatTabLink extends _MatTabLinkBase implements OnDestroy {
324387
this._tabLinkRipple._removeTriggerEvents();
325388
}
326389
}
390+
391+
/**
392+
* Tab panel component associated with MatTabNav.
393+
*/
394+
@Component({
395+
selector: 'mat-tab-nav-panel',
396+
exportAs: 'matTabNavPanel',
397+
template: '<ng-content></ng-content>',
398+
host: {
399+
'[attr.aria-labelledby]': '_activeTabId',
400+
'[attr.id]': 'id',
401+
'role': 'tabpanel',
402+
},
403+
encapsulation: ViewEncapsulation.None,
404+
changeDetection: ChangeDetectionStrategy.OnPush,
405+
})
406+
export class MatTabNavPanel {
407+
/** Unique id for the tab panel. */
408+
@Input() id = `mat-tab-nav-panel-${nextUniqueId++}`;
409+
410+
/** Id of the active tab in the nav bar. */
411+
_activeTabId?: string;
412+
}

0 commit comments

Comments
 (0)