From 77d51ca4286dc3f54561d2aa0d0fcbaeacc84ce3 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 29 Oct 2019 13:31:23 -0700 Subject: [PATCH] feat(material-experimental/mdc-tabs): add option to fit ink bar to content (#17507) * feat(material-experimental/mdc-tabs): add option to fit ink bar to content * rename our use of indicator to ink bar * use getters/setters in ink bar foundation --- src/dev-app/mdc-tabs/mdc-tabs-demo.html | 17 ++++ src/dev-app/mdc-tabs/mdc-tabs-demo.ts | 1 + src/material-experimental/mdc-tabs/ink-bar.ts | 97 +++++++++++++------ .../mdc-tabs/tab-group.html | 1 + .../mdc-tabs/tab-group.spec.ts | 54 +++++++++++ .../mdc-tabs/tab-group.ts | 16 ++- .../mdc-tabs/tab-label-wrapper.ts | 19 +++- .../mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts | 39 +++++++- .../mdc-tabs/tab-nav-bar/tab-nav-bar.ts | 29 +++++- 9 files changed, 229 insertions(+), 44 deletions(-) diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.html b/src/dev-app/mdc-tabs/mdc-tabs-demo.html index c20212d256e7..70156eec4d1c 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.html +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.html @@ -91,6 +91,23 @@

Template labels

+

Ink bar fit to content

+ + + Content 1 + Content 2 + Content 3 + Content 4 + + +

Ink bar fit to content

+ +

Lazy tabs

diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.ts b/src/dev-app/mdc-tabs/mdc-tabs-demo.ts index 3f97a4cf06b7..2a5ca1a17547 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.ts +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.ts @@ -15,6 +15,7 @@ import {Component} from '@angular/core'; styleUrls: ['mdc-tabs-demo.css'], }) export class MdcTabsDemo { + fitInkBarToContent = true; links = ['First', 'Second', 'Third']; lotsOfTabs = new Array(30).fill(0).map((_, index) => `Tab ${index}`); activeLink = this.links[0]; diff --git a/src/material-experimental/mdc-tabs/ink-bar.ts b/src/material-experimental/mdc-tabs/ink-bar.ts index a15dd06ab213..8bc79662f849 100644 --- a/src/material-experimental/mdc-tabs/ink-bar.ts +++ b/src/material-experimental/mdc-tabs/ink-bar.ts @@ -8,9 +8,9 @@ import {ElementRef, QueryList} from '@angular/core'; import { - MDCTabIndicatorFoundation, MDCSlidingTabIndicatorFoundation, - MDCTabIndicatorAdapter + MDCTabIndicatorAdapter, + MDCTabIndicatorFoundation } from '@material/tab-indicator'; /** @@ -23,7 +23,7 @@ export interface MatInkBarItem { } /** - * Abstraction around the MDC tab indicator that manages the ink bar of a tab header. + * Abstraction around the MDC tab indicator that acts as the tab header's ink bar. * @docs-private */ export class MatInkBar { @@ -50,7 +50,7 @@ export class MatInkBar { const clientRect = currentItem ? currentItem._foundation.computeContentClientRect() : undefined; - // The MDC indicator won't animate unless we give it the `ClientRect` of the previous item. + // The ink bar won't animate unless we give it the `ClientRect` of the previous item. correspondingItem._foundation.activate(clientRect); this._currentItem = correspondingItem; } @@ -58,41 +58,39 @@ export class MatInkBar { } /** - * Implementation of MDC's sliding tab indicator foundation. + * Implementation of MDC's sliding tab indicator (ink bar) foundation. * @docs-private */ export class MatInkBarFoundation { private _destroyed: boolean; private _foundation: MDCTabIndicatorFoundation; - private _element: HTMLElement; - private _indicator: HTMLElement; - private _indicatorContent: HTMLElement; + private _inkBarElement: HTMLElement; + private _inkBarContentElement: HTMLElement; + private _fitToContent = false; private _adapter: MDCTabIndicatorAdapter = { addClass: className => { if (!this._destroyed) { - this._element.classList.add(className); + this._hostElement.classList.add(className); } }, removeClass: className => { if (!this._destroyed) { - this._element.classList.remove(className); + this._hostElement.classList.remove(className); } }, setContentStyleProperty: (propName, value) => { - this._indicatorContent.style.setProperty(propName, value); + this._inkBarContentElement.style.setProperty(propName, value); }, computeContentClientRect: () => { // `getBoundingClientRect` isn't available on the server. - return this._destroyed || !this._indicatorContent.getBoundingClientRect ? { + return this._destroyed || !this._inkBarContentElement.getBoundingClientRect ? { width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 - } : this._indicatorContent.getBoundingClientRect(); + } : this._inkBarContentElement.getBoundingClientRect(); } }; - constructor(elementRef: ElementRef, document: Document) { - this._element = elementRef.nativeElement; + constructor(private _hostElement: HTMLElement, private _document: Document) { this._foundation = new MDCSlidingTabIndicatorFoundation(this._adapter); - this._createIndicator(document); } /** Aligns the ink bar to the current item. */ @@ -105,39 +103,78 @@ export class MatInkBarFoundation { this._foundation.deactivate(); } - /** Gets the ClientRect of the indicator. */ + /** Gets the ClientRect of the ink bar. */ computeContentClientRect() { return this._foundation.computeContentClientRect(); } /** Initializes the foundation. */ init() { + this._createInkBarElement(); this._foundation.init(); } /** Destroys the foundation. */ destroy() { - const indicator = this._indicator; - - if (indicator.parentNode) { - indicator.parentNode.removeChild(indicator); + if (this._inkBarElement.parentNode) { + this._inkBarElement.parentNode.removeChild(this._inkBarElement); } - this._element = this._indicator = this._indicatorContent = null!; + this._hostElement = this._inkBarElement = this._inkBarContentElement = null!; this._foundation.destroy(); this._destroyed = true; } - private _createIndicator(document: Document) { - if (!this._indicator) { - const indicator = this._indicator = document.createElement('span'); - const content = this._indicatorContent = document.createElement('span'); + /** + * Sets whether the ink bar should be appended to the content, which will cause the ink bar + * to match the width of the content rather than the tab host element. + */ + setFitToContent(fitToContent: boolean) { + if (this._fitToContent !== fitToContent) { + this._fitToContent = fitToContent; + if (this._inkBarElement) { + this._appendInkBarElement(); + } + } + } + + + /** + * Gets whether the ink bar should be appended to the content, which will cause the ink bar + * to match the width of the content rather than the tab host element. + */ + getFitToContent(): boolean { return this._fitToContent; } + + /** Creates and appends the ink bar element. */ + private _createInkBarElement() { + this._inkBarElement = this._document.createElement('span'); + this._inkBarContentElement = this._document.createElement('span'); - indicator.className = 'mdc-tab-indicator'; - content.className = 'mdc-tab-indicator__content mdc-tab-indicator__content--underline'; + this._inkBarElement.className = 'mdc-tab-indicator'; + this._inkBarContentElement.className = 'mdc-tab-indicator__content' + + ' mdc-tab-indicator__content--underline'; - indicator.appendChild(content); - this._element.appendChild(indicator); + this._inkBarElement.appendChild(this._inkBarContentElement); + this._appendInkBarElement(); + } + + /** + * Appends the ink bar to the tab host element or content, depending on whether + * the ink bar should fit to content. + */ + private _appendInkBarElement() { + if (!this._inkBarElement) { + throw Error('Ink bar element has not been created and cannot be appended'); + } + + const parentElement = this._fitToContent ? + this._hostElement.querySelector('.mdc-tab__content') : + this._hostElement; + + if (!parentElement) { + throw Error('Missing element to host the ink bar'); } + + parentElement.appendChild(this._inkBarElement); } } diff --git a/src/material-experimental/mdc-tabs/tab-group.html b/src/material-experimental/mdc-tabs/tab-group.html index ede63f8603fb..b4ba86eb4c84 100644 --- a/src/material-experimental/mdc-tabs/tab-group.html +++ b/src/material-experimental/mdc-tabs/tab-group.html @@ -20,6 +20,7 @@ [attr.aria-labelledby]="(!tab.ariaLabel && tab.ariaLabelledby) ? tab.ariaLabelledby : null" [class.mdc-tab--active]="selectedIndex == i" [disabled]="tab.disabled" + [fitInkBarToContent]="fitInkBarToContent" (click)="_handleClick(tab, tabHeader, i)"> diff --git a/src/material-experimental/mdc-tabs/tab-group.spec.ts b/src/material-experimental/mdc-tabs/tab-group.spec.ts index f3a8659804ce..144ae001c2bb 100644 --- a/src/material-experimental/mdc-tabs/tab-group.spec.ts +++ b/src/material-experimental/mdc-tabs/tab-group.spec.ts @@ -677,6 +677,47 @@ describe('nested MatTabGroup with enabled animations', () => { }); +describe('MatTabGroup with ink bar fit to content', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatTabsModule, BrowserAnimationsModule], + declarations: [TabGroupWithInkBarFitToContent] + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TabGroupWithInkBarFitToContent); + fixture.detectChanges(); + }); + + it('should properly nest the ink bar when fit to content', () => { + const tabElement = fixture.nativeElement.querySelector('.mdc-tab'); + const contentElement = tabElement.querySelector('.mdc-tab__content'); + const indicatorElement = tabElement.querySelector('.mdc-tab-indicator'); + expect(indicatorElement.parentElement).toBe(contentElement); + }); + + it('should be able to move the ink bar between content and full', () => { + fixture.componentInstance.fitInkBarToContent = false; + fixture.detectChanges(); + + const tabElement = fixture.nativeElement.querySelector('.mdc-tab'); + const indicatorElement = tabElement.querySelector('.mdc-tab-indicator'); + expect(indicatorElement.parentElement).toBe(tabElement); + + fixture.componentInstance.fitInkBarToContent = true; + fixture.detectChanges(); + + const contentElement = tabElement.querySelector('.mdc-tab__content'); + expect(indicatorElement.parentElement).toBe(contentElement); + }); +}); + + @Component({ template: ` + Tab one content + Tab two content + + `, +}) +class TabGroupWithInkBarFitToContent { + fitInkBarToContent = true; +} diff --git a/src/material-experimental/mdc-tabs/tab-group.ts b/src/material-experimental/mdc-tabs/tab-group.ts index c4b078b403a6..d6ad53300b17 100644 --- a/src/material-experimental/mdc-tabs/tab-group.ts +++ b/src/material-experimental/mdc-tabs/tab-group.ts @@ -8,25 +8,27 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, ContentChildren, ElementRef, + Inject, + Input, + Optional, QueryList, ViewChild, ViewEncapsulation, - ChangeDetectorRef, - Inject, - Optional, } from '@angular/core'; import { _MatTabGroupBase, + MAT_TAB_GROUP, MAT_TABS_CONFIG, MatTabsConfig, - MAT_TAB_GROUP, } from '@angular/material/tabs'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import {MatTab} from './tab'; import {MatTabHeader} from './tab-header'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; /** * Material design tab-group component. Supports basic tab pairs (label + content) and includes @@ -57,6 +59,12 @@ export class MatTabGroup extends _MatTabGroupBase { @ViewChild('tabBodyWrapper') _tabBodyWrapper: ElementRef; @ViewChild('tabHeader') _tabHeader: MatTabHeader; + /** Whether the ink bar should fit its width to the size of the tab label content. */ + @Input() + get fitInkBarToContent(): boolean { return this._fitInkBarToContent; } + set fitInkBarToContent(v: boolean) { this._fitInkBarToContent = coerceBooleanProperty(v); } + private _fitInkBarToContent = false; + constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, @Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: MatTabsConfig, diff --git a/src/material-experimental/mdc-tabs/tab-label-wrapper.ts b/src/material-experimental/mdc-tabs/tab-label-wrapper.ts index 79cc82ae5e11..4f2550cb9709 100644 --- a/src/material-experimental/mdc-tabs/tab-label-wrapper.ts +++ b/src/material-experimental/mdc-tabs/tab-label-wrapper.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, Inject, OnDestroy} from '@angular/core'; +import {Directive, ElementRef, Inject, Input, OnDestroy, OnInit} from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {MatTabLabelWrapper as BaseMatTabLabelWrapper} from '@angular/material/tabs'; import {MatInkBarFoundation, MatInkBarItem} from './ink-bar'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; /** * Used in the `mat-tab-group` view to display tab labels. @@ -23,12 +24,24 @@ import {MatInkBarFoundation, MatInkBarItem} from './ink-bar'; '[attr.aria-disabled]': '!!disabled', } }) -export class MatTabLabelWrapper extends BaseMatTabLabelWrapper implements MatInkBarItem, OnDestroy { +export class MatTabLabelWrapper extends BaseMatTabLabelWrapper + implements MatInkBarItem, OnInit, OnDestroy { + private _document: Document; + _foundation: MatInkBarFoundation; + /** Whether the ink bar should fit its width to the size of the tab label content. */ + @Input() + get fitInkBarToContent(): boolean { return this._foundation.getFitToContent(); } + set fitInkBarToContent(v: boolean) { this._foundation.setFitToContent(coerceBooleanProperty(v)); } + constructor(public elementRef: ElementRef, @Inject(DOCUMENT) _document: any) { super(elementRef); - this._foundation = new MatInkBarFoundation(elementRef, _document); + this._document = _document; + this._foundation = new MatInkBarFoundation(this.elementRef.nativeElement, this._document); + } + + ngOnInit() { this._foundation.init(); } diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts index 351ed193f395..03e33be1c754 100644 --- a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -1,5 +1,5 @@ import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; -import {Component, ViewChild, ViewChildren, QueryList} from '@angular/core'; +import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; import {By} from '@angular/platform-browser'; import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; @@ -321,12 +321,46 @@ describe('MatTabNavBar', () => { .toBe(true, 'Expected every tab link to have ripples disabled'); }); }); + + describe('with the ink bar fit to content', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.componentInstance.fitInkBarToContent = true; + fixture.detectChanges(); + }); + + it('should properly nest the ink bar when fit to content', () => { + const tabElement = fixture.nativeElement.querySelector('.mdc-tab'); + const contentElement = tabElement.querySelector('.mdc-tab__content'); + const indicatorElement = tabElement.querySelector('.mdc-tab-indicator'); + expect(indicatorElement.parentElement).toBe(contentElement); + }); + + it('should be able to move the ink bar between content and full', () => { + fixture.componentInstance.fitInkBarToContent = false; + fixture.detectChanges(); + + const tabElement = fixture.nativeElement.querySelector('.mdc-tab'); + const indicatorElement = tabElement.querySelector('.mdc-tab-indicator'); + expect(indicatorElement.parentElement).toBe(tabElement); + + fixture.componentInstance.fitInkBarToContent = true; + fixture.detectChanges(); + + const contentElement = tabElement.querySelector('.mdc-tab__content'); + expect(indicatorElement.parentElement).toBe(contentElement); + }); + }); }); @Component({ selector: 'test-app', template: ` -