diff --git a/src/lib/core/ripple/ripple-renderer.ts b/src/lib/core/ripple/ripple-renderer.ts index bb5f61aafd0e..f4f2ae058501 100644 --- a/src/lib/core/ripple/ripple-renderer.ts +++ b/src/lib/core/ripple/ripple-renderer.ts @@ -17,6 +17,12 @@ export const RIPPLE_FADE_IN_DURATION = 450; /** Fade-out duration for the ripples in milliseconds. This can't be modified by the speedFactor. */ export const RIPPLE_FADE_OUT_DURATION = 400; +/** + * Timeout for ignoring mouse events. Mouse events will be temporary ignored after touch + * events to avoid synthetic mouse events. + */ +const ignoreMouseEventsTimeout = 800; + export type RippleConfig = { color?: string; centered?: boolean; @@ -40,8 +46,8 @@ export class RippleRenderer { /** Element which triggers the ripple elements on mouse events. */ private _triggerElement: HTMLElement | null; - /** Whether the mouse is currently down or not. */ - private _isMousedown: boolean = false; + /** Whether the pointer is currently down or not. */ + private _isPointerDown: boolean = false; /** Events to be registered on the trigger element. */ private _triggerEvents = new Map(); @@ -49,6 +55,9 @@ export class RippleRenderer { /** Set of currently active ripple references. */ private _activeRipples = new Set(); + /** Time in milliseconds when the last touchstart event happened. */ + private _lastTouchStartEvent: number; + /** Ripple config for all ripples created by events. */ rippleConfig: RippleConfig = {}; @@ -62,8 +71,11 @@ export class RippleRenderer { // Specify events which need to be registered on the trigger. this._triggerEvents.set('mousedown', this.onMousedown.bind(this)); - this._triggerEvents.set('mouseup', this.onMouseup.bind(this)); - this._triggerEvents.set('mouseleave', this.onMouseup.bind(this)); + this._triggerEvents.set('mouseup', this.onPointerUp.bind(this)); + this._triggerEvents.set('mouseleave', this.onPointerUp.bind(this)); + + this._triggerEvents.set('touchstart', this.onTouchStart.bind(this)); + this._triggerEvents.set('touchend', this.onPointerUp.bind(this)); // By default use the host element as trigger element. this.setTriggerElement(this._containerElement); @@ -122,7 +134,7 @@ export class RippleRenderer { this.runTimeoutOutsideZone(() => { rippleRef.state = RippleState.VISIBLE; - if (!config.persistent && !this._isMousedown) { + if (!config.persistent && !this._isPointerDown) { rippleRef.fadeOut(); } }, duration); @@ -175,21 +187,37 @@ export class RippleRenderer { this._triggerElement = element; } - /** Function being called whenever the trigger is being pressed. */ + /** Function being called whenever the trigger is being pressed using mouse. */ private onMousedown(event: MouseEvent) { - if (!this.rippleDisabled) { - this._isMousedown = true; + const isSyntheticEvent = this._lastTouchStartEvent && + Date.now() < this._lastTouchStartEvent + ignoreMouseEventsTimeout; + + if (!this.rippleDisabled && !isSyntheticEvent) { + this._isPointerDown = true; this.fadeInRipple(event.clientX, event.clientY, this.rippleConfig); } } + /** Function being called whenever the trigger is being pressed using touch. */ + private onTouchStart(event: TouchEvent) { + if (!this.rippleDisabled) { + // Some browsers fire mouse events after a `touchstart` event. Those synthetic mouse + // events will launch a second ripple if we don't ignore mouse events for a specific + // time after a touchstart event. + this._lastTouchStartEvent = Date.now(); + this._isPointerDown = true; + + this.fadeInRipple(event.touches[0].clientX, event.touches[0].clientY, this.rippleConfig); + } + } + /** Function being called whenever the trigger is being released. */ - private onMouseup() { - if (!this._isMousedown) { + private onPointerUp() { + if (!this._isPointerDown) { return; } - this._isMousedown = false; + this._isPointerDown = false; // Fade-out all ripples that are completely visible and not persistent. this._activeRipples.forEach(ripple => { diff --git a/src/lib/core/ripple/ripple.spec.ts b/src/lib/core/ripple/ripple.spec.ts index 709569d927b1..09517ba85ffb 100644 --- a/src/lib/core/ripple/ripple.spec.ts +++ b/src/lib/core/ripple/ripple.spec.ts @@ -1,7 +1,7 @@ import {TestBed, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing'; import {Component, ViewChild} from '@angular/core'; import {Platform} from '@angular/cdk/platform'; -import {dispatchMouseEvent} from '@angular/cdk/testing'; +import {dispatchMouseEvent, dispatchTouchEvent} from '@angular/cdk/testing'; import {RIPPLE_FADE_OUT_DURATION, RIPPLE_FADE_IN_DURATION} from './ripple-renderer'; import { MatRipple, MatRippleModule, MAT_RIPPLE_GLOBAL_OPTIONS, RippleState, RippleGlobalOptions @@ -104,6 +104,34 @@ describe('MatRipple', () => { expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(2); }); + it('should launch ripples on touchstart', fakeAsync(() => { + dispatchTouchEvent(rippleTarget, 'touchstart'); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + tick(RIPPLE_FADE_IN_DURATION); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + dispatchTouchEvent(rippleTarget, 'touchend'); + + tick(RIPPLE_FADE_OUT_DURATION); + + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0); + })); + + it('should ignore synthetic mouse events after touchstart', () => fakeAsync(() => { + dispatchTouchEvent(rippleTarget, 'touchstart'); + dispatchTouchEvent(rippleTarget, 'mousedown'); + + tick(RIPPLE_FADE_IN_DURATION); + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(1); + + dispatchTouchEvent(rippleTarget, 'touchend'); + + tick(RIPPLE_FADE_OUT_DURATION); + + expect(rippleTarget.querySelectorAll('.mat-ripple-element').length).toBe(0); + })); + it('removes ripple after timeout', fakeAsync(() => { dispatchMouseEvent(rippleTarget, 'mousedown'); dispatchMouseEvent(rippleTarget, 'mouseup');