diff --git a/core/src/utils/gesture/gesture-controller.ts b/core/src/utils/gesture/gesture-controller.ts index 32f381008b0..4e67c80e50c 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,12 @@ export interface GestureConfig { name: string; priority?: number; disableScroll?: boolean; + gestureElement?: Node; +} + +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..06c0cc81736 --- /dev/null +++ b/core/src/utils/gesture/test/gesture-controller.spec.ts @@ -0,0 +1,29 @@ +import { createGesture } from '../index'; + +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); + expect(onGestureCaptured.mock.calls[0][0].detail).toEqual({ + gestureName: 'test', + 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..e119f848c89 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) => { @@ -26,6 +27,16 @@ export const startTapClick = (config: Config) => { } }; + const onGestureCaptured = (ev: Event) => { + const gestureElement = (ev as CustomEvent).detail?.gestureElement; + + if (gestureElement && activatableEle?.contains(gestureElement)) { + return; + } + + cancelActive(); + }; + const pointerDown = (ev: PointerEvent) => { // Ignore right clicks if (activatableEle || ev.button === 2) { @@ -119,7 +130,7 @@ export const startTapClick = (config: Config) => { } }; - doc.addEventListener('ionGestureCaptured', cancelActive); + doc.addEventListener('ionGestureCaptured', onGestureCaptured); doc.addEventListener('pointerdown', pointerDown, true); doc.addEventListener('pointerup', pointerUp, true); 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..0b078a2e934 --- /dev/null +++ b/core/src/utils/tap-click/test/tap-click.spec.ts @@ -0,0 +1,120 @@ +import type { Config } from '../../../interface'; +import { startTapClick } from '../index'; + +let onGestureCaptured: EventListener; +let onPointerDown: EventListener; +let onPointerUp: EventListener; +let onPointerCancel: EventListener; + +describe('tap click utility', () => { + beforeAll(() => { + const addEventListener = jest.spyOn(document, 'addEventListener'); + startTapClick({ + getBoolean: () => false, + } as unknown as Config); + + onGestureCaptured = getListener(addEventListener, 'ionGestureCaptured'); + onPointerDown = getListener(addEventListener, 'pointerdown'); + onPointerUp = getListener(addEventListener, 'pointerup'); + onPointerCancel = getListener(addEventListener, 'pointercancel'); + addEventListener.mockRestore(); + }); + + afterEach(() => { + onPointerUp(new Event('pointerup')); + 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('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); + onPointerCancel(new Event('pointercancel')); + + expect(button.classList.contains('ion-activated')).toBe(false); + }); + + it('clears the active state on pointerup', () => { + const button = createActivatableElement(); + + activate(button); + onPointerUp(new Event('pointerup')); + + 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) => { + onPointerDown({ + button: 0, + target: element, + } as unknown as PointerEvent); + + const activatableElement = element.closest('.ion-activatable'); + expect(activatableElement?.classList.contains('ion-activated')).toBe(true); +}; + +const captureGesture = (gestureElement?: Node) => { + onGestureCaptured( + new CustomEvent('ionGestureCaptured', { + detail: { gestureName: 'test', gestureElement }, + }) + ); +}; + +const getListener = (addEventListener: jest.SpyInstance, eventName: string): EventListener => { + const listener = addEventListener.mock.calls.find(([type]) => type === eventName)?.[1]; + + if (typeof listener !== 'function') { + throw new Error(`Missing ${eventName} listener`); + } + + return listener; +};