Skip to content

Commit

Permalink
feat(material/tabs): Refactor MatTabNav to follow the ARIA tabs patte…
Browse files Browse the repository at this point in the history
…rn (#24062)

by introducing a new tabpanel component.
  • Loading branch information
zelliott committed Jan 7, 2022
1 parent 1dd2955 commit ea78a47
Show file tree
Hide file tree
Showing 23 changed files with 618 additions and 18 deletions.
3 changes: 3 additions & 0 deletions src/components-examples/material/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,6 +36,7 @@ export {
TabGroupStretchedExample,
TabGroupThemeExample,
TabNavBarBasicExample,
TabNavBarWithPanelExample,
};

const EXAMPLES = [
Expand All @@ -51,6 +53,7 @@ const EXAMPLES = [
TabGroupStretchedExample,
TabGroupThemeExample,
TabNavBarBasicExample,
TabNavBarWithPanelExample,
];

@NgModule({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.example-action-button {
margin-top: 8px;
margin-right: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- #docregion mat-tab-nav -->
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
<a mat-tab-link *ngFor="let link of links"
(click)="activeLink = link"
[active]="activeLink == link"> {{link}} </a>
<a mat-tab-link disabled>Disabled Link</a>
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
<!-- #enddocregion mat-tab-nav -->
Original file line number Diff line number Diff line change
@@ -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];
}
9 changes: 9 additions & 0 deletions src/dev-app/mdc-tabs/mdc-tabs-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,13 @@ <h2>Tab nav bar</h2>
[active]="activeLink == link">{{link}}</a>
<a mat-tab-link disabled>Disabled Link</a>
</nav>

<h2>Tab nav bar with panel</h2>
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
<a mat-tab-link *ngFor="let link of links"
(click)="activeLink = link"
[active]="activeLink == link">{{link}}</a>
<a mat-tab-link disabled>Disabled Link</a>
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
</div>
4 changes: 3 additions & 1 deletion src/dev-app/tabs/tabs-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ <h3>Tab group stretched</h3>
<tab-group-stretched-example></tab-group-stretched-example>
<h3>Tab group theming</h3>
<tab-group-theme-example></tab-group-theme-example>
<h3>Tab Navigation Bar basic</h3>
<h3>Tab navigation bar basic</h3>
<tab-nav-bar-basic-example></tab-nav-bar-basic-example>
<h3>Tab navigation bar with panel</h3>
<tab-nav-bar-with-panel-example></tab-nav-bar-with-panel-example>
4 changes: 3 additions & 1 deletion src/material-experimental/mdc-tabs/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -37,6 +37,7 @@ import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar';
MatTab,
MatTabGroup,
MatTabNav,
MatTabNavPanel,
MatTabLink,
],
declarations: [
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/material-experimental/mdc-tabs/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
143 changes: 142 additions & 1 deletion src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
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 {
MAT_RIPPLE_GLOBAL_OPTIONS,
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';
Expand All @@ -30,6 +35,7 @@ describe('MDC-based MatTabNavBar', () => {
TabLinkWithTabIndexBinding,
TabLinkWithNativeTabindexAttr,
TabBarWithInactiveTabsOnInit,
TabBarWithPanel,
],
providers: [
{provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions},
Expand Down Expand Up @@ -309,6 +315,123 @@ describe('MDC-based MatTabNavBar', () => {
expect(instance.tabNavBar.selectedIndex).toBe(1);
});

describe('without panel', () => {
let fixture: ComponentFixture<SimpleTabNavBarTestApp>;

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<TabBarWithPanel>;

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<SimpleTabNavBarTestApp>;

Expand Down Expand Up @@ -532,3 +655,21 @@ class TabLinkWithNativeTabindexAttr {}
class TabBarWithInactiveTabsOnInit {
tabs = [0, 1, 2];
}

@Component({
template: `
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
<a mat-tab-link
*ngFor="let tab of tabs; let index = index"
[active]="index === activeIndex"
(click)="activeIndex = index">
Tab link
</a>
</nav>
<mat-tab-nav-panel #tabPanel id="tab-panel">Tab panel</mat-tab-nav-panel>
`,
})
class TabBarWithPanel {
tabs = [0, 1, 2];
activeIndex = 0;
}
37 changes: 35 additions & 2 deletions src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: '<ng-content></ng-content>',
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,4 +62,17 @@ export class MatTabNavBarHarness extends ComponentHarness {
}
await tabs[0].click();
}

/** Gets the panel associated with the nav bar. */
async getPanel(): Promise<MatTabNavPanelHarness> {
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))();
}
}
Loading

0 comments on commit ea78a47

Please sign in to comment.