diff --git a/src/material-experimental/mdc-tabs/tab-group.html b/src/material-experimental/mdc-tabs/tab-group.html index 8db3d19cd45b..a6d80567d9fa 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.labelClassList" [disabled]="tab.disabled" [fitInkBarToContent]="fitInkBarToContent" (click)="_handleClick(tab, tabHeader, i)" @@ -36,12 +37,12 @@ - + - {{tab.textLabel}} + {{tab.textLabel}} @@ -52,16 +53,17 @@ [class._mat-animation-noopable]="_animationMode === 'NoopAnimations'" #tabBodyWrapper> + *ngFor="let tab of _tabs; let i = index" + [id]="_getTabContentId(i)" + [attr.tabindex]="(contentTabIndex != null && selectedIndex === i) ? contentTabIndex : null" + [attr.aria-labelledby]="_getTabLabelId(i)" + [class.mat-mdc-tab-body-active]="selectedIndex === i" + [ngClass]="tab.bodyClassList" + [content]="tab.content!" + [position]="tab.position!" + [origin]="tab.origin" + [animationDuration]="animationDuration" + (_onCentered)="_removeTabBodyWrapperHeight()" + (_onCentering)="_setTabBodyWrapperHeight($event)"> diff --git a/src/material-experimental/mdc-tabs/tab-group.spec.ts b/src/material-experimental/mdc-tabs/tab-group.spec.ts index 4cdff1500a05..c299997b1294 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, @@ -41,6 +41,7 @@ describe('MDC-based MatTabGroup', () => { TabGroupWithIndirectDescendantTabs, TabGroupWithSpaceAbove, NestedTabGroupWithLabel, + TabsWithClassesTestApp, ], }); @@ -364,7 +365,6 @@ describe('MDC-based MatTabGroup', () => { expect(contentElements.map(e => e.getAttribute('tabindex'))).toEqual(['1', null, null]); }); - }); describe('aria labelling', () => { @@ -404,11 +404,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); }); @@ -482,7 +487,6 @@ describe('MDC-based MatTabGroup', () => { expect(tabs[0].origin).toBeLessThan(0); })); - it('should update selected index if the last tab removed while selected', fakeAsync(() => { const component: MatTabGroup = fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; @@ -500,7 +504,6 @@ describe('MDC-based MatTabGroup', () => { expect(component.selectedIndex).toBe(numberOfTabs - 2); })); - it('should maintain the selected tab if a new tab is added', () => { fixture.detectChanges(); const component: MatTabGroup = @@ -517,7 +520,6 @@ describe('MDC-based MatTabGroup', () => { expect(component._tabs.toArray()[2].isActive).toBe(true); }); - it('should maintain the selected tab if a tab is removed', () => { // Select the second tab. fixture.componentInstance.selectedIndex = 1; @@ -565,7 +567,6 @@ describe('MDC-based MatTabGroup', () => { expect(fixture.componentInstance.handleSelection).not.toHaveBeenCalled(); })); - }); describe('async tabs', () => { @@ -756,6 +757,100 @@ describe('MDC-based MatTabGroup', () => { })); }); + describe('tabs with custom css classes', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(TabsWithClassesTestApp); + }); + + it('should apply label classes', () => { + fixture.detectChanges(); + + const labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.hardcoded.label.classes')); + expect(labelElements.length).toBe(1); + }); + + it('should apply body classes', () => { + fixture.detectChanges(); + + const bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.hardcoded.body.classes')); + expect(bodyElements.length).toBe(1); + }); + + it('should set classes as strings dynamically', () => { + fixture.detectChanges(); + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + + fixture.componentInstance.labelClassList = 'custom-label-class one-more-label-class'; + fixture.componentInstance.bodyClassList = 'custom-body-class one-more-body-class'; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(2); + expect(bodyElements.length).toBe(2); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + }); + + it('should set classes as strings array dynamically', () => { + fixture.detectChanges(); + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + + fixture.componentInstance.labelClassList = ['custom-label-class', 'one-more-label-class']; + fixture.componentInstance.bodyClassList = ['custom-body-class', 'one-more-body-class']; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(2); + expect(bodyElements.length).toBe(2); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mdc-tab.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + }); + }); + /** * Checks that the `selectedIndex` has been updated; checks that the label and body have their * respective `active` classes @@ -935,6 +1030,7 @@ class SimpleTabsTestApp { animationDone() { } } + @Component({ template: ` @@ -990,8 +1087,8 @@ class BindedTabsTestApp { } } + @Component({ - selector: 'test-app', template: ` @@ -1014,6 +1111,7 @@ class DisabledTabsTestApp { isDisabled = false; } + @Component({ template: ` @@ -1059,7 +1157,6 @@ class TabGroupWithSimpleApi { @Component({ - selector: 'nested-tabs', template: ` Tab one content @@ -1077,8 +1174,8 @@ class NestedTabs { @ViewChildren(MatTabGroup) groups: QueryList; } + @Component({ - selector: 'template-tabs', template: ` @@ -1091,11 +1188,11 @@ class NestedTabs { `, - }) - class TemplateTabs {} +}) +class TemplateTabs {} - @Component({ +@Component({ template: ` @@ -1160,6 +1257,7 @@ class TabGroupWithInkBarFitToContent { fitInkBarToContent = true; } + @Component({ template: `
@@ -1202,3 +1300,31 @@ class TabGroupWithSpaceAbove { }) class NestedTabGroupWithLabel { } + + +@Component({ + template: ` + + + Tab one content + + + Tab two content + + + Tab three content + + + Tab four content + + + Tab five content + + + `, +}) +class TabsWithClassesTestApp { + @ViewChildren(MatTab) tabs: QueryList; + labelClassList?: string | string[]; + bodyClassList?: string | string[]; +} diff --git a/src/material-experimental/mdc-tabs/tab.ts b/src/material-experimental/mdc-tabs/tab.ts index eac99fce3a52..bf3875890ad3 100644 --- a/src/material-experimental/mdc-tabs/tab.ts +++ b/src/material-experimental/mdc-tabs/tab.ts @@ -12,6 +12,8 @@ import { ViewEncapsulation, TemplateRef, ContentChild, + OnChanges, + SimpleChanges, } from '@angular/core'; import {MatTab as BaseMatTab, MAT_TAB} from '@angular/material/tabs'; import {MatTabContent} from './tab-content'; @@ -30,7 +32,7 @@ import {MatTabLabel} from './tab-label'; exportAs: 'matTab', providers: [{provide: MAT_TAB, useExisting: MatTab}] }) -export class MatTab extends BaseMatTab { +export class MatTab extends BaseMatTab implements OnChanges { /** * Template provided in the tab content that will be used if present, used to enable lazy-loading */ @@ -41,4 +43,16 @@ export class MatTab extends BaseMatTab { @ContentChild(MatTabLabel) override get templateLabel(): MatTabLabel { return this._templateLabel; } override set templateLabel(value: MatTabLabel) { this._setTemplateLabelInput(value); } + + override ngOnChanges(changes: SimpleChanges): void { + super.ngOnChanges(changes); + + // Triggering ChangeDetectorRef.markForCheck() + if (changes.hasOwnProperty('ariaLabel') + || changes.hasOwnProperty('ariaLabelledby') + || changes.hasOwnProperty('labelClass') + || changes.hasOwnProperty('bodyClass')) { + this._stateChanges.next(); + } + } } diff --git a/src/material/tabs/tab-group.html b/src/material/tabs/tab-group.html index 268ed3076d1e..3d974cdc6b70 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"> -
@@ -38,16 +40,17 @@ [class._mat-animation-noopable]="_animationMode === 'NoopAnimations'" #tabBodyWrapper> + *ngFor="let tab of _tabs; let i = index" + [id]="_getTabContentId(i)" + [attr.tabindex]="(contentTabIndex != null && selectedIndex === i) ? contentTabIndex : null" + [attr.aria-labelledby]="_getTabLabelId(i)" + [class.mat-tab-body-active]="selectedIndex === i" + [ngClass]="tab.bodyClassList" + [content]="tab.content!" + [position]="tab.position!" + [origin]="tab.origin" + [animationDuration]="animationDuration" + (_onCentered)="_removeTabBodyWrapperHeight()" + (_onCentering)="_setTabBodyWrapperHeight($event)"> diff --git a/src/material/tabs/tab-group.spec.ts b/src/material/tabs/tab-group.spec.ts index 08198300588b..120b1254f311 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, @@ -41,6 +41,7 @@ describe('MatTabGroup', () => { TabGroupWithIndirectDescendantTabs, TabGroupWithSpaceAbove, NestedTabGroupWithLabel, + TabsWithClassesTestApp, ], }); @@ -363,7 +364,6 @@ describe('MatTabGroup', () => { expect(contentElements.map(e => e.getAttribute('tabindex'))).toEqual(['1', null, null]); }); - }); describe('aria labelling', () => { @@ -403,11 +403,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); }); @@ -481,7 +486,6 @@ describe('MatTabGroup', () => { expect(tabs[0].origin).toBeLessThan(0); })); - it('should update selected index if the last tab removed while selected', fakeAsync(() => { const component: MatTabGroup = fixture.debugElement.query(By.css('mat-tab-group'))!.componentInstance; @@ -499,7 +503,6 @@ describe('MatTabGroup', () => { expect(component.selectedIndex).toBe(numberOfTabs - 2); })); - it('should maintain the selected tab if a new tab is added', () => { fixture.detectChanges(); const component: MatTabGroup = @@ -516,7 +519,6 @@ describe('MatTabGroup', () => { expect(component._tabs.toArray()[2].isActive).toBe(true); }); - it('should maintain the selected tab if a tab is removed', () => { // Select the second tab. fixture.componentInstance.selectedIndex = 1; @@ -564,7 +566,6 @@ describe('MatTabGroup', () => { expect(fixture.componentInstance.handleSelection).not.toHaveBeenCalled(); })); - }); describe('async tabs', () => { @@ -756,6 +757,100 @@ describe('MatTabGroup', () => { })); }); + describe('tabs with custom css classes', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(TabsWithClassesTestApp); + }); + + it('should apply label classes', () => { + fixture.detectChanges(); + + const labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.hardcoded.label.classes')); + expect(labelElements.length).toBe(1); + }); + + it('should apply body classes', () => { + fixture.detectChanges(); + + const bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.hardcoded.body.classes')); + expect(bodyElements.length).toBe(1); + }); + + it('should set classes as strings dynamically', () => { + fixture.detectChanges(); + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + + fixture.componentInstance.labelClassList = 'custom-label-class one-more-label-class'; + fixture.componentInstance.bodyClassList = 'custom-body-class one-more-body-class'; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(2); + expect(bodyElements.length).toBe(2); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + }); + + it('should set classes as strings array dynamically', () => { + fixture.detectChanges(); + let labelElements: DebugElement[]; + let bodyElements: DebugElement[]; + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + + fixture.componentInstance.labelClassList = ['custom-label-class', 'one-more-label-class']; + fixture.componentInstance.bodyClassList = ['custom-body-class', 'one-more-body-class']; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(2); + expect(bodyElements.length).toBe(2); + + delete fixture.componentInstance.labelClassList; + delete fixture.componentInstance.bodyClassList; + fixture.detectChanges(); + + labelElements = fixture.debugElement + .queryAll(By.css('.mat-tab-label.custom-label-class.one-more-label-class')); + bodyElements = fixture.debugElement + .queryAll(By.css('mat-tab-body.custom-body-class.one-more-body-class')); + expect(labelElements.length).toBe(0); + expect(bodyElements.length).toBe(0); + }); + }); + /** * Checks that the `selectedIndex` has been updated; checks that the label and body have their * respective `active` classes @@ -881,6 +976,7 @@ class SimpleTabsTestApp { animationDone() { } } + @Component({ template: ` @@ -936,8 +1033,8 @@ class BindedTabsTestApp { } } + @Component({ - selector: 'test-app', template: ` @@ -960,6 +1057,7 @@ class DisabledTabsTestApp { isDisabled = false; } + @Component({ template: ` @@ -1005,7 +1103,6 @@ class TabGroupWithSimpleApi { @Component({ - selector: 'nested-tabs', template: ` Tab one content @@ -1023,8 +1120,8 @@ class NestedTabs { @ViewChildren(MatTabGroup) groups: QueryList; } + @Component({ - selector: 'template-tabs', template: ` @@ -1037,11 +1134,11 @@ class NestedTabs { `, - }) - class TemplateTabs {} +}) +class TemplateTabs {} - @Component({ +@Component({ template: ` @@ -1093,6 +1190,7 @@ class TabGroupWithIndirectDescendantTabs { @ViewChild(MatTabGroup) tabGroup: MatTabGroup; } + @Component({ template: `
@@ -1135,3 +1233,31 @@ class TabGroupWithSpaceAbove { }) class NestedTabGroupWithLabel { } + + +@Component({ + template: ` + + + Tab one content + + + Tab two content + + + Tab three content + + + Tab four content + + + Tab five content + + + `, +}) +class TabsWithClassesTestApp { + @ViewChildren(MatTab) tabs: QueryList; + labelClassList?: string | string[]; + bodyClassList?: string | string[]; +} diff --git a/src/material/tabs/tab.ts b/src/material/tabs/tab.ts index 775842436b16..dfceb0d077c2 100644 --- a/src/material/tabs/tab.ts +++ b/src/material/tabs/tab.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {BooleanInput} from '@angular/cdk/coercion'; +import {BooleanInput, coerceStringArray} from '@angular/cdk/coercion'; import {TemplatePortal} from '@angular/cdk/portal'; import { ChangeDetectionStrategy, @@ -79,6 +79,40 @@ export class MatTab extends _MatTabBase implements OnInit, CanDisable, OnChanges */ @Input('aria-labelledby') ariaLabelledby: string; + /** + * Takes classes set on the host mat-tab element and applies them to the tab + * label inside the mat-tab-header container to allow for easy styling. + */ + @Input('class') + set labelClass(value: string | string[]) { + if (value && value.length) { + this.labelClassList = coerceStringArray(value).reduce((classList, className) => { + classList[className] = true; + return classList; + }, {} as {[key: string]: boolean}); + } else { + this.labelClassList = {}; + } + } + labelClassList: {[key: string]: boolean} = {}; + + /** + * Takes classes set on the host mat-tab element and applies them to the tab + * label inside the mat-tab-body container to allow for easy styling. + */ + @Input() + set bodyClass(value: string | string[]) { + if (value && value.length) { + this.bodyClassList = coerceStringArray(value).reduce((classList, className) => { + classList[className] = true; + return classList; + }, {} as {[key: string]: boolean}); + } else { + this.bodyClassList = {}; + } + } + bodyClassList: {[key: string]: boolean} = {}; + /** 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 c7e52a211b57..c7c616ff744d 100644 --- a/tools/public_api_guard/material/tabs.md +++ b/tools/public_api_guard/material/tabs.md @@ -101,12 +101,22 @@ export class MatTab extends _MatTabBase implements OnInit, CanDisable, OnChanges constructor(_viewContainerRef: ViewContainerRef, _closestTabGroup: any); ariaLabel: string; ariaLabelledby: string; + set bodyClass(value: string | string[]); + // (undocumented) + bodyClassList: { + [key: string]: boolean; + }; // (undocumented) _closestTabGroup: any; get content(): TemplatePortal | null; _explicitContent: TemplateRef; _implicitContent: TemplateRef; isActive: boolean; + set labelClass(value: string | string[]); + // (undocumented) + labelClassList: { + [key: string]: boolean; + }; // (undocumented) static ngAcceptInputType_disabled: BooleanInput; // (undocumented) @@ -125,7 +135,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; }