Skip to content

Commit d6d3e3e

Browse files
authored
feat(material/button): make button ripples lazy (#26568)
1 parent 6c93592 commit d6d3e3e

File tree

11 files changed

+255
-21
lines changed

11 files changed

+255
-21
lines changed

src/material/button/button-base.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
NgZone,
1717
OnDestroy,
1818
OnInit,
19-
ViewChild,
2019
} from '@angular/core';
2120
import {
2221
CanColor,
@@ -27,6 +26,7 @@ import {
2726
mixinDisabled,
2827
mixinDisableRipple,
2928
} from '@angular/material/core';
29+
import {MAT_BUTTON_RIPPLE_UNINITIALIZED, MatButtonLazyLoader} from './button-lazy-loader';
3030

3131
/** Inputs common to all buttons. */
3232
export const MAT_BUTTON_INPUTS = ['disabled', 'disableRipple', 'color'];
@@ -42,6 +42,9 @@ export const MAT_BUTTON_HOST = {
4242
// Add a class that applies to all buttons. This makes it easier to target if somebody
4343
// wants to target all Material buttons.
4444
'[class.mat-mdc-button-base]': 'true',
45+
[MAT_BUTTON_RIPPLE_UNINITIALIZED]: '',
46+
'[attr.mat-button-disabled]': '_isRippleDisabled()',
47+
'[attr.mat-button-is-fab]': '_isFab',
4548
};
4649

4750
/** List of classes to add to buttons instances based on host attribute selector. */
@@ -96,11 +99,32 @@ export class MatButtonBase
9699
{
97100
private readonly _focusMonitor = inject(FocusMonitor);
98101

102+
/**
103+
* Handles the lazy creation of the MatButton ripple.
104+
* Used to improve initial load time of large applications.
105+
*/
106+
_rippleLoader: MatButtonLazyLoader = inject(MatButtonLazyLoader);
107+
99108
/** Whether this button is a FAB. Used to apply the correct class on the ripple. */
100109
_isFab = false;
101110

102-
/** Reference to the MatRipple instance of the button. */
103-
@ViewChild(MatRipple) ripple: MatRipple;
111+
/**
112+
* Reference to the MatRipple instance of the button.
113+
* @deprecated Considered an implementation detail. To be removed.
114+
* @breaking-change 17.0.0
115+
*/
116+
get ripple(): MatRipple {
117+
if (!this._ripple && this._rippleLoader) {
118+
this._ripple = this._rippleLoader._createMatRipple(this._elementRef.nativeElement);
119+
}
120+
return this._ripple!;
121+
}
122+
set ripple(v: MatRipple) {
123+
this._ripple = v;
124+
}
125+
126+
/** @docs-private Reference to the MatRipple instance of the button. */
127+
protected _ripple?: MatRipple;
104128

105129
constructor(
106130
elementRef: ElementRef,
@@ -146,7 +170,9 @@ export class MatButtonBase
146170
}
147171

148172
_isRippleDisabled() {
149-
return this.disableRipple || this.disabled;
173+
if (this._ripple) {
174+
this._ripple.disabled = this.disableRipple || this.disabled;
175+
}
150176
}
151177
}
152178

@@ -170,6 +196,9 @@ export const MAT_ANCHOR_HOST = {
170196
// Add a class that applies to all buttons. This makes it easier to target if somebody
171197
// wants to target all Material buttons.
172198
'[class.mat-mdc-button-base]': 'true',
199+
[MAT_BUTTON_RIPPLE_UNINITIALIZED]: '',
200+
'[attr.mat-button-disabled]': '_isRippleDisabled()',
201+
'[attr.mat-button-is-fab]': '_isFab',
173202
};
174203

175204
/**
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {DOCUMENT} from '@angular/common';
10+
import {
11+
ANIMATION_MODULE_TYPE,
12+
ElementRef,
13+
Injectable,
14+
NgZone,
15+
OnDestroy,
16+
inject,
17+
} from '@angular/core';
18+
import {
19+
MAT_RIPPLE_GLOBAL_OPTIONS,
20+
MatRipple,
21+
RippleConfig,
22+
RippleGlobalOptions,
23+
RippleRenderer,
24+
RippleTarget,
25+
} from '@angular/material/core';
26+
import {Platform} from '@angular/cdk/platform';
27+
28+
/** The options for the MatButtonRippleLoader's event listeners. */
29+
const eventListenerOptions = {capture: true};
30+
31+
/** The events that should trigger the initialization of the ripple. */
32+
const rippleInteractionEvents = ['focus', 'click', 'mouseenter', 'touchstart'];
33+
34+
/** The attribute attached to a mat-button whose ripple has not yet been initialized. */
35+
export const MAT_BUTTON_RIPPLE_UNINITIALIZED = 'mat-button-ripple-uninitialized';
36+
37+
/**
38+
* Handles attaching the MatButton's ripple on demand.
39+
*
40+
* This service allows us to avoid eagerly creating & attaching the MatButton's ripple.
41+
* It works by creating & attaching the ripple only when a MatButton is first interacted with.
42+
*/
43+
@Injectable({providedIn: 'root'})
44+
export class MatButtonLazyLoader implements OnDestroy {
45+
private _document = inject(DOCUMENT, {optional: true});
46+
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
47+
private _globalRippleOptions = inject(MAT_RIPPLE_GLOBAL_OPTIONS, {optional: true});
48+
private _platform = inject(Platform);
49+
private _ngZone = inject(NgZone);
50+
51+
constructor() {
52+
this._ngZone.runOutsideAngular(() => {
53+
for (const event of rippleInteractionEvents) {
54+
this._document?.addEventListener(event, this._onInteraction, eventListenerOptions);
55+
}
56+
});
57+
}
58+
59+
ngOnDestroy() {
60+
for (const event of rippleInteractionEvents) {
61+
this._document?.removeEventListener(event, this._onInteraction, eventListenerOptions);
62+
}
63+
}
64+
65+
/** Handles creating and attaching button internals when a button is initially interacted with. */
66+
private _onInteraction = (event: Event) => {
67+
if (event.target === this._document) {
68+
return;
69+
}
70+
const eventTarget = event.target as Element;
71+
72+
// TODO(wagnermaciel): Consider batching these events to improve runtime performance.
73+
74+
const button = eventTarget.closest(`[${MAT_BUTTON_RIPPLE_UNINITIALIZED}]`);
75+
if (button) {
76+
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
77+
this._appendRipple(button as HTMLElement);
78+
}
79+
};
80+
81+
/** Creates a MatButtonRipple and appends it to the given button element. */
82+
private _appendRipple(button: HTMLElement): void {
83+
if (!this._document) {
84+
return;
85+
}
86+
const ripple = this._document.createElement('span');
87+
ripple.classList.add('mat-mdc-button-ripple');
88+
89+
const target = new MatButtonRippleTarget(
90+
button,
91+
this._globalRippleOptions ? this._globalRippleOptions : undefined,
92+
this._animationMode ? this._animationMode : undefined,
93+
);
94+
target.rippleConfig.centered = button.hasAttribute('mat-icon-button');
95+
96+
const rippleRenderer = new RippleRenderer(target, this._ngZone, ripple, this._platform);
97+
rippleRenderer.setupTriggerEvents(button);
98+
button.append(ripple);
99+
}
100+
101+
_createMatRipple(button: HTMLElement): MatRipple | undefined {
102+
if (!this._document) {
103+
return;
104+
}
105+
button.querySelector('.mat-mdc-button-ripple')?.remove();
106+
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
107+
const rippleEl = this._document!.createElement('span');
108+
rippleEl.classList.add('mat-mdc-button-ripple');
109+
const ripple = new MatRipple(
110+
new ElementRef(rippleEl),
111+
this._ngZone,
112+
this._platform,
113+
this._globalRippleOptions ? this._globalRippleOptions : undefined,
114+
this._animationMode ? this._animationMode : undefined,
115+
);
116+
ripple._isInitialized = true;
117+
ripple.trigger = button;
118+
button.append(rippleEl);
119+
return ripple;
120+
}
121+
}
122+
123+
/**
124+
* The RippleTarget for the lazily rendered MatButton ripple.
125+
* It handles ripple configuration and disabled state for ripples interactions.
126+
*
127+
* Note that this configuration is usually handled by the MatRipple, but the MatButtonLazyLoader does not use the
128+
* MatRipple Directive. In order to create & attach a ripple on demand, it uses the "lower level" RippleRenderer.
129+
*/
130+
class MatButtonRippleTarget implements RippleTarget {
131+
rippleConfig: RippleConfig & RippleGlobalOptions;
132+
133+
constructor(
134+
private _button: HTMLElement,
135+
private _globalRippleOptions?: RippleGlobalOptions,
136+
animationMode?: string,
137+
) {
138+
this._setRippleConfig(_globalRippleOptions, animationMode);
139+
}
140+
141+
private _setRippleConfig(globalRippleOptions?: RippleGlobalOptions, animationMode?: string) {
142+
this.rippleConfig = globalRippleOptions || {};
143+
if (animationMode === 'NoopAnimations') {
144+
this.rippleConfig.animation = {enterDuration: 0, exitDuration: 0};
145+
}
146+
}
147+
148+
get rippleDisabled(): boolean {
149+
return this._button.hasAttribute('disabled') || !!this._globalRippleOptions?.disabled;
150+
}
151+
}

src/material/button/button.html

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,4 @@
1717
-->
1818
<span class="mat-mdc-focus-indicator"></span>
1919

20-
<span matRipple class="mat-mdc-button-ripple"
21-
[matRippleDisabled]="_isRippleDisabled()"
22-
[matRippleTrigger]="_elementRef.nativeElement"></span>
23-
2420
<span class="mat-mdc-button-touch-target"></span>

src/material/button/button.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@
9292
}
9393
}
9494

95+
// This style used to be applied by the MatRipple
96+
// directive, which is no longer attached to this element.
97+
.mat-mdc-button-ripple {
98+
overflow: hidden;
99+
}
100+
95101
// Since the stroked button has has an actual border that reduces the available space for
96102
// child elements such as the ripple container or focus overlay, an inherited border radius
97103
// for the absolute-positioned child elements does not work properly. This is because the

src/material/button/button.spec.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {By} from '@angular/platform-browser';
44
import {MatButtonModule, MatButton, MatFabDefaultOptions, MAT_FAB_DEFAULT_OPTIONS} from './index';
55
import {MatRipple, ThemePalette} from '@angular/material/core';
66
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
7+
import {MAT_BUTTON_RIPPLE_UNINITIALIZED} from './button-lazy-loader';
78

89
describe('MDC-based MatButton', () => {
910
beforeEach(waitForAsync(() => {
@@ -267,10 +268,8 @@ describe('MDC-based MatButton', () => {
267268
let fixture: ComponentFixture<TestApp>;
268269
let testComponent: TestApp;
269270
let buttonDebugElement: DebugElement;
270-
let buttonRippleDebugElement: DebugElement;
271271
let buttonRippleInstance: MatRipple;
272272
let anchorDebugElement: DebugElement;
273-
let anchorRippleDebugElement: DebugElement;
274273
let anchorRippleInstance: MatRipple;
275274

276275
beforeEach(() => {
@@ -280,12 +279,10 @@ describe('MDC-based MatButton', () => {
280279
testComponent = fixture.componentInstance;
281280

282281
buttonDebugElement = fixture.debugElement.query(By.css('button[mat-button]'))!;
283-
buttonRippleDebugElement = buttonDebugElement.query(By.directive(MatRipple))!;
284-
buttonRippleInstance = buttonRippleDebugElement.injector.get<MatRipple>(MatRipple);
282+
buttonRippleInstance = buttonDebugElement.componentInstance.ripple;
285283

286284
anchorDebugElement = fixture.debugElement.query(By.css('a[mat-button]'))!;
287-
anchorRippleDebugElement = anchorDebugElement.query(By.directive(MatRipple))!;
288-
anchorRippleInstance = anchorRippleDebugElement.injector.get<MatRipple>(MatRipple);
285+
anchorRippleInstance = anchorDebugElement.componentInstance.ripple;
289286
});
290287

291288
it('should disable the ripple if matRippleDisabled input is set', () => {
@@ -315,6 +312,43 @@ describe('MDC-based MatButton', () => {
315312
'Expected a disabled a[mat-button] not to have an enabled ripple',
316313
);
317314
});
315+
316+
it('should render the ripple once it is referenced', () => {
317+
const fab = fixture.debugElement.query(By.css('button[mat-fab]'))!;
318+
let ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
319+
expect(ripple).withContext('Expect ripple to be absent before user interaction').toBeNull();
320+
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED))
321+
.withContext('Expect mat-button to have the "uninitialized" attr before user interaction')
322+
.toBeTrue();
323+
324+
// Referencing the ripple should instantiate the ripple.
325+
expect(fab.componentInstance.ripple).toBeDefined();
326+
327+
ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
328+
expect(ripple)
329+
.withContext('Expect ripple to be present after user interaction')
330+
.not.toBeNull();
331+
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED))
332+
.withContext(
333+
'Expect mat-button NOT to have the "uninitialized" attr after user interaction',
334+
)
335+
.toBeFalse();
336+
});
337+
338+
// Ensure each of these events triggers the initialization of the button ripple.
339+
for (const event of ['click', 'touchstart', 'mouseenter', 'focus']) {
340+
it(`should render the ripple once a button has received a "${event}" event`, () => {
341+
const fab = fixture.debugElement.query(By.css('button[mat-fab]'))!;
342+
let ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
343+
expect(ripple).toBeNull();
344+
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)).toBeTrue();
345+
346+
dispatchEvent(fab.nativeElement, createMouseEvent(event));
347+
ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
348+
expect(ripple).not.toBeNull();
349+
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)).toBeFalse();
350+
});
351+
}
318352
});
319353

320354
it('should have a focus indicator', () => {

src/material/button/icon-button.html

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,4 @@
88
-->
99
<span class="mat-mdc-focus-indicator"></span>
1010

11-
<span matRipple class="mat-mdc-button-ripple"
12-
[matRippleDisabled]="_isRippleDisabled()"
13-
[matRippleCentered]="true"
14-
[matRippleTrigger]="_elementRef.nativeElement"></span>
15-
1611
<span class="mat-mdc-button-touch-target"></span>

src/material/button/icon-button.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
@include button-base.mat-private-button-touch-target(true);
5656
@include private.private-animation-noop();
5757

58+
// This style used to be applied by the MatRipple
59+
// directive, which is no longer attached to this element.
60+
.mat-mdc-button-ripple {
61+
overflow: hidden;
62+
}
63+
5864
.mat-mdc-button-persistent-ripple {
5965
border-radius: 50%;
6066
}

src/material/button/icon-button.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
MatAnchorBase,
2727
MatButtonBase,
2828
} from './button-base';
29+
import {MatRipple} from '@angular/material/core';
2930

3031
/**
3132
* Material Design icon button component. This type of button displays a single interactive icon for
@@ -43,6 +44,19 @@ import {
4344
changeDetection: ChangeDetectionStrategy.OnPush,
4445
})
4546
export class MatIconButton extends MatButtonBase {
47+
/**
48+
* Reference to the MatRipple instance of the button.
49+
* @deprecated Considered an implementation detail. To be removed.
50+
* @breaking-change 17.0.0
51+
*/
52+
override get ripple(): MatRipple {
53+
if (!this._ripple && this._rippleLoader) {
54+
this._ripple = this._rippleLoader._createMatRipple(this._elementRef.nativeElement);
55+
this._ripple!.centered = true;
56+
}
57+
return this._ripple!;
58+
}
59+
4660
constructor(
4761
elementRef: ElementRef,
4862
platform: Platform,

src/material/core/ripple/ripple.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ export class MatRipple implements OnInit, OnDestroy, RippleTarget {
121121
/** Options that are set globally for all ripples. */
122122
private _globalOptions: RippleGlobalOptions;
123123

124-
/** Whether ripple directive is initialized and the input bindings are set. */
125-
private _isInitialized: boolean = false;
124+
/** @docs-private Whether ripple directive is initialized and the input bindings are set. */
125+
_isInitialized: boolean = false;
126126

127127
constructor(
128128
private _elementRef: ElementRef<HTMLElement>,

0 commit comments

Comments
 (0)