Skip to content

Commit 07507d0

Browse files
committed
fix(material/tooltip): add opt-in for better touch device detection
Currently the touch device detection in the tooltip is based purely on the `Platform` provider which isn't able to detect some newer iOS devices properly. These changes add an additional check through a media query. The check is currently opt-in, because it can cause false positives in tests that run in headless browsers. Fixes #32503. Fixes #25287.
1 parent ef53e73 commit 07507d0

File tree

1 file changed

+32
-8
lines changed

1 file changed

+32
-8
lines changed

src/material/tooltip/tooltip.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
VerticalConnectionPos,
5353
} from '@angular/cdk/overlay';
5454
import {ComponentPortal} from '@angular/cdk/portal';
55+
import {MediaMatcher} from '@angular/cdk/layout';
5556
import {Observable, Subject} from 'rxjs';
5657
import {_animationsDisabled} from '../core';
5758

@@ -146,6 +147,14 @@ export interface MatTooltipDefaultOptions {
146147
* `tooltipClass` is defined directly on the tooltip element, as it will override the default.
147148
*/
148149
tooltipClass?: string | string[];
150+
151+
/**
152+
* Whether the tooltip should use a media query to detect if the device is able to hover.
153+
* Note that this may affect tests that run in a headless browser which reports that it's
154+
* unable to hover. In such cases you may need to include an additional timeout, because
155+
* the tooltip will fall back to treating the device as a touch screen.
156+
*/
157+
detectHoverCapability?: boolean;
149158
}
150159

151160
/**
@@ -190,6 +199,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
190199
protected _dir = inject(Directionality);
191200
private _injector = inject(Injector);
192201
private _viewContainerRef = inject(ViewContainerRef);
202+
private _mediaMatcher = inject(MediaMatcher);
193203
private _animationsDisabled = _animationsDisabled();
194204
private _defaultOptions = inject<MatTooltipDefaultOptions>(MAT_TOOLTIP_DEFAULT_OPTIONS, {
195205
optional: true,
@@ -784,7 +794,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
784794

785795
// The mouse events shouldn't be bound on mobile devices, because they can prevent the
786796
// first tap from firing its click event or can cause the tooltip to open for clicks.
787-
if (this._platformSupportsMouseEvents()) {
797+
if (!this._isTouchPlatform()) {
788798
this._passiveListeners.push([
789799
'mouseenter',
790800
event => {
@@ -801,8 +811,9 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
801811

802812
this._passiveListeners.push([
803813
'touchstart',
804-
event => {
805-
const touch = (event as TouchEvent).targetTouches?.[0];
814+
e => {
815+
const event = e as TouchEvent & {_isHarnessEvent?: boolean};
816+
const touch = event.targetTouches?.[0];
806817
const origin = touch ? {x: touch.clientX, y: touch.clientY} : undefined;
807818
// Note that it's important that we don't `preventDefault` here,
808819
// because it can prevent click events from firing on the element.
@@ -811,11 +822,13 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
811822
clearTimeout(this._touchstartTimeout);
812823
}
813824

814-
const DEFAULT_LONGPRESS_DELAY = 500;
825+
// Harnesses can run in headless browsers that might be picked up as touch
826+
// devices, because they can't hover. If we detect a harness, skip the delay.
827+
const defaultLongpressDelay = event._isHarnessEvent ? 0 : 500;
815828
this._touchstartTimeout = setTimeout(() => {
816829
this._touchstartTimeout = null;
817830
this.show(undefined, origin);
818-
}, this._defaultOptions?.touchLongPressShowDelay ?? DEFAULT_LONGPRESS_DELAY);
831+
}, this._defaultOptions?.touchLongPressShowDelay ?? defaultLongpressDelay);
819832
},
820833
]);
821834
}
@@ -830,7 +843,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
830843
this._pointerExitEventsInitialized = true;
831844

832845
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
833-
if (this._platformSupportsMouseEvents()) {
846+
if (!this._isTouchPlatform()) {
834847
exitListeners.push(
835848
[
836849
'mouseleave',
@@ -865,8 +878,19 @@ export class MatTooltip implements OnDestroy, AfterViewInit {
865878
});
866879
}
867880

868-
private _platformSupportsMouseEvents() {
869-
return !this._platform.IOS && !this._platform.ANDROID;
881+
private _isTouchPlatform(): boolean {
882+
if (this._platform.IOS || this._platform.ANDROID) {
883+
// If we detected iOS or Android, it's definitely supported.
884+
return true;
885+
} else if (!this._platform.isBrowser) {
886+
// If it's not a browser, it's definitely not supported.
887+
return false;
888+
}
889+
890+
return (
891+
!!this._defaultOptions?.detectHoverCapability &&
892+
this._mediaMatcher.matchMedia('(any-hover: none)').matches
893+
);
870894
}
871895

872896
/** Listener for the `wheel` event on the element. */

0 commit comments

Comments
 (0)