diff --git a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts index e3d06ccf..18537016 100644 --- a/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts +++ b/packages/core/src/dom/ui/tooltip/tests/tooltip.test.ts @@ -1,5 +1,5 @@ import { flush } from '@videojs/store'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TooltipGroupCore } from '../../../../core/ui/tooltip/tooltip-group-core'; import { createTestTooltip } from './tooltip-helpers'; @@ -104,6 +104,106 @@ describe('createTooltip', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + describe('touch pointer suppression', () => { + beforeEach(() => { + vi.useFakeTimers(); + // jsdom lacks matchMedia — stub so popover's canHover() returns true, + // allowing us to test that the tooltip layer blocks touch independently. + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query === '(hover: hover)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('does not open on touch pointer enter', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.triggerProps.onPointerEnter({ + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'touch', + buttons: 0, + preventDefault: vi.fn(), + }); + + vi.advanceTimersByTime(600); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('opens on mouse pointer enter', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + tooltip.triggerProps.onPointerEnter({ + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'mouse', + buttons: 0, + preventDefault: vi.fn(), + }); + + vi.advanceTimersByTime(600); + + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'hover' }); + }); + + it('does not open via focus after pointer down (tap)', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + const pointerEvent = { + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'touch' as const, + buttons: 0, + preventDefault: vi.fn(), + }; + + // Simulate tap: pointerdown → focusin (flag consumed and reset) + tooltip.triggerProps.onPointerDown(pointerEvent); + tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('opens via focus when no pointer down (keyboard Tab)', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + + // Simulate keyboard Tab: focusin without preceding pointerdown + tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() }); + + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' }); + }); + + it('opens via keyboard focus after tap-triggered focus was suppressed', () => { + const { tooltip, onOpenChange } = createTestTooltip(); + const pointerEvent = { + clientX: 0, + clientY: 0, + pointerId: 1, + pointerType: 'touch' as const, + buttons: 0, + preventDefault: vi.fn(), + }; + + // Tap: pointerdown → focusin (suppressed, flag consumed) + tooltip.triggerProps.onPointerDown(pointerEvent); + tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() }); + expect(onOpenChange).not.toHaveBeenCalled(); + + // Later keyboard Tab: flag is clean, focus opens tooltip + tooltip.triggerProps.onFocusIn({ relatedTarget: null, preventDefault: vi.fn() }); + expect(onOpenChange).toHaveBeenCalledWith(true, { reason: 'focus' }); + }); + }); + it('does not open via focus when disabled', () => { const { tooltip, onOpenChange } = createTestTooltip({ disabled: () => true, diff --git a/packages/core/src/dom/ui/tooltip/tooltip.ts b/packages/core/src/dom/ui/tooltip/tooltip.ts index 7468671c..972b54e2 100644 --- a/packages/core/src/dom/ui/tooltip/tooltip.ts +++ b/packages/core/src/dom/ui/tooltip/tooltip.ts @@ -1,4 +1,5 @@ import type { TooltipGroupCore } from '../../../core/ui/tooltip/tooltip-group-core'; +import type { UIPointerEvent } from '../event'; import { createPopover, type PopoverApi, @@ -27,7 +28,9 @@ export interface TooltipOptions { group?: () => TooltipGroupCore | undefined; } -export interface TooltipTriggerProps extends Omit {} +export interface TooltipTriggerProps extends Omit { + onPointerDown: (event: UIPointerEvent) => void; +} export interface TooltipPopupProps extends PopoverPopupProps {} @@ -80,16 +83,29 @@ export function createTooltip(options: TooltipOptions): TooltipApi { const popover = createPopover(popoverOpts); - // Spread popover trigger props, omit onClick, guard disabled on open handlers. + // Track whether a pointer is currently down so focus-triggered opens can be + // suppressed during tap. The browser fires pointerdown → focus → pointerup, + // so the flag is true during tap-triggered focus but false during keyboard Tab. + let isPointerDown = false; + + // Spread popover trigger props, omit onClick, guard disabled/touch on open handlers. const { onClick: _, ...baseTriggerProps } = popover.triggerProps; const triggerProps: TooltipTriggerProps = { ...baseTriggerProps, + onPointerDown() { + isPointerDown = true; + }, onPointerEnter(event) { if (options.disabled?.()) return; + if (event.pointerType === 'touch') return; baseTriggerProps.onPointerEnter(event); }, onFocusIn(event) { if (options.disabled?.()) return; + if (isPointerDown) { + isPointerDown = false; + return; + } baseTriggerProps.onFocusIn(event); }, };