diff --git a/.changeset/nice-jars-shop.md b/.changeset/nice-jars-shop.md new file mode 100644 index 00000000000..232745e2f9d --- /dev/null +++ b/.changeset/nice-jars-shop.md @@ -0,0 +1,5 @@ +--- +'@spectrum-web-components/card': minor +--- + +**Fixed**: On mobile Chrome (both Android and iOS), scrolling on `sp-card` components would inadvertently trigger click events. This was caused by the timing-based click detection (200ms threshold) in the pointer event handling, which could misinterpret quick scrolls as clicks. This issue did not affect Safari on mobile devices. diff --git a/.circleci/config.yml b/.circleci/config.yml index f6995810a02..2f35c19dbb8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ parameters: # 3. Commit this change to the PR branch where the changes exist. current_golden_images_hash: type: string - default: c5b517432c05e1eeac576ad651233cef83957ce9 + default: c128578f6599fde71774dae5f1ebe047423aed95 wireit_cache_name: type: string default: wireit diff --git a/packages/card/src/Card.ts b/packages/card/src/Card.ts index b6b8d811744..4b95a854236 100644 --- a/packages/card/src/Card.ts +++ b/packages/card/src/Card.ts @@ -186,21 +186,46 @@ export class Card extends LikeAnchor( } } - private handlePointerdown(event: Event): void { + /** + * Handles pointer down events on the card element. + * Implements a click detection system that distinguishes between clicks and drags + * based on duration and movement distance. + */ + private handlePointerdown(event: PointerEvent): void { const path = event.composedPath(); const hasAnchor = path.some( (el) => (el as HTMLElement).localName === 'a' ); if (hasAnchor) return; - const start = +new Date(); - const handleEnd = (): void => { - const end = +new Date(); - if (end - start < 200) { + // Record the time and initial position of the pointerdown event + const startTime = event.timeStamp; + const startX = event.clientX; + const startY = event.clientY; + + // Define the handler for when the pointer interaction ends + const handleEnd = (endEvent: PointerEvent): void => { + const endTime = endEvent.timeStamp; + const endX = endEvent.clientX; + const endY = endEvent.clientY; + + // Calculate time duration and movement distance of the pointer + const timeDelta = endTime - startTime; + const moveX = Math.abs(endX - startX); + const moveY = Math.abs(endY - startY); + + // Consider the pointer interaction a "click" only if: + // - It was short (under 200ms) + // - It didn't move significantly (less than 10px in any direction) + const moved = moveX > 10 || moveY > 10; + + if (timeDelta < 200 && !moved) { this.click(); } + this.removeEventListener('pointerup', handleEnd); this.removeEventListener('pointercancel', handleEnd); }; + this.addEventListener('pointerup', handleEnd); this.addEventListener('pointercancel', handleEnd); } diff --git a/packages/card/stories/card.stories.ts b/packages/card/stories/card.stories.ts index 4e5251530df..23bf3f7d9cf 100644 --- a/packages/card/stories/card.stories.ts +++ b/packages/card/stories/card.stories.ts @@ -20,6 +20,7 @@ import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/menu/sp-menu-divider.js'; import '@spectrum-web-components/link/sp-link.js'; +import { ifDefined } from '@spectrum-web-components/base/src/directives.js'; export default { component: 'sp-card', @@ -342,7 +343,7 @@ export const smallQuiet = (args: StoryArgs): TemplateResult => { return html`
{ `; }; +export const ScrollTest = (): TemplateResult => { + return html` +
+
+

Switch to mobile view to test touch behavior.

+

+ In mobile view, verify that touch events work correctly and + scrolling doesn't trigger unwanted clicks. +

+
+ ${Array.from( + { length: 20 }, + () => html` +
+ + Demo Graphic +
Footer
+
+
+ ` + )} +
+ `; +}; diff --git a/packages/card/test/card.test.ts b/packages/card/test/card.test.ts index 83af9f45dda..508f1a155de 100644 --- a/packages/card/test/card.test.ts +++ b/packages/card/test/card.test.ts @@ -17,6 +17,7 @@ import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import '@spectrum-web-components/menu/sp-menu-divider.js'; import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { setViewport } from '@web/test-runner-commands'; import { Default, @@ -33,22 +34,7 @@ import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; describe('card', () => { testForLitDevWarnings( async () => - await fixture( - html` - - Slotted Preview -
Footer
-
- ` - ) - ); - it('loads', async () => { - const el = await fixture( - html` + await fixture(html` { />
Footer
- ` - ); + `) + ); + it('loads', async () => { + const el = await fixture(html` + + Slotted Preview +
Footer
+
+ `); await elementUpdated(el); await expect(el).to.be.accessible(); }); it('loads - [quiet]', async () => { - const el = await fixture( - html` - - Slotted Preview -
10/15/18
-
Footer
-
- ` - ); + const el = await fixture(html` + + Slotted Preview +
10/15/18
+
Footer
+
+ `); await elementUpdated(el); @@ -89,63 +80,55 @@ describe('card', () => { }); it('loads - [quiet][small]', async () => { - const el = await fixture( - html` - (html` + + Demo Graphic +
Footer
+ - Demo Graphic -
Footer
- - - Deselect - Select Inverse - Feather... - Select and Mask... - - Save Selection - Make Work Path - - -
- ` - ); + + Deselect + Select Inverse + Feather... + Select and Mask... + + Save Selection + Make Work Path + + +
+ `); await elementUpdated(el); await expect(el).to.be.accessible(); }); it('loads - [gallery]', async () => { - const el = await fixture( - html` - - Slotted Preview -
10/15/18
-
Footer
-
- ` - ); + const el = await fixture(html` + + Slotted Preview +
10/15/18
+
Footer
+
+ `); await elementUpdated(el); @@ -367,18 +350,16 @@ describe('card', () => { }); it('displays the `heading` attribute as `.title`', async () => { const testHeading = 'This is a test heading'; - const el = await fixture( - html` - - Slotted Preview -
Footer
-
- ` - ); + const el = await fixture(html` + + Slotted Preview +
Footer
+
+ `); await elementUpdated(el); @@ -393,19 +374,17 @@ describe('card', () => { }); it('displays the slotted content as `.title`', async () => { const testHeading = 'This is a test heading'; - const el = await fixture( - html` - -

${testHeading}

- Slotted Preview -
Footer
-
- ` - ); + const el = await fixture(html` + +

${testHeading}

+ Slotted Preview +
Footer
+
+ `); await elementUpdated(el); @@ -425,4 +404,112 @@ describe('card', () => { 'the slotted content renders in the element' ); }); + it('does not trigger click when scrolling on mobile', async () => { + // Set mobile viewport + await setViewport({ width: 375, height: 667 }); // iPhone 8 dimensions + + const clickSpy = spy(); + const el = await fixture(html` + + Slotted Preview +
Scroll test description
+
+ `); + // Prevent default navigation + el.addEventListener('click', (event: Event) => { + event.preventDefault(); + const composedTarget = event.composedPath()[0] as HTMLElement; + if (composedTarget.id !== 'like-anchor') return; + clickSpy(); + }); + await elementUpdated(el); + + const boundingRect = el.getBoundingClientRect(); + const startX = boundingRect.x + boundingRect.width / 2; + const startY = boundingRect.y + boundingRect.height / 2; + + // Simulate touch start with mobile-like coordinates + el.dispatchEvent( + new PointerEvent('pointerdown', { + clientX: startX, + clientY: startY, + pointerId: 1, + pointerType: 'touch', + pressure: 0.5, // Add pressure for touch simulation + bubbles: true, + composed: true, + cancelable: true, + }) + ); + await elementUpdated(el); + + // Simulate scroll movement with mobile-like velocity + el.dispatchEvent( + new PointerEvent('pointermove', { + clientX: startX, + clientY: startY + 50, // Move 50px down to simulate scroll + pointerId: 1, + pointerType: 'touch', + pressure: 0.5, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + await elementUpdated(el); + + // Simulate touch end + el.dispatchEvent( + new PointerEvent('pointerup', { + clientX: startX, + clientY: startY + 50, + pointerId: 1, + pointerType: 'touch', + pressure: 0, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + await elementUpdated(el); + + // Verify that no click was triggered during scroll + expect(clickSpy.called).to.be.false; + + // Now verify that a normal click works + el.dispatchEvent( + new PointerEvent('pointerdown', { + clientX: startX, + clientY: startY, + pointerId: 1, + pointerType: 'touch', + pressure: 0.5, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + await elementUpdated(el); + + el.dispatchEvent( + new PointerEvent('pointerup', { + clientX: startX, + clientY: startY, + pointerId: 1, + pointerType: 'touch', + pressure: 0, + bubbles: true, + composed: true, + cancelable: true, + }) + ); + await elementUpdated(el); + + expect(clickSpy.called).to.be.true; + expect(clickSpy.calledOnce).to.be.true; + }); });