Skip to content

fix(tap-click): preserve activatable ripple - #31380

Open
tianrking wants to merge 1 commit into
ionic-team:mainfrom
tianrking:fix/gesture-ripple-22491
Open

fix(tap-click): preserve activatable ripple#31380
tianrking wants to merge 1 commit into
ionic-team:mainfrom
tianrking:fix/gesture-ripple-22491

Conversation

@tianrking

Copy link
Copy Markdown

Issue number: resolves #22491


What is the current behavior?

When a gesture is captured on an activatable control such as ion-button or ion-fab-button, the ionGestureCaptured listener unconditionally clears tap-click's active state. This removes the ripple effect even though the gesture belongs to the same control.

What is the new behavior?

  • Include the gesture element in the existing internal ionGestureCaptured event.
  • Preserve tap-click's active state when that element is the current activatable control or one of its descendants.
  • Continue canceling the active state for unrelated or missing gesture elements, as well as the existing pointer cancel and pointer up paths.
  • Add focused regression tests for event propagation, matching and descendant elements, cancellation, and cleanup.

Does this introduce a breaking change?

  • Yes
  • No

Other information

Local validation:

  • npm run test.spec -- --testNamePattern="tap click utility" --bail=1
  • npm run test.spec -- --testNamePattern="includes the gesture element" --bail=1
  • npm run lint.ts
  • npm run lint.sass
  • npm run build
  • npm run test.lazy-imports
  • Targeted Prettier check for all five changed files

The full npm run test.spec -- --ci --silent run passed both new suites and 82 of 83 executed suites. The only failure was the unrelated src/components/datetime/test/disabled/datetime.spec.tsx assertion (Expected: 4, Received: 3) in this Windows environment; this PR does not modify datetime code.

Include the captured gesture element in the internal event so tap-click can keep the active state only for the same activatable control. Unrelated captures continue to cancel the state.

closes ionic-team#22491
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

@tianrking is attempting to deploy a commit to the Ionic Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the package: core @ionic/core package label Aug 20, 2026
@tianrking
tianrking marked this pull request as ready for review August 20, 2026 02:41
@tianrking
tianrking requested a review from a team as a code owner August 20, 2026 02:41
@tianrking
tianrking requested a review from OS-jacobbell August 20, 2026 02:41
@ShaneK ShaneK changed the title fix(gesture): preserve activatable ripple fix(tap-click): preserve activatable ripple Aug 20, 2026

@ShaneK ShaneK left a comment

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.

Thanks for picking this one up, and for laying out the plan on the issue first, that made it easy to follow what you were going for. I built it and confirmed the fix does what it says, a gesture on an ion-button keeps its ripple now where it doesn't on main.

I just left a few change requests before this goes in. The main one is that the containment check preserves the ripple for drags as well as presses, so a custom pan gesture on an ion-button or ion-item now leaves a ripple up for the whole drag where it used to be suppressed. The rest is test coverage and some small polish/nits.

Not in the diff, but the behavior change is all in tap-click rather than the gesture utility, so I changed the title of the PR to fix(tap-click): [...] to more accurately match the scope convention.

One more thing, and it's not a result of this PR or an issue you have to deal with in it unless you want to: ion-segment-button has the same problem from the other direction, its ripple gets cancelled by the segment's own gesture.

const onGestureCaptured = (ev: Event) => {
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.

}
};

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!

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.

};

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.

Comment on lines +20 to +23
expect(onGestureCaptured.mock.calls[0][0].detail).toEqual({
gestureName: 'test',
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.

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.

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: tap click cancels gesture when used on button

2 participants