Skip to content

Commit

Permalink
feat(material/button): make button ripples lazy (#26568)
Browse files Browse the repository at this point in the history
  • Loading branch information
wagnermaciel committed Apr 5, 2023
1 parent 6c93592 commit d6d3e3e
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 21 deletions.
37 changes: 33 additions & 4 deletions src/material/button/button-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
NgZone,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import {
CanColor,
Expand All @@ -27,6 +26,7 @@ import {
mixinDisabled,
mixinDisableRipple,
} from '@angular/material/core';
import {MAT_BUTTON_RIPPLE_UNINITIALIZED, MatButtonLazyLoader} from './button-lazy-loader';

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

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

/**
* Handles the lazy creation of the MatButton ripple.
* Used to improve initial load time of large applications.
*/
_rippleLoader: MatButtonLazyLoader = inject(MatButtonLazyLoader);

/** Whether this button is a FAB. Used to apply the correct class on the ripple. */
_isFab = false;

/** Reference to the MatRipple instance of the button. */
@ViewChild(MatRipple) ripple: MatRipple;
/**
* Reference to the MatRipple instance of the button.
* @deprecated Considered an implementation detail. To be removed.
* @breaking-change 17.0.0
*/
get ripple(): MatRipple {
if (!this._ripple && this._rippleLoader) {
this._ripple = this._rippleLoader._createMatRipple(this._elementRef.nativeElement);
}
return this._ripple!;
}
set ripple(v: MatRipple) {
this._ripple = v;
}

/** @docs-private Reference to the MatRipple instance of the button. */
protected _ripple?: MatRipple;

constructor(
elementRef: ElementRef,
Expand Down Expand Up @@ -146,7 +170,9 @@ export class MatButtonBase
}

_isRippleDisabled() {
return this.disableRipple || this.disabled;
if (this._ripple) {
this._ripple.disabled = this.disableRipple || this.disabled;
}
}
}

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

/**
Expand Down
151 changes: 151 additions & 0 deletions src/material/button/button-lazy-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {DOCUMENT} from '@angular/common';
import {
ANIMATION_MODULE_TYPE,
ElementRef,
Injectable,
NgZone,
OnDestroy,
inject,
} from '@angular/core';
import {
MAT_RIPPLE_GLOBAL_OPTIONS,
MatRipple,
RippleConfig,
RippleGlobalOptions,
RippleRenderer,
RippleTarget,
} from '@angular/material/core';
import {Platform} from '@angular/cdk/platform';

/** The options for the MatButtonRippleLoader's event listeners. */
const eventListenerOptions = {capture: true};

/** The events that should trigger the initialization of the ripple. */
const rippleInteractionEvents = ['focus', 'click', 'mouseenter', 'touchstart'];

/** The attribute attached to a mat-button whose ripple has not yet been initialized. */
export const MAT_BUTTON_RIPPLE_UNINITIALIZED = 'mat-button-ripple-uninitialized';

/**
* Handles attaching the MatButton's ripple on demand.
*
* This service allows us to avoid eagerly creating & attaching the MatButton's ripple.
* It works by creating & attaching the ripple only when a MatButton is first interacted with.
*/
@Injectable({providedIn: 'root'})
export class MatButtonLazyLoader implements OnDestroy {
private _document = inject(DOCUMENT, {optional: true});
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
private _globalRippleOptions = inject(MAT_RIPPLE_GLOBAL_OPTIONS, {optional: true});
private _platform = inject(Platform);
private _ngZone = inject(NgZone);

constructor() {
this._ngZone.runOutsideAngular(() => {
for (const event of rippleInteractionEvents) {
this._document?.addEventListener(event, this._onInteraction, eventListenerOptions);
}
});
}

ngOnDestroy() {
for (const event of rippleInteractionEvents) {
this._document?.removeEventListener(event, this._onInteraction, eventListenerOptions);
}
}

/** Handles creating and attaching button internals when a button is initially interacted with. */
private _onInteraction = (event: Event) => {
if (event.target === this._document) {
return;
}
const eventTarget = event.target as Element;

// TODO(wagnermaciel): Consider batching these events to improve runtime performance.

const button = eventTarget.closest(`[${MAT_BUTTON_RIPPLE_UNINITIALIZED}]`);
if (button) {
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
this._appendRipple(button as HTMLElement);
}
};

/** Creates a MatButtonRipple and appends it to the given button element. */
private _appendRipple(button: HTMLElement): void {
if (!this._document) {
return;
}
const ripple = this._document.createElement('span');
ripple.classList.add('mat-mdc-button-ripple');

const target = new MatButtonRippleTarget(
button,
this._globalRippleOptions ? this._globalRippleOptions : undefined,
this._animationMode ? this._animationMode : undefined,
);
target.rippleConfig.centered = button.hasAttribute('mat-icon-button');

const rippleRenderer = new RippleRenderer(target, this._ngZone, ripple, this._platform);
rippleRenderer.setupTriggerEvents(button);
button.append(ripple);
}

_createMatRipple(button: HTMLElement): MatRipple | undefined {
if (!this._document) {
return;
}
button.querySelector('.mat-mdc-button-ripple')?.remove();
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
const rippleEl = this._document!.createElement('span');
rippleEl.classList.add('mat-mdc-button-ripple');
const ripple = new MatRipple(
new ElementRef(rippleEl),
this._ngZone,
this._platform,
this._globalRippleOptions ? this._globalRippleOptions : undefined,
this._animationMode ? this._animationMode : undefined,
);
ripple._isInitialized = true;
ripple.trigger = button;
button.append(rippleEl);
return ripple;
}
}

/**
* The RippleTarget for the lazily rendered MatButton ripple.
* It handles ripple configuration and disabled state for ripples interactions.
*
* Note that this configuration is usually handled by the MatRipple, but the MatButtonLazyLoader does not use the
* MatRipple Directive. In order to create & attach a ripple on demand, it uses the "lower level" RippleRenderer.
*/
class MatButtonRippleTarget implements RippleTarget {
rippleConfig: RippleConfig & RippleGlobalOptions;

constructor(
private _button: HTMLElement,
private _globalRippleOptions?: RippleGlobalOptions,
animationMode?: string,
) {
this._setRippleConfig(_globalRippleOptions, animationMode);
}

private _setRippleConfig(globalRippleOptions?: RippleGlobalOptions, animationMode?: string) {
this.rippleConfig = globalRippleOptions || {};
if (animationMode === 'NoopAnimations') {
this.rippleConfig.animation = {enterDuration: 0, exitDuration: 0};
}
}

get rippleDisabled(): boolean {
return this._button.hasAttribute('disabled') || !!this._globalRippleOptions?.disabled;
}
}
4 changes: 0 additions & 4 deletions src/material/button/button.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,4 @@
-->
<span class="mat-mdc-focus-indicator"></span>

<span matRipple class="mat-mdc-button-ripple"
[matRippleDisabled]="_isRippleDisabled()"
[matRippleTrigger]="_elementRef.nativeElement"></span>

<span class="mat-mdc-button-touch-target"></span>
6 changes: 6 additions & 0 deletions src/material/button/button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@
}
}

// This style used to be applied by the MatRipple
// directive, which is no longer attached to this element.
.mat-mdc-button-ripple {
overflow: hidden;
}

// Since the stroked button has has an actual border that reduces the available space for
// child elements such as the ripple container or focus overlay, an inherited border radius
// for the absolute-positioned child elements does not work properly. This is because the
Expand Down
46 changes: 40 additions & 6 deletions src/material/button/button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {By} from '@angular/platform-browser';
import {MatButtonModule, MatButton, MatFabDefaultOptions, MAT_FAB_DEFAULT_OPTIONS} from './index';
import {MatRipple, ThemePalette} from '@angular/material/core';
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
import {MAT_BUTTON_RIPPLE_UNINITIALIZED} from './button-lazy-loader';

describe('MDC-based MatButton', () => {
beforeEach(waitForAsync(() => {
Expand Down Expand Up @@ -267,10 +268,8 @@ describe('MDC-based MatButton', () => {
let fixture: ComponentFixture<TestApp>;
let testComponent: TestApp;
let buttonDebugElement: DebugElement;
let buttonRippleDebugElement: DebugElement;
let buttonRippleInstance: MatRipple;
let anchorDebugElement: DebugElement;
let anchorRippleDebugElement: DebugElement;
let anchorRippleInstance: MatRipple;

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

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

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

it('should disable the ripple if matRippleDisabled input is set', () => {
Expand Down Expand Up @@ -315,6 +312,43 @@ describe('MDC-based MatButton', () => {
'Expected a disabled a[mat-button] not to have an enabled ripple',
);
});

it('should render the ripple once it is referenced', () => {
const fab = fixture.debugElement.query(By.css('button[mat-fab]'))!;
let ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
expect(ripple).withContext('Expect ripple to be absent before user interaction').toBeNull();
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED))
.withContext('Expect mat-button to have the "uninitialized" attr before user interaction')
.toBeTrue();

// Referencing the ripple should instantiate the ripple.
expect(fab.componentInstance.ripple).toBeDefined();

ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
expect(ripple)
.withContext('Expect ripple to be present after user interaction')
.not.toBeNull();
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED))
.withContext(
'Expect mat-button NOT to have the "uninitialized" attr after user interaction',
)
.toBeFalse();
});

// Ensure each of these events triggers the initialization of the button ripple.
for (const event of ['click', 'touchstart', 'mouseenter', 'focus']) {
it(`should render the ripple once a button has received a "${event}" event`, () => {
const fab = fixture.debugElement.query(By.css('button[mat-fab]'))!;
let ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
expect(ripple).toBeNull();
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)).toBeTrue();

dispatchEvent(fab.nativeElement, createMouseEvent(event));
ripple = fab.nativeElement.querySelector('.mat-mdc-button-ripple');
expect(ripple).not.toBeNull();
expect(fab.nativeElement.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)).toBeFalse();
});
}
});

it('should have a focus indicator', () => {
Expand Down
5 changes: 0 additions & 5 deletions src/material/button/icon-button.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,4 @@
-->
<span class="mat-mdc-focus-indicator"></span>

<span matRipple class="mat-mdc-button-ripple"
[matRippleDisabled]="_isRippleDisabled()"
[matRippleCentered]="true"
[matRippleTrigger]="_elementRef.nativeElement"></span>

<span class="mat-mdc-button-touch-target"></span>
6 changes: 6 additions & 0 deletions src/material/button/icon-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
@include button-base.mat-private-button-touch-target(true);
@include private.private-animation-noop();

// This style used to be applied by the MatRipple
// directive, which is no longer attached to this element.
.mat-mdc-button-ripple {
overflow: hidden;
}

.mat-mdc-button-persistent-ripple {
border-radius: 50%;
}
Expand Down
14 changes: 14 additions & 0 deletions src/material/button/icon-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
MatAnchorBase,
MatButtonBase,
} from './button-base';
import {MatRipple} from '@angular/material/core';

/**
* Material Design icon button component. This type of button displays a single interactive icon for
Expand All @@ -43,6 +44,19 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatIconButton extends MatButtonBase {
/**
* Reference to the MatRipple instance of the button.
* @deprecated Considered an implementation detail. To be removed.
* @breaking-change 17.0.0
*/
override get ripple(): MatRipple {
if (!this._ripple && this._rippleLoader) {
this._ripple = this._rippleLoader._createMatRipple(this._elementRef.nativeElement);
this._ripple!.centered = true;
}
return this._ripple!;
}

constructor(
elementRef: ElementRef,
platform: Platform,
Expand Down
4 changes: 2 additions & 2 deletions src/material/core/ripple/ripple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ export class MatRipple implements OnInit, OnDestroy, RippleTarget {
/** Options that are set globally for all ripples. */
private _globalOptions: RippleGlobalOptions;

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

constructor(
private _elementRef: ElementRef<HTMLElement>,
Expand Down
Loading

0 comments on commit d6d3e3e

Please sign in to comment.