From 18400e7f36f0ea6cc06d0f11b1ce09e207442543 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 12 Jun 2023 10:57:52 -0400 Subject: [PATCH] refactor(material/core): generalize lazy ripple logic (#26897) * refactor(material/core): generalize lazy ripple logic * fixup! refactor(material/core): generalize lazy ripple logic * fixup! refactor(material/core): generalize lazy ripple logic * fixup! refactor(material/core): generalize lazy ripple logic * fixup! refactor(material/core): generalize lazy ripple logic --- .github/CODEOWNERS | 1 + src/material/button/button-base.ts | 57 ++++--- src/material/button/button-lazy-loader.ts | 151 ------------------ src/material/button/button.spec.ts | 11 -- src/material/button/icon-button.ts | 16 +- src/material/core/private/index.ts | 9 ++ src/material/core/private/ripple-loader.ts | 168 +++++++++++++++++++++ src/material/core/public-api.ts | 1 + tools/public_api_guard/material/button.md | 4 +- tools/public_api_guard/material/core.md | 20 +++ 10 files changed, 240 insertions(+), 198 deletions(-) delete mode 100644 src/material/button/button-lazy-loader.ts create mode 100644 src/material/core/private/index.ts create mode 100644 src/material/core/private/ripple-loader.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db0983f2611a..39f97fa15a46 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -76,6 +76,7 @@ /src/material/core/mdc-helpers/** @mmalerba /src/material/core/option/** @crisbeto /src/material/core/placeholder/** @mmalerba +/src/material/core/private/** @wagnermaciel /src/material/core/ripple/** @devversion /src/material/core/selection/** @andrewseguin /src/material/core/selection/pseudo*/** @crisbeto @andrewseguin diff --git a/src/material/button/button-base.ts b/src/material/button/button-base.ts index 48df1b686f66..d2a79e699c76 100644 --- a/src/material/button/button-base.ts +++ b/src/material/button/button-base.ts @@ -7,6 +7,7 @@ */ import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; import { AfterViewInit, @@ -14,7 +15,6 @@ import { ElementRef, inject, NgZone, - OnChanges, OnDestroy, OnInit, } from '@angular/core'; @@ -26,8 +26,8 @@ import { mixinColor, mixinDisabled, mixinDisableRipple, + MatRippleLoader, } 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']; @@ -43,7 +43,6 @@ 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]: '', }; /** List of classes to add to buttons instances based on host attribute selector. */ @@ -94,7 +93,7 @@ export const _MatButtonMixin = mixinColor( @Directive() export class MatButtonBase extends _MatButtonMixin - implements CanDisable, CanColor, CanDisableRipple, AfterViewInit, OnChanges, OnDestroy + implements CanDisable, CanColor, CanDisableRipple, AfterViewInit, OnDestroy { private readonly _focusMonitor = inject(FocusMonitor); @@ -102,7 +101,7 @@ export class MatButtonBase * Handles the lazy creation of the MatButton ripple. * Used to improve initial load time of large applications. */ - _rippleLoader: MatButtonLazyLoader = inject(MatButtonLazyLoader); + _rippleLoader: MatRippleLoader = inject(MatRippleLoader); /** Whether this button is a FAB. Used to apply the correct class on the ripple. */ _isFab = false; @@ -113,17 +112,33 @@ export class MatButtonBase * @breaking-change 17.0.0 */ get ripple(): MatRipple { - if (!this._ripple && this._rippleLoader) { - this._ripple = this._rippleLoader._createMatRipple(this._elementRef.nativeElement); - } - return this._ripple!; + return this._rippleLoader?.getRipple(this._elementRef.nativeElement)!; } set ripple(v: MatRipple) { - this._ripple = v; + this._rippleLoader?.attachRipple(this._elementRef.nativeElement, v); } - /** @docs-private Reference to the MatRipple instance of the button. */ - protected _ripple?: MatRipple; + // We override `disableRipple` and `disabled` so we can hook into + // their setters and update the ripple disabled state accordingly. + + /** Whether the ripple effect is disabled or not. */ + override get disableRipple(): boolean { + return this._disableRipple; + } + override set disableRipple(value: any) { + this._disableRipple = coerceBooleanProperty(value); + this._updateRippleDisabled(); + } + private _disableRipple: boolean = false; + + override get disabled(): boolean { + return this._disabled; + } + override set disabled(value: any) { + this._disabled = coerceBooleanProperty(value); + this._updateRippleDisabled(); + } + private _disabled: boolean = false; constructor( elementRef: ElementRef, @@ -133,6 +148,10 @@ export class MatButtonBase ) { super(elementRef); + this._rippleLoader?.configureRipple(this._elementRef.nativeElement, { + className: 'mat-mdc-button-ripple', + }); + const classList = (elementRef.nativeElement as HTMLElement).classList; // For each of the variant selectors that is present in the button's host @@ -150,12 +169,6 @@ export class MatButtonBase this._focusMonitor.monitor(this._elementRef, true); } - ngOnChanges() { - if (this._ripple) { - this._ripple.disabled = this.disableRipple || this.disabled; - } - } - ngOnDestroy() { this._focusMonitor.stopMonitoring(this._elementRef); } @@ -173,6 +186,13 @@ export class MatButtonBase private _hasHostAttributes(...attributes: string[]) { return attributes.some(attribute => this._elementRef.nativeElement.hasAttribute(attribute)); } + + private _updateRippleDisabled(): void { + this._rippleLoader?.setDisabled( + this._elementRef.nativeElement, + this.disableRipple || this.disabled, + ); + } } /** Shared inputs by buttons using the `` tag */ @@ -195,7 +215,6 @@ 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]: '', }; /** diff --git a/src/material/button/button-lazy-loader.ts b/src/material/button/button-lazy-loader.ts deleted file mode 100644 index 8eca9ed67595..000000000000 --- a/src/material/button/button-lazy-loader.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @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; - } -} diff --git a/src/material/button/button.spec.ts b/src/material/button/button.spec.ts index a2e024620d09..2f381b4c7e19 100644 --- a/src/material/button/button.spec.ts +++ b/src/material/button/button.spec.ts @@ -4,7 +4,6 @@ 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(() => { @@ -317,9 +316,6 @@ describe('MDC-based MatButton', () => { 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(); @@ -328,11 +324,6 @@ describe('MDC-based MatButton', () => { 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. @@ -341,12 +332,10 @@ describe('MDC-based MatButton', () => { 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(); }); } }); diff --git a/src/material/button/icon-button.ts b/src/material/button/icon-button.ts index ffd46f80e14f..35219ade69a3 100644 --- a/src/material/button/icon-button.ts +++ b/src/material/button/icon-button.ts @@ -26,7 +26,6 @@ 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 @@ -44,19 +43,6 @@ import {MatRipple} from '@angular/material/core'; 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, @@ -64,6 +50,8 @@ export class MatIconButton extends MatButtonBase { @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, ) { super(elementRef, platform, ngZone, animationMode); + + this._rippleLoader.configureRipple(this._elementRef.nativeElement, {centered: true}); } } diff --git a/src/material/core/private/index.ts b/src/material/core/private/index.ts new file mode 100644 index 000000000000..f52d1d17f233 --- /dev/null +++ b/src/material/core/private/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export {MatRippleLoader} from './ripple-loader'; diff --git a/src/material/core/private/ripple-loader.ts b/src/material/core/private/ripple-loader.ts new file mode 100644 index 000000000000..41fca0c6b432 --- /dev/null +++ b/src/material/core/private/ripple-loader.ts @@ -0,0 +1,168 @@ +/** + * @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} from '../ripple'; +import {Platform} from '@angular/cdk/platform'; + +/** The options for the MatRippleLoader'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 component whose ripple has not yet been initialized. */ +const matRippleUninitialized = 'mat-ripple-loader-uninitialized'; + +/** Additional classes that should be added to the ripple when it is rendered. */ +const matRippleClassName = 'mat-ripple-loader-class-name'; + +/** Whether the ripple should be centered. */ +const matRippleCentered = 'mat-ripple-loader-centered'; + +/** Whether the ripple should be disabled. */ +const matRippleDisabled = 'mat-ripple-loader-disabled'; + +/** + * Handles attaching ripples on demand. + * + * This service allows us to avoid eagerly creating & attaching MatRipples. + * It works by creating & attaching a ripple only when a component is first interacted with. + */ +@Injectable({providedIn: 'root'}) +export class MatRippleLoader 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); + } + } + + /** + * Configures the ripple that will be rendered by the ripple loader. + * + * Stores the given information about how the ripple should be configured on the host + * element so that it can later be retrived & used when the ripple is actually created. + */ + configureRipple( + host: HTMLElement, + config: { + className?: string; + centered?: boolean; + }, + ): void { + // Indicates that the ripple has not yet been rendered for this component. + host.setAttribute(matRippleUninitialized, ''); + + // Store the additional class name(s) that should be added to the ripple element. + if (config.className || !host.hasAttribute(matRippleClassName)) { + host.setAttribute(matRippleClassName, config.className || ''); + } + + // Store whether the ripple should be centered. + if (config.centered) { + host.setAttribute(matRippleCentered, ''); + } + } + + /** Returns the ripple instance for the given host element. */ + getRipple(host: HTMLElement): MatRipple | undefined { + if ((host as any).matRipple) { + return (host as any).matRipple; + } + return this.createRipple(host); + } + + /** Sets the disabled state on the ripple instance corresponding to the given host element. */ + setDisabled(host: HTMLElement, disabled: boolean): void { + const ripple = (host as any).matRipple as MatRipple | undefined; + + // If the ripple has already been instantiated, just disable it. + if (ripple) { + ripple.disabled = disabled; + return; + } + + // Otherwise, set an attribute so we know what the + // disabled state should be when the ripple is initialized. + if (disabled) { + host.setAttribute(matRippleDisabled, ''); + } else { + host.removeAttribute(matRippleDisabled); + } + } + + /** Handles creating and attaching component internals when a component it is initially interacted with. */ + private _onInteraction = (event: Event) => { + if (!(event.target instanceof HTMLElement)) { + return; + } + const eventTarget = event.target as HTMLElement; + + // TODO(wagnermaciel): Consider batching these events to improve runtime performance. + + const element = eventTarget.closest(`[${matRippleUninitialized}]`); + if (element) { + this.createRipple(element as HTMLElement); + } + }; + + /** Creates a MatRipple and appends it to the given element. */ + createRipple(host: HTMLElement): MatRipple | undefined { + if (!this._document) { + return; + } + + // Create the ripple element. + host.querySelector('.mat-ripple')?.remove(); + const rippleEl = this._document!.createElement('span'); + rippleEl.classList.add('mat-ripple', host.getAttribute(matRippleClassName)!); + host.append(rippleEl); + + // Create the MatRipple. + 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 = host; + ripple.centered = host.hasAttribute(matRippleCentered); + ripple.disabled = host.hasAttribute(matRippleDisabled); + this.attachRipple(host, ripple); + return ripple; + } + + attachRipple(host: Element, ripple: MatRipple): void { + host.removeAttribute(matRippleUninitialized); + (host as any).matRipple = ripple; + } +} diff --git a/src/material/core/public-api.ts b/src/material/core/public-api.ts index 4a1837c9cb07..e55112a249a2 100644 --- a/src/material/core/public-api.ts +++ b/src/material/core/public-api.ts @@ -13,5 +13,6 @@ export * from './datetime/index'; export * from './error/error-options'; export * from './line/line'; export * from './option/index'; +export * from './private/index'; export * from './ripple/index'; export * from './selection/index'; diff --git a/tools/public_api_guard/material/button.md b/tools/public_api_guard/material/button.md index 55300d822906..ac22c61f3eee 100644 --- a/tools/public_api_guard/material/button.md +++ b/tools/public_api_guard/material/button.md @@ -17,8 +17,8 @@ import * as i0 from '@angular/core'; import * as i4 from '@angular/material/core'; import { InjectionToken } from '@angular/core'; import { MatRipple } from '@angular/material/core'; +import { MatRippleLoader } from '@angular/material/core'; import { NgZone } from '@angular/core'; -import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; @@ -104,8 +104,6 @@ export class MatIconAnchor extends MatAnchorBase { // @public export class MatIconButton extends MatButtonBase { constructor(elementRef: ElementRef, platform: Platform, ngZone: NgZone, animationMode?: string); - // @deprecated - get ripple(): MatRipple; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) diff --git a/tools/public_api_guard/material/core.md b/tools/public_api_guard/material/core.md index 5c1005b5f7ef..a131f137ed0e 100644 --- a/tools/public_api_guard/material/core.md +++ b/tools/public_api_guard/material/core.md @@ -395,6 +395,26 @@ export class MatRipple implements OnInit, OnDestroy, RippleTarget { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export class MatRippleLoader implements OnDestroy { + constructor(); + // (undocumented) + attachRipple(host: Element, ripple: MatRipple): void; + configureRipple(host: HTMLElement, config: { + className?: string; + centered?: boolean; + }): void; + createRipple(host: HTMLElement): MatRipple | undefined; + getRipple(host: HTMLElement): MatRipple | undefined; + // (undocumented) + ngOnDestroy(): void; + setDisabled(host: HTMLElement, disabled: boolean): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + // @public (undocumented) export class MatRippleModule { // (undocumented)