Skip to content

Commit

Permalink
feat(material/button): make button ripples lazy
Browse files Browse the repository at this point in the history
  • Loading branch information
wagnermaciel committed Mar 28, 2023
1 parent 24fab99 commit 3d72aca
Show file tree
Hide file tree
Showing 14 changed files with 353 additions and 40 deletions.
23 changes: 20 additions & 3 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 {MatButtonRipple} from './button-ripple';

/** 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': 'true',
'[attr.data-mat-button-disabled]': '_isRippleDisabled()',
'[attr.data-mat-button-is-fab]': '_isFab',
};

/** List of classes to add to buttons instances based on host attribute selector. */
Expand Down Expand Up @@ -100,7 +103,18 @@ export class MatButtonBase
_isFab = false;

/** Reference to the MatRipple instance of the button. */
@ViewChild(MatRipple) ripple: MatRipple;
get ripple(): MatRipple {
if (this._ripple instanceof MatButtonRipple && !this._ripple._isAttached) {
this._ripple._renderRipple();
}
return this._ripple;
}
set ripple(v: MatRipple) {
this._ripple = v;
}

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

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

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

Expand All @@ -170,6 +184,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': 'true',
'[attr.data-mat-button-disabled]': '_isRippleDisabled()',
'[attr.data-mat-button-is-fab]': '_isFab',
};

/**
Expand Down
134 changes: 134 additions & 0 deletions src/material/button/button-lazy-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @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,
Inject,
Injectable,
NgZone,
OnDestroy,
Optional,
} from '@angular/core';
import {
MAT_RIPPLE_GLOBAL_OPTIONS,
RippleConfig,
RippleGlobalOptions,
RippleRenderer,
RippleTarget,
} from '@angular/material/core';
import {Platform} from '@angular/cdk/platform';

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

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

@Injectable({providedIn: 'root'})
export class MatButtonLazyLoader implements OnDestroy {
private _document: Document;

constructor(
private _platform: Platform,
private _ngZone: NgZone,
@Optional() @Inject(DOCUMENT) document: any,
@Optional() @Inject(ANIMATION_MODULE_TYPE) private _animationMode?: string,
@Optional()
@Inject(MAT_RIPPLE_GLOBAL_OPTIONS)
private _globalRippleOptions?: RippleGlobalOptions,
) {
this._document = document;

this._ngZone.runOutsideAngular(() => {
this._document.addEventListener('focus', this._onInteraction, OPTIONS);
this._document.addEventListener('click', this._onInteraction, OPTIONS);
this._document.addEventListener('mouseenter', this._onInteraction, OPTIONS);
this._document.addEventListener('touchstart', this._onInteraction, OPTIONS);
});
}

ngOnDestroy() {
this._ngZone.runOutsideAngular(() => {
this._document.removeEventListener('focus', this._onInteraction, OPTIONS);
this._document.removeEventListener('click', this._onInteraction, OPTIONS);
this._document.removeEventListener('mouseenter', this._onInteraction, OPTIONS);
this._document.removeEventListener('touchstart', this._onInteraction, OPTIONS);
});
}

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

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

const button = this._closest(event.target);
if (button) {
button.removeAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED);
this._appendRipple(button as HTMLButtonElement);
}
};

/**
* Traverses the element and its parents (heading toward the document root)
* until it finds a mat-button that has not been initialized.
*/
private _closest(element: Element): Element | null {
let el: Element | null = element;
while (el) {
if (el.hasAttribute(MAT_BUTTON_RIPPLE_UNINITIALIZED)) {
return el;
}
el = el.parentElement;
}
return null;
}

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

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

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

class MatButtonRippleTarget implements RippleTarget {
rippleConfig: RippleConfig & RippleGlobalOptions;

constructor(
private _button: HTMLButtonElement,
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.disabled || !!this._globalRippleOptions?.disabled;
}
}
97 changes: 97 additions & 0 deletions src/material/button/button-ripple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @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 {
ANIMATION_MODULE_TYPE,
Directive,
ElementRef,
Inject,
NgZone,
Optional,
} from '@angular/core';
import {
MAT_RIPPLE_GLOBAL_OPTIONS,
MatRipple,
RippleGlobalOptions,
RippleRenderer,
RippleTarget,
} from '@angular/material/core';
import {DOCUMENT} from '@angular/common';
import {Platform} from '@angular/cdk/platform';
import {MatButtonLazyLoader} from './button-lazy-loader';

/**
* The MatButtonRipple directive is an extention of the MatRipple
* which allows us to lazily append the DOM node for the button ripple.
*
* The MatButtonRipple directive allows us to not immediately attach the ripple node to the button by providing a separate render function
*/
@Directive({
selector: '[mat-button-ripple], [matButtonRipple]',
exportAs: 'matButtonRipple',
standalone: true,
})
export class MatButtonRipple extends MatRipple {
/** The host button element. */
private _buttonEl: HTMLElement;

/** The element that the MatRipple is attached to. */
private _rippleEl: HTMLElement;

/** Whether this ripple has already been attached. */
_isAttached = false;

constructor(
_elementRef: ElementRef<HTMLElement>,
readonly ngZone: NgZone,
readonly platform: Platform,
@Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalOptions?: RippleGlobalOptions,
@Optional() @Inject(ANIMATION_MODULE_TYPE) _animationMode?: string,
@Optional() @Inject(DOCUMENT) document?: Document,
@Inject(MatButtonLazyLoader) _rippleLoader?: MatButtonLazyLoader,
) {
super(_elementRef, ngZone, platform, globalOptions, _animationMode, document);
this._buttonEl = _elementRef.nativeElement;
this._buttonEl.classList.remove('mat-ripple');
}

/**
* Creates a new RippleRenderer.
* The MatButtonRipple overrides the MatRipple's createRippleRenderer so that we can change the element the ripple is attached to.
*/
protected override _createRippleRenderer(
_: RippleTarget,
__: NgZone,
___: ElementRef,
____: Platform,
): RippleRenderer | undefined {
return undefined;
}

/** Initializes the event listeners of the ripple and attaches it to the button. */
_renderRipple(): void {
if (this._isAttached) {
return;
}

// A ripple may have already been rendered by the MatButtonRippleLoader if
// the user interacts with the button before the MatButton's ripple is referenced.
const existingRipple = this._buttonEl.querySelector('.mat-mdc-button-ripple');
if (existingRipple) {
existingRipple.remove();
}

this._rippleEl = this._document!.createElement('span');
this._rippleEl.classList.add('mat-mdc-button-ripple');
this._rippleRenderer = new RippleRenderer(this, this.ngZone, this._rippleEl, this.platform);
this._rippleRenderer!.setupTriggerEvents(this._buttonEl);
this._buttonEl.append(this._rippleEl);
this._buttonEl.removeAttribute('mat-button-ripple-uninitialized');
this._isAttached = true;
}
}
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>
8 changes: 8 additions & 0 deletions src/material/button/button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@
}
}

.mat-mdc-button-ripple {
overflow: hidden;
}

.mat-mdc-button-base {
overflow: visible;
}

// 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
15 changes: 6 additions & 9 deletions src/material/button/button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
import {ApplicationRef, Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MatButtonModule, MatButton, MatFabDefaultOptions, MAT_FAB_DEFAULT_OPTIONS} from './index';
import {MatRipple, ThemePalette} from '@angular/material/core';
import {ThemePalette} from '@angular/material/core';
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
import {MatButtonRipple} from './button-ripple';

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

beforeEach(() => {
fixture = TestBed.createComponent(TestApp);
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
6 changes: 6 additions & 0 deletions src/material/button/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
NgZone,
Optional,
ViewEncapsulation,
inject,
} from '@angular/core';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';

Expand All @@ -26,6 +27,7 @@ import {
MatAnchorBase,
MatButtonBase,
} from './button-base';
import {MatButtonRipple} from './button-ripple';

/**
* Material Design button component. Users interact with a button to perform an action.
Expand All @@ -48,8 +50,10 @@ import {
exportAs: 'matButton',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [MatButtonRipple],
})
export class MatButton extends MatButtonBase {
override _ripple = inject(MatButtonRipple);
constructor(
elementRef: ElementRef,
platform: Platform,
Expand Down Expand Up @@ -79,8 +83,10 @@ export class MatButton extends MatButtonBase {
styleUrls: ['button.css', 'button-high-contrast.css'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [MatButtonRipple],
})
export class MatAnchor extends MatAnchorBase {
override _ripple = inject(MatButtonRipple);
constructor(
elementRef: ElementRef,
platform: Platform,
Expand Down
Loading

0 comments on commit 3d72aca

Please sign in to comment.