Skip to content

Commit 4ba4755

Browse files
authored
refactor(material-experimental/mdc-tabs): remove usage of MDC adapter (#24942)
Reworks the MDC tab ink bar so that it doesn't depend upon the MDC adapter and foundation.
1 parent fc35e2b commit 4ba4755

File tree

4 files changed

+129
-177
lines changed

4 files changed

+129
-177
lines changed

src/material-experimental/mdc-tabs/BUILD.bazel

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ ng_module(
3939
"@npm//@angular/animations",
4040
"@npm//@angular/common",
4141
"@npm//@angular/core",
42-
"@npm//@material/tab-indicator",
4342
],
4443
)
4544

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

Lines changed: 116 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,26 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ElementRef, QueryList} from '@angular/core';
10-
import {
11-
MDCSlidingTabIndicatorFoundation,
12-
MDCTabIndicatorAdapter,
13-
MDCTabIndicatorFoundation,
14-
} from '@material/tab-indicator';
9+
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
10+
import {ElementRef, OnDestroy, OnInit, QueryList} from '@angular/core';
1511

1612
/**
1713
* Item inside a tab header relative to which the ink bar can be aligned.
1814
* @docs-private
1915
*/
20-
export interface MatInkBarItem {
21-
_foundation: MatInkBarFoundation;
16+
export interface MatInkBarItem extends OnInit, OnDestroy {
2217
elementRef: ElementRef<HTMLElement>;
18+
activateInkBar(previousIndicatorClientRect?: ClientRect): void;
19+
deactivateInkBar(): void;
20+
fitInkBarToContent: boolean;
2321
}
2422

23+
/** Class that is applied when a tab indicator is active. */
24+
const ACTIVE_CLASS = 'mdc-tab-indicator--active';
25+
26+
/** Class that is applied when the tab indicator should not transition. */
27+
const NO_TRANSITION_CLASS = 'mdc-tab-indicator--no-transition';
28+
2529
/**
2630
* Abstraction around the MDC tab indicator that acts as the tab header's ink bar.
2731
* @docs-private
@@ -34,157 +38,144 @@ export class MatInkBar {
3438

3539
/** Hides the ink bar. */
3640
hide() {
37-
this._items.forEach(item => item._foundation.deactivate());
41+
this._items.forEach(item => item.deactivateInkBar());
3842
}
3943

4044
/** Aligns the ink bar to a DOM node. */
4145
alignToElement(element: HTMLElement) {
4246
const correspondingItem = this._items.find(item => item.elementRef.nativeElement === element);
4347
const currentItem = this._currentItem;
4448

45-
if (currentItem) {
46-
currentItem._foundation.deactivate();
47-
}
49+
currentItem?.deactivateInkBar();
4850

4951
if (correspondingItem) {
50-
const clientRect = currentItem
51-
? currentItem._foundation.computeContentClientRect()
52-
: undefined;
52+
const clientRect = currentItem?.elementRef.nativeElement.getBoundingClientRect?.();
5353

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

6161
/**
62-
* Implementation of MDC's sliding tab indicator (ink bar) foundation.
62+
* Mixin that can be used to apply the `MatInkBarItem` behavior to a class.
63+
* Base on MDC's `MDCSlidingTabIndicatorFoundation`:
64+
* https://github.com/material-components/material-components-web/blob/c0a11ef0d000a098fd0c372be8f12d6a99302855/packages/mdc-tab-indicator/sliding-foundation.ts
6365
* @docs-private
6466
*/
65-
export class MatInkBarFoundation {
66-
private _destroyed: boolean;
67-
private _foundation: MDCTabIndicatorFoundation;
68-
private _inkBarElement: HTMLElement;
69-
private _inkBarContentElement: HTMLElement;
70-
private _fitToContent = false;
71-
private _adapter: MDCTabIndicatorAdapter = {
72-
addClass: className => {
73-
if (!this._destroyed) {
74-
this._hostElement.classList.add(className);
75-
}
76-
},
77-
removeClass: className => {
78-
if (!this._destroyed) {
79-
this._hostElement.classList.remove(className);
80-
}
81-
},
82-
setContentStyleProperty: (propName, value) => {
83-
if (!this._destroyed) {
84-
this._inkBarContentElement.style.setProperty(propName, value);
85-
}
86-
},
87-
computeContentClientRect: () => {
88-
// `getBoundingClientRect` isn't available on the server.
89-
return this._destroyed || !this._inkBarContentElement.getBoundingClientRect
90-
? ({
91-
width: 0,
92-
height: 0,
93-
top: 0,
94-
left: 0,
95-
right: 0,
96-
bottom: 0,
97-
x: 0,
98-
y: 0,
99-
} as ClientRect)
100-
: this._inkBarContentElement.getBoundingClientRect();
101-
},
102-
};
67+
export function mixinInkBarItem<
68+
T extends new (...args: any[]) => {elementRef: ElementRef<HTMLElement>},
69+
>(base: T): T & (new (...args: any[]) => MatInkBarItem) {
70+
return class extends base {
71+
constructor(...args: any[]) {
72+
super(...args);
73+
}
10374

104-
constructor(private _hostElement: HTMLElement, private _document: Document) {
105-
this._foundation = new MDCSlidingTabIndicatorFoundation(this._adapter);
106-
}
75+
private _inkBarElement: HTMLElement | null;
76+
private _inkBarContentElement: HTMLElement | null;
77+
private _fitToContent = false;
10778

108-
/** Aligns the ink bar to the current item. */
109-
activate(clientRect?: ClientRect) {
110-
this._foundation.activate(clientRect);
111-
}
79+
/** Whether the ink bar should fit to the entire tab or just its content. */
80+
get fitInkBarToContent(): boolean {
81+
return this._fitToContent;
82+
}
83+
set fitInkBarToContent(v: BooleanInput) {
84+
const newValue = coerceBooleanProperty(v);
11285

113-
/** Removes the ink bar from the current item. */
114-
deactivate() {
115-
this._foundation.deactivate();
116-
}
86+
if (this._fitToContent !== newValue) {
87+
this._fitToContent = newValue;
11788

118-
/** Gets the ClientRect of the ink bar. */
119-
computeContentClientRect() {
120-
return this._foundation.computeContentClientRect();
121-
}
89+
if (this._inkBarElement) {
90+
this._appendInkBarElement();
91+
}
92+
}
93+
}
12294

123-
/** Initializes the foundation. */
124-
init() {
125-
this._createInkBarElement();
126-
this._foundation.init();
127-
}
95+
/** Aligns the ink bar to the current item. */
96+
activateInkBar(previousIndicatorClientRect?: ClientRect) {
97+
const element = this.elementRef.nativeElement;
98+
99+
// Early exit if no indicator is present to handle cases where an indicator
100+
// may be activated without a prior indicator state
101+
if (
102+
!previousIndicatorClientRect ||
103+
!element.getBoundingClientRect ||
104+
!this._inkBarContentElement
105+
) {
106+
element.classList.add(ACTIVE_CLASS);
107+
return;
108+
}
128109

129-
/** Destroys the foundation. */
130-
destroy() {
131-
this._inkBarElement.remove();
132-
this._hostElement = this._inkBarElement = this._inkBarContentElement = null!;
133-
this._foundation.destroy();
134-
this._destroyed = true;
135-
}
110+
// This animation uses the FLIP approach. You can read more about it at the link below:
111+
// https://aerotwist.com/blog/flip-your-animations/
112+
113+
// Calculate the dimensions based on the dimensions of the previous indicator
114+
const currentClientRect = element.getBoundingClientRect();
115+
const widthDelta = previousIndicatorClientRect.width / currentClientRect.width;
116+
const xPosition = previousIndicatorClientRect.left - currentClientRect.left;
117+
element.classList.add(NO_TRANSITION_CLASS);
118+
this._inkBarContentElement.style.setProperty(
119+
'transform',
120+
`translateX(${xPosition}px) scaleX(${widthDelta})`,
121+
);
122+
123+
// Force repaint before updating classes and transform to ensure the transform properly takes effect
124+
element.getBoundingClientRect();
125+
126+
element.classList.remove(NO_TRANSITION_CLASS);
127+
element.classList.add(ACTIVE_CLASS);
128+
this._inkBarContentElement.style.setProperty('transform', '');
129+
}
136130

137-
/**
138-
* Sets whether the ink bar should be appended to the content, which will cause the ink bar
139-
* to match the width of the content rather than the tab host element.
140-
*/
141-
setFitToContent(fitToContent: boolean) {
142-
if (this._fitToContent !== fitToContent) {
143-
this._fitToContent = fitToContent;
144-
if (this._inkBarElement) {
145-
this._appendInkBarElement();
146-
}
131+
/** Removes the ink bar from the current item. */
132+
deactivateInkBar() {
133+
this.elementRef.nativeElement.classList.remove(ACTIVE_CLASS);
147134
}
148-
}
149135

150-
/**
151-
* Gets whether the ink bar should be appended to the content, which will cause the ink bar
152-
* to match the width of the content rather than the tab host element.
153-
*/
154-
getFitToContent(): boolean {
155-
return this._fitToContent;
156-
}
136+
/** Initializes the foundation. */
137+
ngOnInit() {
138+
this._createInkBarElement();
139+
}
157140

158-
/** Creates and appends the ink bar element. */
159-
private _createInkBarElement() {
160-
this._inkBarElement = this._document.createElement('span');
161-
this._inkBarContentElement = this._document.createElement('span');
141+
/** Destroys the foundation. */
142+
ngOnDestroy() {
143+
this._inkBarElement?.remove();
144+
this._inkBarElement = this._inkBarContentElement = null!;
145+
}
162146

163-
this._inkBarElement.className = 'mdc-tab-indicator';
164-
this._inkBarContentElement.className =
165-
'mdc-tab-indicator__content' + ' mdc-tab-indicator__content--underline';
147+
/** Creates and appends the ink bar element. */
148+
private _createInkBarElement() {
149+
const documentNode = this.elementRef.nativeElement.ownerDocument || document;
150+
this._inkBarElement = documentNode.createElement('span');
151+
this._inkBarContentElement = documentNode.createElement('span');
166152

167-
this._inkBarElement.appendChild(this._inkBarContentElement);
168-
this._appendInkBarElement();
169-
}
153+
this._inkBarElement.className = 'mdc-tab-indicator';
154+
this._inkBarContentElement.className =
155+
'mdc-tab-indicator__content mdc-tab-indicator__content--underline';
170156

171-
/**
172-
* Appends the ink bar to the tab host element or content, depending on whether
173-
* the ink bar should fit to content.
174-
*/
175-
private _appendInkBarElement() {
176-
if (!this._inkBarElement && (typeof ngDevMode === 'undefined' || ngDevMode)) {
177-
throw Error('Ink bar element has not been created and cannot be appended');
157+
this._inkBarElement.appendChild(this._inkBarContentElement);
158+
this._appendInkBarElement();
178159
}
179160

180-
const parentElement = this._fitToContent
181-
? this._hostElement.querySelector('.mdc-tab__content')
182-
: this._hostElement;
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 && (typeof ngDevMode === 'undefined' || ngDevMode)) {
167+
throw Error('Ink bar element has not been created and cannot be appended');
168+
}
183169

184-
if (!parentElement && (typeof ngDevMode === 'undefined' || ngDevMode)) {
185-
throw Error('Missing element to host the ink bar');
186-
}
170+
const parentElement = this._fitToContent
171+
? this.elementRef.nativeElement.querySelector('.mdc-tab__content')
172+
: this.elementRef.nativeElement;
187173

188-
parentElement!.appendChild(this._inkBarElement);
189-
}
174+
if (!parentElement && (typeof ngDevMode === 'undefined' || ngDevMode)) {
175+
throw Error('Missing element to host the ink bar');
176+
}
177+
178+
parentElement!.appendChild(this._inkBarElement!);
179+
}
180+
};
190181
}

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

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

9-
import {Directive, ElementRef, Inject, Input, OnDestroy, OnInit} from '@angular/core';
10-
import {DOCUMENT} from '@angular/common';
9+
import {Directive} from '@angular/core';
1110
import {MatTabLabelWrapper as BaseMatTabLabelWrapper} from '@angular/material/tabs';
12-
import {MatInkBarFoundation, MatInkBarItem} from './ink-bar';
13-
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
11+
import {MatInkBarItem, mixinInkBarItem} from './ink-bar';
12+
13+
const _MatTabLabelWrapperBase = mixinInkBarItem(BaseMatTabLabelWrapper);
1414

1515
/**
1616
* Used in the `mat-tab-group` view to display tab labels.
1717
* @docs-private
1818
*/
1919
@Directive({
2020
selector: '[matTabLabelWrapper]',
21-
inputs: ['disabled'],
21+
inputs: ['disabled', 'fitInkBarToContent'],
2222
host: {
2323
'[class.mat-mdc-tab-disabled]': 'disabled',
2424
'[attr.aria-disabled]': '!!disabled',
2525
},
2626
})
27-
export class MatTabLabelWrapper
28-
extends BaseMatTabLabelWrapper
29-
implements MatInkBarItem, OnInit, OnDestroy
30-
{
31-
private _document: Document;
32-
33-
_foundation: MatInkBarFoundation;
34-
35-
/** Whether the ink bar should fit its width to the size of the tab label content. */
36-
@Input()
37-
get fitInkBarToContent(): boolean {
38-
return this._foundation.getFitToContent();
39-
}
40-
set fitInkBarToContent(v: BooleanInput) {
41-
this._foundation.setFitToContent(coerceBooleanProperty(v));
42-
}
43-
44-
constructor(elementRef: ElementRef, @Inject(DOCUMENT) _document: any) {
45-
super(elementRef);
46-
this._document = _document;
47-
this._foundation = new MatInkBarFoundation(elementRef.nativeElement, this._document);
48-
}
49-
50-
ngOnInit() {
51-
this._foundation.init();
52-
}
53-
54-
ngOnDestroy() {
55-
this._foundation.destroy();
56-
}
57-
}
27+
export class MatTabLabelWrapper extends _MatTabLabelWrapperBase implements MatInkBarItem {}

0 commit comments

Comments
 (0)