diff --git a/packages/action-button/stories/action-button.stories.ts b/packages/action-button/stories/action-button.stories.ts
index 477ca716caf..8a2ae59b3c8 100644
--- a/packages/action-button/stories/action-button.stories.ts
+++ b/packages/action-button/stories/action-button.stories.ts
@@ -57,3 +57,14 @@ export const hrefWithTarget = (): TemplateResult => html`
Click me
`;
+
+export const singleClick = (): TemplateResult => html`
+
+ console.log(`click handler, event is trusted: ${event.isTrusted}`)}
+ href="https://partners.adobe.com/channelpartnerassets/assets/public/public_1/aem_assets_dynamic_media_capability_spotlight_ue.pdf"
+ download="Adobe Experience Manager Assets Dynamic Media Capability Spotlight.pdf"
+ >
+ Icon Download
+
+`;
diff --git a/packages/action-button/test/action-button.test.ts b/packages/action-button/test/action-button.test.ts
index fafaac529c1..b2fc716e470 100644
--- a/packages/action-button/test/action-button.test.ts
+++ b/packages/action-button/test/action-button.test.ts
@@ -313,4 +313,73 @@ describe('ActionButton', () => {
await elementUpdated(el);
expect(clicked).to.be.true;
});
+ it('dispatches only one click event per user interaction', async () => {
+ let clickCount = 0;
+ const el = await fixture(html`
+
+ Download
+
+ `);
+
+ await elementUpdated(el);
+
+ // Track all click events on the action button
+ el.addEventListener('click', () => {
+ clickCount++;
+ });
+
+ // Prevent the anchor from actually navigating
+ el.shadowRoot
+ ?.querySelector('.anchor')
+ ?.addEventListener('click', (event: Event) => {
+ event.preventDefault();
+ });
+
+ // Simulate a user click
+ await mouseClickOn(el);
+ await elementUpdated(el);
+
+ // Should only have one click event, not two
+ expect(clickCount).to.equal(1);
+ });
+ it('allows keyboard activation with href', async () => {
+ let clickCount = 0;
+ const el = await fixture(html`
+
+ Download
+
+ `);
+
+ await elementUpdated(el);
+
+ // Track all click events on the action button
+ el.addEventListener('click', () => {
+ clickCount++;
+ });
+
+ // Prevent the anchor from actually navigating
+ el.shadowRoot
+ ?.querySelector('.anchor')
+ ?.addEventListener('click', (event: Event) => {
+ event.preventDefault();
+ });
+
+ // Test Enter key
+ el.focus();
+ await sendKeys({ press: 'Enter' });
+ await elementUpdated(el);
+ expect(clickCount).to.equal(1);
+
+ // Test Space key
+ clickCount = 0;
+ await sendKeys({ press: 'Space' });
+ await elementUpdated(el);
+ expect(clickCount).to.equal(1);
+ });
});
diff --git a/packages/button/src/ButtonBase.ts b/packages/button/src/ButtonBase.ts
index 9fbae9d6a79..43ea726ddd2 100644
--- a/packages/button/src/ButtonBase.ts
+++ b/packages/button/src/ButtonBase.ts
@@ -93,7 +93,23 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [
return false;
}
- if (this.shouldProxyClick(event as MouseEvent)) {
+ // If this is a synthetic click (isTrusted: false) bubbling up from our
+ // anchor element proxy, stop it to prevent duplicate click events.
+ // However, allow synthetic clicks that originate from the button itself
+ // (e.g., from keyboard interactions or programmatic clicks).
+ // We check composedPath() because event.target gets retargeted across
+ // shadow boundaries.
+ const mouseEvent = event as MouseEvent;
+ if (
+ !mouseEvent.isTrusted &&
+ this.anchorElement &&
+ event.composedPath()[0] === this.anchorElement
+ ) {
+ event.stopPropagation();
+ return false;
+ }
+
+ if (this.shouldProxyClick(mouseEvent)) {
return;
}
}
diff --git a/packages/button/test/button.test.ts b/packages/button/test/button.test.ts
index c923084f622..ebe830ee04e 100644
--- a/packages/button/test/button.test.ts
+++ b/packages/button/test/button.test.ts
@@ -245,6 +245,75 @@ describe('Button', () => {
await elementUpdated(el);
expect(clicked).to.be.true;
});
+ it('dispatches only one click event per user interaction', async () => {
+ let clickCount = 0;
+ const el = await fixture