/** * @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.dev/license */ import {DOCUMENT} from '@angular/common'; import { ANIMATION_MODULE_TYPE, Injectable, Injector, NgZone, OnDestroy, inject, } from '@angular/core'; import { MAT_RIPPLE_GLOBAL_OPTIONS, RippleRenderer, RippleTarget, defaultRippleAnimationConfig, } from '../ripple'; import {Platform, _getEventTarget} from '@angular/cdk/platform'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** The options for the MatRippleLoader's event listeners. */ const eventListenerOptions = {capture: true}; /** * The events that should trigger the initialization of the ripple. * Note that we use `mousedown`, rather than `click`, for mouse devices because * we can't rely on `mouseenter` in the shadow DOM and `click` happens too late. */ const rippleInteractionEvents = ['focus', 'mousedown', '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. * * @docs-private */ @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); private _injector = inject(Injector); private _hosts = new Map< HTMLElement, {renderer: RippleRenderer; target: RippleTarget; hasSetUpEvents: boolean} >(); constructor() { this._ngZone.runOutsideAngular(() => { for (const event of rippleInteractionEvents) { this._document?.addEventListener(event, this._onInteraction, eventListenerOptions); } }); } ngOnDestroy(): void { const hosts = this._hosts.keys(); for (const host of hosts) { this.destroyRipple(host); } 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; disabled?: boolean; }, ): void { // Indicates that the ripple has not yet been rendered for this component. host.setAttribute(matRippleUninitialized, this._globalRippleOptions?.namespace ?? ''); // 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, ''); } if (config.disabled) { host.setAttribute(matRippleDisabled, ''); } } /** Sets the disabled state on the ripple instance corresponding to the given host element. */ setDisabled(host: HTMLElement, disabled: boolean): void { const ripple = this._hosts.get(host); // If the ripple has already been instantiated, just disable it. if (ripple) { ripple.target.rippleDisabled = disabled; if (!disabled && !ripple.hasSetUpEvents) { ripple.hasSetUpEvents = true; ripple.renderer.setupTriggerEvents(host); } } else if (disabled) { // Otherwise, set an attribute so we know what the // disabled state should be when the ripple is initialized. host.setAttribute(matRippleDisabled, ''); } else { host.removeAttribute(matRippleDisabled); } } /** * Handles creating and attaching component internals * when a component is initially interacted with. */ private _onInteraction = (event: Event) => { const eventTarget = _getEventTarget(event); if (eventTarget instanceof HTMLElement) { // TODO(wagnermaciel): Consider batching these events to improve runtime performance. const element = eventTarget.closest( `[${matRippleUninitialized}="${this._globalRippleOptions?.namespace ?? ''}"]`, ); if (element) { this._createRipple(element as HTMLElement); } } }; /** Creates a MatRipple and appends it to the given element. */ private _createRipple(host: HTMLElement): void { if (!this._document || this._hosts.has(host)) { 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); const isNoopAnimations = this._animationMode === 'NoopAnimations'; const globalOptions = this._globalRippleOptions; const enterDuration = isNoopAnimations ? 0 : globalOptions?.animation?.enterDuration ?? defaultRippleAnimationConfig.enterDuration; const exitDuration = isNoopAnimations ? 0 : globalOptions?.animation?.exitDuration ?? defaultRippleAnimationConfig.exitDuration; const target: RippleTarget = { rippleDisabled: isNoopAnimations || globalOptions?.disabled || host.hasAttribute(matRippleDisabled), rippleConfig: { centered: host.hasAttribute(matRippleCentered), terminateOnPointerUp: globalOptions?.terminateOnPointerUp, animation: { enterDuration, exitDuration, }, }, }; const renderer = new RippleRenderer( target, this._ngZone, rippleEl, this._platform, this._injector, ); const hasSetUpEvents = !target.rippleDisabled; if (hasSetUpEvents) { renderer.setupTriggerEvents(host); } this._hosts.set(host, { target, renderer, hasSetUpEvents, }); host.removeAttribute(matRippleUninitialized); } destroyRipple(host: HTMLElement): void { const ripple = this._hosts.get(host); if (ripple) { ripple.renderer._removeTriggerEvents(); this._hosts.delete(host); } } }