fix(tap-click): preserve activatable ripple - #31380
Conversation
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
|
@tianrking is attempting to deploy a commit to the Ionic Team on Vercel. A member of the Team first needs to authorize it. |
ShaneK
left a comment
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
| expect(onGestureCaptured.mock.calls[0][0].detail).toEqual({ | ||
| gestureName: 'test', | ||
| gestureElement, | ||
| }); |
There was a problem hiding this comment.
| 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; |
There was a problem hiding this comment.
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!
Issue number: resolves #22491
What is the current behavior?
When a gesture is captured on an activatable control such as
ion-buttonorion-fab-button, theionGestureCapturedlistener 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?
ionGestureCapturedevent.Does this introduce a breaking change?
Other information
Local validation:
npm run test.spec -- --testNamePattern="tap click utility" --bail=1npm run test.spec -- --testNamePattern="includes the gesture element" --bail=1npm run lint.tsnpm run lint.sassnpm run buildnpm run test.lazy-importsThe full
npm run test.spec -- --ci --silentrun passed both new suites and 82 of 83 executed suites. The only failure was the unrelatedsrc/components/datetime/test/disabled/datetime.spec.tsxassertion (Expected: 4,Received: 3) in this Windows environment; this PR does not modify datetime code.