Skip to content

Commit 77d51ca

Browse files
authored
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
1 parent 29e2548 commit 77d51ca

File tree

9 files changed

+229
-44
lines changed

9 files changed

+229
-44
lines changed

src/dev-app/mdc-tabs/mdc-tabs-demo.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ <h2>Template labels</h2>
9191
</mat-tab>
9292
</mat-tab-group>
9393

94+
<h2>Ink bar fit to content</h2>
95+
<button (click)="fitInkBarToContent = !fitInkBarToContent"> Toggle Fit To Content </button>
96+
<mat-tab-group [fitInkBarToContent]="fitInkBarToContent">
97+
<mat-tab label="First">Content 1</mat-tab>
98+
<mat-tab label="Second">Content 2</mat-tab>
99+
<mat-tab label="Third">Content 3</mat-tab>
100+
<mat-tab label="Fourth" disabled>Content 4</mat-tab>
101+
</mat-tab-group>
102+
103+
<h2>Ink bar fit to content</h2>
104+
<nav mat-tab-nav-bar [fitInkBarToContent]="fitInkBarToContent">
105+
<a mat-tab-link *ngFor="let link of links"
106+
(click)="activeLink = link"
107+
[active]="activeLink == link">{{link}}</a>
108+
<a mat-tab-link disabled>Disabled Link</a>
109+
</nav>
110+
94111
<h2>Lazy tabs</h2>
95112
<mat-tab-group>
96113
<mat-tab label="One">

src/dev-app/mdc-tabs/mdc-tabs-demo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {Component} from '@angular/core';
1515
styleUrls: ['mdc-tabs-demo.css'],
1616
})
1717
export class MdcTabsDemo {
18+
fitInkBarToContent = true;
1819
links = ['First', 'Second', 'Third'];
1920
lotsOfTabs = new Array(30).fill(0).map((_, index) => `Tab ${index}`);
2021
activeLink = this.links[0];

src/material-experimental/mdc-tabs/ink-bar.ts

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88

99
import {ElementRef, QueryList} from '@angular/core';
1010
import {
11-
MDCTabIndicatorFoundation,
1211
MDCSlidingTabIndicatorFoundation,
13-
MDCTabIndicatorAdapter
12+
MDCTabIndicatorAdapter,
13+
MDCTabIndicatorFoundation
1414
} from '@material/tab-indicator';
1515

1616
/**
@@ -23,7 +23,7 @@ export interface MatInkBarItem {
2323
}
2424

2525
/**
26-
* Abstraction around the MDC tab indicator that manages the ink bar of a tab header.
26+
* Abstraction around the MDC tab indicator that acts as the tab header's ink bar.
2727
* @docs-private
2828
*/
2929
export class MatInkBar {
@@ -50,49 +50,47 @@ export class MatInkBar {
5050
const clientRect = currentItem ?
5151
currentItem._foundation.computeContentClientRect() : undefined;
5252

53-
// The MDC indicator won't animate unless we give it the `ClientRect` of the previous item.
53+
// The ink bar won't animate unless we give it the `ClientRect` of the previous item.
5454
correspondingItem._foundation.activate(clientRect);
5555
this._currentItem = correspondingItem;
5656
}
5757
}
5858
}
5959

6060
/**
61-
* Implementation of MDC's sliding tab indicator foundation.
61+
* Implementation of MDC's sliding tab indicator (ink bar) foundation.
6262
* @docs-private
6363
*/
6464
export class MatInkBarFoundation {
6565
private _destroyed: boolean;
6666
private _foundation: MDCTabIndicatorFoundation;
67-
private _element: HTMLElement;
68-
private _indicator: HTMLElement;
69-
private _indicatorContent: HTMLElement;
67+
private _inkBarElement: HTMLElement;
68+
private _inkBarContentElement: HTMLElement;
69+
private _fitToContent = false;
7070
private _adapter: MDCTabIndicatorAdapter = {
7171
addClass: className => {
7272
if (!this._destroyed) {
73-
this._element.classList.add(className);
73+
this._hostElement.classList.add(className);
7474
}
7575
},
7676
removeClass: className => {
7777
if (!this._destroyed) {
78-
this._element.classList.remove(className);
78+
this._hostElement.classList.remove(className);
7979
}
8080
},
8181
setContentStyleProperty: (propName, value) => {
82-
this._indicatorContent.style.setProperty(propName, value);
82+
this._inkBarContentElement.style.setProperty(propName, value);
8383
},
8484
computeContentClientRect: () => {
8585
// `getBoundingClientRect` isn't available on the server.
86-
return this._destroyed || !this._indicatorContent.getBoundingClientRect ? {
86+
return this._destroyed || !this._inkBarContentElement.getBoundingClientRect ? {
8787
width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0
88-
} : this._indicatorContent.getBoundingClientRect();
88+
} : this._inkBarContentElement.getBoundingClientRect();
8989
}
9090
};
9191

92-
constructor(elementRef: ElementRef<HTMLElement>, document: Document) {
93-
this._element = elementRef.nativeElement;
92+
constructor(private _hostElement: HTMLElement, private _document: Document) {
9493
this._foundation = new MDCSlidingTabIndicatorFoundation(this._adapter);
95-
this._createIndicator(document);
9694
}
9795

9896
/** Aligns the ink bar to the current item. */
@@ -105,39 +103,78 @@ export class MatInkBarFoundation {
105103
this._foundation.deactivate();
106104
}
107105

108-
/** Gets the ClientRect of the indicator. */
106+
/** Gets the ClientRect of the ink bar. */
109107
computeContentClientRect() {
110108
return this._foundation.computeContentClientRect();
111109
}
112110

113111
/** Initializes the foundation. */
114112
init() {
113+
this._createInkBarElement();
115114
this._foundation.init();
116115
}
117116

118117
/** Destroys the foundation. */
119118
destroy() {
120-
const indicator = this._indicator;
121-
122-
if (indicator.parentNode) {
123-
indicator.parentNode.removeChild(indicator);
119+
if (this._inkBarElement.parentNode) {
120+
this._inkBarElement.parentNode.removeChild(this._inkBarElement);
124121
}
125122

126-
this._element = this._indicator = this._indicatorContent = null!;
123+
this._hostElement = this._inkBarElement = this._inkBarContentElement = null!;
127124
this._foundation.destroy();
128125
this._destroyed = true;
129126
}
130127

131-
private _createIndicator(document: Document) {
132-
if (!this._indicator) {
133-
const indicator = this._indicator = document.createElement('span');
134-
const content = this._indicatorContent = document.createElement('span');
128+
/**
129+
* Sets whether the ink bar should be appended to the content, which will cause the ink bar
130+
* to match the width of the content rather than the tab host element.
131+
*/
132+
setFitToContent(fitToContent: boolean) {
133+
if (this._fitToContent !== fitToContent) {
134+
this._fitToContent = fitToContent;
135+
if (this._inkBarElement) {
136+
this._appendInkBarElement();
137+
}
138+
}
139+
}
140+
141+
142+
/**
143+
* Gets whether the ink bar should be appended to the content, which will cause the ink bar
144+
* to match the width of the content rather than the tab host element.
145+
*/
146+
getFitToContent(): boolean { return this._fitToContent; }
147+
148+
/** Creates and appends the ink bar element. */
149+
private _createInkBarElement() {
150+
this._inkBarElement = this._document.createElement('span');
151+
this._inkBarContentElement = this._document.createElement('span');
135152

136-
indicator.className = 'mdc-tab-indicator';
137-
content.className = 'mdc-tab-indicator__content mdc-tab-indicator__content--underline';
153+
this._inkBarElement.className = 'mdc-tab-indicator';
154+
this._inkBarContentElement.className = 'mdc-tab-indicator__content' +
155+
' mdc-tab-indicator__content--underline';
138156

139-
indicator.appendChild(content);
140-
this._element.appendChild(indicator);
157+
this._inkBarElement.appendChild(this._inkBarContentElement);
158+
this._appendInkBarElement();
159+
}
160+
161+
/**
162+
* Appends the ink bar to the tab host element or content, depending on whether
163+
* the ink bar should fit to content.
164+
*/
165+
private _appendInkBarElement() {
166+
if (!this._inkBarElement) {
167+
throw Error('Ink bar element has not been created and cannot be appended');
168+
}
169+
170+
const parentElement = this._fitToContent ?
171+
this._hostElement.querySelector('.mdc-tab__content') :
172+
this._hostElement;
173+
174+
if (!parentElement) {
175+
throw Error('Missing element to host the ink bar');
141176
}
177+
178+
parentElement.appendChild(this._inkBarElement);
142179
}
143180
}

src/material-experimental/mdc-tabs/tab-group.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
[attr.aria-labelledby]="(!tab.ariaLabel && tab.ariaLabelledby) ? tab.ariaLabelledby : null"
2121
[class.mdc-tab--active]="selectedIndex == i"
2222
[disabled]="tab.disabled"
23+
[fitInkBarToContent]="fitInkBarToContent"
2324
(click)="_handleClick(tab, tabHeader, i)">
2425
<span class="mdc-tab__ripple"></span>
2526

src/material-experimental/mdc-tabs/tab-group.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,47 @@ describe('nested MatTabGroup with enabled animations', () => {
677677
});
678678

679679

680+
describe('MatTabGroup with ink bar fit to content', () => {
681+
let fixture: ComponentFixture<TabGroupWithInkBarFitToContent>;
682+
683+
beforeEach(fakeAsync(() => {
684+
TestBed.configureTestingModule({
685+
imports: [MatTabsModule, BrowserAnimationsModule],
686+
declarations: [TabGroupWithInkBarFitToContent]
687+
});
688+
689+
TestBed.compileComponents();
690+
}));
691+
692+
beforeEach(() => {
693+
fixture = TestBed.createComponent(TabGroupWithInkBarFitToContent);
694+
fixture.detectChanges();
695+
});
696+
697+
it('should properly nest the ink bar when fit to content', () => {
698+
const tabElement = fixture.nativeElement.querySelector('.mdc-tab');
699+
const contentElement = tabElement.querySelector('.mdc-tab__content');
700+
const indicatorElement = tabElement.querySelector('.mdc-tab-indicator');
701+
expect(indicatorElement.parentElement).toBe(contentElement);
702+
});
703+
704+
it('should be able to move the ink bar between content and full', () => {
705+
fixture.componentInstance.fitInkBarToContent = false;
706+
fixture.detectChanges();
707+
708+
const tabElement = fixture.nativeElement.querySelector('.mdc-tab');
709+
const indicatorElement = tabElement.querySelector('.mdc-tab-indicator');
710+
expect(indicatorElement.parentElement).toBe(tabElement);
711+
712+
fixture.componentInstance.fitInkBarToContent = true;
713+
fixture.detectChanges();
714+
715+
const contentElement = tabElement.querySelector('.mdc-tab__content');
716+
expect(indicatorElement.parentElement).toBe(contentElement);
717+
});
718+
});
719+
720+
680721
@Component({
681722
template: `
682723
<mat-tab-group class="tab-group"
@@ -928,3 +969,16 @@ class TabsWithCustomAnimationDuration {}
928969
class TabGroupWithIndirectDescendantTabs {
929970
@ViewChild(MatTabGroup) tabGroup: MatTabGroup;
930971
}
972+
973+
974+
@Component({
975+
template: `
976+
<mat-tab-group [fitInkBarToContent]="fitInkBarToContent">
977+
<mat-tab label="One">Tab one content</mat-tab>
978+
<mat-tab label="Two">Tab two content</mat-tab>
979+
</mat-tab-group>
980+
`,
981+
})
982+
class TabGroupWithInkBarFitToContent {
983+
fitInkBarToContent = true;
984+
}

src/material-experimental/mdc-tabs/tab-group.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,27 @@
88

99
import {
1010
ChangeDetectionStrategy,
11+
ChangeDetectorRef,
1112
Component,
1213
ContentChildren,
1314
ElementRef,
15+
Inject,
16+
Input,
17+
Optional,
1418
QueryList,
1519
ViewChild,
1620
ViewEncapsulation,
17-
ChangeDetectorRef,
18-
Inject,
19-
Optional,
2021
} from '@angular/core';
2122
import {
2223
_MatTabGroupBase,
24+
MAT_TAB_GROUP,
2325
MAT_TABS_CONFIG,
2426
MatTabsConfig,
25-
MAT_TAB_GROUP,
2627
} from '@angular/material/tabs';
2728
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
2829
import {MatTab} from './tab';
2930
import {MatTabHeader} from './tab-header';
31+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
3032

3133
/**
3234
* Material design tab-group component. Supports basic tab pairs (label + content) and includes
@@ -57,6 +59,12 @@ export class MatTabGroup extends _MatTabGroupBase {
5759
@ViewChild('tabBodyWrapper') _tabBodyWrapper: ElementRef;
5860
@ViewChild('tabHeader') _tabHeader: MatTabHeader;
5961

62+
/** Whether the ink bar should fit its width to the size of the tab label content. */
63+
@Input()
64+
get fitInkBarToContent(): boolean { return this._fitInkBarToContent; }
65+
set fitInkBarToContent(v: boolean) { this._fitInkBarToContent = coerceBooleanProperty(v); }
66+
private _fitInkBarToContent = false;
67+
6068
constructor(elementRef: ElementRef,
6169
changeDetectorRef: ChangeDetectorRef,
6270
@Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: MatTabsConfig,

src/material-experimental/mdc-tabs/tab-label-wrapper.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, ElementRef, Inject, OnDestroy} from '@angular/core';
9+
import {Directive, ElementRef, Inject, Input, OnDestroy, OnInit} from '@angular/core';
1010
import {DOCUMENT} from '@angular/common';
1111
import {MatTabLabelWrapper as BaseMatTabLabelWrapper} from '@angular/material/tabs';
1212
import {MatInkBarFoundation, MatInkBarItem} from './ink-bar';
13+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1314

1415
/**
1516
* Used in the `mat-tab-group` view to display tab labels.
@@ -23,12 +24,24 @@ import {MatInkBarFoundation, MatInkBarItem} from './ink-bar';
2324
'[attr.aria-disabled]': '!!disabled',
2425
}
2526
})
26-
export class MatTabLabelWrapper extends BaseMatTabLabelWrapper implements MatInkBarItem, OnDestroy {
27+
export class MatTabLabelWrapper extends BaseMatTabLabelWrapper
28+
implements MatInkBarItem, OnInit, OnDestroy {
29+
private _document: Document;
30+
2731
_foundation: MatInkBarFoundation;
2832

33+
/** Whether the ink bar should fit its width to the size of the tab label content. */
34+
@Input()
35+
get fitInkBarToContent(): boolean { return this._foundation.getFitToContent(); }
36+
set fitInkBarToContent(v: boolean) { this._foundation.setFitToContent(coerceBooleanProperty(v)); }
37+
2938
constructor(public elementRef: ElementRef, @Inject(DOCUMENT) _document: any) {
3039
super(elementRef);
31-
this._foundation = new MatInkBarFoundation(elementRef, _document);
40+
this._document = _document;
41+
this._foundation = new MatInkBarFoundation(this.elementRef.nativeElement, this._document);
42+
}
43+
44+
ngOnInit() {
3245
this._foundation.init();
3346
}
3447

0 commit comments

Comments
 (0)