Repository navigation
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'; | ||
|
|
||
| // 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); | ||
| } | ||
| }); | ||
| }); |
| 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) => { | ||
|
|
@@ -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; | ||
|
|
@@ -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) { | ||
|
|
@@ -26,15 +31,43 @@ 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; | ||
|
|
||
| // 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); | ||
| }; | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
| }); |
| 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> |
| 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; | ||
| }; |
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!