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
26 changes: 21 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,12 @@ 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!

}

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';

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,
});
Comment on lines +20 to +23

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.

Suggested change
expect(onGestureCaptured.mock.calls[0][0].detail).toEqual({
gestureName: 'test',
gestureElement,
});
const { detail } = onGestureCaptured.mock.calls[0][0];
expect(detail.gestureName).toBe('test');
expect(detail.gestureElement).toBe(gestureElement);

The test runner compares DOM nodes structurally, so two different but identical div elements pass toEqual here. This assertion would still pass if capture forwarded the wrong element, which is the one thing the test exists to prove.

} finally {
gesture.destroy();
document.removeEventListener('ionGestureCaptured', onGestureCaptured);
}
});
});
13 changes: 12 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 @@ -26,6 +27,16 @@ 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;

if (gestureElement && activatableEle?.contains(gestureElement)) {

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.

This one worries me a bit. The check is topological, so it can't tell a long press from a drag.

Put a pan gesture on an <ion-item button> and drag it with a mouse, and the ripple comes up and stays for the whole drag, where on main there's none at all. Touch escapes it because pointercancel fires once the compositor takes the pointer, but mouse input doesn't, and neither does a gesture setting passive: false or disableScroll.

It's also too narrow the other way. Only the activatable or something inside it matches, so <ion-button appLongPress> works while <div appLongPress><ion-button> still loses its ripple. A bidirectional version isn't the answer either, since that would keep the ripple alive through a reorder or item-sliding drag.

Cancelling once the gesture reports a non-zero deltaX/deltaY after capture would at least sort the drag half. I could be missing a simpler angle though.

return;
}

cancelActive();
};

const pointerDown = (ev: PointerEvent) => {
// Ignore right clicks
if (activatableEle || ev.button === 2) {
Expand Down Expand Up @@ -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);
Expand Down
120 changes: 120 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,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,

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.

With getBoolean returning false, useRippleEffect is false too, so addActivated bails before getRippleEffect and nothing here ever runs addRipple. Every assertion is on ion-activated, and the bug we're fixing is a ripple bug. There's no e2e net underneath either, since the tap-click e2e suite is still describe.skip under a TODO.

An e2e page with an md ion-button and a threshold: 0 gesture, held down, asserting on the ripple inside the shadow root, would pin this properly. It'd also give you somewhere to put the drag case from my other comment.

Small one while you're here: tests covering a tracked issue usually get a URL comment above the block, and neither new spec file has one.

} 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({

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.

The fake event object has no composedPath, so getActivatableTarget falls through to its closest() branch. Browsers always take the composedPath branch, so these tests exercise a path that never runs in production.

Dispatching for real works fine here and hits the branch that matters. Going that way would also let getListener, the four module-level lets and the beforeAll spy all go away. Your gesture-controller.spec.ts in this same PR already does it that way, which I think is the nicer of the two.

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;
};
Loading