Skip to content

fix(ripple): not fading out on touch devices #12488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions src/material-experimental/mdc-list/list.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import {waitForAsync, TestBed, fakeAsync, tick} from '@angular/core/testing';
import {fakeAsync, TestBed, waitForAsync} from '@angular/core/testing';
import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
import {Component, QueryList, ViewChildren} from '@angular/core';
import {defaultRippleAnimationConfig} from '@angular/material-experimental/mdc-core';
import {dispatchMouseEvent} from '../../cdk/testing/private';
import {By} from '@angular/platform-browser';
import {MatListItem, MatListModule} from './index';

describe('MDC-based MatList', () => {
// Default ripple durations used for testing.
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;

beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
Expand Down Expand Up @@ -243,12 +239,16 @@ describe('MDC-based MatList', () => {
dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand All @@ -273,12 +273,16 @@ describe('MDC-based MatList', () => {
dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand Down
12 changes: 7 additions & 5 deletions src/material-experimental/mdc-list/selection-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
waitForAsync,
} from '@angular/core/testing';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
import {defaultRippleAnimationConfig, ThemePalette} from '@angular/material-experimental/mdc-core';
import {ThemePalette} from '@angular/material-experimental/mdc-core';
import {By} from '@angular/platform-browser';
import {numbers} from '@material/list';
import {
Expand Down Expand Up @@ -612,17 +612,19 @@ describe('MDC-based MatSelectionList without forms', () => {
const rippleTarget = fixture.nativeElement.querySelector(
'.mat-mdc-list-option:not(.mdc-list-item--disabled)',
);
const {enterDuration, exitDuration} = defaultRippleAnimationConfig;

dispatchMouseEvent(rippleTarget, 'mousedown');
dispatchMouseEvent(rippleTarget, 'mouseup');

// Flush the ripple enter animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to be enabled by default.')
.toBe(1);

// Wait for the ripples to go away.
tick(enterDuration + exitDuration);
// Flush the ripple exit animation.
dispatchFakeEvent(rippleTarget.querySelector('.mat-ripple-element')!, 'transitionend');

expect(rippleTarget.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected ripples to go away.')
.toBe(0);
Expand Down
20 changes: 10 additions & 10 deletions src/material-experimental/mdc-slider/slider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,13 @@
import {BidiModule, Directionality} from '@angular/cdk/bidi';
import {Platform} from '@angular/cdk/platform';
import {
dispatchFakeEvent,
dispatchMouseEvent,
dispatchPointerEvent,
dispatchTouchEvent,
} from '../../cdk/testing/private';
import {Component, Provider, QueryList, Type, ViewChild, ViewChildren} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
flush,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {By} from '@angular/platform-browser';
import {Thumb} from '@material/slider';
Expand Down Expand Up @@ -297,8 +291,14 @@ describe('MDC-based MatSlider', () => {
);

function isRippleVisible(selector: string) {
tick(500);
return !!document.querySelector(`.mat-mdc-slider-${selector}-ripple`);
flushRippleTransitions();
return thumbElement.querySelector(`.mat-mdc-slider-${selector}-ripple`) !== null;
}

function flushRippleTransitions() {
thumbElement.querySelectorAll('.mat-ripple-element').forEach(el => {
dispatchFakeEvent(el, 'transitionend');
});
}

function blur() {
Expand Down
1 change: 0 additions & 1 deletion src/material-experimental/mdc-tabs/tab-group.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ describe('MDC-based MatTabGroup', () => {
.toBe(0);

dispatchFakeEvent(tabLabel.nativeElement, 'mousedown');
dispatchFakeEvent(tabLabel.nativeElement, 'mouseup');

expect(testElement.querySelectorAll('.mat-ripple-element').length)
.withContext('Expected one ripple to show up on label mousedown.')
Expand Down
2 changes: 2 additions & 0 deletions src/material/core/ripple/ripple-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export class RippleRef {
public element: HTMLElement,
/** Ripple configuration used for the ripple. */
public config: RippleConfig,
/* Whether animations are forcibly disabled for ripples through CSS. */
public _animationForciblyDisabledThroughCss = false,
) {}

/** Fades out the ripple element. */
Expand Down
118 changes: 78 additions & 40 deletions src/material/core/ripple/ripple-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class RippleRenderer implements EventListenerObject {
const radius = config.radius || distanceToFurthestCorner(x, y, containerRect);
const offsetX = x - containerRect.left;
const offsetY = y - containerRect.top;
const duration = animationConfig.enterDuration;
const enterDuration = animationConfig.enterDuration;

const ripple = document.createElement('div');
ripple.classList.add('mat-ripple-element');
Expand All @@ -130,21 +130,38 @@ export class RippleRenderer implements EventListenerObject {
ripple.style.backgroundColor = config.color;
}

ripple.style.transitionDuration = `${duration}ms`;
ripple.style.transitionDuration = `${enterDuration}ms`;

this._containerElement.appendChild(ripple);

// By default the browser does not recalculate the styles of dynamically created
// ripple elements. This is critical because then the `scale` would not animate properly.
enforceStyleRecalculation(ripple);
// ripple elements. This is critical to ensure that the `scale` animates properly.
// We enforce a style recalculation by calling `getComputedStyle` and *accessing* a property.
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const computedStyles = window.getComputedStyle(ripple);
const userTransitionProperty = computedStyles.transitionProperty;
const userTransitionDuration = computedStyles.transitionDuration;

// Note: We detect whether animation is forcibly disabled through CSS by the use of
// `transition: none`. This is technically unexpected since animations are controlled
// through the animation config, but this exists for backwards compatibility. This logic does
// not need to be super accurate since it covers some edge cases which can be easily avoided by users.
const animationForciblyDisabledThroughCss =
userTransitionProperty === 'none' ||
// Note: The canonical unit for serialized CSS `<time>` properties is seconds. Additionally
// some browsers expand the duration for every property (in our case `opacity` and `transform`).
userTransitionDuration === '0s' ||
userTransitionDuration === '0s, 0s';

// We use a 3d transform here in order to avoid an issue in Safari where
// Exposed reference to the ripple that will be returned.
const rippleRef = new RippleRef(this, ripple, config, animationForciblyDisabledThroughCss);

// Start the enter animation by setting the transform/scale to 100%. The animation will
// execute as part of this statement because we forced a style recalculation before.
// Note: We use a 3d transform here in order to avoid an issue in Safari where
// the ripples aren't clipped when inside the shadow DOM (see #24028).
ripple.style.transform = 'scale3d(1, 1, 1)';

// Exposed reference to the ripple that will be returned.
const rippleRef = new RippleRef(this, ripple, config);

rippleRef.state = RippleState.FADING_IN;

// Add the ripple reference to the list of all active ripples.
Expand All @@ -154,21 +171,19 @@ export class RippleRenderer implements EventListenerObject {
this._mostRecentTransientRipple = rippleRef;
}

// Wait for the ripple element to be completely faded in.
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
this._runTimeoutOutsideZone(() => {
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;

rippleRef.state = RippleState.VISIBLE;
// Do not register the `transition` event listener if fade-in and fade-out duration
// are set to zero. The events won't fire anyway and we can save resources here.
if (!animationForciblyDisabledThroughCss && (enterDuration || animationConfig.exitDuration)) {
this._ngZone.runOutsideAngular(() => {
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
});
}

// When the timer runs out while the user has kept their pointer down, we want to
// keep only the persistent ripples and the latest transient ripple. We do this,
// because we don't want stacked transient ripples to appear after their enter
// animation has finished.
if (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
rippleRef.fadeOut();
}
}, duration);
// In case there is no fade-in transition duration, we need to manually call the transition
// end listener because `transitionend` doesn't fire if there is no transition.
if (animationForciblyDisabledThroughCss || !enterDuration) {
this._finishRippleTransition(rippleRef);
}

return rippleRef;
}
Expand All @@ -194,15 +209,17 @@ export class RippleRenderer implements EventListenerObject {
const rippleEl = rippleRef.element;
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};

// This starts the fade-out transition and will fire the transition end listener that
// removes the ripple element from the DOM.
rippleEl.style.transitionDuration = `${animationConfig.exitDuration}ms`;
rippleEl.style.opacity = '0';
rippleRef.state = RippleState.FADING_OUT;

// Once the ripple faded out, the ripple can be safely removed from the DOM.
this._runTimeoutOutsideZone(() => {
rippleRef.state = RippleState.HIDDEN;
rippleEl.remove();
}, animationConfig.exitDuration);
// In case there is no fade-out transition duration, we need to manually call the
// transition end listener because `transitionend` doesn't fire if there is no transition.
if (rippleRef._animationForciblyDisabledThroughCss || !animationConfig.exitDuration) {
this._finishRippleTransition(rippleRef);
}
}

/** Fades out all currently active ripples. */
Expand Down Expand Up @@ -256,6 +273,40 @@ export class RippleRenderer implements EventListenerObject {
}
}

/** Method that will be called if the fade-in or fade-in transition completed. */
private _finishRippleTransition(rippleRef: RippleRef) {
if (rippleRef.state === RippleState.FADING_IN) {
this._startFadeOutTransition(rippleRef);
} else if (rippleRef.state === RippleState.FADING_OUT) {
this._destroyRipple(rippleRef);
}
}

/**
* Starts the fade-out transition of the given ripple if it's not persistent and the pointer
* is not held down anymore.
*/
private _startFadeOutTransition(rippleRef: RippleRef) {
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
const {persistent} = rippleRef.config;

rippleRef.state = RippleState.VISIBLE;

// When the timer runs out while the user has kept their pointer down, we want to
// keep only the persistent ripples and the latest transient ripple. We do this,
// because we don't want stacked transient ripples to appear after their enter
// animation has finished.
if (!persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
rippleRef.fadeOut();
}
}

/** Destroys the given ripple by removing it from the DOM and updating its state. */
private _destroyRipple(rippleRef: RippleRef) {
rippleRef.state = RippleState.HIDDEN;
rippleRef.element.remove();
}

/** Function being called whenever the trigger is being pressed using mouse. */
private _onMousedown(event: MouseEvent) {
// Screen readers will fire fake mouse events for space/enter. Skip launching a
Expand Down Expand Up @@ -312,11 +363,6 @@ export class RippleRenderer implements EventListenerObject {
});
}

/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
private _runTimeoutOutsideZone(fn: Function, delay = 0) {
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
}

/** Registers event listeners for a given list of events. */
private _registerEvents(eventTypes: string[]) {
this._ngZone.runOutsideAngular(() => {
Expand All @@ -342,14 +388,6 @@ export class RippleRenderer implements EventListenerObject {
}
}

/** Enforces a style recalculation of a DOM element by computing its styles. */
function enforceStyleRecalculation(element: HTMLElement) {
// Enforce a style recalculation by calling `getComputedStyle` and accessing any property.
// Calling `getPropertyValue` is important to let optimizers know that this is not a noop.
// See: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
window.getComputedStyle(element).getPropertyValue('opacity');
}

/**
* Returns the distance from the point (x, y) to the furthest corner of a rectangle.
*/
Expand Down
Loading