Skip to content

Commit 091c448

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 dcd2282 commit 091c448

File tree

6 files changed

+205
-173
lines changed

6 files changed

+205
-173
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: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -171,21 +171,15 @@ export class RippleRenderer {
171171
this._mostRecentTransientRipple = rippleRef;
172172
}
173173

174-
// Wait for the ripple element to be completely faded in.
175-
// Once it's faded in, the ripple can be hidden immediately if the mouse is released.
176-
this.runTimeoutOutsideZone(() => {
177-
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
178-
179-
rippleRef.state = RippleState.VISIBLE;
174+
this._ngZone.runOutsideAngular(() => {
175+
ripple.addEventListener('transitionend', () => this._onRippleTransitionEnd(rippleRef));
176+
});
180177

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

190184
return rippleRef;
191185
}
@@ -211,15 +205,17 @@ export class RippleRenderer {
211205
const rippleEl = rippleRef.element;
212206
const animationConfig = {...defaultRippleAnimationConfig, ...rippleRef.config.animation};
213207

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

218-
// Once the ripple faded out, the ripple can be safely removed from the DOM.
219-
this.runTimeoutOutsideZone(() => {
220-
rippleRef.state = RippleState.HIDDEN;
221-
rippleEl.parentNode!.removeChild(rippleEl);
222-
}, animationConfig.exitDuration);
214+
// In case there is no fade-out transition duration, we need to manually call the
215+
// transition end listener because `transitionend` doesn't fire if there is no transition.
216+
if (!animationConfig.exitDuration) {
217+
this._onRippleTransitionEnd(rippleRef);
218+
}
223219
}
224220

225221
/** Fades out all currently active ripples. */
@@ -244,6 +240,28 @@ export class RippleRenderer {
244240
this._triggerElement = element;
245241
}
246242

243+
/** Transition end event listener that fires after ripples fade in or fade out. */
244+
private _onRippleTransitionEnd(rippleRef: RippleRef) {
245+
const {config, element} = rippleRef;
246+
247+
if (rippleRef.state === RippleState.FADING_IN) {
248+
const isMostRecentTransientRipple = rippleRef === this._mostRecentTransientRipple;
249+
250+
rippleRef.state = RippleState.VISIBLE;
251+
252+
// When the timer runs out while the user has kept their pointer down, we want to
253+
// keep only the persistent ripples and the latest transient ripple. We do this,
254+
// because we don't want stacked transient ripples to appear after their enter
255+
// animation has finished.
256+
if (!config.persistent && (!isMostRecentTransientRipple || !this._isPointerDown)) {
257+
rippleRef.fadeOut();
258+
}
259+
} else if (rippleRef.state === RippleState.FADING_OUT) {
260+
rippleRef.state = RippleState.HIDDEN;
261+
element.parentNode!.removeChild(element);
262+
}
263+
}
264+
247265
/** Function being called whenever the trigger is being pressed using mouse. */
248266
private onMousedown = (event: MouseEvent) => {
249267
const isSyntheticEvent = this._lastTouchStartEvent &&
@@ -290,11 +308,6 @@ export class RippleRenderer {
290308
});
291309
}
292310

293-
/** Runs a timeout outside of the Angular zone to avoid triggering the change detection. */
294-
private runTimeoutOutsideZone(fn: Function, delay = 0) {
295-
this._ngZone.runOutsideAngular(() => setTimeout(fn, delay));
296-
}
297-
298311
/** Removes previously registered event listeners from the trigger element. */
299312
_removeTriggerEvents() {
300313
if (this._triggerElement) {

0 commit comments

Comments
 (0)