From 769d436f4ec78b71d18e0028a606114af48b7afb Mon Sep 17 00:00:00 2001 From: rahim Date: Fri, 17 Apr 2026 18:01:26 -0700 Subject: [PATCH] fix(react): prevent gesture tap from firing on slider interactions (#1361) --- apps/e2e/tests/gestures.spec.ts | 51 ++++++++++++++++++- .../core/src/dom/store/features/controls.ts | 1 + packages/core/src/dom/ui/slider.ts | 6 ++- packages/utils/src/dom/interactive.ts | 3 +- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/e2e/tests/gestures.spec.ts b/apps/e2e/tests/gestures.spec.ts index edab7cff..7eb938ba 100644 --- a/apps/e2e/tests/gestures.spec.ts +++ b/apps/e2e/tests/gestures.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { DATA_ATTRS, SELECTORS } from '../fixtures/selectors'; +import { DATA_ATTRS } from '../fixtures/selectors'; import { PlayerPage } from '../page-objects/player'; /** @@ -52,6 +52,55 @@ test.describe('Mouse Gestures', () => { await player.playButton.click(); await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); }); + + test('click on slider does not trigger container gesture', async ({ page }) => { + // Start playback so the slider has a seekable range + await player.play(); + await page.waitForTimeout(500); + + // Click the time slider — should seek, not toggle play/pause. + // Wait briefly after seek to give any leaked gesture time to fire. + await player.seekTo(50); + await page.waitForTimeout(300); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused); + }); +}); + +// --- React gestures (verify slider interaction isolation) --- + +test.describe('React Mouse Gestures', () => { + let player: PlayerPage; + + test.beforeEach(async ({ page }) => { + player = new PlayerPage(page); + await page.goto('/pages/react-video-mp4.html'); + await player.waitForMediaReady(); + }); + + test('click center of container toggles play/pause', async ({ page }) => { + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + + const { x, y } = await getCenter(player); + await page.mouse.click(x, y); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); + }); + + test('click on button does not trigger container gesture', async () => { + await expect(player.playButton).toHaveAttribute(DATA_ATTRS.paused, ''); + await player.playButton.click(); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused, { timeout: 5_000 }); + }); + + test('click on slider does not trigger container gesture', async ({ page }) => { + await player.play(); + await page.waitForTimeout(500); + + // Click the time slider — should seek, not toggle play/pause. + // Wait briefly after seek to give any leaked gesture time to fire. + await player.seekTo(50); + await page.waitForTimeout(300); + await expect(player.playButton).not.toHaveAttribute(DATA_ATTRS.paused); + }); }); // --- Touch gestures (pointer="touch") --- diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts index a35231ab..854b5ca5 100644 --- a/packages/core/src/dom/store/features/controls.ts +++ b/packages/core/src/dom/store/features/controls.ts @@ -85,6 +85,7 @@ export const controlsFeature = definePlayerFeature({ if (event.pointerType === 'touch' && Date.now() - pointerDownTime < TAP_THRESHOLD) { // When a toggleControls touch tap gesture is registered, it handles toggle — skip inline handler. const coordinator = findGestureCoordinator(container as HTMLElement); + if ( coordinator?.bindings.some( (b) => b.type === 'tap' && b.action === 'toggleControls' && (!b.pointer || b.pointer === 'touch') diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index 063fb7dd..7b9b8f19 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -215,10 +215,14 @@ export function createSlider(options: SliderOptions): SliderApi { }, onPointerUp(event) { - if (isNull(capturedPointerId)) return; + if (options.isDisabled()) return; + // The slider fully owns pointer interactions — prevent parent gesture + // coordinators from misinterpreting slider taps as surface gestures. event.stopPropagation(); + if (isNull(capturedPointerId)) return; + const percent = getPercentFromPointerEvent(event, cachedRect!, options.getOrientation(), cachedRTL); // Cancel any pending throttled change before the final unthrottled pair. diff --git a/packages/utils/src/dom/interactive.ts b/packages/utils/src/dom/interactive.ts index 9f718387..7d0da34a 100644 --- a/packages/utils/src/dom/interactive.ts +++ b/packages/utils/src/dom/interactive.ts @@ -1,6 +1,7 @@ import { resolveEventTarget } from './event'; -export const INTERACTIVE_SELECTOR = 'button,input,select,textarea,a[href],[role="slider"],[role="button"]'; +export const INTERACTIVE_SELECTOR = + 'button,input,select,textarea,a[href],[role="slider"],[role="button"],[data-interactive]'; const EDITABLE_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'email', 'password', 'number'];