Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions core/src/utils/gesture/gesture-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

/**
Expand All @@ -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;
}
Expand All @@ -43,7 +50,9 @@ class GestureController {
this.capturedId = id;
requestedStart.clear();

const event = new CustomEvent('ionGestureCaptured', { detail: { gestureName } });
const event = new CustomEvent<GestureCapturedEventDetail>('ionGestureCaptured', {
detail: { gestureName, gestureElement },
});
document.dispatchEvent(event);
return true;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -236,6 +246,13 @@ export interface GestureConfig {
name: string;
priority?: number;
disableScroll?: boolean;
gestureElement?: Node;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, and totally optional: the internal config strips the gesture prefix off the public one, gestureName to name and gesturePriority to priority, and this new field goes the other way with el becoming gestureElement. Calling it el here would keep that consistent, and gestureElement still reads well on the detail below next to gestureName.

The GestureCapturedEventDetail interface below is the same kind of thing. It's correctly out of the public exports and ionGestureCaptured isn't documented, but people do build on that event, there are a couple of workarounds using it in the issue thread. An @internal JSDoc would say so and keep the door open to changing the shape later without it counting as breaking. No action required on either!

}

/** @internal */
export interface GestureCapturedEventDetail {
gestureName: string;
gestureElement?: Node;
}

export interface BlockerConfig {
Expand Down
1 change: 1 addition & 0 deletions core/src/utils/gesture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
29 changes: 29 additions & 0 deletions core/src/utils/gesture/test/gesture-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
36 changes: 35 additions & 1 deletion core/src/utils/tap-click/index.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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;
Expand All @@ -18,6 +22,7 @@ export const startTapClick = (config: Config) => {
const clearDefers = new WeakMap<HTMLElement, any>();

const cancelActive = () => {
gestureCaptured = false;
if (activeDefer) clearTimeout(activeDefer);
activeDefer = undefined;
if (activatableEle) {
Expand All @@ -26,15 +31,43 @@ export const startTapClick = (config: Config) => {
}
};

const onGestureCaptured = (ev: Event) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a short block comment here be worth it? The pointercancel listener further down has a good one, and the direction of this check is the part that isn't obvious. A couple of lines saying a gesture on the control or inside it keeps the ripple, while an ancestor's gesture still cancels, would save the next person from widening it and regressing reorder. Up to you!

const gestureElement = (ev as CustomEvent<GestureCapturedEventDetail>).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);
};

Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions core/src/utils/tap-click/test/gesture/gesture.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
31 changes: 31 additions & 0 deletions core/src/utils/tap-click/test/gesture/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Tap Click - Captured Gesture</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<script type="module">
import { createGesture } from '../../../../../dist/ionic/index.esm.js';
await customElements.whenDefined('ion-button');
const button = document.querySelector('ion-button');
await button.componentOnReady();
const gesture = createGesture({
el: button,
gestureName: 'long-press',
threshold: 0,
passive: false,
disableScroll: true,
});
gesture.enable();
document.body.dataset.ready = 'true';
</script>
</head>
<body>
<ion-app
><ion-content><ion-button id="press" mode="md">Hold or drag</ion-button></ion-content></ion-app
>
</body>
</html>
131 changes: 131 additions & 0 deletions core/src/utils/tap-click/test/tap-click.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading