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