Skip to content

Commit 6b2cbec

Browse files
committed
feat(material/button): make button ripples lazy
1 parent f7a5bcb commit 6b2cbec

File tree

13 files changed

+443
-60
lines changed

13 files changed

+443
-60
lines changed

src/material/button/button-base.ts

Lines changed: 31 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 {MatButtonRipple} from './button-ripple';
3030

3131
/** Inputs common to all buttons. */
3232
export const MAT_BUTTON_INPUTS = ['disabled', 'disableRipple', 'color'];
@@ -89,7 +89,14 @@ export const _MatButtonMixin = mixinColor(
8989
);
9090

9191
/** Base class for all buttons. */
92-
@Directive()
92+
@Directive({
93+
host: {
94+
'mat-button-ripple-uninitialized': 'true',
95+
'mat-button-internals-uninitialized': 'true',
96+
'[attr.mat-button-disabled]': '_isRippleDisabled()',
97+
'[attr.data-mat-button-is-fab]': '_isFab',
98+
},
99+
})
93100
export class MatButtonBase
94101
extends _MatButtonMixin
95102
implements CanDisable, CanColor, CanDisableRipple, AfterViewInit, OnDestroy
@@ -100,7 +107,27 @@ export class MatButtonBase
100107
_isFab = false;
101108

102109
/** Reference to the MatRipple instance of the button. */
103-
@ViewChild(MatRipple) ripple: MatRipple;
110+
get ripple(): MatRipple {
111+
// The ripple has been overridden.
112+
if (this._customRipple) {
113+
return this._customRipple;
114+
}
115+
116+
// The button ripple does not render until it is referenced.
117+
if (!this._buttonRipple._initialized) {
118+
this._buttonRipple._render();
119+
}
120+
return this._buttonRipple;
121+
}
122+
set ripple(v: MatRipple) {
123+
this._customRipple = v;
124+
}
125+
126+
/** @docs-private Stores a reference to a custom MatRipple instance if the user sets one. */
127+
private _customRipple: MatRipple | undefined;
128+
129+
/** @docs-private Reference to the MatButtonRipple instance of the button. */
130+
protected _buttonRipple: MatButtonRipple;
104131

105132
constructor(
106133
elementRef: ElementRef,
@@ -146,7 +173,7 @@ export class MatButtonBase
146173
}
147174

148175
_isRippleDisabled() {
149-
return this.disableRipple || this.disabled;
176+
this._buttonRipple.disabled = this.disableRipple || this.disabled;
150177
}
151178
}
152179

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
Inject,
13+
Injectable,
14+
NgZone,
15+
OnDestroy,
16+
Optional,
17+
} from '@angular/core';
18+
import {
19+
MAT_RIPPLE_GLOBAL_OPTIONS,
20+
RippleConfig,
21+
RippleGlobalOptions,
22+
RippleRenderer,
23+
RippleTarget,
24+
} from '@angular/material/core';
25+
import {Platform} from '@angular/cdk/platform';
26+
27+
/** The options for the MatButtonRippleLoader's event listeners. */
28+
const OPTIONS = {passive: true, capture: true};
29+
30+
/** The attribute attached to a mat-button whose ripple has not yet been initialized. */
31+
const MAT_BUTTON_RIPPLE_UNINITIALIZED = 'mat-button-ripple-uninitialized';
32+
33+
/** The attribute attached to a mat-button whose internals (excluding the ripple) have not yet been initialized. */
34+
const MAT_BUTTON_INTERNALS_UNINITIALIZED = 'mat-button-internals-uninitialized';
35+
36+
@Injectable({providedIn: 'root'})
37+
export class MatButtonLazyLoader implements OnDestroy {
38+
private _document: Document;
39+
40+
/** A batch of actions to run. */
41+
private _actionQueue: Function[] = [];
42+
43+
/** A timeout for when the action queue will be emptied / ran. */
44+
private _runActionsTimeout: any | null = null;
45+
46+
constructor(
47+
private _platform: Platform,
48+
private _ngZone: NgZone,
49+
@Optional() @Inject(DOCUMENT) document: any,
50+
@Optional() @Inject(ANIMATION_MODULE_TYPE) private _animationMode?: string,
51+
@Optional()
52+
@Inject(MAT_RIPPLE_GLOBAL_OPTIONS)
53+
private _globalRippleOptions?: RippleGlobalOptions,
54+
) {
55+
this._document = document;
56+
57+
this._ngZone.runOutsideAngular(() => {
58+
this._document.addEventListener('focus', this._onInteraction, OPTIONS);
59+
this._document.addEventListener('mouseenter', this._onInteraction, OPTIONS);
60+
});
61+
}
62+
63+
ngOnDestroy() {
64+
this._ngZone.runOutsideAngular(() => {
65+
this._document.removeEventListener('focus', this._onInteraction, OPTIONS);
66+
this._document.removeEventListener('mouseenter', this._onInteraction, OPTIONS);
67+
});
68+
}
69+
70+
_renderInternals(button: HTMLButtonElement): void {
71+
button.removeAttribute(MAT_BUTTON_INTERNALS_UNINITIALIZED);
72+
this._attachButtonInternals(button);
73+
}
74+
75+
/** Handles creating and attaching button internals when a button is initially interacted with. */
76+
private _onInteraction = (event: Event) => {
77+
if (!(event.target instanceof Element)) {
78+
return;
79+
}
80+
81+
const button = this._closest(event.target);
82+
if (!button) {
83+
return;
84+
}
85+
86+
button.removeAttribute(MAT_BUTTON_INTERNALS_UNINITIALIZED);
87+
this._actionQueue.push(() => this._attachButtonInternals(button as HTMLButtonElement));
88+
89+
// Immediately run all of the queued actions if a focus event occurs.
90+
91+
if (event.type === 'focus') {
92+
this._runActions();
93+
} else if (event.type === 'mouseenter') {
94+
this._runActionsTimeout = setTimeout(() => this._runActions(), 50);
95+
}
96+
};
97+
98+
/** Runs all of the actions that have been queued up. */
99+
private _runActions(): void {
100+
if (this._runActionsTimeout !== null) {
101+
clearTimeout(this._runActionsTimeout);
102+
this._runActionsTimeout = null;
103+
}
104+
for (const callback of this._actionQueue) {
105+
callback();
106+
}
107+
this._actionQueue = [];
108+
}
109+
110+
/**
111+
* Traverses the element and its parents (heading toward the document root)
112+
* until it finds a mat-button that has not been initialized.
113+
*/
114+
private _closest(element: Element): Element | null {
115+
let el: Element | null = element;
116+
while (el) {
117+
if (el.hasAttribute(MAT_BUTTON_INTERNALS_UNINITIALIZED)) {
118+
return el;
119+
}
120+
el = el.parentElement;
121+
}
122+
return null;
123+
}
124+
125+
private _attachButtonInternals(button: HTMLButtonElement): void {
126+
button.prepend(this._createSpan(this._getPersistentRippleClassName(button)));
127+
128+
if (button.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)) {
129+
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
130+
button.append(this._createSpan('mat-mdc-focus-indicator'));
131+
this._appendRipple(button);
132+
} else {
133+
const rippleEl = button.querySelector('.mat-mdc-button-ripple');
134+
rippleEl!.before(this._createSpan('mat-mdc-focus-indicator'));
135+
}
136+
137+
// Move the touch target to the correct location in the button.
138+
const touchTarget = button.querySelector('.mat-mdc-button-touch-target')!;
139+
button.appendChild(touchTarget);
140+
}
141+
142+
private _appendRipple(button: HTMLButtonElement): void {
143+
const ripple = this._document.createElement('span');
144+
ripple.classList.add('mat-mdc-button-ripple');
145+
146+
const target = new MatButtonRippleTarget(
147+
button,
148+
this._globalRippleOptions,
149+
this._animationMode,
150+
);
151+
target.rippleConfig.centered = button.hasAttribute('mat-icon-button');
152+
153+
const rippleRenderer = new RippleRenderer(target, this._ngZone, ripple, this._platform);
154+
rippleRenderer.setupTriggerEvents(button);
155+
156+
button.append(ripple);
157+
}
158+
159+
private _createSpan(className: string): HTMLElement {
160+
const span = this._document.createElement('span');
161+
span.className = className;
162+
return span;
163+
}
164+
165+
private _getPersistentRippleClassName(button: Element): string {
166+
const baseClass = 'mat-mdc-button-persistent-ripple';
167+
if (button.getAttribute('data-mat-button-is-fab') === 'true') {
168+
return baseClass + ' mdc-fab__ripple';
169+
}
170+
if (button.hasAttribute('mat-icon-button')) {
171+
return baseClass + ' mdc-icon-button__ripple';
172+
}
173+
return baseClass + ' mdc-button__ripple';
174+
}
175+
}
176+
177+
class MatButtonRippleTarget implements RippleTarget {
178+
rippleConfig: RippleConfig & RippleGlobalOptions;
179+
180+
constructor(
181+
private _button: HTMLButtonElement,
182+
private _globalRippleOptions?: RippleGlobalOptions,
183+
animationMode?: string,
184+
) {
185+
this._setRippleConfig(_globalRippleOptions, animationMode);
186+
}
187+
188+
private _setRippleConfig(globalRippleOptions?: RippleGlobalOptions, animationMode?: string) {
189+
this.rippleConfig = globalRippleOptions || {};
190+
if (animationMode === 'NoopAnimations') {
191+
this.rippleConfig.animation = {enterDuration: 0, exitDuration: 0};
192+
}
193+
}
194+
195+
get rippleDisabled(): boolean {
196+
return this._button.disabled || !!this._globalRippleOptions?.disabled;
197+
}
198+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 {
10+
ANIMATION_MODULE_TYPE,
11+
Directive,
12+
ElementRef,
13+
Inject,
14+
NgZone,
15+
Optional,
16+
} from '@angular/core';
17+
import {
18+
MAT_RIPPLE_GLOBAL_OPTIONS,
19+
MatRipple,
20+
RippleGlobalOptions,
21+
RippleRenderer,
22+
RippleTarget,
23+
} from '@angular/material/core';
24+
import {DOCUMENT} from '@angular/common';
25+
import {Platform} from '@angular/cdk/platform';
26+
import {MatButtonLazyLoader} from './button-lazy-loader';
27+
28+
/**
29+
* The MatButtonRipple directive is an extention of the MatRipple
30+
* which allows us to lazily append the DOM node for the button ripple.
31+
*
32+
* The MatButtonRipple directive allows us to not immediately attach the ripple node to the button by providing a separate render function
33+
*/
34+
@Directive({
35+
selector: '[mat-button-ripple], [matButtonRipple]',
36+
exportAs: 'matButtonRipple',
37+
standalone: true,
38+
})
39+
export class MatButtonRipple extends MatRipple {
40+
/** The host button element. */
41+
private _buttonEl: HTMLElement;
42+
43+
/** The element that the MatRipple is attached to. */
44+
private _rippleEl: HTMLElement;
45+
46+
/** Whether this ripple has already been attached. */
47+
_initialized = false;
48+
49+
constructor(
50+
_elementRef: ElementRef<HTMLElement>,
51+
readonly ngZone: NgZone,
52+
readonly platform: Platform,
53+
@Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalOptions?: RippleGlobalOptions,
54+
@Optional() @Inject(ANIMATION_MODULE_TYPE) _animationMode?: string,
55+
@Optional() @Inject(DOCUMENT) document?: Document,
56+
@Inject(MatButtonLazyLoader) private _rippleLoader?: MatButtonLazyLoader,
57+
) {
58+
super(_elementRef, ngZone, platform, globalOptions, _animationMode, document);
59+
this._buttonEl = _elementRef.nativeElement;
60+
}
61+
62+
/**
63+
* Creates a new RippleRenderer.
64+
* The MatButtonRipple overrides the MatRipple's createRippleRenderer so that we can change the element the ripple is attached to.
65+
*/
66+
protected override _createRippleRenderer(
67+
_: RippleTarget,
68+
__: NgZone,
69+
___: ElementRef,
70+
____: Platform,
71+
): RippleRenderer | undefined {
72+
return undefined;
73+
}
74+
75+
_renderInternals(): void {
76+
this._renderRipple();
77+
this._rippleLoader?._renderInternals(this._buttonEl as HTMLButtonElement);
78+
}
79+
80+
/** Initializes the event listeners of the ripple and attaches it to the button. */
81+
_renderRipple(): void {
82+
if (this._initialized) {
83+
return;
84+
}
85+
86+
// A ripple may have already been rendered by the MatButtonRippleLoader if
87+
// the user interacts with the button before the MatButton's ripple is referenced.
88+
const existingRipple = this._buttonEl.querySelector('.mat-mdc-button-ripple');
89+
if (existingRipple) {
90+
existingRipple.remove();
91+
}
92+
93+
this._rippleEl = this._document!.createElement('span');
94+
this._rippleEl.classList.add('mat-mdc-button-ripple');
95+
this._rippleRenderer = new RippleRenderer(this, this.ngZone, this._rippleEl, this.platform);
96+
this._rippleRenderer!.setupTriggerEvents(this._buttonEl);
97+
this._buttonEl.append(this._rippleEl);
98+
this._buttonEl.removeAttribute('mat-button-ripple-uninitialized');
99+
this._initialized = true;
100+
}
101+
}

src/material/button/button.html

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
<span
2-
class="mat-mdc-button-persistent-ripple"
3-
[class.mdc-button__ripple]="!_isFab"
4-
[class.mdc-fab__ripple]="_isFab"></span>
5-
61
<ng-content select=".material-icons:not([iconPositionEnd]), mat-icon:not([iconPositionEnd]), [matButtonIcon]:not([iconPositionEnd])">
72
</ng-content>
83

@@ -11,14 +6,4 @@
116
<ng-content select=".material-icons[iconPositionEnd], mat-icon[iconPositionEnd], [matButtonIcon][iconPositionEnd]">
127
</ng-content>
138

14-
<!--
15-
The indicator can't be directly on the button, because MDC uses ::before for high contrast
16-
indication and it can't be on the ripple, because it has a border radius and overflow: hidden.
17-
-->
18-
<span class="mat-mdc-focus-indicator"></span>
19-
20-
<span matRipple class="mat-mdc-button-ripple"
21-
[matRippleDisabled]="_isRippleDisabled()"
22-
[matRippleTrigger]="_elementRef.nativeElement"></span>
23-
249
<span class="mat-mdc-button-touch-target"></span>

0 commit comments

Comments
 (0)