Skip to content

fix(core): suppress tooltip hover on touch pointer events#933

Draft
mihar-22 wants to merge 1 commit intomainfrom
fix/issue-921
Draft

fix(core): suppress tooltip hover on touch pointer events#933
mihar-22 wants to merge 1 commit intomainfrom
fix/issue-921

Conversation

@mihar-22
Copy link
Member

@mihar-22 mihar-22 commented Mar 13, 2026

Closes #921

Summary

Tooltips incorrectly open on touch interactions on hybrid devices (e.g. touchscreen laptops). The popover layer's canHover() guard uses matchMedia('(hover: hover)') which describes device capability, not current input method — so it always matches on hybrid devices even during touch input.

Adds a two-part touch suppression in the tooltip layer, following the pattern used by Radix UI:

  1. pointerType === 'touch' guard in onPointerEnter — blocks touch-triggered hover
  2. isPointerDown flag via onPointerDown/onPointerUp in triggerProps, checked in onFocusIn — suppresses tap-triggered focus (browser fires pointerdown → focus → pointerup, so the flag is set during tap but not during keyboard Tab)

Both handlers are exposed through triggerProps so the framework layer (HTML/React) attaches them — no imperative listen() calls.

Changes

  • Add onPointerDown and onPointerUp to TooltipTriggerProps interface
  • Add event.pointerType === 'touch' early return in onPointerEnter
  • Add isPointerDown flag checked in onFocusIn to distinguish tap-focus from keyboard-focus
  • Mouse/pen hover and keyboard Tab focus continue to open tooltips normally
  • Popovers are unaffected — hover-based popovers still respond to touch
Design decisions

Why tooltip layer, not popover? Base UI recommends Popover with openOnHover as the touch-accessible alternative to tooltips. Blocking touch in the popover layer would break that use case. The guard belongs specifically in the tooltip layer.

Why not long-press? Researched Base UI and Radix — both disable tooltips on touch entirely. Long-press conflicts with browser context menus and delays primary button actions in a video player.

Why pointerType over (hover: hover)? The media query is device-level; pointerType is per-event. On a hybrid device with trackpad + touchscreen, (hover: hover) is always true, but pointerType correctly reports 'touch' vs 'mouse' per interaction.

Why the isPointerDown pattern? A touch tap fires pointerdown → focus → pointerup. Without this guard, the tooltip would open via the focusin handler even though hover was suppressed. The flag distinguishes tap-triggered focus (pointer is down) from keyboard Tab focus (pointer is not down). This is the same pattern Radix uses.

Testing

pnpm -F @videojs/core test src/dom/ui/tooltip

5 new tests in "touch pointer suppression" block:

  • Does not open on touch pointer enter
  • Opens on mouse pointer enter
  • Does not open via focus when pointer is down (tap)
  • Opens via focus when no pointer is down (keyboard Tab)
  • Opens via focus after pointer is released

@vercel
Copy link

vercel bot commented Mar 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Mar 13, 2026 8:16am

Request Review

@netlify
Copy link

netlify bot commented Mar 13, 2026

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 64d64e2
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/69b3c7ac7fb52d000811eb31
😎 Deploy Preview https://deploy-preview-933--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

📦 Bundle Size Report

🎨 @videojs/html

(no changes)

Presets (7)
Entry Size
/video (default) 21.98 kB
/video (default + hls) 152.41 kB
/video (minimal) 21.81 kB
/video (minimal + hls) 152.20 kB
/audio (default) 20.67 kB
/audio (minimal) 20.67 kB
/background 6.47 kB
Media (4)
Entry Size
/media/background-video 617 B
/media/container 1.91 kB
/media/hls-video 131.23 kB
/media/simple-hls-video 11.89 kB
Players (3)
Entry Size
/video/player 6.33 kB
/audio/player 6.32 kB
/background/player 6.31 kB
Skins (16)
Entry Type Size
/video/minimal-skin.css css 2.65 kB
/video/skin.css css 2.68 kB
/video/minimal-skin js 21.23 kB
/video/minimal-skin.tailwind js 21.42 kB
/video/skin js 21.38 kB
/video/skin.tailwind js 21.68 kB
/audio/minimal-skin.css css 2.17 kB
/audio/skin.css css 2.19 kB
/audio/minimal-skin js 20.11 kB
/audio/minimal-skin.tailwind js 20.08 kB
/audio/skin js 20.14 kB
/audio/skin.tailwind js 20.30 kB
/background/skin.css css 124 B
/background/skin js 999 B
/base.css css 205 B
/shared.css css 35 B
UI Components (21)
Entry Size
/ui/alert-dialog 2.02 kB
/ui/alert-dialog-close 1.21 kB
/ui/alert-dialog-description 1.45 kB
/ui/alert-dialog-title 1.47 kB
/ui/buffering-indicator 1.75 kB
/ui/captions-button 1.73 kB
/ui/controls 1.80 kB
/ui/fullscreen-button 1.76 kB
/ui/mute-button 1.75 kB
/ui/pip-button 1.74 kB
/ui/play-button 1.80 kB
/ui/playback-rate-button 1.81 kB
/ui/popover 3.07 kB
/ui/poster 1.64 kB
/ui/seek-button 1.75 kB
/ui/slider 1.95 kB
/ui/thumbnail 2.05 kB
/ui/time 1.60 kB
/ui/time-slider 2.99 kB
/ui/tooltip 2.33 kB
/ui/volume-slider 2.11 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

(no changes)

Presets (7)
Entry Size
/video (default) 16.94 kB
/video (default + hls) 147.68 kB
/video (minimal) 16.91 kB
/video (minimal + hls) 147.66 kB
/audio (default) 14.63 kB
/audio (minimal) 14.68 kB
/background 3.19 kB
Media (3)
Entry Size
/media/background-video 539 B
/media/hls-video 131.52 kB
/media/simple-hls-video 12.28 kB
Skins (14)
Entry Type Size
/video/minimal-skin.css css 2.64 kB
/video/skin.css css 2.68 kB
/video/minimal-skin js 16.81 kB
/video/minimal-skin.tailwind js 19.46 kB
/video/skin js 16.83 kB
/video/skin.tailwind js 19.52 kB
/audio/minimal-skin.css css 2.16 kB
/audio/skin.css css 2.18 kB
/audio/minimal-skin js 14.56 kB
/audio/minimal-skin.tailwind js 16.43 kB
/audio/skin js 14.52 kB
/audio/skin.tailwind js 16.63 kB
/background/skin.css css 90 B
/background/skin js 272 B
UI Components (17)
Entry Size
/ui/alert-dialog 2.75 kB
/ui/buffering-indicator 2.24 kB
/ui/captions-button 2.25 kB
/ui/controls 2.23 kB
/ui/fullscreen-button 2.25 kB
/ui/mute-button 2.25 kB
/ui/pip-button 2.26 kB
/ui/play-button 2.25 kB
/ui/playback-rate-button 2.25 kB
/ui/popover 3.01 kB
/ui/poster 2.03 kB
/ui/seek-button 2.25 kB
/ui/slider 3.19 kB
/ui/time 2.34 kB
/ui/time-slider 2.80 kB
/ui/tooltip 3.29 kB
/ui/volume-slider 2.75 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

(no changes)

Entries (5)
Entry Size
. 4.78 kB
/dom 8.10 kB
/dom/media/custom-media-element 1.76 kB
/dom/media/hls 131.14 kB
/dom/media/simple-hls 11.85 kB

🏷️ @videojs/element

(no changes)

Entries (2)
Entry Size
. 999 B
/context 936 B

📦 @videojs/store

(no changes)

Entries (3)
Entry Size
. 1.32 kB
/html 700 B
/react 360 B

🔧 @videojs/utils

(no changes)

Entries (10)
Entry Size
/array 104 B
/dom 1003 B
/events 227 B
/function 261 B
/object 119 B
/predicate 265 B
/string 148 B
/style 190 B
/time 478 B
/number 158 B

📦 @videojs/spf

(no changes)

Entries (3)
Entry Size
. 40 B
/dom 10.04 kB
/playback-engine 9.95 kB

ℹ️ How to interpret

All sizes are standalone totals (minified + brotli).

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current sizes.

Add two-part touch suppression in the tooltip layer (matching Radix):
1. pointerType === 'touch' guard on onPointerEnter blocks touch hover
2. isPointerDown flag gates onFocusIn so tap-triggered focus is
   suppressed while keyboard focus (Tab) continues to work

The fix is scoped to tooltips so popovers with openOnHover continue
to work with touch as expected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: tooltips should not show on touch

1 participant