From f10d245cca22930e6d88aad03ede12f50f96746f Mon Sep 17 00:00:00 2001 From: Ruslan Lekhman Date: Sat, 15 Jan 2022 14:49:29 -0700 Subject: [PATCH] feat(material/tabs): label & body classes (#23691) closes #23685, #9290, #15997 --- .../mdc-tabs/tab-body.ts | 3 +- .../mdc-tabs/tab-group.html | 10 ++- .../mdc-tabs/tab-group.spec.ts | 85 ++++++++++++++++++- .../mdc-tabs/tab-group.ts | 3 +- .../mdc-tabs/tab-header.ts | 3 +- .../mdc-tabs/tab-nav-bar/tab-nav-bar.ts | 3 +- src/material-experimental/mdc-tabs/tab.ts | 3 +- src/material/tabs/tab-group.html | 13 +-- src/material/tabs/tab-group.spec.ts | 85 ++++++++++++++++++- src/material/tabs/tab.ts | 12 +++ tools/public_api_guard/material/tabs.md | 4 +- 11 files changed, 201 insertions(+), 23 deletions(-) diff --git a/src/material-experimental/mdc-tabs/tab-body.ts b/src/material-experimental/mdc-tabs/tab-body.ts index 3613e72edc5b..75d684f714e7 100644 --- a/src/material-experimental/mdc-tabs/tab-body.ts +++ b/src/material-experimental/mdc-tabs/tab-body.ts @@ -56,7 +56,8 @@ export class MatTabBodyPortal extends BaseMatTabBodyPortal { templateUrl: 'tab-body.html', styleUrls: ['tab-body.css'], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, animations: [matTabsAnimations.translateTab], host: { 'class': 'mat-mdc-tab-body', diff --git a/src/material-experimental/mdc-tabs/tab-group.html b/src/material-experimental/mdc-tabs/tab-group.html index 8db3d19cd45b..4fa26e85c807 100644 --- a/src/material-experimental/mdc-tabs/tab-group.html +++ b/src/material-experimental/mdc-tabs/tab-group.html @@ -15,10 +15,11 @@ [attr.aria-posinset]="i + 1" [attr.aria-setsize]="_tabs.length" [attr.aria-controls]="_getTabContentId(i)" - [attr.aria-selected]="selectedIndex == i" + [attr.aria-selected]="selectedIndex === i" [attr.aria-label]="tab.ariaLabel || null" [attr.aria-labelledby]="(!tab.ariaLabel && tab.ariaLabelledby) ? tab.ariaLabelledby : null" - [class.mdc-tab--active]="selectedIndex == i" + [class.mdc-tab--active]="selectedIndex === i" + [ngClass]="tab.labelClass" [disabled]="tab.disabled" [fitInkBarToContent]="fitInkBarToContent" (click)="_handleClick(tab, tabHeader, i)" @@ -36,12 +37,12 @@ - + - {{tab.textLabel}} + {{tab.textLabel}} @@ -57,6 +58,7 @@ [attr.tabindex]="(contentTabIndex != null && selectedIndex === i) ? contentTabIndex : null" [attr.aria-labelledby]="_getTabLabelId(i)" [class.mat-mdc-tab-body-active]="selectedIndex === i" + [ngClass]="tab.bodyClass" [content]="tab.content!" [position]="tab.position!" [origin]="tab.origin" diff --git a/src/material-experimental/mdc-tabs/tab-group.spec.ts b/src/material-experimental/mdc-tabs/tab-group.spec.ts index 1b5930e815dd..af5b9b55e0b4 100644 --- a/src/material-experimental/mdc-tabs/tab-group.spec.ts +++ b/src/material-experimental/mdc-tabs/tab-group.spec.ts @@ -1,6 +1,6 @@ import {LEFT_ARROW} from '@angular/cdk/keycodes'; import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/private'; -import {Component, OnInit, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {Component, DebugElement, OnInit, QueryList, ViewChild, ViewChildren} from '@angular/core'; import { waitForAsync, ComponentFixture, @@ -40,6 +40,7 @@ describe('MDC-based MatTabGroup', () => { TabGroupWithIndirectDescendantTabs, TabGroupWithSpaceAbove, NestedTabGroupWithLabel, + TabsWithClassesTestApp, ], }); @@ -420,11 +421,16 @@ describe('MDC-based MatTabGroup', () => { expect(tab.getAttribute('aria-label')).toBe('Fruit'); expect(tab.hasAttribute('aria-labelledby')).toBe(false); + + fixture.componentInstance.ariaLabel = 'Veggie'; + fixture.detectChanges(); + expect(tab.getAttribute('aria-label')).toBe('Veggie'); }); }); describe('disable tabs', () => { let fixture: ComponentFixture; + beforeEach(() => { fixture = TestBed.createComponent(DisabledTabsTestApp); }); @@ -780,6 +786,62 @@ describe('MDC-based MatTabGroup', () => { })); }); + describe('tabs with custom css classes', () => { + let fixture: ComponentFixture; + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + beforeEach(() => { + fixture = TestBed.createComponent(TabsWithClassesTestApp); + fixture.detectChanges(); + labelElements = fixture.debugElement.queryAll(By.css('.mdc-tab')); + bodyElements = fixture.debugElement.queryAll(By.css('mat-tab-body')); + }); + + it('should apply label/body classes', () => { + expect(labelElements[1].nativeElement.classList).toContain('hardcoded-label-class'); + expect(bodyElements[1].nativeElement.classList).toContain('hardcoded-body-class'); + }); + + it('should set classes as strings dynamically', () => { + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + + fixture.componentInstance.labelClassList = 'custom-label-class'; + fixture.componentInstance.bodyClassList = 'custom-body-class'; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).toContain('custom-body-class'); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + }); + + it('should set classes as strings array dynamically', () => { + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + + fixture.componentInstance.labelClassList = ['custom-label-class']; + fixture.componentInstance.bodyClassList = ['custom-body-class']; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).toContain('custom-body-class'); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + }); + }); + /** * Checks that the `selectedIndex` has been updated; checks that the label and body have their * respective `active` classes @@ -1014,7 +1076,6 @@ class BindedTabsTestApp { } @Component({ - selector: 'test-app', template: ` @@ -1080,7 +1141,6 @@ class TabGroupWithSimpleApi { } @Component({ - selector: 'nested-tabs', template: ` Tab one content @@ -1099,7 +1159,6 @@ class NestedTabs { } @Component({ - selector: 'template-tabs', template: ` @@ -1215,3 +1274,21 @@ class TabGroupWithSpaceAbove { `, }) class NestedTabGroupWithLabel {} + +@Component({ + template: ` + + + Tab one content + + + Tab two content + + + `, +}) +class TabsWithClassesTestApp { + labelClassList?: string | string[]; + bodyClassList?: string | string[]; +} diff --git a/src/material-experimental/mdc-tabs/tab-group.ts b/src/material-experimental/mdc-tabs/tab-group.ts index e268f713057f..2befb5837125 100644 --- a/src/material-experimental/mdc-tabs/tab-group.ts +++ b/src/material-experimental/mdc-tabs/tab-group.ts @@ -41,7 +41,8 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; templateUrl: 'tab-group.html', styleUrls: ['tab-group.css'], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, inputs: ['color', 'disableRipple'], providers: [ { diff --git a/src/material-experimental/mdc-tabs/tab-header.ts b/src/material-experimental/mdc-tabs/tab-header.ts index e733577679fc..a5dce8c6e139 100644 --- a/src/material-experimental/mdc-tabs/tab-header.ts +++ b/src/material-experimental/mdc-tabs/tab-header.ts @@ -42,7 +42,8 @@ import {MatInkBar} from './ink-bar'; inputs: ['selectedIndex'], outputs: ['selectFocusedIndex', 'indexFocused'], encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, host: { 'class': 'mat-mdc-tab-header', '[class.mat-mdc-tab-header-pagination-controls-enabled]': '_showPaginationControls', diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts index bb88dce83f2c..5b949b82e91e 100644 --- a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts @@ -66,7 +66,8 @@ import {takeUntil} from 'rxjs/operators'; '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', }, encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, }) export class MatTabNav extends _MatTabNavBase implements AfterContentInit { /** Whether the ink bar should fit its width to the size of the tab label content. */ diff --git a/src/material-experimental/mdc-tabs/tab.ts b/src/material-experimental/mdc-tabs/tab.ts index 3d38e03c1a8e..b7384bb6b428 100644 --- a/src/material-experimental/mdc-tabs/tab.ts +++ b/src/material-experimental/mdc-tabs/tab.ts @@ -25,7 +25,8 @@ import {MatTabLabel} from './tab-label'; // that creating the extra class will generate more code than just duplicating the template. templateUrl: 'tab.html', inputs: ['disabled'], - changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, encapsulation: ViewEncapsulation.None, exportAs: 'matTab', providers: [{provide: MAT_TAB, useExisting: MatTab}], diff --git a/src/material/tabs/tab-group.html b/src/material/tabs/tab-group.html index 268ed3076d1e..ebd767eda5b7 100644 --- a/src/material/tabs/tab-group.html +++ b/src/material/tabs/tab-group.html @@ -4,17 +4,19 @@ [disablePagination]="disablePagination" (indexFocused)="_focusChanged($event)" (selectFocusedIndex)="selectedIndex = $event"> - @@ -43,6 +45,7 @@ [attr.tabindex]="(contentTabIndex != null && selectedIndex === i) ? contentTabIndex : null" [attr.aria-labelledby]="_getTabLabelId(i)" [class.mat-tab-body-active]="selectedIndex === i" + [ngClass]="tab.bodyClass" [content]="tab.content!" [position]="tab.position!" [origin]="tab.origin" diff --git a/src/material/tabs/tab-group.spec.ts b/src/material/tabs/tab-group.spec.ts index d91b51ca43cf..6cb53a529583 100644 --- a/src/material/tabs/tab-group.spec.ts +++ b/src/material/tabs/tab-group.spec.ts @@ -1,6 +1,6 @@ import {LEFT_ARROW} from '@angular/cdk/keycodes'; import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/private'; -import {Component, OnInit, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {Component, DebugElement, OnInit, QueryList, ViewChild, ViewChildren} from '@angular/core'; import { waitForAsync, ComponentFixture, @@ -40,6 +40,7 @@ describe('MatTabGroup', () => { TabGroupWithIndirectDescendantTabs, TabGroupWithSpaceAbove, NestedTabGroupWithLabel, + TabsWithClassesTestApp, ], }); @@ -419,11 +420,16 @@ describe('MatTabGroup', () => { expect(tab.getAttribute('aria-label')).toBe('Fruit'); expect(tab.hasAttribute('aria-labelledby')).toBe(false); + + fixture.componentInstance.ariaLabel = 'Veggie'; + fixture.detectChanges(); + expect(tab.getAttribute('aria-label')).toBe('Veggie'); }); }); describe('disable tabs', () => { let fixture: ComponentFixture; + beforeEach(() => { fixture = TestBed.createComponent(DisabledTabsTestApp); }); @@ -779,6 +785,62 @@ describe('MatTabGroup', () => { })); }); + describe('tabs with custom css classes', () => { + let fixture: ComponentFixture; + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + beforeEach(() => { + fixture = TestBed.createComponent(TabsWithClassesTestApp); + fixture.detectChanges(); + labelElements = fixture.debugElement.queryAll(By.css('.mat-tab-label')); + bodyElements = fixture.debugElement.queryAll(By.css('mat-tab-body')); + }); + + it('should apply label/body classes', () => { + expect(labelElements[1].nativeElement.classList).toContain('hardcoded-label-class'); + expect(bodyElements[1].nativeElement.classList).toContain('hardcoded-body-class'); + }); + + it('should set classes as strings dynamically', () => { + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + + fixture.componentInstance.labelClassList = 'custom-label-class'; + fixture.componentInstance.bodyClassList = 'custom-body-class'; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).toContain('custom-body-class'); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + }); + + it('should set classes as strings array dynamically', () => { + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + + fixture.componentInstance.labelClassList = ['custom-label-class']; + fixture.componentInstance.bodyClassList = ['custom-body-class']; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).toContain('custom-body-class'); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + expect(labelElements[0].nativeElement.classList).not.toContain('custom-label-class'); + expect(bodyElements[0].nativeElement.classList).not.toContain('custom-body-class'); + }); + }); + /** * Checks that the `selectedIndex` has been updated; checks that the label and body have their * respective `active` classes @@ -960,7 +1022,6 @@ class BindedTabsTestApp { } @Component({ - selector: 'test-app', template: ` @@ -1026,7 +1087,6 @@ class TabGroupWithSimpleApi { } @Component({ - selector: 'nested-tabs', template: ` Tab one content @@ -1045,7 +1105,6 @@ class NestedTabs { } @Component({ - selector: 'template-tabs', template: ` @@ -1149,3 +1208,21 @@ class TabGroupWithSpaceAbove { `, }) class NestedTabGroupWithLabel {} + +@Component({ + template: ` + + + Tab one content + + + Tab two content + + + `, +}) +class TabsWithClassesTestApp { + labelClassList?: string | string[]; + bodyClassList?: string | string[]; +} diff --git a/src/material/tabs/tab.ts b/src/material/tabs/tab.ts index 5378b7f898d6..b58e5e61f893 100644 --- a/src/material/tabs/tab.ts +++ b/src/material/tabs/tab.ts @@ -81,6 +81,18 @@ export class MatTab extends _MatTabBase implements OnInit, CanDisable, OnChanges */ @Input('aria-labelledby') ariaLabelledby: string; + /** + * Classes to be passed to the tab label inside the mat-tab-header container. + * Supports string and string array values, same as `ngClass`. + */ + @Input() labelClass: string | string[]; + + /** + * Classes to be passed to the tab mat-tab-body container. + * Supports string and string array values, same as `ngClass`. + */ + @Input() bodyClass: string | string[]; + /** Portal that will be the hosted content of the tab */ private _contentPortal: TemplatePortal | null = null; diff --git a/tools/public_api_guard/material/tabs.md b/tools/public_api_guard/material/tabs.md index ebb3a947298a..6cd03e5acfaf 100644 --- a/tools/public_api_guard/material/tabs.md +++ b/tools/public_api_guard/material/tabs.md @@ -101,12 +101,14 @@ export class MatTab extends _MatTabBase implements OnInit, CanDisable, OnChanges constructor(_viewContainerRef: ViewContainerRef, _closestTabGroup: any); ariaLabel: string; ariaLabelledby: string; + bodyClass: string | string[]; // (undocumented) _closestTabGroup: any; get content(): TemplatePortal | null; _explicitContent: TemplateRef; _implicitContent: TemplateRef; isActive: boolean; + labelClass: string | string[]; // (undocumented) ngOnChanges(changes: SimpleChanges): void; // (undocumented) @@ -123,7 +125,7 @@ export class MatTab extends _MatTabBase implements OnInit, CanDisable, OnChanges protected _templateLabel: MatTabLabel; textLabel: string; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }