Skip to content

Commit b415e23

Browse files
committed
fix(ripple): not fading out on touch devices
* Makes the ripple animations no longer dependent on `setTimeout` that does not always fire properly / or within the specified duration. (related chrome issue: https://bugs.chromium.org/p/chromium/issues/detail?id=567800) * Fix indentation of a few ripple tests * Fixes that the speed factor tests are basically not checking anything (even though they will be removed in the future; they need to pass right now) Fixes #12470
1 parent 948e563 commit b415e23

File tree

4 files changed

+226
-176
lines changed

4 files changed

+226
-176
lines changed

src/lib/checkbox/checkbox.spec.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
ComponentFixture,
33
fakeAsync,
44
TestBed,
5-
tick,
65
flush,
76
flushMicrotasks,
87
} from '@angular/core/testing';
@@ -11,7 +10,6 @@ import {Component, DebugElement, ViewChild, Type} from '@angular/core';
1110
import {By} from '@angular/platform-browser';
1211
import {dispatchFakeEvent} from '@angular/cdk/testing';
1312
import {MatCheckbox, MatCheckboxChange, MatCheckboxModule} from './index';
14-
import {defaultRippleAnimationConfig} from '@angular/material/core';
1513
import {MAT_CHECKBOX_CLICK_ACTION} from './checkbox-config';
1614
import {MutationObserverFactory} from '@angular/cdk/observers';
1715

@@ -379,24 +377,29 @@ describe('MatCheckbox', () => {
379377
expect(inputElement.value).toBe('basic_checkbox');
380378
});
381379

382-
it('should show a ripple when focused by a keyboard action', fakeAsync(() => {
380+
it('should show a ripple when focused by a keyboard action', () => {
383381
expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
384382
.toBe(0, 'Expected no ripples on load.');
385383

386384
dispatchFakeEvent(inputElement, 'keydown');
387385
dispatchFakeEvent(inputElement, 'focus');
388386

389-
tick(defaultRippleAnimationConfig.enterDuration);
390-
391387
expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
392388
.toBe(1, 'Expected ripple after element is focused.');
393389

394-
dispatchFakeEvent(checkboxInstance._inputElement.nativeElement, 'blur');
395-
tick(defaultRippleAnimationConfig.exitDuration);
390+
const rippleElement = fixture.nativeElement
391+
.querySelector('.mat-ripple-element')! as HTMLElement;
392+
393+
// Flush the fade-in transition of the ripple.
394+
dispatchFakeEvent(rippleElement, 'transitionend');
395+
396+
// Blur the input element and flush the fade-out transition of the ripple.
397+
dispatchFakeEvent(inputElement, 'blur');
398+
dispatchFakeEvent(rippleElement, 'transitionend');
396399

397400
expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length)
398401
.toBe(0, 'Expected no ripple after element is blurred.');
399-
}));
402+
});
400403

401404
it('should remove the SVG checkmark from the tab order', () => {
402405
expect(checkboxNativeElement.querySelector('svg')!.getAttribute('focusable')).toBe('false');

src/lib/core/ripple/ripple-renderer.ts

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,19 @@ export class RippleRenderer {
174174
this._mostRecentTransientRipple = rippleRef;
175175
}
176176

177-
// Wait for the ripple element to be completely faded in.
178-
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
179-
this.runTimeoutOutsideZone(() => {
180-
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
181-
182-
rippleRef.state = RippleState.VISIBLE;
183-
184-
// When the timer runs out while the user has kept their pointer down, we want to
185-
// keep only the persistent ripples and the latest transient ripple. We do this,
186-
// because we don't want stacked transient ripples to appear after their enter
187-
// animation has finished.
188-
if (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
189-
rippleRef.fadeOut();
190-
}
191-
}, duration);
177+
// Do not register the transition event listener if the fade-in and fade-out duration
178+
// are set to zero because the event won't be fired at all.
179+
if (duration || animationConfig.exitDuration) {
180+
this._ngZone.runOutsideAngular(() => {
181+
ripple.addEventListener('transitionend', () => this._finishRippleTransition(rippleRef));
182+
});
183+
}
184+
185+
// In case there is no fade-in transition duration, we need to manually call the
186+
// transition end listener because `transitionend` doesn't fire if there is no transition.
187+
if (!duration) {
188+
this._finishRippleTransition(rippleRef);
189+
}
192190

193191
return rippleRef;
194192
}
@@ -214,15 +212,17 @@ export class RippleRenderer {
214212
const rippleEl = rippleRef.element;
215213
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};
216214

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

221-
// Once the ripple faded out, the ripple can be safely removed from the DOM.
222-
this.runTimeoutOutsideZone(() => {
223-
rippleRef.state = RippleState.HIDDEN;
224-
rippleEl.parentNode!.removeChild(rippleEl);
225-
}, animationConfig.exitDuration);
221+
// In case there is no fade-out transition duration, we need to manually call the
222+
// transition end listener because `transitionend` doesn't fire if there is no transition.
223+
if (!animationConfig.exitDuration) {
224+
this._finishRippleTransition(rippleRef);
225+
}
226226
}
227227

228228
/** Fades out all currently active ripples. */
@@ -247,6 +247,39 @@ export class RippleRenderer {
247247
this._triggerElement = element;
248248
}
249249

250+
/** Method that will be called if the fade-in or fade-in transition completed. */
251+
private _finishRippleTransition(rippleRef: RippleRef) {
252+
if (rippleRef.state === RippleState.FADING_IN) {
253+
this._startFadeOutTransition(rippleRef);
254+
} else if (rippleRef.state === RippleState.FADING_OUT) {
255+
this._destroyRipple(rippleRef);
256+
}
257+
}
258+
259+
/**
260+
* Starts the fade-out transition of the given ripple if it's not persistent and the pointer
261+
* is not held down anymore.
262+
*/
263+
private _startFadeOutTransition(rippleRef: RippleRef) {
264+
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
265+
266+
rippleRef.state = RippleState.VISIBLE;
267+
268+
// When the timer runs out while the user has kept their pointer down, we want to
269+
// keep only the persistent ripples and the latest transient ripple. We do this,
270+
// because we don't want stacked transient ripples to appear after their enter
271+
// animation has finished.
272+
if (!rippleRef.config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
273+
rippleRef.fadeOut();
274+
}
275+
}
276+
277+
/** Destroys the given ripple by removing it from the DOM and updating its state. */
278+
private _destroyRipple(rippleRef: RippleRef) {
279+
rippleRef.state = RippleState.HIDDEN;
280+
rippleRef.element.parentNode!.removeChild(rippleRef.element);
281+
}
282+
250283
/** Function being called whenever the trigger is being pressed using mouse. */
251284
private onMousedown = (event: MouseEvent) => {
252285
// Screen readers will fire fake mouse events for space/enter. Skip launching a
@@ -301,11 +334,6 @@ export class RippleRenderer {
301334
});
302335
}
303336

304-
/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
305-
private runTimeoutOutsideZone(fn: Function, delay = 0) {
306-
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
307-
}
308-
309337
/** Removes previously registered event listeners from the trigger element. */
310338
_removeTriggerEvents() {
311339
if (this._triggerElement) {

0 commit comments

Comments
 (0)