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`
+
+ `;
+};
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`
-
-
- Footer
-
- `
- )
- );
- it('loads', async () => {
- const el = await fixture(
- html`
+ await fixture(html`
{
/>
Footer
- `
- );
+ `)
+ );
+ it('loads', async () => {
+ const el = await fixture(html`
+
+
+ Footer
+
+ `);
await elementUpdated(el);
await expect(el).to.be.accessible();
});
it('loads - [quiet]', async () => {
- const el = await fixture(
- html`
-
-
- 10/15/18
- Footer
-
- `
- );
+ const el = await fixture(html`
+
+
+ 10/15/18
+ Footer
+
+ `);
await elementUpdated(el);
@@ -89,63 +80,55 @@ describe('card', () => {
});
it('loads - [quiet][small]', async () => {
- const el = await fixture(
- html`
- (html`
+
+
+ Footer
+
-
- 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`
-
-
- 10/15/18
- Footer
-
- `
- );
+ const el = await fixture(html`
+
+
+ 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`
-
-
- Footer
-
- `
- );
+ const el = await fixture(html`
+
+
+ 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}
-
- Footer
-
- `
- );
+ const el = await fixture(html`
+
+ ${testHeading}
+
+ 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`
+
+
+ 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;
+ });
});