-
Notifications
You must be signed in to change notification settings - Fork 13.3k
fix(tap-click): preserve activatable ripple #31380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The test runner compares DOM nodes structurally, so two different but identical |
||||||||||||||||
| } finally { | ||||||||||||||||
| gesture.destroy(); | ||||||||||||||||
| document.removeEventListener('ionGestureCaptured', onGestureCaptured); | ||||||||||||||||
| } | ||||||||||||||||
| }); | ||||||||||||||||
| }); | ||||||||||||||||
| 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) => { | ||
|
|
@@ -26,6 +27,16 @@ export const startTapClick = (config: Config) => { | |
| } | ||
| }; | ||
|
|
||
| const onGestureCaptured = (ev: Event) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would a short block comment here be worth it? The |
||
| const gestureElement = (ev as CustomEvent<GestureCapturedEventDetail>).detail?.gestureElement; | ||
|
|
||
| if (gestureElement && activatableEle?.contains(gestureElement)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 It's also too narrow the other way. Only the activatable or something inside it matches, so Cancelling once the gesture reports a non-zero |
||
| 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); | ||
|
|
||
| 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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With An e2e page with an md 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({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fake event object has no Dispatching for real works fine here and hits the branch that matters. Going that way would also let |
||
| 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; | ||
| }; | ||
There was a problem hiding this comment.
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
gestureprefix off the public one,gestureNametonameandgesturePrioritytopriority, and this new field goes the other way withelbecominggestureElement. Calling itelhere would keep that consistent, andgestureElementstill reads well on the detail below next togestureName.The
GestureCapturedEventDetailinterface below is the same kind of thing. It's correctly out of the public exports andionGestureCapturedisn't documented, but people do build on that event, there are a couple of workarounds using it in the issue thread. An@internalJSDoc would say so and keep the door open to changing the shape later without it counting as breaking. No action required on either!