From 3985096f01795ff54fba0750f35f10f9741d4794 Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:44:25 +0800 Subject: [PATCH] fix(gesture): preserve stationary button ripples during capture --- core/src/utils/gesture/gesture-controller.ts | 27 +++- core/src/utils/gesture/index.ts | 1 + .../gesture/test/gesture-controller.spec.ts | 29 ++++ core/src/utils/tap-click/index.ts | 36 ++++- .../tap-click/test/gesture/gesture.e2e.ts | 39 ++++++ .../utils/tap-click/test/gesture/index.html | 31 +++++ .../utils/tap-click/test/tap-click.spec.ts | 131 ++++++++++++++++++ 7 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 core/src/utils/gesture/test/gesture-controller.spec.ts create mode 100644 core/src/utils/tap-click/test/gesture/gesture.e2e.ts create mode 100644 core/src/utils/tap-click/test/gesture/index.html create mode 100644 core/src/utils/tap-click/test/tap-click.spec.ts diff --git a/core/src/utils/gesture/gesture-controller.ts b/core/src/utils/gesture/gesture-controller.ts index 32f381008b0..8c53d06d21a 100644 --- a/core/src/utils/gesture/gesture-controller.ts +++ b/core/src/utils/gesture/gesture-controller.ts @@ -9,7 +9,14 @@ class GestureController { * Creates a gesture delegate based on the GestureConfig passed */ createGesture(config: GestureConfig): GestureDelegate { - return new GestureDelegate(this, this.newID(), config.name, config.priority ?? 0, !!config.disableScroll); + return new GestureDelegate( + this, + this.newID(), + config.name, + config.priority ?? 0, + !!config.disableScroll, + config.gestureElement + ); } /** @@ -28,7 +35,7 @@ class GestureController { return true; } - capture(gestureName: string, id: number, priority: number): boolean { + capture(gestureName: string, id: number, priority: number, gestureElement?: Node): boolean { if (!this.start(gestureName, id, priority)) { return false; } @@ -43,7 +50,9 @@ class GestureController { this.capturedId = id; requestedStart.clear(); - const event = new CustomEvent('ionGestureCaptured', { detail: { gestureName } }); + const event = new CustomEvent('ionGestureCaptured', { + detail: { gestureName, gestureElement }, + }); document.dispatchEvent(event); return true; } @@ -134,7 +143,8 @@ class GestureDelegate { private id: number, private name: string, priority: number, - private disableScroll: boolean + private disableScroll: boolean, + private gestureElement?: Node ) { this.priority = priority * 1000000 + id; this.ctrl = ctrl; @@ -161,7 +171,7 @@ class GestureDelegate { return false; } - const captured = this.ctrl.capture(this.name, this.id, this.priority); + const captured = this.ctrl.capture(this.name, this.id, this.priority, this.gestureElement); if (captured && this.disableScroll) { this.ctrl.disableScroll(this.id); } @@ -236,6 +246,13 @@ export interface GestureConfig { name: string; priority?: number; disableScroll?: boolean; + gestureElement?: Node; +} + +/** @internal */ +export interface GestureCapturedEventDetail { + gestureName: string; + gestureElement?: Node; } export interface BlockerConfig { diff --git a/core/src/utils/gesture/index.ts b/core/src/utils/gesture/index.ts index 79e5ee323df..e02f3ca2cee 100644 --- a/core/src/utils/gesture/index.ts +++ b/core/src/utils/gesture/index.ts @@ -52,6 +52,7 @@ export const createGesture = (config: GestureConfig): Gesture => { name: config.gestureName, priority: config.gesturePriority, disableScroll: config.disableScroll, + gestureElement: config.el, }); const pointerDown = (ev: UIEvent): boolean => { diff --git a/core/src/utils/gesture/test/gesture-controller.spec.ts b/core/src/utils/gesture/test/gesture-controller.spec.ts new file mode 100644 index 00000000000..5410d9382c5 --- /dev/null +++ b/core/src/utils/gesture/test/gesture-controller.spec.ts @@ -0,0 +1,29 @@ +import { createGesture } from '../index'; + +// https://github.com/ionic-team/ionic-framework/issues/22491 +describe('GestureController', () => { + it('includes the gesture element in the captured event', () => { + const gestureElement = document.createElement('div'); + const onGestureCaptured = jest.fn(); + const gesture = createGesture({ + el: gestureElement, + gestureName: 'test', + threshold: 0, + }); + + document.addEventListener('ionGestureCaptured', onGestureCaptured); + + try { + gesture.enable(); + gestureElement.dispatchEvent(new Event('touchstart')); + + expect(onGestureCaptured).toHaveBeenCalledTimes(1); + const { detail } = onGestureCaptured.mock.calls[0][0]; + expect(detail.gestureName).toBe('test'); + expect(detail.gestureElement).toBe(gestureElement); + } finally { + gesture.destroy(); + document.removeEventListener('ionGestureCaptured', onGestureCaptured); + } + }); +}); diff --git a/core/src/utils/tap-click/index.ts b/core/src/utils/tap-click/index.ts index c5a10595f47..e9a450e1f9b 100644 --- a/core/src/utils/tap-click/index.ts +++ b/core/src/utils/tap-click/index.ts @@ -1,6 +1,7 @@ import { doc } from '@utils/browser'; import type { Config } from '../../interface'; +import type { GestureCapturedEventDetail } from '../gesture/gesture-controller'; import { pointerCoord } from '../helpers'; export const startTapClick = (config: Config) => { @@ -9,6 +10,9 @@ export const startTapClick = (config: Config) => { } let lastActivated = 0; + let gestureCaptured = false; + let pointerMoved = false; + let pointerStart: { x: number; y: number } | undefined; let activatableEle: HTMLElement | undefined; let activeRipple: Promise<() => void> | undefined; @@ -18,6 +22,7 @@ export const startTapClick = (config: Config) => { const clearDefers = new WeakMap(); const cancelActive = () => { + gestureCaptured = false; if (activeDefer) clearTimeout(activeDefer); activeDefer = undefined; if (activatableEle) { @@ -26,15 +31,43 @@ export const startTapClick = (config: Config) => { } }; + const onGestureCaptured = (ev: Event) => { + const gestureElement = (ev as CustomEvent).detail?.gestureElement; + + // A gesture on this control or inside it can be a long press. + // Ancestor gestures still cancel activation for sliding and reordering. + if (gestureElement && activatableEle?.contains(gestureElement) && !pointerMoved) { + gestureCaptured = true; + return; + } + + cancelActive(); + }; + const pointerDown = (ev: PointerEvent) => { // Ignore right clicks if (activatableEle || ev.button === 2) { return; } + gestureCaptured = false; + pointerMoved = false; + pointerStart = pointerCoord(ev); setActivatedElement(getActivatableTarget(ev), ev); }; + const pointerMove = (ev: PointerEvent) => { + if (pointerStart) { + const { x, y } = pointerCoord(ev); + pointerMoved = pointerMoved || x !== pointerStart.x || y !== pointerStart.y; + if (gestureCaptured && pointerMoved) { + cancelActive(); + } + } + }; + const pointerUp = (ev: UIEvent) => { + gestureCaptured = false; + pointerStart = undefined; setActivatedElement(undefined, ev); }; @@ -119,10 +152,11 @@ export const startTapClick = (config: Config) => { } }; - doc.addEventListener('ionGestureCaptured', cancelActive); + doc.addEventListener('ionGestureCaptured', onGestureCaptured); doc.addEventListener('pointerdown', pointerDown, true); doc.addEventListener('pointerup', pointerUp, true); + doc.addEventListener('pointermove', pointerMove, true); /** * Tap click effects such as the ripple effect should diff --git a/core/src/utils/tap-click/test/gesture/gesture.e2e.ts b/core/src/utils/tap-click/test/gesture/gesture.e2e.ts new file mode 100644 index 00000000000..e8e58f766da --- /dev/null +++ b/core/src/utils/tap-click/test/gesture/gesture.e2e.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +// https://github.com/ionic-team/ionic-framework/issues/22491 +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tap click captured gesture'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/utils/tap-click/test/gesture?ionic:_testing=false', config); + await expect(page.locator('body')).toHaveAttribute('data-ready', 'true'); + }); + + test('keeps the ripple while a captured press is held', async ({ page }) => { + const button = page.locator('#press'); + const box = await button.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.mouse.down(); + await expect(button.locator('ion-ripple-effect .ripple-effect')).toHaveCount(1); + await page.waitForTimeout(600); + await expect(button.locator('ion-ripple-effect .ripple-effect')).toHaveCount(1); + await page.mouse.up(); + await expect(button.locator('ion-ripple-effect .ripple-effect')).toHaveCount(0); + }); + + test('removes the ripple during a captured mouse drag', async ({ page }) => { + const button = page.locator('#press'); + const box = await button.boundingBox(); + expect(box).not.toBeNull(); + const x = box!.x + box!.width / 2; + const y = box!.y + box!.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await expect(button.locator('ion-ripple-effect .ripple-effect')).toHaveCount(1); + await page.mouse.move(x + 20, y); + await expect(button.locator('ion-ripple-effect .ripple-effect')).toHaveCount(0); + await page.mouse.up(); + }); + }); +}); diff --git a/core/src/utils/tap-click/test/gesture/index.html b/core/src/utils/tap-click/test/gesture/index.html new file mode 100644 index 00000000000..6d0eaf108f3 --- /dev/null +++ b/core/src/utils/tap-click/test/gesture/index.html @@ -0,0 +1,31 @@ + + + + + Tap Click - Captured Gesture + + + + + + + + Hold or drag + + diff --git a/core/src/utils/tap-click/test/tap-click.spec.ts b/core/src/utils/tap-click/test/tap-click.spec.ts new file mode 100644 index 00000000000..f15025eaca3 --- /dev/null +++ b/core/src/utils/tap-click/test/tap-click.spec.ts @@ -0,0 +1,131 @@ +import type { Config } from '../../../interface'; +import { startTapClick } from '../index'; + +// https://github.com/ionic-team/ionic-framework/issues/22491 +describe('tap click utility', () => { + beforeEach(() => { + startTapClick({ + getBoolean: () => true, + } as unknown as Config); + }); + + afterEach(() => { + document.dispatchEvent(new Event('pointerup', { bubbles: true })); + document.body.innerHTML = ''; + }); + + it('preserves the active state when the captured gesture element matches', () => { + const button = createActivatableElement(); + + activate(button); + captureGesture(button); + + expect(button.classList.contains('ion-activated')).toBe(true); + }); + + it('preserves the active state when the captured gesture element is a descendant', () => { + const button = createActivatableElement(); + const child = document.createElement('span'); + button.append(child); + + activate(child); + captureGesture(child); + + expect(button.classList.contains('ion-activated')).toBe(true); + }); + + it('removes a captured ripple when a mouse drag starts', async () => { + const button = createActivatableElement(); + const ripple = document.createElement('ion-ripple-effect'); + const remove = jest.fn(); + ripple.addRipple = jest.fn().mockResolvedValue(remove); + button.append(ripple); + activate(button); + captureGesture(button); + expect(ripple.addRipple).toHaveBeenCalledTimes(1); + button.dispatchEvent(pointerEvent('pointermove', 20, 10)); + await Promise.resolve(); + expect(remove).toHaveBeenCalledTimes(1); + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('cancels a gesture captured after the pointer has already moved', () => { + const button = createActivatableElement(); + activate(button); + button.dispatchEvent(pointerEvent('pointermove', 20, 10)); + captureGesture(button); + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('cancels activation when an ancestor captures a gesture', () => { + const button = createActivatableElement(); + activate(button); + captureGesture(document.body); + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('cancels the active state when the captured gesture element is unrelated', () => { + const button = createActivatableElement(); + const unrelatedElement = document.createElement('div'); + document.body.append(unrelatedElement); + + activate(button); + captureGesture(unrelatedElement); + + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('cancels the active state when the captured gesture element is missing', () => { + const button = createActivatableElement(); + + activate(button); + captureGesture(); + + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('cancels the active state on pointercancel', () => { + const button = createActivatableElement(); + + activate(button); + document.dispatchEvent(new Event('pointercancel', { bubbles: true })); + + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('clears the active state on pointerup', () => { + const button = createActivatableElement(); + + activate(button); + document.dispatchEvent(new Event('pointerup', { bubbles: true })); + + expect(button.classList.contains('ion-activated')).toBe(false); + }); +}); + +const createActivatableElement = () => { + const button = document.createElement('button'); + button.classList.add('ion-activatable', 'ion-activatable-instant'); + document.body.append(button); + return button; +}; + +const activate = (element: HTMLElement) => { + element.dispatchEvent(pointerEvent('pointerdown', 10, 10)); + expect(element.closest('.ion-activatable')?.classList.contains('ion-activated')).toBe(true); +}; + +const captureGesture = (gestureElement?: Node) => { + document.dispatchEvent( + new CustomEvent('ionGestureCaptured', { + detail: { gestureName: 'test', gestureElement }, + }) + ); +}; + +// Stencil's MouseEvent mock does not derive page coordinates from client coordinates. +const pointerEvent = (type: string, x: number, y: number) => { + const event = new MouseEvent(type, { bubbles: true, composed: true, button: 0, clientX: x, clientY: y }); + Object.defineProperties(event, { pageX: { value: x }, pageY: { value: y } }); + return event; +};